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

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

ui(editor): clear active/inactive state for formatting on mobile

Active formatting button now shown fully filled with the accent colour
(white icon) instead of a vague 14% tint, and :hover only fires on real
hover devices (on touch, hover used to "stick" → unreadable or bold
wouldn't toggle). Now immediately clear whether a style is on; tap again

off.

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

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