source: Klonkt/src/views/pages/admin-playlists.ejs@ 63edb0d

main
Last change on this file since 63edb0d was 8b014ef, checked in by roboburr <roboburr@…>, 3 months ago

admin: one consistent "← Admin" back button on all admin pages

There were 3 variants (.btn "← Admin"/"← Sites", .admin-tagline text link,
.ax-back icon). Now the same everywhere: <a href="/admin" class="btn">← Admin</a>
at the top. Site-edit back link no longer mode-conditional (always Admin).

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

  • Property mode set to 100644
File size: 10.7 KB
Line 
1<%
2// Admin: Playlists management
3// Shows all playlists for current site, with new/edit/delete actions.
4// The editor itself is the same modal that's used from the post-editor.
5const _csrf = (typeof csrfToken !== 'undefined' && csrfToken) || '';
6%>
7<div class="container ax-page">
8
9 <p><a href="/admin" class="btn">&larr; Beheer</a></p>
10 <header class="ax-header">
11 <div>
12 <h1>Playlists</h1>
13 <p class="ax-tagline">
14 Canonieke playlists. Bewerk een playlist hier en de wijzigingen werken
15 door in álle posts die hem gebruiken via <code>[[playlist:&lt;id&gt;]]</code>.
16 </p>
17 </div>
18 </header>
19
20 <%# Sticky-on-mobile create button — easier reach than a header button. %>
21 <div class="ax-actions-bar">
22 <button type="button" class="ax-btn ax-btn-primary" id="pl-new-btn">
23 + Nieuwe playlist
24 </button>
25 </div>
26
27 <% if (!playlists.length) { %>
28 <div class="ax-card">
29 <div class="ax-empty">
30 <div class="ax-empty-icon">📃</div>
31 <p>Nog geen playlists.</p>
32 <p class="ax-empty-sub">Maak er een via de knop hierboven, of via de 📃 knop in de post-editor.</p>
33 </div>
34 </div>
35 <% } else { %>
36 <ul class="ax-list">
37 <% playlists.forEach(function(p) { %>
38 <li class="ax-track" data-id="<%= p.id %>">
39 <div class="ax-track-cover" <%- p.cover ? `style="background-image:url('${p.cover}'); background-size:cover; background-position:center"` : '' %>>
40 <% if (!p.cover) { %><span><%= p.kind === 'playlist' ? '📃' : '💿' %></span><% } %>
41 </div>
42
43 <div class="ax-track-meta">
44 <div class="ax-track-title">
45 <%= p.title %>
46 <span class="ax-pill"><%= p.kind === 'playlist' ? '📃 playlist' : '💿 album' %></span>
47 </div>
48 <div class="ax-track-sub">
49 <span><%= p.track_count %> <%= p.track_count === 1 ? 'track' : 'tracks' %></span>
50 <% if (p.artist) { %><span class="ax-track-sep">·</span><span><%= p.artist %></span><% } %>
51 <% if (p.year) { %><span class="ax-track-sep">·</span><span><%= p.year %></span><% } %>
52 </div>
53 <code class="ax-embed" data-copy="[[playlist:<%= p.id %>]]" title="Klik om te kopiëren">[[playlist:<%= p.id %>]]</code>
54 </div>
55
56 <div class="ax-track-actions">
57 <button type="button" class="ax-icon-btn" data-pl-edit data-id="<%= p.id %>" aria-label="Bewerken" title="Bewerken">✎</button>
58 <button type="button" class="ax-icon-btn ax-icon-btn-danger" data-pl-delete data-id="<%= p.id %>" data-title="<%= p.title %>" aria-label="Verwijderen" title="Verwijderen">🗑</button>
59 </div>
60 </li>
61 <% }); %>
62 </ul>
63 <% } %>
64
65</div>
66
67<%# Editor modal lives in its own partial so the post-editor can include it too %>
68<%- include('../partials/playlist-editor', { csrfToken: _csrf }) %>
69
70<style>
71/* Re-use the admin-audio (.ax-*) primitives. Define them here too so this
72 page is self-contained — both pages get refreshed independently. */
73
74.ax-page { max-width: 880px; margin: 1.5rem auto 4rem; padding: 0 1rem; }
75.ax-header { display: flex; align-items: flex-start; gap: 0.75rem; margin-bottom: 1.5rem; }
76.ax-header h1 { font-family: var(--font-display, serif); font-size: 1.75rem; margin: 0 0 0.25rem; }
77.ax-tagline { color: var(--ink-muted, var(--ink-soft)); margin: 0; font-size: 0.9rem; line-height: 1.5; }
78.ax-tagline code { background: var(--paper-2); padding: 0.1em 0.4em; border-radius: 4px; font-size: 0.9em; }
79.ax-back {
80 display: inline-flex; align-items: center; justify-content: center;
81 width: 40px; height: 40px;
82 border: 1px solid var(--rule); border-radius: 8px;
83 background: var(--paper); color: var(--ink); text-decoration: none;
84 font-size: 1.1rem; flex-shrink: 0; transition: border-color 120ms;
85}
86.ax-back:hover { border-color: var(--accent); }
87
88.ax-actions-bar {
89 display: flex;
90 margin-bottom: 1rem;
91}
92
93.ax-card {
94 background: var(--paper);
95 border: 1px solid var(--rule);
96 border-radius: 12px;
97 padding: 1.25rem;
98 margin-bottom: 1rem;
99}
100
101/* ─── Buttons (same primitives as admin-audio) ─────────────────── */
102.ax-btn {
103 display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
104 padding: 0.6rem 1.1rem;
105 border: 1px solid var(--rule); background: var(--paper-2);
106 color: var(--ink);
107 font-family: var(--font-ui, system-ui), sans-serif;
108 font-size: 0.95rem; font-weight: 500;
109 text-decoration: none; border-radius: 8px; cursor: pointer;
110 min-height: 44px;
111 transition: background 120ms, border-color 120ms;
112 -webkit-tap-highlight-color: transparent;
113}
114.ax-btn:hover { border-color: var(--accent); }
115.ax-btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
116.ax-btn-primary:hover { opacity: 0.92; }
117
118.ax-icon-btn {
119 display: inline-flex; align-items: center; justify-content: center;
120 width: 40px; height: 40px;
121 border: 1px solid var(--rule); border-radius: 8px;
122 background: var(--paper-2); color: var(--ink);
123 cursor: pointer; font-size: 1rem;
124 transition: background 120ms, border-color 120ms, color 120ms;
125}
126.ax-icon-btn:hover { border-color: var(--accent); }
127.ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
128
129/* ─── Empty state ──────────────────────────────────────────────── */
130.ax-empty { text-align: center; padding: 2rem 1rem; color: var(--ink-muted, var(--ink-soft)); }
131.ax-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; opacity: 0.5; }
132.ax-empty p { margin: 0; }
133.ax-empty-sub { font-size: 0.85rem; margin-top: 0.5rem !important; }
134
135/* ─── List items (same as audio tracks) ───────────────────────── */
136.ax-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.6rem; }
137.ax-track {
138 display: grid;
139 grid-template-columns: 64px 1fr auto;
140 gap: 0.85rem; align-items: center;
141 padding: 0.85rem;
142 background: var(--paper);
143 border: 1px solid var(--rule);
144 border-radius: 12px;
145 transition: border-color 120ms;
146}
147.ax-track:hover { border-color: var(--accent); }
148.ax-track-cover {
149 width: 64px; height: 64px; border-radius: 8px;
150 background: var(--paper-2);
151 display: flex; align-items: center; justify-content: center;
152 font-family: var(--font-display, serif);
153 font-size: 1.5rem; color: var(--accent);
154 flex-shrink: 0;
155}
156.ax-track-meta { min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; }
157.ax-track-title {
158 font-family: var(--font-display, serif);
159 font-weight: 600; font-size: 1.05rem; color: var(--ink);
160 display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;
161}
162.ax-pill {
163 font-family: var(--font-ui);
164 font-size: 0.7rem; font-weight: 500;
165 color: var(--ink-soft);
166 background: var(--paper-2);
167 padding: 0.15em 0.55em; border-radius: 999px;
168 letter-spacing: 0;
169}
170.ax-track-sub {
171 font-size: 0.85rem;
172 color: var(--ink-muted, var(--ink-soft));
173 display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
174}
175.ax-track-sep { opacity: 0.5; }
176.ax-embed {
177 display: inline-block;
178 background: var(--paper-2);
179 padding: 0.15rem 0.5rem;
180 border-radius: 4px;
181 font-family: var(--font-mono, ui-monospace, monospace);
182 font-size: 0.75rem;
183 color: var(--ink-soft);
184 cursor: pointer; user-select: all;
185 margin-top: 0.15rem;
186 word-break: break-all;
187 align-self: flex-start;
188 transition: background 120ms;
189}
190.ax-embed:hover { background: var(--rule); }
191.ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }
192
193.ax-track-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
194
195/* ─── Mobile ──────────────────────────────────────────────────── */
196@media (max-width: 480px) {
197 .ax-track {
198 grid-template-columns: 56px 1fr;
199 grid-template-areas: "cover meta" "actions actions";
200 gap: 0.6rem;
201 }
202 .ax-track-cover { grid-area: cover; width: 56px; height: 56px; }
203 .ax-track-meta { grid-area: meta; }
204 .ax-track-actions {
205 grid-area: actions;
206 justify-content: flex-end;
207 border-top: 1px solid var(--rule);
208 padding-top: 0.5rem;
209 margin-top: 0.25rem;
210 }
211}
212</style>
213
214<script>
215(function() {
216 const csrf = '<%= _csrf %>';
217
218 document.getElementById('pl-new-btn')?.addEventListener('click', () => {
219 if (typeof window.openPlaylistEditor === 'function') {
220 window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() });
221 }
222 });
223
224 // Click-to-copy on shortcodes
225 document.querySelectorAll('[data-copy]').forEach(el => {
226 el.addEventListener('click', async () => {
227 try {
228 await navigator.clipboard.writeText(el.dataset.copy);
229 el.classList.add('is-copied');
230 const original = el.textContent;
231 el.textContent = '✓ gekopieerd';
232 setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200);
233 } catch (_) { /* fall back to selection */ }
234 });
235 });
236
237 document.querySelectorAll('[data-pl-edit]').forEach(btn => {
238 btn.addEventListener('click', () => {
239 if (typeof window.openPlaylistEditor === 'function') {
240 window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() });
241 }
242 });
243 });
244
245 document.querySelectorAll('[data-pl-delete]').forEach(btn => {
246 btn.addEventListener('click', async () => {
247 const id = btn.dataset.id;
248 const title = btn.dataset.title || id;
249 if (!confirm(`Playlist "${title}" verwijderen? Posts die deze playlist embedden tonen vanaf nu een placeholder.`)) return;
250 try {
251 const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
252 method: 'POST',
253 headers: { 'X-CSRF-Token': csrf },
254 credentials: 'same-origin',
255 });
256 const j = await r.json();
257 if (j.ok) location.reload();
258 else alert('Verwijderen mislukt: ' + (j.error || ''));
259 } catch (err) {
260 alert('Verwijderen mislukt: ' + err.message);
261 }
262 });
263 });
264
265 // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor.
266 // openPlaylistEditor is defined synchronously by the included partial, so
267 // it's available by the time this IIFE runs.
268 (function deepLinkEdit() {
269 const params = new URLSearchParams(location.search);
270 const editId = params.get('edit');
271 if (!editId) return;
272 if (typeof window.openPlaylistEditor !== 'function') return;
273 // Strip the query param immediately so reload after save doesn't re-open.
274 history.replaceState({}, '', location.pathname);
275 window.openPlaylistEditor({
276 mode: 'edit',
277 id: editId,
278 onSaved: () => location.reload(),
279 });
280 })();
281})();
282</script>
Note: See TracBrowser for help on using the repository browser.