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

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

De posteditor, de laatste (shaer-bqr, stap 3g)

Zeven blokken, ruim 1000 regels, 34 vertaalsleutels en een tijdzone. Daarmee is
er in pages en partials geen inline script meer over, op embed-player na -- die
staat los (res.render, eigen CSP, altijd een volledige laadbeurt).

ESCAPEN. Deze pagina bouwt op plekken HTML met stringplakwerk, en daar gingen de
vertalingen doorheen als escapende EJS-tag. Een module heeft die niet, dus er is
een esc() bij gekomen in lib.js en alle 34 gaan daardoorheen. Dat is
gedragsgelijk aan wat er stond, en het houdt een apostrof in een vertaling uit
het attribuut waar hij in staat -- het soort fout dat pas in een andere taal
opvalt.

EEN FOUT DIE DE STEEKPROEF VING: ik liet de vervanging kiezen tussen een enkele
en een dubbele aanhalingsvorm op grond van het teken ernaast. Maar bij
aria-label="..." is dat aanhalingsteken HTML en niet de grens van de JS-string;
die is overal enkel. Resultaat was aria-label="" + esc(T.title) + "". Teruggezet
en uniform de enkele vorm gebruikt.

post-edit neemt de playlist-editor op, dus de route vraagt om beide modules.

Templates compileren, 23 modules schoon en syntactisch geldig, suite 565/565.

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