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

main
Last change on this file since 1d1fdc9 was 8e1af9c, checked in by Robin <roboburr@…>, 3 weeks ago

Mixtape als posttype, kiesbaar in de composer

Het bandje bestond wel als soort playlist, maar je kon er geen post van maken.
Nu wel: een eigen knop met een eigen teken, en het muziekpaneel deelt hij met
album en playlist -- het verschil zit in de playlist die je insluit, niet in
wat je uploadt.

KEUZE_TYPES en MUZIEK_TYPES in config/post-types.js waren de goede plek; dat
bestand bestaat precies omdat die lijst eerder drie keer los rondslingerde.

Nog twee keer dezelfde binaire vorm opgeruimd, en de tweede was een echte:

  • VOLGBAAR kende geen mixtape, dus het type volgde de muziek niet.
  • De editor zocht de soort op met de vorm p.kind === 'playlist' ? 'playlist' : 'album'. Het scherm zag dus album terwijl de server mixtape opsloeg: het type verspringt onder je handen bij het bewaren.
  • De renderer koos teken en woord met dezelfde tweewegkeuze, waardoor een ingesloten mixtape het jasje van een album droeg.

De SPELER is er nog niet -- een mixtape rendert voorlopig als de gewone lijst.
Wat af is, is dat hij overal zichzelf noemt.

Zes tests, waaronder de opslagweg door de echte route: kent de opslag een type
niet, dan wordt de post zonder melding een gewone post, en dat is precies de
fout waar config/post-types.js voor waarschuwt. Een van de tests riep eerst een
methode aan die niet bestaat en sloeg zichzelf over; die gaat nu door
embedPlaylistShortcodes heen en valt om als je de tweewegkeuze terugzet.

MOD_V naar 51.

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

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