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

main
Last change on this file since 0c8768a was 62dea30, checked in by Robin Genis <roboburr@…>, 3 months ago

editor: remove the 'Start writing…' placeholder (lingered behind chips/embeds)

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

  • Property mode set to 100644
File size: 83.7 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 mode, save/create/cancel must carry the /user/<slug>/ prefix, otherwise
12// the request falls back to the primary site (siteUrlBase empty) and the post
13// renders headerless (bareChrome) after saving. In solo mode _base is empty.
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') %>"></div>
138
139 <%# Sticky "tap to edit" hint (only visible on touch + non-fullscreen
140 via CSS). Purely visual (pointer-events:none); tapping the field opens fullscreen. %>
141 <div class="pe-edit-hint" aria-hidden="true">✎ <%= t('pedit.tap_to_edit') %></div>
142
143 <%# Hidden field is what actually submits to the server. The visible
144 contenteditable's serialized HTML is copied here on submit. %>
145 <input type="hidden" name="content" id="content-hidden" value="">
146
147 <%# Initial content is injected as a JSON string in a script tag so we
148 can set innerHTML safely from JS. EJS-escaping HTML directly into
149 the editor would render entity-escaped tags as text. %>
150 <script id="initial-content" type="application/json"><%- JSON.stringify(post.content || '').replace(/</g, '\\u003c') %></script>
151
152 <div class="pe-editor-status">
153 <small id="content-upload-status" class="pe-status"></small>
154 <span class="pe-char-count"><span id="char-count">0</span> <%= t('pedit.chars') %></span>
155 </div>
156
157 <input type="file" id="content-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
158 </div>
159 </section>
160
161 <%# ── META ─────────────────────────────────────────────────── %>
162 <section class="pe-card">
163 <div class="pe-section-title"><%= t('pedit.s_publication') %></div>
164 <div class="pe-row pe-row-2">
165 <label class="pe-field">
166 <span><%= t('pedit.f_status') %></span>
167 <% var _st = isNew ? 'published' : (post.status || 'draft'); %>
168 <select name="status">
169 <option value="published" <%= _st === 'published' ? 'selected' : '' %>><%= t('pedit.status_published') %></option>
170 <option value="draft" <%= _st === 'draft' ? 'selected' : '' %>><%= t('pedit.status_draft') %></option>
171 <option value="archived" <%= _st === 'archived' ? 'selected' : '' %>><%= t('pedit.status_archived') %></option>
172 </select>
173 </label>
174 <label class="pe-field">
175 <span><%= t('pedit.f_type') %></span>
176 <select name="type">
177 <% var ptype = post.type || 'post'; %>
178 <option value="post" <%= ptype === 'post' ? 'selected' : '' %>><%= t('pedit.type_post') %></option>
179 <option value="foto" <%= ptype === 'foto' ? 'selected' : '' %>><%= t('pedit.type_foto') %></option>
180 <option value="video" <%= ptype === 'video' ? 'selected' : '' %>><%= t('pedit.type_video') %></option>
181 <option value="audio" <%= ptype === 'audio' ? 'selected' : '' %>><%= t('pedit.type_audio') %></option>
182 </select>
183 </label>
184 </div>
185 <div class="pe-checkboxes">
186 <div class="pe-pin">
187 <label class="pe-checkbox">
188 <input type="checkbox" id="pin-toggle" <%= (Number(post.pinned) > 0) ? 'checked' : '' %>>
189 <span>📌 <%= t('pedit.pin_label') %></span>
190 </label>
191 <input type="hidden" name="pinned" id="pin-rank" value="<%= post.pinned || 0 %>">
192 <div class="pe-pin-pos" id="pin-pos" <%= (Number(post.pinned) > 0) ? '' : 'hidden' %>>
193 <span class="pe-pin-steps">
194 <button type="button" class="pe-pin-btn" id="pin-up" aria-label="<%= t('pedit.pin_up') %>">▲</button>
195 <button type="button" class="pe-pin-btn" id="pin-down" aria-label="<%= t('pedit.pin_down') %>">▼</button>
196 </span>
197 <span class="pe-pin-label" id="pin-label"></span>
198 </div>
199 </div>
200 <label class="pe-checkbox">
201 <input type="checkbox" name="noindex" value="1" <%= post.noindex ? 'checked' : '' %>>
202 <span>🚫 <%= t('pedit.noindex_label') %></span>
203 </label>
204 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
205 <label class="pe-checkbox">
206 <input type="checkbox" name="fan_only" value="1" <%= post.fan_only ? 'checked' : '' %>>
207 <span>🔒 <%= t('pedit.fan_only_label') %></span>
208 </label>
209 <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
210 <label class="pe-checkbox" style="margin-top:8px">
211 <input type="checkbox" name="schedule_enabled" value="1" id="pe-sched-toggle" <%= _scheduled ? 'checked' : '' %>>
212 <span>📅 <%= t('pedit.schedule_label') %></span>
213 </label>
214 <div id="pe-sched-fields" style="margin-top:6px;<%= _scheduled ? '' : 'display:none' %>">
215 <label style="display:block;font-size:12.5px;opacity:.8;margin-bottom:4px"><%= t('pedit.publish_at_label') %></label>
216 <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">
217 <% 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><% } %>
218 <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
219 </div>
220 <script>
221 (function () {
222 var cb = document.getElementById('pe-sched-toggle');
223 var box = document.getElementById('pe-sched-fields');
224 if (!cb || !box) return;
225 var inp = document.getElementById('pe-publish-at');
226 var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local
227 var pad = function (n) { return String(n).padStart(2, '0'); };
228 // Offset (ms) between a timezone and UTC at a given moment.
229 function tzOffset(date, tz) {
230 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' });
231 var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; });
232 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
233 }
234 // datetime-local "wall time" (in the site zone) → UTC Date.
235 function wallToUtc(wall) {
236 if (!SITE_TZ) return new Date(wall);
237 var guess = new Date(wall + ':00Z').getTime();
238 return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
239 }
240 // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
241 function utcToWall(iso) {
242 var d = new Date(iso); if (isNaN(d)) return '';
243 if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
244 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' });
245 var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; });
246 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
247 }
248 // Prefill: stored UTC → wall time in the site zone.
249 if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
250 // "Scheduled for" in human-readable time in the site zone.
251 var when = document.getElementById('pe-sched-when');
252 if (when && when.dataset.iso) {
253 var dw = new Date(when.dataset.iso);
254 if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined);
255 }
256 function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
257 cb.addEventListener('change', sync); sync();
258 // On save: wall time in the site zone → UTC-ISO via a hidden field.
259 var form = cb.closest('form');
260 if (form) {
261 form.addEventListener('submit', function () {
262 if (inp) inp.removeAttribute('name');
263 var old = form.querySelector('input[data-pa-utc]');
264 if (old) old.remove();
265 if (cb.checked && inp && inp.value) {
266 var d2 = wallToUtc(inp.value);
267 if (!isNaN(d2)) {
268 var h = document.createElement('input');
269 h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', '');
270 h.value = d2.toISOString();
271 form.appendChild(h);
272 }
273 }
274 });
275 }
276 })();
277 </script>
278 <% } %>
279 </div>
280 </section>
281
282 <%# ── ACTIONS (sticky at bottom) ──────────────────────────── %>
283 <div class="pe-actions">
284 <a href="<%= _base %><%= isNew ? '/' : '/' + post.slug %>" class="pe-btn"><%= t('pedit.cancel') %></a>
285 <div class="pe-actions-spacer"></div>
286 <% if (!isNew && post.status !== 'published') { %>
287 <button type="submit" name="action" value="publish" class="pe-btn pe-btn-success">📤 <%= t('pedit.publish') %></button>
288 <% } %>
289 <button type="submit" class="pe-btn pe-btn-primary">💾 <%= t('pedit.save') %></button>
290 </div>
291 </form>
292</div>
293
294<style>
295/* ─── Page wrapper ──────────────────────────────────────────────── */
296.post-edit-page {
297 max-width: 880px;
298 margin: 1.5rem auto 6rem; /* extra bottom for sticky-actions clearance */
299 padding: 0 1rem;
300}
301.post-edit-page h1 {
302 font-family: var(--font-display, serif);
303 margin: 0 0 1.25rem;
304 font-size: 1.75rem;
305}
306
307.post-edit-form {
308 display: flex; flex-direction: column;
309 gap: 1rem;
310}
311
312/* ─── Card sections ─────────────────────────────────────────────── */
313.pe-card {
314 background: var(--paper);
315 border: 1px solid var(--rule);
316 border-radius: 12px;
317 padding: 1.25rem;
318 display: flex; flex-direction: column;
319 gap: 0.85rem;
320}
321.pe-card-content { padding-bottom: 0; overflow: hidden; } /* editor flush to bottom */
322.pe-section-title {
323 font-size: 0.8rem;
324 font-weight: 600;
325 text-transform: uppercase;
326 letter-spacing: 0.05em;
327 color: var(--ink-soft);
328 display: flex; align-items: baseline; gap: 0.5rem;
329 flex-wrap: wrap;
330}
331.pe-section-title small {
332 font-size: 0.75rem; font-weight: 400;
333 letter-spacing: 0; text-transform: none;
334 color: var(--ink-muted, var(--ink-soft));
335}
336
337/* ─── Field primitives ──────────────────────────────────────────── */
338.pe-field {
339 display: flex; flex-direction: column;
340 gap: 0.35rem;
341 min-width: 0; /* allow grid items to shrink */
342}
343.pe-field > span {
344 font-size: 0.8rem;
345 font-weight: 600;
346 color: var(--ink-soft);
347 display: flex; align-items: baseline; gap: 0.4rem;
348}
349.pe-field > span small {
350 font-weight: 400; color: var(--ink-muted);
351}
352.pe-field input,
353.pe-field textarea,
354.pe-field select {
355 width: 100%;
356 box-sizing: border-box;
357 display: block;
358 padding: 0.6rem 0.75rem;
359 border: 1px solid var(--rule);
360 border-radius: 6px;
361 background: var(--paper-2);
362 color: var(--ink);
363 font-family: var(--font-ui, system-ui), sans-serif;
364 font-size: 0.95rem;
365 -webkit-appearance: none;
366 appearance: none;
367 transition: border-color 120ms;
368}
369.pe-field input:focus,
370.pe-field textarea:focus,
371.pe-field select:focus {
372 outline: 2px solid var(--accent);
373 outline-offset: -1px;
374 border-color: var(--accent);
375}
376.pe-field textarea {
377 font-family: var(--font-mono, monospace);
378 resize: vertical;
379}
380.pe-field select {
381 /* Restore the dropdown arrow we removed with appearance:none */
382 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>");
383 background-repeat: no-repeat;
384 background-position: right 0.75rem center;
385 padding-right: 2rem;
386}
387
388.pe-row {
389 display: grid;
390 gap: 0.75rem;
391}
392.pe-row-2 { grid-template-columns: 1fr 1fr; }
393@media (max-width: 600px) {
394 .pe-row-2 { grid-template-columns: 1fr; }
395}
396
397/* ─── Cover field ───────────────────────────────────────────────── */
398.pe-cover-row {
399 display: grid;
400 grid-template-columns: 120px 1fr;
401 gap: 1rem;
402 align-items: start;
403}
404.pe-cover-thumb {
405 width: 120px; height: 120px;
406 border-radius: 10px;
407 overflow: hidden;
408 background: var(--paper-2);
409 border: 1px solid var(--rule);
410 display: flex; align-items: center; justify-content: center;
411 position: relative;
412}
413.pe-cover-thumb[data-empty] {
414 border-style: dashed;
415}
416.pe-cover-thumb img {
417 width: 100%; height: 100%;
418 object-fit: cover; display: block;
419}
420/* Honor HTML hidden — our display:block above otherwise renders an empty
421 broken-image icon when no cover is set. */
422.pe-cover-thumb img[hidden] { display: none; }
423.pe-cover-empty {
424 font-size: 2rem;
425 color: var(--ink-muted, var(--ink-soft));
426 opacity: 0.5;
427}
428.pe-cover-actions {
429 display: flex; flex-direction: column;
430 gap: 0.6rem;
431 min-width: 0;
432}
433.pe-cover-upload {
434 display: flex; align-items: center; gap: 0.6rem;
435 flex-wrap: wrap;
436}
437@media (max-width: 600px) {
438 .pe-cover-row { grid-template-columns: 88px 1fr; }
439 .pe-cover-thumb { width: 88px; height: 88px; }
440}
441
442/* ─── Editor frame (WYSIWYG: top toolbar, contenteditable body, status bar) ─── */
443.pe-editor-frame {
444 margin: 0 -1.25rem -1.25rem; /* break out of card padding for flush edges */
445 border-top: 1px solid var(--rule);
446 background: var(--paper);
447 display: flex; flex-direction: column;
448}
449
450/* Top toolbar — sticks to the top of the viewport while editing */
451.pe-toolbar {
452 display: flex; align-items: center; gap: .15rem;
453 flex-wrap: wrap;
454 padding: .4rem .75rem;
455 background: color-mix(in srgb, var(--paper) 92%, transparent);
456 backdrop-filter: blur(10px);
457 -webkit-backdrop-filter: blur(10px);
458 border-bottom: 1px solid var(--rule);
459 position: sticky;
460 top: 0;
461 z-index: 10;
462}
463.pe-toolbar button {
464 display: inline-flex; align-items: center; justify-content: center;
465 width: 32px; height: 32px;
466 padding: 0;
467 touch-action: manipulation; /* snappy taps on mobile, no double-tap zoom */
468 -webkit-user-select: none; user-select: none;
469 background: transparent;
470 border: 1px solid transparent;
471 border-radius: 6px;
472 color: var(--ink);
473 cursor: pointer;
474 font-family: var(--font-ui, system-ui), sans-serif;
475 font-weight: 600; font-size: .85rem;
476 transition: background var(--transition), color var(--transition), border-color var(--transition);
477 -webkit-tap-highlight-color: transparent;
478}
479/* Hover only on real hover-capable devices — on touch :hover otherwise "sticks"
480 after a tap, making the active/inactive state unreadable. */
481@media (hover: hover) {
482 .pe-toolbar button:hover {
483 background: var(--paper-2);
484 color: var(--accent);
485 }
486}
487.pe-toolbar button:active { transform: scale(.94); }
488/* Active formatting = unmistakably filled with the accent colour. On mobile
489 it's immediately clear when e.g. bold is ON (tap again = off). */
490.pe-toolbar button.is-active {
491 background: var(--accent);
492 color: #fff;
493 border-color: var(--accent);
494}
495.pe-toolbar button.is-active svg { color: #fff; }
496.pe-toolbar button svg { width: 16px; height: 16px; }
497.pe-toolbar button.pe-tb-text { font-family: var(--font-display, serif); font-weight: 700; }
498.pe-toolbar-sep {
499 width: 1px; height: 18px;
500 background: var(--rule);
501 margin: 0 .35rem;
502 flex-shrink: 0;
503}
504.pe-toolbar-spacer { flex: 1; }
505
506/* ─── Full-screen writing mode ─── */
507.fs-icon-compress { display: none; }
508.pe-editor-frame.pe-fs .fs-icon-expand { display: none; }
509.pe-editor-frame.pe-fs .fs-icon-compress { display: inline; }
510.pe-editor-frame.pe-fs {
511 position: fixed; inset: 0; z-index: 1000;
512 margin: 0; border-top: 0;
513 height: 100dvh;
514 background: var(--paper);
515 overflow: hidden; /* only the text field scrolls, not the frame itself */
516}
517/* In fullscreen the writing field fills the remaining space and scrolls itself
518 (max-height: none overrides the mobile box limit below). */
519.pe-editor-frame.pe-fs .pe-editor {
520 flex: 1 1 auto; min-height: 0; height: auto; max-height: none;
521}
522/* NB: deliberately NO overflow:hidden on html/body in fullscreen — that could get
523 stuck (e.g. leaving fullscreen via navigation) and would block scrolling on the
524 whole page. The fullscreen frame (position:fixed, inset:0) already covers the page,
525 and on touch scrollbars are hidden → no double bar needed. */
526
527/* "Done" button: only visible in fullscreen (left side of toolbar), clear
528 accent-pill instead of a square icon. */
529/* Hide rule more specific than ".pe-toolbar button" (otherwise that wins and the
530 Done button also shows inline as a small button). Only shown in fullscreen → large. */
531.pe-toolbar button.pe-fs-done { display: none; }
532.pe-editor-frame.pe-fs .pe-fs-done {
533 display: inline-flex; align-items: center; gap: .35rem;
534 width: auto !important; height: auto !important; min-height: 40px;
535 padding: .55rem 1.3rem !important; margin-right: .5rem;
536 background: var(--accent); color: #fff;
537 font-weight: 700; font-size: 1.05rem; border-radius: 999px;
538}
539.pe-editor-frame.pe-fs .pe-fs-done:hover { background: var(--accent); color: #fff; }
540
541/* Editor body — looks like prose, behaves like a textarea */
542.pe-editor {
543 width: 100%;
544 box-sizing: border-box;
545 min-height: 420px;
546 padding: 1.25rem 1.5rem;
547 border: 0;
548 background: transparent;
549 color: var(--ink);
550 font-family: var(--font-body, system-ui), serif;
551 font-size: 1.0625rem;
552 line-height: 1.65;
553 outline: none;
554 overflow-y: auto;
555}
556.pe-editor:focus { outline: none; }
557/* Inline (non-fullscreen) the writing field simply grows with the content — no
558 internal scroll-box (scroll-within-scroll is confusing). On mobile/tablet typing
559 always goes fullscreen (see JS), where the field fills the page and is the sole
560 scroller. */
561.pe-editor-frame:not(.pe-fs) .pe-editor { overflow: visible; }
562
563/* Touch: the inline content field is not a text field but a tap target → fullscreen
564 editing. A "✎ Tap to edit" pill sticks to the bottom of the container,
565 so the hint is always visible without sitting in the middle of the text. */
566.pe-editor.pe-tap-to-edit { cursor: pointer; }
567.pe-edit-hint { display: none; }
568@media (pointer: coarse) {
569 .pe-editor-frame:not(.pe-fs) .pe-edit-hint {
570 display: block;
571 position: sticky;
572 bottom: 4.5rem; /* above the sticky Save/Cancel bar */
573 width: max-content;
574 max-width: calc(100% - 2rem);
575 margin: .4rem auto;
576 background: var(--accent); color: #fff;
577 font-size: .9rem; font-weight: 700; padding: .5rem 1.1rem; border-radius: 999px;
578 box-shadow: 0 4px 14px rgba(0,0,0,.35);
579 pointer-events: none; text-align: center; white-space: nowrap;
580 }
581 /* Not relevant on touch (drag/select belongs to inline editing on desktop). */
582 .pe-content-hint { display: none; }
583 /* Inline (non-fullscreen) on touch: keep it simple — you don't edit here anyway,
584 so no formatting toolbar and no border. Just the content preview + the
585 "tap to edit" pill. The toolbar only appears in fullscreen. */
586 .pe-editor-frame:not(.pe-fs) .pe-toolbar { display: none; }
587 .pe-editor-frame:not(.pe-fs) { border-top: 0; }
588 .pe-editor-frame:not(.pe-fs) .pe-editor { min-height: 8rem; }
589}
590.pe-editor.is-dragover {
591 outline: 2px dashed var(--accent);
592 outline-offset: -10px;
593}
594/* Inline prose styles inside the editor — match the public post styling so
595 what you see is roughly what you'll get. Margins are tighter than on the
596 live site since this is a confined writing space. */
597.pe-editor h1, .pe-editor h2, .pe-editor h3, .pe-editor h4 {
598 font-family: var(--font-display, serif);
599 line-height: 1.2;
600 margin: 1.1rem 0 .35rem;
601}
602.pe-editor h1 { font-size: 1.85rem; }
603.pe-editor h2 { font-size: 1.45rem; }
604.pe-editor h3 { font-size: 1.2rem; }
605.pe-editor p { margin: 0 0 .85em; }
606.pe-editor ul, .pe-editor ol { margin: 0 0 .85em 1.4em; padding: 0; }
607.pe-editor li { margin: .15em 0; }
608.pe-editor blockquote {
609 margin: 1em 0;
610 padding: .15em 0 .15em 1em;
611 border-left: 3px solid var(--accent);
612 color: var(--ink-soft, var(--ink));
613 font-style: italic;
614}
615.pe-editor code {
616 background: var(--paper-2);
617 border: 1px solid var(--rule);
618 border-radius: 4px;
619 padding: .1em .35em;
620 font-family: var(--font-mono, ui-monospace, monospace);
621 font-size: .92em;
622}
623.pe-editor a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
624.pe-editor img {
625 max-width: 100%; height: auto;
626 border-radius: 6px;
627 display: block;
628 margin: 1em auto;
629}
630
631/* Shortcode chips — visible inline placeholder in the editor for
632 [[track:UUID]] / [[album:Name]] / [[playlist:slug]]. They serialize back
633 to plain shortcode text on submit. */
634.sc-chip {
635 display: inline-flex; align-items: center; gap: .3rem;
636 padding: .15em .5em;
637 margin: 0 .15em;
638 background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
639 border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
640 border-radius: 999px;
641 color: var(--accent);
642 font-family: var(--font-ui, system-ui), sans-serif;
643 font-size: .85em;
644 font-weight: 600;
645 font-style: normal;
646 white-space: nowrap;
647 user-select: none;
648 cursor: default;
649 vertical-align: baseline;
650}
651.sc-chip-icon { display: inline-flex; opacity: .8; }
652.sc-chip-icon svg { width: 12px; height: 12px; }
653
654/* Status bar below the editor */
655.pe-editor-status {
656 display: flex; align-items: center; justify-content: space-between;
657 gap: .75rem;
658 padding: .45rem 1rem;
659 border-top: 1px solid var(--rule);
660 background: var(--paper-2);
661 color: var(--ink-muted, var(--ink-soft));
662 font-size: .78rem;
663}
664.pe-char-count { font-variant-numeric: tabular-nums; }
665
666/* ─── Track picker modal (P59) ─────────────────────────────────
667 Mobile-first: full-screen bottom-sheet on phones, centered card
668 on desktop. Lock body scroll while open via .tp-locked on body. */
669.tp-modal {
670 position: fixed; inset: 0;
671 z-index: 1000;
672 display: flex; align-items: stretch; justify-content: center;
673 /* Avoid the iOS home-indicator + the keyboard when search is focused */
674 padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
675}
676.tp-modal[hidden] { display: none; }
677.tp-backdrop {
678 position: absolute; inset: 0;
679 background: color-mix(in srgb, var(--ink) 55%, transparent);
680 backdrop-filter: blur(4px);
681 -webkit-backdrop-filter: blur(4px);
682 animation: tp-bd-in .18s ease-out;
683}
684@keyframes tp-bd-in { from { opacity: 0 } to { opacity: 1 } }
685
686/* Mobile: sheet docks to the bottom and fills viewport almost completely */
687.tp-sheet {
688 position: relative;
689 margin-top: auto;
690 width: 100%;
691 max-height: calc(100dvh - env(safe-area-inset-top) - 1rem);
692 background: var(--paper);
693 border-top: 1px solid var(--rule);
694 border-radius: 16px 16px 0 0;
695 box-shadow:
696 0 -2px 8px color-mix(in srgb, var(--ink) 8%, transparent),
697 0 -16px 48px color-mix(in srgb, var(--ink) 18%, transparent);
698 display: flex; flex-direction: column;
699 animation: tp-sheet-up .22s cubic-bezier(.2, .8, .25, 1);
700 overflow: hidden;
701}
702@keyframes tp-sheet-up {
703 from { transform: translateY(100%); opacity: .8; }
704 to { transform: translateY(0); opacity: 1; }
705}
706
707/* Header — title + close, with a small grab-handle bar on mobile */
708.tp-header {
709 display: flex; align-items: center; justify-content: space-between;
710 gap: .75rem;
711 padding: .85rem 1rem .65rem;
712 border-bottom: 1px solid var(--rule);
713 position: relative;
714}
715.tp-header::before {
716 /* Grab-handle: visible on mobile only */
717 content: '';
718 position: absolute;
719 top: .35rem; left: 50%;
720 transform: translateX(-50%);
721 width: 38px; height: 4px;
722 background: var(--rule-2, var(--rule));
723 border-radius: 2px;
724}
725.tp-h2 {
726 margin: .35rem 0 0;
727 font-family: var(--font-display, serif);
728 font-size: 1.15rem; font-weight: 600;
729 color: var(--ink);
730 line-height: 1.1;
731}
732.tp-close {
733 width: 32px; height: 32px; border-radius: 8px;
734 display: inline-flex; align-items: center; justify-content: center;
735 background: transparent; border: 1px solid transparent;
736 color: var(--ink-muted, var(--ink-soft));
737 cursor: pointer;
738 transition: background var(--transition), color var(--transition), border-color var(--transition);
739 -webkit-tap-highlight-color: transparent;
740 margin-top: .35rem;
741}
742.tp-close:hover, .tp-close:focus-visible {
743 background: var(--paper-2);
744 color: var(--ink);
745 outline: none;
746}
747.tp-close svg { width: 18px; height: 18px; }
748
749/* Search row — sticky just below header so list scrolls underneath */
750.tp-search-row {
751 position: relative;
752 padding: .65rem 1rem;
753 border-bottom: 1px solid var(--rule);
754 background: var(--paper);
755}
756.tp-search-icon {
757 position: absolute;
758 top: 50%; left: 1.65rem;
759 transform: translateY(-50%);
760 color: var(--ink-muted, var(--ink-soft));
761 pointer-events: none;
762 display: inline-flex;
763}
764.tp-search-icon svg { width: 16px; height: 16px; }
765.tp-search {
766 width: 100%; box-sizing: border-box;
767 padding: .65rem .85rem .65rem 2.4rem;
768 font-family: var(--font-ui, system-ui), sans-serif;
769 font-size: 1rem;
770 background: var(--paper-2);
771 color: var(--ink);
772 border: 1px solid var(--rule);
773 border-radius: 9px;
774 -webkit-appearance: none;
775 appearance: none;
776}
777.tp-search:focus {
778 outline: none;
779 border-color: var(--accent);
780 box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
781}
782
783/* List — vertically scrollable, takes remaining sheet height */
784.tp-list {
785 flex: 1 1 auto;
786 overflow-y: auto;
787 -webkit-overflow-scrolling: touch;
788 padding: .35rem .35rem 1rem;
789}
790.tp-empty {
791 padding: 1.5rem 1rem;
792 text-align: center;
793 color: var(--ink-muted, var(--ink-soft));
794 font-size: .9rem;
795}
796
797/* Track row — tap target ≥ 56px for mobile */
798.tp-row {
799 display: grid;
800 grid-template-columns: 44px 1fr auto;
801 align-items: center;
802 gap: .75rem;
803 width: 100%;
804 padding: .55rem .65rem;
805 border: 0; background: transparent;
806 border-radius: 10px;
807 text-align: left;
808 cursor: pointer;
809 color: inherit;
810 font: inherit;
811 transition: background var(--transition);
812 -webkit-tap-highlight-color: transparent;
813}
814.tp-row:hover, .tp-row:focus-visible {
815 background: color-mix(in srgb, var(--accent) 8%, var(--paper-2));
816 outline: none;
817}
818.tp-row:active { transform: scale(.98); }
819.tp-row[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
820
821.tp-cover {
822 width: 44px; height: 44px;
823 border-radius: 6px;
824 background-size: cover; background-position: center;
825 background-color: var(--rule);
826 display: inline-flex; align-items: center; justify-content: center;
827 flex-shrink: 0;
828}
829.tp-cover-empty {
830 background: linear-gradient(135deg,
831 color-mix(in srgb, var(--accent) 30%, var(--paper-2)),
832 color-mix(in srgb, var(--accent) 8%, var(--paper-2)));
833 color: var(--accent);
834}
835.tp-cover-empty svg { width: 18px; height: 18px; opacity: .8; }
836
837.tp-meta {
838 display: flex; flex-direction: column;
839 min-width: 0;
840 line-height: 1.25;
841}
842.tp-row-title {
843 font-weight: 600; font-size: .94rem;
844 color: var(--ink);
845 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
846}
847.tp-row-artist {
848 font-size: .8rem;
849 color: var(--ink-muted, var(--ink-soft));
850 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
851 margin-top: .1rem;
852}
853.tp-duration {
854 font-variant-numeric: tabular-nums;
855 font-size: .8rem;
856 color: var(--ink-muted, var(--ink-soft));
857 flex-shrink: 0;
858}
859
860/* Body lock when modal open */
861body.tp-locked { overflow: hidden; touch-action: none; }
862
863/* Desktop: centered card, max-width ~520px */
864@media (min-width: 640px) {
865 .tp-modal {
866 align-items: center;
867 padding: 1.5rem;
868 }
869 .tp-sheet {
870 margin-top: 0;
871 width: 100%;
872 max-width: 520px;
873 max-height: 80dvh;
874 border-radius: 14px;
875 border: 1px solid var(--rule);
876 animation: tp-sheet-pop .2s cubic-bezier(.2, .8, .25, 1);
877 }
878 .tp-header::before { display: none; } /* hide grab-handle on desktop */
879 .tp-header { padding: 1rem 1.25rem .75rem; }
880 .tp-h2 { margin-top: 0; font-size: 1.25rem; }
881 .tp-close { margin-top: 0; }
882 @keyframes tp-sheet-pop {
883 from { transform: translateY(-8px) scale(.97); opacity: 0; }
884 to { transform: translateY(0) scale(1); opacity: 1; }
885 }
886}
887
888/* ─── Buttons ───────────────────────────────────────────────────── */
889.pe-btn {
890 display: inline-flex; align-items: center; gap: 0.4rem;
891 padding: 0.55rem 1rem;
892 border: 1px solid var(--rule);
893 background: var(--paper-2);
894 color: var(--ink);
895 font-family: var(--font-ui, system-ui), sans-serif;
896 font-size: 0.9rem; font-weight: 500;
897 text-decoration: none;
898 border-radius: 6px;
899 cursor: pointer;
900 transition: background 120ms, border-color 120ms;
901 min-height: 40px;
902 -webkit-tap-highlight-color: transparent;
903}
904.pe-btn:hover { border-color: var(--accent); }
905.pe-btn:active { transform: scale(0.97); }
906.pe-btn-tiny {
907 padding: 0.35rem 0.7rem;
908 font-size: 0.85rem;
909 min-height: 32px;
910}
911.pe-btn-secondary { background: var(--paper-2); }
912.pe-btn-primary {
913 background: var(--accent); color: white;
914 border-color: var(--accent);
915}
916.pe-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
917.pe-btn-success {
918 background: #16a34a; color: white;
919 border-color: #16a34a;
920}
921.pe-btn-success:hover { opacity: 0.92; border-color: #16a34a; }
922
923.pe-status {
924 color: var(--ink-muted, var(--ink-soft));
925 font-size: 0.8rem;
926}
927.pe-status.is-error { color: #dc2626; }
928
929/* ─── Checkboxes (clean inline row) ─────────────────────────────── */
930.pe-checkboxes {
931 display: flex; flex-direction: column;
932 gap: 0.5rem;
933 padding-top: 0.25rem;
934}
935.pe-checkbox {
936 display: inline-flex; align-items: center; gap: 0.5rem;
937 cursor: pointer;
938 font-size: 0.95rem;
939 color: var(--ink);
940 user-select: none;
941}
942.pe-checkbox input[type="checkbox"] {
943 width: 18px; height: 18px;
944 margin: 0;
945 cursor: pointer;
946 accent-color: var(--accent);
947}
948
949/* Pin: checkbox + ▲▼-stepper with description (instead of a raw rank number). */
950.pe-pin { display: flex; flex-direction: column; gap: 0.45rem; }
951.pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; }
952.pe-pin-pos[hidden] { display: none; }
953.pe-pin-steps { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
954.pe-pin-btn {
955 width: 30px; height: 28px; padding: 0;
956 border: none; background: var(--paper-2); color: var(--ink);
957 cursor: pointer; font-size: 0.68rem; line-height: 1;
958 display: inline-flex; align-items: center; justify-content: center;
959}
960.pe-pin-btn + .pe-pin-btn { border-left: 1px solid var(--rule); }
961.pe-pin-btn:hover { background: color-mix(in srgb, var(--accent) 14%, var(--paper-2)); color: var(--accent); }
962.pe-pin-btn:disabled { opacity: 0.35; cursor: default; }
963.pe-pin-label { font-size: 0.85rem; color: var(--accent); font-weight: 600; }
964
965/* ─── Sticky action footer ──────────────────────────────────────── */
966.pe-actions {
967 position: sticky;
968 bottom: 0;
969 display: flex; align-items: center;
970 gap: 0.5rem;
971 padding: 0.85rem 1rem;
972 margin: 0.5rem -1rem 0; /* extend to viewport edges on a narrow container */
973 background: color-mix(in srgb, var(--paper) 94%, transparent);
974 -webkit-backdrop-filter: blur(8px);
975 backdrop-filter: blur(8px);
976 border-top: 1px solid var(--rule);
977 z-index: 10;
978}
979.pe-actions-spacer { flex: 1; }
980
981/* The editor is a focus screen: hide the mobile site tab bar (Home/Search/…)
982 so two bars don't stack at the bottom (Save bar + tab bar). The
983 Save/Cancel bar then becomes the only bottom bar → plain bottom:0,
984 no tab offset needed. On desktop the tab bar is already hidden. */
985body:has(.post-edit-page) .bottom-tab { display: none; }
986/* Audio player (if playing) no longer lifted 56px for the now-hidden
987 tab bar, otherwise it would float above the action bar. */
988body:has(.post-edit-page) .audio-player { bottom: 0; }
989
990/* ── Image editor (rotate / crop / mirror) ── */
991.imed-backdrop {
992 position: fixed; inset: 0; z-index: 9999;
993 display: flex; align-items: center; justify-content: center;
994 background: rgba(0,0,0,.62); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
995 padding: 14px;
996}
997.imed-modal {
998 display: flex; flex-direction: column; gap: 12px;
999 width: 100%; max-width: 640px; max-height: 92vh;
1000 background: var(--paper, #fff); color: var(--ink, #111);
1001 border: 1px solid var(--rule, rgba(128,128,128,.3)); border-radius: 14px;
1002 padding: 14px; box-sizing: border-box;
1003}
1004.imed-stage {
1005 height: 58vh; flex: 0 0 auto;
1006 background: var(--paper-2, rgba(128,128,128,.08)); border-radius: 10px; overflow: hidden;
1007}
1008.imed-stage img { display: block; max-width: 100%; }
1009/* Cropper's container must fill the full stage height — otherwise it collapses
1010 in a flex column without an explicit height → empty edit window (no image). */
1011.imed-stage .cropper-container { width: 100% !important; height: 100% !important; }
1012.imed-tools {
1013 display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;
1014}
1015.imed-tools button {
1016 width: 42px; height: 42px; font-size: 18px; line-height: 1;
1017 border: 1px solid var(--rule, rgba(128,128,128,.4)); border-radius: 10px;
1018 background: var(--paper-2, transparent); color: inherit; cursor: pointer;
1019}
1020.imed-tools button:hover { border-color: var(--accent); }
1021.imed-actions { display: flex; gap: 8px; justify-content: flex-end; }
1022</style>
1023
1024<script>
1025(function() {
1026
1027 // ── Cover upload ────────────────────────────────────────────────
1028 const coverField = document.getElementById('cover-upload-field');
1029 const coverTrigger = document.getElementById('cover-upload-trigger');
1030 const coverUrl = document.getElementById('cover-url-field');
1031 const coverStatus = document.getElementById('cover-upload-status');
1032 const coverWrap = document.getElementById('cover-preview-wrap');
1033 const coverImg = document.getElementById('cover-preview-img');
1034
1035 async function uploadImage(file) {
1036 const fd = new FormData();
1037 fd.append('image', file);
1038 const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
1039 if (!res.ok) {
1040 const j = await res.json().catch(() => ({}));
1041 throw new Error(j.error || ('Upload failed (' + res.status + ')'));
1042 }
1043 return await res.json(); // {url, size, mime}
1044 }
1045
1046 // ── Image editor (rotate / crop / mirror) ──────────
1047 // Lazy-load Cropper.js (locally vendored) on first use.
1048 let _cropperReady = null;
1049 function ensureCropper() {
1050 if (window.Cropper) return Promise.resolve();
1051 if (_cropperReady) return _cropperReady;
1052 _cropperReady = new Promise((resolve, reject) => {
1053 if (!document.querySelector('link[data-cropper-css]')) {
1054 const l = document.createElement('link');
1055 l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', '');
1056 document.head.appendChild(l);
1057 }
1058 const s = document.createElement('script');
1059 s.src = '/assets/vendor/cropper.min.js';
1060 s.onload = () => resolve();
1061 s.onerror = () => reject(new Error('cropper load failed'));
1062 document.head.appendChild(s);
1063 });
1064 return _cropperReady;
1065 }
1066
1067 // Opens the editor for a chosen file; resolves with an edited File,
1068 // or null if the user cancels. Animated GIFs are not sent through the
1069 // canvas editor (they would become static) — those upload directly.
1070 async function openImageEditor(file) {
1071 if (!file || !file.type || !file.type.startsWith('image/')) return file;
1072 if (file.type === 'image/gif') return file; // preserve animation
1073 try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
1074
1075 return new Promise((resolve) => {
1076 const back = document.createElement('div');
1077 back.className = 'imed-backdrop';
1078 back.innerHTML =
1079 '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="<%= t('imed.title') %>">' +
1080 '<div class="imed-stage"><img alt=""></div>' +
1081 '<div class="imed-tools">' +
1082 '<button type="button" data-act="rl" title="<%= t('imed.rotate_left') %>">⟲</button>' +
1083 '<button type="button" data-act="rr" title="<%= t('imed.rotate_right') %>">⟳</button>' +
1084 '<button type="button" data-act="fh" title="<%= t('imed.flip_h') %>">⇆</button>' +
1085 '<button type="button" data-act="fv" title="<%= t('imed.flip_v') %>">⇅</button>' +
1086 '<button type="button" data-act="zi" title="<%= t('imed.zoom_in') %>">+</button>' +
1087 '<button type="button" data-act="zo" title="<%= t('imed.zoom_out') %>">-</button>' +
1088 '<button type="button" data-act="reset" title="<%= t('imed.reset') %>">↺</button>' +
1089 '</div>' +
1090 '<div class="imed-actions">' +
1091 '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary"><%= t('imed.cancel') %></button>' +
1092 '<button type="button" data-act="apply" class="pe-btn pe-btn-primary"><%= t('imed.apply') %></button>' +
1093 '</div>' +
1094 '</div>';
1095 document.body.appendChild(back);
1096 const img = back.querySelector('img');
1097 const url = URL.createObjectURL(file);
1098 let cropper = null, sx = 1, sy = 1;
1099
1100 function cleanup() {
1101 try { if (cropper) cropper.destroy(); } catch (_) {}
1102 URL.revokeObjectURL(url);
1103 back.remove();
1104 document.removeEventListener('keydown', onKey);
1105 }
1106 function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } }
1107 document.addEventListener('keydown', onKey);
1108
1109 img.onload = () => {
1110 cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
1111 };
1112 img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
1113 img.src = url;
1114
1115 back.addEventListener('click', (e) => {
1116 const btn = e.target.closest('[data-act]');
1117 if (e.target === back) { cleanup(); resolve(null); return; }
1118 if (!btn || !cropper) return;
1119 const a = btn.getAttribute('data-act');
1120 if (a === 'rl') cropper.rotate(-90);
1121 else if (a === 'rr') cropper.rotate(90);
1122 else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); }
1123 else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); }
1124 else if (a === 'zi') cropper.zoom(0.1);
1125 else if (a === 'zo') cropper.zoom(-0.1);
1126 else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); }
1127 else if (a === 'cancel') { cleanup(); resolve(null); }
1128 else if (a === 'apply') {
1129 const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
1130 const png = (file.type === 'image/png' || file.type === 'image/webp');
1131 const mime = png ? 'image/png' : 'image/jpeg';
1132 const ext = png ? '.png' : '.jpg';
1133 canvas.toBlob((blob) => {
1134 cleanup();
1135 if (!blob) { resolve(file); return; }
1136 const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, '');
1137 resolve(new File([blob], base + ext, { type: mime }));
1138 }, mime, 0.92);
1139 }
1140 });
1141 });
1142 }
1143
1144 function showCoverPreview(url) {
1145 if (!coverWrap || !coverImg) return;
1146 if (url) {
1147 coverImg.src = url;
1148 coverImg.hidden = false;
1149 coverWrap.removeAttribute('data-empty');
1150 const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
1151 if (emptyIcon) emptyIcon.remove();
1152 } else {
1153 coverImg.hidden = true;
1154 coverImg.src = '';
1155 coverWrap.setAttribute('data-empty', '');
1156 if (!coverWrap.querySelector('.pe-cover-empty')) {
1157 const span = document.createElement('span');
1158 span.className = 'pe-cover-empty';
1159 span.textContent = '🖼';
1160 coverWrap.appendChild(span);
1161 }
1162 }
1163 }
1164
1165 if (coverTrigger && coverField) {
1166 coverTrigger.addEventListener('click', () => coverField.click());
1167 }
1168 if (coverField) {
1169 coverField.addEventListener('change', async () => {
1170 if (!coverField.files[0]) return;
1171 const edited = await openImageEditor(coverField.files[0]);
1172 coverField.value = '';
1173 if (!edited) return; // cancelled
1174 coverStatus.classList.remove('is-error');
1175 coverStatus.textContent = '<%= t('pedit.js_uploading') %>';
1176 try {
1177 const j = await uploadImage(edited);
1178 coverUrl.value = j.url;
1179 showCoverPreview(j.url);
1180 coverStatus.textContent = '<%= t('pedit.js_uploaded') %> ✓';
1181 setTimeout(() => { coverStatus.textContent = ''; }, 2000);
1182 } catch (e) {
1183 coverStatus.classList.add('is-error');
1184 coverStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message;
1185 }
1186 });
1187 }
1188 // Live-update preview when user pastes a URL manually
1189 if (coverUrl) {
1190 coverUrl.addEventListener('input', () => {
1191 const v = coverUrl.value.trim();
1192 if (v) showCoverPreview(v); else showCoverPreview('');
1193 });
1194 }
1195
1196 // ── WYSIWYG editor (P58) ────────────────────────────────────────
1197 // Architecture:
1198 // - Visible <div contenteditable> (`#content-editor`) is what the user
1199 // types in; it shows real HTML (formatted, not raw markup).
1200 // - Hidden <input name="content"> (`#content-hidden`) is what submits.
1201 // On submit we serialize the editor's HTML into it, with shortcode
1202 // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
1203 // - Initial content comes from a <script type="application/json"> tag
1204 // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
1205 // and walk text nodes to render shortcode tokens as chips.
1206 const contentField = document.getElementById('content-upload-field');
1207 const contentBtn = document.getElementById('insert-image-btn');
1208 const contentStatus = document.getElementById('content-upload-status');
1209 const editor = document.getElementById('content-editor');
1210 const hiddenField = document.getElementById('content-hidden');
1211 const charCountEl = document.getElementById('char-count');
1212 const initialEl = document.getElementById('initial-content');
1213 const toolbar = document.getElementById('pe-toolbar');
1214 const form = editor && editor.closest('form');
1215
1216 if (!editor) return;
1217
1218 // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
1219 // immediately open the keyboard when the editor opens — not desired.
1220 try {
1221 const titleInput = form && form.querySelector('input[name="title"]');
1222 if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
1223 titleInput.focus({ preventScroll: true });
1224 }
1225 } catch (_) {}
1226
1227 // ── Shortcode chip rendering / serialization ────────────────────
1228 // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
1229 // — but we DON'T want to chipify text the user is mid-typing inside an
1230 // HTML attribute; since chipify only walks text nodes (never attribute
1231 // values) that's already safe.
1232 const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
1233
1234 const SC_ICONS = {
1235 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>',
1236 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>',
1237 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>',
1238 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>',
1239 };
1240
1241 function chipLabel(kind, value) {
1242 if (kind === 'track') {
1243 // UUIDs are noisy — show a 6-char prefix for visual hint
1244 const v = String(value || '');
1245 return '<%= t('pedit.chip_track') %> ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
1246 }
1247 if (kind === 'album') return '<%= t('pedit.chip_album') %> ' + value;
1248 if (kind === 'playlist') return '<%= t('pedit.chip_playlist') %> ' + value;
1249 if (kind === 'embed') {
1250 const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
1251 return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
1252 }
1253 return value;
1254 }
1255
1256 function makeChip(kind, value) {
1257 const span = document.createElement('span');
1258 span.className = 'sc-chip';
1259 span.contentEditable = 'false';
1260 span.setAttribute('data-sc', kind + ':' + value);
1261 span.innerHTML =
1262 '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
1263 '<span class="sc-chip-label"></span>';
1264 span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
1265 return span;
1266 }
1267
1268 // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
1269 function chipifyShortcodes(root) {
1270 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
1271 const targets = [];
1272 while (walker.nextNode()) {
1273 const n = walker.currentNode;
1274 // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
1275 if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
1276 if (SC_RE.test(n.nodeValue)) targets.push(n);
1277 SC_RE.lastIndex = 0;
1278 }
1279 for (const node of targets) {
1280 const txt = node.nodeValue;
1281 const frag = document.createDocumentFragment();
1282 let last = 0;
1283 let m;
1284 SC_RE.lastIndex = 0;
1285 while ((m = SC_RE.exec(txt)) !== null) {
1286 if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
1287 frag.appendChild(makeChip(m[1], m[2].trim()));
1288 last = m.index + m[0].length;
1289 }
1290 if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
1291 node.parentNode.replaceChild(frag, node);
1292 }
1293 }
1294
1295 // Inverse of chipify: clone the editor, replace every chip with its text.
1296 function serializeChips(rootClone) {
1297 const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
1298 for (const c of chips) {
1299 const txt = '[[' + c.getAttribute('data-sc') + ']]';
1300 c.replaceWith(document.createTextNode(txt));
1301 }
1302 }
1303
1304 // ── Boot: load initial content as HTML, then render shortcodes as chips
1305 try {
1306 const initial = JSON.parse(initialEl.textContent || '""');
1307 editor.innerHTML = initial || '';
1308 chipifyShortcodes(editor);
1309 } catch (e) {
1310 console.error('[editor] could not parse initial content', e);
1311 editor.innerHTML = '';
1312 }
1313
1314 // ── Char counter
1315 function updateCharCount() {
1316 const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
1317 if (charCountEl) charCountEl.textContent = String(text.length);
1318 }
1319 updateCharCount();
1320 editor.addEventListener('input', updateCharCount);
1321
1322 // ── Toolbar wiring
1323 // Lock the scroll position around an edit command. execCommand/insert scrolls
1324 // the caret into view by default → the view "jumps" when clicking a formatting
1325 // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
1326 // + the page and restore them — sync and over a few frames, because Chrome
1327 // sometimes scrolls a frame later. The user scrolls themselves.
1328 function scrollableAncestors(el) {
1329 const list = [];
1330 let node = el;
1331 while (node && node !== document.body && node !== document.documentElement) {
1332 const oy = getComputedStyle(node).overflowY;
1333 if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
1334 node = node.parentElement;
1335 }
1336 return list;
1337 }
1338 function keepScroll(fn) {
1339 // In fullscreen the page is locked (body overflow:hidden) and the field may
1340 // scroll to the caret freely — no page jump possible, so nothing to fix.
1341 const frame = document.querySelector('.pe-editor-frame');
1342 if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
1343 const wx = window.scrollX, wy = window.scrollY;
1344 const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
1345 const restore = function () {
1346 window.scrollTo(wx, wy);
1347 anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
1348 };
1349 fn();
1350 restore();
1351 requestAnimationFrame(restore);
1352 }
1353 function execCmd(cmd, arg) {
1354 keepScroll(function () {
1355 editor.focus({ preventScroll: true });
1356 document.execCommand(cmd, false, arg);
1357 });
1358 updateToolbarState();
1359 updateCharCount();
1360 }
1361 function wrapCode() {
1362 const sel = window.getSelection();
1363 if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
1364 keepScroll(function () {
1365 const range = sel.getRangeAt(0);
1366 const code = document.createElement('code');
1367 code.textContent = sel.toString();
1368 range.deleteContents();
1369 range.insertNode(code);
1370 // Move caret after the new node
1371 range.setStartAfter(code);
1372 range.collapse(true);
1373 sel.removeAllRanges();
1374 sel.addRange(range);
1375 editor.focus({ preventScroll: true });
1376 });
1377 }
1378 function linkPrompt() {
1379 const url = window.prompt('<%= t('pedit.js_link_prompt') %>');
1380 if (!url) return;
1381 execCmd('createLink', url);
1382 }
1383 // Is the current selection inside a <blockquote> within the editor? Return it.
1384 function blockquoteAncestor() {
1385 const sel = window.getSelection();
1386 if (!sel || sel.rangeCount === 0) return null;
1387 let node = sel.anchorNode;
1388 while (node && node !== editor) {
1389 if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
1390 node = node.parentNode;
1391 }
1392 return null;
1393 }
1394 // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
1395 // can never turn it OFF (browser quirk). If the caret is already in a quote →
1396 // unwrap it; otherwise apply blockquote.
1397 function toggleBlockquote() {
1398 keepScroll(function () {
1399 editor.focus({ preventScroll: true });
1400 const bq = blockquoteAncestor();
1401 if (bq) {
1402 const parent = bq.parentNode;
1403 // Extract content from the quote in place, then remove the empty wrapper.
1404 const ref = bq;
1405 let firstMoved = null;
1406 while (bq.firstChild) {
1407 const child = bq.firstChild;
1408 if (!firstMoved) firstMoved = child;
1409 parent.insertBefore(child, ref);
1410 }
1411 parent.removeChild(bq);
1412 // Restore the caret inside the unwrapped content.
1413 if (firstMoved) {
1414 const sel = window.getSelection();
1415 const range = document.createRange();
1416 range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
1417 range.collapse(false);
1418 sel.removeAllRanges();
1419 sel.addRange(range);
1420 }
1421 } else {
1422 document.execCommand('formatBlock', false, 'blockquote');
1423 }
1424 });
1425 updateToolbarState();
1426 updateCharCount();
1427 }
1428
1429 if (toolbar) {
1430 // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
1431 // from the editor field. Without this the selection is lost on tap
1432 // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
1433 // and the browser scrolls the caret back into view (the "jump down"). preventDefault
1434 // on mousedown keeps focus in the editor; the click still fires normally.
1435 toolbar.addEventListener('mousedown', (e) => {
1436 if (e.target.closest('button')) e.preventDefault();
1437 });
1438 toolbar.addEventListener('click', (e) => {
1439 const btn = e.target.closest('button[data-cmd]');
1440 if (!btn) return;
1441 e.preventDefault();
1442 const cmd = btn.dataset.cmd;
1443 const arg = btn.dataset.arg || null;
1444 if (cmd === 'link-prompt') linkPrompt();
1445 else if (cmd === 'code-wrap') wrapCode();
1446 else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
1447 else execCmd(cmd, arg);
1448 });
1449 }
1450
1451 // ── Full-screen writing mode: the writing field fills the whole page.
1452 const fsBtn = document.getElementById('pe-fullscreen-btn');
1453 const editorFrame = document.querySelector('.pe-editor-frame');
1454 const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
1455
1456 // On mobile the keyboard pushes the visible (visual) viewport up while
1457 // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
1458 // slides out of view. Keep the fullscreen frame aligned to the visual
1459 // viewport (top + height) so the toolbar stays visible at the top.
1460 function syncFsViewport() {
1461 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
1462 const vv = window.visualViewport;
1463 if (!vv) return;
1464 editorFrame.style.top = vv.offsetTop + 'px';
1465 editorFrame.style.height = vv.height + 'px';
1466 }
1467 function clearFsViewport() {
1468 if (!editorFrame) return;
1469 editorFrame.style.top = '';
1470 editorFrame.style.height = '';
1471 }
1472 function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
1473 function applyFs(on) {
1474 if (!editorFrame) return;
1475 editorFrame.classList.toggle('pe-fs', on);
1476 document.body.classList.toggle('pe-fs-open', on);
1477 document.documentElement.classList.toggle('pe-fs-open', on);
1478 if (fsBtn) {
1479 fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
1480 fsBtn.title = on ? '<%= t('pedit.tb_done') %>' : '<%= t('pedit.tb_fullscreen') %>';
1481 }
1482 if (window.visualViewport) {
1483 if (on) {
1484 window.visualViewport.addEventListener('resize', syncFsViewport);
1485 window.visualViewport.addEventListener('scroll', syncFsViewport);
1486 syncFsViewport();
1487 } else {
1488 window.visualViewport.removeEventListener('resize', syncFsViewport);
1489 window.visualViewport.removeEventListener('scroll', syncFsViewport);
1490 clearFsViewport();
1491 }
1492 }
1493 // On touch the field is NOT editable inline; only in fullscreen.
1494 if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
1495 if (on) {
1496 editor.focus({ preventScroll: true });
1497 } else {
1498 if (isTouch) editor.blur();
1499 // On close: scroll to the TOP of the content instead of staying
1500 // somewhere at the bottom (footer).
1501 requestAnimationFrame(function () {
1502 try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
1503 });
1504 }
1505 }
1506 // The fullscreen writing "page": opening pushes a history state so the browser
1507 // back button (and the Done button) closes it and returns you to the form — feels
1508 // like a separate page, but all form fields remain intact (same DOM).
1509 function openFs() {
1510 if (isFs()) return;
1511 try { history.pushState({ peFs: true }, ''); } catch (_) {}
1512 applyFs(true);
1513 }
1514 function closeFs() {
1515 if (!isFs()) return;
1516 if (history.state && history.state.peFs) history.back(); // → popstate closes it
1517 else applyFs(false);
1518 }
1519 function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
1520 window.addEventListener('popstate', function () { if (isFs()) applyFs(false); });
1521 if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
1522 var fsDoneBtn = document.getElementById('pe-fs-done');
1523 if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs);
1524 document.addEventListener('keydown', (e) => {
1525 if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
1526 });
1527
1528 // On mobile/tablet (touch): the content field is NOT editable inline — it is
1529 // not a text field there. One tap → fullscreen, where it becomes editable
1530 // (toggleFullscreen toggles contenteditable). This prevents inline typing.
1531 if (isTouch) {
1532 editor.setAttribute('contenteditable', 'false');
1533 editor.classList.add('pe-tap-to-edit');
1534 editor.addEventListener('click', function () {
1535 if (!isFs()) openFs();
1536 });
1537 }
1538
1539 // Reflect bold/italic/list state on the toolbar buttons
1540 function updateToolbarState() {
1541 if (!toolbar) return;
1542 const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
1543 for (const cmd of cmds) {
1544 const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
1545 if (!btn) continue;
1546 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
1547 }
1548 // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
1549 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
1550 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
1551 }
1552 document.addEventListener('selectionchange', () => {
1553 if (document.activeElement === editor) updateToolbarState();
1554 });
1555
1556 // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
1557 editor.addEventListener('keydown', (e) => {
1558 const mod = e.ctrlKey || e.metaKey;
1559 if (!mod) return;
1560 const k = e.key.toLowerCase();
1561 if (k === 'b') { e.preventDefault(); execCmd('bold'); }
1562 else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
1563 else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
1564 else if (k === 'k') { e.preventDefault(); linkPrompt(); }
1565 });
1566
1567 // Paste: keep it simple — strip formatting unless user wants it. Default
1568 // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
1569 // styles from clipboard only when shift is held — otherwise plain text.
1570 editor.addEventListener('paste', (e) => {
1571 if (e.shiftKey) return; // user wants formatted paste
1572 const text = (e.clipboardData || window.clipboardData).getData('text/plain');
1573 if (text == null) return;
1574 e.preventDefault();
1575 document.execCommand('insertText', false, text);
1576 });
1577
1578 // ── Image upload (button + drag-drop into the editor)
1579 async function uploadAndInsertImage(file) {
1580 const edited = await openImageEditor(file);
1581 if (!edited) return; // cancelled
1582 contentStatus.classList.remove('is-error');
1583 contentStatus.textContent = '<%= t('pedit.js_uploading') %>';
1584 try {
1585 const j = await uploadImage(edited);
1586 const img = '<img src="' + j.url + '" alt="">';
1587 editor.focus({ preventScroll: true });
1588 document.execCommand('insertHTML', false, img);
1589 contentStatus.textContent = '<%= t('pedit.js_inserted') %> ✓';
1590 setTimeout(() => { contentStatus.textContent = ''; }, 2000);
1591 updateCharCount();
1592 } catch (e) {
1593 contentStatus.classList.add('is-error');
1594 contentStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message;
1595 }
1596 }
1597
1598 if (contentBtn && contentField) {
1599 contentBtn.addEventListener('click', () => contentField.click());
1600 contentField.addEventListener('change', () => {
1601 if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
1602 contentField.value = '';
1603 });
1604
1605 editor.addEventListener('dragover', (e) => {
1606 if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
1607 e.preventDefault();
1608 editor.classList.add('is-dragover');
1609 }
1610 });
1611 editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
1612 editor.addEventListener('drop', async (e) => {
1613 editor.classList.remove('is-dragover');
1614 const files = e.dataTransfer && e.dataTransfer.files;
1615 if (!files || !files.length) return;
1616 e.preventDefault();
1617 for (const f of files) {
1618 if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
1619 }
1620 });
1621 }
1622
1623 // ── Insert chip helpers (track / playlist)
1624 function insertChip(kind, value) {
1625 editor.focus({ preventScroll: true });
1626 const chip = makeChip(kind, value);
1627 // Insert at caret using the Selection API (execCommand insertNode)
1628 const sel = window.getSelection();
1629 if (sel && sel.rangeCount > 0) {
1630 const range = sel.getRangeAt(0);
1631 range.deleteContents();
1632 range.insertNode(chip);
1633 // Insert a trailing space so the user can keep typing after the chip
1634 const space = document.createTextNode('\u00A0');
1635 chip.after(space);
1636 range.setStartAfter(space);
1637 range.collapse(true);
1638 sel.removeAllRanges();
1639 sel.addRange(range);
1640 } else {
1641 editor.appendChild(chip);
1642 editor.appendChild(document.createTextNode('\u00A0'));
1643 }
1644 updateCharCount();
1645 }
1646
1647 // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
1648 // an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
1649 const embedBtn = document.getElementById('insert-embed-btn');
1650 if (embedBtn) {
1651 embedBtn.addEventListener('click', () => {
1652 const raw = window.prompt('<%= t('pedit.js_embed_prompt') %>');
1653 if (!raw) return;
1654 const url = raw.trim();
1655 if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; }
1656 insertChip('embed', url);
1657 });
1658 }
1659
1660 // ── Track insert: opens the track-picker modal (P59)
1661 const trackBtn = document.getElementById('insert-track-btn');
1662 const trackPicker = document.getElementById('track-picker');
1663 if (trackBtn && trackPicker) {
1664 const tpList = document.getElementById('tp-list');
1665 const tpEmpty = document.getElementById('tp-empty');
1666 const tpSearch = document.getElementById('tp-search');
1667 let tpCache = null; // cached track list (fetched once per page load)
1668 let tpLastFocus = null; // element to restore focus to on close
1669
1670 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>';
1671
1672 function fmtDur(sec) {
1673 sec = Math.max(0, Math.floor(sec || 0));
1674 const m = Math.floor(sec / 60), s = sec % 60;
1675 return m + ':' + String(s).padStart(2, '0');
1676 }
1677 function escAttr(s) {
1678 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
1679 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
1680 }[c]));
1681 }
1682
1683 function renderList(filter) {
1684 if (!Array.isArray(tpCache)) return;
1685 const q = (filter || '').trim().toLowerCase();
1686 const filtered = q
1687 ? tpCache.filter(t =>
1688 (t.title || '').toLowerCase().includes(q) ||
1689 (t.artist || '').toLowerCase().includes(q))
1690 : tpCache;
1691
1692 if (!filtered.length) {
1693 tpList.innerHTML = '';
1694 tpEmpty.textContent = q ? '<%= t('pedit.js_no_tracks_found') %> ' + q : '<%= t('pedit.js_no_tracks_yet') %>';
1695 tpList.appendChild(tpEmpty);
1696 return;
1697 }
1698
1699 tpList.innerHTML = filtered.map(t => {
1700 const cov = t.cover
1701 ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
1702 : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
1703 const dis = t.playable ? '' : ' aria-disabled="true"';
1704 const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
1705 return (
1706 '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
1707 cov +
1708 '<span class="tp-meta">' +
1709 '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
1710 sub +
1711 '</span>' +
1712 '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
1713 '</button>'
1714 );
1715 }).join('');
1716 }
1717
1718 async function loadTracks() {
1719 if (Array.isArray(tpCache)) return tpCache;
1720 tpEmpty.textContent = '<%= t('pedit.js_tracks_loading') %>';
1721 try {
1722 const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
1723 const j = await r.json();
1724 tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
1725 } catch (e) {
1726 tpCache = [];
1727 tpEmpty.textContent = '<%= t('pedit.js_tracks_load_fail') %>: ' + e.message;
1728 }
1729 return tpCache;
1730 }
1731
1732 function openPicker() {
1733 tpLastFocus = document.activeElement;
1734 trackPicker.hidden = false;
1735 trackPicker.setAttribute('aria-hidden', 'false');
1736 document.body.classList.add('tp-locked');
1737 tpSearch.value = '';
1738 renderList('');
1739 // Defer focus so the open animation doesn't get jumped
1740 setTimeout(() => tpSearch.focus(), 30);
1741 }
1742 function closePicker() {
1743 trackPicker.hidden = true;
1744 trackPicker.setAttribute('aria-hidden', 'true');
1745 document.body.classList.remove('tp-locked');
1746 if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
1747 try { tpLastFocus.focus(); } catch(_) {}
1748 }
1749 }
1750
1751 trackBtn.addEventListener('click', async () => {
1752 openPicker();
1753 await loadTracks();
1754 renderList(tpSearch.value);
1755 });
1756
1757 // Close: backdrop click, [data-tp-close], or Escape
1758 trackPicker.addEventListener('click', (e) => {
1759 if (e.target.closest('[data-tp-close]')) {
1760 closePicker();
1761 return;
1762 }
1763 const row = e.target.closest('.tp-row[data-track-id]');
1764 if (row) {
1765 if (row.getAttribute('aria-disabled') === 'true') return;
1766 const id = row.dataset.trackId;
1767 if (id) {
1768 insertChip('track', id);
1769 closePicker();
1770 }
1771 }
1772 });
1773 document.addEventListener('keydown', (e) => {
1774 if (!trackPicker.hidden && e.key === 'Escape') {
1775 e.preventDefault();
1776 closePicker();
1777 }
1778 });
1779
1780 // Live filter
1781 tpSearch.addEventListener('input', () => renderList(tpSearch.value));
1782 }
1783
1784 // ── Playlist insert (open existing or create new via modal)
1785 const playlistBtn = document.getElementById('insert-playlist-btn');
1786 if (playlistBtn) {
1787 playlistBtn.addEventListener('click', async () => {
1788 if (typeof window.openPlaylistEditor !== 'function') {
1789 alert('<%= t('pedit.js_playlist_editor_missing') %>');
1790 return;
1791 }
1792 try {
1793 const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
1794 const j = await r.json();
1795 if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
1796 const choice = prompt(
1797 '<%= t('pedit.js_playlist_existing') %>\n\n' +
1798 j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
1799 '\n\n<%= t('pedit.js_playlist_choose') %>'
1800 );
1801 if (choice && /^\d+$/.test(choice.trim())) {
1802 const idx = parseInt(choice.trim(), 10) - 1;
1803 if (idx >= 0 && idx < j.playlists.length) {
1804 insertChip('playlist', j.playlists[idx].id);
1805 return;
1806 }
1807 }
1808 if (choice === null) return;
1809 }
1810 } catch (_) { /* fall through to create */ }
1811
1812 window.openPlaylistEditor({
1813 mode: 'create',
1814 onSaved: ({ id }) => insertChip('playlist', id),
1815 });
1816 });
1817 }
1818
1819 // ── Submit: serialize editor contents into the hidden field
1820 if (form && hiddenField) {
1821 form.addEventListener('submit', () => {
1822 const clone = editor.cloneNode(true);
1823 serializeChips(clone);
1824 hiddenField.value = clone.innerHTML;
1825 });
1826 }
1827})();
1828</script>
1829
1830<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
1831 viewports, centered modal on ≥640px. Populated lazily on first open. %>
1832<% if (typeof user !== 'undefined' && user) { %>
1833<div id="track-picker" class="tp-modal" hidden aria-hidden="true">
1834 <div class="tp-backdrop" data-tp-close></div>
1835 <div class="tp-sheet" role="dialog" aria-modal="true" aria-labelledby="tp-title">
1836 <header class="tp-header">
1837 <h2 id="tp-title" class="tp-h2"><%= t('pedit.tp_title') %></h2>
1838 <button type="button" class="tp-close" data-tp-close aria-label="<%= t('pedit.tp_close') %>">
1839 <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>
1840 </button>
1841 </header>
1842 <div class="tp-search-row">
1843 <span class="tp-search-icon" aria-hidden="true">
1844 <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>
1845 </span>
1846 <input type="search" class="tp-search" id="tp-search" placeholder="<%= t('pedit.tp_search_placeholder') %>" autocomplete="off" inputmode="search">
1847 </div>
1848 <div class="tp-list" id="tp-list" role="listbox" aria-label="<%= t('pedit.tp_list_aria') %>">
1849 <div class="tp-empty" id="tp-empty"><%= t('pedit.js_tracks_loading') %></div>
1850 </div>
1851 </div>
1852</div>
1853<% } %>
1854
1855<%# Playlist editor modal — included so its global window.openPlaylistEditor
1856 is available when the toolbar button fires. Only renders if user can post. %>
1857<% if (typeof user !== 'undefined' && user) { %>
1858 <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
1859<% } %>
1860
1861<script>
1862(function () {
1863 // Pin: checkbox toggles the hidden rank field (0 = not pinned),
1864 // ▲▼ shifts the position, with a readable description instead of a raw number.
1865 var toggle = document.getElementById('pin-toggle');
1866 var rank = document.getElementById('pin-rank');
1867 var pos = document.getElementById('pin-pos');
1868 var label = document.getElementById('pin-label');
1869 var up = document.getElementById('pin-up'); // higher = lower number (towards 1/top)
1870 var down = document.getElementById('pin-down');
1871 if (!toggle || !rank || !pos) return;
1872
1873 function descr(n) {
1874 n = Number(n) || 0;
1875 if (n <= 1) return '<%= t('pedit.pin_top') %>';
1876 return n + '<%= t('pedit.pin_nth_suffix') %>';
1877 }
1878 function render() {
1879 var on = toggle.checked;
1880 pos.hidden = !on;
1881 if (on && Number(rank.value) < 1) rank.value = 1;
1882 if (!on) rank.value = 0;
1883 if (label) label.textContent = on ? descr(rank.value) : '';
1884 if (up) up.disabled = Number(rank.value) <= 1;
1885 }
1886 toggle.addEventListener('change', render);
1887 if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
1888 if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
1889 render();
1890})();
1891
1892(function () {
1893 // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
1894 // obstacles by setting a dynamic bottom offset = the greater of:
1895 // 1) the height of the keyboard area NOT covered by the layout viewport
1896 // (on iOS the visual viewport shifts; on Android the layout viewport shrinks
1897 // due to interactive-widget=resizes-content → offset ≈ 0);
1898 // 2) the height of the playing audio player (fixed, z-index 1000).
1899 // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
1900 var bar = document.querySelector('.pe-actions');
1901 if (!bar) return;
1902 var vv = window.visualViewport;
1903 function position() {
1904 var ap = document.querySelector('.audio-player');
1905 var playing = document.body.classList.contains('has-audio-player') &&
1906 ap && getComputedStyle(ap).display !== 'none';
1907 var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
1908 var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
1909 var offset = Math.max(audioOffset, kbCovered);
1910 bar.style.bottom = offset ? offset + 'px' : '';
1911 }
1912 position();
1913 window.addEventListener('resize', position);
1914 if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
1915 // has-audio-player is toggled via a body class → observe it.
1916 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
1917})();
1918</script>
Note: See TracBrowser for help on using the repository browser.