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

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

De editpagina laadt zijn editor weer, en opslaan zonder module wist niets meer

Robins test at een post op: /posts/:slug/edit rendert dezelfde template als
/posts/new, maar alleen de nieuw-route kreeg bij shaer-bqr zijn pageJs. De
editpagina had dus data-js="chrome" -- geen editor, geen mediaprompts, en
een verborgen contentveld dat leeg rendert. Opslaan schreef dat lege veld
naar de database: strike-homerun verloor zo om 18:54 zijn inhoud (testpost
van 18:12, niets ouds verloren).

Drie lagen, van symptoom naar vangrail:

  1. de edit-route declareert pageJs 'post-edit playlist-editor'
  2. het verborgen veld rendert de BESTAANDE inhoud: laadt de module niet (oude cache, js uit), dan is opslaan een no-op in plaats van een wisser
  3. test/page-modules.test.js scheurt luid als een render van de editor zijn modules vergeet -- een brontekst-test, bewust, want het gedrag leeft in de browser en dit is de fout die twee keer beet

En dezelfde audit ving download: drie renders, en pageJs stond op het
FORMULIER in plaats van op ready -- de auto-start hoorde v66r shaer-bqr bij
de ready-tak en omzeilde nu de e-mailvraag. Verplaatst, en de module weigert
voortaan een lege fileUrl.

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

  • Property mode set to 100644
File size: 58.4 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 <%# value = de BESTAANDE inhoud: laadt de module niet (oude cache, js uit),
211 dan slaat het formulier de post ongewijzigd op in plaats van hem te wissen. %>
212 <input type="hidden" name="content" id="content-hidden" value="<%= post.content || '' %>">
213
214 <%# Initial content is injected as a JSON string in a script tag so we
215 can set innerHTML safely from JS. EJS-escaping HTML directly into
216 the editor would render entity-escaped tags as text. %>
217 <script id="initial-content" type="application/json"><%- JSON.stringify(post.content || '').replace(/</g, '\\u003c') %></script>
218
219 <div class="pe-editor-status">
220 <small id="content-upload-status" class="pe-status"></small>
221 <span class="pe-char-count"><span id="char-count">0</span> <%= t('pedit.chars') %></span>
222 </div>
223
224 <input type="file" id="content-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
225 </div>
226 </section>
227
228 <%# ── META ─────────────────────────────────────────────────── %>
229 <section class="pe-card">
230 <div class="pe-section-title"><%= t('pedit.s_publication') %></div>
231 <label class="pe-field">
232 <span><%= t('pedit.f_status') %></span>
233 <% var _st = isNew ? 'published' : (post.status || 'draft'); %>
234 <select name="status">
235 <option value="published" <%= _st === 'published' ? 'selected' : '' %>><%= t('pedit.status_published') %></option>
236 <option value="draft" <%= _st === 'draft' ? 'selected' : '' %>><%= t('pedit.status_draft') %></option>
237 <option value="archived" <%= _st === 'archived' ? 'selected' : '' %>><%= t('pedit.status_archived') %></option>
238 </select>
239 </label>
240 <div class="pe-checkboxes">
241 <div class="pe-pin">
242 <label class="pe-checkbox">
243 <input type="checkbox" id="pin-toggle" <%= (Number(post.pinned) > 0) ? 'checked' : '' %>>
244 <span>📌 <%= t('pedit.pin_label') %></span>
245 </label>
246 <input type="hidden" name="pinned" id="pin-rank" value="<%= post.pinned || 0 %>">
247 <div class="pe-pin-pos" id="pin-pos" <%= (Number(post.pinned) > 0) ? '' : 'hidden' %>>
248 <span class="pe-pin-steps">
249 <button type="button" class="pe-pin-btn" id="pin-up" aria-label="<%= t('pedit.pin_up') %>">▲</button>
250 <button type="button" class="pe-pin-btn" id="pin-down" aria-label="<%= t('pedit.pin_down') %>">▼</button>
251 </span>
252 <span class="pe-pin-label" id="pin-label"></span>
253 </div>
254 </div>
255 <label class="pe-checkbox">
256 <input type="checkbox" name="noindex" value="1" <%= post.noindex ? 'checked' : '' %>>
257 <span>🚫 <%= t('pedit.noindex_label') %></span>
258 </label>
259 <label class="pe-checkbox">
260 <input type="checkbox" name="nsfw" value="1" id="pe-nsfw" <%= post.nsfw ? 'checked' : '' %>>
261 <span>🔞 <%= t('pedit.nsfw_label') %></span>
262 </label>
263 <input type="text" name="content_warning" id="pe-cw" value="<%= post.content_warning || '' %>" maxlength="200"
264 placeholder="<%= t('pedit.nsfw_cw_ph') %>"
265 style="width:100%;box-sizing:border-box;margin:6px 0 2px;font-size:13px;padding:7px 9px">
266 <% var _fediOpen = (typeof fediOpenAudio !== 'undefined' && fediOpenAudio); %>
267 <label class="pe-checkbox">
268 <%# One-way: once opened, the file has federated — re-gating would be false security, so the box locks. %>
269 <input type="checkbox" name="fedi_open_audio" value="1" id="pe-fedi-audio" <%= _fediOpen ? 'checked disabled' : '' %>>
270 <span>🌐 <%= t('pedit.fedi_audio_label') %></span>
271 </label>
272 <% if (_fediOpen) { %>
273 <p style="font-size:12px;opacity:.7;margin:2px 0 0 26px"><%= t('pedit.fedi_audio_locked') %></p>
274 <% } else { %>
275 <p id="pe-fedi-audio-warn" style="font-size:12px;color:#c0392b;margin:2px 0 0 26px" hidden>⚠️ <%= t('pedit.fedi_audio_oneway') %></p>
276 <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
277 <% } %>
278
279 <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
280 var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
281 var _pollLocked = (typeof pollLocked !== 'undefined' && pollLocked);
282 var _pollOpts = (_poll && Array.isArray(_poll.options) && _poll.options.length) ? _poll.options : [{ name: '' }, { name: '' }]; %>
283 <label class="pe-checkbox" style="margin-top:8px">
284 <input type="checkbox" name="poll_enabled" value="1" id="pe-poll-toggle" <%= _poll ? 'checked' : '' %> <%= _pollLocked ? 'disabled' : '' %>>
285 <span>📊 <%= t('pedit.poll_label') %></span>
286 </label>
287 <style>
288 .pe-poll { margin-top: 6px; }
289 .pe-poll-locked { font-size: 12px; opacity: .7; margin: 0 0 6px; }
290 .pe-poll-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
291 .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; }
292 .pe-poll-opt:focus { outline: none; border-color: var(--accent); }
293 .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; }
294 .pe-poll-del:hover { border-color: #c0392b; color: #c0392b; }
295 .pe-poll-del[hidden] { display: none; }
296 .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; }
297 .pe-poll-add:hover { border-color: var(--accent); color: var(--accent); }
298 .pe-poll-add:disabled { opacity: .4; cursor: default; border-style: dashed; }
299 .pe-poll-durlabel { display: block; font-size: 12.5px; opacity: .8; margin: 8px 0 4px; }
300 .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; }
301 </style>
302 <%# NB: keep the condition INSIDE the quoted attribute — escaped output (equals-tag)
303 turns a whole emitted style attribute into entity soup (fields showed always). %>
304 <div id="pe-poll-fields" class="pe-poll" style="<%= _poll ? '' : 'display:none' %>">
305 <% if (_pollLocked) { %><p class="pe-poll-locked"><%= t('pedit.poll_locked') %></p><% } %>
306 <div id="pe-poll-opts" data-ph="<%= t('pedit.poll_option_ph') %>" data-del="<%= t('pedit.poll_remove') %>">
307 <% _pollOpts.forEach(function (o) { %>
308 <div class="pe-poll-row">
309 <input type="text" name="poll_option" class="pe-poll-opt" maxlength="100" value="<%= (o && o.name) || '' %>" placeholder="<%= t('pedit.poll_option_ph') %>" <%= _pollLocked ? 'disabled' : '' %>>
310 <% if (!_pollLocked) { %><button type="button" class="pe-poll-del" aria-label="<%= t('pedit.poll_remove') %>" title="<%= t('pedit.poll_remove') %>">&times;</button><% } %>
311 </div>
312 <% }); %>
313 </div>
314 <% if (!_pollLocked) { %><button type="button" id="pe-poll-add" class="pe-poll-add">+ <%= t('pedit.poll_add') %></button><% } %>
315 <label class="pe-checkbox">
316 <input type="checkbox" name="poll_multiple" value="1" <%= (_poll && _poll.multiple) ? 'checked' : '' %> <%= _pollLocked ? 'disabled' : '' %>>
317 <span><%= t('pedit.poll_multiple') %></span>
318 </label>
319 <label class="pe-poll-durlabel"><%= t('pedit.poll_duration') %></label>
320 <select name="poll_duration" class="pe-poll-dur" <%= _pollLocked ? 'disabled' : '' %>>
321 <% [['300','5m'],['1800','30m'],['3600','1h'],['21600','6h'],['43200','12h'],['86400','1d'],['259200','3d'],['604800','7d']].forEach(function (d) { %>
322 <option value="<%= d[0] %>" <%= d[0] === '86400' ? 'selected' : '' %>><%= t('pedit.poll_dur_' + d[1]) %></option>
323 <% }); %>
324 </select>
325 </div>
326
327 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
328 <label class="pe-checkbox">
329 <input type="checkbox" name="fan_only" value="1" <%= post.fan_only ? 'checked' : '' %>>
330 <span>🔒 <%= t('pedit.fan_only_label') %></span>
331 </label>
332 <label class="pe-checkbox" style="margin-top:8px">
333 <input type="checkbox" name="paid" value="1" id="pe-paid" <%= post.paid ? 'checked' : '' %>>
334 <span>💶 Betaalde post (supporters ontgrendelen met Patreon)</span>
335 </label>
336 <div id="pe-paid-price" style="margin:6px 0 0 26px;<%= post.paid ? '' : 'display:none' %>">
337 <label style="font-size:.85rem;opacity:.8">Vereist steunbedrag (euro, leeg = standaard)
338 <input type="text" name="paid_min_eur" inputmode="decimal" style="width:100px"
339 value="<%= post.paid_min_cents ? (post.paid_min_cents/100).toFixed(2) : '' %>">
340 </label>
341 </div>
342
343 <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
344 <label class="pe-checkbox" style="margin-top:8px">
345 <input type="checkbox" name="schedule_enabled" value="1" id="pe-sched-toggle" <%= _scheduled ? 'checked' : '' %>>
346 <span>📅 <%= t('pedit.schedule_label') %></span>
347 </label>
348 <div id="pe-sched-fields" style="margin-top:6px;<%= _scheduled ? '' : 'display:none' %>">
349 <label style="display:block;font-size:12.5px;opacity:.8;margin-bottom:4px"><%= t('pedit.publish_at_label') %></label>
350 <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">
351 <% 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><% } %>
352 <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
353 </div>
354
355 <% } %>
356 </div>
357 </section>
358
359 <%# ── ACTIONS (sticky at bottom) ──────────────────────────── %>
360 <div class="pe-actions">
361 <a href="<%= _base %><%= isNew ? '/' : '/' + post.slug %>" class="pe-btn"><%= t('pedit.cancel') %></a>
362 <div class="pe-actions-spacer"></div>
363 <% if (!isNew && post.status !== 'published') { %>
364 <button type="submit" name="action" value="publish" class="pe-btn pe-btn-success">📤 <%= t('pedit.publish') %></button>
365 <% } %>
366 <button type="submit" class="pe-btn pe-btn-primary">💾 <%= t('pedit.save') %></button>
367 </div>
368 </form>
369</div>
370
371<style>
372/* ─── Page wrapper ──────────────────────────────────────────────── */
373.post-edit-page {
374 max-width: 880px;
375 margin: 1.5rem auto 6rem; /* extra bottom for sticky-actions clearance */
376 padding: 0 1rem;
377}
378.post-edit-page h1 {
379 font-family: var(--font-display, serif);
380 margin: 0 0 1.25rem;
381 font-size: 1.75rem;
382}
383
384.post-edit-form {
385 display: flex; flex-direction: column;
386 gap: 1rem;
387}
388
389/* ─── Card sections ─────────────────────────────────────────────── */
390.pe-card {
391 background: var(--paper);
392 border: 1px solid var(--rule);
393 border-radius: 12px;
394 padding: 1.25rem;
395 display: flex; flex-direction: column;
396 gap: 0.85rem;
397}
398.pe-card-content { padding-bottom: 0; overflow: hidden; } /* editor flush to bottom */
399.pe-section-title {
400 font-size: 0.8rem;
401 font-weight: 600;
402 text-transform: uppercase;
403 letter-spacing: 0.05em;
404 color: var(--ink-soft);
405 display: flex; align-items: baseline; gap: 0.5rem;
406 flex-wrap: wrap;
407}
408.pe-section-title small {
409 font-size: 0.75rem; font-weight: 400;
410 letter-spacing: 0; text-transform: none;
411 color: var(--ink-muted, var(--ink-soft));
412}
413
414/* ─── Field primitives ──────────────────────────────────────────── */
415.pe-field {
416 display: flex; flex-direction: column;
417 gap: 0.35rem;
418 min-width: 0; /* allow grid items to shrink */
419}
420.pe-field > span {
421 font-size: 0.8rem;
422 font-weight: 600;
423 color: var(--ink-soft);
424 display: flex; align-items: baseline; gap: 0.4rem;
425}
426.pe-field > span small {
427 font-weight: 400; color: var(--ink-muted);
428}
429.pe-field input,
430.pe-field textarea,
431.pe-field select {
432 width: 100%;
433 box-sizing: border-box;
434 display: block;
435 padding: 0.6rem 0.75rem;
436 border: 1px solid var(--rule);
437 border-radius: 6px;
438 background: var(--paper-2);
439 color: var(--ink);
440 font-family: var(--font-ui, system-ui), sans-serif;
441 font-size: 0.95rem;
442 -webkit-appearance: none;
443 appearance: none;
444 transition: border-color 120ms;
445}
446.pe-field input:focus,
447.pe-field textarea:focus,
448.pe-field select:focus {
449 outline: 2px solid var(--accent);
450 outline-offset: -1px;
451 border-color: var(--accent);
452}
453.pe-field textarea {
454 font-family: var(--font-mono, monospace);
455 resize: vertical;
456}
457.pe-field select {
458 /* Restore the dropdown arrow we removed with appearance:none */
459 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>");
460 background-repeat: no-repeat;
461 background-position: right 0.75rem center;
462 padding-right: 2rem;
463}
464
465.pe-row {
466 display: grid;
467 gap: 0.75rem;
468}
469.pe-row-2 { grid-template-columns: 1fr 1fr; }
470@media (max-width: 600px) {
471 .pe-row-2 { grid-template-columns: 1fr; }
472}
473
474/* ─── Cover field ───────────────────────────────────────────────── */
475.pe-cover-row {
476 display: grid;
477 grid-template-columns: 120px 1fr;
478 gap: 1rem;
479 align-items: start;
480}
481.pe-cover-thumb {
482 width: 120px; height: 120px;
483 border-radius: 10px;
484 overflow: hidden;
485 background: var(--paper-2);
486 border: 1px solid var(--rule);
487 display: flex; align-items: center; justify-content: center;
488 position: relative;
489}
490.pe-cover-thumb[data-empty] {
491 border-style: dashed;
492}
493.pe-cover-thumb img {
494 width: 100%; height: 100%;
495 object-fit: cover; display: block;
496}
497/* Honor HTML hidden — our display:block above otherwise renders an empty
498 broken-image icon when no cover is set. */
499.pe-cover-thumb img[hidden] { display: none; }
500.pe-cover-empty {
501 font-size: 2rem;
502 color: var(--ink-muted, var(--ink-soft));
503 opacity: 0.5;
504}
505.pe-cover-actions {
506 display: flex; flex-direction: column;
507 gap: 0.6rem;
508 min-width: 0;
509}
510.pe-cover-upload {
511 display: flex; align-items: center; gap: 0.6rem;
512 flex-wrap: wrap;
513}
514@media (max-width: 600px) {
515 .pe-cover-row { grid-template-columns: 88px 1fr; }
516 .pe-cover-thumb { width: 88px; height: 88px; }
517}
518
519/* ─── Post type: segmented control + type-aware panels ──────────── */
520.pe-typeseg {
521 display: grid;
522 grid-template-columns: repeat(4, 1fr);
523 gap: 0.4rem;
524}
525.pe-typeseg-btn {
526 display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
527 padding: 0.7rem 0.4rem;
528 border: 1px solid var(--rule);
529 border-radius: 9px;
530 background: var(--paper-2);
531 color: var(--ink-soft);
532 font-size: 0.85rem; font-weight: 600;
533 cursor: pointer;
534 transition: border-color 120ms, background 120ms, color 120ms;
535}
536.pe-typeseg-btn:hover { border-color: var(--accent); color: var(--ink); }
537.pe-typeseg-btn .pe-typeseg-ic { font-size: 1.3rem; line-height: 1; }
538.pe-typeseg-btn.is-active {
539 border-color: var(--accent);
540 background: color-mix(in srgb, var(--accent) 14%, var(--paper-2));
541 color: var(--ink);
542}
543.pe-typeseg-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
544@media (max-width: 480px) {
545 .pe-typeseg-btn { font-size: 0.78rem; padding: 0.6rem 0.25rem; }
546 .pe-typeseg-btn .pe-typeseg-ic { font-size: 1.15rem; }
547}
548
549.pe-type-panel { margin-top: 0.2rem; }
550
551/* Audio drop/upload zone */
552.pe-audio-up {
553 display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
554 text-align: center;
555 padding: 1.4rem 1rem;
556 border: 2px dashed var(--rule);
557 border-radius: 10px;
558 background: var(--paper-2);
559 color: var(--ink-soft);
560 cursor: pointer;
561 transition: border-color 120ms, background 120ms;
562}
563.pe-audio-up:hover,
564.pe-audio-up:focus-visible { border-color: var(--accent); outline: none; }
565.pe-audio-up.is-drag {
566 border-color: var(--accent);
567 background: color-mix(in srgb, var(--accent) 12%, var(--paper-2));
568}
569.pe-audio-up .pe-audio-up-ic { font-size: 1.7rem; line-height: 1; }
570.pe-audio-up strong { color: var(--ink); font-size: 0.95rem; }
571.pe-audio-up small { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); max-width: 42ch; }
572
573.pe-audio-list { list-style: none; margin: 0.7rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
574.pe-audio-item {
575 display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
576 padding: 0.5rem 0.7rem;
577 border: 1px solid var(--rule);
578 border-radius: 7px;
579 background: var(--paper-2);
580 font-size: 0.85rem;
581}
582.pe-audio-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
583.pe-audio-item-state { flex: none; font-size: 0.8rem; color: var(--ink-soft); }
584.pe-audio-item.is-done { border-color: color-mix(in srgb, var(--accent) 50%, var(--rule)); }
585.pe-audio-item.is-done .pe-audio-item-state { color: var(--accent); }
586.pe-audio-item.is-fail .pe-audio-item-state { color: #d9534f; }
587
588/* Video URL row */
589.pe-video-row { display: flex; gap: 0.5rem; }
590.pe-video-row input { flex: 1 1 auto; min-width: 0; }
591.pe-video-row input {
592 box-sizing: border-box; padding: 0.6rem 0.75rem;
593 border: 1px solid var(--rule); border-radius: 6px;
594 background: var(--paper-2); color: var(--ink); font-size: 0.95rem;
595}
596.pe-video-row input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
597.pe-video-row .pe-btn { flex: none; }
598
599.pe-foto-hint { margin: 0; font-size: 0.88rem; color: var(--ink-soft); }
600
601/* ─── Editor frame (WYSIWYG: top toolbar, contenteditable body, status bar) ─── */
602.pe-editor-frame {
603 margin: 0 -1.25rem -1.25rem; /* break out of card padding for flush edges */
604 border-top: 1px solid var(--rule);
605 background: var(--paper);
606 display: flex; flex-direction: column;
607}
608
609/* Top toolbar — sticks to the top of the viewport while editing */
610.pe-toolbar {
611 display: flex; align-items: center; gap: .15rem;
612 flex-wrap: wrap;
613 padding: .4rem .75rem;
614 background: color-mix(in srgb, var(--paper) 92%, transparent);
615 backdrop-filter: blur(10px);
616 -webkit-backdrop-filter: blur(10px);
617 border-bottom: 1px solid var(--rule);
618 position: sticky;
619 top: 0;
620 z-index: 10;
621}
622.pe-toolbar button {
623 display: inline-flex; align-items: center; justify-content: center;
624 width: 32px; height: 32px;
625 padding: 0;
626 touch-action: manipulation; /* snappy taps on mobile, no double-tap zoom */
627 -webkit-user-select: none; user-select: none;
628 background: transparent;
629 border: 1px solid transparent;
630 border-radius: 6px;
631 color: var(--ink);
632 cursor: pointer;
633 font-family: var(--font-ui, system-ui), sans-serif;
634 font-weight: 600; font-size: .85rem;
635 transition: background var(--transition), color var(--transition), border-color var(--transition);
636 -webkit-tap-highlight-color: transparent;
637}
638/* Hover only on real hover-capable devices — on touch :hover otherwise "sticks"
639 after a tap, making the active/inactive state unreadable. */
640@media (hover: hover) {
641 .pe-toolbar button:hover {
642 background: var(--paper-2);
643 color: var(--accent);
644 }
645}
646.pe-toolbar button:active { transform: scale(.94); }
647/* Active formatting = unmistakably filled with the accent colour. On mobile
648 it's immediately clear when e.g. bold is ON (tap again = off). */
649.pe-toolbar button.is-active {
650 background: var(--accent);
651 color: #fff;
652 border-color: var(--accent);
653}
654.pe-toolbar button.is-active svg { color: #fff; }
655.pe-toolbar button svg { width: 16px; height: 16px; }
656.pe-toolbar button.pe-tb-text { font-family: var(--font-display, serif); font-weight: 700; }
657.pe-toolbar-sep {
658 width: 1px; height: 18px;
659 background: var(--rule);
660 margin: 0 .35rem;
661 flex-shrink: 0;
662}
663.pe-toolbar-spacer { flex: 1; }
664
665/* ─── Full-screen writing mode ─── */
666.fs-icon-compress { display: none; }
667.pe-editor-frame.pe-fs .fs-icon-expand { display: none; }
668.pe-editor-frame.pe-fs .fs-icon-compress { display: inline; }
669.pe-editor-frame.pe-fs {
670 position: fixed; inset: 0; z-index: 1000;
671 margin: 0; border-top: 0;
672 height: 100dvh;
673 background: var(--paper);
674 overflow: hidden; /* only the text field scrolls, not the frame itself */
675}
676/* In fullscreen the writing field fills the remaining space and scrolls itself
677 (max-height: none overrides the mobile box limit below). */
678.pe-editor-frame.pe-fs .pe-editor {
679 flex: 1 1 auto; min-height: 0; height: auto; max-height: none;
680}
681/* iOS: in fullscreen the toolbar sits at the very top → push it below the
682 camera / Dynamic Island with the top safe-area inset. */
683.pe-editor-frame.pe-fs .pe-toolbar { padding-top: calc(.4rem + env(safe-area-inset-top, 0px)); }
684/* NB: deliberately NO overflow:hidden on html/body in fullscreen — that could get
685 stuck (e.g. leaving fullscreen via navigation) and would block scrolling on the
686 whole page. The fullscreen frame (position:fixed, inset:0) already covers the page,
687 and on touch scrollbars are hidden → no double bar needed. */
688
689/* "Done" button: only visible in fullscreen (left side of toolbar), clear
690 accent-pill instead of a square icon. */
691/* Hide rule more specific than ".pe-toolbar button" (otherwise that wins and the
692 Done button also shows inline as a small button). Only shown in fullscreen → large. */
693.pe-toolbar button.pe-fs-done { display: none; }
694.pe-editor-frame.pe-fs .pe-fs-done {
695 display: inline-flex; align-items: center; gap: .35rem;
696 width: auto !important; height: auto !important; min-height: 40px;
697 padding: .55rem 1.3rem !important; margin-right: .5rem;
698 background: var(--accent); color: #fff;
699 font-weight: 700; font-size: 1.05rem; border-radius: 999px;
700}
701.pe-editor-frame.pe-fs .pe-fs-done:hover { background: var(--accent); color: #fff; }
702
703/* Editor body — looks like prose, behaves like a textarea */
704.pe-editor {
705 width: 100%;
706 box-sizing: border-box;
707 min-height: 420px;
708 padding: 1.25rem 1.5rem;
709 border: 0;
710 background: transparent;
711 color: var(--ink);
712 font-family: var(--font-body, system-ui), serif;
713 font-size: 1.0625rem;
714 line-height: 1.65;
715 outline: none;
716 overflow-y: auto;
717}
718.pe-editor:focus { outline: none; }
719/* Inline (non-fullscreen) the writing field simply grows with the content — no
720 internal scroll-box (scroll-within-scroll is confusing). On mobile/tablet typing
721 always goes fullscreen (see JS), where the field fills the page and is the sole
722 scroller. */
723.pe-editor-frame:not(.pe-fs) .pe-editor { overflow: visible; }
724
725/* Touch: the inline content field is not a text field but a tap target → fullscreen
726 editing. A "✎ Tap to edit" pill sticks to the bottom of the container,
727 so the hint is always visible without sitting in the middle of the text. */
728.pe-editor.pe-tap-to-edit { cursor: pointer; }
729.pe-edit-hint { display: none; }
730@media (pointer: coarse) {
731 .pe-editor-frame:not(.pe-fs) .pe-edit-hint {
732 display: block;
733 position: sticky;
734 bottom: 4.5rem; /* above the sticky Save/Cancel bar */
735 width: max-content;
736 max-width: calc(100% - 2rem);
737 margin: .4rem auto;
738 background: var(--accent); color: #fff;
739 font-size: .9rem; font-weight: 700; padding: .5rem 1.1rem; border-radius: 999px;
740 box-shadow: 0 4px 14px rgba(0,0,0,.35);
741 pointer-events: none; text-align: center; white-space: nowrap;
742 }
743 /* Not relevant on touch (drag/select belongs to inline editing on desktop). */
744 .pe-content-hint { display: none; }
745 /* Inline (non-fullscreen) on touch: keep it simple — you don't edit here anyway,
746 so no formatting toolbar and no border. Just the content preview + the
747 "tap to edit" pill. The toolbar only appears in fullscreen. */
748 .pe-editor-frame:not(.pe-fs) .pe-toolbar { display: none; }
749 .pe-editor-frame:not(.pe-fs) { border-top: 0; }
750 .pe-editor-frame:not(.pe-fs) .pe-editor { min-height: 8rem; }
751}
752.pe-editor.is-dragover {
753 outline: 2px dashed var(--accent);
754 outline-offset: -10px;
755}
756/* Inline prose styles inside the editor — match the public post styling so
757 what you see is roughly what you'll get. Margins are tighter than on the
758 live site since this is a confined writing space. */
759.pe-editor h1, .pe-editor h2, .pe-editor h3, .pe-editor h4 {
760 font-family: var(--font-display, serif);
761 line-height: 1.2;
762 margin: 1.1rem 0 .35rem;
763}
764.pe-editor h1 { font-size: 1.85rem; }
765.pe-editor h2 { font-size: 1.45rem; }
766.pe-editor h3 { font-size: 1.2rem; }
767.pe-editor p { margin: 0 0 .85em; }
768.pe-editor ul, .pe-editor ol { margin: 0 0 .85em 1.4em; padding: 0; }
769.pe-editor li { margin: .15em 0; }
770.pe-editor blockquote {
771 margin: 1em 0;
772 padding: .15em 0 .15em 1em;
773 border-left: 3px solid var(--accent);
774 color: var(--ink-soft, var(--ink));
775 font-style: italic;
776}
777.pe-editor code {
778 background: var(--paper-2);
779 border: 1px solid var(--rule);
780 border-radius: 4px;
781 padding: .1em .35em;
782 font-family: var(--font-mono, ui-monospace, monospace);
783 font-size: .92em;
784}
785.pe-editor a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
786.pe-editor img {
787 max-width: 100%; height: auto;
788 border-radius: 6px;
789 display: block;
790 margin: 1em auto;
791}
792
793/* Shortcode chips — visible inline placeholder in the editor for
794 [[track:UUID]] / [[album:Name]] / [[playlist:slug]]. They serialize back
795 to plain shortcode text on submit. */
796.sc-chip {
797 display: inline-flex; align-items: center; gap: .3rem;
798 padding: .15em .5em;
799 margin: 0 .15em;
800 background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
801 border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
802 border-radius: 999px;
803 color: var(--accent);
804 font-family: var(--font-ui, system-ui), sans-serif;
805 font-size: .85em;
806 font-weight: 600;
807 font-style: normal;
808 white-space: nowrap;
809 user-select: none;
810 cursor: default;
811 vertical-align: baseline;
812}
813.sc-chip-icon { display: inline-flex; opacity: .8; }
814.sc-chip-icon svg { width: 12px; height: 12px; }
815
816/* Status bar below the editor */
817.pe-editor-status {
818 display: flex; align-items: center; justify-content: space-between;
819 gap: .75rem;
820 padding: .45rem 1rem;
821 border-top: 1px solid var(--rule);
822 background: var(--paper-2);
823 color: var(--ink-muted, var(--ink-soft));
824 font-size: .78rem;
825}
826.pe-char-count { font-variant-numeric: tabular-nums; }
827
828/* ─── Track picker modal (P59) ─────────────────────────────────
829 Mobile-first: full-screen bottom-sheet on phones, centered card
830 on desktop. Lock body scroll while open via .tp-locked on body. */
831.tp-modal {
832 position: fixed; inset: 0;
833 z-index: 1000;
834 display: flex; align-items: stretch; justify-content: center;
835 /* Avoid the iOS home-indicator + the keyboard when search is focused */
836 padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
837}
838.tp-modal[hidden] { display: none; }
839.tp-backdrop {
840 position: absolute; inset: 0;
841 background: color-mix(in srgb, var(--ink) 55%, transparent);
842 backdrop-filter: blur(4px);
843 -webkit-backdrop-filter: blur(4px);
844 animation: tp-bd-in .18s ease-out;
845}
846@keyframes tp-bd-in { from { opacity: 0 } to { opacity: 1 } }
847
848/* Mobile: sheet docks to the bottom and fills viewport almost completely */
849.tp-sheet {
850 position: relative;
851 margin-top: auto;
852 width: 100%;
853 max-height: calc(100dvh - env(safe-area-inset-top) - 1rem);
854 background: var(--paper);
855 border-top: 1px solid var(--rule);
856 border-radius: 16px 16px 0 0;
857 box-shadow:
858 0 -2px 8px color-mix(in srgb, var(--ink) 8%, transparent),
859 0 -16px 48px color-mix(in srgb, var(--ink) 18%, transparent);
860 display: flex; flex-direction: column;
861 animation: tp-sheet-up .22s cubic-bezier(.2, .8, .25, 1);
862 overflow: hidden;
863}
864@keyframes tp-sheet-up {
865 from { transform: translateY(100%); opacity: .8; }
866 to { transform: translateY(0); opacity: 1; }
867}
868
869/* Header — title + close, with a small grab-handle bar on mobile */
870.tp-header {
871 display: flex; align-items: center; justify-content: space-between;
872 gap: .75rem;
873 padding: .85rem 1rem .65rem;
874 border-bottom: 1px solid var(--rule);
875 position: relative;
876}
877.tp-header::before {
878 /* Grab-handle: visible on mobile only */
879 content: '';
880 position: absolute;
881 top: .35rem; left: 50%;
882 transform: translateX(-50%);
883 width: 38px; height: 4px;
884 background: var(--rule-2, var(--rule));
885 border-radius: 2px;
886}
887.tp-h2 {
888 margin: .35rem 0 0;
889 font-family: var(--font-display, serif);
890 font-size: 1.15rem; font-weight: 600;
891 color: var(--ink);
892 line-height: 1.1;
893}
894.tp-close {
895 width: 32px; height: 32px; border-radius: 8px;
896 display: inline-flex; align-items: center; justify-content: center;
897 background: transparent; border: 1px solid transparent;
898 color: var(--ink-muted, var(--ink-soft));
899 cursor: pointer;
900 transition: background var(--transition), color var(--transition), border-color var(--transition);
901 -webkit-tap-highlight-color: transparent;
902 margin-top: .35rem;
903}
904.tp-close:hover, .tp-close:focus-visible {
905 background: var(--paper-2);
906 color: var(--ink);
907 outline: none;
908}
909.tp-close svg { width: 18px; height: 18px; }
910
911/* Search row — sticky just below header so list scrolls underneath */
912.tp-search-row {
913 position: relative;
914 padding: .65rem 1rem;
915 border-bottom: 1px solid var(--rule);
916 background: var(--paper);
917}
918.tp-search-icon {
919 position: absolute;
920 top: 50%; left: 1.65rem;
921 transform: translateY(-50%);
922 color: var(--ink-muted, var(--ink-soft));
923 pointer-events: none;
924 display: inline-flex;
925}
926.tp-search-icon svg { width: 16px; height: 16px; }
927.tp-search {
928 width: 100%; box-sizing: border-box;
929 padding: .65rem .85rem .65rem 2.4rem;
930 font-family: var(--font-ui, system-ui), sans-serif;
931 font-size: 1rem;
932 background: var(--paper-2);
933 color: var(--ink);
934 border: 1px solid var(--rule);
935 border-radius: 9px;
936 -webkit-appearance: none;
937 appearance: none;
938}
939.tp-search:focus {
940 outline: none;
941 border-color: var(--accent);
942 box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
943}
944
945/* List — vertically scrollable, takes remaining sheet height */
946.tp-list {
947 flex: 1 1 auto;
948 overflow-y: auto;
949 -webkit-overflow-scrolling: touch;
950 padding: .35rem .35rem 1rem;
951}
952.tp-empty {
953 padding: 1.5rem 1rem;
954 text-align: center;
955 color: var(--ink-muted, var(--ink-soft));
956 font-size: .9rem;
957}
958
959/* Track row — tap target ≥ 56px for mobile */
960.tp-row {
961 display: grid;
962 grid-template-columns: 44px 1fr auto;
963 align-items: center;
964 gap: .75rem;
965 width: 100%;
966 padding: .55rem .65rem;
967 border: 0; background: transparent;
968 border-radius: 10px;
969 text-align: left;
970 cursor: pointer;
971 color: inherit;
972 font: inherit;
973 transition: background var(--transition);
974 -webkit-tap-highlight-color: transparent;
975}
976.tp-row:hover, .tp-row:focus-visible {
977 background: color-mix(in srgb, var(--accent) 8%, var(--paper-2));
978 outline: none;
979}
980.tp-row:active { transform: scale(.98); }
981.tp-row[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
982
983.tp-cover {
984 width: 44px; height: 44px;
985 border-radius: 6px;
986 background-size: cover; background-position: center;
987 background-color: var(--rule);
988 display: inline-flex; align-items: center; justify-content: center;
989 flex-shrink: 0;
990}
991.tp-cover-empty {
992 background: linear-gradient(135deg,
993 color-mix(in srgb, var(--accent) 30%, var(--paper-2)),
994 color-mix(in srgb, var(--accent) 8%, var(--paper-2)));
995 color: var(--accent);
996}
997.tp-cover-empty svg { width: 18px; height: 18px; opacity: .8; }
998
999.tp-meta {
1000 display: flex; flex-direction: column;
1001 min-width: 0;
1002 line-height: 1.25;
1003}
1004.tp-row-title {
1005 font-weight: 600; font-size: .94rem;
1006 color: var(--ink);
1007 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1008}
1009.tp-row-artist {
1010 font-size: .8rem;
1011 color: var(--ink-muted, var(--ink-soft));
1012 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1013 margin-top: .1rem;
1014}
1015.tp-duration {
1016 font-variant-numeric: tabular-nums;
1017 font-size: .8rem;
1018 color: var(--ink-muted, var(--ink-soft));
1019 flex-shrink: 0;
1020}
1021
1022/* Body lock when modal open */
1023body.tp-locked { overflow: hidden; touch-action: none; }
1024
1025/* Desktop: centered card, max-width ~520px */
1026@media (min-width: 640px) {
1027 .tp-modal {
1028 align-items: center;
1029 padding: 1.5rem;
1030 }
1031 .tp-sheet {
1032 margin-top: 0;
1033 width: 100%;
1034 max-width: 520px;
1035 max-height: 80dvh;
1036 border-radius: 14px;
1037 border: 1px solid var(--rule);
1038 animation: tp-sheet-pop .2s cubic-bezier(.2, .8, .25, 1);
1039 }
1040 .tp-header::before { display: none; } /* hide grab-handle on desktop */
1041 .tp-header { padding: 1rem 1.25rem .75rem; }
1042 .tp-h2 { margin-top: 0; font-size: 1.25rem; }
1043 .tp-close { margin-top: 0; }
1044 @keyframes tp-sheet-pop {
1045 from { transform: translateY(-8px) scale(.97); opacity: 0; }
1046 to { transform: translateY(0) scale(1); opacity: 1; }
1047 }
1048}
1049
1050/* ─── Buttons ───────────────────────────────────────────────────── */
1051.pe-btn {
1052 display: inline-flex; align-items: center; gap: 0.4rem;
1053 padding: 0.55rem 1rem;
1054 border: 1px solid var(--rule);
1055 background: var(--paper-2);
1056 color: var(--ink);
1057 font-family: var(--font-ui, system-ui), sans-serif;
1058 font-size: 0.9rem; font-weight: 500;
1059 text-decoration: none;
1060 border-radius: 6px;
1061 cursor: pointer;
1062 transition: background 120ms, border-color 120ms;
1063 min-height: 40px;
1064 -webkit-tap-highlight-color: transparent;
1065}
1066.pe-btn:hover { border-color: var(--accent); }
1067.pe-btn:active { transform: scale(0.97); }
1068.pe-btn-tiny {
1069 padding: 0.35rem 0.7rem;
1070 font-size: 0.85rem;
1071 min-height: 32px;
1072}
1073.pe-btn-secondary { background: var(--paper-2); }
1074.pe-btn-primary {
1075 background: var(--accent); color: white;
1076 border-color: var(--accent);
1077}
1078.pe-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
1079.pe-btn-success {
1080 background: #16a34a; color: white;
1081 border-color: #16a34a;
1082}
1083.pe-btn-success:hover { opacity: 0.92; border-color: #16a34a; }
1084
1085.pe-status {
1086 color: var(--ink-muted, var(--ink-soft));
1087 font-size: 0.8rem;
1088}
1089.pe-status.is-error { color: #dc2626; }
1090
1091/* ─── Checkboxes (clean inline row) ─────────────────────────────── */
1092.pe-checkboxes {
1093 display: flex; flex-direction: column;
1094 gap: 0.5rem;
1095 padding-top: 0.25rem;
1096}
1097.pe-checkbox {
1098 display: inline-flex; align-items: center; gap: 0.5rem;
1099 cursor: pointer;
1100 font-size: 0.95rem;
1101 color: var(--ink);
1102 user-select: none;
1103}
1104.pe-checkbox input[type="checkbox"] {
1105 width: 18px; height: 18px;
1106 margin: 0;
1107 cursor: pointer;
1108 accent-color: var(--accent);
1109}
1110
1111/* Pin: checkbox + ▲▼-stepper with description (instead of a raw rank number). */
1112.pe-pin { display: flex; flex-direction: column; gap: 0.45rem; }
1113.pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; }
1114.pe-pin-pos[hidden] { display: none; }
1115.pe-pin-steps { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
1116.pe-pin-btn {
1117 width: 30px; height: 28px; padding: 0;
1118 border: none; background: var(--paper-2); color: var(--ink);
1119 cursor: pointer; font-size: 0.68rem; line-height: 1;
1120 display: inline-flex; align-items: center; justify-content: center;
1121}
1122.pe-pin-btn + .pe-pin-btn { border-left: 1px solid var(--rule); }
1123.pe-pin-btn:hover { background: color-mix(in srgb, var(--accent) 14%, var(--paper-2)); color: var(--accent); }
1124.pe-pin-btn:disabled { opacity: 0.35; cursor: default; }
1125.pe-pin-label { font-size: 0.85rem; color: var(--accent); font-weight: 600; }
1126
1127/* ─── Sticky action footer ──────────────────────────────────────── */
1128.pe-actions {
1129 position: sticky;
1130 bottom: 0;
1131 display: flex; align-items: center;
1132 gap: 0.5rem;
1133 padding: 0.85rem 1rem;
1134 margin: 0.5rem -1rem 0; /* extend to viewport edges on a narrow container */
1135 background: color-mix(in srgb, var(--paper) 94%, transparent);
1136 -webkit-backdrop-filter: blur(8px);
1137 backdrop-filter: blur(8px);
1138 border-top: 1px solid var(--rule);
1139 z-index: 10;
1140}
1141.pe-actions-spacer { flex: 1; }
1142
1143/* The editor is a focus screen: hide the mobile site tab bar (Home/Search/…)
1144 so two bars don't stack at the bottom (Save bar + tab bar). The
1145 Save/Cancel bar then becomes the only bottom bar → plain bottom:0,
1146 no tab offset needed. On desktop the tab bar is already hidden. */
1147body:has(.post-edit-page) .bottom-tab { display: none; }
1148/* Audio player (if playing) no longer lifted 56px for the now-hidden
1149 tab bar, otherwise it would float above the action bar. */
1150body:has(.post-edit-page) .audio-player { bottom: 0; }
1151
1152/* ── Image editor (rotate / crop / mirror) ── */
1153.imed-backdrop {
1154 position: fixed; inset: 0; z-index: 9999;
1155 display: flex; align-items: center; justify-content: center;
1156 background: rgba(0,0,0,.62); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
1157 padding: 14px;
1158}
1159.imed-modal {
1160 display: flex; flex-direction: column; gap: 12px;
1161 width: 100%; max-width: 640px; max-height: 92vh;
1162 background: var(--paper, #fff); color: var(--ink, #111);
1163 border: 1px solid var(--rule, rgba(128,128,128,.3)); border-radius: 14px;
1164 padding: 14px; box-sizing: border-box;
1165}
1166.imed-stage {
1167 height: 58vh; flex: 0 0 auto;
1168 background: var(--paper-2, rgba(128,128,128,.08)); border-radius: 10px; overflow: hidden;
1169}
1170.imed-stage img { display: block; max-width: 100%; }
1171/* Cropper's container must fill the full stage height — otherwise it collapses
1172 in a flex column without an explicit height → empty edit window (no image). */
1173.imed-stage .cropper-container { width: 100% !important; height: 100% !important; }
1174.imed-tools {
1175 display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;
1176}
1177.imed-tools button {
1178 width: 42px; height: 42px; font-size: 18px; line-height: 1;
1179 border: 1px solid var(--rule, rgba(128,128,128,.4)); border-radius: 10px;
1180 background: var(--paper-2, transparent); color: inherit; cursor: pointer;
1181}
1182.imed-tools button:hover { border-color: var(--accent); }
1183.imed-actions { display: flex; gap: 8px; justify-content: flex-end; }
1184</style>
1185
1186
1187
1188<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
1189 viewports, centered modal on ≥640px. Populated lazily on first open. %>
1190<% if (typeof user !== 'undefined' && user) { %>
1191<div id="track-picker" class="tp-modal" hidden aria-hidden="true">
1192 <div class="tp-backdrop" data-tp-close></div>
1193 <div class="tp-sheet" role="dialog" aria-modal="true" aria-labelledby="tp-title">
1194 <header class="tp-header">
1195 <h2 id="tp-title" class="tp-h2"><%= t('pedit.tp_title') %></h2>
1196 <button type="button" class="tp-close" data-tp-close aria-label="<%= t('pedit.tp_close') %>">
1197 <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>
1198 </button>
1199 </header>
1200 <div class="tp-search-row">
1201 <span class="tp-search-icon" aria-hidden="true">
1202 <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>
1203 </span>
1204 <input type="search" class="tp-search" id="tp-search" placeholder="<%= t('pedit.tp_search_placeholder') %>" autocomplete="off" inputmode="search">
1205 </div>
1206 <div class="tp-list" id="tp-list" role="listbox" aria-label="<%= t('pedit.tp_list_aria') %>">
1207 <div class="tp-empty" id="tp-empty"><%= t('pedit.js_tracks_loading') %></div>
1208 </div>
1209 </div>
1210</div>
1211<% } %>
1212
1213<%# Playlist editor modal — included so its global window.openPlaylistEditor
1214 is available when the toolbar button fires. Only renders if user can post. %>
1215<% if (typeof user !== 'undefined' && user) { %>
1216 <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
1217<% } %>
1218<%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %>
Note: See TracBrowser for help on using the repository browser.