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

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

fix(editor): mobile writing field as bounded box with internal scroll

The field grew with the text → the whole page scrolled to the caret on a
formatting command. On mobile now min-height 240 / max-height 62vh +
overflow auto: caret scroll stays within the box, the page does not jump,
and the toolbar sits sticky above it. Fullscreen resets max-height to none
(fills the page).

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

  • Property mode set to 100644
File size: 67.7 KB
RevLine 
[7bc636b]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;
[851239c]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 : '';
[7bc636b]15%>
16<div class="container post-edit-page">
17 <h1><%= isNew ? 'Nieuwe post' : 'Post bewerken' %></h1>
18
[851239c]19 <form method="post" action="<%= _base %><%= isNew ? '/posts/create' : '/posts/' + post.slug + '/save' %>" class="post-edit-form">
[7bc636b]20
21 <%# ── IDENTITY ─────────────────────────────────────────────── %>
22 <section class="pe-card">
23 <label class="pe-field">
24 <span>Titel</span>
[a0bff44]25 <input type="text" name="title" value="<%= post.title %>" required>
[7bc636b]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>
[7eb35ae]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>
[7bc636b]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>
[b50a885]55 <input type="text" name="cover_image_url" id="cover-url-field"
[7bc636b]56 value="<%= post.cover_image_url || '' %>"
[b50a885]57 inputmode="url" autocapitalize="none" spellcheck="false"
58 placeholder="/media/… of https://… (of upload met de knop)">
[7bc636b]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>
[1907a18]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>
[7bc636b]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>
[d2078f4]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>
[7bc636b]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>
[edc462d]163 <% var _st = isNew ? 'published' : (post.status || 'draft'); %>
[7bc636b]164 <select name="status">
[edc462d]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>
[7bc636b]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">
[edc462d]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>
[7bc636b]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>
[b9dc94c]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 <% } %>
[7bc636b]211 </div>
212 </section>
213
214 <%# ── ACTIONS (sticky at bottom) ──────────────────────────── %>
215 <div class="pe-actions">
[851239c]216 <a href="<%= _base %><%= isNew ? '/' : '/' + post.slug %>" class="pe-btn">Annuleren</a>
[7bc636b]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;
[a7e8e19]399 touch-action: manipulation; /* snappy taps op mobiel, geen dubbeltik-zoom */
400 -webkit-user-select: none; user-select: none;
[7bc636b]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}
[16ce669]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 }
[7bc636b]418}
419.pe-toolbar button:active { transform: scale(.94); }
[16ce669]420/* Actieve opmaak = onmiskenbaar gevuld met de accentkleur. Zo is op mobiel
421 meteen duidelijk dat bv. vet AAN staat (tik nogmaals = uit). */
[7bc636b]422.pe-toolbar button.is-active {
[16ce669]423 background: var(--accent);
424 color: #fff;
425 border-color: var(--accent);
[7bc636b]426}
[16ce669]427.pe-toolbar button.is-active svg { color: #fff; }
[7bc636b]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
[d2078f4]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}
[859bace0]448/* In fullscreen vult het schrijfveld de resterende ruimte en scrollt het zelf
449 (max-height: none overschrijft de mobiele box-begrenzing hieronder). */
[d2078f4]450.pe-editor-frame.pe-fs .pe-editor {
[859bace0]451 flex: 1 1 auto; min-height: 0; height: auto; max-height: none;
[d2078f4]452}
453body.pe-fs-open { overflow: hidden; }
454
[7bc636b]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; }
[859bace0]471/* Mobiel: begrens het schrijfveld tot een box met EIGEN interne scroll. Zo blijft
472 de caret-scroll (die mobiel Chrome forceert bij een opmaak-commando) binnen de
473 box i.p.v. de hele pagina te laten verspringen. De toolbar staat er sticky boven.
474 In fullscreen vult het veld de hele pagina (regel hierboven wint via .pe-fs). */
475@media (max-width: 768px) {
476 .pe-editor { min-height: 240px; max-height: 62vh; }
477}
[7bc636b]478.pe-editor.is-dragover {
479 outline: 2px dashed var(--accent);
480 outline-offset: -10px;
481}
482.pe-editor[data-placeholder]:empty::before,
483.pe-editor[data-placeholder]:has(br:only-child)::before {
484 content: attr(data-placeholder);
485 color: var(--ink-muted, #999);
486 pointer-events: none;
487 opacity: .55;
488}
489/* Inline prose styles inside the editor — match the public post styling so
490 what you see is roughly what you'll get. Margins are tighter than on the
491 live site since this is a confined writing space. */
492.pe-editor h1, .pe-editor h2, .pe-editor h3, .pe-editor h4 {
493 font-family: var(--font-display, serif);
494 line-height: 1.2;
495 margin: 1.1rem 0 .35rem;
496}
497.pe-editor h1 { font-size: 1.85rem; }
498.pe-editor h2 { font-size: 1.45rem; }
499.pe-editor h3 { font-size: 1.2rem; }
500.pe-editor p { margin: 0 0 .85em; }
501.pe-editor ul, .pe-editor ol { margin: 0 0 .85em 1.4em; padding: 0; }
502.pe-editor li { margin: .15em 0; }
503.pe-editor blockquote {
504 margin: 1em 0;
505 padding: .15em 0 .15em 1em;
506 border-left: 3px solid var(--accent);
507 color: var(--ink-soft, var(--ink));
508 font-style: italic;
509}
510.pe-editor code {
511 background: var(--paper-2);
512 border: 1px solid var(--rule);
513 border-radius: 4px;
514 padding: .1em .35em;
515 font-family: var(--font-mono, ui-monospace, monospace);
516 font-size: .92em;
517}
518.pe-editor a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
519.pe-editor img {
520 max-width: 100%; height: auto;
521 border-radius: 6px;
522 display: block;
523 margin: 1em auto;
524}
525
526/* Shortcode chips — visible inline placeholder in the editor for
527 [[track:UUID]] / [[album:Name]] / [[playlist:slug]]. They serialize back
528 to plain shortcode text on submit. */
529.sc-chip {
530 display: inline-flex; align-items: center; gap: .3rem;
531 padding: .15em .5em;
532 margin: 0 .15em;
533 background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
534 border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
535 border-radius: 999px;
536 color: var(--accent);
537 font-family: var(--font-ui, system-ui), sans-serif;
538 font-size: .85em;
539 font-weight: 600;
540 font-style: normal;
541 white-space: nowrap;
542 user-select: none;
543 cursor: default;
544 vertical-align: baseline;
545}
546.sc-chip-icon { display: inline-flex; opacity: .8; }
547.sc-chip-icon svg { width: 12px; height: 12px; }
548
549/* Status bar below the editor */
550.pe-editor-status {
551 display: flex; align-items: center; justify-content: space-between;
552 gap: .75rem;
553 padding: .45rem 1rem;
554 border-top: 1px solid var(--rule);
555 background: var(--paper-2);
556 color: var(--ink-muted, var(--ink-soft));
557 font-size: .78rem;
558}
559.pe-char-count { font-variant-numeric: tabular-nums; }
560
561/* ─── Track picker modal (P59) ─────────────────────────────────
562 Mobile-first: full-screen bottom-sheet on phones, centered card
563 on desktop. Lock body scroll while open via .tp-locked on body. */
564.tp-modal {
565 position: fixed; inset: 0;
566 z-index: 1000;
567 display: flex; align-items: stretch; justify-content: center;
568 /* Avoid the iOS home-indicator + the keyboard when search is focused */
569 padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
570}
571.tp-modal[hidden] { display: none; }
572.tp-backdrop {
573 position: absolute; inset: 0;
574 background: color-mix(in srgb, var(--ink) 55%, transparent);
575 backdrop-filter: blur(4px);
576 -webkit-backdrop-filter: blur(4px);
577 animation: tp-bd-in .18s ease-out;
578}
579@keyframes tp-bd-in { from { opacity: 0 } to { opacity: 1 } }
580
581/* Mobile: sheet docks to the bottom and fills viewport almost completely */
582.tp-sheet {
583 position: relative;
584 margin-top: auto;
585 width: 100%;
586 max-height: calc(100dvh - env(safe-area-inset-top) - 1rem);
587 background: var(--paper);
588 border-top: 1px solid var(--rule);
589 border-radius: 16px 16px 0 0;
590 box-shadow:
591 0 -2px 8px color-mix(in srgb, var(--ink) 8%, transparent),
592 0 -16px 48px color-mix(in srgb, var(--ink) 18%, transparent);
593 display: flex; flex-direction: column;
594 animation: tp-sheet-up .22s cubic-bezier(.2, .8, .25, 1);
595 overflow: hidden;
596}
597@keyframes tp-sheet-up {
598 from { transform: translateY(100%); opacity: .8; }
599 to { transform: translateY(0); opacity: 1; }
600}
601
602/* Header — title + close, with a small grab-handle bar on mobile */
603.tp-header {
604 display: flex; align-items: center; justify-content: space-between;
605 gap: .75rem;
606 padding: .85rem 1rem .65rem;
607 border-bottom: 1px solid var(--rule);
608 position: relative;
609}
610.tp-header::before {
611 /* Grab-handle: visible on mobile only */
612 content: '';
613 position: absolute;
614 top: .35rem; left: 50%;
615 transform: translateX(-50%);
616 width: 38px; height: 4px;
617 background: var(--rule-2, var(--rule));
618 border-radius: 2px;
619}
620.tp-h2 {
621 margin: .35rem 0 0;
622 font-family: var(--font-display, serif);
623 font-size: 1.15rem; font-weight: 600;
624 color: var(--ink);
625 line-height: 1.1;
626}
627.tp-close {
628 width: 32px; height: 32px; border-radius: 8px;
629 display: inline-flex; align-items: center; justify-content: center;
630 background: transparent; border: 1px solid transparent;
631 color: var(--ink-muted, var(--ink-soft));
632 cursor: pointer;
633 transition: background var(--transition), color var(--transition), border-color var(--transition);
634 -webkit-tap-highlight-color: transparent;
635 margin-top: .35rem;
636}
637.tp-close:hover, .tp-close:focus-visible {
638 background: var(--paper-2);
639 color: var(--ink);
640 outline: none;
641}
642.tp-close svg { width: 18px; height: 18px; }
643
644/* Search row — sticky just below header so list scrolls underneath */
645.tp-search-row {
646 position: relative;
647 padding: .65rem 1rem;
648 border-bottom: 1px solid var(--rule);
649 background: var(--paper);
650}
651.tp-search-icon {
652 position: absolute;
653 top: 50%; left: 1.65rem;
654 transform: translateY(-50%);
655 color: var(--ink-muted, var(--ink-soft));
656 pointer-events: none;
657 display: inline-flex;
658}
659.tp-search-icon svg { width: 16px; height: 16px; }
660.tp-search {
661 width: 100%; box-sizing: border-box;
662 padding: .65rem .85rem .65rem 2.4rem;
663 font-family: var(--font-ui, system-ui), sans-serif;
664 font-size: 1rem;
665 background: var(--paper-2);
666 color: var(--ink);
667 border: 1px solid var(--rule);
668 border-radius: 9px;
669 -webkit-appearance: none;
670 appearance: none;
671}
672.tp-search:focus {
673 outline: none;
674 border-color: var(--accent);
675 box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
676}
677
678/* List — vertically scrollable, takes remaining sheet height */
679.tp-list {
680 flex: 1 1 auto;
681 overflow-y: auto;
682 -webkit-overflow-scrolling: touch;
683 padding: .35rem .35rem 1rem;
684}
685.tp-empty {
686 padding: 1.5rem 1rem;
687 text-align: center;
688 color: var(--ink-muted, var(--ink-soft));
689 font-size: .9rem;
690}
691
692/* Track row — tap target ≥ 56px for mobile */
693.tp-row {
694 display: grid;
695 grid-template-columns: 44px 1fr auto;
696 align-items: center;
697 gap: .75rem;
698 width: 100%;
699 padding: .55rem .65rem;
700 border: 0; background: transparent;
701 border-radius: 10px;
702 text-align: left;
703 cursor: pointer;
704 color: inherit;
705 font: inherit;
706 transition: background var(--transition);
707 -webkit-tap-highlight-color: transparent;
708}
709.tp-row:hover, .tp-row:focus-visible {
710 background: color-mix(in srgb, var(--accent) 8%, var(--paper-2));
711 outline: none;
712}
713.tp-row:active { transform: scale(.98); }
714.tp-row[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
715
716.tp-cover {
717 width: 44px; height: 44px;
718 border-radius: 6px;
719 background-size: cover; background-position: center;
720 background-color: var(--rule);
721 display: inline-flex; align-items: center; justify-content: center;
722 flex-shrink: 0;
723}
724.tp-cover-empty {
725 background: linear-gradient(135deg,
726 color-mix(in srgb, var(--accent) 30%, var(--paper-2)),
727 color-mix(in srgb, var(--accent) 8%, var(--paper-2)));
728 color: var(--accent);
729}
730.tp-cover-empty svg { width: 18px; height: 18px; opacity: .8; }
731
732.tp-meta {
733 display: flex; flex-direction: column;
734 min-width: 0;
735 line-height: 1.25;
736}
737.tp-row-title {
738 font-weight: 600; font-size: .94rem;
739 color: var(--ink);
740 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
741}
742.tp-row-artist {
743 font-size: .8rem;
744 color: var(--ink-muted, var(--ink-soft));
745 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
746 margin-top: .1rem;
747}
748.tp-duration {
749 font-variant-numeric: tabular-nums;
750 font-size: .8rem;
751 color: var(--ink-muted, var(--ink-soft));
752 flex-shrink: 0;
753}
754
755/* Body lock when modal open */
756body.tp-locked { overflow: hidden; touch-action: none; }
757
758/* Desktop: centered card, max-width ~520px */
759@media (min-width: 640px) {
760 .tp-modal {
761 align-items: center;
762 padding: 1.5rem;
763 }
764 .tp-sheet {
765 margin-top: 0;
766 width: 100%;
767 max-width: 520px;
768 max-height: 80dvh;
769 border-radius: 14px;
770 border: 1px solid var(--rule);
771 animation: tp-sheet-pop .2s cubic-bezier(.2, .8, .25, 1);
772 }
773 .tp-header::before { display: none; } /* hide grab-handle on desktop */
774 .tp-header { padding: 1rem 1.25rem .75rem; }
775 .tp-h2 { margin-top: 0; font-size: 1.25rem; }
776 .tp-close { margin-top: 0; }
777 @keyframes tp-sheet-pop {
778 from { transform: translateY(-8px) scale(.97); opacity: 0; }
779 to { transform: translateY(0) scale(1); opacity: 1; }
780 }
781}
782
783/* ─── Buttons ───────────────────────────────────────────────────── */
784.pe-btn {
785 display: inline-flex; align-items: center; gap: 0.4rem;
786 padding: 0.55rem 1rem;
787 border: 1px solid var(--rule);
788 background: var(--paper-2);
789 color: var(--ink);
790 font-family: var(--font-ui, system-ui), sans-serif;
791 font-size: 0.9rem; font-weight: 500;
792 text-decoration: none;
793 border-radius: 6px;
794 cursor: pointer;
795 transition: background 120ms, border-color 120ms;
796 min-height: 40px;
797 -webkit-tap-highlight-color: transparent;
798}
799.pe-btn:hover { border-color: var(--accent); }
800.pe-btn:active { transform: scale(0.97); }
801.pe-btn-tiny {
802 padding: 0.35rem 0.7rem;
803 font-size: 0.85rem;
804 min-height: 32px;
805}
806.pe-btn-secondary { background: var(--paper-2); }
807.pe-btn-primary {
808 background: var(--accent); color: white;
809 border-color: var(--accent);
810}
811.pe-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
812.pe-btn-success {
813 background: #16a34a; color: white;
814 border-color: #16a34a;
815}
816.pe-btn-success:hover { opacity: 0.92; border-color: #16a34a; }
817
818.pe-status {
819 color: var(--ink-muted, var(--ink-soft));
820 font-size: 0.8rem;
821}
822.pe-status.is-error { color: #dc2626; }
823
824/* ─── Checkboxes (clean inline row) ─────────────────────────────── */
825.pe-checkboxes {
826 display: flex; flex-direction: column;
827 gap: 0.5rem;
828 padding-top: 0.25rem;
829}
830.pe-checkbox {
831 display: inline-flex; align-items: center; gap: 0.5rem;
832 cursor: pointer;
833 font-size: 0.95rem;
834 color: var(--ink);
835 user-select: none;
836}
837.pe-checkbox input[type="checkbox"] {
838 width: 18px; height: 18px;
839 margin: 0;
840 cursor: pointer;
841 accent-color: var(--accent);
842}
843
[edc462d]844/* Vastpinnen: checkbox + ▲▼-stepper met beschrijving (i.p.v. een ruw rang-getal). */
845.pe-pin { display: flex; flex-direction: column; gap: 0.45rem; }
846.pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; }
847.pe-pin-pos[hidden] { display: none; }
848.pe-pin-steps { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
849.pe-pin-btn {
850 width: 30px; height: 28px; padding: 0;
851 border: none; background: var(--paper-2); color: var(--ink);
852 cursor: pointer; font-size: 0.68rem; line-height: 1;
853 display: inline-flex; align-items: center; justify-content: center;
[7bc636b]854}
[edc462d]855.pe-pin-btn + .pe-pin-btn { border-left: 1px solid var(--rule); }
856.pe-pin-btn:hover { background: color-mix(in srgb, var(--accent) 14%, var(--paper-2)); color: var(--accent); }
857.pe-pin-btn:disabled { opacity: 0.35; cursor: default; }
858.pe-pin-label { font-size: 0.85rem; color: var(--accent); font-weight: 600; }
[7bc636b]859
860/* ─── Sticky action footer ──────────────────────────────────────── */
861.pe-actions {
862 position: sticky;
863 bottom: 0;
864 display: flex; align-items: center;
865 gap: 0.5rem;
866 padding: 0.85rem 1rem;
867 margin: 0.5rem -1rem 0; /* extend to viewport edges on tight container */
868 background: color-mix(in srgb, var(--paper) 94%, transparent);
869 -webkit-backdrop-filter: blur(8px);
870 backdrop-filter: blur(8px);
871 border-top: 1px solid var(--rule);
872 z-index: 10;
873}
874.pe-actions-spacer { flex: 1; }
875
[2beef82]876/* De editor is een focus-scherm: verberg de mobiele site-tabbalk (Home/Zoek/…)
877 zodat er niet twee balken onderaan stapelen (Opslaan-balk + tabbalk). De
878 Opslaan/Annuleren-balk wordt dan de enige onderbalk → gewoon op bottom:0,
879 geen tab-offset meer nodig. Op desktop is de tabbalk toch al verborgen. */
880body:has(.post-edit-page) .bottom-tab { display: none; }
881/* Audiospeler (indien aan het spelen) niet meer 56px optillen voor de nu-verborgen
882 tabbalk, anders zou die zweven boven de actiebalk. */
883body:has(.post-edit-page) .audio-player { bottom: 0; }
[7bc636b]884</style>
885
886<script>
887(function() {
888
889 // ── Cover upload ────────────────────────────────────────────────
890 const coverField = document.getElementById('cover-upload-field');
891 const coverTrigger = document.getElementById('cover-upload-trigger');
892 const coverUrl = document.getElementById('cover-url-field');
893 const coverStatus = document.getElementById('cover-upload-status');
894 const coverWrap = document.getElementById('cover-preview-wrap');
895 const coverImg = document.getElementById('cover-preview-img');
896
897 async function uploadImage(file) {
898 const fd = new FormData();
899 fd.append('image', file);
900 const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
901 if (!res.ok) {
902 const j = await res.json().catch(() => ({}));
903 throw new Error(j.error || ('Upload failed (' + res.status + ')'));
904 }
905 return await res.json(); // {url, size, mime}
906 }
907
908 function showCoverPreview(url) {
909 if (!coverWrap || !coverImg) return;
910 if (url) {
911 coverImg.src = url;
912 coverImg.hidden = false;
913 coverWrap.removeAttribute('data-empty');
914 const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
915 if (emptyIcon) emptyIcon.remove();
916 } else {
917 coverImg.hidden = true;
918 coverImg.src = '';
919 coverWrap.setAttribute('data-empty', '');
920 if (!coverWrap.querySelector('.pe-cover-empty')) {
921 const span = document.createElement('span');
922 span.className = 'pe-cover-empty';
923 span.textContent = '🖼';
924 coverWrap.appendChild(span);
925 }
926 }
927 }
928
929 if (coverTrigger && coverField) {
930 coverTrigger.addEventListener('click', () => coverField.click());
931 }
932 if (coverField) {
933 coverField.addEventListener('change', async () => {
934 if (!coverField.files[0]) return;
935 coverStatus.classList.remove('is-error');
936 coverStatus.textContent = 'Uploaden…';
937 try {
938 const j = await uploadImage(coverField.files[0]);
939 coverUrl.value = j.url;
940 showCoverPreview(j.url);
941 coverStatus.textContent = 'Geüpload ✓';
942 setTimeout(() => { coverStatus.textContent = ''; }, 2000);
943 } catch (e) {
944 coverStatus.classList.add('is-error');
945 coverStatus.textContent = 'Mislukt: ' + e.message;
946 }
947 });
948 }
949 // Live-update preview when user pastes a URL manually
950 if (coverUrl) {
951 coverUrl.addEventListener('input', () => {
952 const v = coverUrl.value.trim();
953 if (v) showCoverPreview(v); else showCoverPreview('');
954 });
955 }
956
957 // ── WYSIWYG editor (P58) ────────────────────────────────────────
958 // Architecture:
959 // - Visible <div contenteditable> (`#content-editor`) is what the user
960 // types in; it shows real HTML (formatted, not raw markup).
961 // - Hidden <input name="content"> (`#content-hidden`) is what submits.
962 // On submit we serialize the editor's HTML into it, with shortcode
963 // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
964 // - Initial content comes from a <script type="application/json"> tag
965 // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
966 // and walk text nodes to render shortcode tokens as chips.
967 const contentField = document.getElementById('content-upload-field');
968 const contentBtn = document.getElementById('insert-image-btn');
969 const contentStatus = document.getElementById('content-upload-status');
970 const editor = document.getElementById('content-editor');
971 const hiddenField = document.getElementById('content-hidden');
972 const charCountEl = document.getElementById('char-count');
973 const initialEl = document.getElementById('initial-content');
974 const toolbar = document.getElementById('pe-toolbar');
975 const form = editor && editor.closest('form');
976
977 if (!editor) return;
978
[a0bff44]979 // Titel alleen automatisch focussen op desktop (muis/trackpad). Op touch zou
980 // dit meteen het toetsenbord openen bij het openen van de editor — niet gewenst.
981 try {
982 const titleInput = form && form.querySelector('input[name="title"]');
983 if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
984 titleInput.focus({ preventScroll: true });
985 }
986 } catch (_) {}
987
[7bc636b]988 // ── Shortcode chip rendering / serialization ────────────────────
989 // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
990 // — but we DON'T want to chipify text the user is mid-typing inside an
991 // HTML attribute; since chipify only walks text nodes (never attribute
992 // values) that's already safe.
[1907a18]993 const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
[7bc636b]994
995 const SC_ICONS = {
996 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>',
997 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>',
998 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>',
[1907a18]999 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>',
[7bc636b]1000 };
1001
1002 function chipLabel(kind, value) {
1003 if (kind === 'track') {
1004 // UUIDs are noisy — show a 6-char prefix for visual hint
1005 const v = String(value || '');
1006 return 'Track ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
1007 }
1008 if (kind === 'album') return 'Album: ' + value;
1009 if (kind === 'playlist') return 'Playlist: ' + value;
[1907a18]1010 if (kind === 'embed') {
1011 const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
1012 return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
1013 }
[7bc636b]1014 return value;
1015 }
1016
1017 function makeChip(kind, value) {
1018 const span = document.createElement('span');
1019 span.className = 'sc-chip';
1020 span.contentEditable = 'false';
1021 span.setAttribute('data-sc', kind + ':' + value);
1022 span.innerHTML =
1023 '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
1024 '<span class="sc-chip-label"></span>';
1025 span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
1026 return span;
1027 }
1028
1029 // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
1030 function chipifyShortcodes(root) {
1031 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
1032 const targets = [];
1033 while (walker.nextNode()) {
1034 const n = walker.currentNode;
1035 // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
1036 if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
1037 if (SC_RE.test(n.nodeValue)) targets.push(n);
1038 SC_RE.lastIndex = 0;
1039 }
1040 for (const node of targets) {
1041 const txt = node.nodeValue;
1042 const frag = document.createDocumentFragment();
1043 let last = 0;
1044 let m;
1045 SC_RE.lastIndex = 0;
1046 while ((m = SC_RE.exec(txt)) !== null) {
1047 if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
1048 frag.appendChild(makeChip(m[1], m[2].trim()));
1049 last = m.index + m[0].length;
1050 }
1051 if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
1052 node.parentNode.replaceChild(frag, node);
1053 }
1054 }
1055
1056 // Inverse of chipify: clone the editor, replace every chip with its text.
1057 function serializeChips(rootClone) {
1058 const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
1059 for (const c of chips) {
1060 const txt = '[[' + c.getAttribute('data-sc') + ']]';
1061 c.replaceWith(document.createTextNode(txt));
1062 }
1063 }
1064
1065 // ── Boot: load initial content as HTML, then render shortcodes as chips
1066 try {
1067 const initial = JSON.parse(initialEl.textContent || '""');
1068 editor.innerHTML = initial || '';
1069 chipifyShortcodes(editor);
1070 } catch (e) {
1071 console.error('[editor] could not parse initial content', e);
1072 editor.innerHTML = '';
1073 }
1074
1075 // ── Char counter
1076 function updateCharCount() {
1077 const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
1078 if (charCountEl) charCountEl.textContent = String(text.length);
1079 }
1080 updateCharCount();
1081 editor.addEventListener('input', updateCharCount);
1082
1083 // ── Toolbar wiring
[cbfbf88]1084 // Houd de scrollpositie vast rond een edit-commando. execCommand/insert scrollt
1085 // standaard de caret in beeld → het beeld "verspringt" bij het aanklikken van een
[f8e8f9e]1086 // opmaakknop. We leggen ALLE scrollbare voorouders (editor, frame, #pcms-main, …)
1087 // + de pagina vast en zetten ze terug — sync én over een paar frames, want Chrome
1088 // scrollt soms pas een frame later. De gebruiker scrollt zo zelf.
1089 function scrollableAncestors(el) {
1090 const list = [];
1091 let node = el;
1092 while (node && node !== document.body && node !== document.documentElement) {
1093 const oy = getComputedStyle(node).overflowY;
1094 if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
1095 node = node.parentElement;
1096 }
1097 return list;
1098 }
[cbfbf88]1099 function keepScroll(fn) {
[f8e8f9e]1100 const wx = window.scrollX, wy = window.scrollY;
1101 const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
1102 const restore = function () {
1103 window.scrollTo(wx, wy);
1104 anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
1105 };
[cbfbf88]1106 fn();
[f8e8f9e]1107 restore();
[2d596cf]1108 // Mobiel Chrome scrollt de caret ASYNCHROON in beeld (ná meerdere frames), ook
1109 // bij een lege caret. Vang élke scroll de eerstvolgende ~350 ms af en zet 'm
1110 // terug; daarna mag de gebruiker weer vrij scrollen.
1111 const onScroll = function () { restore(); };
1112 window.addEventListener('scroll', onScroll, true);
1113 setTimeout(function () { window.removeEventListener('scroll', onScroll, true); }, 350);
[cbfbf88]1114 }
[7bc636b]1115 function execCmd(cmd, arg) {
[cbfbf88]1116 keepScroll(function () {
1117 editor.focus({ preventScroll: true });
1118 document.execCommand(cmd, false, arg);
1119 });
[7bc636b]1120 updateToolbarState();
1121 updateCharCount();
1122 }
1123 function wrapCode() {
1124 const sel = window.getSelection();
1125 if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
[cbfbf88]1126 keepScroll(function () {
1127 const range = sel.getRangeAt(0);
1128 const code = document.createElement('code');
1129 code.textContent = sel.toString();
1130 range.deleteContents();
1131 range.insertNode(code);
1132 // Move caret after the new node
1133 range.setStartAfter(code);
1134 range.collapse(true);
1135 sel.removeAllRanges();
1136 sel.addRange(range);
1137 editor.focus({ preventScroll: true });
1138 });
[7bc636b]1139 }
1140 function linkPrompt() {
1141 const url = window.prompt('Link URL (https://… of /pad)');
1142 if (!url) return;
1143 execCmd('createLink', url);
1144 }
[91d948c]1145 // De huidige selectie zit in een <blockquote> binnen de editor? Geef 'm terug.
1146 function blockquoteAncestor() {
1147 const sel = window.getSelection();
1148 if (!sel || sel.rangeCount === 0) return null;
1149 let node = sel.anchorNode;
1150 while (node && node !== editor) {
1151 if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
1152 node = node.parentNode;
1153 }
1154 return null;
1155 }
1156 // Echte toggle: execCommand('formatBlock','blockquote') zet 'm wél AAN maar
1157 // krijgt 'm nooit meer UIT (browser-quirk). Staat de caret al in een quote →
1158 // pak de wrapper uit; anders pas blockquote toe.
1159 function toggleBlockquote() {
[cbfbf88]1160 keepScroll(function () {
1161 editor.focus({ preventScroll: true });
1162 const bq = blockquoteAncestor();
1163 if (bq) {
1164 const parent = bq.parentNode;
1165 // Inhoud uit de quote halen, op z'n plek, en de lege wrapper verwijderen.
1166 const ref = bq;
1167 let firstMoved = null;
1168 while (bq.firstChild) {
1169 const child = bq.firstChild;
1170 if (!firstMoved) firstMoved = child;
1171 parent.insertBefore(child, ref);
1172 }
1173 parent.removeChild(bq);
1174 // Caret terugzetten in de uitgepakte inhoud.
1175 if (firstMoved) {
1176 const sel = window.getSelection();
1177 const range = document.createRange();
1178 range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
1179 range.collapse(false);
1180 sel.removeAllRanges();
1181 sel.addRange(range);
1182 }
1183 } else {
1184 document.execCommand('formatBlock', false, 'blockquote');
[91d948c]1185 }
[cbfbf88]1186 });
[91d948c]1187 updateToolbarState();
1188 updateCharCount();
1189 }
[7bc636b]1190
1191 if (toolbar) {
[a7e8e19]1192 // CRUCIAAL (mobiel + desktop): voorkom dat een toolbar-knop de focus/selectie
1193 // uit het editor-veld steelt. Zonder dit raakt de selectie kwijt bij het tikken
1194 // → execCommand werkt op een lege selectie (vet kan niet meer UIT) én de browser
1195 // scrollt de caret opnieuw in beeld (de "sprong naar beneden"). preventDefault op
1196 // mousedown houdt de focus in de editor; de click blijft gewoon vuren.
1197 toolbar.addEventListener('mousedown', (e) => {
1198 if (e.target.closest('button')) e.preventDefault();
1199 });
[7bc636b]1200 toolbar.addEventListener('click', (e) => {
1201 const btn = e.target.closest('button[data-cmd]');
1202 if (!btn) return;
1203 e.preventDefault();
1204 const cmd = btn.dataset.cmd;
1205 const arg = btn.dataset.arg || null;
1206 if (cmd === 'link-prompt') linkPrompt();
1207 else if (cmd === 'code-wrap') wrapCode();
[91d948c]1208 else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
[7bc636b]1209 else execCmd(cmd, arg);
1210 });
1211 }
1212
[d2078f4]1213 // ── Volledig-scherm schrijfmodus: het schrijfveld vult de hele pagina.
1214 const fsBtn = document.getElementById('pe-fullscreen-btn');
1215 const editorFrame = document.querySelector('.pe-editor-frame');
[06f3791]1216
1217 // Op mobiel duwt het toetsenbord de zichtbare (visual) viewport omhoog terwijl
1218 // een position:fixed-frame aan de LAYOUT-viewport blijft hangen → de toolbar
1219 // schuift uit beeld. Houd het fullscreen-frame daarom gelijk aan de visual
1220 // viewport (top + hoogte), zodat de toolbar altijd bovenaan zichtbaar blijft.
1221 function syncFsViewport() {
1222 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
1223 const vv = window.visualViewport;
1224 if (!vv) return;
1225 editorFrame.style.top = vv.offsetTop + 'px';
1226 editorFrame.style.height = vv.height + 'px';
1227 }
1228 function clearFsViewport() {
1229 if (!editorFrame) return;
1230 editorFrame.style.top = '';
1231 editorFrame.style.height = '';
1232 }
[d2078f4]1233 function toggleFullscreen() {
1234 if (!editorFrame) return;
1235 const on = editorFrame.classList.toggle('pe-fs');
1236 document.body.classList.toggle('pe-fs-open', on);
1237 if (fsBtn) {
1238 fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
1239 fsBtn.title = on ? 'Verklein' : 'Volledig scherm';
1240 }
[06f3791]1241 if (window.visualViewport) {
1242 if (on) {
1243 window.visualViewport.addEventListener('resize', syncFsViewport);
1244 window.visualViewport.addEventListener('scroll', syncFsViewport);
1245 syncFsViewport();
1246 } else {
1247 window.visualViewport.removeEventListener('resize', syncFsViewport);
1248 window.visualViewport.removeEventListener('scroll', syncFsViewport);
1249 clearFsViewport();
1250 }
1251 }
[d2078f4]1252 if (on) editor.focus({ preventScroll: true });
1253 }
1254 if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
1255 document.addEventListener('keydown', (e) => {
1256 if (e.key === 'Escape' && editorFrame && editorFrame.classList.contains('pe-fs')) {
1257 e.preventDefault();
1258 toggleFullscreen();
1259 }
1260 });
1261
[7bc636b]1262 // Reflect bold/italic/list state on the toolbar buttons
1263 function updateToolbarState() {
1264 if (!toolbar) return;
1265 const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
1266 for (const cmd of cmds) {
1267 const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
1268 if (!btn) continue;
1269 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
1270 }
[91d948c]1271 // Quote-knop: actief als de caret in een <blockquote> staat (toggle-feedback).
1272 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
1273 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
[7bc636b]1274 }
1275 document.addEventListener('selectionchange', () => {
1276 if (document.activeElement === editor) updateToolbarState();
1277 });
1278
1279 // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
1280 editor.addEventListener('keydown', (e) => {
1281 const mod = e.ctrlKey || e.metaKey;
1282 if (!mod) return;
1283 const k = e.key.toLowerCase();
1284 if (k === 'b') { e.preventDefault(); execCmd('bold'); }
1285 else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
1286 else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
1287 else if (k === 'k') { e.preventDefault(); linkPrompt(); }
1288 });
1289
1290 // Paste: keep it simple — strip formatting unless user wants it. Default
1291 // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
1292 // styles from clipboard only when shift is held — otherwise plain text.
1293 editor.addEventListener('paste', (e) => {
1294 if (e.shiftKey) return; // user wants formatted paste
1295 const text = (e.clipboardData || window.clipboardData).getData('text/plain');
1296 if (text == null) return;
1297 e.preventDefault();
1298 document.execCommand('insertText', false, text);
1299 });
1300
1301 // ── Image upload (button + drag-drop into the editor)
1302 async function uploadAndInsertImage(file) {
1303 contentStatus.classList.remove('is-error');
1304 contentStatus.textContent = 'Uploaden…';
1305 try {
1306 const j = await uploadImage(file);
1307 const img = '<img src="' + j.url + '" alt="">';
[a7e8e19]1308 editor.focus({ preventScroll: true });
[7bc636b]1309 document.execCommand('insertHTML', false, img);
1310 contentStatus.textContent = 'Ingevoegd ✓';
1311 setTimeout(() => { contentStatus.textContent = ''; }, 2000);
1312 updateCharCount();
1313 } catch (e) {
1314 contentStatus.classList.add('is-error');
1315 contentStatus.textContent = 'Mislukt: ' + e.message;
1316 }
1317 }
1318
1319 if (contentBtn && contentField) {
1320 contentBtn.addEventListener('click', () => contentField.click());
1321 contentField.addEventListener('change', () => {
1322 if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
1323 contentField.value = '';
1324 });
1325
1326 editor.addEventListener('dragover', (e) => {
1327 if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
1328 e.preventDefault();
1329 editor.classList.add('is-dragover');
1330 }
1331 });
1332 editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
1333 editor.addEventListener('drop', async (e) => {
1334 editor.classList.remove('is-dragover');
1335 const files = e.dataTransfer && e.dataTransfer.files;
1336 if (!files || !files.length) return;
1337 e.preventDefault();
1338 for (const f of files) {
1339 if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
1340 }
1341 });
1342 }
1343
1344 // ── Insert chip helpers (track / playlist)
1345 function insertChip(kind, value) {
[a7e8e19]1346 editor.focus({ preventScroll: true });
[7bc636b]1347 const chip = makeChip(kind, value);
1348 // Insert at caret using the Selection API (execCommand insertNode)
1349 const sel = window.getSelection();
1350 if (sel && sel.rangeCount > 0) {
1351 const range = sel.getRangeAt(0);
1352 range.deleteContents();
1353 range.insertNode(chip);
1354 // Insert a trailing space so the user can keep typing after the chip
1355 const space = document.createTextNode('\u00A0');
1356 chip.after(space);
1357 range.setStartAfter(space);
1358 range.collapse(true);
1359 sel.removeAllRanges();
1360 sel.addRange(range);
1361 } else {
1362 editor.appendChild(chip);
1363 editor.appendChild(document.createTextNode('\u00A0'));
1364 }
1365 updateCharCount();
1366 }
1367
[1907a18]1368 // ── Embed insert: plak een platform-URL -> [[embed:url]]-chip die server-side
1369 // een iframe wordt (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
1370 const embedBtn = document.getElementById('insert-embed-btn');
1371 if (embedBtn) {
1372 embedBtn.addEventListener('click', () => {
1373 const raw = window.prompt('Plak een media-URL om in te sluiten\n(YouTube, Spotify, SoundCloud, Vimeo, Apple Music, Bandcamp):');
1374 if (!raw) return;
1375 const url = raw.trim();
1376 if (!/^https?:\/\//i.test(url)) { alert('Geef een volledige URL (https://…).'); return; }
1377 insertChip('embed', url);
1378 });
1379 }
1380
[7bc636b]1381 // ── Track insert: opens the track-picker modal (P59)
1382 const trackBtn = document.getElementById('insert-track-btn');
1383 const trackPicker = document.getElementById('track-picker');
1384 if (trackBtn && trackPicker) {
1385 const tpList = document.getElementById('tp-list');
1386 const tpEmpty = document.getElementById('tp-empty');
1387 const tpSearch = document.getElementById('tp-search');
1388 let tpCache = null; // cached tracks list (fetched once per page load)
1389 let tpLastFocus = null; // element to restore focus to on close
1390
1391 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>';
1392
1393 function fmtDur(sec) {
1394 sec = Math.max(0, Math.floor(sec || 0));
1395 const m = Math.floor(sec / 60), s = sec % 60;
1396 return m + ':' + String(s).padStart(2, '0');
1397 }
1398 function escAttr(s) {
1399 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
1400 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
1401 }[c]));
1402 }
1403
1404 function renderList(filter) {
1405 if (!Array.isArray(tpCache)) return;
1406 const q = (filter || '').trim().toLowerCase();
1407 const filtered = q
1408 ? tpCache.filter(t =>
1409 (t.title || '').toLowerCase().includes(q) ||
1410 (t.artist || '').toLowerCase().includes(q))
1411 : tpCache;
1412
1413 if (!filtered.length) {
1414 tpList.innerHTML = '';
1415 tpEmpty.textContent = q ? 'Geen tracks gevonden voor "' + q + '"' : 'Nog geen tracks. Upload via Beheer → Audio.';
1416 tpList.appendChild(tpEmpty);
1417 return;
1418 }
1419
1420 tpList.innerHTML = filtered.map(t => {
1421 const cov = t.cover
1422 ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
1423 : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
1424 const dis = t.playable ? '' : ' aria-disabled="true"';
1425 const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
1426 return (
1427 '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
1428 cov +
1429 '<span class="tp-meta">' +
1430 '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
1431 sub +
1432 '</span>' +
1433 '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
1434 '</button>'
1435 );
1436 }).join('');
1437 }
1438
1439 async function loadTracks() {
1440 if (Array.isArray(tpCache)) return tpCache;
1441 tpEmpty.textContent = 'Tracks laden…';
1442 try {
1443 const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
1444 const j = await r.json();
1445 tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
1446 } catch (e) {
1447 tpCache = [];
1448 tpEmpty.textContent = 'Kon tracks niet laden: ' + e.message;
1449 }
1450 return tpCache;
1451 }
1452
1453 function openPicker() {
1454 tpLastFocus = document.activeElement;
1455 trackPicker.hidden = false;
1456 trackPicker.setAttribute('aria-hidden', 'false');
1457 document.body.classList.add('tp-locked');
1458 tpSearch.value = '';
1459 renderList('');
1460 // Defer focus so the open animation doesn't get jumped
1461 setTimeout(() => tpSearch.focus(), 30);
1462 }
1463 function closePicker() {
1464 trackPicker.hidden = true;
1465 trackPicker.setAttribute('aria-hidden', 'true');
1466 document.body.classList.remove('tp-locked');
1467 if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
1468 try { tpLastFocus.focus(); } catch(_) {}
1469 }
1470 }
1471
1472 trackBtn.addEventListener('click', async () => {
1473 openPicker();
1474 await loadTracks();
1475 renderList(tpSearch.value);
1476 });
1477
1478 // Close: backdrop click, [data-tp-close], or Escape
1479 trackPicker.addEventListener('click', (e) => {
1480 if (e.target.closest('[data-tp-close]')) {
1481 closePicker();
1482 return;
1483 }
1484 const row = e.target.closest('.tp-row[data-track-id]');
1485 if (row) {
1486 if (row.getAttribute('aria-disabled') === 'true') return;
1487 const id = row.dataset.trackId;
1488 if (id) {
1489 insertChip('track', id);
1490 closePicker();
1491 }
1492 }
1493 });
1494 document.addEventListener('keydown', (e) => {
1495 if (!trackPicker.hidden && e.key === 'Escape') {
1496 e.preventDefault();
1497 closePicker();
1498 }
1499 });
1500
1501 // Live filter
1502 tpSearch.addEventListener('input', () => renderList(tpSearch.value));
1503 }
1504
1505 // ── Playlist insert (open existing or create new via modal)
1506 const playlistBtn = document.getElementById('insert-playlist-btn');
1507 if (playlistBtn) {
1508 playlistBtn.addEventListener('click', async () => {
1509 if (typeof window.openPlaylistEditor !== 'function') {
1510 alert('Playlist editor niet geladen');
1511 return;
1512 }
1513 try {
1514 const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
1515 const j = await r.json();
1516 if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
1517 const choice = prompt(
1518 'Bestaande playlists:\n\n' +
1519 j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
1520 '\n\nKies een nummer om in te voegen, leeg = nieuwe maken:'
1521 );
1522 if (choice && /^\d+$/.test(choice.trim())) {
1523 const idx = parseInt(choice.trim(), 10) - 1;
1524 if (idx >= 0 && idx < j.playlists.length) {
1525 insertChip('playlist', j.playlists[idx].id);
1526 return;
1527 }
1528 }
1529 if (choice === null) return;
1530 }
1531 } catch (_) { /* fall through to create */ }
1532
1533 window.openPlaylistEditor({
1534 mode: 'create',
1535 onSaved: ({ id }) => insertChip('playlist', id),
1536 });
1537 });
1538 }
1539
1540 // ── Submit: serialize editor contents into the hidden field
1541 if (form && hiddenField) {
1542 form.addEventListener('submit', () => {
1543 const clone = editor.cloneNode(true);
1544 serializeChips(clone);
1545 hiddenField.value = clone.innerHTML;
1546 });
1547 }
1548})();
1549</script>
1550
1551<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
1552 viewports, centered modal on ≥640px. Populated lazily on first open. %>
1553<% if (typeof user !== 'undefined' && user) { %>
1554<div id="track-picker" class="tp-modal" hidden aria-hidden="true">
1555 <div class="tp-backdrop" data-tp-close></div>
1556 <div class="tp-sheet" role="dialog" aria-modal="true" aria-labelledby="tp-title">
1557 <header class="tp-header">
1558 <h2 id="tp-title" class="tp-h2">Track invoegen</h2>
1559 <button type="button" class="tp-close" data-tp-close aria-label="Sluiten">
1560 <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>
1561 </button>
1562 </header>
1563 <div class="tp-search-row">
1564 <span class="tp-search-icon" aria-hidden="true">
1565 <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>
1566 </span>
1567 <input type="search" class="tp-search" id="tp-search" placeholder="Zoek op titel of artiest…" autocomplete="off" inputmode="search">
1568 </div>
1569 <div class="tp-list" id="tp-list" role="listbox" aria-label="Tracks">
1570 <div class="tp-empty" id="tp-empty">Tracks laden…</div>
1571 </div>
1572 </div>
1573</div>
1574<% } %>
1575
1576<%# Playlist editor modal — included so its global window.openPlaylistEditor
1577 is available when the toolbar button fires. Only renders if user can post. %>
1578<% if (typeof user !== 'undefined' && user) { %>
1579 <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
1580<% } %>
[edc462d]1581
1582<script>
1583(function () {
1584 // Vastpinnen: checkbox toggelt het verborgen rang-veld (0 = niet gepind),
1585 // ▲▼ verschuift de plek, met een leesbare beschrijving i.p.v. een ruw getal.
1586 var toggle = document.getElementById('pin-toggle');
1587 var rank = document.getElementById('pin-rank');
1588 var pos = document.getElementById('pin-pos');
1589 var label = document.getElementById('pin-label');
1590 var up = document.getElementById('pin-up'); // hoger = lager getal (richting 1/bovenaan)
1591 var down = document.getElementById('pin-down');
1592 if (!toggle || !rank || !pos) return;
1593
1594 function descr(n) {
1595 n = Number(n) || 0;
1596 if (n <= 1) return 'bovenaan';
1597 return n + 'e van boven';
1598 }
1599 function render() {
1600 var on = toggle.checked;
1601 pos.hidden = !on;
1602 if (on && Number(rank.value) < 1) rank.value = 1;
1603 if (!on) rank.value = 0;
1604 if (label) label.textContent = on ? descr(rank.value) : '';
1605 if (up) up.disabled = Number(rank.value) <= 1;
1606 }
1607 toggle.addEventListener('change', render);
1608 if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
1609 if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
1610 render();
1611})();
[d3e2987]1612
1613(function () {
[5f7dba2]1614 // De Opslaan/Annuleren-balk (position: sticky; bottom:0) net boven twee mogelijke
1615 // obstakels houden door een dynamische bottom-offset te zetten = het grootste van:
1616 // 1) de hoogte van het toetsenbord-deel dat de layout-viewport NIET dekt
1617 // (op iOS verschuift de visual viewport; op Android verkleint de layout-
1618 // viewport door interactive-widget=resizes-content → offset ≈ 0);
1619 // 2) de hoogte van de spelende audiospeler (fixed, z-index 1000).
1620 // We blijven bij sticky (geen fixed/top-gegoochel → geen balk midden in beeld).
[d3e2987]1621 var bar = document.querySelector('.pe-actions');
1622 if (!bar) return;
[408afc1]1623 var vv = window.visualViewport;
[5f7dba2]1624 function position() {
[d3e2987]1625 var ap = document.querySelector('.audio-player');
1626 var playing = document.body.classList.contains('has-audio-player') &&
1627 ap && getComputedStyle(ap).display !== 'none';
[5f7dba2]1628 var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
1629 var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
1630 var offset = Math.max(audioOffset, kbCovered);
1631 bar.style.bottom = offset ? offset + 'px' : '';
[408afc1]1632 }
1633 position();
1634 window.addEventListener('resize', position);
1635 if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
[5f7dba2]1636 // has-audio-player wisselt via een body-class → daarop reageren.
[408afc1]1637 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
[d3e2987]1638})();
[edc462d]1639</script>
Note: See TracBrowser for help on using the repository browser.