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

main
Last change on this file since f1627b6 was bbf9d1a, checked in by roboburr <roboburr@…>, 3 months ago

feat(i18n phase 5): all admin sub-pages translated (NL/EN/DE)

settings, site-edit, seo, google, audio, stats, playlists, users, sites,
comments, circle, shows, newsletter, updates, epk, help. ~520 new keys
per language. Loop-shadowing of t() resolved by fetching before the loop. 709
view-keys cross-checked against the dictionary (0 missing).

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

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