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

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

feat: embed media via editor button ([[embed:url]] shortcode)

Embed YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp with a toolbar
button in the post editor: paste a URL -> [[embed:url]] chip -> server renders
an iframe (AudioEmbedService.embedMediaShortcodes, in the existing embed pipeline).
Standalone URL lines also embed via autoembed(). CSP already allows the domains.

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

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