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

main
Last change on this file since e685f55 was e685f55, checked in by Robin <roboburr@…>, 7 weeks ago

Fix: paid client scripts were blocked by CSP (garbage nonce)

The paid-* views wrote nonce="<%= cspNonce %>" on their <script> tags, but
cspNonce is not a real value in the template: renderPage's default is a
function, so it rendered as nonce="() =&gt; ". injectCspNonce only adds
the real nonce to <script> tags that have NO nonce attribute yet, so these
tags kept the garbage nonce. Under our strict-dynamic CSP (no unsafe-inline,
no host sources, 'self' ignored) that blocked every one of them:

  • the vendored SimpleWebAuthnBrowser lib never loaded
  • the passkey-creation script (slice 3) never ran
  • the per-post unlock script (slice 4) never ran
  • the paid-price toggle in the editor (slice 2) never ran

Tests never caught it: the WebAuthn ceremony only runs in a real browser,
so nothing exercised these inline scripts. The convention everywhere else
is a plain <script> with no nonce; injectCspNonce fills in the real one.
Drop the hand-written nonce attribute so that happens.

Changed files:
src/views/pages/paid-gate.ejs

  • drop nonce attr on the vendored lib + unlock scripts

src/views/pages/paid-passkey.ejs

  • drop nonce attr on the vendored lib + registration scripts

src/views/pages/post-edit.ejs

  • drop nonce attr on the paid-price toggle script

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

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