source: Klonkt/src/views/pages/post-edit.ejs@ e22230e

main
Last change on this file since e22230e was e22230e, checked in by roboburr <roboburr@…>, 3 months ago

fix(editor): remove 350ms scroll guard + always type fullscreen on touch

  • keepScroll: removed the 350ms scroll trap (itself caused a visible jump); now only sync + 1 frame restore, and nothing at all in fullscreen (page is locked there → field may scroll to caret).
  • Auto-fullscreen on touch now also on beforeinput + pointerup (not just focus), so you can't accidentally type without fullscreen after leaving it.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 68.2 KB
Line 
1<%
2// Post editor — split into card sections for visual hierarchy:
3// - Identity : title, slug, excerpt
4// - Cover : thumbnail + URL + custom upload button
5// - Content : markdown textarea with attached toolbar
6// - Meta : status, type, pin, noindex, tags
7//
8// All behavior (IDs, names, upload endpoints) is preserved — only layout
9// and styling changed.
10const _hasCover = !!post.cover_image_url;
11// In hub-modus moeten save/create/cancel de /user/<slug>/-prefix dragen, anders
12// valt de request terug op de primaire site (siteUrlBase leeg) en rendert de
13// post na opslaan headerless (bareChrome). In solo is _base leeg.
14const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
15%>
16<div class="container post-edit-page">
17 <h1><%= isNew ? 'Nieuwe post' : 'Post bewerken' %></h1>
18
19 <form method="post" action="<%= _base %><%= isNew ? '/posts/create' : '/posts/' + post.slug + '/save' %>" class="post-edit-form">
20
21 <%# ── IDENTITY ─────────────────────────────────────────────── %>
22 <section class="pe-card">
23 <label class="pe-field">
24 <span>Titel</span>
25 <input type="text" name="title" value="<%= post.title %>" required>
26 </label>
27 <div class="pe-row pe-row-2">
28 <label class="pe-field">
29 <span>Slug (URL)</span>
30 <input type="text" name="slug" value="<%= post.slug %>" placeholder="auto van titel als leeg">
31 </label>
32 <label class="pe-field">
33 <span>Tags <small>(komma-gescheiden)</small></span>
34 <input type="text" name="tags" value="<%= Array.isArray(post.tags) ? post.tags.join(', ') : '' %>">
35 </label>
36 </div>
37 <label class="pe-field">
38 <span>Excerpt <small>(preview tekst)</small></span>
39 <textarea name="excerpt" rows="2"><%= post.excerpt %></textarea>
40 <small class="pe-hint" style="opacity:.7">Wordt ook gebruikt als samenvatting in <strong>Cirkels</strong> (andere sites die je post tonen). Leeg laten = begin van de post.</small>
41 </label>
42 </section>
43
44 <%# ── COVER ────────────────────────────────────────────────── %>
45 <section class="pe-card">
46 <div class="pe-section-title">Cover</div>
47 <div class="pe-cover-row">
48 <div class="pe-cover-thumb" id="cover-preview-wrap" <%= _hasCover ? '' : 'data-empty' %>>
49 <img src="<%= _hasCover ? post.cover_image_url : '' %>" alt="" id="cover-preview-img" <%= _hasCover ? '' : 'hidden' %>>
50 <% if (!_hasCover) { %><span class="pe-cover-empty">🖼</span><% } %>
51 </div>
52 <div class="pe-cover-actions">
53 <label class="pe-field">
54 <span>Cover URL</span>
55 <input type="text" name="cover_image_url" id="cover-url-field"
56 value="<%= post.cover_image_url || '' %>"
57 inputmode="url" autocapitalize="none" spellcheck="false"
58 placeholder="/media/… of https://… (of upload met de knop)">
59 </label>
60 <div class="pe-cover-upload">
61 <button type="button" class="pe-btn pe-btn-secondary" id="cover-upload-trigger">
62 📷 Upload nieuwe cover
63 </button>
64 <input type="file" id="cover-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
65 <small id="cover-upload-status" class="pe-status"></small>
66 </div>
67 </div>
68 </div>
69 </section>
70
71 <%# ── CONTENT ──────────────────────────────────────────────── %>
72 <section class="pe-card pe-card-content">
73 <div class="pe-section-title">
74 Content
75 <small>Sleep een afbeelding om in te voegen · selecteer tekst om op te maken</small>
76 </div>
77 <div class="pe-editor-frame">
78 <div class="pe-toolbar" id="pe-toolbar" role="toolbar" aria-label="Format">
79 <button type="button" data-cmd="bold" title="Vet (Ctrl+B)" aria-label="Vet">
80 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"/><path d="M6 12h9a4 4 0 0 1 0 8H6z"/></svg>
81 </button>
82 <button type="button" data-cmd="italic" title="Cursief (Ctrl+I)" aria-label="Cursief">
83 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
84 </button>
85 <button type="button" data-cmd="underline" title="Onderstreept" aria-label="Onderstreept">
86 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" y1="20" x2="20" y2="20"/></svg>
87 </button>
88 <span class="pe-toolbar-sep" aria-hidden="true"></span>
89 <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h2" title="Kop">H2</button>
90 <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h3" title="Subkop">H3</button>
91 <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="p" title="Paragraaf">¶</button>
92 <span class="pe-toolbar-sep" aria-hidden="true"></span>
93 <button type="button" data-cmd="insertUnorderedList" title="Lijst" aria-label="Lijst">
94 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1.2"/><circle cx="4.5" cy="12" r="1.2"/><circle cx="4.5" cy="18" r="1.2"/></svg>
95 </button>
96 <button type="button" data-cmd="insertOrderedList" title="Genummerde lijst" aria-label="Genummerde lijst">
97 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>
98 </button>
99 <button type="button" data-cmd="formatBlock" data-arg="blockquote" title="Quote" aria-label="Quote">
100 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h2.5C6 17.5 4 19 3 19v2z"/><path d="M14 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h2.5c-.5 4.5-2.5 6-3.5 6v2z"/></svg>
101 </button>
102 <button type="button" data-cmd="link-prompt" title="Link (Ctrl+K)" aria-label="Link">
103 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
104 </button>
105 <button type="button" data-cmd="code-wrap" title="Code (inline)" aria-label="Code">
106 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
107 </button>
108 <span class="pe-toolbar-sep" aria-hidden="true"></span>
109 <button type="button" id="insert-image-btn" title="Afbeelding invoegen" aria-label="Afbeelding">
110 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
111 </button>
112 <button type="button" id="insert-track-btn" title="Track invoegen" aria-label="Track">
113 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
114 </button>
115 <button type="button" id="insert-playlist-btn" title="Playlist invoegen" aria-label="Playlist">
116 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>
117 </button>
118 <button type="button" id="insert-embed-btn" title="Embed (YouTube, Spotify, SoundCloud, Vimeo…)" aria-label="Media embedden">
119 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>
120 </button>
121 <span class="pe-toolbar-spacer"></span>
122 <button type="button" data-cmd="removeFormat" title="Wis opmaak" aria-label="Wis opmaak">
123 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M5 5l14 14" stroke-linecap="round"/></svg>
124 </button>
125 <button type="button" id="pe-fullscreen-btn" title="Volledig scherm" aria-label="Volledig scherm" aria-pressed="false">
126 <svg class="fs-icon-expand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
127 <svg class="fs-icon-compress" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
128 </button>
129 </div>
130
131 <div id="content-editor"
132 class="pe-editor"
133 contenteditable="true"
134 role="textbox"
135 aria-multiline="true"
136 aria-label="Content"
137 data-placeholder="Begin met schrijven…"></div>
138
139 <%# Hidden field is what actually submits to the server. The visible
140 contenteditable's serialized HTML is copied here on submit. %>
141 <input type="hidden" name="content" id="content-hidden" value="">
142
143 <%# Initial content is injected as a JSON string in a script tag so we
144 can set innerHTML safely from JS. EJS-escaping HTML directly into
145 the editor would render entity-escaped tags as text. %>
146 <script id="initial-content" type="application/json"><%- JSON.stringify(post.content || '').replace(/</g, '\\u003c') %></script>
147
148 <div class="pe-editor-status">
149 <small id="content-upload-status" class="pe-status"></small>
150 <span class="pe-char-count"><span id="char-count">0</span> tekens</span>
151 </div>
152
153 <input type="file" id="content-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
154 </div>
155 </section>
156
157 <%# ── META ─────────────────────────────────────────────────── %>
158 <section class="pe-card">
159 <div class="pe-section-title">Publicatie</div>
160 <div class="pe-row pe-row-2">
161 <label class="pe-field">
162 <span>Status</span>
163 <% var _st = isNew ? 'published' : (post.status || 'draft'); %>
164 <select name="status">
165 <option value="published" <%= _st === 'published' ? 'selected' : '' %>>Gepubliceerd</option>
166 <option value="draft" <%= _st === 'draft' ? 'selected' : '' %>>Concept</option>
167 <option value="archived" <%= _st === 'archived' ? 'selected' : '' %>>Gearchiveerd</option>
168 </select>
169 </label>
170 <label class="pe-field">
171 <span>Type</span>
172 <select name="type">
173 <% var ptype = post.type || 'post'; %>
174 <option value="post" <%= ptype === 'post' ? 'selected' : '' %>>Post</option>
175 <option value="foto" <%= ptype === 'foto' ? 'selected' : '' %>>Foto</option>
176 <option value="video" <%= ptype === 'video' ? 'selected' : '' %>>Video</option>
177 <option value="audio" <%= ptype === 'audio' ? 'selected' : '' %>>Audio</option>
178 </select>
179 </label>
180 </div>
181 <div class="pe-checkboxes">
182 <div class="pe-pin">
183 <label class="pe-checkbox">
184 <input type="checkbox" id="pin-toggle" <%= (Number(post.pinned) > 0) ? 'checked' : '' %>>
185 <span>📌 Vastpinnen bovenaan</span>
186 </label>
187 <input type="hidden" name="pinned" id="pin-rank" value="<%= post.pinned || 0 %>">
188 <div class="pe-pin-pos" id="pin-pos" <%= (Number(post.pinned) > 0) ? '' : 'hidden' %>>
189 <span class="pe-pin-steps">
190 <button type="button" class="pe-pin-btn" id="pin-up" aria-label="Hoger zetten">▲</button>
191 <button type="button" class="pe-pin-btn" id="pin-down" aria-label="Lager zetten">▼</button>
192 </span>
193 <span class="pe-pin-label" id="pin-label"></span>
194 </div>
195 </div>
196 <label class="pe-checkbox">
197 <input type="checkbox" name="noindex" value="1" <%= post.noindex ? 'checked' : '' %>>
198 <span>🚫 noindex (verberg voor zoekmachines)</span>
199 </label>
200 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
201 <label class="pe-checkbox">
202 <input type="checkbox" name="fan_only" value="1" <%= post.fan_only ? 'checked' : '' %>>
203 <span>🔒 Alleen voor ingelogde fans</span>
204 </label>
205 <div style="margin-top:8px">
206 <label style="display:block;font-size:12.5px;opacity:.8;margin-bottom:4px">📅 Publiceren op (laat leeg = direct)</label>
207 <input type="datetime-local" name="publish_at" value="<%= post.publish_at ? String(post.publish_at).replace(' ','T').slice(0,16) : '' %>" style="padding:8px 10px;border-radius:8px;border:1px solid var(--rule,rgba(128,128,128,.4));background:transparent;color:inherit">
208 <% if (post.status === 'scheduled' && post.publish_at) { %><div style="font-size:12px;opacity:.7;margin-top:4px">⏳ Ingepland voor <%= post.publish_at %></div><% } %>
209 </div>
210 <% } %>
211 </div>
212 </section>
213
214 <%# ── ACTIONS (sticky at bottom) ──────────────────────────── %>
215 <div class="pe-actions">
216 <a href="<%= _base %><%= isNew ? '/' : '/' + post.slug %>" class="pe-btn">Annuleren</a>
217 <div class="pe-actions-spacer"></div>
218 <% if (!isNew && post.status !== 'published') { %>
219 <button type="submit" name="action" value="publish" class="pe-btn pe-btn-success">📤 Publish</button>
220 <% } %>
221 <button type="submit" class="pe-btn pe-btn-primary">💾 Opslaan</button>
222 </div>
223 </form>
224</div>
225
226<style>
227/* ─── Page wrapper ──────────────────────────────────────────────── */
228.post-edit-page {
229 max-width: 880px;
230 margin: 1.5rem auto 6rem; /* extra bottom for sticky-actions clearance */
231 padding: 0 1rem;
232}
233.post-edit-page h1 {
234 font-family: var(--font-display, serif);
235 margin: 0 0 1.25rem;
236 font-size: 1.75rem;
237}
238
239.post-edit-form {
240 display: flex; flex-direction: column;
241 gap: 1rem;
242}
243
244/* ─── Card sections ─────────────────────────────────────────────── */
245.pe-card {
246 background: var(--paper);
247 border: 1px solid var(--rule);
248 border-radius: 12px;
249 padding: 1.25rem;
250 display: flex; flex-direction: column;
251 gap: 0.85rem;
252}
253.pe-card-content { padding-bottom: 0; overflow: hidden; } /* editor flush to bottom */
254.pe-section-title {
255 font-size: 0.8rem;
256 font-weight: 600;
257 text-transform: uppercase;
258 letter-spacing: 0.05em;
259 color: var(--ink-soft);
260 display: flex; align-items: baseline; gap: 0.5rem;
261 flex-wrap: wrap;
262}
263.pe-section-title small {
264 font-size: 0.75rem; font-weight: 400;
265 letter-spacing: 0; text-transform: none;
266 color: var(--ink-muted, var(--ink-soft));
267}
268
269/* ─── Field primitives ──────────────────────────────────────────── */
270.pe-field {
271 display: flex; flex-direction: column;
272 gap: 0.35rem;
273 min-width: 0; /* allow grid items to shrink */
274}
275.pe-field > span {
276 font-size: 0.8rem;
277 font-weight: 600;
278 color: var(--ink-soft);
279 display: flex; align-items: baseline; gap: 0.4rem;
280}
281.pe-field > span small {
282 font-weight: 400; color: var(--ink-muted);
283}
284.pe-field input,
285.pe-field textarea,
286.pe-field select {
287 width: 100%;
288 box-sizing: border-box;
289 display: block;
290 padding: 0.6rem 0.75rem;
291 border: 1px solid var(--rule);
292 border-radius: 6px;
293 background: var(--paper-2);
294 color: var(--ink);
295 font-family: var(--font-ui, system-ui), sans-serif;
296 font-size: 0.95rem;
297 -webkit-appearance: none;
298 appearance: none;
299 transition: border-color 120ms;
300}
301.pe-field input:focus,
302.pe-field textarea:focus,
303.pe-field select:focus {
304 outline: 2px solid var(--accent);
305 outline-offset: -1px;
306 border-color: var(--accent);
307}
308.pe-field textarea {
309 font-family: var(--font-mono, monospace);
310 resize: vertical;
311}
312.pe-field select {
313 /* Restore the dropdown arrow we removed with appearance:none */
314 background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
315 background-repeat: no-repeat;
316 background-position: right 0.75rem center;
317 padding-right: 2rem;
318}
319
320.pe-row {
321 display: grid;
322 gap: 0.75rem;
323}
324.pe-row-2 { grid-template-columns: 1fr 1fr; }
325@media (max-width: 600px) {
326 .pe-row-2 { grid-template-columns: 1fr; }
327}
328
329/* ─── Cover field ───────────────────────────────────────────────── */
330.pe-cover-row {
331 display: grid;
332 grid-template-columns: 120px 1fr;
333 gap: 1rem;
334 align-items: start;
335}
336.pe-cover-thumb {
337 width: 120px; height: 120px;
338 border-radius: 10px;
339 overflow: hidden;
340 background: var(--paper-2);
341 border: 1px solid var(--rule);
342 display: flex; align-items: center; justify-content: center;
343 position: relative;
344}
345.pe-cover-thumb[data-empty] {
346 border-style: dashed;
347}
348.pe-cover-thumb img {
349 width: 100%; height: 100%;
350 object-fit: cover; display: block;
351}
352/* Honor HTML hidden — our display:block above otherwise renders an empty
353 broken-image icon when no cover is set. */
354.pe-cover-thumb img[hidden] { display: none; }
355.pe-cover-empty {
356 font-size: 2rem;
357 color: var(--ink-muted, var(--ink-soft));
358 opacity: 0.5;
359}
360.pe-cover-actions {
361 display: flex; flex-direction: column;
362 gap: 0.6rem;
363 min-width: 0;
364}
365.pe-cover-upload {
366 display: flex; align-items: center; gap: 0.6rem;
367 flex-wrap: wrap;
368}
369@media (max-width: 600px) {
370 .pe-cover-row { grid-template-columns: 88px 1fr; }
371 .pe-cover-thumb { width: 88px; height: 88px; }
372}
373
374/* ─── Editor frame (WYSIWYG: top toolbar, contenteditable body, status bar) ─── */
375.pe-editor-frame {
376 margin: 0 -1.25rem -1.25rem; /* break out of card padding for flush edges */
377 border-top: 1px solid var(--rule);
378 background: var(--paper);
379 display: flex; flex-direction: column;
380}
381
382/* Top toolbar — sticks to the top of the viewport while editing */
383.pe-toolbar {
384 display: flex; align-items: center; gap: .15rem;
385 flex-wrap: wrap;
386 padding: .4rem .75rem;
387 background: color-mix(in srgb, var(--paper) 92%, transparent);
388 backdrop-filter: blur(10px);
389 -webkit-backdrop-filter: blur(10px);
390 border-bottom: 1px solid var(--rule);
391 position: sticky;
392 top: 0;
393 z-index: 10;
394}
395.pe-toolbar button {
396 display: inline-flex; align-items: center; justify-content: center;
397 width: 32px; height: 32px;
398 padding: 0;
399 touch-action: manipulation; /* snappy taps op mobiel, geen dubbeltik-zoom */
400 -webkit-user-select: none; user-select: none;
401 background: transparent;
402 border: 1px solid transparent;
403 border-radius: 6px;
404 color: var(--ink);
405 cursor: pointer;
406 font-family: var(--font-ui, system-ui), sans-serif;
407 font-weight: 600; font-size: .85rem;
408 transition: background var(--transition), color var(--transition), border-color var(--transition);
409 -webkit-tap-highlight-color: transparent;
410}
411/* Hover alleen op echte hover-apparaten — op touch blijft :hover anders "plakken"
412 na een tik, waardoor de actief/inactief-staat onleesbaar wordt. */
413@media (hover: hover) {
414 .pe-toolbar button:hover {
415 background: var(--paper-2);
416 color: var(--accent);
417 }
418}
419.pe-toolbar button:active { transform: scale(.94); }
420/* Actieve opmaak = onmiskenbaar gevuld met de accentkleur. Zo is op mobiel
421 meteen duidelijk dat bv. vet AAN staat (tik nogmaals = uit). */
422.pe-toolbar button.is-active {
423 background: var(--accent);
424 color: #fff;
425 border-color: var(--accent);
426}
427.pe-toolbar button.is-active svg { color: #fff; }
428.pe-toolbar button svg { width: 16px; height: 16px; }
429.pe-toolbar button.pe-tb-text { font-family: var(--font-display, serif); font-weight: 700; }
430.pe-toolbar-sep {
431 width: 1px; height: 18px;
432 background: var(--rule);
433 margin: 0 .35rem;
434 flex-shrink: 0;
435}
436.pe-toolbar-spacer { flex: 1; }
437
438/* ─── Volledig-scherm schrijfmodus ─── */
439.fs-icon-compress { display: none; }
440.pe-editor-frame.pe-fs .fs-icon-expand { display: none; }
441.pe-editor-frame.pe-fs .fs-icon-compress { display: inline; }
442.pe-editor-frame.pe-fs {
443 position: fixed; inset: 0; z-index: 1000;
444 margin: 0; border-top: 0;
445 height: 100dvh;
446 background: var(--paper);
447}
448/* In fullscreen vult het schrijfveld de resterende ruimte en scrollt het zelf
449 (max-height: none overschrijft de mobiele box-begrenzing hieronder). */
450.pe-editor-frame.pe-fs .pe-editor {
451 flex: 1 1 auto; min-height: 0; height: auto; max-height: none;
452}
453body.pe-fs-open { overflow: hidden; }
454
455/* Editor body — looks like prose, behaves like a textarea */
456.pe-editor {
457 width: 100%;
458 box-sizing: border-box;
459 min-height: 420px;
460 padding: 1.25rem 1.5rem;
461 border: 0;
462 background: transparent;
463 color: var(--ink);
464 font-family: var(--font-body, system-ui), serif;
465 font-size: 1.0625rem;
466 line-height: 1.65;
467 outline: none;
468 overflow-y: auto;
469}
470.pe-editor:focus { outline: none; }
471/* Inline (niet-fullscreen) groeit het schrijfveld gewoon mee met de inhoud — geen
472 interne scroll-box (scroll-binnen-scroll is verwarrend). Op mobiel/tablet gaat
473 typen sowieso fullscreen (zie JS), waar het veld de pagina vult en als enige
474 scrollt. */
475.pe-editor-frame:not(.pe-fs) .pe-editor { overflow: visible; }
476.pe-editor.is-dragover {
477 outline: 2px dashed var(--accent);
478 outline-offset: -10px;
479}
480.pe-editor[data-placeholder]:empty::before,
481.pe-editor[data-placeholder]:has(br:only-child)::before {
482 content: attr(data-placeholder);
483 color: var(--ink-muted, #999);
484 pointer-events: none;
485 opacity: .55;
486}
487/* Inline prose styles inside the editor — match the public post styling so
488 what you see is roughly what you'll get. Margins are tighter than on the
489 live site since this is a confined writing space. */
490.pe-editor h1, .pe-editor h2, .pe-editor h3, .pe-editor h4 {
491 font-family: var(--font-display, serif);
492 line-height: 1.2;
493 margin: 1.1rem 0 .35rem;
494}
495.pe-editor h1 { font-size: 1.85rem; }
496.pe-editor h2 { font-size: 1.45rem; }
497.pe-editor h3 { font-size: 1.2rem; }
498.pe-editor p { margin: 0 0 .85em; }
499.pe-editor ul, .pe-editor ol { margin: 0 0 .85em 1.4em; padding: 0; }
500.pe-editor li { margin: .15em 0; }
501.pe-editor blockquote {
502 margin: 1em 0;
503 padding: .15em 0 .15em 1em;
504 border-left: 3px solid var(--accent);
505 color: var(--ink-soft, var(--ink));
506 font-style: italic;
507}
508.pe-editor code {
509 background: var(--paper-2);
510 border: 1px solid var(--rule);
511 border-radius: 4px;
512 padding: .1em .35em;
513 font-family: var(--font-mono, ui-monospace, monospace);
514 font-size: .92em;
515}
516.pe-editor a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
517.pe-editor img {
518 max-width: 100%; height: auto;
519 border-radius: 6px;
520 display: block;
521 margin: 1em auto;
522}
523
524/* Shortcode chips — visible inline placeholder in the editor for
525 [[track:UUID]] / [[album:Name]] / [[playlist:slug]]. They serialize back
526 to plain shortcode text on submit. */
527.sc-chip {
528 display: inline-flex; align-items: center; gap: .3rem;
529 padding: .15em .5em;
530 margin: 0 .15em;
531 background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
532 border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
533 border-radius: 999px;
534 color: var(--accent);
535 font-family: var(--font-ui, system-ui), sans-serif;
536 font-size: .85em;
537 font-weight: 600;
538 font-style: normal;
539 white-space: nowrap;
540 user-select: none;
541 cursor: default;
542 vertical-align: baseline;
543}
544.sc-chip-icon { display: inline-flex; opacity: .8; }
545.sc-chip-icon svg { width: 12px; height: 12px; }
546
547/* Status bar below the editor */
548.pe-editor-status {
549 display: flex; align-items: center; justify-content: space-between;
550 gap: .75rem;
551 padding: .45rem 1rem;
552 border-top: 1px solid var(--rule);
553 background: var(--paper-2);
554 color: var(--ink-muted, var(--ink-soft));
555 font-size: .78rem;
556}
557.pe-char-count { font-variant-numeric: tabular-nums; }
558
559/* ─── Track picker modal (P59) ─────────────────────────────────
560 Mobile-first: full-screen bottom-sheet on phones, centered card
561 on desktop. Lock body scroll while open via .tp-locked on body. */
562.tp-modal {
563 position: fixed; inset: 0;
564 z-index: 1000;
565 display: flex; align-items: stretch; justify-content: center;
566 /* Avoid the iOS home-indicator + the keyboard when search is focused */
567 padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
568}
569.tp-modal[hidden] { display: none; }
570.tp-backdrop {
571 position: absolute; inset: 0;
572 background: color-mix(in srgb, var(--ink) 55%, transparent);
573 backdrop-filter: blur(4px);
574 -webkit-backdrop-filter: blur(4px);
575 animation: tp-bd-in .18s ease-out;
576}
577@keyframes tp-bd-in { from { opacity: 0 } to { opacity: 1 } }
578
579/* Mobile: sheet docks to the bottom and fills viewport almost completely */
580.tp-sheet {
581 position: relative;
582 margin-top: auto;
583 width: 100%;
584 max-height: calc(100dvh - env(safe-area-inset-top) - 1rem);
585 background: var(--paper);
586 border-top: 1px solid var(--rule);
587 border-radius: 16px 16px 0 0;
588 box-shadow:
589 0 -2px 8px color-mix(in srgb, var(--ink) 8%, transparent),
590 0 -16px 48px color-mix(in srgb, var(--ink) 18%, transparent);
591 display: flex; flex-direction: column;
592 animation: tp-sheet-up .22s cubic-bezier(.2, .8, .25, 1);
593 overflow: hidden;
594}
595@keyframes tp-sheet-up {
596 from { transform: translateY(100%); opacity: .8; }
597 to { transform: translateY(0); opacity: 1; }
598}
599
600/* Header — title + close, with a small grab-handle bar on mobile */
601.tp-header {
602 display: flex; align-items: center; justify-content: space-between;
603 gap: .75rem;
604 padding: .85rem 1rem .65rem;
605 border-bottom: 1px solid var(--rule);
606 position: relative;
607}
608.tp-header::before {
609 /* Grab-handle: visible on mobile only */
610 content: '';
611 position: absolute;
612 top: .35rem; left: 50%;
613 transform: translateX(-50%);
614 width: 38px; height: 4px;
615 background: var(--rule-2, var(--rule));
616 border-radius: 2px;
617}
618.tp-h2 {
619 margin: .35rem 0 0;
620 font-family: var(--font-display, serif);
621 font-size: 1.15rem; font-weight: 600;
622 color: var(--ink);
623 line-height: 1.1;
624}
625.tp-close {
626 width: 32px; height: 32px; border-radius: 8px;
627 display: inline-flex; align-items: center; justify-content: center;
628 background: transparent; border: 1px solid transparent;
629 color: var(--ink-muted, var(--ink-soft));
630 cursor: pointer;
631 transition: background var(--transition), color var(--transition), border-color var(--transition);
632 -webkit-tap-highlight-color: transparent;
633 margin-top: .35rem;
634}
635.tp-close:hover, .tp-close:focus-visible {
636 background: var(--paper-2);
637 color: var(--ink);
638 outline: none;
639}
640.tp-close svg { width: 18px; height: 18px; }
641
642/* Search row — sticky just below header so list scrolls underneath */
643.tp-search-row {
644 position: relative;
645 padding: .65rem 1rem;
646 border-bottom: 1px solid var(--rule);
647 background: var(--paper);
648}
649.tp-search-icon {
650 position: absolute;
651 top: 50%; left: 1.65rem;
652 transform: translateY(-50%);
653 color: var(--ink-muted, var(--ink-soft));
654 pointer-events: none;
655 display: inline-flex;
656}
657.tp-search-icon svg { width: 16px; height: 16px; }
658.tp-search {
659 width: 100%; box-sizing: border-box;
660 padding: .65rem .85rem .65rem 2.4rem;
661 font-family: var(--font-ui, system-ui), sans-serif;
662 font-size: 1rem;
663 background: var(--paper-2);
664 color: var(--ink);
665 border: 1px solid var(--rule);
666 border-radius: 9px;
667 -webkit-appearance: none;
668 appearance: none;
669}
670.tp-search:focus {
671 outline: none;
672 border-color: var(--accent);
673 box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
674}
675
676/* List — vertically scrollable, takes remaining sheet height */
677.tp-list {
678 flex: 1 1 auto;
679 overflow-y: auto;
680 -webkit-overflow-scrolling: touch;
681 padding: .35rem .35rem 1rem;
682}
683.tp-empty {
684 padding: 1.5rem 1rem;
685 text-align: center;
686 color: var(--ink-muted, var(--ink-soft));
687 font-size: .9rem;
688}
689
690/* Track row — tap target ≥ 56px for mobile */
691.tp-row {
692 display: grid;
693 grid-template-columns: 44px 1fr auto;
694 align-items: center;
695 gap: .75rem;
696 width: 100%;
697 padding: .55rem .65rem;
698 border: 0; background: transparent;
699 border-radius: 10px;
700 text-align: left;
701 cursor: pointer;
702 color: inherit;
703 font: inherit;
704 transition: background var(--transition);
705 -webkit-tap-highlight-color: transparent;
706}
707.tp-row:hover, .tp-row:focus-visible {
708 background: color-mix(in srgb, var(--accent) 8%, var(--paper-2));
709 outline: none;
710}
711.tp-row:active { transform: scale(.98); }
712.tp-row[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
713
714.tp-cover {
715 width: 44px; height: 44px;
716 border-radius: 6px;
717 background-size: cover; background-position: center;
718 background-color: var(--rule);
719 display: inline-flex; align-items: center; justify-content: center;
720 flex-shrink: 0;
721}
722.tp-cover-empty {
723 background: linear-gradient(135deg,
724 color-mix(in srgb, var(--accent) 30%, var(--paper-2)),
725 color-mix(in srgb, var(--accent) 8%, var(--paper-2)));
726 color: var(--accent);
727}
728.tp-cover-empty svg { width: 18px; height: 18px; opacity: .8; }
729
730.tp-meta {
731 display: flex; flex-direction: column;
732 min-width: 0;
733 line-height: 1.25;
734}
735.tp-row-title {
736 font-weight: 600; font-size: .94rem;
737 color: var(--ink);
738 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
739}
740.tp-row-artist {
741 font-size: .8rem;
742 color: var(--ink-muted, var(--ink-soft));
743 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
744 margin-top: .1rem;
745}
746.tp-duration {
747 font-variant-numeric: tabular-nums;
748 font-size: .8rem;
749 color: var(--ink-muted, var(--ink-soft));
750 flex-shrink: 0;
751}
752
753/* Body lock when modal open */
754body.tp-locked { overflow: hidden; touch-action: none; }
755
756/* Desktop: centered card, max-width ~520px */
757@media (min-width: 640px) {
758 .tp-modal {
759 align-items: center;
760 padding: 1.5rem;
761 }
762 .tp-sheet {
763 margin-top: 0;
764 width: 100%;
765 max-width: 520px;
766 max-height: 80dvh;
767 border-radius: 14px;
768 border: 1px solid var(--rule);
769 animation: tp-sheet-pop .2s cubic-bezier(.2, .8, .25, 1);
770 }
771 .tp-header::before { display: none; } /* hide grab-handle on desktop */
772 .tp-header { padding: 1rem 1.25rem .75rem; }
773 .tp-h2 { margin-top: 0; font-size: 1.25rem; }
774 .tp-close { margin-top: 0; }
775 @keyframes tp-sheet-pop {
776 from { transform: translateY(-8px) scale(.97); opacity: 0; }
777 to { transform: translateY(0) scale(1); opacity: 1; }
778 }
779}
780
781/* ─── Buttons ───────────────────────────────────────────────────── */
782.pe-btn {
783 display: inline-flex; align-items: center; gap: 0.4rem;
784 padding: 0.55rem 1rem;
785 border: 1px solid var(--rule);
786 background: var(--paper-2);
787 color: var(--ink);
788 font-family: var(--font-ui, system-ui), sans-serif;
789 font-size: 0.9rem; font-weight: 500;
790 text-decoration: none;
791 border-radius: 6px;
792 cursor: pointer;
793 transition: background 120ms, border-color 120ms;
794 min-height: 40px;
795 -webkit-tap-highlight-color: transparent;
796}
797.pe-btn:hover { border-color: var(--accent); }
798.pe-btn:active { transform: scale(0.97); }
799.pe-btn-tiny {
800 padding: 0.35rem 0.7rem;
801 font-size: 0.85rem;
802 min-height: 32px;
803}
804.pe-btn-secondary { background: var(--paper-2); }
805.pe-btn-primary {
806 background: var(--accent); color: white;
807 border-color: var(--accent);
808}
809.pe-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
810.pe-btn-success {
811 background: #16a34a; color: white;
812 border-color: #16a34a;
813}
814.pe-btn-success:hover { opacity: 0.92; border-color: #16a34a; }
815
816.pe-status {
817 color: var(--ink-muted, var(--ink-soft));
818 font-size: 0.8rem;
819}
820.pe-status.is-error { color: #dc2626; }
821
822/* ─── Checkboxes (clean inline row) ─────────────────────────────── */
823.pe-checkboxes {
824 display: flex; flex-direction: column;
825 gap: 0.5rem;
826 padding-top: 0.25rem;
827}
828.pe-checkbox {
829 display: inline-flex; align-items: center; gap: 0.5rem;
830 cursor: pointer;
831 font-size: 0.95rem;
832 color: var(--ink);
833 user-select: none;
834}
835.pe-checkbox input[type="checkbox"] {
836 width: 18px; height: 18px;
837 margin: 0;
838 cursor: pointer;
839 accent-color: var(--accent);
840}
841
842/* Vastpinnen: checkbox + ▲▼-stepper met beschrijving (i.p.v. een ruw rang-getal). */
843.pe-pin { display: flex; flex-direction: column; gap: 0.45rem; }
844.pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; }
845.pe-pin-pos[hidden] { display: none; }
846.pe-pin-steps { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
847.pe-pin-btn {
848 width: 30px; height: 28px; padding: 0;
849 border: none; background: var(--paper-2); color: var(--ink);
850 cursor: pointer; font-size: 0.68rem; line-height: 1;
851 display: inline-flex; align-items: center; justify-content: center;
852}
853.pe-pin-btn + .pe-pin-btn { border-left: 1px solid var(--rule); }
854.pe-pin-btn:hover { background: color-mix(in srgb, var(--accent) 14%, var(--paper-2)); color: var(--accent); }
855.pe-pin-btn:disabled { opacity: 0.35; cursor: default; }
856.pe-pin-label { font-size: 0.85rem; color: var(--accent); font-weight: 600; }
857
858/* ─── Sticky action footer ──────────────────────────────────────── */
859.pe-actions {
860 position: sticky;
861 bottom: 0;
862 display: flex; align-items: center;
863 gap: 0.5rem;
864 padding: 0.85rem 1rem;
865 margin: 0.5rem -1rem 0; /* extend to viewport edges on tight container */
866 background: color-mix(in srgb, var(--paper) 94%, transparent);
867 -webkit-backdrop-filter: blur(8px);
868 backdrop-filter: blur(8px);
869 border-top: 1px solid var(--rule);
870 z-index: 10;
871}
872.pe-actions-spacer { flex: 1; }
873
874/* De editor is een focus-scherm: verberg de mobiele site-tabbalk (Home/Zoek/…)
875 zodat er niet twee balken onderaan stapelen (Opslaan-balk + tabbalk). De
876 Opslaan/Annuleren-balk wordt dan de enige onderbalk → gewoon op bottom:0,
877 geen tab-offset meer nodig. Op desktop is de tabbalk toch al verborgen. */
878body:has(.post-edit-page) .bottom-tab { display: none; }
879/* Audiospeler (indien aan het spelen) niet meer 56px optillen voor de nu-verborgen
880 tabbalk, anders zou die zweven boven de actiebalk. */
881body:has(.post-edit-page) .audio-player { bottom: 0; }
882</style>
883
884<script>
885(function() {
886
887 // ── Cover upload ────────────────────────────────────────────────
888 const coverField = document.getElementById('cover-upload-field');
889 const coverTrigger = document.getElementById('cover-upload-trigger');
890 const coverUrl = document.getElementById('cover-url-field');
891 const coverStatus = document.getElementById('cover-upload-status');
892 const coverWrap = document.getElementById('cover-preview-wrap');
893 const coverImg = document.getElementById('cover-preview-img');
894
895 async function uploadImage(file) {
896 const fd = new FormData();
897 fd.append('image', file);
898 const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
899 if (!res.ok) {
900 const j = await res.json().catch(() => ({}));
901 throw new Error(j.error || ('Upload failed (' + res.status + ')'));
902 }
903 return await res.json(); // {url, size, mime}
904 }
905
906 function showCoverPreview(url) {
907 if (!coverWrap || !coverImg) return;
908 if (url) {
909 coverImg.src = url;
910 coverImg.hidden = false;
911 coverWrap.removeAttribute('data-empty');
912 const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
913 if (emptyIcon) emptyIcon.remove();
914 } else {
915 coverImg.hidden = true;
916 coverImg.src = '';
917 coverWrap.setAttribute('data-empty', '');
918 if (!coverWrap.querySelector('.pe-cover-empty')) {
919 const span = document.createElement('span');
920 span.className = 'pe-cover-empty';
921 span.textContent = '🖼';
922 coverWrap.appendChild(span);
923 }
924 }
925 }
926
927 if (coverTrigger && coverField) {
928 coverTrigger.addEventListener('click', () => coverField.click());
929 }
930 if (coverField) {
931 coverField.addEventListener('change', async () => {
932 if (!coverField.files[0]) return;
933 coverStatus.classList.remove('is-error');
934 coverStatus.textContent = 'Uploaden…';
935 try {
936 const j = await uploadImage(coverField.files[0]);
937 coverUrl.value = j.url;
938 showCoverPreview(j.url);
939 coverStatus.textContent = 'Geüpload ✓';
940 setTimeout(() => { coverStatus.textContent = ''; }, 2000);
941 } catch (e) {
942 coverStatus.classList.add('is-error');
943 coverStatus.textContent = 'Mislukt: ' + e.message;
944 }
945 });
946 }
947 // Live-update preview when user pastes a URL manually
948 if (coverUrl) {
949 coverUrl.addEventListener('input', () => {
950 const v = coverUrl.value.trim();
951 if (v) showCoverPreview(v); else showCoverPreview('');
952 });
953 }
954
955 // ── WYSIWYG editor (P58) ────────────────────────────────────────
956 // Architecture:
957 // - Visible <div contenteditable> (`#content-editor`) is what the user
958 // types in; it shows real HTML (formatted, not raw markup).
959 // - Hidden <input name="content"> (`#content-hidden`) is what submits.
960 // On submit we serialize the editor's HTML into it, with shortcode
961 // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
962 // - Initial content comes from a <script type="application/json"> tag
963 // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
964 // and walk text nodes to render shortcode tokens as chips.
965 const contentField = document.getElementById('content-upload-field');
966 const contentBtn = document.getElementById('insert-image-btn');
967 const contentStatus = document.getElementById('content-upload-status');
968 const editor = document.getElementById('content-editor');
969 const hiddenField = document.getElementById('content-hidden');
970 const charCountEl = document.getElementById('char-count');
971 const initialEl = document.getElementById('initial-content');
972 const toolbar = document.getElementById('pe-toolbar');
973 const form = editor && editor.closest('form');
974
975 if (!editor) return;
976
977 // Titel alleen automatisch focussen op desktop (muis/trackpad). Op touch zou
978 // dit meteen het toetsenbord openen bij het openen van de editor — niet gewenst.
979 try {
980 const titleInput = form && form.querySelector('input[name="title"]');
981 if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
982 titleInput.focus({ preventScroll: true });
983 }
984 } catch (_) {}
985
986 // ── Shortcode chip rendering / serialization ────────────────────
987 // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
988 // — but we DON'T want to chipify text the user is mid-typing inside an
989 // HTML attribute; since chipify only walks text nodes (never attribute
990 // values) that's already safe.
991 const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
992
993 const SC_ICONS = {
994 track: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>',
995 album: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>',
996 playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>',
997 embed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>',
998 };
999
1000 function chipLabel(kind, value) {
1001 if (kind === 'track') {
1002 // UUIDs are noisy — show a 6-char prefix for visual hint
1003 const v = String(value || '');
1004 return 'Track ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
1005 }
1006 if (kind === 'album') return 'Album: ' + value;
1007 if (kind === 'playlist') return 'Playlist: ' + value;
1008 if (kind === 'embed') {
1009 const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
1010 return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
1011 }
1012 return value;
1013 }
1014
1015 function makeChip(kind, value) {
1016 const span = document.createElement('span');
1017 span.className = 'sc-chip';
1018 span.contentEditable = 'false';
1019 span.setAttribute('data-sc', kind + ':' + value);
1020 span.innerHTML =
1021 '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
1022 '<span class="sc-chip-label"></span>';
1023 span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
1024 return span;
1025 }
1026
1027 // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
1028 function chipifyShortcodes(root) {
1029 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
1030 const targets = [];
1031 while (walker.nextNode()) {
1032 const n = walker.currentNode;
1033 // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
1034 if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
1035 if (SC_RE.test(n.nodeValue)) targets.push(n);
1036 SC_RE.lastIndex = 0;
1037 }
1038 for (const node of targets) {
1039 const txt = node.nodeValue;
1040 const frag = document.createDocumentFragment();
1041 let last = 0;
1042 let m;
1043 SC_RE.lastIndex = 0;
1044 while ((m = SC_RE.exec(txt)) !== null) {
1045 if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
1046 frag.appendChild(makeChip(m[1], m[2].trim()));
1047 last = m.index + m[0].length;
1048 }
1049 if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
1050 node.parentNode.replaceChild(frag, node);
1051 }
1052 }
1053
1054 // Inverse of chipify: clone the editor, replace every chip with its text.
1055 function serializeChips(rootClone) {
1056 const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
1057 for (const c of chips) {
1058 const txt = '[[' + c.getAttribute('data-sc') + ']]';
1059 c.replaceWith(document.createTextNode(txt));
1060 }
1061 }
1062
1063 // ── Boot: load initial content as HTML, then render shortcodes as chips
1064 try {
1065 const initial = JSON.parse(initialEl.textContent || '""');
1066 editor.innerHTML = initial || '';
1067 chipifyShortcodes(editor);
1068 } catch (e) {
1069 console.error('[editor] could not parse initial content', e);
1070 editor.innerHTML = '';
1071 }
1072
1073 // ── Char counter
1074 function updateCharCount() {
1075 const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
1076 if (charCountEl) charCountEl.textContent = String(text.length);
1077 }
1078 updateCharCount();
1079 editor.addEventListener('input', updateCharCount);
1080
1081 // ── Toolbar wiring
1082 // Houd de scrollpositie vast rond een edit-commando. execCommand/insert scrollt
1083 // standaard de caret in beeld → het beeld "verspringt" bij het aanklikken van een
1084 // opmaakknop. We leggen ALLE scrollbare voorouders (editor, frame, #pcms-main, …)
1085 // + de pagina vast en zetten ze terug — sync én over een paar frames, want Chrome
1086 // scrollt soms pas een frame later. De gebruiker scrollt zo zelf.
1087 function scrollableAncestors(el) {
1088 const list = [];
1089 let node = el;
1090 while (node && node !== document.body && node !== document.documentElement) {
1091 const oy = getComputedStyle(node).overflowY;
1092 if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
1093 node = node.parentElement;
1094 }
1095 return list;
1096 }
1097 function keepScroll(fn) {
1098 // In fullscreen staat de pagina vast (body overflow:hidden) en mag het veld
1099 // gewoon naar de caret scrollen — geen pagina-sprong mogelijk, dus niets fixen.
1100 const frame = document.querySelector('.pe-editor-frame');
1101 if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
1102 const wx = window.scrollX, wy = window.scrollY;
1103 const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
1104 const restore = function () {
1105 window.scrollTo(wx, wy);
1106 anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
1107 };
1108 fn();
1109 restore();
1110 requestAnimationFrame(restore);
1111 }
1112 function execCmd(cmd, arg) {
1113 keepScroll(function () {
1114 editor.focus({ preventScroll: true });
1115 document.execCommand(cmd, false, arg);
1116 });
1117 updateToolbarState();
1118 updateCharCount();
1119 }
1120 function wrapCode() {
1121 const sel = window.getSelection();
1122 if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
1123 keepScroll(function () {
1124 const range = sel.getRangeAt(0);
1125 const code = document.createElement('code');
1126 code.textContent = sel.toString();
1127 range.deleteContents();
1128 range.insertNode(code);
1129 // Move caret after the new node
1130 range.setStartAfter(code);
1131 range.collapse(true);
1132 sel.removeAllRanges();
1133 sel.addRange(range);
1134 editor.focus({ preventScroll: true });
1135 });
1136 }
1137 function linkPrompt() {
1138 const url = window.prompt('Link URL (https://… of /pad)');
1139 if (!url) return;
1140 execCmd('createLink', url);
1141 }
1142 // De huidige selectie zit in een <blockquote> binnen de editor? Geef 'm terug.
1143 function blockquoteAncestor() {
1144 const sel = window.getSelection();
1145 if (!sel || sel.rangeCount === 0) return null;
1146 let node = sel.anchorNode;
1147 while (node && node !== editor) {
1148 if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
1149 node = node.parentNode;
1150 }
1151 return null;
1152 }
1153 // Echte toggle: execCommand('formatBlock','blockquote') zet 'm wél AAN maar
1154 // krijgt 'm nooit meer UIT (browser-quirk). Staat de caret al in een quote →
1155 // pak de wrapper uit; anders pas blockquote toe.
1156 function toggleBlockquote() {
1157 keepScroll(function () {
1158 editor.focus({ preventScroll: true });
1159 const bq = blockquoteAncestor();
1160 if (bq) {
1161 const parent = bq.parentNode;
1162 // Inhoud uit de quote halen, op z'n plek, en de lege wrapper verwijderen.
1163 const ref = bq;
1164 let firstMoved = null;
1165 while (bq.firstChild) {
1166 const child = bq.firstChild;
1167 if (!firstMoved) firstMoved = child;
1168 parent.insertBefore(child, ref);
1169 }
1170 parent.removeChild(bq);
1171 // Caret terugzetten in de uitgepakte inhoud.
1172 if (firstMoved) {
1173 const sel = window.getSelection();
1174 const range = document.createRange();
1175 range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
1176 range.collapse(false);
1177 sel.removeAllRanges();
1178 sel.addRange(range);
1179 }
1180 } else {
1181 document.execCommand('formatBlock', false, 'blockquote');
1182 }
1183 });
1184 updateToolbarState();
1185 updateCharCount();
1186 }
1187
1188 if (toolbar) {
1189 // CRUCIAAL (mobiel + desktop): voorkom dat een toolbar-knop de focus/selectie
1190 // uit het editor-veld steelt. Zonder dit raakt de selectie kwijt bij het tikken
1191 // → execCommand werkt op een lege selectie (vet kan niet meer UIT) én de browser
1192 // scrollt de caret opnieuw in beeld (de "sprong naar beneden"). preventDefault op
1193 // mousedown houdt de focus in de editor; de click blijft gewoon vuren.
1194 toolbar.addEventListener('mousedown', (e) => {
1195 if (e.target.closest('button')) e.preventDefault();
1196 });
1197 toolbar.addEventListener('click', (e) => {
1198 const btn = e.target.closest('button[data-cmd]');
1199 if (!btn) return;
1200 e.preventDefault();
1201 const cmd = btn.dataset.cmd;
1202 const arg = btn.dataset.arg || null;
1203 if (cmd === 'link-prompt') linkPrompt();
1204 else if (cmd === 'code-wrap') wrapCode();
1205 else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
1206 else execCmd(cmd, arg);
1207 });
1208 }
1209
1210 // ── Volledig-scherm schrijfmodus: het schrijfveld vult de hele pagina.
1211 const fsBtn = document.getElementById('pe-fullscreen-btn');
1212 const editorFrame = document.querySelector('.pe-editor-frame');
1213
1214 // Op mobiel duwt het toetsenbord de zichtbare (visual) viewport omhoog terwijl
1215 // een position:fixed-frame aan de LAYOUT-viewport blijft hangen → de toolbar
1216 // schuift uit beeld. Houd het fullscreen-frame daarom gelijk aan de visual
1217 // viewport (top + hoogte), zodat de toolbar altijd bovenaan zichtbaar blijft.
1218 function syncFsViewport() {
1219 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
1220 const vv = window.visualViewport;
1221 if (!vv) return;
1222 editorFrame.style.top = vv.offsetTop + 'px';
1223 editorFrame.style.height = vv.height + 'px';
1224 }
1225 function clearFsViewport() {
1226 if (!editorFrame) return;
1227 editorFrame.style.top = '';
1228 editorFrame.style.height = '';
1229 }
1230 function toggleFullscreen() {
1231 if (!editorFrame) return;
1232 const on = editorFrame.classList.toggle('pe-fs');
1233 document.body.classList.toggle('pe-fs-open', on);
1234 if (fsBtn) {
1235 fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
1236 fsBtn.title = on ? 'Verklein' : 'Volledig scherm';
1237 }
1238 if (window.visualViewport) {
1239 if (on) {
1240 window.visualViewport.addEventListener('resize', syncFsViewport);
1241 window.visualViewport.addEventListener('scroll', syncFsViewport);
1242 syncFsViewport();
1243 } else {
1244 window.visualViewport.removeEventListener('resize', syncFsViewport);
1245 window.visualViewport.removeEventListener('scroll', syncFsViewport);
1246 clearFsViewport();
1247 }
1248 }
1249 if (on) editor.focus({ preventScroll: true });
1250 }
1251 if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
1252 document.addEventListener('keydown', (e) => {
1253 if (e.key === 'Escape' && editorFrame && editorFrame.classList.contains('pe-fs')) {
1254 e.preventDefault();
1255 toggleFullscreen();
1256 }
1257 });
1258
1259 // Op mobiel/tablet (touch-apparaat = pointer:coarse) gebeurt typen ALTIJD
1260 // fullscreen. We gaan fullscreen zowel bij focus ALS bij de eerste invoer
1261 // (beforeinput) — zo kun je ook na het verlaten van fullscreen niet per ongeluk
1262 // zonder fullscreen doortypen. Eruit kan met de knop (⤡) of Escape.
1263 var isTouch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
1264 if (isTouch) {
1265 var enterFs = function () {
1266 if (editorFrame && !editorFrame.classList.contains('pe-fs')) toggleFullscreen();
1267 };
1268 editor.addEventListener('focus', enterFs);
1269 editor.addEventListener('beforeinput', enterFs);
1270 editor.addEventListener('pointerup', enterFs);
1271 }
1272
1273 // Reflect bold/italic/list state on the toolbar buttons
1274 function updateToolbarState() {
1275 if (!toolbar) return;
1276 const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
1277 for (const cmd of cmds) {
1278 const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
1279 if (!btn) continue;
1280 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
1281 }
1282 // Quote-knop: actief als de caret in een <blockquote> staat (toggle-feedback).
1283 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
1284 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
1285 }
1286 document.addEventListener('selectionchange', () => {
1287 if (document.activeElement === editor) updateToolbarState();
1288 });
1289
1290 // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
1291 editor.addEventListener('keydown', (e) => {
1292 const mod = e.ctrlKey || e.metaKey;
1293 if (!mod) return;
1294 const k = e.key.toLowerCase();
1295 if (k === 'b') { e.preventDefault(); execCmd('bold'); }
1296 else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
1297 else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
1298 else if (k === 'k') { e.preventDefault(); linkPrompt(); }
1299 });
1300
1301 // Paste: keep it simple — strip formatting unless user wants it. Default
1302 // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
1303 // styles from clipboard only when shift is held — otherwise plain text.
1304 editor.addEventListener('paste', (e) => {
1305 if (e.shiftKey) return; // user wants formatted paste
1306 const text = (e.clipboardData || window.clipboardData).getData('text/plain');
1307 if (text == null) return;
1308 e.preventDefault();
1309 document.execCommand('insertText', false, text);
1310 });
1311
1312 // ── Image upload (button + drag-drop into the editor)
1313 async function uploadAndInsertImage(file) {
1314 contentStatus.classList.remove('is-error');
1315 contentStatus.textContent = 'Uploaden…';
1316 try {
1317 const j = await uploadImage(file);
1318 const img = '<img src="' + j.url + '" alt="">';
1319 editor.focus({ preventScroll: true });
1320 document.execCommand('insertHTML', false, img);
1321 contentStatus.textContent = 'Ingevoegd ✓';
1322 setTimeout(() => { contentStatus.textContent = ''; }, 2000);
1323 updateCharCount();
1324 } catch (e) {
1325 contentStatus.classList.add('is-error');
1326 contentStatus.textContent = 'Mislukt: ' + e.message;
1327 }
1328 }
1329
1330 if (contentBtn && contentField) {
1331 contentBtn.addEventListener('click', () => contentField.click());
1332 contentField.addEventListener('change', () => {
1333 if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
1334 contentField.value = '';
1335 });
1336
1337 editor.addEventListener('dragover', (e) => {
1338 if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
1339 e.preventDefault();
1340 editor.classList.add('is-dragover');
1341 }
1342 });
1343 editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
1344 editor.addEventListener('drop', async (e) => {
1345 editor.classList.remove('is-dragover');
1346 const files = e.dataTransfer && e.dataTransfer.files;
1347 if (!files || !files.length) return;
1348 e.preventDefault();
1349 for (const f of files) {
1350 if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
1351 }
1352 });
1353 }
1354
1355 // ── Insert chip helpers (track / playlist)
1356 function insertChip(kind, value) {
1357 editor.focus({ preventScroll: true });
1358 const chip = makeChip(kind, value);
1359 // Insert at caret using the Selection API (execCommand insertNode)
1360 const sel = window.getSelection();
1361 if (sel && sel.rangeCount > 0) {
1362 const range = sel.getRangeAt(0);
1363 range.deleteContents();
1364 range.insertNode(chip);
1365 // Insert a trailing space so the user can keep typing after the chip
1366 const space = document.createTextNode('\u00A0');
1367 chip.after(space);
1368 range.setStartAfter(space);
1369 range.collapse(true);
1370 sel.removeAllRanges();
1371 sel.addRange(range);
1372 } else {
1373 editor.appendChild(chip);
1374 editor.appendChild(document.createTextNode('\u00A0'));
1375 }
1376 updateCharCount();
1377 }
1378
1379 // ── Embed insert: plak een platform-URL -> [[embed:url]]-chip die server-side
1380 // een iframe wordt (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
1381 const embedBtn = document.getElementById('insert-embed-btn');
1382 if (embedBtn) {
1383 embedBtn.addEventListener('click', () => {
1384 const raw = window.prompt('Plak een media-URL om in te sluiten\n(YouTube, Spotify, SoundCloud, Vimeo, Apple Music, Bandcamp):');
1385 if (!raw) return;
1386 const url = raw.trim();
1387 if (!/^https?:\/\//i.test(url)) { alert('Geef een volledige URL (https://…).'); return; }
1388 insertChip('embed', url);
1389 });
1390 }
1391
1392 // ── Track insert: opens the track-picker modal (P59)
1393 const trackBtn = document.getElementById('insert-track-btn');
1394 const trackPicker = document.getElementById('track-picker');
1395 if (trackBtn && trackPicker) {
1396 const tpList = document.getElementById('tp-list');
1397 const tpEmpty = document.getElementById('tp-empty');
1398 const tpSearch = document.getElementById('tp-search');
1399 let tpCache = null; // cached tracks list (fetched once per page load)
1400 let tpLastFocus = null; // element to restore focus to on close
1401
1402 const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>';
1403
1404 function fmtDur(sec) {
1405 sec = Math.max(0, Math.floor(sec || 0));
1406 const m = Math.floor(sec / 60), s = sec % 60;
1407 return m + ':' + String(s).padStart(2, '0');
1408 }
1409 function escAttr(s) {
1410 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
1411 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
1412 }[c]));
1413 }
1414
1415 function renderList(filter) {
1416 if (!Array.isArray(tpCache)) return;
1417 const q = (filter || '').trim().toLowerCase();
1418 const filtered = q
1419 ? tpCache.filter(t =>
1420 (t.title || '').toLowerCase().includes(q) ||
1421 (t.artist || '').toLowerCase().includes(q))
1422 : tpCache;
1423
1424 if (!filtered.length) {
1425 tpList.innerHTML = '';
1426 tpEmpty.textContent = q ? 'Geen tracks gevonden voor "' + q + '"' : 'Nog geen tracks. Upload via Beheer → Audio.';
1427 tpList.appendChild(tpEmpty);
1428 return;
1429 }
1430
1431 tpList.innerHTML = filtered.map(t => {
1432 const cov = t.cover
1433 ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
1434 : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
1435 const dis = t.playable ? '' : ' aria-disabled="true"';
1436 const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
1437 return (
1438 '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
1439 cov +
1440 '<span class="tp-meta">' +
1441 '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
1442 sub +
1443 '</span>' +
1444 '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
1445 '</button>'
1446 );
1447 }).join('');
1448 }
1449
1450 async function loadTracks() {
1451 if (Array.isArray(tpCache)) return tpCache;
1452 tpEmpty.textContent = 'Tracks laden…';
1453 try {
1454 const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
1455 const j = await r.json();
1456 tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
1457 } catch (e) {
1458 tpCache = [];
1459 tpEmpty.textContent = 'Kon tracks niet laden: ' + e.message;
1460 }
1461 return tpCache;
1462 }
1463
1464 function openPicker() {
1465 tpLastFocus = document.activeElement;
1466 trackPicker.hidden = false;
1467 trackPicker.setAttribute('aria-hidden', 'false');
1468 document.body.classList.add('tp-locked');
1469 tpSearch.value = '';
1470 renderList('');
1471 // Defer focus so the open animation doesn't get jumped
1472 setTimeout(() => tpSearch.focus(), 30);
1473 }
1474 function closePicker() {
1475 trackPicker.hidden = true;
1476 trackPicker.setAttribute('aria-hidden', 'true');
1477 document.body.classList.remove('tp-locked');
1478 if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
1479 try { tpLastFocus.focus(); } catch(_) {}
1480 }
1481 }
1482
1483 trackBtn.addEventListener('click', async () => {
1484 openPicker();
1485 await loadTracks();
1486 renderList(tpSearch.value);
1487 });
1488
1489 // Close: backdrop click, [data-tp-close], or Escape
1490 trackPicker.addEventListener('click', (e) => {
1491 if (e.target.closest('[data-tp-close]')) {
1492 closePicker();
1493 return;
1494 }
1495 const row = e.target.closest('.tp-row[data-track-id]');
1496 if (row) {
1497 if (row.getAttribute('aria-disabled') === 'true') return;
1498 const id = row.dataset.trackId;
1499 if (id) {
1500 insertChip('track', id);
1501 closePicker();
1502 }
1503 }
1504 });
1505 document.addEventListener('keydown', (e) => {
1506 if (!trackPicker.hidden && e.key === 'Escape') {
1507 e.preventDefault();
1508 closePicker();
1509 }
1510 });
1511
1512 // Live filter
1513 tpSearch.addEventListener('input', () => renderList(tpSearch.value));
1514 }
1515
1516 // ── Playlist insert (open existing or create new via modal)
1517 const playlistBtn = document.getElementById('insert-playlist-btn');
1518 if (playlistBtn) {
1519 playlistBtn.addEventListener('click', async () => {
1520 if (typeof window.openPlaylistEditor !== 'function') {
1521 alert('Playlist editor niet geladen');
1522 return;
1523 }
1524 try {
1525 const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
1526 const j = await r.json();
1527 if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
1528 const choice = prompt(
1529 'Bestaande playlists:\n\n' +
1530 j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
1531 '\n\nKies een nummer om in te voegen, leeg = nieuwe maken:'
1532 );
1533 if (choice && /^\d+$/.test(choice.trim())) {
1534 const idx = parseInt(choice.trim(), 10) - 1;
1535 if (idx >= 0 && idx < j.playlists.length) {
1536 insertChip('playlist', j.playlists[idx].id);
1537 return;
1538 }
1539 }
1540 if (choice === null) return;
1541 }
1542 } catch (_) { /* fall through to create */ }
1543
1544 window.openPlaylistEditor({
1545 mode: 'create',
1546 onSaved: ({ id }) => insertChip('playlist', id),
1547 });
1548 });
1549 }
1550
1551 // ── Submit: serialize editor contents into the hidden field
1552 if (form && hiddenField) {
1553 form.addEventListener('submit', () => {
1554 const clone = editor.cloneNode(true);
1555 serializeChips(clone);
1556 hiddenField.value = clone.innerHTML;
1557 });
1558 }
1559})();
1560</script>
1561
1562<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
1563 viewports, centered modal on ≥640px. Populated lazily on first open. %>
1564<% if (typeof user !== 'undefined' && user) { %>
1565<div id="track-picker" class="tp-modal" hidden aria-hidden="true">
1566 <div class="tp-backdrop" data-tp-close></div>
1567 <div class="tp-sheet" role="dialog" aria-modal="true" aria-labelledby="tp-title">
1568 <header class="tp-header">
1569 <h2 id="tp-title" class="tp-h2">Track invoegen</h2>
1570 <button type="button" class="tp-close" data-tp-close aria-label="Sluiten">
1571 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1572 </button>
1573 </header>
1574 <div class="tp-search-row">
1575 <span class="tp-search-icon" aria-hidden="true">
1576 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
1577 </span>
1578 <input type="search" class="tp-search" id="tp-search" placeholder="Zoek op titel of artiest…" autocomplete="off" inputmode="search">
1579 </div>
1580 <div class="tp-list" id="tp-list" role="listbox" aria-label="Tracks">
1581 <div class="tp-empty" id="tp-empty">Tracks laden…</div>
1582 </div>
1583 </div>
1584</div>
1585<% } %>
1586
1587<%# Playlist editor modal — included so its global window.openPlaylistEditor
1588 is available when the toolbar button fires. Only renders if user can post. %>
1589<% if (typeof user !== 'undefined' && user) { %>
1590 <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
1591<% } %>
1592
1593<script>
1594(function () {
1595 // Vastpinnen: checkbox toggelt het verborgen rang-veld (0 = niet gepind),
1596 // ▲▼ verschuift de plek, met een leesbare beschrijving i.p.v. een ruw getal.
1597 var toggle = document.getElementById('pin-toggle');
1598 var rank = document.getElementById('pin-rank');
1599 var pos = document.getElementById('pin-pos');
1600 var label = document.getElementById('pin-label');
1601 var up = document.getElementById('pin-up'); // hoger = lager getal (richting 1/bovenaan)
1602 var down = document.getElementById('pin-down');
1603 if (!toggle || !rank || !pos) return;
1604
1605 function descr(n) {
1606 n = Number(n) || 0;
1607 if (n <= 1) return 'bovenaan';
1608 return n + 'e van boven';
1609 }
1610 function render() {
1611 var on = toggle.checked;
1612 pos.hidden = !on;
1613 if (on && Number(rank.value) < 1) rank.value = 1;
1614 if (!on) rank.value = 0;
1615 if (label) label.textContent = on ? descr(rank.value) : '';
1616 if (up) up.disabled = Number(rank.value) <= 1;
1617 }
1618 toggle.addEventListener('change', render);
1619 if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
1620 if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
1621 render();
1622})();
1623
1624(function () {
1625 // De Opslaan/Annuleren-balk (position: sticky; bottom:0) net boven twee mogelijke
1626 // obstakels houden door een dynamische bottom-offset te zetten = het grootste van:
1627 // 1) de hoogte van het toetsenbord-deel dat de layout-viewport NIET dekt
1628 // (op iOS verschuift de visual viewport; op Android verkleint de layout-
1629 // viewport door interactive-widget=resizes-content → offset ≈ 0);
1630 // 2) de hoogte van de spelende audiospeler (fixed, z-index 1000).
1631 // We blijven bij sticky (geen fixed/top-gegoochel → geen balk midden in beeld).
1632 var bar = document.querySelector('.pe-actions');
1633 if (!bar) return;
1634 var vv = window.visualViewport;
1635 function position() {
1636 var ap = document.querySelector('.audio-player');
1637 var playing = document.body.classList.contains('has-audio-player') &&
1638 ap && getComputedStyle(ap).display !== 'none';
1639 var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
1640 var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
1641 var offset = Math.max(audioOffset, kbCovered);
1642 bar.style.bottom = offset ? offset + 'px' : '';
1643 }
1644 position();
1645 window.addEventListener('resize', position);
1646 if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
1647 // has-audio-player wisselt via een body-class → daarop reageren.
1648 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
1649})();
1650</script>
Note: See TracBrowser for help on using the repository browser.