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

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

fix: cover URL field accepts relative /media/ paths (type=url -> text)

<input type='url'> required an absolute URL, but uploads return a relative
path (/media/post-images/...). This caused saving to fail with 'Please enter
a URL'. Now type=text (inputmode=url) — accepts both /media/... and https://...
Same fix in the track editor cover URL.

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

  • Property mode set to 100644
File size: 52.3 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;
11%>
12<div class="container post-edit-page">
13 <h1><%= isNew ? 'Nieuwe post' : 'Post bewerken' %></h1>
14
15 <form method="post" action="<%= isNew ? '/posts/create' : '/posts/' + post.slug + '/save' %>" class="post-edit-form">
16
17 <%# ── IDENTITY ─────────────────────────────────────────────── %>
18 <section class="pe-card">
19 <label class="pe-field">
20 <span>Titel</span>
21 <input type="text" name="title" value="<%= post.title %>" required autofocus>
22 </label>
23 <div class="pe-row pe-row-2">
24 <label class="pe-field">
25 <span>Slug (URL)</span>
26 <input type="text" name="slug" value="<%= post.slug %>" placeholder="auto van titel als leeg">
27 </label>
28 <label class="pe-field">
29 <span>Tags <small>(komma-gescheiden)</small></span>
30 <input type="text" name="tags" value="<%= Array.isArray(post.tags) ? post.tags.join(', ') : '' %>">
31 </label>
32 </div>
33 <label class="pe-field">
34 <span>Excerpt <small>(preview tekst)</small></span>
35 <textarea name="excerpt" rows="2"><%= post.excerpt %></textarea>
36 </label>
37 </section>
38
39 <%# ── COVER ────────────────────────────────────────────────── %>
40 <section class="pe-card">
41 <div class="pe-section-title">Cover</div>
42 <div class="pe-cover-row">
43 <div class="pe-cover-thumb" id="cover-preview-wrap" <%= _hasCover ? '' : 'data-empty' %>>
44 <img src="<%= _hasCover ? post.cover_image_url : '' %>" alt="" id="cover-preview-img" <%= _hasCover ? '' : 'hidden' %>>
45 <% if (!_hasCover) { %><span class="pe-cover-empty">🖼</span><% } %>
46 </div>
47 <div class="pe-cover-actions">
48 <label class="pe-field">
49 <span>Cover URL</span>
[b50a885]50 <input type="text" name="cover_image_url" id="cover-url-field"
[7bc636b]51 value="<%= post.cover_image_url || '' %>"
[b50a885]52 inputmode="url" autocapitalize="none" spellcheck="false"
53 placeholder="/media/… of https://… (of upload met de knop)">
[7bc636b]54 </label>
55 <div class="pe-cover-upload">
56 <button type="button" class="pe-btn pe-btn-secondary" id="cover-upload-trigger">
57 📷 Upload nieuwe cover
58 </button>
59 <input type="file" id="cover-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
60 <small id="cover-upload-status" class="pe-status"></small>
61 </div>
62 </div>
63 </div>
64 </section>
65
66 <%# ── CONTENT ──────────────────────────────────────────────── %>
67 <section class="pe-card pe-card-content">
68 <div class="pe-section-title">
69 Content
70 <small>Sleep een afbeelding om in te voegen · selecteer tekst om op te maken</small>
71 </div>
72 <div class="pe-editor-frame">
73 <div class="pe-toolbar" id="pe-toolbar" role="toolbar" aria-label="Format">
74 <button type="button" data-cmd="bold" title="Vet (Ctrl+B)" aria-label="Vet">
75 <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>
76 </button>
77 <button type="button" data-cmd="italic" title="Cursief (Ctrl+I)" aria-label="Cursief">
78 <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>
79 </button>
80 <button type="button" data-cmd="underline" title="Onderstreept" aria-label="Onderstreept">
81 <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>
82 </button>
83 <span class="pe-toolbar-sep" aria-hidden="true"></span>
84 <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h2" title="Kop">H2</button>
85 <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h3" title="Subkop">H3</button>
86 <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="p" title="Paragraaf">¶</button>
87 <span class="pe-toolbar-sep" aria-hidden="true"></span>
88 <button type="button" data-cmd="insertUnorderedList" title="Lijst" aria-label="Lijst">
89 <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>
90 </button>
91 <button type="button" data-cmd="insertOrderedList" title="Genummerde lijst" aria-label="Genummerde lijst">
92 <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>
93 </button>
94 <button type="button" data-cmd="formatBlock" data-arg="blockquote" title="Quote" aria-label="Quote">
95 <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>
96 </button>
97 <button type="button" data-cmd="link-prompt" title="Link (Ctrl+K)" aria-label="Link">
98 <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>
99 </button>
100 <button type="button" data-cmd="code-wrap" title="Code (inline)" aria-label="Code">
101 <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>
102 </button>
103 <span class="pe-toolbar-sep" aria-hidden="true"></span>
104 <button type="button" id="insert-image-btn" title="Afbeelding invoegen" aria-label="Afbeelding">
105 <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>
106 </button>
107 <button type="button" id="insert-track-btn" title="Track invoegen" aria-label="Track">
108 <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>
109 </button>
110 <button type="button" id="insert-playlist-btn" title="Playlist invoegen" aria-label="Playlist">
111 <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>
112 </button>
113 <span class="pe-toolbar-spacer"></span>
114 <button type="button" data-cmd="removeFormat" title="Wis opmaak" aria-label="Wis opmaak">
115 <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>
116 </button>
117 </div>
118
119 <div id="content-editor"
120 class="pe-editor"
121 contenteditable="true"
122 role="textbox"
123 aria-multiline="true"
124 aria-label="Content"
125 data-placeholder="Begin met schrijven…"></div>
126
127 <%# Hidden field is what actually submits to the server. The visible
128 contenteditable's serialized HTML is copied here on submit. %>
129 <input type="hidden" name="content" id="content-hidden" value="">
130
131 <%# Initial content is injected as a JSON string in a script tag so we
132 can set innerHTML safely from JS. EJS-escaping HTML directly into
133 the editor would render entity-escaped tags as text. %>
134 <script id="initial-content" type="application/json"><%- JSON.stringify(post.content || '').replace(/</g, '\\u003c') %></script>
135
136 <div class="pe-editor-status">
137 <small id="content-upload-status" class="pe-status"></small>
138 <span class="pe-char-count"><span id="char-count">0</span> tekens</span>
139 </div>
140
141 <input type="file" id="content-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
142 </div>
143 </section>
144
145 <%# ── META ─────────────────────────────────────────────────── %>
146 <section class="pe-card">
147 <div class="pe-section-title">Publicatie</div>
148 <div class="pe-row pe-row-2">
149 <label class="pe-field">
150 <span>Status</span>
151 <select name="status">
152 <option value="draft" <%= post.status === 'draft' ? 'selected' : '' %>>Draft</option>
153 <option value="published" <%= post.status === 'published' ? 'selected' : '' %>>Published</option>
154 <option value="archived" <%= post.status === 'archived' ? 'selected' : '' %>>Archived</option>
155 </select>
156 </label>
157 <label class="pe-field">
158 <span>Type</span>
159 <select name="type">
160 <% var ptype = post.type || 'post'; %>
161 <option value="post" <%= ptype === 'post' ? 'selected' : '' %>>Post</option>
162 <option value="foto" <%= ptype === 'foto' ? 'selected' : '' %>>Foto</option>
163 <option value="video" <%= ptype === 'video' ? 'selected' : '' %>>Video</option>
164 <option value="audio" <%= ptype === 'audio' ? 'selected' : '' %>>Audio</option>
165 </select>
166 </label>
167 </div>
168 <div class="pe-checkboxes">
169 <label class="pe-pinned-rank">
170 <span>📌 Pinned rank</span>
171 <input type="number" name="pinned" min="0" step="1"
172 inputmode="numeric" pattern="[0-9]*"
173 value="<%= post.pinned || 0 %>"
174 title="0 = niet pinned. 1 = bovenaan, 2 = daaronder, etc.">
175 <small>0 = niet pinned · 1 = bovenaan · 2 daaronder · …</small>
176 </label>
177 <label class="pe-checkbox">
178 <input type="checkbox" name="noindex" value="1" <%= post.noindex ? 'checked' : '' %>>
179 <span>🚫 noindex (verberg voor zoekmachines)</span>
180 </label>
181 </div>
182 </section>
183
184 <%# ── ACTIONS (sticky at bottom) ──────────────────────────── %>
185 <div class="pe-actions">
186 <a href="<%= isNew ? '/' : '/' + post.slug %>" class="pe-btn">Annuleren</a>
187 <div class="pe-actions-spacer"></div>
188 <% if (!isNew && post.status !== 'published') { %>
189 <button type="submit" name="action" value="publish" class="pe-btn pe-btn-success">📤 Publish</button>
190 <% } %>
191 <button type="submit" class="pe-btn pe-btn-primary">💾 Opslaan</button>
192 </div>
193 </form>
194</div>
195
196<style>
197/* ─── Page wrapper ──────────────────────────────────────────────── */
198.post-edit-page {
199 max-width: 880px;
200 margin: 1.5rem auto 6rem; /* extra bottom for sticky-actions clearance */
201 padding: 0 1rem;
202}
203.post-edit-page h1 {
204 font-family: var(--font-display, serif);
205 margin: 0 0 1.25rem;
206 font-size: 1.75rem;
207}
208
209.post-edit-form {
210 display: flex; flex-direction: column;
211 gap: 1rem;
212}
213
214/* ─── Card sections ─────────────────────────────────────────────── */
215.pe-card {
216 background: var(--paper);
217 border: 1px solid var(--rule);
218 border-radius: 12px;
219 padding: 1.25rem;
220 display: flex; flex-direction: column;
221 gap: 0.85rem;
222}
223.pe-card-content { padding-bottom: 0; overflow: hidden; } /* editor flush to bottom */
224.pe-section-title {
225 font-size: 0.8rem;
226 font-weight: 600;
227 text-transform: uppercase;
228 letter-spacing: 0.05em;
229 color: var(--ink-soft);
230 display: flex; align-items: baseline; gap: 0.5rem;
231 flex-wrap: wrap;
232}
233.pe-section-title small {
234 font-size: 0.75rem; font-weight: 400;
235 letter-spacing: 0; text-transform: none;
236 color: var(--ink-muted, var(--ink-soft));
237}
238
239/* ─── Field primitives ──────────────────────────────────────────── */
240.pe-field {
241 display: flex; flex-direction: column;
242 gap: 0.35rem;
243 min-width: 0; /* allow grid items to shrink */
244}
245.pe-field > span {
246 font-size: 0.8rem;
247 font-weight: 600;
248 color: var(--ink-soft);
249 display: flex; align-items: baseline; gap: 0.4rem;
250}
251.pe-field > span small {
252 font-weight: 400; color: var(--ink-muted);
253}
254.pe-field input,
255.pe-field textarea,
256.pe-field select {
257 width: 100%;
258 box-sizing: border-box;
259 display: block;
260 padding: 0.6rem 0.75rem;
261 border: 1px solid var(--rule);
262 border-radius: 6px;
263 background: var(--paper-2);
264 color: var(--ink);
265 font-family: var(--font-ui, system-ui), sans-serif;
266 font-size: 0.95rem;
267 -webkit-appearance: none;
268 appearance: none;
269 transition: border-color 120ms;
270}
271.pe-field input:focus,
272.pe-field textarea:focus,
273.pe-field select:focus {
274 outline: 2px solid var(--accent);
275 outline-offset: -1px;
276 border-color: var(--accent);
277}
278.pe-field textarea {
279 font-family: var(--font-mono, monospace);
280 resize: vertical;
281}
282.pe-field select {
283 /* Restore the dropdown arrow we removed with appearance:none */
284 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>");
285 background-repeat: no-repeat;
286 background-position: right 0.75rem center;
287 padding-right: 2rem;
288}
289
290.pe-row {
291 display: grid;
292 gap: 0.75rem;
293}
294.pe-row-2 { grid-template-columns: 1fr 1fr; }
295@media (max-width: 600px) {
296 .pe-row-2 { grid-template-columns: 1fr; }
297}
298
299/* ─── Cover field ───────────────────────────────────────────────── */
300.pe-cover-row {
301 display: grid;
302 grid-template-columns: 120px 1fr;
303 gap: 1rem;
304 align-items: start;
305}
306.pe-cover-thumb {
307 width: 120px; height: 120px;
308 border-radius: 10px;
309 overflow: hidden;
310 background: var(--paper-2);
311 border: 1px solid var(--rule);
312 display: flex; align-items: center; justify-content: center;
313 position: relative;
314}
315.pe-cover-thumb[data-empty] {
316 border-style: dashed;
317}
318.pe-cover-thumb img {
319 width: 100%; height: 100%;
320 object-fit: cover; display: block;
321}
322/* Honor HTML hidden — our display:block above otherwise renders an empty
323 broken-image icon when no cover is set. */
324.pe-cover-thumb img[hidden] { display: none; }
325.pe-cover-empty {
326 font-size: 2rem;
327 color: var(--ink-muted, var(--ink-soft));
328 opacity: 0.5;
329}
330.pe-cover-actions {
331 display: flex; flex-direction: column;
332 gap: 0.6rem;
333 min-width: 0;
334}
335.pe-cover-upload {
336 display: flex; align-items: center; gap: 0.6rem;
337 flex-wrap: wrap;
338}
339@media (max-width: 600px) {
340 .pe-cover-row { grid-template-columns: 88px 1fr; }
341 .pe-cover-thumb { width: 88px; height: 88px; }
342}
343
344/* ─── Editor frame (WYSIWYG: top toolbar, contenteditable body, status bar) ─── */
345.pe-editor-frame {
346 margin: 0 -1.25rem -1.25rem; /* break out of card padding for flush edges */
347 border-top: 1px solid var(--rule);
348 background: var(--paper);
349 display: flex; flex-direction: column;
350}
351
352/* Top toolbar — sticks to the top of the viewport while editing */
353.pe-toolbar {
354 display: flex; align-items: center; gap: .15rem;
355 flex-wrap: wrap;
356 padding: .4rem .75rem;
357 background: color-mix(in srgb, var(--paper) 92%, transparent);
358 backdrop-filter: blur(10px);
359 -webkit-backdrop-filter: blur(10px);
360 border-bottom: 1px solid var(--rule);
361 position: sticky;
362 top: 0;
363 z-index: 10;
364}
365.pe-toolbar button {
366 display: inline-flex; align-items: center; justify-content: center;
367 width: 32px; height: 32px;
368 padding: 0;
369 background: transparent;
370 border: 1px solid transparent;
371 border-radius: 6px;
372 color: var(--ink);
373 cursor: pointer;
374 font-family: var(--font-ui, system-ui), sans-serif;
375 font-weight: 600; font-size: .85rem;
376 transition: background var(--transition), color var(--transition), border-color var(--transition);
377 -webkit-tap-highlight-color: transparent;
378}
379.pe-toolbar button:hover {
380 background: var(--paper-2);
381 color: var(--accent);
382}
383.pe-toolbar button:active { transform: scale(.94); }
384.pe-toolbar button.is-active {
385 background: color-mix(in srgb, var(--accent) 14%, var(--paper-2));
386 color: var(--accent);
387 border-color: color-mix(in srgb, var(--accent) 30%, transparent);
388}
389.pe-toolbar button svg { width: 16px; height: 16px; }
390.pe-toolbar button.pe-tb-text { font-family: var(--font-display, serif); font-weight: 700; }
391.pe-toolbar-sep {
392 width: 1px; height: 18px;
393 background: var(--rule);
394 margin: 0 .35rem;
395 flex-shrink: 0;
396}
397.pe-toolbar-spacer { flex: 1; }
398
399/* Editor body — looks like prose, behaves like a textarea */
400.pe-editor {
401 width: 100%;
402 box-sizing: border-box;
403 min-height: 420px;
404 padding: 1.25rem 1.5rem;
405 border: 0;
406 background: transparent;
407 color: var(--ink);
408 font-family: var(--font-body, system-ui), serif;
409 font-size: 1.0625rem;
410 line-height: 1.65;
411 outline: none;
412 overflow-y: auto;
413}
414.pe-editor:focus { outline: none; }
415.pe-editor.is-dragover {
416 outline: 2px dashed var(--accent);
417 outline-offset: -10px;
418}
419.pe-editor[data-placeholder]:empty::before,
420.pe-editor[data-placeholder]:has(br:only-child)::before {
421 content: attr(data-placeholder);
422 color: var(--ink-muted, #999);
423 pointer-events: none;
424 opacity: .55;
425}
426/* Inline prose styles inside the editor — match the public post styling so
427 what you see is roughly what you'll get. Margins are tighter than on the
428 live site since this is a confined writing space. */
429.pe-editor h1, .pe-editor h2, .pe-editor h3, .pe-editor h4 {
430 font-family: var(--font-display, serif);
431 line-height: 1.2;
432 margin: 1.1rem 0 .35rem;
433}
434.pe-editor h1 { font-size: 1.85rem; }
435.pe-editor h2 { font-size: 1.45rem; }
436.pe-editor h3 { font-size: 1.2rem; }
437.pe-editor p { margin: 0 0 .85em; }
438.pe-editor ul, .pe-editor ol { margin: 0 0 .85em 1.4em; padding: 0; }
439.pe-editor li { margin: .15em 0; }
440.pe-editor blockquote {
441 margin: 1em 0;
442 padding: .15em 0 .15em 1em;
443 border-left: 3px solid var(--accent);
444 color: var(--ink-soft, var(--ink));
445 font-style: italic;
446}
447.pe-editor code {
448 background: var(--paper-2);
449 border: 1px solid var(--rule);
450 border-radius: 4px;
451 padding: .1em .35em;
452 font-family: var(--font-mono, ui-monospace, monospace);
453 font-size: .92em;
454}
455.pe-editor a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
456.pe-editor img {
457 max-width: 100%; height: auto;
458 border-radius: 6px;
459 display: block;
460 margin: 1em auto;
461}
462
463/* Shortcode chips — visible inline placeholder in the editor for
464 [[track:UUID]] / [[album:Name]] / [[playlist:slug]]. They serialize back
465 to plain shortcode text on submit. */
466.sc-chip {
467 display: inline-flex; align-items: center; gap: .3rem;
468 padding: .15em .5em;
469 margin: 0 .15em;
470 background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
471 border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
472 border-radius: 999px;
473 color: var(--accent);
474 font-family: var(--font-ui, system-ui), sans-serif;
475 font-size: .85em;
476 font-weight: 600;
477 font-style: normal;
478 white-space: nowrap;
479 user-select: none;
480 cursor: default;
481 vertical-align: baseline;
482}
483.sc-chip-icon { display: inline-flex; opacity: .8; }
484.sc-chip-icon svg { width: 12px; height: 12px; }
485
486/* Status bar below the editor */
487.pe-editor-status {
488 display: flex; align-items: center; justify-content: space-between;
489 gap: .75rem;
490 padding: .45rem 1rem;
491 border-top: 1px solid var(--rule);
492 background: var(--paper-2);
493 color: var(--ink-muted, var(--ink-soft));
494 font-size: .78rem;
495}
496.pe-char-count { font-variant-numeric: tabular-nums; }
497
498/* ─── Track picker modal (P59) ─────────────────────────────────
499 Mobile-first: full-screen bottom-sheet on phones, centered card
500 on desktop. Lock body scroll while open via .tp-locked on body. */
501.tp-modal {
502 position: fixed; inset: 0;
503 z-index: 1000;
504 display: flex; align-items: stretch; justify-content: center;
505 /* Avoid the iOS home-indicator + the keyboard when search is focused */
506 padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
507}
508.tp-modal[hidden] { display: none; }
509.tp-backdrop {
510 position: absolute; inset: 0;
511 background: color-mix(in srgb, var(--ink) 55%, transparent);
512 backdrop-filter: blur(4px);
513 -webkit-backdrop-filter: blur(4px);
514 animation: tp-bd-in .18s ease-out;
515}
516@keyframes tp-bd-in { from { opacity: 0 } to { opacity: 1 } }
517
518/* Mobile: sheet docks to the bottom and fills viewport almost completely */
519.tp-sheet {
520 position: relative;
521 margin-top: auto;
522 width: 100%;
523 max-height: calc(100dvh - env(safe-area-inset-top) - 1rem);
524 background: var(--paper);
525 border-top: 1px solid var(--rule);
526 border-radius: 16px 16px 0 0;
527 box-shadow:
528 0 -2px 8px color-mix(in srgb, var(--ink) 8%, transparent),
529 0 -16px 48px color-mix(in srgb, var(--ink) 18%, transparent);
530 display: flex; flex-direction: column;
531 animation: tp-sheet-up .22s cubic-bezier(.2, .8, .25, 1);
532 overflow: hidden;
533}
534@keyframes tp-sheet-up {
535 from { transform: translateY(100%); opacity: .8; }
536 to { transform: translateY(0); opacity: 1; }
537}
538
539/* Header — title + close, with a small grab-handle bar on mobile */
540.tp-header {
541 display: flex; align-items: center; justify-content: space-between;
542 gap: .75rem;
543 padding: .85rem 1rem .65rem;
544 border-bottom: 1px solid var(--rule);
545 position: relative;
546}
547.tp-header::before {
548 /* Grab-handle: visible on mobile only */
549 content: '';
550 position: absolute;
551 top: .35rem; left: 50%;
552 transform: translateX(-50%);
553 width: 38px; height: 4px;
554 background: var(--rule-2, var(--rule));
555 border-radius: 2px;
556}
557.tp-h2 {
558 margin: .35rem 0 0;
559 font-family: var(--font-display, serif);
560 font-size: 1.15rem; font-weight: 600;
561 color: var(--ink);
562 line-height: 1.1;
563}
564.tp-close {
565 width: 32px; height: 32px; border-radius: 8px;
566 display: inline-flex; align-items: center; justify-content: center;
567 background: transparent; border: 1px solid transparent;
568 color: var(--ink-muted, var(--ink-soft));
569 cursor: pointer;
570 transition: background var(--transition), color var(--transition), border-color var(--transition);
571 -webkit-tap-highlight-color: transparent;
572 margin-top: .35rem;
573}
574.tp-close:hover, .tp-close:focus-visible {
575 background: var(--paper-2);
576 color: var(--ink);
577 outline: none;
578}
579.tp-close svg { width: 18px; height: 18px; }
580
581/* Search row — sticky just below header so list scrolls underneath */
582.tp-search-row {
583 position: relative;
584 padding: .65rem 1rem;
585 border-bottom: 1px solid var(--rule);
586 background: var(--paper);
587}
588.tp-search-icon {
589 position: absolute;
590 top: 50%; left: 1.65rem;
591 transform: translateY(-50%);
592 color: var(--ink-muted, var(--ink-soft));
593 pointer-events: none;
594 display: inline-flex;
595}
596.tp-search-icon svg { width: 16px; height: 16px; }
597.tp-search {
598 width: 100%; box-sizing: border-box;
599 padding: .65rem .85rem .65rem 2.4rem;
600 font-family: var(--font-ui, system-ui), sans-serif;
601 font-size: 1rem;
602 background: var(--paper-2);
603 color: var(--ink);
604 border: 1px solid var(--rule);
605 border-radius: 9px;
606 -webkit-appearance: none;
607 appearance: none;
608}
609.tp-search:focus {
610 outline: none;
611 border-color: var(--accent);
612 box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
613}
614
615/* List — vertically scrollable, takes remaining sheet height */
616.tp-list {
617 flex: 1 1 auto;
618 overflow-y: auto;
619 -webkit-overflow-scrolling: touch;
620 padding: .35rem .35rem 1rem;
621}
622.tp-empty {
623 padding: 1.5rem 1rem;
624 text-align: center;
625 color: var(--ink-muted, var(--ink-soft));
626 font-size: .9rem;
627}
628
629/* Track row — tap target ≥ 56px for mobile */
630.tp-row {
631 display: grid;
632 grid-template-columns: 44px 1fr auto;
633 align-items: center;
634 gap: .75rem;
635 width: 100%;
636 padding: .55rem .65rem;
637 border: 0; background: transparent;
638 border-radius: 10px;
639 text-align: left;
640 cursor: pointer;
641 color: inherit;
642 font: inherit;
643 transition: background var(--transition);
644 -webkit-tap-highlight-color: transparent;
645}
646.tp-row:hover, .tp-row:focus-visible {
647 background: color-mix(in srgb, var(--accent) 8%, var(--paper-2));
648 outline: none;
649}
650.tp-row:active { transform: scale(.98); }
651.tp-row[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
652
653.tp-cover {
654 width: 44px; height: 44px;
655 border-radius: 6px;
656 background-size: cover; background-position: center;
657 background-color: var(--rule);
658 display: inline-flex; align-items: center; justify-content: center;
659 flex-shrink: 0;
660}
661.tp-cover-empty {
662 background: linear-gradient(135deg,
663 color-mix(in srgb, var(--accent) 30%, var(--paper-2)),
664 color-mix(in srgb, var(--accent) 8%, var(--paper-2)));
665 color: var(--accent);
666}
667.tp-cover-empty svg { width: 18px; height: 18px; opacity: .8; }
668
669.tp-meta {
670 display: flex; flex-direction: column;
671 min-width: 0;
672 line-height: 1.25;
673}
674.tp-row-title {
675 font-weight: 600; font-size: .94rem;
676 color: var(--ink);
677 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
678}
679.tp-row-artist {
680 font-size: .8rem;
681 color: var(--ink-muted, var(--ink-soft));
682 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
683 margin-top: .1rem;
684}
685.tp-duration {
686 font-variant-numeric: tabular-nums;
687 font-size: .8rem;
688 color: var(--ink-muted, var(--ink-soft));
689 flex-shrink: 0;
690}
691
692/* Body lock when modal open */
693body.tp-locked { overflow: hidden; touch-action: none; }
694
695/* Desktop: centered card, max-width ~520px */
696@media (min-width: 640px) {
697 .tp-modal {
698 align-items: center;
699 padding: 1.5rem;
700 }
701 .tp-sheet {
702 margin-top: 0;
703 width: 100%;
704 max-width: 520px;
705 max-height: 80dvh;
706 border-radius: 14px;
707 border: 1px solid var(--rule);
708 animation: tp-sheet-pop .2s cubic-bezier(.2, .8, .25, 1);
709 }
710 .tp-header::before { display: none; } /* hide grab-handle on desktop */
711 .tp-header { padding: 1rem 1.25rem .75rem; }
712 .tp-h2 { margin-top: 0; font-size: 1.25rem; }
713 .tp-close { margin-top: 0; }
714 @keyframes tp-sheet-pop {
715 from { transform: translateY(-8px) scale(.97); opacity: 0; }
716 to { transform: translateY(0) scale(1); opacity: 1; }
717 }
718}
719
720/* ─── Buttons ───────────────────────────────────────────────────── */
721.pe-btn {
722 display: inline-flex; align-items: center; gap: 0.4rem;
723 padding: 0.55rem 1rem;
724 border: 1px solid var(--rule);
725 background: var(--paper-2);
726 color: var(--ink);
727 font-family: var(--font-ui, system-ui), sans-serif;
728 font-size: 0.9rem; font-weight: 500;
729 text-decoration: none;
730 border-radius: 6px;
731 cursor: pointer;
732 transition: background 120ms, border-color 120ms;
733 min-height: 40px;
734 -webkit-tap-highlight-color: transparent;
735}
736.pe-btn:hover { border-color: var(--accent); }
737.pe-btn:active { transform: scale(0.97); }
738.pe-btn-tiny {
739 padding: 0.35rem 0.7rem;
740 font-size: 0.85rem;
741 min-height: 32px;
742}
743.pe-btn-secondary { background: var(--paper-2); }
744.pe-btn-primary {
745 background: var(--accent); color: white;
746 border-color: var(--accent);
747}
748.pe-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
749.pe-btn-success {
750 background: #16a34a; color: white;
751 border-color: #16a34a;
752}
753.pe-btn-success:hover { opacity: 0.92; border-color: #16a34a; }
754
755.pe-status {
756 color: var(--ink-muted, var(--ink-soft));
757 font-size: 0.8rem;
758}
759.pe-status.is-error { color: #dc2626; }
760
761/* ─── Checkboxes (clean inline row) ─────────────────────────────── */
762.pe-checkboxes {
763 display: flex; flex-direction: column;
764 gap: 0.5rem;
765 padding-top: 0.25rem;
766}
767.pe-checkbox {
768 display: inline-flex; align-items: center; gap: 0.5rem;
769 cursor: pointer;
770 font-size: 0.95rem;
771 color: var(--ink);
772 user-select: none;
773}
774.pe-checkbox input[type="checkbox"] {
775 width: 18px; height: 18px;
776 margin: 0;
777 cursor: pointer;
778 accent-color: var(--accent);
779}
780
781/* Pinned rank: replaces the old boolean checkbox with a number input.
782 Stack the label / input / hint vertically — small footprint. */
783.pe-pinned-rank {
784 display: flex; flex-direction: column; gap: 0.25rem;
785 font-size: 0.95rem;
786 color: var(--ink);
787}
788.pe-pinned-rank input[type="number"] {
789 width: 80px;
790 padding: 0.4rem 0.6rem; min-height: 36px;
791 border: 1px solid var(--rule); border-radius: 6px;
792 background: var(--paper-2); color: var(--ink);
793 font-family: var(--font-ui, system-ui), sans-serif;
794 font-size: 0.95rem;
795 font-variant-numeric: tabular-nums;
796 -webkit-appearance: none; appearance: textfield;
797}
798.pe-pinned-rank input[type="number"]:focus {
799 outline: 2px solid var(--accent); outline-offset: -1px;
800 border-color: var(--accent);
801}
802.pe-pinned-rank small {
803 font-size: 0.78rem;
804 color: var(--ink-muted, var(--ink-soft));
805}
806
807/* ─── Sticky action footer ──────────────────────────────────────── */
808.pe-actions {
809 position: sticky;
810 bottom: 0;
811 display: flex; align-items: center;
812 gap: 0.5rem;
813 padding: 0.85rem 1rem;
814 margin: 0.5rem -1rem 0; /* extend to viewport edges on tight container */
815 background: color-mix(in srgb, var(--paper) 94%, transparent);
816 -webkit-backdrop-filter: blur(8px);
817 backdrop-filter: blur(8px);
818 border-top: 1px solid var(--rule);
819 z-index: 10;
820}
821.pe-actions-spacer { flex: 1; }
822
823/* On a body that already has a fixed bottom-tab nav (mobile), nudge the
824 sticky footer up so it doesn't sit under it. The bottom-tab is ~64px tall. */
825body.has-bottom-tab .pe-actions {
826 bottom: 64px;
827}
828</style>
829
830<script>
831(function() {
832
833 // ── Cover upload ────────────────────────────────────────────────
834 const coverField = document.getElementById('cover-upload-field');
835 const coverTrigger = document.getElementById('cover-upload-trigger');
836 const coverUrl = document.getElementById('cover-url-field');
837 const coverStatus = document.getElementById('cover-upload-status');
838 const coverWrap = document.getElementById('cover-preview-wrap');
839 const coverImg = document.getElementById('cover-preview-img');
840
841 async function uploadImage(file) {
842 const fd = new FormData();
843 fd.append('image', file);
844 const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
845 if (!res.ok) {
846 const j = await res.json().catch(() => ({}));
847 throw new Error(j.error || ('Upload failed (' + res.status + ')'));
848 }
849 return await res.json(); // {url, size, mime}
850 }
851
852 function showCoverPreview(url) {
853 if (!coverWrap || !coverImg) return;
854 if (url) {
855 coverImg.src = url;
856 coverImg.hidden = false;
857 coverWrap.removeAttribute('data-empty');
858 const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
859 if (emptyIcon) emptyIcon.remove();
860 } else {
861 coverImg.hidden = true;
862 coverImg.src = '';
863 coverWrap.setAttribute('data-empty', '');
864 if (!coverWrap.querySelector('.pe-cover-empty')) {
865 const span = document.createElement('span');
866 span.className = 'pe-cover-empty';
867 span.textContent = '🖼';
868 coverWrap.appendChild(span);
869 }
870 }
871 }
872
873 if (coverTrigger && coverField) {
874 coverTrigger.addEventListener('click', () => coverField.click());
875 }
876 if (coverField) {
877 coverField.addEventListener('change', async () => {
878 if (!coverField.files[0]) return;
879 coverStatus.classList.remove('is-error');
880 coverStatus.textContent = 'Uploaden…';
881 try {
882 const j = await uploadImage(coverField.files[0]);
883 coverUrl.value = j.url;
884 showCoverPreview(j.url);
885 coverStatus.textContent = 'Geüpload ✓';
886 setTimeout(() => { coverStatus.textContent = ''; }, 2000);
887 } catch (e) {
888 coverStatus.classList.add('is-error');
889 coverStatus.textContent = 'Mislukt: ' + e.message;
890 }
891 });
892 }
893 // Live-update preview when user pastes a URL manually
894 if (coverUrl) {
895 coverUrl.addEventListener('input', () => {
896 const v = coverUrl.value.trim();
897 if (v) showCoverPreview(v); else showCoverPreview('');
898 });
899 }
900
901 // ── WYSIWYG editor (P58) ────────────────────────────────────────
902 // Architecture:
903 // - Visible <div contenteditable> (`#content-editor`) is what the user
904 // types in; it shows real HTML (formatted, not raw markup).
905 // - Hidden <input name="content"> (`#content-hidden`) is what submits.
906 // On submit we serialize the editor's HTML into it, with shortcode
907 // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
908 // - Initial content comes from a <script type="application/json"> tag
909 // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
910 // and walk text nodes to render shortcode tokens as chips.
911 const contentField = document.getElementById('content-upload-field');
912 const contentBtn = document.getElementById('insert-image-btn');
913 const contentStatus = document.getElementById('content-upload-status');
914 const editor = document.getElementById('content-editor');
915 const hiddenField = document.getElementById('content-hidden');
916 const charCountEl = document.getElementById('char-count');
917 const initialEl = document.getElementById('initial-content');
918 const toolbar = document.getElementById('pe-toolbar');
919 const form = editor && editor.closest('form');
920
921 if (!editor) return;
922
923 // ── Shortcode chip rendering / serialization ────────────────────
924 // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
925 // — but we DON'T want to chipify text the user is mid-typing inside an
926 // HTML attribute; since chipify only walks text nodes (never attribute
927 // values) that's already safe.
928 const SC_RE = /\[\[(track|album|playlist):([^\]]+)\]\]/g;
929
930 const SC_ICONS = {
931 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>',
932 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>',
933 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>',
934 };
935
936 function chipLabel(kind, value) {
937 if (kind === 'track') {
938 // UUIDs are noisy — show a 6-char prefix for visual hint
939 const v = String(value || '');
940 return 'Track ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
941 }
942 if (kind === 'album') return 'Album: ' + value;
943 if (kind === 'playlist') return 'Playlist: ' + value;
944 return value;
945 }
946
947 function makeChip(kind, value) {
948 const span = document.createElement('span');
949 span.className = 'sc-chip';
950 span.contentEditable = 'false';
951 span.setAttribute('data-sc', kind + ':' + value);
952 span.innerHTML =
953 '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
954 '<span class="sc-chip-label"></span>';
955 span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
956 return span;
957 }
958
959 // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
960 function chipifyShortcodes(root) {
961 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
962 const targets = [];
963 while (walker.nextNode()) {
964 const n = walker.currentNode;
965 // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
966 if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
967 if (SC_RE.test(n.nodeValue)) targets.push(n);
968 SC_RE.lastIndex = 0;
969 }
970 for (const node of targets) {
971 const txt = node.nodeValue;
972 const frag = document.createDocumentFragment();
973 let last = 0;
974 let m;
975 SC_RE.lastIndex = 0;
976 while ((m = SC_RE.exec(txt)) !== null) {
977 if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
978 frag.appendChild(makeChip(m[1], m[2].trim()));
979 last = m.index + m[0].length;
980 }
981 if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
982 node.parentNode.replaceChild(frag, node);
983 }
984 }
985
986 // Inverse of chipify: clone the editor, replace every chip with its text.
987 function serializeChips(rootClone) {
988 const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
989 for (const c of chips) {
990 const txt = '[[' + c.getAttribute('data-sc') + ']]';
991 c.replaceWith(document.createTextNode(txt));
992 }
993 }
994
995 // ── Boot: load initial content as HTML, then render shortcodes as chips
996 try {
997 const initial = JSON.parse(initialEl.textContent || '""');
998 editor.innerHTML = initial || '';
999 chipifyShortcodes(editor);
1000 } catch (e) {
1001 console.error('[editor] could not parse initial content', e);
1002 editor.innerHTML = '';
1003 }
1004
1005 // ── Char counter
1006 function updateCharCount() {
1007 const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
1008 if (charCountEl) charCountEl.textContent = String(text.length);
1009 }
1010 updateCharCount();
1011 editor.addEventListener('input', updateCharCount);
1012
1013 // ── Toolbar wiring
1014 function execCmd(cmd, arg) {
1015 editor.focus();
1016 document.execCommand(cmd, false, arg);
1017 updateToolbarState();
1018 updateCharCount();
1019 }
1020 function wrapCode() {
1021 const sel = window.getSelection();
1022 if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
1023 const range = sel.getRangeAt(0);
1024 const code = document.createElement('code');
1025 code.textContent = sel.toString();
1026 range.deleteContents();
1027 range.insertNode(code);
1028 // Move caret after the new node
1029 range.setStartAfter(code);
1030 range.collapse(true);
1031 sel.removeAllRanges();
1032 sel.addRange(range);
1033 editor.focus();
1034 }
1035 function linkPrompt() {
1036 const url = window.prompt('Link URL (https://… of /pad)');
1037 if (!url) return;
1038 execCmd('createLink', url);
1039 }
1040
1041 if (toolbar) {
1042 toolbar.addEventListener('click', (e) => {
1043 const btn = e.target.closest('button[data-cmd]');
1044 if (!btn) return;
1045 e.preventDefault();
1046 const cmd = btn.dataset.cmd;
1047 const arg = btn.dataset.arg || null;
1048 if (cmd === 'link-prompt') linkPrompt();
1049 else if (cmd === 'code-wrap') wrapCode();
1050 else execCmd(cmd, arg);
1051 });
1052 }
1053
1054 // Reflect bold/italic/list state on the toolbar buttons
1055 function updateToolbarState() {
1056 if (!toolbar) return;
1057 const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
1058 for (const cmd of cmds) {
1059 const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
1060 if (!btn) continue;
1061 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
1062 }
1063 }
1064 document.addEventListener('selectionchange', () => {
1065 if (document.activeElement === editor) updateToolbarState();
1066 });
1067
1068 // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
1069 editor.addEventListener('keydown', (e) => {
1070 const mod = e.ctrlKey || e.metaKey;
1071 if (!mod) return;
1072 const k = e.key.toLowerCase();
1073 if (k === 'b') { e.preventDefault(); execCmd('bold'); }
1074 else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
1075 else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
1076 else if (k === 'k') { e.preventDefault(); linkPrompt(); }
1077 });
1078
1079 // Paste: keep it simple — strip formatting unless user wants it. Default
1080 // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
1081 // styles from clipboard only when shift is held — otherwise plain text.
1082 editor.addEventListener('paste', (e) => {
1083 if (e.shiftKey) return; // user wants formatted paste
1084 const text = (e.clipboardData || window.clipboardData).getData('text/plain');
1085 if (text == null) return;
1086 e.preventDefault();
1087 document.execCommand('insertText', false, text);
1088 });
1089
1090 // ── Image upload (button + drag-drop into the editor)
1091 async function uploadAndInsertImage(file) {
1092 contentStatus.classList.remove('is-error');
1093 contentStatus.textContent = 'Uploaden…';
1094 try {
1095 const j = await uploadImage(file);
1096 const img = '<img src="' + j.url + '" alt="">';
1097 editor.focus();
1098 document.execCommand('insertHTML', false, img);
1099 contentStatus.textContent = 'Ingevoegd ✓';
1100 setTimeout(() => { contentStatus.textContent = ''; }, 2000);
1101 updateCharCount();
1102 } catch (e) {
1103 contentStatus.classList.add('is-error');
1104 contentStatus.textContent = 'Mislukt: ' + e.message;
1105 }
1106 }
1107
1108 if (contentBtn && contentField) {
1109 contentBtn.addEventListener('click', () => contentField.click());
1110 contentField.addEventListener('change', () => {
1111 if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
1112 contentField.value = '';
1113 });
1114
1115 editor.addEventListener('dragover', (e) => {
1116 if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
1117 e.preventDefault();
1118 editor.classList.add('is-dragover');
1119 }
1120 });
1121 editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
1122 editor.addEventListener('drop', async (e) => {
1123 editor.classList.remove('is-dragover');
1124 const files = e.dataTransfer && e.dataTransfer.files;
1125 if (!files || !files.length) return;
1126 e.preventDefault();
1127 for (const f of files) {
1128 if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
1129 }
1130 });
1131 }
1132
1133 // ── Insert chip helpers (track / playlist)
1134 function insertChip(kind, value) {
1135 editor.focus();
1136 const chip = makeChip(kind, value);
1137 // Insert at caret using the Selection API (execCommand insertNode)
1138 const sel = window.getSelection();
1139 if (sel && sel.rangeCount > 0) {
1140 const range = sel.getRangeAt(0);
1141 range.deleteContents();
1142 range.insertNode(chip);
1143 // Insert a trailing space so the user can keep typing after the chip
1144 const space = document.createTextNode('\u00A0');
1145 chip.after(space);
1146 range.setStartAfter(space);
1147 range.collapse(true);
1148 sel.removeAllRanges();
1149 sel.addRange(range);
1150 } else {
1151 editor.appendChild(chip);
1152 editor.appendChild(document.createTextNode('\u00A0'));
1153 }
1154 updateCharCount();
1155 }
1156
1157 // ── Track insert: opens the track-picker modal (P59)
1158 const trackBtn = document.getElementById('insert-track-btn');
1159 const trackPicker = document.getElementById('track-picker');
1160 if (trackBtn && trackPicker) {
1161 const tpList = document.getElementById('tp-list');
1162 const tpEmpty = document.getElementById('tp-empty');
1163 const tpSearch = document.getElementById('tp-search');
1164 let tpCache = null; // cached tracks list (fetched once per page load)
1165 let tpLastFocus = null; // element to restore focus to on close
1166
1167 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>';
1168
1169 function fmtDur(sec) {
1170 sec = Math.max(0, Math.floor(sec || 0));
1171 const m = Math.floor(sec / 60), s = sec % 60;
1172 return m + ':' + String(s).padStart(2, '0');
1173 }
1174 function escAttr(s) {
1175 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
1176 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
1177 }[c]));
1178 }
1179
1180 function renderList(filter) {
1181 if (!Array.isArray(tpCache)) return;
1182 const q = (filter || '').trim().toLowerCase();
1183 const filtered = q
1184 ? tpCache.filter(t =>
1185 (t.title || '').toLowerCase().includes(q) ||
1186 (t.artist || '').toLowerCase().includes(q))
1187 : tpCache;
1188
1189 if (!filtered.length) {
1190 tpList.innerHTML = '';
1191 tpEmpty.textContent = q ? 'Geen tracks gevonden voor "' + q + '"' : 'Nog geen tracks. Upload via Beheer → Audio.';
1192 tpList.appendChild(tpEmpty);
1193 return;
1194 }
1195
1196 tpList.innerHTML = filtered.map(t => {
1197 const cov = t.cover
1198 ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
1199 : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
1200 const dis = t.playable ? '' : ' aria-disabled="true"';
1201 const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
1202 return (
1203 '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
1204 cov +
1205 '<span class="tp-meta">' +
1206 '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
1207 sub +
1208 '</span>' +
1209 '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
1210 '</button>'
1211 );
1212 }).join('');
1213 }
1214
1215 async function loadTracks() {
1216 if (Array.isArray(tpCache)) return tpCache;
1217 tpEmpty.textContent = 'Tracks laden…';
1218 try {
1219 const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
1220 const j = await r.json();
1221 tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
1222 } catch (e) {
1223 tpCache = [];
1224 tpEmpty.textContent = 'Kon tracks niet laden: ' + e.message;
1225 }
1226 return tpCache;
1227 }
1228
1229 function openPicker() {
1230 tpLastFocus = document.activeElement;
1231 trackPicker.hidden = false;
1232 trackPicker.setAttribute('aria-hidden', 'false');
1233 document.body.classList.add('tp-locked');
1234 tpSearch.value = '';
1235 renderList('');
1236 // Defer focus so the open animation doesn't get jumped
1237 setTimeout(() => tpSearch.focus(), 30);
1238 }
1239 function closePicker() {
1240 trackPicker.hidden = true;
1241 trackPicker.setAttribute('aria-hidden', 'true');
1242 document.body.classList.remove('tp-locked');
1243 if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
1244 try { tpLastFocus.focus(); } catch(_) {}
1245 }
1246 }
1247
1248 trackBtn.addEventListener('click', async () => {
1249 openPicker();
1250 await loadTracks();
1251 renderList(tpSearch.value);
1252 });
1253
1254 // Close: backdrop click, [data-tp-close], or Escape
1255 trackPicker.addEventListener('click', (e) => {
1256 if (e.target.closest('[data-tp-close]')) {
1257 closePicker();
1258 return;
1259 }
1260 const row = e.target.closest('.tp-row[data-track-id]');
1261 if (row) {
1262 if (row.getAttribute('aria-disabled') === 'true') return;
1263 const id = row.dataset.trackId;
1264 if (id) {
1265 insertChip('track', id);
1266 closePicker();
1267 }
1268 }
1269 });
1270 document.addEventListener('keydown', (e) => {
1271 if (!trackPicker.hidden && e.key === 'Escape') {
1272 e.preventDefault();
1273 closePicker();
1274 }
1275 });
1276
1277 // Live filter
1278 tpSearch.addEventListener('input', () => renderList(tpSearch.value));
1279 }
1280
1281 // ── Playlist insert (open existing or create new via modal)
1282 const playlistBtn = document.getElementById('insert-playlist-btn');
1283 if (playlistBtn) {
1284 playlistBtn.addEventListener('click', async () => {
1285 if (typeof window.openPlaylistEditor !== 'function') {
1286 alert('Playlist editor niet geladen');
1287 return;
1288 }
1289 try {
1290 const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
1291 const j = await r.json();
1292 if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
1293 const choice = prompt(
1294 'Bestaande playlists:\n\n' +
1295 j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
1296 '\n\nKies een nummer om in te voegen, leeg = nieuwe maken:'
1297 );
1298 if (choice && /^\d+$/.test(choice.trim())) {
1299 const idx = parseInt(choice.trim(), 10) - 1;
1300 if (idx >= 0 && idx < j.playlists.length) {
1301 insertChip('playlist', j.playlists[idx].id);
1302 return;
1303 }
1304 }
1305 if (choice === null) return;
1306 }
1307 } catch (_) { /* fall through to create */ }
1308
1309 window.openPlaylistEditor({
1310 mode: 'create',
1311 onSaved: ({ id }) => insertChip('playlist', id),
1312 });
1313 });
1314 }
1315
1316 // ── Submit: serialize editor contents into the hidden field
1317 if (form && hiddenField) {
1318 form.addEventListener('submit', () => {
1319 const clone = editor.cloneNode(true);
1320 serializeChips(clone);
1321 hiddenField.value = clone.innerHTML;
1322 });
1323 }
1324})();
1325</script>
1326
1327<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
1328 viewports, centered modal on ≥640px. Populated lazily on first open. %>
1329<% if (typeof user !== 'undefined' && user) { %>
1330<div id="track-picker" class="tp-modal" hidden aria-hidden="true">
1331 <div class="tp-backdrop" data-tp-close></div>
1332 <div class="tp-sheet" role="dialog" aria-modal="true" aria-labelledby="tp-title">
1333 <header class="tp-header">
1334 <h2 id="tp-title" class="tp-h2">Track invoegen</h2>
1335 <button type="button" class="tp-close" data-tp-close aria-label="Sluiten">
1336 <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>
1337 </button>
1338 </header>
1339 <div class="tp-search-row">
1340 <span class="tp-search-icon" aria-hidden="true">
1341 <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>
1342 </span>
1343 <input type="search" class="tp-search" id="tp-search" placeholder="Zoek op titel of artiest…" autocomplete="off" inputmode="search">
1344 </div>
1345 <div class="tp-list" id="tp-list" role="listbox" aria-label="Tracks">
1346 <div class="tp-empty" id="tp-empty">Tracks laden…</div>
1347 </div>
1348 </div>
1349</div>
1350<% } %>
1351
1352<%# Playlist editor modal — included so its global window.openPlaylistEditor
1353 is available when the toolbar button fires. Only renders if user can post. %>
1354<% if (typeof user !== 'undefined' && user) { %>
1355 <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
1356<% } %>
Note: See TracBrowser for help on using the repository browser.