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

main
Last change on this file since 77019fa was d7e72b8, checked in by Robin <roboburr@…>, 4 weeks ago

Audio is geen keuze meer: Album en Playlist staan in de balk (shaer-cyg)

De posttype-balk had post/foto/video/audio. Audio verdwijnt als keuze -- alles
wat muziek is landt voortaan op album of playlist, en dat maakt de keuze
betekenisvol in plaats van cosmetisch. Album en Playlist delen het muziekpaneel,
want het verschil zit in de playlist en niet in wat je uploadt.

De knop Audio blijft alleen staan zolang een post nog type=audio IS. Anders zou
het openen van een oude post hem stilzwijgend van type veranderen, en dat is
precies het soort verlies dat niemand ziet gebeuren.

De lijst van geldige types stond op DRIE plaatsen los van elkaar: twee keer in
routes/posts.js en een keer in routes/types.js. Dat viel niet op zolang ze
gelijk waren, maar het faalt stil: kent het opslaan 'playlist' niet, dan wordt
de post zonder melding een gewone post en is de keuze verdwenen in plaats van
geweigerd. Nu een lijst, in config/post-types.js, die ook de editor voedt.

En de migratie: scripts/backfill-post-types.mjs zet bestaande audio-posts om via
postMusicType. Proefdraai is de standaard, --doen schrijft. Wat de afleiding
niet tot album of playlist maakt blijft met rust en wordt apart gemeld -- die
verdienen een blik en geen gok.

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

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