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

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

editor: note that the excerpt is also the Circles summary

CircleFederation uses p.excerpt as the summary in the outbox (falls back
to the post body). Hint added below the Excerpt field.

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

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