| [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;
|
|---|
| [851239c] | 11 | // In hub-modus moeten save/create/cancel de /user/<slug>/-prefix dragen, anders
|
|---|
| 12 | // valt de request terug op de primaire site (siteUrlBase leeg) en rendert de
|
|---|
| 13 | // post na opslaan headerless (bareChrome). In solo is _base leeg.
|
|---|
| 14 | const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
|
|---|
| [7bc636b] | 15 | %>
|
|---|
| 16 | <div class="container post-edit-page">
|
|---|
| 17 | <h1><%= isNew ? 'Nieuwe post' : 'Post bewerken' %></h1>
|
|---|
| 18 |
|
|---|
| [851239c] | 19 | <form method="post" action="<%= _base %><%= isNew ? '/posts/create' : '/posts/' + post.slug + '/save' %>" class="post-edit-form">
|
|---|
| [7bc636b] | 20 |
|
|---|
| 21 | <%# ββ IDENTITY βββββββββββββββββββββββββββββββββββββββββββββββ %>
|
|---|
| 22 | <section class="pe-card">
|
|---|
| 23 | <label class="pe-field">
|
|---|
| 24 | <span>Titel</span>
|
|---|
| 25 | <input type="text" name="title" value="<%= post.title %>" required autofocus>
|
|---|
| 26 | </label>
|
|---|
| 27 | <div class="pe-row pe-row-2">
|
|---|
| 28 | <label class="pe-field">
|
|---|
| 29 | <span>Slug (URL)</span>
|
|---|
| 30 | <input type="text" name="slug" value="<%= post.slug %>" placeholder="auto van titel als leeg">
|
|---|
| 31 | </label>
|
|---|
| 32 | <label class="pe-field">
|
|---|
| 33 | <span>Tags <small>(komma-gescheiden)</small></span>
|
|---|
| 34 | <input type="text" name="tags" value="<%= Array.isArray(post.tags) ? post.tags.join(', ') : '' %>">
|
|---|
| 35 | </label>
|
|---|
| 36 | </div>
|
|---|
| 37 | <label class="pe-field">
|
|---|
| 38 | <span>Excerpt <small>(preview tekst)</small></span>
|
|---|
| 39 | <textarea name="excerpt" rows="2"><%= post.excerpt %></textarea>
|
|---|
| 40 | </label>
|
|---|
| 41 | </section>
|
|---|
| 42 |
|
|---|
| 43 | <%# ββ COVER ββββββββββββββββββββββββββββββββββββββββββββββββββ %>
|
|---|
| 44 | <section class="pe-card">
|
|---|
| 45 | <div class="pe-section-title">Cover</div>
|
|---|
| 46 | <div class="pe-cover-row">
|
|---|
| 47 | <div class="pe-cover-thumb" id="cover-preview-wrap" <%= _hasCover ? '' : 'data-empty' %>>
|
|---|
| 48 | <img src="<%= _hasCover ? post.cover_image_url : '' %>" alt="" id="cover-preview-img" <%= _hasCover ? '' : 'hidden' %>>
|
|---|
| 49 | <% if (!_hasCover) { %><span class="pe-cover-empty">πΌ</span><% } %>
|
|---|
| 50 | </div>
|
|---|
| 51 | <div class="pe-cover-actions">
|
|---|
| 52 | <label class="pe-field">
|
|---|
| 53 | <span>Cover URL</span>
|
|---|
| [b50a885] | 54 | <input type="text" name="cover_image_url" id="cover-url-field"
|
|---|
| [7bc636b] | 55 | value="<%= post.cover_image_url || '' %>"
|
|---|
| [b50a885] | 56 | inputmode="url" autocapitalize="none" spellcheck="false"
|
|---|
| 57 | placeholder="/media/β¦ of https://β¦ (of upload met de knop)">
|
|---|
| [7bc636b] | 58 | </label>
|
|---|
| 59 | <div class="pe-cover-upload">
|
|---|
| 60 | <button type="button" class="pe-btn pe-btn-secondary" id="cover-upload-trigger">
|
|---|
| 61 | π· Upload nieuwe cover
|
|---|
| 62 | </button>
|
|---|
| 63 | <input type="file" id="cover-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
|
|---|
| 64 | <small id="cover-upload-status" class="pe-status"></small>
|
|---|
| 65 | </div>
|
|---|
| 66 | </div>
|
|---|
| 67 | </div>
|
|---|
| 68 | </section>
|
|---|
| 69 |
|
|---|
| 70 | <%# ββ CONTENT ββββββββββββββββββββββββββββββββββββββββββββββββ %>
|
|---|
| 71 | <section class="pe-card pe-card-content">
|
|---|
| 72 | <div class="pe-section-title">
|
|---|
| 73 | Content
|
|---|
| 74 | <small>Sleep een afbeelding om in te voegen Β· selecteer tekst om op te maken</small>
|
|---|
| 75 | </div>
|
|---|
| 76 | <div class="pe-editor-frame">
|
|---|
| 77 | <div class="pe-toolbar" id="pe-toolbar" role="toolbar" aria-label="Format">
|
|---|
| 78 | <button type="button" data-cmd="bold" title="Vet (Ctrl+B)" aria-label="Vet">
|
|---|
| 79 | <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>
|
|---|
| 80 | </button>
|
|---|
| 81 | <button type="button" data-cmd="italic" title="Cursief (Ctrl+I)" aria-label="Cursief">
|
|---|
| 82 | <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>
|
|---|
| 83 | </button>
|
|---|
| 84 | <button type="button" data-cmd="underline" title="Onderstreept" aria-label="Onderstreept">
|
|---|
| 85 | <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>
|
|---|
| 86 | </button>
|
|---|
| 87 | <span class="pe-toolbar-sep" aria-hidden="true"></span>
|
|---|
| 88 | <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h2" title="Kop">H2</button>
|
|---|
| 89 | <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h3" title="Subkop">H3</button>
|
|---|
| 90 | <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="p" title="Paragraaf">ΒΆ</button>
|
|---|
| 91 | <span class="pe-toolbar-sep" aria-hidden="true"></span>
|
|---|
| 92 | <button type="button" data-cmd="insertUnorderedList" title="Lijst" aria-label="Lijst">
|
|---|
| 93 | <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>
|
|---|
| 94 | </button>
|
|---|
| 95 | <button type="button" data-cmd="insertOrderedList" title="Genummerde lijst" aria-label="Genummerde lijst">
|
|---|
| 96 | <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>
|
|---|
| 97 | </button>
|
|---|
| 98 | <button type="button" data-cmd="formatBlock" data-arg="blockquote" title="Quote" aria-label="Quote">
|
|---|
| 99 | <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>
|
|---|
| 100 | </button>
|
|---|
| 101 | <button type="button" data-cmd="link-prompt" title="Link (Ctrl+K)" aria-label="Link">
|
|---|
| 102 | <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>
|
|---|
| 103 | </button>
|
|---|
| 104 | <button type="button" data-cmd="code-wrap" title="Code (inline)" aria-label="Code">
|
|---|
| 105 | <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>
|
|---|
| 106 | </button>
|
|---|
| 107 | <span class="pe-toolbar-sep" aria-hidden="true"></span>
|
|---|
| 108 | <button type="button" id="insert-image-btn" title="Afbeelding invoegen" aria-label="Afbeelding">
|
|---|
| 109 | <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>
|
|---|
| 110 | </button>
|
|---|
| 111 | <button type="button" id="insert-track-btn" title="Track invoegen" aria-label="Track">
|
|---|
| 112 | <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>
|
|---|
| 113 | </button>
|
|---|
| 114 | <button type="button" id="insert-playlist-btn" title="Playlist invoegen" aria-label="Playlist">
|
|---|
| 115 | <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>
|
|---|
| 116 | </button>
|
|---|
| [1907a18] | 117 | <button type="button" id="insert-embed-btn" title="Embed (YouTube, Spotify, SoundCloud, Vimeoβ¦)" aria-label="Media embedden">
|
|---|
| 118 | <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>
|
|---|
| 119 | </button>
|
|---|
| [7bc636b] | 120 | <span class="pe-toolbar-spacer"></span>
|
|---|
| 121 | <button type="button" data-cmd="removeFormat" title="Wis opmaak" aria-label="Wis opmaak">
|
|---|
| 122 | <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>
|
|---|
| 123 | </button>
|
|---|
| 124 | </div>
|
|---|
| 125 |
|
|---|
| 126 | <div id="content-editor"
|
|---|
| 127 | class="pe-editor"
|
|---|
| 128 | contenteditable="true"
|
|---|
| 129 | role="textbox"
|
|---|
| 130 | aria-multiline="true"
|
|---|
| 131 | aria-label="Content"
|
|---|
| 132 | data-placeholder="Begin met schrijvenβ¦"></div>
|
|---|
| 133 |
|
|---|
| 134 | <%# Hidden field is what actually submits to the server. The visible
|
|---|
| 135 | contenteditable's serialized HTML is copied here on submit. %>
|
|---|
| 136 | <input type="hidden" name="content" id="content-hidden" value="">
|
|---|
| 137 |
|
|---|
| 138 | <%# Initial content is injected as a JSON string in a script tag so we
|
|---|
| 139 | can set innerHTML safely from JS. EJS-escaping HTML directly into
|
|---|
| 140 | the editor would render entity-escaped tags as text. %>
|
|---|
| 141 | <script id="initial-content" type="application/json"><%- JSON.stringify(post.content || '').replace(/</g, '\\u003c') %></script>
|
|---|
| 142 |
|
|---|
| 143 | <div class="pe-editor-status">
|
|---|
| 144 | <small id="content-upload-status" class="pe-status"></small>
|
|---|
| 145 | <span class="pe-char-count"><span id="char-count">0</span> tekens</span>
|
|---|
| 146 | </div>
|
|---|
| 147 |
|
|---|
| 148 | <input type="file" id="content-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
|
|---|
| 149 | </div>
|
|---|
| 150 | </section>
|
|---|
| 151 |
|
|---|
| 152 | <%# ββ META βββββββββββββββββββββββββββββββββββββββββββββββββββ %>
|
|---|
| 153 | <section class="pe-card">
|
|---|
| 154 | <div class="pe-section-title">Publicatie</div>
|
|---|
| 155 | <div class="pe-row pe-row-2">
|
|---|
| 156 | <label class="pe-field">
|
|---|
| 157 | <span>Status</span>
|
|---|
| [edc462d] | 158 | <% var _st = isNew ? 'published' : (post.status || 'draft'); %>
|
|---|
| [7bc636b] | 159 | <select name="status">
|
|---|
| [edc462d] | 160 | <option value="published" <%= _st === 'published' ? 'selected' : '' %>>Gepubliceerd</option>
|
|---|
| 161 | <option value="draft" <%= _st === 'draft' ? 'selected' : '' %>>Concept</option>
|
|---|
| 162 | <option value="archived" <%= _st === 'archived' ? 'selected' : '' %>>Gearchiveerd</option>
|
|---|
| [7bc636b] | 163 | </select>
|
|---|
| 164 | </label>
|
|---|
| 165 | <label class="pe-field">
|
|---|
| 166 | <span>Type</span>
|
|---|
| 167 | <select name="type">
|
|---|
| 168 | <% var ptype = post.type || 'post'; %>
|
|---|
| 169 | <option value="post" <%= ptype === 'post' ? 'selected' : '' %>>Post</option>
|
|---|
| 170 | <option value="foto" <%= ptype === 'foto' ? 'selected' : '' %>>Foto</option>
|
|---|
| 171 | <option value="video" <%= ptype === 'video' ? 'selected' : '' %>>Video</option>
|
|---|
| 172 | <option value="audio" <%= ptype === 'audio' ? 'selected' : '' %>>Audio</option>
|
|---|
| 173 | </select>
|
|---|
| 174 | </label>
|
|---|
| 175 | </div>
|
|---|
| 176 | <div class="pe-checkboxes">
|
|---|
| [edc462d] | 177 | <div class="pe-pin">
|
|---|
| 178 | <label class="pe-checkbox">
|
|---|
| 179 | <input type="checkbox" id="pin-toggle" <%= (Number(post.pinned) > 0) ? 'checked' : '' %>>
|
|---|
| 180 | <span>π Vastpinnen bovenaan</span>
|
|---|
| 181 | </label>
|
|---|
| 182 | <input type="hidden" name="pinned" id="pin-rank" value="<%= post.pinned || 0 %>">
|
|---|
| 183 | <div class="pe-pin-pos" id="pin-pos" <%= (Number(post.pinned) > 0) ? '' : 'hidden' %>>
|
|---|
| 184 | <span class="pe-pin-steps">
|
|---|
| 185 | <button type="button" class="pe-pin-btn" id="pin-up" aria-label="Hoger zetten">β²</button>
|
|---|
| 186 | <button type="button" class="pe-pin-btn" id="pin-down" aria-label="Lager zetten">βΌ</button>
|
|---|
| 187 | </span>
|
|---|
| 188 | <span class="pe-pin-label" id="pin-label"></span>
|
|---|
| 189 | </div>
|
|---|
| 190 | </div>
|
|---|
| [7bc636b] | 191 | <label class="pe-checkbox">
|
|---|
| 192 | <input type="checkbox" name="noindex" value="1" <%= post.noindex ? 'checked' : '' %>>
|
|---|
| 193 | <span>π« noindex (verberg voor zoekmachines)</span>
|
|---|
| 194 | </label>
|
|---|
| 195 | </div>
|
|---|
| 196 | </section>
|
|---|
| 197 |
|
|---|
| 198 | <%# ββ ACTIONS (sticky at bottom) ββββββββββββββββββββββββββββ %>
|
|---|
| 199 | <div class="pe-actions">
|
|---|
| [851239c] | 200 | <a href="<%= _base %><%= isNew ? '/' : '/' + post.slug %>" class="pe-btn">Annuleren</a>
|
|---|
| [7bc636b] | 201 | <div class="pe-actions-spacer"></div>
|
|---|
| 202 | <% if (!isNew && post.status !== 'published') { %>
|
|---|
| 203 | <button type="submit" name="action" value="publish" class="pe-btn pe-btn-success">π€ Publish</button>
|
|---|
| 204 | <% } %>
|
|---|
| 205 | <button type="submit" class="pe-btn pe-btn-primary">πΎ Opslaan</button>
|
|---|
| 206 | </div>
|
|---|
| 207 | </form>
|
|---|
| 208 | </div>
|
|---|
| 209 |
|
|---|
| 210 | <style>
|
|---|
| 211 | /* βββ Page wrapper ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|---|
| 212 | .post-edit-page {
|
|---|
| 213 | max-width: 880px;
|
|---|
| 214 | margin: 1.5rem auto 6rem; /* extra bottom for sticky-actions clearance */
|
|---|
| 215 | padding: 0 1rem;
|
|---|
| 216 | }
|
|---|
| 217 | .post-edit-page h1 {
|
|---|
| 218 | font-family: var(--font-display, serif);
|
|---|
| 219 | margin: 0 0 1.25rem;
|
|---|
| 220 | font-size: 1.75rem;
|
|---|
| 221 | }
|
|---|
| 222 |
|
|---|
| 223 | .post-edit-form {
|
|---|
| 224 | display: flex; flex-direction: column;
|
|---|
| 225 | gap: 1rem;
|
|---|
| 226 | }
|
|---|
| 227 |
|
|---|
| 228 | /* βββ Card sections βββββββββββββββββββββββββββββββββββββββββββββββ */
|
|---|
| 229 | .pe-card {
|
|---|
| 230 | background: var(--paper);
|
|---|
| 231 | border: 1px solid var(--rule);
|
|---|
| 232 | border-radius: 12px;
|
|---|
| 233 | padding: 1.25rem;
|
|---|
| 234 | display: flex; flex-direction: column;
|
|---|
| 235 | gap: 0.85rem;
|
|---|
| 236 | }
|
|---|
| 237 | .pe-card-content { padding-bottom: 0; overflow: hidden; } /* editor flush to bottom */
|
|---|
| 238 | .pe-section-title {
|
|---|
| 239 | font-size: 0.8rem;
|
|---|
| 240 | font-weight: 600;
|
|---|
| 241 | text-transform: uppercase;
|
|---|
| 242 | letter-spacing: 0.05em;
|
|---|
| 243 | color: var(--ink-soft);
|
|---|
| 244 | display: flex; align-items: baseline; gap: 0.5rem;
|
|---|
| 245 | flex-wrap: wrap;
|
|---|
| 246 | }
|
|---|
| 247 | .pe-section-title small {
|
|---|
| 248 | font-size: 0.75rem; font-weight: 400;
|
|---|
| 249 | letter-spacing: 0; text-transform: none;
|
|---|
| 250 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 251 | }
|
|---|
| 252 |
|
|---|
| 253 | /* βββ Field primitives ββββββββββββββββββββββββββββββββββββββββββββ */
|
|---|
| 254 | .pe-field {
|
|---|
| 255 | display: flex; flex-direction: column;
|
|---|
| 256 | gap: 0.35rem;
|
|---|
| 257 | min-width: 0; /* allow grid items to shrink */
|
|---|
| 258 | }
|
|---|
| 259 | .pe-field > span {
|
|---|
| 260 | font-size: 0.8rem;
|
|---|
| 261 | font-weight: 600;
|
|---|
| 262 | color: var(--ink-soft);
|
|---|
| 263 | display: flex; align-items: baseline; gap: 0.4rem;
|
|---|
| 264 | }
|
|---|
| 265 | .pe-field > span small {
|
|---|
| 266 | font-weight: 400; color: var(--ink-muted);
|
|---|
| 267 | }
|
|---|
| 268 | .pe-field input,
|
|---|
| 269 | .pe-field textarea,
|
|---|
| 270 | .pe-field select {
|
|---|
| 271 | width: 100%;
|
|---|
| 272 | box-sizing: border-box;
|
|---|
| 273 | display: block;
|
|---|
| 274 | padding: 0.6rem 0.75rem;
|
|---|
| 275 | border: 1px solid var(--rule);
|
|---|
| 276 | border-radius: 6px;
|
|---|
| 277 | background: var(--paper-2);
|
|---|
| 278 | color: var(--ink);
|
|---|
| 279 | font-family: var(--font-ui, system-ui), sans-serif;
|
|---|
| 280 | font-size: 0.95rem;
|
|---|
| 281 | -webkit-appearance: none;
|
|---|
| 282 | appearance: none;
|
|---|
| 283 | transition: border-color 120ms;
|
|---|
| 284 | }
|
|---|
| 285 | .pe-field input:focus,
|
|---|
| 286 | .pe-field textarea:focus,
|
|---|
| 287 | .pe-field select:focus {
|
|---|
| 288 | outline: 2px solid var(--accent);
|
|---|
| 289 | outline-offset: -1px;
|
|---|
| 290 | border-color: var(--accent);
|
|---|
| 291 | }
|
|---|
| 292 | .pe-field textarea {
|
|---|
| 293 | font-family: var(--font-mono, monospace);
|
|---|
| 294 | resize: vertical;
|
|---|
| 295 | }
|
|---|
| 296 | .pe-field select {
|
|---|
| 297 | /* Restore the dropdown arrow we removed with appearance:none */
|
|---|
| 298 | 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>");
|
|---|
| 299 | background-repeat: no-repeat;
|
|---|
| 300 | background-position: right 0.75rem center;
|
|---|
| 301 | padding-right: 2rem;
|
|---|
| 302 | }
|
|---|
| 303 |
|
|---|
| 304 | .pe-row {
|
|---|
| 305 | display: grid;
|
|---|
| 306 | gap: 0.75rem;
|
|---|
| 307 | }
|
|---|
| 308 | .pe-row-2 { grid-template-columns: 1fr 1fr; }
|
|---|
| 309 | @media (max-width: 600px) {
|
|---|
| 310 | .pe-row-2 { grid-template-columns: 1fr; }
|
|---|
| 311 | }
|
|---|
| 312 |
|
|---|
| 313 | /* βββ Cover field βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|---|
| 314 | .pe-cover-row {
|
|---|
| 315 | display: grid;
|
|---|
| 316 | grid-template-columns: 120px 1fr;
|
|---|
| 317 | gap: 1rem;
|
|---|
| 318 | align-items: start;
|
|---|
| 319 | }
|
|---|
| 320 | .pe-cover-thumb {
|
|---|
| 321 | width: 120px; height: 120px;
|
|---|
| 322 | border-radius: 10px;
|
|---|
| 323 | overflow: hidden;
|
|---|
| 324 | background: var(--paper-2);
|
|---|
| 325 | border: 1px solid var(--rule);
|
|---|
| 326 | display: flex; align-items: center; justify-content: center;
|
|---|
| 327 | position: relative;
|
|---|
| 328 | }
|
|---|
| 329 | .pe-cover-thumb[data-empty] {
|
|---|
| 330 | border-style: dashed;
|
|---|
| 331 | }
|
|---|
| 332 | .pe-cover-thumb img {
|
|---|
| 333 | width: 100%; height: 100%;
|
|---|
| 334 | object-fit: cover; display: block;
|
|---|
| 335 | }
|
|---|
| 336 | /* Honor HTML hidden β our display:block above otherwise renders an empty
|
|---|
| 337 | broken-image icon when no cover is set. */
|
|---|
| 338 | .pe-cover-thumb img[hidden] { display: none; }
|
|---|
| 339 | .pe-cover-empty {
|
|---|
| 340 | font-size: 2rem;
|
|---|
| 341 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 342 | opacity: 0.5;
|
|---|
| 343 | }
|
|---|
| 344 | .pe-cover-actions {
|
|---|
| 345 | display: flex; flex-direction: column;
|
|---|
| 346 | gap: 0.6rem;
|
|---|
| 347 | min-width: 0;
|
|---|
| 348 | }
|
|---|
| 349 | .pe-cover-upload {
|
|---|
| 350 | display: flex; align-items: center; gap: 0.6rem;
|
|---|
| 351 | flex-wrap: wrap;
|
|---|
| 352 | }
|
|---|
| 353 | @media (max-width: 600px) {
|
|---|
| 354 | .pe-cover-row { grid-template-columns: 88px 1fr; }
|
|---|
| 355 | .pe-cover-thumb { width: 88px; height: 88px; }
|
|---|
| 356 | }
|
|---|
| 357 |
|
|---|
| 358 | /* βββ Editor frame (WYSIWYG: top toolbar, contenteditable body, status bar) βββ */
|
|---|
| 359 | .pe-editor-frame {
|
|---|
| 360 | margin: 0 -1.25rem -1.25rem; /* break out of card padding for flush edges */
|
|---|
| 361 | border-top: 1px solid var(--rule);
|
|---|
| 362 | background: var(--paper);
|
|---|
| 363 | display: flex; flex-direction: column;
|
|---|
| 364 | }
|
|---|
| 365 |
|
|---|
| 366 | /* Top toolbar β sticks to the top of the viewport while editing */
|
|---|
| 367 | .pe-toolbar {
|
|---|
| 368 | display: flex; align-items: center; gap: .15rem;
|
|---|
| 369 | flex-wrap: wrap;
|
|---|
| 370 | padding: .4rem .75rem;
|
|---|
| 371 | background: color-mix(in srgb, var(--paper) 92%, transparent);
|
|---|
| 372 | backdrop-filter: blur(10px);
|
|---|
| 373 | -webkit-backdrop-filter: blur(10px);
|
|---|
| 374 | border-bottom: 1px solid var(--rule);
|
|---|
| 375 | position: sticky;
|
|---|
| 376 | top: 0;
|
|---|
| 377 | z-index: 10;
|
|---|
| 378 | }
|
|---|
| 379 | .pe-toolbar button {
|
|---|
| 380 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| 381 | width: 32px; height: 32px;
|
|---|
| 382 | padding: 0;
|
|---|
| 383 | background: transparent;
|
|---|
| 384 | border: 1px solid transparent;
|
|---|
| 385 | border-radius: 6px;
|
|---|
| 386 | color: var(--ink);
|
|---|
| 387 | cursor: pointer;
|
|---|
| 388 | font-family: var(--font-ui, system-ui), sans-serif;
|
|---|
| 389 | font-weight: 600; font-size: .85rem;
|
|---|
| 390 | transition: background var(--transition), color var(--transition), border-color var(--transition);
|
|---|
| 391 | -webkit-tap-highlight-color: transparent;
|
|---|
| 392 | }
|
|---|
| 393 | .pe-toolbar button:hover {
|
|---|
| 394 | background: var(--paper-2);
|
|---|
| 395 | color: var(--accent);
|
|---|
| 396 | }
|
|---|
| 397 | .pe-toolbar button:active { transform: scale(.94); }
|
|---|
| 398 | .pe-toolbar button.is-active {
|
|---|
| 399 | background: color-mix(in srgb, var(--accent) 14%, var(--paper-2));
|
|---|
| 400 | color: var(--accent);
|
|---|
| 401 | border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
|---|
| 402 | }
|
|---|
| 403 | .pe-toolbar button svg { width: 16px; height: 16px; }
|
|---|
| 404 | .pe-toolbar button.pe-tb-text { font-family: var(--font-display, serif); font-weight: 700; }
|
|---|
| 405 | .pe-toolbar-sep {
|
|---|
| 406 | width: 1px; height: 18px;
|
|---|
| 407 | background: var(--rule);
|
|---|
| 408 | margin: 0 .35rem;
|
|---|
| 409 | flex-shrink: 0;
|
|---|
| 410 | }
|
|---|
| 411 | .pe-toolbar-spacer { flex: 1; }
|
|---|
| 412 |
|
|---|
| 413 | /* Editor body β looks like prose, behaves like a textarea */
|
|---|
| 414 | .pe-editor {
|
|---|
| 415 | width: 100%;
|
|---|
| 416 | box-sizing: border-box;
|
|---|
| 417 | min-height: 420px;
|
|---|
| 418 | padding: 1.25rem 1.5rem;
|
|---|
| 419 | border: 0;
|
|---|
| 420 | background: transparent;
|
|---|
| 421 | color: var(--ink);
|
|---|
| 422 | font-family: var(--font-body, system-ui), serif;
|
|---|
| 423 | font-size: 1.0625rem;
|
|---|
| 424 | line-height: 1.65;
|
|---|
| 425 | outline: none;
|
|---|
| 426 | overflow-y: auto;
|
|---|
| 427 | }
|
|---|
| 428 | .pe-editor:focus { outline: none; }
|
|---|
| 429 | .pe-editor.is-dragover {
|
|---|
| 430 | outline: 2px dashed var(--accent);
|
|---|
| 431 | outline-offset: -10px;
|
|---|
| 432 | }
|
|---|
| 433 | .pe-editor[data-placeholder]:empty::before,
|
|---|
| 434 | .pe-editor[data-placeholder]:has(br:only-child)::before {
|
|---|
| 435 | content: attr(data-placeholder);
|
|---|
| 436 | color: var(--ink-muted, #999);
|
|---|
| 437 | pointer-events: none;
|
|---|
| 438 | opacity: .55;
|
|---|
| 439 | }
|
|---|
| 440 | /* Inline prose styles inside the editor β match the public post styling so
|
|---|
| 441 | what you see is roughly what you'll get. Margins are tighter than on the
|
|---|
| 442 | live site since this is a confined writing space. */
|
|---|
| 443 | .pe-editor h1, .pe-editor h2, .pe-editor h3, .pe-editor h4 {
|
|---|
| 444 | font-family: var(--font-display, serif);
|
|---|
| 445 | line-height: 1.2;
|
|---|
| 446 | margin: 1.1rem 0 .35rem;
|
|---|
| 447 | }
|
|---|
| 448 | .pe-editor h1 { font-size: 1.85rem; }
|
|---|
| 449 | .pe-editor h2 { font-size: 1.45rem; }
|
|---|
| 450 | .pe-editor h3 { font-size: 1.2rem; }
|
|---|
| 451 | .pe-editor p { margin: 0 0 .85em; }
|
|---|
| 452 | .pe-editor ul, .pe-editor ol { margin: 0 0 .85em 1.4em; padding: 0; }
|
|---|
| 453 | .pe-editor li { margin: .15em 0; }
|
|---|
| 454 | .pe-editor blockquote {
|
|---|
| 455 | margin: 1em 0;
|
|---|
| 456 | padding: .15em 0 .15em 1em;
|
|---|
| 457 | border-left: 3px solid var(--accent);
|
|---|
| 458 | color: var(--ink-soft, var(--ink));
|
|---|
| 459 | font-style: italic;
|
|---|
| 460 | }
|
|---|
| 461 | .pe-editor code {
|
|---|
| 462 | background: var(--paper-2);
|
|---|
| 463 | border: 1px solid var(--rule);
|
|---|
| 464 | border-radius: 4px;
|
|---|
| 465 | padding: .1em .35em;
|
|---|
| 466 | font-family: var(--font-mono, ui-monospace, monospace);
|
|---|
| 467 | font-size: .92em;
|
|---|
| 468 | }
|
|---|
| 469 | .pe-editor a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
|---|
| 470 | .pe-editor img {
|
|---|
| 471 | max-width: 100%; height: auto;
|
|---|
| 472 | border-radius: 6px;
|
|---|
| 473 | display: block;
|
|---|
| 474 | margin: 1em auto;
|
|---|
| 475 | }
|
|---|
| 476 |
|
|---|
| 477 | /* Shortcode chips β visible inline placeholder in the editor for
|
|---|
| 478 | [[track:UUID]] / [[album:Name]] / [[playlist:slug]]. They serialize back
|
|---|
| 479 | to plain shortcode text on submit. */
|
|---|
| 480 | .sc-chip {
|
|---|
| 481 | display: inline-flex; align-items: center; gap: .3rem;
|
|---|
| 482 | padding: .15em .5em;
|
|---|
| 483 | margin: 0 .15em;
|
|---|
| 484 | background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
|
|---|
| 485 | border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
|
|---|
| 486 | border-radius: 999px;
|
|---|
| 487 | color: var(--accent);
|
|---|
| 488 | font-family: var(--font-ui, system-ui), sans-serif;
|
|---|
| 489 | font-size: .85em;
|
|---|
| 490 | font-weight: 600;
|
|---|
| 491 | font-style: normal;
|
|---|
| 492 | white-space: nowrap;
|
|---|
| 493 | user-select: none;
|
|---|
| 494 | cursor: default;
|
|---|
| 495 | vertical-align: baseline;
|
|---|
| 496 | }
|
|---|
| 497 | .sc-chip-icon { display: inline-flex; opacity: .8; }
|
|---|
| 498 | .sc-chip-icon svg { width: 12px; height: 12px; }
|
|---|
| 499 |
|
|---|
| 500 | /* Status bar below the editor */
|
|---|
| 501 | .pe-editor-status {
|
|---|
| 502 | display: flex; align-items: center; justify-content: space-between;
|
|---|
| 503 | gap: .75rem;
|
|---|
| 504 | padding: .45rem 1rem;
|
|---|
| 505 | border-top: 1px solid var(--rule);
|
|---|
| 506 | background: var(--paper-2);
|
|---|
| 507 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 508 | font-size: .78rem;
|
|---|
| 509 | }
|
|---|
| 510 | .pe-char-count { font-variant-numeric: tabular-nums; }
|
|---|
| 511 |
|
|---|
| 512 | /* βββ Track picker modal (P59) βββββββββββββββββββββββββββββββββ
|
|---|
| 513 | Mobile-first: full-screen bottom-sheet on phones, centered card
|
|---|
| 514 | on desktop. Lock body scroll while open via .tp-locked on body. */
|
|---|
| 515 | .tp-modal {
|
|---|
| 516 | position: fixed; inset: 0;
|
|---|
| 517 | z-index: 1000;
|
|---|
| 518 | display: flex; align-items: stretch; justify-content: center;
|
|---|
| 519 | /* Avoid the iOS home-indicator + the keyboard when search is focused */
|
|---|
| 520 | padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
|
|---|
| 521 | }
|
|---|
| 522 | .tp-modal[hidden] { display: none; }
|
|---|
| 523 | .tp-backdrop {
|
|---|
| 524 | position: absolute; inset: 0;
|
|---|
| 525 | background: color-mix(in srgb, var(--ink) 55%, transparent);
|
|---|
| 526 | backdrop-filter: blur(4px);
|
|---|
| 527 | -webkit-backdrop-filter: blur(4px);
|
|---|
| 528 | animation: tp-bd-in .18s ease-out;
|
|---|
| 529 | }
|
|---|
| 530 | @keyframes tp-bd-in { from { opacity: 0 } to { opacity: 1 } }
|
|---|
| 531 |
|
|---|
| 532 | /* Mobile: sheet docks to the bottom and fills viewport almost completely */
|
|---|
| 533 | .tp-sheet {
|
|---|
| 534 | position: relative;
|
|---|
| 535 | margin-top: auto;
|
|---|
| 536 | width: 100%;
|
|---|
| 537 | max-height: calc(100dvh - env(safe-area-inset-top) - 1rem);
|
|---|
| 538 | background: var(--paper);
|
|---|
| 539 | border-top: 1px solid var(--rule);
|
|---|
| 540 | border-radius: 16px 16px 0 0;
|
|---|
| 541 | box-shadow:
|
|---|
| 542 | 0 -2px 8px color-mix(in srgb, var(--ink) 8%, transparent),
|
|---|
| 543 | 0 -16px 48px color-mix(in srgb, var(--ink) 18%, transparent);
|
|---|
| 544 | display: flex; flex-direction: column;
|
|---|
| 545 | animation: tp-sheet-up .22s cubic-bezier(.2, .8, .25, 1);
|
|---|
| 546 | overflow: hidden;
|
|---|
| 547 | }
|
|---|
| 548 | @keyframes tp-sheet-up {
|
|---|
| 549 | from { transform: translateY(100%); opacity: .8; }
|
|---|
| 550 | to { transform: translateY(0); opacity: 1; }
|
|---|
| 551 | }
|
|---|
| 552 |
|
|---|
| 553 | /* Header β title + close, with a small grab-handle bar on mobile */
|
|---|
| 554 | .tp-header {
|
|---|
| 555 | display: flex; align-items: center; justify-content: space-between;
|
|---|
| 556 | gap: .75rem;
|
|---|
| 557 | padding: .85rem 1rem .65rem;
|
|---|
| 558 | border-bottom: 1px solid var(--rule);
|
|---|
| 559 | position: relative;
|
|---|
| 560 | }
|
|---|
| 561 | .tp-header::before {
|
|---|
| 562 | /* Grab-handle: visible on mobile only */
|
|---|
| 563 | content: '';
|
|---|
| 564 | position: absolute;
|
|---|
| 565 | top: .35rem; left: 50%;
|
|---|
| 566 | transform: translateX(-50%);
|
|---|
| 567 | width: 38px; height: 4px;
|
|---|
| 568 | background: var(--rule-2, var(--rule));
|
|---|
| 569 | border-radius: 2px;
|
|---|
| 570 | }
|
|---|
| 571 | .tp-h2 {
|
|---|
| 572 | margin: .35rem 0 0;
|
|---|
| 573 | font-family: var(--font-display, serif);
|
|---|
| 574 | font-size: 1.15rem; font-weight: 600;
|
|---|
| 575 | color: var(--ink);
|
|---|
| 576 | line-height: 1.1;
|
|---|
| 577 | }
|
|---|
| 578 | .tp-close {
|
|---|
| 579 | width: 32px; height: 32px; border-radius: 8px;
|
|---|
| 580 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| 581 | background: transparent; border: 1px solid transparent;
|
|---|
| 582 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 583 | cursor: pointer;
|
|---|
| 584 | transition: background var(--transition), color var(--transition), border-color var(--transition);
|
|---|
| 585 | -webkit-tap-highlight-color: transparent;
|
|---|
| 586 | margin-top: .35rem;
|
|---|
| 587 | }
|
|---|
| 588 | .tp-close:hover, .tp-close:focus-visible {
|
|---|
| 589 | background: var(--paper-2);
|
|---|
| 590 | color: var(--ink);
|
|---|
| 591 | outline: none;
|
|---|
| 592 | }
|
|---|
| 593 | .tp-close svg { width: 18px; height: 18px; }
|
|---|
| 594 |
|
|---|
| 595 | /* Search row β sticky just below header so list scrolls underneath */
|
|---|
| 596 | .tp-search-row {
|
|---|
| 597 | position: relative;
|
|---|
| 598 | padding: .65rem 1rem;
|
|---|
| 599 | border-bottom: 1px solid var(--rule);
|
|---|
| 600 | background: var(--paper);
|
|---|
| 601 | }
|
|---|
| 602 | .tp-search-icon {
|
|---|
| 603 | position: absolute;
|
|---|
| 604 | top: 50%; left: 1.65rem;
|
|---|
| 605 | transform: translateY(-50%);
|
|---|
| 606 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 607 | pointer-events: none;
|
|---|
| 608 | display: inline-flex;
|
|---|
| 609 | }
|
|---|
| 610 | .tp-search-icon svg { width: 16px; height: 16px; }
|
|---|
| 611 | .tp-search {
|
|---|
| 612 | width: 100%; box-sizing: border-box;
|
|---|
| 613 | padding: .65rem .85rem .65rem 2.4rem;
|
|---|
| 614 | font-family: var(--font-ui, system-ui), sans-serif;
|
|---|
| 615 | font-size: 1rem;
|
|---|
| 616 | background: var(--paper-2);
|
|---|
| 617 | color: var(--ink);
|
|---|
| 618 | border: 1px solid var(--rule);
|
|---|
| 619 | border-radius: 9px;
|
|---|
| 620 | -webkit-appearance: none;
|
|---|
| 621 | appearance: none;
|
|---|
| 622 | }
|
|---|
| 623 | .tp-search:focus {
|
|---|
| 624 | outline: none;
|
|---|
| 625 | border-color: var(--accent);
|
|---|
| 626 | box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
|
|---|
| 627 | }
|
|---|
| 628 |
|
|---|
| 629 | /* List β vertically scrollable, takes remaining sheet height */
|
|---|
| 630 | .tp-list {
|
|---|
| 631 | flex: 1 1 auto;
|
|---|
| 632 | overflow-y: auto;
|
|---|
| 633 | -webkit-overflow-scrolling: touch;
|
|---|
| 634 | padding: .35rem .35rem 1rem;
|
|---|
| 635 | }
|
|---|
| 636 | .tp-empty {
|
|---|
| 637 | padding: 1.5rem 1rem;
|
|---|
| 638 | text-align: center;
|
|---|
| 639 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 640 | font-size: .9rem;
|
|---|
| 641 | }
|
|---|
| 642 |
|
|---|
| 643 | /* Track row β tap target β₯ 56px for mobile */
|
|---|
| 644 | .tp-row {
|
|---|
| 645 | display: grid;
|
|---|
| 646 | grid-template-columns: 44px 1fr auto;
|
|---|
| 647 | align-items: center;
|
|---|
| 648 | gap: .75rem;
|
|---|
| 649 | width: 100%;
|
|---|
| 650 | padding: .55rem .65rem;
|
|---|
| 651 | border: 0; background: transparent;
|
|---|
| 652 | border-radius: 10px;
|
|---|
| 653 | text-align: left;
|
|---|
| 654 | cursor: pointer;
|
|---|
| 655 | color: inherit;
|
|---|
| 656 | font: inherit;
|
|---|
| 657 | transition: background var(--transition);
|
|---|
| 658 | -webkit-tap-highlight-color: transparent;
|
|---|
| 659 | }
|
|---|
| 660 | .tp-row:hover, .tp-row:focus-visible {
|
|---|
| 661 | background: color-mix(in srgb, var(--accent) 8%, var(--paper-2));
|
|---|
| 662 | outline: none;
|
|---|
| 663 | }
|
|---|
| 664 | .tp-row:active { transform: scale(.98); }
|
|---|
| 665 | .tp-row[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
|
|---|
| 666 |
|
|---|
| 667 | .tp-cover {
|
|---|
| 668 | width: 44px; height: 44px;
|
|---|
| 669 | border-radius: 6px;
|
|---|
| 670 | background-size: cover; background-position: center;
|
|---|
| 671 | background-color: var(--rule);
|
|---|
| 672 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| 673 | flex-shrink: 0;
|
|---|
| 674 | }
|
|---|
| 675 | .tp-cover-empty {
|
|---|
| 676 | background: linear-gradient(135deg,
|
|---|
| 677 | color-mix(in srgb, var(--accent) 30%, var(--paper-2)),
|
|---|
| 678 | color-mix(in srgb, var(--accent) 8%, var(--paper-2)));
|
|---|
| 679 | color: var(--accent);
|
|---|
| 680 | }
|
|---|
| 681 | .tp-cover-empty svg { width: 18px; height: 18px; opacity: .8; }
|
|---|
| 682 |
|
|---|
| 683 | .tp-meta {
|
|---|
| 684 | display: flex; flex-direction: column;
|
|---|
| 685 | min-width: 0;
|
|---|
| 686 | line-height: 1.25;
|
|---|
| 687 | }
|
|---|
| 688 | .tp-row-title {
|
|---|
| 689 | font-weight: 600; font-size: .94rem;
|
|---|
| 690 | color: var(--ink);
|
|---|
| 691 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|---|
| 692 | }
|
|---|
| 693 | .tp-row-artist {
|
|---|
| 694 | font-size: .8rem;
|
|---|
| 695 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 696 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|---|
| 697 | margin-top: .1rem;
|
|---|
| 698 | }
|
|---|
| 699 | .tp-duration {
|
|---|
| 700 | font-variant-numeric: tabular-nums;
|
|---|
| 701 | font-size: .8rem;
|
|---|
| 702 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 703 | flex-shrink: 0;
|
|---|
| 704 | }
|
|---|
| 705 |
|
|---|
| 706 | /* Body lock when modal open */
|
|---|
| 707 | body.tp-locked { overflow: hidden; touch-action: none; }
|
|---|
| 708 |
|
|---|
| 709 | /* Desktop: centered card, max-width ~520px */
|
|---|
| 710 | @media (min-width: 640px) {
|
|---|
| 711 | .tp-modal {
|
|---|
| 712 | align-items: center;
|
|---|
| 713 | padding: 1.5rem;
|
|---|
| 714 | }
|
|---|
| 715 | .tp-sheet {
|
|---|
| 716 | margin-top: 0;
|
|---|
| 717 | width: 100%;
|
|---|
| 718 | max-width: 520px;
|
|---|
| 719 | max-height: 80dvh;
|
|---|
| 720 | border-radius: 14px;
|
|---|
| 721 | border: 1px solid var(--rule);
|
|---|
| 722 | animation: tp-sheet-pop .2s cubic-bezier(.2, .8, .25, 1);
|
|---|
| 723 | }
|
|---|
| 724 | .tp-header::before { display: none; } /* hide grab-handle on desktop */
|
|---|
| 725 | .tp-header { padding: 1rem 1.25rem .75rem; }
|
|---|
| 726 | .tp-h2 { margin-top: 0; font-size: 1.25rem; }
|
|---|
| 727 | .tp-close { margin-top: 0; }
|
|---|
| 728 | @keyframes tp-sheet-pop {
|
|---|
| 729 | from { transform: translateY(-8px) scale(.97); opacity: 0; }
|
|---|
| 730 | to { transform: translateY(0) scale(1); opacity: 1; }
|
|---|
| 731 | }
|
|---|
| 732 | }
|
|---|
| 733 |
|
|---|
| 734 | /* βββ Buttons βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|---|
| 735 | .pe-btn {
|
|---|
| 736 | display: inline-flex; align-items: center; gap: 0.4rem;
|
|---|
| 737 | padding: 0.55rem 1rem;
|
|---|
| 738 | border: 1px solid var(--rule);
|
|---|
| 739 | background: var(--paper-2);
|
|---|
| 740 | color: var(--ink);
|
|---|
| 741 | font-family: var(--font-ui, system-ui), sans-serif;
|
|---|
| 742 | font-size: 0.9rem; font-weight: 500;
|
|---|
| 743 | text-decoration: none;
|
|---|
| 744 | border-radius: 6px;
|
|---|
| 745 | cursor: pointer;
|
|---|
| 746 | transition: background 120ms, border-color 120ms;
|
|---|
| 747 | min-height: 40px;
|
|---|
| 748 | -webkit-tap-highlight-color: transparent;
|
|---|
| 749 | }
|
|---|
| 750 | .pe-btn:hover { border-color: var(--accent); }
|
|---|
| 751 | .pe-btn:active { transform: scale(0.97); }
|
|---|
| 752 | .pe-btn-tiny {
|
|---|
| 753 | padding: 0.35rem 0.7rem;
|
|---|
| 754 | font-size: 0.85rem;
|
|---|
| 755 | min-height: 32px;
|
|---|
| 756 | }
|
|---|
| 757 | .pe-btn-secondary { background: var(--paper-2); }
|
|---|
| 758 | .pe-btn-primary {
|
|---|
| 759 | background: var(--accent); color: white;
|
|---|
| 760 | border-color: var(--accent);
|
|---|
| 761 | }
|
|---|
| 762 | .pe-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
|
|---|
| 763 | .pe-btn-success {
|
|---|
| 764 | background: #16a34a; color: white;
|
|---|
| 765 | border-color: #16a34a;
|
|---|
| 766 | }
|
|---|
| 767 | .pe-btn-success:hover { opacity: 0.92; border-color: #16a34a; }
|
|---|
| 768 |
|
|---|
| 769 | .pe-status {
|
|---|
| 770 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 771 | font-size: 0.8rem;
|
|---|
| 772 | }
|
|---|
| 773 | .pe-status.is-error { color: #dc2626; }
|
|---|
| 774 |
|
|---|
| 775 | /* βββ Checkboxes (clean inline row) βββββββββββββββββββββββββββββββ */
|
|---|
| 776 | .pe-checkboxes {
|
|---|
| 777 | display: flex; flex-direction: column;
|
|---|
| 778 | gap: 0.5rem;
|
|---|
| 779 | padding-top: 0.25rem;
|
|---|
| 780 | }
|
|---|
| 781 | .pe-checkbox {
|
|---|
| 782 | display: inline-flex; align-items: center; gap: 0.5rem;
|
|---|
| 783 | cursor: pointer;
|
|---|
| 784 | font-size: 0.95rem;
|
|---|
| 785 | color: var(--ink);
|
|---|
| 786 | user-select: none;
|
|---|
| 787 | }
|
|---|
| 788 | .pe-checkbox input[type="checkbox"] {
|
|---|
| 789 | width: 18px; height: 18px;
|
|---|
| 790 | margin: 0;
|
|---|
| 791 | cursor: pointer;
|
|---|
| 792 | accent-color: var(--accent);
|
|---|
| 793 | }
|
|---|
| 794 |
|
|---|
| [edc462d] | 795 | /* Vastpinnen: checkbox + β²βΌ-stepper met beschrijving (i.p.v. een ruw rang-getal). */
|
|---|
| 796 | .pe-pin { display: flex; flex-direction: column; gap: 0.45rem; }
|
|---|
| 797 | .pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; }
|
|---|
| 798 | .pe-pin-pos[hidden] { display: none; }
|
|---|
| 799 | .pe-pin-steps { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
|
|---|
| 800 | .pe-pin-btn {
|
|---|
| 801 | width: 30px; height: 28px; padding: 0;
|
|---|
| 802 | border: none; background: var(--paper-2); color: var(--ink);
|
|---|
| 803 | cursor: pointer; font-size: 0.68rem; line-height: 1;
|
|---|
| 804 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| [7bc636b] | 805 | }
|
|---|
| [edc462d] | 806 | .pe-pin-btn + .pe-pin-btn { border-left: 1px solid var(--rule); }
|
|---|
| 807 | .pe-pin-btn:hover { background: color-mix(in srgb, var(--accent) 14%, var(--paper-2)); color: var(--accent); }
|
|---|
| 808 | .pe-pin-btn:disabled { opacity: 0.35; cursor: default; }
|
|---|
| 809 | .pe-pin-label { font-size: 0.85rem; color: var(--accent); font-weight: 600; }
|
|---|
| [7bc636b] | 810 |
|
|---|
| 811 | /* βββ Sticky action footer ββββββββββββββββββββββββββββββββββββββββ */
|
|---|
| 812 | .pe-actions {
|
|---|
| 813 | position: sticky;
|
|---|
| 814 | bottom: 0;
|
|---|
| 815 | display: flex; align-items: center;
|
|---|
| 816 | gap: 0.5rem;
|
|---|
| 817 | padding: 0.85rem 1rem;
|
|---|
| 818 | margin: 0.5rem -1rem 0; /* extend to viewport edges on tight container */
|
|---|
| 819 | background: color-mix(in srgb, var(--paper) 94%, transparent);
|
|---|
| 820 | -webkit-backdrop-filter: blur(8px);
|
|---|
| 821 | backdrop-filter: blur(8px);
|
|---|
| 822 | border-top: 1px solid var(--rule);
|
|---|
| 823 | z-index: 10;
|
|---|
| 824 | }
|
|---|
| 825 | .pe-actions-spacer { flex: 1; }
|
|---|
| 826 |
|
|---|
| 827 | /* On a body that already has a fixed bottom-tab nav (mobile), nudge the
|
|---|
| 828 | sticky footer up so it doesn't sit under it. The bottom-tab is ~64px tall. */
|
|---|
| 829 | body.has-bottom-tab .pe-actions {
|
|---|
| 830 | bottom: 64px;
|
|---|
| 831 | }
|
|---|
| 832 | </style>
|
|---|
| 833 |
|
|---|
| 834 | <script>
|
|---|
| 835 | (function() {
|
|---|
| 836 |
|
|---|
| 837 | // ββ Cover upload ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|---|
| 838 | const coverField = document.getElementById('cover-upload-field');
|
|---|
| 839 | const coverTrigger = document.getElementById('cover-upload-trigger');
|
|---|
| 840 | const coverUrl = document.getElementById('cover-url-field');
|
|---|
| 841 | const coverStatus = document.getElementById('cover-upload-status');
|
|---|
| 842 | const coverWrap = document.getElementById('cover-preview-wrap');
|
|---|
| 843 | const coverImg = document.getElementById('cover-preview-img');
|
|---|
| 844 |
|
|---|
| 845 | async function uploadImage(file) {
|
|---|
| 846 | const fd = new FormData();
|
|---|
| 847 | fd.append('image', file);
|
|---|
| 848 | const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
|
|---|
| 849 | if (!res.ok) {
|
|---|
| 850 | const j = await res.json().catch(() => ({}));
|
|---|
| 851 | throw new Error(j.error || ('Upload failed (' + res.status + ')'));
|
|---|
| 852 | }
|
|---|
| 853 | return await res.json(); // {url, size, mime}
|
|---|
| 854 | }
|
|---|
| 855 |
|
|---|
| 856 | function showCoverPreview(url) {
|
|---|
| 857 | if (!coverWrap || !coverImg) return;
|
|---|
| 858 | if (url) {
|
|---|
| 859 | coverImg.src = url;
|
|---|
| 860 | coverImg.hidden = false;
|
|---|
| 861 | coverWrap.removeAttribute('data-empty');
|
|---|
| 862 | const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
|
|---|
| 863 | if (emptyIcon) emptyIcon.remove();
|
|---|
| 864 | } else {
|
|---|
| 865 | coverImg.hidden = true;
|
|---|
| 866 | coverImg.src = '';
|
|---|
| 867 | coverWrap.setAttribute('data-empty', '');
|
|---|
| 868 | if (!coverWrap.querySelector('.pe-cover-empty')) {
|
|---|
| 869 | const span = document.createElement('span');
|
|---|
| 870 | span.className = 'pe-cover-empty';
|
|---|
| 871 | span.textContent = 'πΌ';
|
|---|
| 872 | coverWrap.appendChild(span);
|
|---|
| 873 | }
|
|---|
| 874 | }
|
|---|
| 875 | }
|
|---|
| 876 |
|
|---|
| 877 | if (coverTrigger && coverField) {
|
|---|
| 878 | coverTrigger.addEventListener('click', () => coverField.click());
|
|---|
| 879 | }
|
|---|
| 880 | if (coverField) {
|
|---|
| 881 | coverField.addEventListener('change', async () => {
|
|---|
| 882 | if (!coverField.files[0]) return;
|
|---|
| 883 | coverStatus.classList.remove('is-error');
|
|---|
| 884 | coverStatus.textContent = 'Uploadenβ¦';
|
|---|
| 885 | try {
|
|---|
| 886 | const j = await uploadImage(coverField.files[0]);
|
|---|
| 887 | coverUrl.value = j.url;
|
|---|
| 888 | showCoverPreview(j.url);
|
|---|
| 889 | coverStatus.textContent = 'GeΓΌpload β';
|
|---|
| 890 | setTimeout(() => { coverStatus.textContent = ''; }, 2000);
|
|---|
| 891 | } catch (e) {
|
|---|
| 892 | coverStatus.classList.add('is-error');
|
|---|
| 893 | coverStatus.textContent = 'Mislukt: ' + e.message;
|
|---|
| 894 | }
|
|---|
| 895 | });
|
|---|
| 896 | }
|
|---|
| 897 | // Live-update preview when user pastes a URL manually
|
|---|
| 898 | if (coverUrl) {
|
|---|
| 899 | coverUrl.addEventListener('input', () => {
|
|---|
| 900 | const v = coverUrl.value.trim();
|
|---|
| 901 | if (v) showCoverPreview(v); else showCoverPreview('');
|
|---|
| 902 | });
|
|---|
| 903 | }
|
|---|
| 904 |
|
|---|
| 905 | // ββ WYSIWYG editor (P58) ββββββββββββββββββββββββββββββββββββββββ
|
|---|
| 906 | // Architecture:
|
|---|
| 907 | // - Visible <div contenteditable> (`#content-editor`) is what the user
|
|---|
| 908 | // types in; it shows real HTML (formatted, not raw markup).
|
|---|
| 909 | // - Hidden <input name="content"> (`#content-hidden`) is what submits.
|
|---|
| 910 | // On submit we serialize the editor's HTML into it, with shortcode
|
|---|
| 911 | // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
|
|---|
| 912 | // - Initial content comes from a <script type="application/json"> tag
|
|---|
| 913 | // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
|
|---|
| 914 | // and walk text nodes to render shortcode tokens as chips.
|
|---|
| 915 | const contentField = document.getElementById('content-upload-field');
|
|---|
| 916 | const contentBtn = document.getElementById('insert-image-btn');
|
|---|
| 917 | const contentStatus = document.getElementById('content-upload-status');
|
|---|
| 918 | const editor = document.getElementById('content-editor');
|
|---|
| 919 | const hiddenField = document.getElementById('content-hidden');
|
|---|
| 920 | const charCountEl = document.getElementById('char-count');
|
|---|
| 921 | const initialEl = document.getElementById('initial-content');
|
|---|
| 922 | const toolbar = document.getElementById('pe-toolbar');
|
|---|
| 923 | const form = editor && editor.closest('form');
|
|---|
| 924 |
|
|---|
| 925 | if (!editor) return;
|
|---|
| 926 |
|
|---|
| 927 | // ββ Shortcode chip rendering / serialization ββββββββββββββββββββ
|
|---|
| 928 | // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
|
|---|
| 929 | // β but we DON'T want to chipify text the user is mid-typing inside an
|
|---|
| 930 | // HTML attribute; since chipify only walks text nodes (never attribute
|
|---|
| 931 | // values) that's already safe.
|
|---|
| [1907a18] | 932 | const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
|
|---|
| [7bc636b] | 933 |
|
|---|
| 934 | const SC_ICONS = {
|
|---|
| 935 | 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>',
|
|---|
| 936 | 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>',
|
|---|
| 937 | 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] | 938 | 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] | 939 | };
|
|---|
| 940 |
|
|---|
| 941 | function chipLabel(kind, value) {
|
|---|
| 942 | if (kind === 'track') {
|
|---|
| 943 | // UUIDs are noisy β show a 6-char prefix for visual hint
|
|---|
| 944 | const v = String(value || '');
|
|---|
| 945 | return 'Track ' + (v.length > 8 ? v.slice(0, 6) + 'β¦' : v);
|
|---|
| 946 | }
|
|---|
| 947 | if (kind === 'album') return 'Album: ' + value;
|
|---|
| 948 | if (kind === 'playlist') return 'Playlist: ' + value;
|
|---|
| [1907a18] | 949 | if (kind === 'embed') {
|
|---|
| 950 | const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
|
|---|
| 951 | return 'βΆ ' + (clean.length > 36 ? clean.slice(0, 34) + 'β¦' : clean);
|
|---|
| 952 | }
|
|---|
| [7bc636b] | 953 | return value;
|
|---|
| 954 | }
|
|---|
| 955 |
|
|---|
| 956 | function makeChip(kind, value) {
|
|---|
| 957 | const span = document.createElement('span');
|
|---|
| 958 | span.className = 'sc-chip';
|
|---|
| 959 | span.contentEditable = 'false';
|
|---|
| 960 | span.setAttribute('data-sc', kind + ':' + value);
|
|---|
| 961 | span.innerHTML =
|
|---|
| 962 | '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
|
|---|
| 963 | '<span class="sc-chip-label"></span>';
|
|---|
| 964 | span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
|
|---|
| 965 | return span;
|
|---|
| 966 | }
|
|---|
| 967 |
|
|---|
| 968 | // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
|
|---|
| 969 | function chipifyShortcodes(root) {
|
|---|
| 970 | const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|---|
| 971 | const targets = [];
|
|---|
| 972 | while (walker.nextNode()) {
|
|---|
| 973 | const n = walker.currentNode;
|
|---|
| 974 | // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
|
|---|
| 975 | if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
|
|---|
| 976 | if (SC_RE.test(n.nodeValue)) targets.push(n);
|
|---|
| 977 | SC_RE.lastIndex = 0;
|
|---|
| 978 | }
|
|---|
| 979 | for (const node of targets) {
|
|---|
| 980 | const txt = node.nodeValue;
|
|---|
| 981 | const frag = document.createDocumentFragment();
|
|---|
| 982 | let last = 0;
|
|---|
| 983 | let m;
|
|---|
| 984 | SC_RE.lastIndex = 0;
|
|---|
| 985 | while ((m = SC_RE.exec(txt)) !== null) {
|
|---|
| 986 | if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
|
|---|
| 987 | frag.appendChild(makeChip(m[1], m[2].trim()));
|
|---|
| 988 | last = m.index + m[0].length;
|
|---|
| 989 | }
|
|---|
| 990 | if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
|
|---|
| 991 | node.parentNode.replaceChild(frag, node);
|
|---|
| 992 | }
|
|---|
| 993 | }
|
|---|
| 994 |
|
|---|
| 995 | // Inverse of chipify: clone the editor, replace every chip with its text.
|
|---|
| 996 | function serializeChips(rootClone) {
|
|---|
| 997 | const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
|
|---|
| 998 | for (const c of chips) {
|
|---|
| 999 | const txt = '[[' + c.getAttribute('data-sc') + ']]';
|
|---|
| 1000 | c.replaceWith(document.createTextNode(txt));
|
|---|
| 1001 | }
|
|---|
| 1002 | }
|
|---|
| 1003 |
|
|---|
| 1004 | // ββ Boot: load initial content as HTML, then render shortcodes as chips
|
|---|
| 1005 | try {
|
|---|
| 1006 | const initial = JSON.parse(initialEl.textContent || '""');
|
|---|
| 1007 | editor.innerHTML = initial || '';
|
|---|
| 1008 | chipifyShortcodes(editor);
|
|---|
| 1009 | } catch (e) {
|
|---|
| 1010 | console.error('[editor] could not parse initial content', e);
|
|---|
| 1011 | editor.innerHTML = '';
|
|---|
| 1012 | }
|
|---|
| 1013 |
|
|---|
| 1014 | // ββ Char counter
|
|---|
| 1015 | function updateCharCount() {
|
|---|
| 1016 | const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
|
|---|
| 1017 | if (charCountEl) charCountEl.textContent = String(text.length);
|
|---|
| 1018 | }
|
|---|
| 1019 | updateCharCount();
|
|---|
| 1020 | editor.addEventListener('input', updateCharCount);
|
|---|
| 1021 |
|
|---|
| 1022 | // ββ Toolbar wiring
|
|---|
| 1023 | function execCmd(cmd, arg) {
|
|---|
| 1024 | editor.focus();
|
|---|
| 1025 | document.execCommand(cmd, false, arg);
|
|---|
| 1026 | updateToolbarState();
|
|---|
| 1027 | updateCharCount();
|
|---|
| 1028 | }
|
|---|
| 1029 | function wrapCode() {
|
|---|
| 1030 | const sel = window.getSelection();
|
|---|
| 1031 | if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
|
|---|
| 1032 | const range = sel.getRangeAt(0);
|
|---|
| 1033 | const code = document.createElement('code');
|
|---|
| 1034 | code.textContent = sel.toString();
|
|---|
| 1035 | range.deleteContents();
|
|---|
| 1036 | range.insertNode(code);
|
|---|
| 1037 | // Move caret after the new node
|
|---|
| 1038 | range.setStartAfter(code);
|
|---|
| 1039 | range.collapse(true);
|
|---|
| 1040 | sel.removeAllRanges();
|
|---|
| 1041 | sel.addRange(range);
|
|---|
| 1042 | editor.focus();
|
|---|
| 1043 | }
|
|---|
| 1044 | function linkPrompt() {
|
|---|
| 1045 | const url = window.prompt('Link URL (https://β¦ of /pad)');
|
|---|
| 1046 | if (!url) return;
|
|---|
| 1047 | execCmd('createLink', url);
|
|---|
| 1048 | }
|
|---|
| 1049 |
|
|---|
| 1050 | if (toolbar) {
|
|---|
| 1051 | toolbar.addEventListener('click', (e) => {
|
|---|
| 1052 | const btn = e.target.closest('button[data-cmd]');
|
|---|
| 1053 | if (!btn) return;
|
|---|
| 1054 | e.preventDefault();
|
|---|
| 1055 | const cmd = btn.dataset.cmd;
|
|---|
| 1056 | const arg = btn.dataset.arg || null;
|
|---|
| 1057 | if (cmd === 'link-prompt') linkPrompt();
|
|---|
| 1058 | else if (cmd === 'code-wrap') wrapCode();
|
|---|
| 1059 | else execCmd(cmd, arg);
|
|---|
| 1060 | });
|
|---|
| 1061 | }
|
|---|
| 1062 |
|
|---|
| 1063 | // Reflect bold/italic/list state on the toolbar buttons
|
|---|
| 1064 | function updateToolbarState() {
|
|---|
| 1065 | if (!toolbar) return;
|
|---|
| 1066 | const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
|
|---|
| 1067 | for (const cmd of cmds) {
|
|---|
| 1068 | const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
|
|---|
| 1069 | if (!btn) continue;
|
|---|
| 1070 | try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
|
|---|
| 1071 | }
|
|---|
| 1072 | }
|
|---|
| 1073 | document.addEventListener('selectionchange', () => {
|
|---|
| 1074 | if (document.activeElement === editor) updateToolbarState();
|
|---|
| 1075 | });
|
|---|
| 1076 |
|
|---|
| 1077 | // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
|
|---|
| 1078 | editor.addEventListener('keydown', (e) => {
|
|---|
| 1079 | const mod = e.ctrlKey || e.metaKey;
|
|---|
| 1080 | if (!mod) return;
|
|---|
| 1081 | const k = e.key.toLowerCase();
|
|---|
| 1082 | if (k === 'b') { e.preventDefault(); execCmd('bold'); }
|
|---|
| 1083 | else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
|
|---|
| 1084 | else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
|
|---|
| 1085 | else if (k === 'k') { e.preventDefault(); linkPrompt(); }
|
|---|
| 1086 | });
|
|---|
| 1087 |
|
|---|
| 1088 | // Paste: keep it simple β strip formatting unless user wants it. Default
|
|---|
| 1089 | // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
|
|---|
| 1090 | // styles from clipboard only when shift is held β otherwise plain text.
|
|---|
| 1091 | editor.addEventListener('paste', (e) => {
|
|---|
| 1092 | if (e.shiftKey) return; // user wants formatted paste
|
|---|
| 1093 | const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
|---|
| 1094 | if (text == null) return;
|
|---|
| 1095 | e.preventDefault();
|
|---|
| 1096 | document.execCommand('insertText', false, text);
|
|---|
| 1097 | });
|
|---|
| 1098 |
|
|---|
| 1099 | // ββ Image upload (button + drag-drop into the editor)
|
|---|
| 1100 | async function uploadAndInsertImage(file) {
|
|---|
| 1101 | contentStatus.classList.remove('is-error');
|
|---|
| 1102 | contentStatus.textContent = 'Uploadenβ¦';
|
|---|
| 1103 | try {
|
|---|
| 1104 | const j = await uploadImage(file);
|
|---|
| 1105 | const img = '<img src="' + j.url + '" alt="">';
|
|---|
| 1106 | editor.focus();
|
|---|
| 1107 | document.execCommand('insertHTML', false, img);
|
|---|
| 1108 | contentStatus.textContent = 'Ingevoegd β';
|
|---|
| 1109 | setTimeout(() => { contentStatus.textContent = ''; }, 2000);
|
|---|
| 1110 | updateCharCount();
|
|---|
| 1111 | } catch (e) {
|
|---|
| 1112 | contentStatus.classList.add('is-error');
|
|---|
| 1113 | contentStatus.textContent = 'Mislukt: ' + e.message;
|
|---|
| 1114 | }
|
|---|
| 1115 | }
|
|---|
| 1116 |
|
|---|
| 1117 | if (contentBtn && contentField) {
|
|---|
| 1118 | contentBtn.addEventListener('click', () => contentField.click());
|
|---|
| 1119 | contentField.addEventListener('change', () => {
|
|---|
| 1120 | if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
|
|---|
| 1121 | contentField.value = '';
|
|---|
| 1122 | });
|
|---|
| 1123 |
|
|---|
| 1124 | editor.addEventListener('dragover', (e) => {
|
|---|
| 1125 | if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
|---|
| 1126 | e.preventDefault();
|
|---|
| 1127 | editor.classList.add('is-dragover');
|
|---|
| 1128 | }
|
|---|
| 1129 | });
|
|---|
| 1130 | editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
|
|---|
| 1131 | editor.addEventListener('drop', async (e) => {
|
|---|
| 1132 | editor.classList.remove('is-dragover');
|
|---|
| 1133 | const files = e.dataTransfer && e.dataTransfer.files;
|
|---|
| 1134 | if (!files || !files.length) return;
|
|---|
| 1135 | e.preventDefault();
|
|---|
| 1136 | for (const f of files) {
|
|---|
| 1137 | if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
|
|---|
| 1138 | }
|
|---|
| 1139 | });
|
|---|
| 1140 | }
|
|---|
| 1141 |
|
|---|
| 1142 | // ββ Insert chip helpers (track / playlist)
|
|---|
| 1143 | function insertChip(kind, value) {
|
|---|
| 1144 | editor.focus();
|
|---|
| 1145 | const chip = makeChip(kind, value);
|
|---|
| 1146 | // Insert at caret using the Selection API (execCommand insertNode)
|
|---|
| 1147 | const sel = window.getSelection();
|
|---|
| 1148 | if (sel && sel.rangeCount > 0) {
|
|---|
| 1149 | const range = sel.getRangeAt(0);
|
|---|
| 1150 | range.deleteContents();
|
|---|
| 1151 | range.insertNode(chip);
|
|---|
| 1152 | // Insert a trailing space so the user can keep typing after the chip
|
|---|
| 1153 | const space = document.createTextNode('\u00A0');
|
|---|
| 1154 | chip.after(space);
|
|---|
| 1155 | range.setStartAfter(space);
|
|---|
| 1156 | range.collapse(true);
|
|---|
| 1157 | sel.removeAllRanges();
|
|---|
| 1158 | sel.addRange(range);
|
|---|
| 1159 | } else {
|
|---|
| 1160 | editor.appendChild(chip);
|
|---|
| 1161 | editor.appendChild(document.createTextNode('\u00A0'));
|
|---|
| 1162 | }
|
|---|
| 1163 | updateCharCount();
|
|---|
| 1164 | }
|
|---|
| 1165 |
|
|---|
| [1907a18] | 1166 | // ββ Embed insert: plak een platform-URL -> [[embed:url]]-chip die server-side
|
|---|
| 1167 | // een iframe wordt (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
|
|---|
| 1168 | const embedBtn = document.getElementById('insert-embed-btn');
|
|---|
| 1169 | if (embedBtn) {
|
|---|
| 1170 | embedBtn.addEventListener('click', () => {
|
|---|
| 1171 | const raw = window.prompt('Plak een media-URL om in te sluiten\n(YouTube, Spotify, SoundCloud, Vimeo, Apple Music, Bandcamp):');
|
|---|
| 1172 | if (!raw) return;
|
|---|
| 1173 | const url = raw.trim();
|
|---|
| 1174 | if (!/^https?:\/\//i.test(url)) { alert('Geef een volledige URL (https://β¦).'); return; }
|
|---|
| 1175 | insertChip('embed', url);
|
|---|
| 1176 | });
|
|---|
| 1177 | }
|
|---|
| 1178 |
|
|---|
| [7bc636b] | 1179 | // ββ Track insert: opens the track-picker modal (P59)
|
|---|
| 1180 | const trackBtn = document.getElementById('insert-track-btn');
|
|---|
| 1181 | const trackPicker = document.getElementById('track-picker');
|
|---|
| 1182 | if (trackBtn && trackPicker) {
|
|---|
| 1183 | const tpList = document.getElementById('tp-list');
|
|---|
| 1184 | const tpEmpty = document.getElementById('tp-empty');
|
|---|
| 1185 | const tpSearch = document.getElementById('tp-search');
|
|---|
| 1186 | let tpCache = null; // cached tracks list (fetched once per page load)
|
|---|
| 1187 | let tpLastFocus = null; // element to restore focus to on close
|
|---|
| 1188 |
|
|---|
| 1189 | 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>';
|
|---|
| 1190 |
|
|---|
| 1191 | function fmtDur(sec) {
|
|---|
| 1192 | sec = Math.max(0, Math.floor(sec || 0));
|
|---|
| 1193 | const m = Math.floor(sec / 60), s = sec % 60;
|
|---|
| 1194 | return m + ':' + String(s).padStart(2, '0');
|
|---|
| 1195 | }
|
|---|
| 1196 | function escAttr(s) {
|
|---|
| 1197 | return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
|---|
| 1198 | '&':'&','<':'<','>':'>','"':'"',"'":'''
|
|---|
| 1199 | }[c]));
|
|---|
| 1200 | }
|
|---|
| 1201 |
|
|---|
| 1202 | function renderList(filter) {
|
|---|
| 1203 | if (!Array.isArray(tpCache)) return;
|
|---|
| 1204 | const q = (filter || '').trim().toLowerCase();
|
|---|
| 1205 | const filtered = q
|
|---|
| 1206 | ? tpCache.filter(t =>
|
|---|
| 1207 | (t.title || '').toLowerCase().includes(q) ||
|
|---|
| 1208 | (t.artist || '').toLowerCase().includes(q))
|
|---|
| 1209 | : tpCache;
|
|---|
| 1210 |
|
|---|
| 1211 | if (!filtered.length) {
|
|---|
| 1212 | tpList.innerHTML = '';
|
|---|
| 1213 | tpEmpty.textContent = q ? 'Geen tracks gevonden voor "' + q + '"' : 'Nog geen tracks. Upload via Beheer β Audio.';
|
|---|
| 1214 | tpList.appendChild(tpEmpty);
|
|---|
| 1215 | return;
|
|---|
| 1216 | }
|
|---|
| 1217 |
|
|---|
| 1218 | tpList.innerHTML = filtered.map(t => {
|
|---|
| 1219 | const cov = t.cover
|
|---|
| 1220 | ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
|
|---|
| 1221 | : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
|
|---|
| 1222 | const dis = t.playable ? '' : ' aria-disabled="true"';
|
|---|
| 1223 | const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
|
|---|
| 1224 | return (
|
|---|
| 1225 | '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
|
|---|
| 1226 | cov +
|
|---|
| 1227 | '<span class="tp-meta">' +
|
|---|
| 1228 | '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
|
|---|
| 1229 | sub +
|
|---|
| 1230 | '</span>' +
|
|---|
| 1231 | '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
|
|---|
| 1232 | '</button>'
|
|---|
| 1233 | );
|
|---|
| 1234 | }).join('');
|
|---|
| 1235 | }
|
|---|
| 1236 |
|
|---|
| 1237 | async function loadTracks() {
|
|---|
| 1238 | if (Array.isArray(tpCache)) return tpCache;
|
|---|
| 1239 | tpEmpty.textContent = 'Tracks ladenβ¦';
|
|---|
| 1240 | try {
|
|---|
| 1241 | const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
|
|---|
| 1242 | const j = await r.json();
|
|---|
| 1243 | tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
|
|---|
| 1244 | } catch (e) {
|
|---|
| 1245 | tpCache = [];
|
|---|
| 1246 | tpEmpty.textContent = 'Kon tracks niet laden: ' + e.message;
|
|---|
| 1247 | }
|
|---|
| 1248 | return tpCache;
|
|---|
| 1249 | }
|
|---|
| 1250 |
|
|---|
| 1251 | function openPicker() {
|
|---|
| 1252 | tpLastFocus = document.activeElement;
|
|---|
| 1253 | trackPicker.hidden = false;
|
|---|
| 1254 | trackPicker.setAttribute('aria-hidden', 'false');
|
|---|
| 1255 | document.body.classList.add('tp-locked');
|
|---|
| 1256 | tpSearch.value = '';
|
|---|
| 1257 | renderList('');
|
|---|
| 1258 | // Defer focus so the open animation doesn't get jumped
|
|---|
| 1259 | setTimeout(() => tpSearch.focus(), 30);
|
|---|
| 1260 | }
|
|---|
| 1261 | function closePicker() {
|
|---|
| 1262 | trackPicker.hidden = true;
|
|---|
| 1263 | trackPicker.setAttribute('aria-hidden', 'true');
|
|---|
| 1264 | document.body.classList.remove('tp-locked');
|
|---|
| 1265 | if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
|
|---|
| 1266 | try { tpLastFocus.focus(); } catch(_) {}
|
|---|
| 1267 | }
|
|---|
| 1268 | }
|
|---|
| 1269 |
|
|---|
| 1270 | trackBtn.addEventListener('click', async () => {
|
|---|
| 1271 | openPicker();
|
|---|
| 1272 | await loadTracks();
|
|---|
| 1273 | renderList(tpSearch.value);
|
|---|
| 1274 | });
|
|---|
| 1275 |
|
|---|
| 1276 | // Close: backdrop click, [data-tp-close], or Escape
|
|---|
| 1277 | trackPicker.addEventListener('click', (e) => {
|
|---|
| 1278 | if (e.target.closest('[data-tp-close]')) {
|
|---|
| 1279 | closePicker();
|
|---|
| 1280 | return;
|
|---|
| 1281 | }
|
|---|
| 1282 | const row = e.target.closest('.tp-row[data-track-id]');
|
|---|
| 1283 | if (row) {
|
|---|
| 1284 | if (row.getAttribute('aria-disabled') === 'true') return;
|
|---|
| 1285 | const id = row.dataset.trackId;
|
|---|
| 1286 | if (id) {
|
|---|
| 1287 | insertChip('track', id);
|
|---|
| 1288 | closePicker();
|
|---|
| 1289 | }
|
|---|
| 1290 | }
|
|---|
| 1291 | });
|
|---|
| 1292 | document.addEventListener('keydown', (e) => {
|
|---|
| 1293 | if (!trackPicker.hidden && e.key === 'Escape') {
|
|---|
| 1294 | e.preventDefault();
|
|---|
| 1295 | closePicker();
|
|---|
| 1296 | }
|
|---|
| 1297 | });
|
|---|
| 1298 |
|
|---|
| 1299 | // Live filter
|
|---|
| 1300 | tpSearch.addEventListener('input', () => renderList(tpSearch.value));
|
|---|
| 1301 | }
|
|---|
| 1302 |
|
|---|
| 1303 | // ββ Playlist insert (open existing or create new via modal)
|
|---|
| 1304 | const playlistBtn = document.getElementById('insert-playlist-btn');
|
|---|
| 1305 | if (playlistBtn) {
|
|---|
| 1306 | playlistBtn.addEventListener('click', async () => {
|
|---|
| 1307 | if (typeof window.openPlaylistEditor !== 'function') {
|
|---|
| 1308 | alert('Playlist editor niet geladen');
|
|---|
| 1309 | return;
|
|---|
| 1310 | }
|
|---|
| 1311 | try {
|
|---|
| 1312 | const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
|
|---|
| 1313 | const j = await r.json();
|
|---|
| 1314 | if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
|
|---|
| 1315 | const choice = prompt(
|
|---|
| 1316 | 'Bestaande playlists:\n\n' +
|
|---|
| 1317 | j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
|
|---|
| 1318 | '\n\nKies een nummer om in te voegen, leeg = nieuwe maken:'
|
|---|
| 1319 | );
|
|---|
| 1320 | if (choice && /^\d+$/.test(choice.trim())) {
|
|---|
| 1321 | const idx = parseInt(choice.trim(), 10) - 1;
|
|---|
| 1322 | if (idx >= 0 && idx < j.playlists.length) {
|
|---|
| 1323 | insertChip('playlist', j.playlists[idx].id);
|
|---|
| 1324 | return;
|
|---|
| 1325 | }
|
|---|
| 1326 | }
|
|---|
| 1327 | if (choice === null) return;
|
|---|
| 1328 | }
|
|---|
| 1329 | } catch (_) { /* fall through to create */ }
|
|---|
| 1330 |
|
|---|
| 1331 | window.openPlaylistEditor({
|
|---|
| 1332 | mode: 'create',
|
|---|
| 1333 | onSaved: ({ id }) => insertChip('playlist', id),
|
|---|
| 1334 | });
|
|---|
| 1335 | });
|
|---|
| 1336 | }
|
|---|
| 1337 |
|
|---|
| 1338 | // ββ Submit: serialize editor contents into the hidden field
|
|---|
| 1339 | if (form && hiddenField) {
|
|---|
| 1340 | form.addEventListener('submit', () => {
|
|---|
| 1341 | const clone = editor.cloneNode(true);
|
|---|
| 1342 | serializeChips(clone);
|
|---|
| 1343 | hiddenField.value = clone.innerHTML;
|
|---|
| 1344 | });
|
|---|
| 1345 | }
|
|---|
| 1346 | })();
|
|---|
| 1347 | </script>
|
|---|
| 1348 |
|
|---|
| 1349 | <%# ββ Track picker modal (P59). Mobile-first: full-screen sheet on small
|
|---|
| 1350 | viewports, centered modal on β₯640px. Populated lazily on first open. %>
|
|---|
| 1351 | <% if (typeof user !== 'undefined' && user) { %>
|
|---|
| 1352 | <div id="track-picker" class="tp-modal" hidden aria-hidden="true">
|
|---|
| 1353 | <div class="tp-backdrop" data-tp-close></div>
|
|---|
| 1354 | <div class="tp-sheet" role="dialog" aria-modal="true" aria-labelledby="tp-title">
|
|---|
| 1355 | <header class="tp-header">
|
|---|
| 1356 | <h2 id="tp-title" class="tp-h2">Track invoegen</h2>
|
|---|
| 1357 | <button type="button" class="tp-close" data-tp-close aria-label="Sluiten">
|
|---|
| 1358 | <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>
|
|---|
| 1359 | </button>
|
|---|
| 1360 | </header>
|
|---|
| 1361 | <div class="tp-search-row">
|
|---|
| 1362 | <span class="tp-search-icon" aria-hidden="true">
|
|---|
| 1363 | <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>
|
|---|
| 1364 | </span>
|
|---|
| 1365 | <input type="search" class="tp-search" id="tp-search" placeholder="Zoek op titel of artiestβ¦" autocomplete="off" inputmode="search">
|
|---|
| 1366 | </div>
|
|---|
| 1367 | <div class="tp-list" id="tp-list" role="listbox" aria-label="Tracks">
|
|---|
| 1368 | <div class="tp-empty" id="tp-empty">Tracks ladenβ¦</div>
|
|---|
| 1369 | </div>
|
|---|
| 1370 | </div>
|
|---|
| 1371 | </div>
|
|---|
| 1372 | <% } %>
|
|---|
| 1373 |
|
|---|
| 1374 | <%# Playlist editor modal β included so its global window.openPlaylistEditor
|
|---|
| 1375 | is available when the toolbar button fires. Only renders if user can post. %>
|
|---|
| 1376 | <% if (typeof user !== 'undefined' && user) { %>
|
|---|
| 1377 | <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
|
|---|
| 1378 | <% } %>
|
|---|
| [edc462d] | 1379 |
|
|---|
| 1380 | <script>
|
|---|
| 1381 | (function () {
|
|---|
| 1382 | // Vastpinnen: checkbox toggelt het verborgen rang-veld (0 = niet gepind),
|
|---|
| 1383 | // β²βΌ verschuift de plek, met een leesbare beschrijving i.p.v. een ruw getal.
|
|---|
| 1384 | var toggle = document.getElementById('pin-toggle');
|
|---|
| 1385 | var rank = document.getElementById('pin-rank');
|
|---|
| 1386 | var pos = document.getElementById('pin-pos');
|
|---|
| 1387 | var label = document.getElementById('pin-label');
|
|---|
| 1388 | var up = document.getElementById('pin-up'); // hoger = lager getal (richting 1/bovenaan)
|
|---|
| 1389 | var down = document.getElementById('pin-down');
|
|---|
| 1390 | if (!toggle || !rank || !pos) return;
|
|---|
| 1391 |
|
|---|
| 1392 | function descr(n) {
|
|---|
| 1393 | n = Number(n) || 0;
|
|---|
| 1394 | if (n <= 1) return 'bovenaan';
|
|---|
| 1395 | return n + 'e van boven';
|
|---|
| 1396 | }
|
|---|
| 1397 | function render() {
|
|---|
| 1398 | var on = toggle.checked;
|
|---|
| 1399 | pos.hidden = !on;
|
|---|
| 1400 | if (on && Number(rank.value) < 1) rank.value = 1;
|
|---|
| 1401 | if (!on) rank.value = 0;
|
|---|
| 1402 | if (label) label.textContent = on ? descr(rank.value) : '';
|
|---|
| 1403 | if (up) up.disabled = Number(rank.value) <= 1;
|
|---|
| 1404 | }
|
|---|
| 1405 | toggle.addEventListener('change', render);
|
|---|
| 1406 | if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
|
|---|
| 1407 | if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
|
|---|
| 1408 | render();
|
|---|
| 1409 | })();
|
|---|
| 1410 | </script>
|
|---|