source: Klonkt/src/views/pages/admin-playlists.ejs@ 742ba7e

main
Last change on this file since 742ba7e was 2ab99c0, checked in by roboburr <roboburr@…>, 2 months ago

feat(media): shared Media tab bar across Images / Audio / Playlists

A new partials/media-tabs.ejs renders the Afbeeldingen | Audio | Playlists tabs (active one
highlighted) and is included on /admin/media, /admin/audio and /admin/playlists — so you can move
between all three from any of them. Playlists is now a tab too.

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

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