feat: implement three-tier visibility system for songs and playlists
- Add public/logged_in/private visibility levels to songs and playlists via database migration (002_visibility.sql) - Replace old `is_public` boolean with new `visibility` enum in admin forms, API schemas, and services - Implement access control logic: - Guests see only public items that don't contain restricted tracks - Logged-in users see public + logged_in items (excluding private ones) - Admins see all; regular users manage their own content - Auto-upgrade playlist visibility when adding more restrictive songs - Add UI badges for visibility levels and update admin views to reflect new hierarchy PWA enhancements: - Update web manifest with proper name, colors, and icon paths - Add service worker (sw.js) with cache-first for static assets and network-first for HTML - Register SW at root (/sw.js) with Service-Worker-Allowed header for full origin scope - Add theme-color meta tag and navigate.js for client-side routing Other: - Update admin nav label from "Admin" to "My Music" to reflect role-based access
This commit is contained in:
parent
be25e35230
commit
b4af77066a
|
|
@ -0,0 +1,200 @@
|
||||||
|
# PWA Implementation Notes
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
The groundwork is already in place:
|
||||||
|
- `public/site.webmanifest` exists and is linked in `layout.ejs`
|
||||||
|
- All icon sizes are present (`favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`,
|
||||||
|
`apple-touch-icon.png`, `android-chrome-192x192.png`, `android-chrome-512x512.png`)
|
||||||
|
- The app is served from a single origin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Fix the web manifest
|
||||||
|
|
||||||
|
`public/site.webmanifest` currently has empty `name`/`short_name` and wrong colors.
|
||||||
|
Update it to:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Bri-Tunes",
|
||||||
|
"short_name": "Bri-Tunes",
|
||||||
|
"description": "Retrofuture music player",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#05060d",
|
||||||
|
"theme_color": "#ff2bd6",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/static/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/static/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: icon `src` paths need the `/static/` prefix since Express serves `public/` at that mount point.
|
||||||
|
The manifest itself is also served from `/static/site.webmanifest`.
|
||||||
|
|
||||||
|
Also add `theme-color` meta to `src/views/layout.ejs` so the browser chrome matches on mobile:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta name="theme-color" content="#ff2bd6">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Write the service worker
|
||||||
|
|
||||||
|
Create `public/sw.js`. Recommended caching strategy for a server-rendered music app:
|
||||||
|
|
||||||
|
### Static shell — cache-first
|
||||||
|
Cache these on install so the app shell loads instantly on repeat visits:
|
||||||
|
|
||||||
|
```
|
||||||
|
/static/css/app.css
|
||||||
|
/static/js/player.js
|
||||||
|
/static/js/bg-grid.js
|
||||||
|
/static/js/player-viz.js
|
||||||
|
/static/js/boot-sequence.js
|
||||||
|
/static/vendor/fonts/Orbitron.woff2
|
||||||
|
/static/vendor/fonts/Inter.woff2
|
||||||
|
/static/vendor/three/three.module.min.js
|
||||||
|
/static/favicon.ico
|
||||||
|
/static/android-chrome-192x192.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTML pages — network-first with cache fallback
|
||||||
|
Fetch from network; if offline serve the cached version. Cache `/` on install as the
|
||||||
|
offline fallback page.
|
||||||
|
|
||||||
|
### Audio streams (`/stream/*`) — do not cache
|
||||||
|
Audio files are large and frequently requested with HTTP Range headers.
|
||||||
|
The service worker should `return fetch(event.request)` for these and never
|
||||||
|
store them.
|
||||||
|
|
||||||
|
### Skeleton implementation
|
||||||
|
|
||||||
|
```js
|
||||||
|
const CACHE = 'bri-tunes-v1';
|
||||||
|
const STATIC = [
|
||||||
|
'/',
|
||||||
|
'/static/css/app.css',
|
||||||
|
'/static/js/player.js',
|
||||||
|
'/static/js/bg-grid.js',
|
||||||
|
'/static/js/player-viz.js',
|
||||||
|
'/static/js/boot-sequence.js',
|
||||||
|
'/static/vendor/fonts/Orbitron.woff2',
|
||||||
|
'/static/vendor/fonts/Inter.woff2',
|
||||||
|
'/static/vendor/three/three.module.min.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(STATIC)));
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const { request } = e;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Never intercept audio streams or cross-origin requests.
|
||||||
|
if (url.pathname.startsWith('/stream/') || url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Static assets: cache-first.
|
||||||
|
if (url.pathname.startsWith('/static/')) {
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(request).then((cached) => cached || fetch(request))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML pages: network-first, fall back to cache.
|
||||||
|
e.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((res) => {
|
||||||
|
const clone = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(request, clone));
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(request))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Register the service worker
|
||||||
|
|
||||||
|
Add to `src/views/layout.ejs` just before `</body>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/static/sw.js');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
The service worker file lives at `public/sw.js` and is served at `/static/sw.js`.
|
||||||
|
|
||||||
|
**Important:** the service worker's scope defaults to its path (`/static/`), which
|
||||||
|
limits it to only intercepting requests under `/static/`. To control the full origin
|
||||||
|
it must be served from `/sw.js` (i.e., at the root, not under `/static/`).
|
||||||
|
|
||||||
|
This means `sw.js` needs a **dedicated route** in Express rather than being served
|
||||||
|
as a static file:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// In src/app.js, before the static middleware:
|
||||||
|
app.get('/sw.js', (req, res) => {
|
||||||
|
res.set('Service-Worker-Allowed', '/');
|
||||||
|
res.sendFile(path.join(__dirname, '..', 'public', 'sw.js'));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
And register it at the root in the template:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Bump cache version on deploy
|
||||||
|
|
||||||
|
When CSS/JS changes, increment the cache name (`bri-tunes-v1` → `bri-tunes-v2`).
|
||||||
|
The `activate` handler automatically evicts the old cache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to create / modify
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `public/site.webmanifest` | Fill in name, colors, fix icon paths |
|
||||||
|
| `src/views/layout.ejs` | Add `theme-color` meta, add SW registration script |
|
||||||
|
| `src/app.js` | Add `/sw.js` route with `Service-Worker-Allowed: /` header |
|
||||||
|
| `public/sw.js` | New — service worker (see skeleton above) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (optional future work)
|
||||||
|
|
||||||
|
- **Offline song caching** — let users pin songs for offline playback. Requires
|
||||||
|
an explicit "Save offline" button, manual `cache.put()` calls, storage quota
|
||||||
|
management, and UI to show cached vs. uncached songs.
|
||||||
|
- **Background sync** — retry failed actions (e.g., playlist edits) when
|
||||||
|
connectivity returns.
|
||||||
|
- **Push notifications** — notify users when new songs are added.
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Add three-tier visibility to songs and playlists.
|
||||||
|
-- Values: 'public' | 'logged_in' | 'private'
|
||||||
|
-- Default to 'logged_in' for any existing rows that had is_public = 0,
|
||||||
|
-- and 'public' for rows that had is_public = 1.
|
||||||
|
|
||||||
|
ALTER TABLE songs ADD COLUMN visibility TEXT NOT NULL DEFAULT 'logged_in';
|
||||||
|
ALTER TABLE playlists ADD COLUMN visibility TEXT NOT NULL DEFAULT 'logged_in';
|
||||||
|
|
||||||
|
UPDATE songs SET visibility = CASE WHEN is_public = 1 THEN 'public' ELSE 'logged_in' END;
|
||||||
|
UPDATE playlists SET visibility = CASE WHEN is_public = 1 THEN 'public' ELSE 'logged_in' END;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 370 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
|
|
@ -121,6 +121,7 @@ a:hover { color: #fff; text-shadow: var(--glow-cyan); text-decoration: none; }
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
position: sticky; top: 0;
|
position: sticky; top: 0;
|
||||||
|
z-index: 20;
|
||||||
}
|
}
|
||||||
.header-inner {
|
.header-inner {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
|
@ -240,6 +241,41 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
.card-body { padding: 0.65rem 0.85rem; }
|
.card-body { padding: 0.65rem 0.85rem; }
|
||||||
.card-title { font-size: 0.85rem; color: var(--fg); }
|
.card-title { font-size: 0.85rem; color: var(--fg); }
|
||||||
|
|
||||||
|
/* ---------- Visibility badges ---------- */
|
||||||
|
.badge {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.12rem 0.42rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
vertical-align: middle;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge-public { color: var(--neon-cyan); border: 1px solid var(--neon-cyan); box-shadow: 0 0 5px rgba(0,229,255,0.35); }
|
||||||
|
.badge-logged-in { color: var(--neon-amber); border: 1px solid var(--neon-amber); box-shadow: 0 0 5px rgba(255,179,71,0.4); }
|
||||||
|
.badge-private { color: var(--neon-magenta); border: 1px solid var(--neon-magenta); box-shadow: 0 0 5px rgba(255,43,214,0.4); }
|
||||||
|
|
||||||
|
/* ---------- Visibility radio fieldset ---------- */
|
||||||
|
.visibility-field {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.visibility-field legend {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Song list ---------- */
|
/* ---------- Song list ---------- */
|
||||||
.song-list { list-style: none; margin: 0; padding: 0; }
|
.song-list { list-style: none; margin: 0; padding: 0; }
|
||||||
.song-row {
|
.song-row {
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,98 @@
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Full page reload for these form actions — logout intentionally kills the session.
|
||||||
|
const FULL_RELOAD_ACTIONS = ['/logout'];
|
||||||
|
|
||||||
|
function isSameOrigin(href) {
|
||||||
|
try { return new URL(href, location.href).origin === location.origin; }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap #page-shell content, update title, and optionally push history.
|
||||||
|
function applyPage(doc, finalUrl, pushState) {
|
||||||
|
const newShell = doc.getElementById('page-shell');
|
||||||
|
const curShell = document.getElementById('page-shell');
|
||||||
|
if (newShell && curShell) curShell.innerHTML = newShell.innerHTML;
|
||||||
|
document.title = doc.title;
|
||||||
|
if (pushState) history.pushState({ btiNav: true }, doc.title, finalUrl);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(url, pushState) {
|
||||||
|
if (pushState === undefined) pushState = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!isSameOrigin(res.url)) { location.href = url; return; }
|
||||||
|
const html = await res.text();
|
||||||
|
applyPage(new DOMParser().parseFromString(html, 'text/html'), res.url, pushState);
|
||||||
|
} catch {
|
||||||
|
location.href = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the right body type for fetch POST:
|
||||||
|
// - Forms with actual file selections → multipart FormData (multer handles it)
|
||||||
|
// - All other forms → URL-encoded (express.urlencoded handles it)
|
||||||
|
function buildBody(form) {
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const hasFile = [...fd.values()].some(v => v instanceof File && v.size > 0);
|
||||||
|
if (hasFile) return fd;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [k, v] of fd.entries()) {
|
||||||
|
if (typeof v === 'string') params.append(k, v);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Intercept link clicks ---
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
const a = e.target.closest('a[href]');
|
||||||
|
if (!a) return;
|
||||||
|
if (a.target && a.target !== '_self') return;
|
||||||
|
if (a.hasAttribute('download')) return;
|
||||||
|
const raw = a.getAttribute('href');
|
||||||
|
if (!raw || raw.startsWith('#') || raw.startsWith('mailto:') || raw.startsWith('tel:')) return;
|
||||||
|
if (!isSameOrigin(a.href)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(a.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Intercept form submissions ---
|
||||||
|
document.addEventListener('submit', (e) => {
|
||||||
|
const form = e.target;
|
||||||
|
if (!(form instanceof HTMLFormElement)) return;
|
||||||
|
if (!isSameOrigin(form.action)) return;
|
||||||
|
|
||||||
|
const action = new URL(form.action, location.href).pathname;
|
||||||
|
if (FULL_RELOAD_ACTIONS.some(ex => action.startsWith(ex))) return;
|
||||||
|
|
||||||
|
const method = (form.method || 'GET').toUpperCase();
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
// Rebuild URL with form data then navigate.
|
||||||
|
e.preventDefault();
|
||||||
|
const url = new URL(form.action, location.href);
|
||||||
|
for (const [k, v] of new FormData(form).entries()) {
|
||||||
|
if (typeof v === 'string') url.searchParams.set(k, v);
|
||||||
|
}
|
||||||
|
navigate(url.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST') {
|
||||||
|
e.preventDefault();
|
||||||
|
fetch(form.action, { method: 'POST', body: buildBody(form) })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!isSameOrigin(res.url)) { location.href = res.url; return; }
|
||||||
|
const html = await res.text();
|
||||||
|
applyPage(new DOMParser().parseFromString(html, 'text/html'), res.url, true);
|
||||||
|
})
|
||||||
|
.catch(() => form.submit());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Back / forward ---
|
||||||
|
window.addEventListener('popstate', () => navigate(location.href, false));
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
|
|
@ -5,7 +5,7 @@ const express = require('express');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
|
|
||||||
const { requireAdmin } = require('../middleware/auth');
|
const { requireUser, requireAdmin } = require('../middleware/auth');
|
||||||
const songs = require('../services/songs');
|
const songs = require('../services/songs');
|
||||||
const playlists = require('../services/playlists');
|
const playlists = require('../services/playlists');
|
||||||
|
|
||||||
|
|
@ -38,6 +38,9 @@ const upload = multer({
|
||||||
const songFields = upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }]);
|
const songFields = upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }]);
|
||||||
const playlistFields = upload.fields([{ name: 'cover', maxCount: 1 }]);
|
const playlistFields = upload.fields([{ name: 'cover', maxCount: 1 }]);
|
||||||
|
|
||||||
|
const VISIBILITY_VALUES = ['public', 'logged_in', 'private'];
|
||||||
|
const VISIBILITY_RANK = { public: 0, logged_in: 1, private: 2 };
|
||||||
|
|
||||||
// For uploads, all text fields are optional — we'll fill blanks from ID3 tags.
|
// For uploads, all text fields are optional — we'll fill blanks from ID3 tags.
|
||||||
// The edit form enforces title/artist via a stricter schema below.
|
// The edit form enforces title/artist via a stricter schema below.
|
||||||
const songUploadSchema = z.object({
|
const songUploadSchema = z.object({
|
||||||
|
|
@ -46,7 +49,7 @@ const songUploadSchema = z.object({
|
||||||
album: z.string().trim().max(200).optional().or(z.literal('')),
|
album: z.string().trim().max(200).optional().or(z.literal('')),
|
||||||
genre: z.string().trim().max(80).optional().or(z.literal('')),
|
genre: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()),
|
year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()),
|
||||||
is_public: z.any().optional(),
|
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const songEditSchema = z.object({
|
const songEditSchema = z.object({
|
||||||
|
|
@ -55,13 +58,13 @@ const songEditSchema = z.object({
|
||||||
album: z.string().trim().max(200).optional().or(z.literal('')),
|
album: z.string().trim().max(200).optional().or(z.literal('')),
|
||||||
genre: z.string().trim().max(80).optional().or(z.literal('')),
|
genre: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()),
|
year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()),
|
||||||
is_public: z.any().optional(),
|
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const playlistSchema = z.object({
|
const playlistSchema = z.object({
|
||||||
title: z.string().trim().min(1).max(200),
|
title: z.string().trim().min(1).max(200),
|
||||||
description: z.string().trim().max(2000).optional().or(z.literal('')),
|
description: z.string().trim().max(2000).optional().or(z.literal('')),
|
||||||
is_public: z.any().optional(),
|
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse embedded tags (ID3, Vorbis comments, etc.) from an audio file.
|
// Parse embedded tags (ID3, Vorbis comments, etc.) from an audio file.
|
||||||
|
|
@ -129,19 +132,34 @@ function unlinkQuiet(absPath) {
|
||||||
fs.unlink(absPath, () => {});
|
fs.unlink(absPath, () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canManage(item, user) {
|
||||||
|
if (user.role === 'admin') return true;
|
||||||
|
return (item.uploadedBy != null && item.uploadedBy === user.id) ||
|
||||||
|
(item.createdBy != null && item.createdBy === user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function forbidden(next) {
|
||||||
|
const err = new Error('Not authorized.');
|
||||||
|
err.status = 403;
|
||||||
|
err.expose = true;
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function adminRoutes(csrfProtection) {
|
module.exports = function adminRoutes(csrfProtection) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireAdmin);
|
router.use(requireUser);
|
||||||
|
|
||||||
// ---------- Songs ----------
|
// ---------- Songs ----------
|
||||||
router.get('/', (req, res) => res.redirect('/admin/songs'));
|
router.get('/', (req, res) => res.redirect('/admin/songs'));
|
||||||
|
|
||||||
router.get('/songs', (req, res) => {
|
router.get('/songs', (req, res) => {
|
||||||
res.render('admin/songs', { title: 'Admin · Songs', songs: songs.listAll() });
|
const user = req.session.user;
|
||||||
|
const songList = user.role === 'admin' ? songs.listAll() : songs.listByUser(user.id);
|
||||||
|
res.render('admin/songs', { title: 'My Music · Songs', songs: songList });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/songs/new', (req, res) => {
|
router.get('/songs/new', (req, res) => {
|
||||||
res.render('admin/song_form', { title: 'New song', song: null, errors: {}, values: { is_public: 'on' } });
|
res.render('admin/song_form', { title: 'New song', song: null, errors: {}, values: { visibility: 'logged_in' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/songs', songFields, csrfProtection, async (req, res, next) => {
|
router.post('/songs', songFields, csrfProtection, async (req, res, next) => {
|
||||||
|
|
@ -191,7 +209,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
durationSeconds: tags.duration,
|
durationSeconds: tags.duration,
|
||||||
audioPath: relMedia(audioFile, 'audio'),
|
audioPath: relMedia(audioFile, 'audio'),
|
||||||
coverPath,
|
coverPath,
|
||||||
isPublic: !!parsed.data.is_public,
|
visibility: parsed.data.visibility,
|
||||||
uploadedBy: req.session.user.id,
|
uploadedBy: req.session.user.id,
|
||||||
});
|
});
|
||||||
req.flash('success', 'Song uploaded. Tags were read from the file — review and edit if needed.');
|
req.flash('success', 'Song uploaded. Tags were read from the file — review and edit if needed.');
|
||||||
|
|
@ -202,9 +220,10 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
router.get('/songs/:id/edit', (req, res, next) => {
|
router.get('/songs/:id/edit', (req, res, next) => {
|
||||||
const song = songs.findById(Number(req.params.id));
|
const song = songs.findById(Number(req.params.id));
|
||||||
if (!song) return next();
|
if (!song) return next();
|
||||||
|
if (!canManage(song, req.session.user)) return forbidden(next);
|
||||||
res.render('admin/song_form', {
|
res.render('admin/song_form', {
|
||||||
title: 'Edit song', song, errors: {},
|
title: 'Edit song', song, errors: {},
|
||||||
values: { ...song, is_public: song.isPublic ? 'on' : '' },
|
values: { ...song },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -213,6 +232,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const existing = songs.findById(id);
|
const existing = songs.findById(id);
|
||||||
if (!existing) return next();
|
if (!existing) return next();
|
||||||
|
if (!canManage(existing, req.session.user)) return forbidden(next);
|
||||||
const parsed = songEditSchema.safeParse(req.body);
|
const parsed = songEditSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errors = {};
|
const errors = {};
|
||||||
|
|
@ -225,7 +245,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
album: parsed.data.album || null,
|
album: parsed.data.album || null,
|
||||||
genre: parsed.data.genre || null,
|
genre: parsed.data.genre || null,
|
||||||
year: parsed.data.year ?? null,
|
year: parsed.data.year ?? null,
|
||||||
isPublic: !!parsed.data.is_public,
|
visibility: parsed.data.visibility,
|
||||||
});
|
});
|
||||||
req.flash('success', 'Song updated.');
|
req.flash('success', 'Song updated.');
|
||||||
res.redirect('/admin/songs');
|
res.redirect('/admin/songs');
|
||||||
|
|
@ -237,6 +257,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const existing = songs.findById(id);
|
const existing = songs.findById(id);
|
||||||
if (!existing) return next();
|
if (!existing) return next();
|
||||||
|
if (!canManage(existing, req.session.user)) return forbidden(next);
|
||||||
songs.remove(id);
|
songs.remove(id);
|
||||||
// Best-effort cleanup of media.
|
// Best-effort cleanup of media.
|
||||||
if (existing.audioPath) unlinkQuiet(path.join(mediaDir, existing.audioPath));
|
if (existing.audioPath) unlinkQuiet(path.join(mediaDir, existing.audioPath));
|
||||||
|
|
@ -248,28 +269,34 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
|
|
||||||
// ---------- Playlists ----------
|
// ---------- Playlists ----------
|
||||||
router.get('/playlists', (req, res) => {
|
router.get('/playlists', (req, res) => {
|
||||||
res.render('admin/playlists', { title: 'Admin · Playlists', playlists: playlists.listAll() });
|
const user = req.session.user;
|
||||||
|
const playlistList = user.role === 'admin' ? playlists.listAll() : playlists.listByUser(user.id);
|
||||||
|
res.render('admin/playlists', { title: 'My Music · Playlists', playlists: playlistList });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/playlists/new', (req, res) => {
|
router.get('/playlists/new', (req, res) => {
|
||||||
res.render('admin/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs: songs.listAll(), errors: {}, values: { is_public: 'on' } });
|
const user = req.session.user;
|
||||||
|
const allSongs = user.role === 'admin' ? songs.listAll() : songs.listForManagement(user.id);
|
||||||
|
res.render('admin/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors: {}, values: { visibility: 'logged_in' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/playlists', playlistFields, csrfProtection, (req, res, next) => {
|
router.post('/playlists', playlistFields, csrfProtection, (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const user = req.session.user;
|
||||||
const parsed = playlistSchema.safeParse(req.body);
|
const parsed = playlistSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errors = {};
|
const errors = {};
|
||||||
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
|
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
|
||||||
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
|
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
|
||||||
return res.status(400).render('admin/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs: songs.listAll(), errors, values: req.body });
|
const allSongs = user.role === 'admin' ? songs.listAll() : songs.listForManagement(user.id);
|
||||||
|
return res.status(400).render('admin/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors, values: req.body });
|
||||||
}
|
}
|
||||||
const coverFile = req.files?.cover?.[0];
|
const coverFile = req.files?.cover?.[0];
|
||||||
const created = playlists.create({
|
const created = playlists.create({
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
description: parsed.data.description || null,
|
description: parsed.data.description || null,
|
||||||
coverPath: coverFile ? relMedia(coverFile, 'covers') : null,
|
coverPath: coverFile ? relMedia(coverFile, 'covers') : null,
|
||||||
isPublic: !!parsed.data.is_public,
|
visibility: parsed.data.visibility,
|
||||||
createdBy: req.session.user.id,
|
createdBy: req.session.user.id,
|
||||||
});
|
});
|
||||||
req.flash('success', 'Playlist created.');
|
req.flash('success', 'Playlist created.');
|
||||||
|
|
@ -281,13 +308,16 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const playlist = playlists.findById(id);
|
const playlist = playlists.findById(id);
|
||||||
if (!playlist) return next();
|
if (!playlist) return next();
|
||||||
|
if (!canManage(playlist, req.session.user)) return forbidden(next);
|
||||||
|
const user = req.session.user;
|
||||||
|
const allSongs = user.role === 'admin' ? songs.listAll() : songs.listForManagement(user.id);
|
||||||
res.render('admin/playlist_form', {
|
res.render('admin/playlist_form', {
|
||||||
title: 'Edit playlist',
|
title: 'Edit playlist',
|
||||||
playlist,
|
playlist,
|
||||||
tracks: playlists.getTracks(id),
|
tracks: playlists.getTracks(id),
|
||||||
allSongs: songs.listAll(),
|
allSongs,
|
||||||
errors: {},
|
errors: {},
|
||||||
values: { ...playlist, is_public: playlist.isPublic ? 'on' : '' },
|
values: { ...playlist },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -296,21 +326,24 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const existing = playlists.findById(id);
|
const existing = playlists.findById(id);
|
||||||
if (!existing) return next();
|
if (!existing) return next();
|
||||||
|
if (!canManage(existing, req.session.user)) return forbidden(next);
|
||||||
|
const user = req.session.user;
|
||||||
const parsed = playlistSchema.safeParse(req.body);
|
const parsed = playlistSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errors = {};
|
const errors = {};
|
||||||
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
|
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
|
||||||
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
|
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
|
||||||
|
const allSongs = user.role === 'admin' ? songs.listAll() : songs.listForManagement(user.id);
|
||||||
return res.status(400).render('admin/playlist_form', {
|
return res.status(400).render('admin/playlist_form', {
|
||||||
title: 'Edit playlist', playlist: existing,
|
title: 'Edit playlist', playlist: existing,
|
||||||
tracks: playlists.getTracks(id), allSongs: songs.listAll(),
|
tracks: playlists.getTracks(id), allSongs,
|
||||||
errors, values: req.body,
|
errors, values: req.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
playlists.update(id, {
|
playlists.update(id, {
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
description: parsed.data.description || null,
|
description: parsed.data.description || null,
|
||||||
isPublic: !!parsed.data.is_public,
|
visibility: parsed.data.visibility,
|
||||||
});
|
});
|
||||||
const coverFile = req.files?.cover?.[0];
|
const coverFile = req.files?.cover?.[0];
|
||||||
if (coverFile) {
|
if (coverFile) {
|
||||||
|
|
@ -326,8 +359,15 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const songId = Number(req.body.song_id);
|
const songId = Number(req.body.song_id);
|
||||||
if (!playlists.findById(id) || !songs.findById(songId)) return next();
|
const playlist = playlists.findById(id);
|
||||||
|
const song = songs.findById(songId);
|
||||||
|
if (!playlist || !song) return next();
|
||||||
|
if (!canManage(playlist, req.session.user)) return forbidden(next);
|
||||||
playlists.addTrack(id, songId);
|
playlists.addTrack(id, songId);
|
||||||
|
// Auto-upgrade playlist visibility if the song is more restrictive.
|
||||||
|
if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[playlist.visibility]) {
|
||||||
|
playlists.setVisibility(id, song.visibility);
|
||||||
|
}
|
||||||
res.redirect(`/admin/playlists/${id}/edit`);
|
res.redirect(`/admin/playlists/${id}/edit`);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
@ -336,6 +376,9 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const songId = Number(req.body.song_id);
|
const songId = Number(req.body.song_id);
|
||||||
|
const playlist = playlists.findById(id);
|
||||||
|
if (!playlist) return next();
|
||||||
|
if (!canManage(playlist, req.session.user)) return forbidden(next);
|
||||||
playlists.removeTrack(id, songId);
|
playlists.removeTrack(id, songId);
|
||||||
res.redirect(`/admin/playlists/${id}/edit`);
|
res.redirect(`/admin/playlists/${id}/edit`);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|
@ -344,6 +387,9 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
router.post('/playlists/:id/reorder', csrfProtection, (req, res, next) => {
|
router.post('/playlists/:id/reorder', csrfProtection, (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
|
const playlist = playlists.findById(id);
|
||||||
|
if (!playlist) return next();
|
||||||
|
if (!canManage(playlist, req.session.user)) return forbidden(next);
|
||||||
const order = String(req.body.order || '')
|
const order = String(req.body.order || '')
|
||||||
.split(',').map((x) => Number(x.trim())).filter(Number.isFinite);
|
.split(',').map((x) => Number(x.trim())).filter(Number.isFinite);
|
||||||
playlists.setOrder(id, order);
|
playlists.setOrder(id, order);
|
||||||
|
|
@ -356,6 +402,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const existing = playlists.findById(id);
|
const existing = playlists.findById(id);
|
||||||
if (!existing) return next();
|
if (!existing) return next();
|
||||||
|
if (!canManage(existing, req.session.user)) return forbidden(next);
|
||||||
playlists.remove(id);
|
playlists.remove(id);
|
||||||
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
|
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
|
||||||
req.flash('success', 'Playlist deleted.');
|
req.flash('success', 'Playlist deleted.');
|
||||||
|
|
|
||||||
|
|
@ -15,21 +15,43 @@ const MIME = {
|
||||||
'.flac': 'audio/flac', '.wav': 'audio/wav', '.webm': 'audio/webm',
|
'.flac': 'audio/flac', '.wav': 'audio/wav', '.webm': 'audio/webm',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Can the current viewer access an item with this visibility/owner?
|
||||||
|
function canView(visibility, ownerId, userId) {
|
||||||
|
if (visibility === 'public') return true;
|
||||||
|
if (visibility === 'logged_in') return !!userId;
|
||||||
|
if (visibility === 'private') return userId != null && ownerId === userId;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For members-only items, redirect guests to login rather than 404.
|
||||||
|
// Private items always 404 (don't reveal they exist).
|
||||||
|
function denyAccess(req, res, next, visibility) {
|
||||||
|
if (visibility === 'logged_in' && !req.session.user) {
|
||||||
|
req.session.returnTo = req.originalUrl;
|
||||||
|
req.flash('error', 'You must be logged in to access that.');
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
const loggedIn = !!req.session.user;
|
||||||
res.render('public/home', {
|
res.render('public/home', {
|
||||||
title: 'Tunes',
|
title: 'Tunes',
|
||||||
featured: playlists.featured(6),
|
featured: playlists.featured(6, loggedIn),
|
||||||
recent: songs.recentPublic(8),
|
recent: songs.recent(8, loggedIn),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/songs', (req, res) => {
|
router.get('/songs', (req, res) => {
|
||||||
|
const loggedIn = !!req.session.user;
|
||||||
const page = Math.max(1, Number(req.query.page) || 1);
|
const page = Math.max(1, Number(req.query.page) || 1);
|
||||||
const pageSize = 25;
|
const pageSize = 25;
|
||||||
const { rows, total } = songs.listPublic({
|
const { rows, total } = songs.list({
|
||||||
q: req.query.q || '',
|
q: req.query.q || '',
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset: (page - 1) * pageSize,
|
offset: (page - 1) * pageSize,
|
||||||
|
loggedIn,
|
||||||
});
|
});
|
||||||
res.render('public/songs', {
|
res.render('public/songs', {
|
||||||
title: 'Songs',
|
title: 'Songs',
|
||||||
|
|
@ -44,29 +66,39 @@ router.get('/songs', (req, res) => {
|
||||||
|
|
||||||
router.get('/songs/:id', (req, res, next) => {
|
router.get('/songs/:id', (req, res, next) => {
|
||||||
const song = songs.findById(Number(req.params.id));
|
const song = songs.findById(Number(req.params.id));
|
||||||
if (!song || !song.isPublic) return next();
|
if (!song) return next();
|
||||||
|
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) {
|
||||||
|
return denyAccess(req, res, next, song.visibility);
|
||||||
|
}
|
||||||
res.render('public/song', { title: song.title, song });
|
res.render('public/song', { title: song.title, song });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/playlists', (req, res) => {
|
router.get('/playlists', (req, res) => {
|
||||||
res.render('public/playlists', { title: 'Playlists', playlists: playlists.listPublic() });
|
const loggedIn = !!req.session.user;
|
||||||
|
res.render('public/playlists', { title: 'Playlists', playlists: playlists.listVisible(loggedIn) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/playlists/:id', (req, res, next) => {
|
router.get('/playlists/:id', (req, res, next) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const playlist = playlists.findById(id);
|
const playlist = playlists.findById(id);
|
||||||
if (!playlist || !playlist.isPublic) return next();
|
if (!playlist) return next();
|
||||||
res.render('public/playlist', {
|
const userId = req.session.user?.id;
|
||||||
title: playlist.title,
|
// Use effective visibility (most restrictive of playlist + all its tracks).
|
||||||
playlist,
|
const effectiveVisibility = playlists.getEffectiveVisibility(id);
|
||||||
tracks: playlists.getTracks(id),
|
if (!canView(effectiveVisibility, playlist.createdBy, userId)) {
|
||||||
});
|
return denyAccess(req, res, next, effectiveVisibility);
|
||||||
|
}
|
||||||
|
const tracks = playlists.getTracks(id).filter(t => canView(t.visibility, t.uploadedBy, userId));
|
||||||
|
res.render('public/playlist', { title: playlist.title, playlist, tracks });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Audio streaming with HTTP Range support.
|
// Audio streaming with HTTP Range support.
|
||||||
router.get('/stream/:id', (req, res, next) => {
|
router.get('/stream/:id', (req, res, next) => {
|
||||||
const song = songs.findById(Number(req.params.id));
|
const song = songs.findById(Number(req.params.id));
|
||||||
if (!song || !song.isPublic) return next();
|
if (!song) return next();
|
||||||
|
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) {
|
||||||
|
return denyAccess(req, res, next, song.visibility);
|
||||||
|
}
|
||||||
const absPath = path.join(mediaDir, song.audioPath);
|
const absPath = path.join(mediaDir, song.audioPath);
|
||||||
fs.stat(absPath, (err, stat) => {
|
fs.stat(absPath, (err, stat) => {
|
||||||
if (err) return next();
|
if (err) return next();
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,78 @@
|
||||||
const { db } = require('../../db');
|
const { db } = require('../../db');
|
||||||
|
|
||||||
const insertStmt = db.prepare(`
|
const insertStmt = db.prepare(`
|
||||||
INSERT INTO playlists (title, description, cover_path, is_public, created_by)
|
INSERT INTO playlists (title, description, cover_path, visibility, created_by)
|
||||||
VALUES (@title, @description, @cover_path, @is_public, @created_by)
|
VALUES (@title, @description, @cover_path, @visibility, @created_by)
|
||||||
`);
|
`);
|
||||||
const updateStmt = db.prepare(`
|
const updateStmt = db.prepare(`
|
||||||
UPDATE playlists SET title = @title, description = @description, is_public = @is_public
|
UPDATE playlists SET title = @title, description = @description, visibility = @visibility
|
||||||
WHERE id = @id
|
WHERE id = @id
|
||||||
`);
|
`);
|
||||||
const updateCoverStmt = db.prepare('UPDATE playlists SET cover_path = ? WHERE id = ?');
|
const updateCoverStmt = db.prepare('UPDATE playlists SET cover_path = ? WHERE id = ?');
|
||||||
|
const setVisibilityStmt = db.prepare('UPDATE playlists SET visibility = ? WHERE id = ?');
|
||||||
const deleteStmt = db.prepare('DELETE FROM playlists WHERE id = ?');
|
const deleteStmt = db.prepare('DELETE FROM playlists WHERE id = ?');
|
||||||
const findByIdStmt = db.prepare('SELECT * FROM playlists WHERE id = ?');
|
const findByIdStmt = db.prepare('SELECT * FROM playlists WHERE id = ?');
|
||||||
|
|
||||||
const listPublicStmt = db.prepare('SELECT * FROM playlists WHERE is_public = 1 ORDER BY created_at DESC');
|
// For list queries, effective visibility = max(playlist.visibility, most restrictive track).
|
||||||
|
// Guests only see playlists where nothing in them is logged_in or private.
|
||||||
|
const listGuestStmt = db.prepare(`
|
||||||
|
SELECT p.* FROM playlists p
|
||||||
|
WHERE p.visibility = 'public'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'private')
|
||||||
|
)
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
`);
|
||||||
|
// Logged-in users see public/logged_in playlists where nothing in them is private.
|
||||||
|
const listUserStmt = db.prepare(`
|
||||||
|
SELECT p.* FROM playlists p
|
||||||
|
WHERE p.visibility IN ('public', 'logged_in')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
WHERE ps.playlist_id = p.id AND s.visibility = 'private'
|
||||||
|
)
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
`);
|
||||||
const listAllStmt = db.prepare('SELECT * FROM playlists ORDER BY created_at DESC');
|
const listAllStmt = db.prepare('SELECT * FROM playlists ORDER BY created_at DESC');
|
||||||
const featuredStmt = db.prepare('SELECT * FROM playlists WHERE is_public = 1 ORDER BY created_at DESC LIMIT ?');
|
const listByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ? ORDER BY created_at DESC');
|
||||||
|
const featuredGuestStmt = db.prepare(`
|
||||||
|
SELECT p.* FROM playlists p
|
||||||
|
WHERE p.visibility = 'public'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'private')
|
||||||
|
)
|
||||||
|
ORDER BY p.created_at DESC LIMIT ?
|
||||||
|
`);
|
||||||
|
const featuredUserStmt = db.prepare(`
|
||||||
|
SELECT p.* FROM playlists p
|
||||||
|
WHERE p.visibility IN ('public', 'logged_in')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
WHERE ps.playlist_id = p.id AND s.visibility = 'private'
|
||||||
|
)
|
||||||
|
ORDER BY p.created_at DESC LIMIT ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Compute the single most-restrictive visibility for one playlist (stored + tracks).
|
||||||
|
const effectiveVisibilityStmt = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN p.visibility = 'private'
|
||||||
|
OR EXISTS (SELECT 1 FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id WHERE ps.playlist_id = p.id AND s.visibility = 'private')
|
||||||
|
THEN 'private'
|
||||||
|
WHEN p.visibility = 'logged_in'
|
||||||
|
OR EXISTS (SELECT 1 FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id WHERE ps.playlist_id = p.id AND s.visibility = 'logged_in')
|
||||||
|
THEN 'logged_in'
|
||||||
|
ELSE 'public'
|
||||||
|
END AS effective_visibility
|
||||||
|
FROM playlists p WHERE p.id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
const tracksStmt = db.prepare(`
|
const tracksStmt = db.prepare(`
|
||||||
SELECT s.*, ps.position FROM playlist_songs ps
|
SELECT s.*, ps.position FROM playlist_songs ps
|
||||||
|
|
@ -35,7 +93,7 @@ function toView(row) {
|
||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
coverPath: row.cover_path,
|
coverPath: row.cover_path,
|
||||||
isPublic: !!row.is_public,
|
visibility: row.visibility || 'logged_in',
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
};
|
};
|
||||||
|
|
@ -49,6 +107,8 @@ function trackView(row) {
|
||||||
album: row.album,
|
album: row.album,
|
||||||
durationSeconds: row.duration_seconds,
|
durationSeconds: row.duration_seconds,
|
||||||
coverPath: row.cover_path,
|
coverPath: row.cover_path,
|
||||||
|
visibility: row.visibility || 'logged_in',
|
||||||
|
uploadedBy: row.uploaded_by,
|
||||||
position: row.position,
|
position: row.position,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +118,7 @@ function create(data) {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
cover_path: data.coverPath || null,
|
cover_path: data.coverPath || null,
|
||||||
is_public: data.isPublic ? 1 : 0,
|
visibility: data.visibility || 'logged_in',
|
||||||
created_by: data.createdBy || null,
|
created_by: data.createdBy || null,
|
||||||
});
|
});
|
||||||
return toView(findByIdStmt.get(info.lastInsertRowid));
|
return toView(findByIdStmt.get(info.lastInsertRowid));
|
||||||
|
|
@ -69,7 +129,7 @@ function update(id, data) {
|
||||||
id,
|
id,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
is_public: data.isPublic ? 1 : 0,
|
visibility: data.visibility || 'logged_in',
|
||||||
});
|
});
|
||||||
return toView(findByIdStmt.get(id));
|
return toView(findByIdStmt.get(id));
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +138,10 @@ function setCover(id, coverPath) {
|
||||||
updateCoverStmt.run(coverPath, id);
|
updateCoverStmt.run(coverPath, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setVisibility(id, visibility) {
|
||||||
|
setVisibilityStmt.run(visibility, id);
|
||||||
|
}
|
||||||
|
|
||||||
function remove(id) {
|
function remove(id) {
|
||||||
deleteStmt.run(id);
|
deleteStmt.run(id);
|
||||||
}
|
}
|
||||||
|
|
@ -86,16 +150,25 @@ function findById(id) {
|
||||||
return toView(findByIdStmt.get(id));
|
return toView(findByIdStmt.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPublic() {
|
function getEffectiveVisibility(id) {
|
||||||
return listPublicStmt.all().map(toView);
|
const row = effectiveVisibilityStmt.get(id);
|
||||||
|
return row ? row.effective_visibility : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listVisible(loggedIn = false) {
|
||||||
|
return (loggedIn ? listUserStmt : listGuestStmt).all().map(toView);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listAll() {
|
function listAll() {
|
||||||
return listAllStmt.all().map(toView);
|
return listAllStmt.all().map(toView);
|
||||||
}
|
}
|
||||||
|
|
||||||
function featured(n = 6) {
|
function listByUser(userId) {
|
||||||
return featuredStmt.all(n).map(toView);
|
return listByUserStmt.all(userId).map(toView);
|
||||||
|
}
|
||||||
|
|
||||||
|
function featured(n = 6, loggedIn = false) {
|
||||||
|
return (loggedIn ? featuredUserStmt : featuredGuestStmt).all(n).map(toView);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTracks(playlistId) {
|
function getTracks(playlistId) {
|
||||||
|
|
@ -124,7 +197,7 @@ function setOrder(playlistId, songIds) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
create, update, setCover, remove, findById,
|
create, update, setCover, setVisibility, remove, findById, getEffectiveVisibility,
|
||||||
listPublic, listAll, featured,
|
listVisible, listAll, listByUser, featured,
|
||||||
getTracks, trackCount, addTrack, removeTrack, setOrder,
|
getTracks, trackCount, addTrack, removeTrack, setOrder,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,39 @@
|
||||||
const { db } = require('../../db');
|
const { db } = require('../../db');
|
||||||
|
|
||||||
const insertStmt = db.prepare(`
|
const insertStmt = db.prepare(`
|
||||||
INSERT INTO songs (title, artist, album, genre, year, duration_seconds, audio_path, cover_path, is_public, uploaded_by)
|
INSERT INTO songs (title, artist, album, genre, year, duration_seconds, audio_path, cover_path, visibility, uploaded_by)
|
||||||
VALUES (@title, @artist, @album, @genre, @year, @duration_seconds, @audio_path, @cover_path, @is_public, @uploaded_by)
|
VALUES (@title, @artist, @album, @genre, @year, @duration_seconds, @audio_path, @cover_path, @visibility, @uploaded_by)
|
||||||
`);
|
`);
|
||||||
const updateStmt = db.prepare(`
|
const updateStmt = db.prepare(`
|
||||||
UPDATE songs SET title = @title, artist = @artist, album = @album, genre = @genre, year = @year, is_public = @is_public
|
UPDATE songs SET title = @title, artist = @artist, album = @album, genre = @genre, year = @year, visibility = @visibility
|
||||||
WHERE id = @id
|
WHERE id = @id
|
||||||
`);
|
`);
|
||||||
const updateCoverStmt = db.prepare('UPDATE songs SET cover_path = ? WHERE id = ?');
|
const updateCoverStmt = db.prepare('UPDATE songs SET cover_path = ? WHERE id = ?');
|
||||||
const deleteStmt = db.prepare('DELETE FROM songs WHERE id = ?');
|
const deleteStmt = db.prepare('DELETE FROM songs WHERE id = ?');
|
||||||
const findByIdStmt = db.prepare('SELECT * FROM songs WHERE id = ?');
|
const findByIdStmt = db.prepare('SELECT * FROM songs WHERE id = ?');
|
||||||
|
|
||||||
const listPublicStmt = db.prepare(`
|
// Guest (not logged in): public only
|
||||||
SELECT * FROM songs WHERE is_public = 1
|
const listGuestStmt = db.prepare(`SELECT * FROM songs WHERE visibility = 'public' ORDER BY created_at DESC LIMIT ? OFFSET ?`);
|
||||||
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
const countGuestStmt = db.prepare(`SELECT COUNT(*) AS c FROM songs WHERE visibility = 'public'`);
|
||||||
`);
|
const searchGuestStmt = db.prepare(`SELECT * FROM songs WHERE visibility = 'public' AND (title LIKE @q OR artist LIKE @q OR album LIKE @q) ORDER BY created_at DESC LIMIT @limit OFFSET @offset`);
|
||||||
const countPublicStmt = db.prepare('SELECT COUNT(*) AS c FROM songs WHERE is_public = 1');
|
const countSearchGuestStmt = db.prepare(`SELECT COUNT(*) AS c FROM songs WHERE visibility = 'public' AND (title LIKE @q OR artist LIKE @q OR album LIKE @q)`);
|
||||||
|
|
||||||
const searchPublicStmt = db.prepare(`
|
// Logged-in user: public + logged_in
|
||||||
SELECT * FROM songs
|
const listUserStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in') ORDER BY created_at DESC LIMIT ? OFFSET ?`);
|
||||||
WHERE is_public = 1 AND (title LIKE @q OR artist LIKE @q OR album LIKE @q)
|
const countUserStmt = db.prepare(`SELECT COUNT(*) AS c FROM songs WHERE visibility IN ('public', 'logged_in')`);
|
||||||
ORDER BY created_at DESC LIMIT @limit OFFSET @offset
|
const searchUserStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in') AND (title LIKE @q OR artist LIKE @q OR album LIKE @q) ORDER BY created_at DESC LIMIT @limit OFFSET @offset`);
|
||||||
`);
|
const countSearchUserStmt = db.prepare(`SELECT COUNT(*) AS c FROM songs WHERE visibility IN ('public', 'logged_in') AND (title LIKE @q OR artist LIKE @q OR album LIKE @q)`);
|
||||||
const countSearchPublicStmt = db.prepare(`
|
|
||||||
SELECT COUNT(*) AS c FROM songs
|
|
||||||
WHERE is_public = 1 AND (title LIKE @q OR artist LIKE @q OR album LIKE @q)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const listAllStmt = db.prepare('SELECT * FROM songs ORDER BY created_at DESC');
|
const listAllStmt = db.prepare('SELECT * FROM songs ORDER BY created_at DESC');
|
||||||
const recentPublicStmt = db.prepare('SELECT * FROM songs WHERE is_public = 1 ORDER BY created_at DESC LIMIT ?');
|
const listByUserStmt = db.prepare('SELECT * FROM songs WHERE uploaded_by = ? ORDER BY created_at DESC');
|
||||||
|
// For the "add song to playlist" dropdown: public + logged_in + the user's own private songs.
|
||||||
|
const listForManagementStmt = db.prepare(`
|
||||||
|
SELECT * FROM songs
|
||||||
|
WHERE visibility IN ('public', 'logged_in') OR uploaded_by = ?
|
||||||
|
ORDER BY title ASC
|
||||||
|
`);
|
||||||
|
const recentGuestStmt = db.prepare(`SELECT * FROM songs WHERE visibility = 'public' ORDER BY created_at DESC LIMIT ?`);
|
||||||
|
const recentUserStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in') ORDER BY created_at DESC LIMIT ?`);
|
||||||
|
|
||||||
function toView(row) {
|
function toView(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
@ -43,7 +47,7 @@ function toView(row) {
|
||||||
durationSeconds: row.duration_seconds,
|
durationSeconds: row.duration_seconds,
|
||||||
audioPath: row.audio_path,
|
audioPath: row.audio_path,
|
||||||
coverPath: row.cover_path,
|
coverPath: row.cover_path,
|
||||||
isPublic: !!row.is_public,
|
visibility: row.visibility || 'logged_in',
|
||||||
uploadedBy: row.uploaded_by,
|
uploadedBy: row.uploaded_by,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
};
|
};
|
||||||
|
|
@ -59,7 +63,7 @@ function create(data) {
|
||||||
duration_seconds: data.durationSeconds || null,
|
duration_seconds: data.durationSeconds || null,
|
||||||
audio_path: data.audioPath,
|
audio_path: data.audioPath,
|
||||||
cover_path: data.coverPath || null,
|
cover_path: data.coverPath || null,
|
||||||
is_public: data.isPublic ? 1 : 0,
|
visibility: data.visibility || 'logged_in',
|
||||||
uploaded_by: data.uploadedBy || null,
|
uploaded_by: data.uploadedBy || null,
|
||||||
});
|
});
|
||||||
return toView(findByIdStmt.get(info.lastInsertRowid));
|
return toView(findByIdStmt.get(info.lastInsertRowid));
|
||||||
|
|
@ -73,7 +77,7 @@ function update(id, data) {
|
||||||
album: data.album || null,
|
album: data.album || null,
|
||||||
genre: data.genre || null,
|
genre: data.genre || null,
|
||||||
year: data.year || null,
|
year: data.year || null,
|
||||||
is_public: data.isPublic ? 1 : 0,
|
visibility: data.visibility || 'logged_in',
|
||||||
});
|
});
|
||||||
return toView(findByIdStmt.get(id));
|
return toView(findByIdStmt.get(id));
|
||||||
}
|
}
|
||||||
|
|
@ -92,24 +96,35 @@ function findById(id) {
|
||||||
return toView(findByIdStmt.get(id));
|
return toView(findByIdStmt.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPublic({ q, limit = 25, offset = 0 }) {
|
// loggedIn=true means the viewer is authenticated (sees public + logged_in).
|
||||||
|
function list({ q, limit = 25, offset = 0, loggedIn = false }) {
|
||||||
if (q && q.trim()) {
|
if (q && q.trim()) {
|
||||||
const like = `%${q.trim()}%`;
|
const like = `%${q.trim()}%`;
|
||||||
const rows = searchPublicStmt.all({ q: like, limit, offset });
|
if (loggedIn) {
|
||||||
const total = countSearchPublicStmt.get({ q: like }).c;
|
return { rows: searchUserStmt.all({ q: like, limit, offset }).map(toView), total: countSearchUserStmt.get({ q: like }).c };
|
||||||
return { rows: rows.map(toView), total };
|
}
|
||||||
|
return { rows: searchGuestStmt.all({ q: like, limit, offset }).map(toView), total: countSearchGuestStmt.get({ q: like }).c };
|
||||||
}
|
}
|
||||||
const rows = listPublicStmt.all(limit, offset);
|
if (loggedIn) {
|
||||||
const total = countPublicStmt.get().c;
|
return { rows: listUserStmt.all(limit, offset).map(toView), total: countUserStmt.get().c };
|
||||||
return { rows: rows.map(toView), total };
|
}
|
||||||
|
return { rows: listGuestStmt.all(limit, offset).map(toView), total: countGuestStmt.get().c };
|
||||||
}
|
}
|
||||||
|
|
||||||
function listAll() {
|
function listAll() {
|
||||||
return listAllStmt.all().map(toView);
|
return listAllStmt.all().map(toView);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recentPublic(n = 8) {
|
function listByUser(userId) {
|
||||||
return recentPublicStmt.all(n).map(toView);
|
return listByUserStmt.all(userId).map(toView);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { create, update, setCover, remove, findById, listPublic, listAll, recentPublic };
|
function listForManagement(userId) {
|
||||||
|
return listForManagementStmt.all(userId).map(toView);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recent(n = 8, loggedIn = false) {
|
||||||
|
return (loggedIn ? recentUserStmt : recentGuestStmt).all(n).map(toView);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { create, update, setCover, remove, findById, list, listAll, listByUser, listForManagement, recent };
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,22 @@
|
||||||
Cover art
|
Cover art
|
||||||
<input type="file" name="cover" accept="image/*">
|
<input type="file" name="cover" accept="image/*">
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox">
|
<fieldset class="visibility-field">
|
||||||
<input type="checkbox" name="is_public" <%= values.is_public ? 'checked' : '' %>>
|
<legend>Visibility</legend>
|
||||||
Public
|
<label class="checkbox">
|
||||||
</label>
|
<input type="radio" name="visibility" value="public" <%= (values.visibility === 'public') ? 'checked' : '' %>>
|
||||||
|
Public — visible to everyone
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio" name="visibility" value="logged_in" <%= (!values.visibility || values.visibility === 'logged_in') ? 'checked' : '' %>>
|
||||||
|
Logged in only — members only
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio" name="visibility" value="private" <%= (values.visibility === 'private') ? 'checked' : '' %>>
|
||||||
|
Private — only you
|
||||||
|
</label>
|
||||||
|
<p class="muted small">Note: adding a more-restrictive song will automatically upgrade the playlist's visibility.</p>
|
||||||
|
</fieldset>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="submit" class="btn-primary"><%= playlist ? 'Save' : 'Create' %></button>
|
<button type="submit" class="btn-primary"><%= playlist ? 'Save' : 'Create' %></button>
|
||||||
<a href="/admin/playlists" class="btn-link">Cancel</a>
|
<a href="/admin/playlists" class="btn-link">Cancel</a>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div class="row-between">
|
<div class="row-between">
|
||||||
<h1>Admin · Playlists</h1>
|
<h1>My Music · Playlists</h1>
|
||||||
<a href="/admin/playlists/new" class="btn-primary">New playlist</a>
|
<a href="/admin/playlists/new" class="btn-primary">New playlist</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -12,12 +12,20 @@
|
||||||
<p class="muted">No playlists yet.</p>
|
<p class="muted">No playlists yet.</p>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead><tr><th>Title</th><th>Public</th><th>Created</th><th></th></tr></thead>
|
<thead><tr><th>Title</th><th>Visibility</th><th>Created</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% playlists.forEach((p) => { %>
|
<% playlists.forEach((p) => { %>
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/playlists/<%= p.id %>"><%= p.title %></a></td>
|
<td><a href="/playlists/<%= p.id %>"><%= p.title %></a></td>
|
||||||
<td><%= p.isPublic ? 'yes' : 'no' %></td>
|
<td>
|
||||||
|
<% if (p.visibility === 'logged_in') { %>
|
||||||
|
<span class="badge badge-logged-in">Members</span>
|
||||||
|
<% } else if (p.visibility === 'private') { %>
|
||||||
|
<span class="badge badge-private">Private</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="badge badge-public">Public</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
<td class="muted small"><%= p.createdAt %></td>
|
<td class="muted small"><%= p.createdAt %></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a href="/admin/playlists/<%= p.id %>/edit">Edit</a>
|
<a href="/admin/playlists/<%= p.id %>/edit">Edit</a>
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,21 @@
|
||||||
<p class="muted small">Audio and cover can't be replaced from this form yet — delete and re-upload to change them.</p>
|
<p class="muted small">Audio and cover can't be replaced from this form yet — delete and re-upload to change them.</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<label class="checkbox">
|
<fieldset class="visibility-field">
|
||||||
<input type="checkbox" name="is_public" <%= values.is_public ? 'checked' : '' %>>
|
<legend>Visibility</legend>
|
||||||
Public (visible to everyone)
|
<label class="checkbox">
|
||||||
</label>
|
<input type="radio" name="visibility" value="public" <%= (values.visibility === 'public') ? 'checked' : '' %>>
|
||||||
|
Public — visible to everyone
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio" name="visibility" value="logged_in" <%= (!values.visibility || values.visibility === 'logged_in') ? 'checked' : '' %>>
|
||||||
|
Logged in only — members only
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio" name="visibility" value="private" <%= (values.visibility === 'private') ? 'checked' : '' %>>
|
||||||
|
Private — only you
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="submit" class="btn-primary"><%= song ? 'Save' : 'Upload' %></button>
|
<button type="submit" class="btn-primary"><%= song ? 'Save' : 'Upload' %></button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div class="row-between">
|
<div class="row-between">
|
||||||
<h1>Admin · Songs</h1>
|
<h1>My Music · Songs</h1>
|
||||||
<a href="/admin/songs/new" class="btn-primary">Upload song</a>
|
<a href="/admin/songs/new" class="btn-primary">Upload song</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -12,14 +12,22 @@
|
||||||
<p class="muted">No songs yet.</p>
|
<p class="muted">No songs yet.</p>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead><tr><th>Title</th><th>Artist</th><th>Album</th><th>Public</th><th></th></tr></thead>
|
<thead><tr><th>Title</th><th>Artist</th><th>Album</th><th>Visibility</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% songs.forEach((s) => { %>
|
<% songs.forEach((s) => { %>
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/songs/<%= s.id %>"><%= s.title %></a></td>
|
<td><a href="/songs/<%= s.id %>"><%= s.title %></a></td>
|
||||||
<td><%= s.artist %></td>
|
<td><%= s.artist %></td>
|
||||||
<td><%= s.album || '' %></td>
|
<td><%= s.album || '' %></td>
|
||||||
<td><%= s.isPublic ? 'yes' : 'no' %></td>
|
<td>
|
||||||
|
<% if (s.visibility === 'logged_in') { %>
|
||||||
|
<span class="badge badge-logged-in">Members</span>
|
||||||
|
<% } else if (s.visibility === 'private') { %>
|
||||||
|
<span class="badge badge-private">Private</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="badge badge-public">Public</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a href="/admin/songs/<%= s.id %>/edit">Edit</a>
|
<a href="/admin/songs/<%= s.id %>/edit">Edit</a>
|
||||||
<form method="post" action="/admin/songs/<%= s.id %>/delete" class="inline-form" onsubmit="return confirm('Delete this song?');">
|
<form method="post" action="/admin/songs/<%= s.id %>/delete" class="inline-form" onsubmit="return confirm('Delete this song?');">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@
|
||||||
<title><%= typeof title !== 'undefined' ? title : 'Bri-Tunes' %></title>
|
<title><%= typeof title !== 'undefined' ? title : 'Bri-Tunes' %></title>
|
||||||
<link rel="preload" href="/static/vendor/fonts/Orbitron.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/static/vendor/fonts/Orbitron.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="preload" href="/static/vendor/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/static/vendor/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="icon" href="/static/favicon.ico" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="/static/site.webmanifest">
|
||||||
<link rel="stylesheet" href="/static/css/app.css">
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{ "imports": { "three": "/static/vendor/three/three.module.min.js" } }
|
{ "imports": { "three": "/static/vendor/three/three.module.min.js" } }
|
||||||
|
|
@ -18,14 +23,16 @@
|
||||||
<div class="boot-inner" id="boot-text"></div>
|
<div class="boot-inner" id="boot-text"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%- include('partials/header') %>
|
<div id="page-shell">
|
||||||
<%- include('partials/flash') %>
|
<%- include('partials/header') %>
|
||||||
<main class="container">
|
<%- include('partials/flash') %>
|
||||||
<%- body %>
|
<main class="container">
|
||||||
</main>
|
<%- body %>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
<%- include('partials/player') %>
|
<%- include('partials/player') %>
|
||||||
|
|
||||||
<script src="/static/vendor/htmx.min.js" defer></script>
|
<script src="/static/js/navigate.js" defer></script>
|
||||||
<script src="/static/js/player.js" defer></script>
|
<script src="/static/js/player.js" defer></script>
|
||||||
<script type="module" src="/static/js/bg-grid.js"></script>
|
<script type="module" src="/static/js/bg-grid.js"></script>
|
||||||
<script type="module" src="/static/js/player-viz.js"></script>
|
<script type="module" src="/static/js/player-viz.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@
|
||||||
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
|
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
|
||||||
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
|
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
|
||||||
<% if (user) { %>
|
<% if (user) { %>
|
||||||
<% if (user.role === 'admin') { %>
|
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') ? 'active' : '' %>">My Music</a>
|
||||||
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') ? 'active' : '' %>">Admin</a>
|
|
||||||
<% } %>
|
|
||||||
<a href="/account">Account</a>
|
<a href="/account">Account</a>
|
||||||
<form method="post" action="/logout" class="inline-form">
|
<form method="post" action="/logout" class="inline-form">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
<% } %>
|
<% } %>
|
||||||
<div class="song-main">
|
<div class="song-main">
|
||||||
<a class="song-title" href="/songs/<%= s.id %>"><%= s.title %></a>
|
<a class="song-title" href="/songs/<%= s.id %>"><%= s.title %></a>
|
||||||
|
<% if (s.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
|
||||||
|
<% if (s.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
|
||||||
<div class="muted small"><%= s.artist %><% if (s.album) { %> · <%= s.album %><% } %></div>
|
<div class="muted small"><%= s.artist %><% if (s.album) { %> · <%= s.album %><% } %></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="song-meta muted small">
|
<div class="song-meta muted small">
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@
|
||||||
<div class="cover-fallback">🎧</div>
|
<div class="cover-fallback">🎧</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title"><%= p.title %></div>
|
<div class="card-title"><%= p.title %>
|
||||||
|
<% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
|
||||||
|
<% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
|
||||||
|
</div>
|
||||||
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
|
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@
|
||||||
<div class="cover-fallback">🎧</div>
|
<div class="cover-fallback">🎧</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title"><%= p.title %></div>
|
<div class="card-title"><%= p.title %>
|
||||||
|
<% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
|
||||||
|
<% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
|
||||||
|
</div>
|
||||||
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
|
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@
|
||||||
<div class="song-detail-cover cover-fallback">♪</div>
|
<div class="song-detail-cover cover-fallback">♪</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<div>
|
<div>
|
||||||
<h1><%= song.title %></h1>
|
<h1><%= song.title %>
|
||||||
|
<% if (song.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members only</span><% } %>
|
||||||
|
<% if (song.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
|
||||||
|
</h1>
|
||||||
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
|
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
|
||||||
<p class="muted small">
|
<p class="muted small">
|
||||||
<% if (song.genre) { %><%= song.genre %> · <% } %>
|
<% if (song.genre) { %><%= song.genre %> · <% } %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue