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

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

feat(instellingen): site timezone — all dates + scheduling in chosen zone

Admin → Settings gets a Timezone picker (IANA, validated via Intl; empty =
server/UTC). Applied to: server-side date display (formatDate/formatDateTime
receive timeZone), and post scheduling (datetime-local is now interpreted/shown
in the site's zone instead of browser-local — wall↔UTC via Intl offset).
'Scheduled for' shows the time in the site's zone. NL/EN/DE.

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

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