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