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

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

fix(editor): schedule publication behind an on/off toggle (no accidental date)

The "Publish on" date field was always open → an accidentally chosen date silently
set the post to 'scheduled' (not live, no obvious reason). Now a "Schedule
publication" toggle, off by default = publish immediately; the date field appears
and counts only when the toggle is on (input disabled when off). Server honours
publish_at only with schedule_enabled (bulletproof, create + update). NL/EN/DE.

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

  • Property mode set to 100644
File size: 74.6 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 ? t('pedit.title_new') : t('pedit.title_edit') %></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><%= t('pedit.f_title') %></span>
25 <input type="text" name="title" value="<%= post.title %>" required>
26 </label>
27 <div class="pe-row pe-row-2">
28 <label class="pe-field">
29 <span><%= t('pedit.f_slug') %></span>
30 <input type="text" name="slug" value="<%= post.slug %>" placeholder="<%= t('pedit.slug_placeholder') %>">
31 </label>
32 <label class="pe-field">
33 <span><%= t('pedit.f_tags') %> <small><%= t('pedit.tags_hint') %></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><%= t('pedit.f_excerpt') %></span>
39 <textarea name="excerpt" rows="2"><%= post.excerpt %></textarea>
40 <small class="pe-hint" style="opacity:.7"><%- t('pedit.excerpt_hint') %></small>
41 </label>
42 </section>
43
44 <%# ── COVER ────────────────────────────────────────────────── %>
45 <section class="pe-card">
46 <div class="pe-section-title"><%= t('pedit.s_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><%= t('pedit.f_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="<%= t('pedit.cover_url_placeholder') %>">
59 </label>
60 <div class="pe-cover-upload">
61 <button type="button" class="pe-btn pe-btn-secondary" id="cover-upload-trigger">
62 📷 <%= t('pedit.cover_upload_btn') %>
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 <%= t('pedit.s_content') %>
75 <small class="pe-content-hint"><%= t('pedit.content_hint') %></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" id="pe-fs-done" class="pe-fs-done" title="<%= t('pedit.tb_done_title') %>">✓ <%= t('pedit.tb_done') %></button>
80 <button type="button" data-cmd="bold" title="<%= t('pedit.tb_bold_title') %>" aria-label="<%= t('pedit.tb_bold') %>">
81 <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>
82 </button>
83 <button type="button" data-cmd="italic" title="<%= t('pedit.tb_italic_title') %>" aria-label="<%= t('pedit.tb_italic') %>">
84 <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>
85 </button>
86 <button type="button" data-cmd="underline" title="<%= t('pedit.tb_underline') %>" aria-label="<%= t('pedit.tb_underline') %>">
87 <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>
88 </button>
89 <span class="pe-toolbar-sep" aria-hidden="true"></span>
90 <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h2" title="<%= t('pedit.tb_h2') %>">H2</button>
91 <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h3" title="<%= t('pedit.tb_h3') %>">H3</button>
92 <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="p" title="<%= t('pedit.tb_p') %>">¶</button>
93 <span class="pe-toolbar-sep" aria-hidden="true"></span>
94 <button type="button" data-cmd="insertUnorderedList" title="<%= t('pedit.tb_ul') %>" aria-label="<%= t('pedit.tb_ul') %>">
95 <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>
96 </button>
97 <button type="button" data-cmd="insertOrderedList" title="<%= t('pedit.tb_ol') %>" aria-label="<%= t('pedit.tb_ol') %>">
98 <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>
99 </button>
100 <button type="button" data-cmd="formatBlock" data-arg="blockquote" title="<%= t('pedit.tb_quote') %>" aria-label="<%= t('pedit.tb_quote') %>">
101 <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>
102 </button>
103 <button type="button" data-cmd="link-prompt" title="<%= t('pedit.tb_link_title') %>" aria-label="<%= t('pedit.tb_link') %>">
104 <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>
105 </button>
106 <button type="button" data-cmd="code-wrap" title="<%= t('pedit.tb_code_title') %>" aria-label="<%= t('pedit.tb_code') %>">
107 <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>
108 </button>
109 <span class="pe-toolbar-sep" aria-hidden="true"></span>
110 <button type="button" id="insert-image-btn" title="<%= t('pedit.tb_image_title') %>" aria-label="<%= t('pedit.tb_image') %>">
111 <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>
112 </button>
113 <button type="button" id="insert-track-btn" title="<%= t('pedit.tb_track_title') %>" aria-label="<%= t('pedit.tb_track') %>">
114 <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>
115 </button>
116 <button type="button" id="insert-playlist-btn" title="<%= t('pedit.tb_playlist_title') %>" aria-label="<%= t('pedit.tb_playlist') %>">
117 <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>
118 </button>
119 <button type="button" id="insert-embed-btn" title="<%= t('pedit.tb_embed_title') %>" aria-label="<%= t('pedit.tb_embed') %>">
120 <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>
121 </button>
122 <span class="pe-toolbar-spacer"></span>
123 <button type="button" data-cmd="removeFormat" title="<%= t('pedit.tb_clear') %>" aria-label="<%= t('pedit.tb_clear') %>">
124 <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>
125 </button>
126 <button type="button" id="pe-fullscreen-btn" title="<%= t('pedit.tb_fullscreen') %>" aria-label="<%= t('pedit.tb_fullscreen') %>" aria-pressed="false">
127 <svg class="fs-icon-expand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
128 <svg class="fs-icon-compress" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
129 </button>
130 </div>
131
132 <div id="content-editor"
133 class="pe-editor"
134 contenteditable="true"
135 role="textbox"
136 aria-multiline="true"
137 aria-label="<%= t('pedit.editor_aria') %>"
138 data-placeholder="<%= t('pedit.editor_placeholder') %>"></div>
139
140 <%# Sticky "tik om te bewerken"-hint (alleen zichtbaar op touch + niet-fullscreen
141 via CSS). Puur visueel (pointer-events:none); de tik op het veld opent fullscreen. %>
142 <div class="pe-edit-hint" aria-hidden="true">✎ <%= t('pedit.tap_to_edit') %></div>
143
144 <%# Hidden field is what actually submits to the server. The visible
145 contenteditable's serialized HTML is copied here on submit. %>
146 <input type="hidden" name="content" id="content-hidden" value="">
147
148 <%# Initial content is injected as a JSON string in a script tag so we
149 can set innerHTML safely from JS. EJS-escaping HTML directly into
150 the editor would render entity-escaped tags as text. %>
151 <script id="initial-content" type="application/json"><%- JSON.stringify(post.content || '').replace(/</g, '\\u003c') %></script>
152
153 <div class="pe-editor-status">
154 <small id="content-upload-status" class="pe-status"></small>
155 <span class="pe-char-count"><span id="char-count">0</span> <%= t('pedit.chars') %></span>
156 </div>
157
158 <input type="file" id="content-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
159 </div>
160 </section>
161
162 <%# ── META ─────────────────────────────────────────────────── %>
163 <section class="pe-card">
164 <div class="pe-section-title"><%= t('pedit.s_publication') %></div>
165 <div class="pe-row pe-row-2">
166 <label class="pe-field">
167 <span><%= t('pedit.f_status') %></span>
168 <% var _st = isNew ? 'published' : (post.status || 'draft'); %>
169 <select name="status">
170 <option value="published" <%= _st === 'published' ? 'selected' : '' %>><%= t('pedit.status_published') %></option>
171 <option value="draft" <%= _st === 'draft' ? 'selected' : '' %>><%= t('pedit.status_draft') %></option>
172 <option value="archived" <%= _st === 'archived' ? 'selected' : '' %>><%= t('pedit.status_archived') %></option>
173 </select>
174 </label>
175 <label class="pe-field">
176 <span><%= t('pedit.f_type') %></span>
177 <select name="type">
178 <% var ptype = post.type || 'post'; %>
179 <option value="post" <%= ptype === 'post' ? 'selected' : '' %>><%= t('pedit.type_post') %></option>
180 <option value="foto" <%= ptype === 'foto' ? 'selected' : '' %>><%= t('pedit.type_foto') %></option>
181 <option value="video" <%= ptype === 'video' ? 'selected' : '' %>><%= t('pedit.type_video') %></option>
182 <option value="audio" <%= ptype === 'audio' ? 'selected' : '' %>><%= t('pedit.type_audio') %></option>
183 </select>
184 </label>
185 </div>
186 <div class="pe-checkboxes">
187 <div class="pe-pin">
188 <label class="pe-checkbox">
189 <input type="checkbox" id="pin-toggle" <%= (Number(post.pinned) > 0) ? 'checked' : '' %>>
190 <span>📌 <%= t('pedit.pin_label') %></span>
191 </label>
192 <input type="hidden" name="pinned" id="pin-rank" value="<%= post.pinned || 0 %>">
193 <div class="pe-pin-pos" id="pin-pos" <%= (Number(post.pinned) > 0) ? '' : 'hidden' %>>
194 <span class="pe-pin-steps">
195 <button type="button" class="pe-pin-btn" id="pin-up" aria-label="<%= t('pedit.pin_up') %>">▲</button>
196 <button type="button" class="pe-pin-btn" id="pin-down" aria-label="<%= t('pedit.pin_down') %>">▼</button>
197 </span>
198 <span class="pe-pin-label" id="pin-label"></span>
199 </div>
200 </div>
201 <label class="pe-checkbox">
202 <input type="checkbox" name="noindex" value="1" <%= post.noindex ? 'checked' : '' %>>
203 <span>🚫 <%= t('pedit.noindex_label') %></span>
204 </label>
205 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
206 <label class="pe-checkbox">
207 <input type="checkbox" name="fan_only" value="1" <%= post.fan_only ? 'checked' : '' %>>
208 <span>🔒 <%= t('pedit.fan_only_label') %></span>
209 </label>
210 <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
211 <label class="pe-checkbox" style="margin-top:8px">
212 <input type="checkbox" name="schedule_enabled" value="1" id="pe-sched-toggle" <%= _scheduled ? 'checked' : '' %>>
213 <span>📅 <%= t('pedit.schedule_label') %></span>
214 </label>
215 <div id="pe-sched-fields" style="margin-top:6px;<%= _scheduled ? '' : 'display:none' %>">
216 <label style="display:block;font-size:12.5px;opacity:.8;margin-bottom:4px"><%= t('pedit.publish_at_label') %></label>
217 <input type="datetime-local" name="publish_at" <%= _scheduled ? '' : 'disabled' %> 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">
218 <% if (post.status === 'scheduled' && post.publish_at) { %><div style="font-size:12px;opacity:.7;margin-top:4px">⏳ <%= t('pedit.scheduled_for', { d: post.publish_at }) %></div><% } %>
219 <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
220 </div>
221 <script>
222 (function () {
223 var cb = document.getElementById('pe-sched-toggle');
224 var box = document.getElementById('pe-sched-fields');
225 if (!cb || !box) return;
226 var inp = box.querySelector('input[name="publish_at"]');
227 function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
228 cb.addEventListener('change', sync); sync();
229 })();
230 </script>
231 <% } %>
232 </div>
233 </section>
234
235 <%# ── ACTIONS (sticky at bottom) ──────────────────────────── %>
236 <div class="pe-actions">
237 <a href="<%= _base %><%= isNew ? '/' : '/' + post.slug %>" class="pe-btn"><%= t('pedit.cancel') %></a>
238 <div class="pe-actions-spacer"></div>
239 <% if (!isNew && post.status !== 'published') { %>
240 <button type="submit" name="action" value="publish" class="pe-btn pe-btn-success">📤 <%= t('pedit.publish') %></button>
241 <% } %>
242 <button type="submit" class="pe-btn pe-btn-primary">💾 <%= t('pedit.save') %></button>
243 </div>
244 </form>
245</div>
246
247<style>
248/* ─── Page wrapper ──────────────────────────────────────────────── */
249.post-edit-page {
250 max-width: 880px;
251 margin: 1.5rem auto 6rem; /* extra bottom for sticky-actions clearance */
252 padding: 0 1rem;
253}
254.post-edit-page h1 {
255 font-family: var(--font-display, serif);
256 margin: 0 0 1.25rem;
257 font-size: 1.75rem;
258}
259
260.post-edit-form {
261 display: flex; flex-direction: column;
262 gap: 1rem;
263}
264
265/* ─── Card sections ─────────────────────────────────────────────── */
266.pe-card {
267 background: var(--paper);
268 border: 1px solid var(--rule);
269 border-radius: 12px;
270 padding: 1.25rem;
271 display: flex; flex-direction: column;
272 gap: 0.85rem;
273}
274.pe-card-content { padding-bottom: 0; overflow: hidden; } /* editor flush to bottom */
275.pe-section-title {
276 font-size: 0.8rem;
277 font-weight: 600;
278 text-transform: uppercase;
279 letter-spacing: 0.05em;
280 color: var(--ink-soft);
281 display: flex; align-items: baseline; gap: 0.5rem;
282 flex-wrap: wrap;
283}
284.pe-section-title small {
285 font-size: 0.75rem; font-weight: 400;
286 letter-spacing: 0; text-transform: none;
287 color: var(--ink-muted, var(--ink-soft));
288}
289
290/* ─── Field primitives ──────────────────────────────────────────── */
291.pe-field {
292 display: flex; flex-direction: column;
293 gap: 0.35rem;
294 min-width: 0; /* allow grid items to shrink */
295}
296.pe-field > span {
297 font-size: 0.8rem;
298 font-weight: 600;
299 color: var(--ink-soft);
300 display: flex; align-items: baseline; gap: 0.4rem;
301}
302.pe-field > span small {
303 font-weight: 400; color: var(--ink-muted);
304}
305.pe-field input,
306.pe-field textarea,
307.pe-field select {
308 width: 100%;
309 box-sizing: border-box;
310 display: block;
311 padding: 0.6rem 0.75rem;
312 border: 1px solid var(--rule);
313 border-radius: 6px;
314 background: var(--paper-2);
315 color: var(--ink);
316 font-family: var(--font-ui, system-ui), sans-serif;
317 font-size: 0.95rem;
318 -webkit-appearance: none;
319 appearance: none;
320 transition: border-color 120ms;
321}
322.pe-field input:focus,
323.pe-field textarea:focus,
324.pe-field select:focus {
325 outline: 2px solid var(--accent);
326 outline-offset: -1px;
327 border-color: var(--accent);
328}
329.pe-field textarea {
330 font-family: var(--font-mono, monospace);
331 resize: vertical;
332}
333.pe-field select {
334 /* Restore the dropdown arrow we removed with appearance:none */
335 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>");
336 background-repeat: no-repeat;
337 background-position: right 0.75rem center;
338 padding-right: 2rem;
339}
340
341.pe-row {
342 display: grid;
343 gap: 0.75rem;
344}
345.pe-row-2 { grid-template-columns: 1fr 1fr; }
346@media (max-width: 600px) {
347 .pe-row-2 { grid-template-columns: 1fr; }
348}
349
350/* ─── Cover field ───────────────────────────────────────────────── */
351.pe-cover-row {
352 display: grid;
353 grid-template-columns: 120px 1fr;
354 gap: 1rem;
355 align-items: start;
356}
357.pe-cover-thumb {
358 width: 120px; height: 120px;
359 border-radius: 10px;
360 overflow: hidden;
361 background: var(--paper-2);
362 border: 1px solid var(--rule);
363 display: flex; align-items: center; justify-content: center;
364 position: relative;
365}
366.pe-cover-thumb[data-empty] {
367 border-style: dashed;
368}
369.pe-cover-thumb img {
370 width: 100%; height: 100%;
371 object-fit: cover; display: block;
372}
373/* Honor HTML hidden — our display:block above otherwise renders an empty
374 broken-image icon when no cover is set. */
375.pe-cover-thumb img[hidden] { display: none; }
376.pe-cover-empty {
377 font-size: 2rem;
378 color: var(--ink-muted, var(--ink-soft));
379 opacity: 0.5;
380}
381.pe-cover-actions {
382 display: flex; flex-direction: column;
383 gap: 0.6rem;
384 min-width: 0;
385}
386.pe-cover-upload {
387 display: flex; align-items: center; gap: 0.6rem;
388 flex-wrap: wrap;
389}
390@media (max-width: 600px) {
391 .pe-cover-row { grid-template-columns: 88px 1fr; }
392 .pe-cover-thumb { width: 88px; height: 88px; }
393}
394
395/* ─── Editor frame (WYSIWYG: top toolbar, contenteditable body, status bar) ─── */
396.pe-editor-frame {
397 margin: 0 -1.25rem -1.25rem; /* break out of card padding for flush edges */
398 border-top: 1px solid var(--rule);
399 background: var(--paper);
400 display: flex; flex-direction: column;
401}
402
403/* Top toolbar — sticks to the top of the viewport while editing */
404.pe-toolbar {
405 display: flex; align-items: center; gap: .15rem;
406 flex-wrap: wrap;
407 padding: .4rem .75rem;
408 background: color-mix(in srgb, var(--paper) 92%, transparent);
409 backdrop-filter: blur(10px);
410 -webkit-backdrop-filter: blur(10px);
411 border-bottom: 1px solid var(--rule);
412 position: sticky;
413 top: 0;
414 z-index: 10;
415}
416.pe-toolbar button {
417 display: inline-flex; align-items: center; justify-content: center;
418 width: 32px; height: 32px;
419 padding: 0;
420 touch-action: manipulation; /* snappy taps op mobiel, geen dubbeltik-zoom */
421 -webkit-user-select: none; user-select: none;
422 background: transparent;
423 border: 1px solid transparent;
424 border-radius: 6px;
425 color: var(--ink);
426 cursor: pointer;
427 font-family: var(--font-ui, system-ui), sans-serif;
428 font-weight: 600; font-size: .85rem;
429 transition: background var(--transition), color var(--transition), border-color var(--transition);
430 -webkit-tap-highlight-color: transparent;
431}
432/* Hover alleen op echte hover-apparaten — op touch blijft :hover anders "plakken"
433 na een tik, waardoor de actief/inactief-staat onleesbaar wordt. */
434@media (hover: hover) {
435 .pe-toolbar button:hover {
436 background: var(--paper-2);
437 color: var(--accent);
438 }
439}
440.pe-toolbar button:active { transform: scale(.94); }
441/* Actieve opmaak = onmiskenbaar gevuld met de accentkleur. Zo is op mobiel
442 meteen duidelijk dat bv. vet AAN staat (tik nogmaals = uit). */
443.pe-toolbar button.is-active {
444 background: var(--accent);
445 color: #fff;
446 border-color: var(--accent);
447}
448.pe-toolbar button.is-active svg { color: #fff; }
449.pe-toolbar button svg { width: 16px; height: 16px; }
450.pe-toolbar button.pe-tb-text { font-family: var(--font-display, serif); font-weight: 700; }
451.pe-toolbar-sep {
452 width: 1px; height: 18px;
453 background: var(--rule);
454 margin: 0 .35rem;
455 flex-shrink: 0;
456}
457.pe-toolbar-spacer { flex: 1; }
458
459/* ─── Volledig-scherm schrijfmodus ─── */
460.fs-icon-compress { display: none; }
461.pe-editor-frame.pe-fs .fs-icon-expand { display: none; }
462.pe-editor-frame.pe-fs .fs-icon-compress { display: inline; }
463.pe-editor-frame.pe-fs {
464 position: fixed; inset: 0; z-index: 1000;
465 margin: 0; border-top: 0;
466 height: 100dvh;
467 background: var(--paper);
468 overflow: hidden; /* alléén het tekstveld scrollt, niet het frame zelf */
469}
470/* In fullscreen vult het schrijfveld de resterende ruimte en scrollt het zelf
471 (max-height: none overschrijft de mobiele box-begrenzing hieronder). */
472.pe-editor-frame.pe-fs .pe-editor {
473 flex: 1 1 auto; min-height: 0; height: auto; max-height: none;
474}
475/* NB: bewust GEEN overflow:hidden op html/body in fullscreen — dat kon blijven
476 hangen (bv. fullscreen verlaten via navigatie) en blokkeerde dan het scrollen van
477 de hele pagina. Het fullscreen-frame (position:fixed, inset:0) dekt de pagina al
478 af, en op touch zijn de scrollbalken verborgen → geen dubbele balk nodig. */
479
480/* "Klaar"-knop: alleen in fullscreen zichtbaar (links in de toolbar), duidelijke
481 accent-pill i.p.v. een vierkant icoontje. */
482/* Verberg-regel specifieker dan ".pe-toolbar button" (anders wint die en zie je
483 de Klaar-knop ook inline als kleine knop). Alleen in fullscreen tonen → groot. */
484.pe-toolbar button.pe-fs-done { display: none; }
485.pe-editor-frame.pe-fs .pe-fs-done {
486 display: inline-flex; align-items: center; gap: .35rem;
487 width: auto !important; height: auto !important; min-height: 40px;
488 padding: .55rem 1.3rem !important; margin-right: .5rem;
489 background: var(--accent); color: #fff;
490 font-weight: 700; font-size: 1.05rem; border-radius: 999px;
491}
492.pe-editor-frame.pe-fs .pe-fs-done:hover { background: var(--accent); color: #fff; }
493
494/* Editor body — looks like prose, behaves like a textarea */
495.pe-editor {
496 width: 100%;
497 box-sizing: border-box;
498 min-height: 420px;
499 padding: 1.25rem 1.5rem;
500 border: 0;
501 background: transparent;
502 color: var(--ink);
503 font-family: var(--font-body, system-ui), serif;
504 font-size: 1.0625rem;
505 line-height: 1.65;
506 outline: none;
507 overflow-y: auto;
508}
509.pe-editor:focus { outline: none; }
510/* Inline (niet-fullscreen) groeit het schrijfveld gewoon mee met de inhoud — geen
511 interne scroll-box (scroll-binnen-scroll is verwarrend). Op mobiel/tablet gaat
512 typen sowieso fullscreen (zie JS), waar het veld de pagina vult en als enige
513 scrollt. */
514.pe-editor-frame:not(.pe-fs) .pe-editor { overflow: visible; }
515
516/* Touch: het inline content-veld is geen tekstveld maar een tap-vlak → fullscreen
517 bewerken. Een "✎ Tik om te bewerken"-pill plakt sticky onderaan de container,
518 zodat de hint altijd in beeld is zonder midden in de tekst te staan. */
519.pe-editor.pe-tap-to-edit { cursor: pointer; }
520.pe-edit-hint { display: none; }
521@media (pointer: coarse) {
522 .pe-editor-frame:not(.pe-fs) .pe-edit-hint {
523 display: block;
524 position: sticky;
525 bottom: 4.5rem; /* boven de sticky Opslaan/Annuleren-balk */
526 width: max-content;
527 max-width: calc(100% - 2rem);
528 margin: .4rem auto;
529 background: var(--accent); color: #fff;
530 font-size: .9rem; font-weight: 700; padding: .5rem 1.1rem; border-radius: 999px;
531 box-shadow: 0 4px 14px rgba(0,0,0,.35);
532 pointer-events: none; text-align: center; white-space: nowrap;
533 }
534 /* Niet relevant op touch (slepen/selecteren hoort bij inline-bewerken op desktop). */
535 .pe-content-hint { display: none; }
536 /* Inline (niet-fullscreen) op touch: simpel houden — je bewerkt hier toch niet,
537 dus geen opmaak-toolbar en geen losse rand. Alleen de content-preview + de
538 "tik om te bewerken"-pill. De toolbar verschijnt pas in fullscreen. */
539 .pe-editor-frame:not(.pe-fs) .pe-toolbar { display: none; }
540 .pe-editor-frame:not(.pe-fs) { border-top: 0; }
541 .pe-editor-frame:not(.pe-fs) .pe-editor { min-height: 8rem; }
542}
543.pe-editor.is-dragover {
544 outline: 2px dashed var(--accent);
545 outline-offset: -10px;
546}
547.pe-editor[data-placeholder]:empty::before,
548.pe-editor[data-placeholder]:has(br:only-child)::before {
549 content: attr(data-placeholder);
550 color: var(--ink-muted, #999);
551 pointer-events: none;
552 opacity: .55;
553}
554/* Inline prose styles inside the editor — match the public post styling so
555 what you see is roughly what you'll get. Margins are tighter than on the
556 live site since this is a confined writing space. */
557.pe-editor h1, .pe-editor h2, .pe-editor h3, .pe-editor h4 {
558 font-family: var(--font-display, serif);
559 line-height: 1.2;
560 margin: 1.1rem 0 .35rem;
561}
562.pe-editor h1 { font-size: 1.85rem; }
563.pe-editor h2 { font-size: 1.45rem; }
564.pe-editor h3 { font-size: 1.2rem; }
565.pe-editor p { margin: 0 0 .85em; }
566.pe-editor ul, .pe-editor ol { margin: 0 0 .85em 1.4em; padding: 0; }
567.pe-editor li { margin: .15em 0; }
568.pe-editor blockquote {
569 margin: 1em 0;
570 padding: .15em 0 .15em 1em;
571 border-left: 3px solid var(--accent);
572 color: var(--ink-soft, var(--ink));
573 font-style: italic;
574}
575.pe-editor code {
576 background: var(--paper-2);
577 border: 1px solid var(--rule);
578 border-radius: 4px;
579 padding: .1em .35em;
580 font-family: var(--font-mono, ui-monospace, monospace);
581 font-size: .92em;
582}
583.pe-editor a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
584.pe-editor img {
585 max-width: 100%; height: auto;
586 border-radius: 6px;
587 display: block;
588 margin: 1em auto;
589}
590
591/* Shortcode chips — visible inline placeholder in the editor for
592 [[track:UUID]] / [[album:Name]] / [[playlist:slug]]. They serialize back
593 to plain shortcode text on submit. */
594.sc-chip {
595 display: inline-flex; align-items: center; gap: .3rem;
596 padding: .15em .5em;
597 margin: 0 .15em;
598 background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
599 border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
600 border-radius: 999px;
601 color: var(--accent);
602 font-family: var(--font-ui, system-ui), sans-serif;
603 font-size: .85em;
604 font-weight: 600;
605 font-style: normal;
606 white-space: nowrap;
607 user-select: none;
608 cursor: default;
609 vertical-align: baseline;
610}
611.sc-chip-icon { display: inline-flex; opacity: .8; }
612.sc-chip-icon svg { width: 12px; height: 12px; }
613
614/* Status bar below the editor */
615.pe-editor-status {
616 display: flex; align-items: center; justify-content: space-between;
617 gap: .75rem;
618 padding: .45rem 1rem;
619 border-top: 1px solid var(--rule);
620 background: var(--paper-2);
621 color: var(--ink-muted, var(--ink-soft));
622 font-size: .78rem;
623}
624.pe-char-count { font-variant-numeric: tabular-nums; }
625
626/* ─── Track picker modal (P59) ─────────────────────────────────
627 Mobile-first: full-screen bottom-sheet on phones, centered card
628 on desktop. Lock body scroll while open via .tp-locked on body. */
629.tp-modal {
630 position: fixed; inset: 0;
631 z-index: 1000;
632 display: flex; align-items: stretch; justify-content: center;
633 /* Avoid the iOS home-indicator + the keyboard when search is focused */
634 padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
635}
636.tp-modal[hidden] { display: none; }
637.tp-backdrop {
638 position: absolute; inset: 0;
639 background: color-mix(in srgb, var(--ink) 55%, transparent);
640 backdrop-filter: blur(4px);
641 -webkit-backdrop-filter: blur(4px);
642 animation: tp-bd-in .18s ease-out;
643}
644@keyframes tp-bd-in { from { opacity: 0 } to { opacity: 1 } }
645
646/* Mobile: sheet docks to the bottom and fills viewport almost completely */
647.tp-sheet {
648 position: relative;
649 margin-top: auto;
650 width: 100%;
651 max-height: calc(100dvh - env(safe-area-inset-top) - 1rem);
652 background: var(--paper);
653 border-top: 1px solid var(--rule);
654 border-radius: 16px 16px 0 0;
655 box-shadow:
656 0 -2px 8px color-mix(in srgb, var(--ink) 8%, transparent),
657 0 -16px 48px color-mix(in srgb, var(--ink) 18%, transparent);
658 display: flex; flex-direction: column;
659 animation: tp-sheet-up .22s cubic-bezier(.2, .8, .25, 1);
660 overflow: hidden;
661}
662@keyframes tp-sheet-up {
663 from { transform: translateY(100%); opacity: .8; }
664 to { transform: translateY(0); opacity: 1; }
665}
666
667/* Header — title + close, with a small grab-handle bar on mobile */
668.tp-header {
669 display: flex; align-items: center; justify-content: space-between;
670 gap: .75rem;
671 padding: .85rem 1rem .65rem;
672 border-bottom: 1px solid var(--rule);
673 position: relative;
674}
675.tp-header::before {
676 /* Grab-handle: visible on mobile only */
677 content: '';
678 position: absolute;
679 top: .35rem; left: 50%;
680 transform: translateX(-50%);
681 width: 38px; height: 4px;
682 background: var(--rule-2, var(--rule));
683 border-radius: 2px;
684}
685.tp-h2 {
686 margin: .35rem 0 0;
687 font-family: var(--font-display, serif);
688 font-size: 1.15rem; font-weight: 600;
689 color: var(--ink);
690 line-height: 1.1;
691}
692.tp-close {
693 width: 32px; height: 32px; border-radius: 8px;
694 display: inline-flex; align-items: center; justify-content: center;
695 background: transparent; border: 1px solid transparent;
696 color: var(--ink-muted, var(--ink-soft));
697 cursor: pointer;
698 transition: background var(--transition), color var(--transition), border-color var(--transition);
699 -webkit-tap-highlight-color: transparent;
700 margin-top: .35rem;
701}
702.tp-close:hover, .tp-close:focus-visible {
703 background: var(--paper-2);
704 color: var(--ink);
705 outline: none;
706}
707.tp-close svg { width: 18px; height: 18px; }
708
709/* Search row — sticky just below header so list scrolls underneath */
710.tp-search-row {
711 position: relative;
712 padding: .65rem 1rem;
713 border-bottom: 1px solid var(--rule);
714 background: var(--paper);
715}
716.tp-search-icon {
717 position: absolute;
718 top: 50%; left: 1.65rem;
719 transform: translateY(-50%);
720 color: var(--ink-muted, var(--ink-soft));
721 pointer-events: none;
722 display: inline-flex;
723}
724.tp-search-icon svg { width: 16px; height: 16px; }
725.tp-search {
726 width: 100%; box-sizing: border-box;
727 padding: .65rem .85rem .65rem 2.4rem;
728 font-family: var(--font-ui, system-ui), sans-serif;
729 font-size: 1rem;
730 background: var(--paper-2);
731 color: var(--ink);
732 border: 1px solid var(--rule);
733 border-radius: 9px;
734 -webkit-appearance: none;
735 appearance: none;
736}
737.tp-search:focus {
738 outline: none;
739 border-color: var(--accent);
740 box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
741}
742
743/* List — vertically scrollable, takes remaining sheet height */
744.tp-list {
745 flex: 1 1 auto;
746 overflow-y: auto;
747 -webkit-overflow-scrolling: touch;
748 padding: .35rem .35rem 1rem;
749}
750.tp-empty {
751 padding: 1.5rem 1rem;
752 text-align: center;
753 color: var(--ink-muted, var(--ink-soft));
754 font-size: .9rem;
755}
756
757/* Track row — tap target ≥ 56px for mobile */
758.tp-row {
759 display: grid;
760 grid-template-columns: 44px 1fr auto;
761 align-items: center;
762 gap: .75rem;
763 width: 100%;
764 padding: .55rem .65rem;
765 border: 0; background: transparent;
766 border-radius: 10px;
767 text-align: left;
768 cursor: pointer;
769 color: inherit;
770 font: inherit;
771 transition: background var(--transition);
772 -webkit-tap-highlight-color: transparent;
773}
774.tp-row:hover, .tp-row:focus-visible {
775 background: color-mix(in srgb, var(--accent) 8%, var(--paper-2));
776 outline: none;
777}
778.tp-row:active { transform: scale(.98); }
779.tp-row[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
780
781.tp-cover {
782 width: 44px; height: 44px;
783 border-radius: 6px;
784 background-size: cover; background-position: center;
785 background-color: var(--rule);
786 display: inline-flex; align-items: center; justify-content: center;
787 flex-shrink: 0;
788}
789.tp-cover-empty {
790 background: linear-gradient(135deg,
791 color-mix(in srgb, var(--accent) 30%, var(--paper-2)),
792 color-mix(in srgb, var(--accent) 8%, var(--paper-2)));
793 color: var(--accent);
794}
795.tp-cover-empty svg { width: 18px; height: 18px; opacity: .8; }
796
797.tp-meta {
798 display: flex; flex-direction: column;
799 min-width: 0;
800 line-height: 1.25;
801}
802.tp-row-title {
803 font-weight: 600; font-size: .94rem;
804 color: var(--ink);
805 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
806}
807.tp-row-artist {
808 font-size: .8rem;
809 color: var(--ink-muted, var(--ink-soft));
810 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
811 margin-top: .1rem;
812}
813.tp-duration {
814 font-variant-numeric: tabular-nums;
815 font-size: .8rem;
816 color: var(--ink-muted, var(--ink-soft));
817 flex-shrink: 0;
818}
819
820/* Body lock when modal open */
821body.tp-locked { overflow: hidden; touch-action: none; }
822
823/* Desktop: centered card, max-width ~520px */
824@media (min-width: 640px) {
825 .tp-modal {
826 align-items: center;
827 padding: 1.5rem;
828 }
829 .tp-sheet {
830 margin-top: 0;
831 width: 100%;
832 max-width: 520px;
833 max-height: 80dvh;
834 border-radius: 14px;
835 border: 1px solid var(--rule);
836 animation: tp-sheet-pop .2s cubic-bezier(.2, .8, .25, 1);
837 }
838 .tp-header::before { display: none; } /* hide grab-handle on desktop */
839 .tp-header { padding: 1rem 1.25rem .75rem; }
840 .tp-h2 { margin-top: 0; font-size: 1.25rem; }
841 .tp-close { margin-top: 0; }
842 @keyframes tp-sheet-pop {
843 from { transform: translateY(-8px) scale(.97); opacity: 0; }
844 to { transform: translateY(0) scale(1); opacity: 1; }
845 }
846}
847
848/* ─── Buttons ───────────────────────────────────────────────────── */
849.pe-btn {
850 display: inline-flex; align-items: center; gap: 0.4rem;
851 padding: 0.55rem 1rem;
852 border: 1px solid var(--rule);
853 background: var(--paper-2);
854 color: var(--ink);
855 font-family: var(--font-ui, system-ui), sans-serif;
856 font-size: 0.9rem; font-weight: 500;
857 text-decoration: none;
858 border-radius: 6px;
859 cursor: pointer;
860 transition: background 120ms, border-color 120ms;
861 min-height: 40px;
862 -webkit-tap-highlight-color: transparent;
863}
864.pe-btn:hover { border-color: var(--accent); }
865.pe-btn:active { transform: scale(0.97); }
866.pe-btn-tiny {
867 padding: 0.35rem 0.7rem;
868 font-size: 0.85rem;
869 min-height: 32px;
870}
871.pe-btn-secondary { background: var(--paper-2); }
872.pe-btn-primary {
873 background: var(--accent); color: white;
874 border-color: var(--accent);
875}
876.pe-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
877.pe-btn-success {
878 background: #16a34a; color: white;
879 border-color: #16a34a;
880}
881.pe-btn-success:hover { opacity: 0.92; border-color: #16a34a; }
882
883.pe-status {
884 color: var(--ink-muted, var(--ink-soft));
885 font-size: 0.8rem;
886}
887.pe-status.is-error { color: #dc2626; }
888
889/* ─── Checkboxes (clean inline row) ─────────────────────────────── */
890.pe-checkboxes {
891 display: flex; flex-direction: column;
892 gap: 0.5rem;
893 padding-top: 0.25rem;
894}
895.pe-checkbox {
896 display: inline-flex; align-items: center; gap: 0.5rem;
897 cursor: pointer;
898 font-size: 0.95rem;
899 color: var(--ink);
900 user-select: none;
901}
902.pe-checkbox input[type="checkbox"] {
903 width: 18px; height: 18px;
904 margin: 0;
905 cursor: pointer;
906 accent-color: var(--accent);
907}
908
909/* Vastpinnen: checkbox + ▲▼-stepper met beschrijving (i.p.v. een ruw rang-getal). */
910.pe-pin { display: flex; flex-direction: column; gap: 0.45rem; }
911.pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; }
912.pe-pin-pos[hidden] { display: none; }
913.pe-pin-steps { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
914.pe-pin-btn {
915 width: 30px; height: 28px; padding: 0;
916 border: none; background: var(--paper-2); color: var(--ink);
917 cursor: pointer; font-size: 0.68rem; line-height: 1;
918 display: inline-flex; align-items: center; justify-content: center;
919}
920.pe-pin-btn + .pe-pin-btn { border-left: 1px solid var(--rule); }
921.pe-pin-btn:hover { background: color-mix(in srgb, var(--accent) 14%, var(--paper-2)); color: var(--accent); }
922.pe-pin-btn:disabled { opacity: 0.35; cursor: default; }
923.pe-pin-label { font-size: 0.85rem; color: var(--accent); font-weight: 600; }
924
925/* ─── Sticky action footer ──────────────────────────────────────── */
926.pe-actions {
927 position: sticky;
928 bottom: 0;
929 display: flex; align-items: center;
930 gap: 0.5rem;
931 padding: 0.85rem 1rem;
932 margin: 0.5rem -1rem 0; /* extend to viewport edges on tight container */
933 background: color-mix(in srgb, var(--paper) 94%, transparent);
934 -webkit-backdrop-filter: blur(8px);
935 backdrop-filter: blur(8px);
936 border-top: 1px solid var(--rule);
937 z-index: 10;
938}
939.pe-actions-spacer { flex: 1; }
940
941/* De editor is een focus-scherm: verberg de mobiele site-tabbalk (Home/Zoek/…)
942 zodat er niet twee balken onderaan stapelen (Opslaan-balk + tabbalk). De
943 Opslaan/Annuleren-balk wordt dan de enige onderbalk → gewoon op bottom:0,
944 geen tab-offset meer nodig. Op desktop is de tabbalk toch al verborgen. */
945body:has(.post-edit-page) .bottom-tab { display: none; }
946/* Audiospeler (indien aan het spelen) niet meer 56px optillen voor de nu-verborgen
947 tabbalk, anders zou die zweven boven de actiebalk. */
948body:has(.post-edit-page) .audio-player { bottom: 0; }
949</style>
950
951<script>
952(function() {
953
954 // ── Cover upload ────────────────────────────────────────────────
955 const coverField = document.getElementById('cover-upload-field');
956 const coverTrigger = document.getElementById('cover-upload-trigger');
957 const coverUrl = document.getElementById('cover-url-field');
958 const coverStatus = document.getElementById('cover-upload-status');
959 const coverWrap = document.getElementById('cover-preview-wrap');
960 const coverImg = document.getElementById('cover-preview-img');
961
962 async function uploadImage(file) {
963 const fd = new FormData();
964 fd.append('image', file);
965 const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
966 if (!res.ok) {
967 const j = await res.json().catch(() => ({}));
968 throw new Error(j.error || ('Upload failed (' + res.status + ')'));
969 }
970 return await res.json(); // {url, size, mime}
971 }
972
973 function showCoverPreview(url) {
974 if (!coverWrap || !coverImg) return;
975 if (url) {
976 coverImg.src = url;
977 coverImg.hidden = false;
978 coverWrap.removeAttribute('data-empty');
979 const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
980 if (emptyIcon) emptyIcon.remove();
981 } else {
982 coverImg.hidden = true;
983 coverImg.src = '';
984 coverWrap.setAttribute('data-empty', '');
985 if (!coverWrap.querySelector('.pe-cover-empty')) {
986 const span = document.createElement('span');
987 span.className = 'pe-cover-empty';
988 span.textContent = '🖼';
989 coverWrap.appendChild(span);
990 }
991 }
992 }
993
994 if (coverTrigger && coverField) {
995 coverTrigger.addEventListener('click', () => coverField.click());
996 }
997 if (coverField) {
998 coverField.addEventListener('change', async () => {
999 if (!coverField.files[0]) return;
1000 coverStatus.classList.remove('is-error');
1001 coverStatus.textContent = '<%= t('pedit.js_uploading') %>';
1002 try {
1003 const j = await uploadImage(coverField.files[0]);
1004 coverUrl.value = j.url;
1005 showCoverPreview(j.url);
1006 coverStatus.textContent = '<%= t('pedit.js_uploaded') %> ✓';
1007 setTimeout(() => { coverStatus.textContent = ''; }, 2000);
1008 } catch (e) {
1009 coverStatus.classList.add('is-error');
1010 coverStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message;
1011 }
1012 });
1013 }
1014 // Live-update preview when user pastes a URL manually
1015 if (coverUrl) {
1016 coverUrl.addEventListener('input', () => {
1017 const v = coverUrl.value.trim();
1018 if (v) showCoverPreview(v); else showCoverPreview('');
1019 });
1020 }
1021
1022 // ── WYSIWYG editor (P58) ────────────────────────────────────────
1023 // Architecture:
1024 // - Visible <div contenteditable> (`#content-editor`) is what the user
1025 // types in; it shows real HTML (formatted, not raw markup).
1026 // - Hidden <input name="content"> (`#content-hidden`) is what submits.
1027 // On submit we serialize the editor's HTML into it, with shortcode
1028 // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
1029 // - Initial content comes from a <script type="application/json"> tag
1030 // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
1031 // and walk text nodes to render shortcode tokens as chips.
1032 const contentField = document.getElementById('content-upload-field');
1033 const contentBtn = document.getElementById('insert-image-btn');
1034 const contentStatus = document.getElementById('content-upload-status');
1035 const editor = document.getElementById('content-editor');
1036 const hiddenField = document.getElementById('content-hidden');
1037 const charCountEl = document.getElementById('char-count');
1038 const initialEl = document.getElementById('initial-content');
1039 const toolbar = document.getElementById('pe-toolbar');
1040 const form = editor && editor.closest('form');
1041
1042 if (!editor) return;
1043
1044 // Titel alleen automatisch focussen op desktop (muis/trackpad). Op touch zou
1045 // dit meteen het toetsenbord openen bij het openen van de editor — niet gewenst.
1046 try {
1047 const titleInput = form && form.querySelector('input[name="title"]');
1048 if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
1049 titleInput.focus({ preventScroll: true });
1050 }
1051 } catch (_) {}
1052
1053 // ── Shortcode chip rendering / serialization ────────────────────
1054 // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
1055 // — but we DON'T want to chipify text the user is mid-typing inside an
1056 // HTML attribute; since chipify only walks text nodes (never attribute
1057 // values) that's already safe.
1058 const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
1059
1060 const SC_ICONS = {
1061 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>',
1062 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>',
1063 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>',
1064 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>',
1065 };
1066
1067 function chipLabel(kind, value) {
1068 if (kind === 'track') {
1069 // UUIDs are noisy — show a 6-char prefix for visual hint
1070 const v = String(value || '');
1071 return '<%= t('pedit.chip_track') %> ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
1072 }
1073 if (kind === 'album') return '<%= t('pedit.chip_album') %> ' + value;
1074 if (kind === 'playlist') return '<%= t('pedit.chip_playlist') %> ' + value;
1075 if (kind === 'embed') {
1076 const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
1077 return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
1078 }
1079 return value;
1080 }
1081
1082 function makeChip(kind, value) {
1083 const span = document.createElement('span');
1084 span.className = 'sc-chip';
1085 span.contentEditable = 'false';
1086 span.setAttribute('data-sc', kind + ':' + value);
1087 span.innerHTML =
1088 '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
1089 '<span class="sc-chip-label"></span>';
1090 span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
1091 return span;
1092 }
1093
1094 // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
1095 function chipifyShortcodes(root) {
1096 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
1097 const targets = [];
1098 while (walker.nextNode()) {
1099 const n = walker.currentNode;
1100 // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
1101 if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
1102 if (SC_RE.test(n.nodeValue)) targets.push(n);
1103 SC_RE.lastIndex = 0;
1104 }
1105 for (const node of targets) {
1106 const txt = node.nodeValue;
1107 const frag = document.createDocumentFragment();
1108 let last = 0;
1109 let m;
1110 SC_RE.lastIndex = 0;
1111 while ((m = SC_RE.exec(txt)) !== null) {
1112 if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
1113 frag.appendChild(makeChip(m[1], m[2].trim()));
1114 last = m.index + m[0].length;
1115 }
1116 if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
1117 node.parentNode.replaceChild(frag, node);
1118 }
1119 }
1120
1121 // Inverse of chipify: clone the editor, replace every chip with its text.
1122 function serializeChips(rootClone) {
1123 const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
1124 for (const c of chips) {
1125 const txt = '[[' + c.getAttribute('data-sc') + ']]';
1126 c.replaceWith(document.createTextNode(txt));
1127 }
1128 }
1129
1130 // ── Boot: load initial content as HTML, then render shortcodes as chips
1131 try {
1132 const initial = JSON.parse(initialEl.textContent || '""');
1133 editor.innerHTML = initial || '';
1134 chipifyShortcodes(editor);
1135 } catch (e) {
1136 console.error('[editor] could not parse initial content', e);
1137 editor.innerHTML = '';
1138 }
1139
1140 // ── Char counter
1141 function updateCharCount() {
1142 const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
1143 if (charCountEl) charCountEl.textContent = String(text.length);
1144 }
1145 updateCharCount();
1146 editor.addEventListener('input', updateCharCount);
1147
1148 // ── Toolbar wiring
1149 // Houd de scrollpositie vast rond een edit-commando. execCommand/insert scrollt
1150 // standaard de caret in beeld → het beeld "verspringt" bij het aanklikken van een
1151 // opmaakknop. We leggen ALLE scrollbare voorouders (editor, frame, #pcms-main, …)
1152 // + de pagina vast en zetten ze terug — sync én over een paar frames, want Chrome
1153 // scrollt soms pas een frame later. De gebruiker scrollt zo zelf.
1154 function scrollableAncestors(el) {
1155 const list = [];
1156 let node = el;
1157 while (node && node !== document.body && node !== document.documentElement) {
1158 const oy = getComputedStyle(node).overflowY;
1159 if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
1160 node = node.parentElement;
1161 }
1162 return list;
1163 }
1164 function keepScroll(fn) {
1165 // In fullscreen staat de pagina vast (body overflow:hidden) en mag het veld
1166 // gewoon naar de caret scrollen — geen pagina-sprong mogelijk, dus niets fixen.
1167 const frame = document.querySelector('.pe-editor-frame');
1168 if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
1169 const wx = window.scrollX, wy = window.scrollY;
1170 const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
1171 const restore = function () {
1172 window.scrollTo(wx, wy);
1173 anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
1174 };
1175 fn();
1176 restore();
1177 requestAnimationFrame(restore);
1178 }
1179 function execCmd(cmd, arg) {
1180 keepScroll(function () {
1181 editor.focus({ preventScroll: true });
1182 document.execCommand(cmd, false, arg);
1183 });
1184 updateToolbarState();
1185 updateCharCount();
1186 }
1187 function wrapCode() {
1188 const sel = window.getSelection();
1189 if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
1190 keepScroll(function () {
1191 const range = sel.getRangeAt(0);
1192 const code = document.createElement('code');
1193 code.textContent = sel.toString();
1194 range.deleteContents();
1195 range.insertNode(code);
1196 // Move caret after the new node
1197 range.setStartAfter(code);
1198 range.collapse(true);
1199 sel.removeAllRanges();
1200 sel.addRange(range);
1201 editor.focus({ preventScroll: true });
1202 });
1203 }
1204 function linkPrompt() {
1205 const url = window.prompt('<%= t('pedit.js_link_prompt') %>');
1206 if (!url) return;
1207 execCmd('createLink', url);
1208 }
1209 // De huidige selectie zit in een <blockquote> binnen de editor? Geef 'm terug.
1210 function blockquoteAncestor() {
1211 const sel = window.getSelection();
1212 if (!sel || sel.rangeCount === 0) return null;
1213 let node = sel.anchorNode;
1214 while (node && node !== editor) {
1215 if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
1216 node = node.parentNode;
1217 }
1218 return null;
1219 }
1220 // Echte toggle: execCommand('formatBlock','blockquote') zet 'm wél AAN maar
1221 // krijgt 'm nooit meer UIT (browser-quirk). Staat de caret al in een quote →
1222 // pak de wrapper uit; anders pas blockquote toe.
1223 function toggleBlockquote() {
1224 keepScroll(function () {
1225 editor.focus({ preventScroll: true });
1226 const bq = blockquoteAncestor();
1227 if (bq) {
1228 const parent = bq.parentNode;
1229 // Inhoud uit de quote halen, op z'n plek, en de lege wrapper verwijderen.
1230 const ref = bq;
1231 let firstMoved = null;
1232 while (bq.firstChild) {
1233 const child = bq.firstChild;
1234 if (!firstMoved) firstMoved = child;
1235 parent.insertBefore(child, ref);
1236 }
1237 parent.removeChild(bq);
1238 // Caret terugzetten in de uitgepakte inhoud.
1239 if (firstMoved) {
1240 const sel = window.getSelection();
1241 const range = document.createRange();
1242 range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
1243 range.collapse(false);
1244 sel.removeAllRanges();
1245 sel.addRange(range);
1246 }
1247 } else {
1248 document.execCommand('formatBlock', false, 'blockquote');
1249 }
1250 });
1251 updateToolbarState();
1252 updateCharCount();
1253 }
1254
1255 if (toolbar) {
1256 // CRUCIAAL (mobiel + desktop): voorkom dat een toolbar-knop de focus/selectie
1257 // uit het editor-veld steelt. Zonder dit raakt de selectie kwijt bij het tikken
1258 // → execCommand werkt op een lege selectie (vet kan niet meer UIT) én de browser
1259 // scrollt de caret opnieuw in beeld (de "sprong naar beneden"). preventDefault op
1260 // mousedown houdt de focus in de editor; de click blijft gewoon vuren.
1261 toolbar.addEventListener('mousedown', (e) => {
1262 if (e.target.closest('button')) e.preventDefault();
1263 });
1264 toolbar.addEventListener('click', (e) => {
1265 const btn = e.target.closest('button[data-cmd]');
1266 if (!btn) return;
1267 e.preventDefault();
1268 const cmd = btn.dataset.cmd;
1269 const arg = btn.dataset.arg || null;
1270 if (cmd === 'link-prompt') linkPrompt();
1271 else if (cmd === 'code-wrap') wrapCode();
1272 else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
1273 else execCmd(cmd, arg);
1274 });
1275 }
1276
1277 // ── Volledig-scherm schrijfmodus: het schrijfveld vult de hele pagina.
1278 const fsBtn = document.getElementById('pe-fullscreen-btn');
1279 const editorFrame = document.querySelector('.pe-editor-frame');
1280 const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
1281
1282 // Op mobiel duwt het toetsenbord de zichtbare (visual) viewport omhoog terwijl
1283 // een position:fixed-frame aan de LAYOUT-viewport blijft hangen → de toolbar
1284 // schuift uit beeld. Houd het fullscreen-frame daarom gelijk aan de visual
1285 // viewport (top + hoogte), zodat de toolbar altijd bovenaan zichtbaar blijft.
1286 function syncFsViewport() {
1287 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
1288 const vv = window.visualViewport;
1289 if (!vv) return;
1290 editorFrame.style.top = vv.offsetTop + 'px';
1291 editorFrame.style.height = vv.height + 'px';
1292 }
1293 function clearFsViewport() {
1294 if (!editorFrame) return;
1295 editorFrame.style.top = '';
1296 editorFrame.style.height = '';
1297 }
1298 function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
1299 function applyFs(on) {
1300 if (!editorFrame) return;
1301 editorFrame.classList.toggle('pe-fs', on);
1302 document.body.classList.toggle('pe-fs-open', on);
1303 document.documentElement.classList.toggle('pe-fs-open', on);
1304 if (fsBtn) {
1305 fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
1306 fsBtn.title = on ? '<%= t('pedit.tb_done') %>' : '<%= t('pedit.tb_fullscreen') %>';
1307 }
1308 if (window.visualViewport) {
1309 if (on) {
1310 window.visualViewport.addEventListener('resize', syncFsViewport);
1311 window.visualViewport.addEventListener('scroll', syncFsViewport);
1312 syncFsViewport();
1313 } else {
1314 window.visualViewport.removeEventListener('resize', syncFsViewport);
1315 window.visualViewport.removeEventListener('scroll', syncFsViewport);
1316 clearFsViewport();
1317 }
1318 }
1319 // Op touch is het veld inline NIET bewerkbaar; alleen in fullscreen wel.
1320 if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
1321 if (on) {
1322 editor.focus({ preventScroll: true });
1323 } else {
1324 if (isTouch) editor.blur();
1325 // Bij sluiten: naar de TOP van de content scrollen i.p.v. ergens onderaan
1326 // (footer) te blijven hangen.
1327 requestAnimationFrame(function () {
1328 try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
1329 });
1330 }
1331 }
1332 // De fullscreen schrijf-"pagina": openen pusht een history-state zodat de browser-
1333 // back-knop (en de Klaar-knop) 'm sluit en je terug bent in het formulier — voelt
1334 // als een aparte pagina, maar álle formuliervelden blijven intact (zelfde DOM).
1335 function openFs() {
1336 if (isFs()) return;
1337 try { history.pushState({ peFs: true }, ''); } catch (_) {}
1338 applyFs(true);
1339 }
1340 function closeFs() {
1341 if (!isFs()) return;
1342 if (history.state && history.state.peFs) history.back(); // → popstate sluit af
1343 else applyFs(false);
1344 }
1345 function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
1346 window.addEventListener('popstate', function () { if (isFs()) applyFs(false); });
1347 if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
1348 var fsDoneBtn = document.getElementById('pe-fs-done');
1349 if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs);
1350 document.addEventListener('keydown', (e) => {
1351 if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
1352 });
1353
1354 // Op mobiel/tablet (touch): het content-veld is INLINE NIET bewerkbaar — het is
1355 // dan geen tekstveld. Eén tik → fullscreen, waar het wél bewerkbaar wordt
1356 // (toggleFullscreen zet contenteditable aan/uit). Zo kun je nooit inline typen.
1357 if (isTouch) {
1358 editor.setAttribute('contenteditable', 'false');
1359 editor.classList.add('pe-tap-to-edit');
1360 editor.setAttribute('data-placeholder', '<%= t('pedit.tap_to_write') %>');
1361 editor.addEventListener('click', function () {
1362 if (!isFs()) openFs();
1363 });
1364 }
1365
1366 // Reflect bold/italic/list state on the toolbar buttons
1367 function updateToolbarState() {
1368 if (!toolbar) return;
1369 const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
1370 for (const cmd of cmds) {
1371 const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
1372 if (!btn) continue;
1373 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
1374 }
1375 // Quote-knop: actief als de caret in een <blockquote> staat (toggle-feedback).
1376 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
1377 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
1378 }
1379 document.addEventListener('selectionchange', () => {
1380 if (document.activeElement === editor) updateToolbarState();
1381 });
1382
1383 // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
1384 editor.addEventListener('keydown', (e) => {
1385 const mod = e.ctrlKey || e.metaKey;
1386 if (!mod) return;
1387 const k = e.key.toLowerCase();
1388 if (k === 'b') { e.preventDefault(); execCmd('bold'); }
1389 else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
1390 else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
1391 else if (k === 'k') { e.preventDefault(); linkPrompt(); }
1392 });
1393
1394 // Paste: keep it simple — strip formatting unless user wants it. Default
1395 // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
1396 // styles from clipboard only when shift is held — otherwise plain text.
1397 editor.addEventListener('paste', (e) => {
1398 if (e.shiftKey) return; // user wants formatted paste
1399 const text = (e.clipboardData || window.clipboardData).getData('text/plain');
1400 if (text == null) return;
1401 e.preventDefault();
1402 document.execCommand('insertText', false, text);
1403 });
1404
1405 // ── Image upload (button + drag-drop into the editor)
1406 async function uploadAndInsertImage(file) {
1407 contentStatus.classList.remove('is-error');
1408 contentStatus.textContent = '<%= t('pedit.js_uploading') %>';
1409 try {
1410 const j = await uploadImage(file);
1411 const img = '<img src="' + j.url + '" alt="">';
1412 editor.focus({ preventScroll: true });
1413 document.execCommand('insertHTML', false, img);
1414 contentStatus.textContent = '<%= t('pedit.js_inserted') %> ✓';
1415 setTimeout(() => { contentStatus.textContent = ''; }, 2000);
1416 updateCharCount();
1417 } catch (e) {
1418 contentStatus.classList.add('is-error');
1419 contentStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message;
1420 }
1421 }
1422
1423 if (contentBtn && contentField) {
1424 contentBtn.addEventListener('click', () => contentField.click());
1425 contentField.addEventListener('change', () => {
1426 if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
1427 contentField.value = '';
1428 });
1429
1430 editor.addEventListener('dragover', (e) => {
1431 if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
1432 e.preventDefault();
1433 editor.classList.add('is-dragover');
1434 }
1435 });
1436 editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
1437 editor.addEventListener('drop', async (e) => {
1438 editor.classList.remove('is-dragover');
1439 const files = e.dataTransfer && e.dataTransfer.files;
1440 if (!files || !files.length) return;
1441 e.preventDefault();
1442 for (const f of files) {
1443 if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
1444 }
1445 });
1446 }
1447
1448 // ── Insert chip helpers (track / playlist)
1449 function insertChip(kind, value) {
1450 editor.focus({ preventScroll: true });
1451 const chip = makeChip(kind, value);
1452 // Insert at caret using the Selection API (execCommand insertNode)
1453 const sel = window.getSelection();
1454 if (sel && sel.rangeCount > 0) {
1455 const range = sel.getRangeAt(0);
1456 range.deleteContents();
1457 range.insertNode(chip);
1458 // Insert a trailing space so the user can keep typing after the chip
1459 const space = document.createTextNode('\u00A0');
1460 chip.after(space);
1461 range.setStartAfter(space);
1462 range.collapse(true);
1463 sel.removeAllRanges();
1464 sel.addRange(range);
1465 } else {
1466 editor.appendChild(chip);
1467 editor.appendChild(document.createTextNode('\u00A0'));
1468 }
1469 updateCharCount();
1470 }
1471
1472 // ── Embed insert: plak een platform-URL -> [[embed:url]]-chip die server-side
1473 // een iframe wordt (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
1474 const embedBtn = document.getElementById('insert-embed-btn');
1475 if (embedBtn) {
1476 embedBtn.addEventListener('click', () => {
1477 const raw = window.prompt('<%= t('pedit.js_embed_prompt') %>');
1478 if (!raw) return;
1479 const url = raw.trim();
1480 if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; }
1481 insertChip('embed', url);
1482 });
1483 }
1484
1485 // ── Track insert: opens the track-picker modal (P59)
1486 const trackBtn = document.getElementById('insert-track-btn');
1487 const trackPicker = document.getElementById('track-picker');
1488 if (trackBtn && trackPicker) {
1489 const tpList = document.getElementById('tp-list');
1490 const tpEmpty = document.getElementById('tp-empty');
1491 const tpSearch = document.getElementById('tp-search');
1492 let tpCache = null; // cached tracks list (fetched once per page load)
1493 let tpLastFocus = null; // element to restore focus to on close
1494
1495 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>';
1496
1497 function fmtDur(sec) {
1498 sec = Math.max(0, Math.floor(sec || 0));
1499 const m = Math.floor(sec / 60), s = sec % 60;
1500 return m + ':' + String(s).padStart(2, '0');
1501 }
1502 function escAttr(s) {
1503 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
1504 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
1505 }[c]));
1506 }
1507
1508 function renderList(filter) {
1509 if (!Array.isArray(tpCache)) return;
1510 const q = (filter || '').trim().toLowerCase();
1511 const filtered = q
1512 ? tpCache.filter(t =>
1513 (t.title || '').toLowerCase().includes(q) ||
1514 (t.artist || '').toLowerCase().includes(q))
1515 : tpCache;
1516
1517 if (!filtered.length) {
1518 tpList.innerHTML = '';
1519 tpEmpty.textContent = q ? '<%= t('pedit.js_no_tracks_found') %> ' + q : '<%= t('pedit.js_no_tracks_yet') %>';
1520 tpList.appendChild(tpEmpty);
1521 return;
1522 }
1523
1524 tpList.innerHTML = filtered.map(t => {
1525 const cov = t.cover
1526 ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
1527 : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
1528 const dis = t.playable ? '' : ' aria-disabled="true"';
1529 const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
1530 return (
1531 '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
1532 cov +
1533 '<span class="tp-meta">' +
1534 '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
1535 sub +
1536 '</span>' +
1537 '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
1538 '</button>'
1539 );
1540 }).join('');
1541 }
1542
1543 async function loadTracks() {
1544 if (Array.isArray(tpCache)) return tpCache;
1545 tpEmpty.textContent = '<%= t('pedit.js_tracks_loading') %>';
1546 try {
1547 const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
1548 const j = await r.json();
1549 tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
1550 } catch (e) {
1551 tpCache = [];
1552 tpEmpty.textContent = '<%= t('pedit.js_tracks_load_fail') %>: ' + e.message;
1553 }
1554 return tpCache;
1555 }
1556
1557 function openPicker() {
1558 tpLastFocus = document.activeElement;
1559 trackPicker.hidden = false;
1560 trackPicker.setAttribute('aria-hidden', 'false');
1561 document.body.classList.add('tp-locked');
1562 tpSearch.value = '';
1563 renderList('');
1564 // Defer focus so the open animation doesn't get jumped
1565 setTimeout(() => tpSearch.focus(), 30);
1566 }
1567 function closePicker() {
1568 trackPicker.hidden = true;
1569 trackPicker.setAttribute('aria-hidden', 'true');
1570 document.body.classList.remove('tp-locked');
1571 if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
1572 try { tpLastFocus.focus(); } catch(_) {}
1573 }
1574 }
1575
1576 trackBtn.addEventListener('click', async () => {
1577 openPicker();
1578 await loadTracks();
1579 renderList(tpSearch.value);
1580 });
1581
1582 // Close: backdrop click, [data-tp-close], or Escape
1583 trackPicker.addEventListener('click', (e) => {
1584 if (e.target.closest('[data-tp-close]')) {
1585 closePicker();
1586 return;
1587 }
1588 const row = e.target.closest('.tp-row[data-track-id]');
1589 if (row) {
1590 if (row.getAttribute('aria-disabled') === 'true') return;
1591 const id = row.dataset.trackId;
1592 if (id) {
1593 insertChip('track', id);
1594 closePicker();
1595 }
1596 }
1597 });
1598 document.addEventListener('keydown', (e) => {
1599 if (!trackPicker.hidden && e.key === 'Escape') {
1600 e.preventDefault();
1601 closePicker();
1602 }
1603 });
1604
1605 // Live filter
1606 tpSearch.addEventListener('input', () => renderList(tpSearch.value));
1607 }
1608
1609 // ── Playlist insert (open existing or create new via modal)
1610 const playlistBtn = document.getElementById('insert-playlist-btn');
1611 if (playlistBtn) {
1612 playlistBtn.addEventListener('click', async () => {
1613 if (typeof window.openPlaylistEditor !== 'function') {
1614 alert('<%= t('pedit.js_playlist_editor_missing') %>');
1615 return;
1616 }
1617 try {
1618 const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
1619 const j = await r.json();
1620 if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
1621 const choice = prompt(
1622 '<%= t('pedit.js_playlist_existing') %>\n\n' +
1623 j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
1624 '\n\n<%= t('pedit.js_playlist_choose') %>'
1625 );
1626 if (choice && /^\d+$/.test(choice.trim())) {
1627 const idx = parseInt(choice.trim(), 10) - 1;
1628 if (idx >= 0 && idx < j.playlists.length) {
1629 insertChip('playlist', j.playlists[idx].id);
1630 return;
1631 }
1632 }
1633 if (choice === null) return;
1634 }
1635 } catch (_) { /* fall through to create */ }
1636
1637 window.openPlaylistEditor({
1638 mode: 'create',
1639 onSaved: ({ id }) => insertChip('playlist', id),
1640 });
1641 });
1642 }
1643
1644 // ── Submit: serialize editor contents into the hidden field
1645 if (form && hiddenField) {
1646 form.addEventListener('submit', () => {
1647 const clone = editor.cloneNode(true);
1648 serializeChips(clone);
1649 hiddenField.value = clone.innerHTML;
1650 });
1651 }
1652})();
1653</script>
1654
1655<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
1656 viewports, centered modal on ≥640px. Populated lazily on first open. %>
1657<% if (typeof user !== 'undefined' && user) { %>
1658<div id="track-picker" class="tp-modal" hidden aria-hidden="true">
1659 <div class="tp-backdrop" data-tp-close></div>
1660 <div class="tp-sheet" role="dialog" aria-modal="true" aria-labelledby="tp-title">
1661 <header class="tp-header">
1662 <h2 id="tp-title" class="tp-h2"><%= t('pedit.tp_title') %></h2>
1663 <button type="button" class="tp-close" data-tp-close aria-label="<%= t('pedit.tp_close') %>">
1664 <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>
1665 </button>
1666 </header>
1667 <div class="tp-search-row">
1668 <span class="tp-search-icon" aria-hidden="true">
1669 <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>
1670 </span>
1671 <input type="search" class="tp-search" id="tp-search" placeholder="<%= t('pedit.tp_search_placeholder') %>" autocomplete="off" inputmode="search">
1672 </div>
1673 <div class="tp-list" id="tp-list" role="listbox" aria-label="<%= t('pedit.tp_list_aria') %>">
1674 <div class="tp-empty" id="tp-empty"><%= t('pedit.js_tracks_loading') %></div>
1675 </div>
1676 </div>
1677</div>
1678<% } %>
1679
1680<%# Playlist editor modal — included so its global window.openPlaylistEditor
1681 is available when the toolbar button fires. Only renders if user can post. %>
1682<% if (typeof user !== 'undefined' && user) { %>
1683 <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
1684<% } %>
1685
1686<script>
1687(function () {
1688 // Vastpinnen: checkbox toggelt het verborgen rang-veld (0 = niet gepind),
1689 // ▲▼ verschuift de plek, met een leesbare beschrijving i.p.v. een ruw getal.
1690 var toggle = document.getElementById('pin-toggle');
1691 var rank = document.getElementById('pin-rank');
1692 var pos = document.getElementById('pin-pos');
1693 var label = document.getElementById('pin-label');
1694 var up = document.getElementById('pin-up'); // hoger = lager getal (richting 1/bovenaan)
1695 var down = document.getElementById('pin-down');
1696 if (!toggle || !rank || !pos) return;
1697
1698 function descr(n) {
1699 n = Number(n) || 0;
1700 if (n <= 1) return '<%= t('pedit.pin_top') %>';
1701 return n + '<%= t('pedit.pin_nth_suffix') %>';
1702 }
1703 function render() {
1704 var on = toggle.checked;
1705 pos.hidden = !on;
1706 if (on && Number(rank.value) < 1) rank.value = 1;
1707 if (!on) rank.value = 0;
1708 if (label) label.textContent = on ? descr(rank.value) : '';
1709 if (up) up.disabled = Number(rank.value) <= 1;
1710 }
1711 toggle.addEventListener('change', render);
1712 if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
1713 if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
1714 render();
1715})();
1716
1717(function () {
1718 // De Opslaan/Annuleren-balk (position: sticky; bottom:0) net boven twee mogelijke
1719 // obstakels houden door een dynamische bottom-offset te zetten = het grootste van:
1720 // 1) de hoogte van het toetsenbord-deel dat de layout-viewport NIET dekt
1721 // (op iOS verschuift de visual viewport; op Android verkleint de layout-
1722 // viewport door interactive-widget=resizes-content → offset ≈ 0);
1723 // 2) de hoogte van de spelende audiospeler (fixed, z-index 1000).
1724 // We blijven bij sticky (geen fixed/top-gegoochel → geen balk midden in beeld).
1725 var bar = document.querySelector('.pe-actions');
1726 if (!bar) return;
1727 var vv = window.visualViewport;
1728 function position() {
1729 var ap = document.querySelector('.audio-player');
1730 var playing = document.body.classList.contains('has-audio-player') &&
1731 ap && getComputedStyle(ap).display !== 'none';
1732 var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
1733 var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
1734 var offset = Math.max(audioOffset, kbCovered);
1735 bar.style.bottom = offset ? offset + 'px' : '';
1736 }
1737 position();
1738 window.addEventListener('resize', position);
1739 if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
1740 // has-audio-player wisselt via een body-class → daarop reageren.
1741 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
1742})();
1743</script>
Note: See TracBrowser for help on using the repository browser.