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:
Brian Fertig 2026-04-09 22:40:18 -06:00
parent be25e35230
commit b4af77066a
25 changed files with 669 additions and 102 deletions

200
PWA.md Normal file
View File

@ -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.

View File

@ -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

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -121,6 +121,7 @@ a:hover { color: #fff; text-shadow: var(--glow-cyan); text-decoration: none; }
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
position: sticky; top: 0;
z-index: 20;
}
.header-inner {
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-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 { list-style: none; margin: 0; padding: 0; }
.song-row {

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

98
public/js/navigate.js Normal file
View File

@ -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));
})();

1
public/site.webmanifest Normal file
View File

@ -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"}

View File

@ -5,7 +5,7 @@ const express = require('express');
const multer = require('multer');
const { z } = require('zod');
const { requireAdmin } = require('../middleware/auth');
const { requireUser, requireAdmin } = require('../middleware/auth');
const songs = require('../services/songs');
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 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.
// The edit form enforces title/artist via a stricter schema below.
const songUploadSchema = z.object({
@ -46,7 +49,7 @@ const songUploadSchema = z.object({
album: z.string().trim().max(200).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()),
is_public: z.any().optional(),
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
});
const songEditSchema = z.object({
@ -55,13 +58,13 @@ const songEditSchema = z.object({
album: z.string().trim().max(200).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()),
is_public: z.any().optional(),
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
});
const playlistSchema = z.object({
title: z.string().trim().min(1).max(200),
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.
@ -129,19 +132,34 @@ function unlinkQuiet(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) {
const router = express.Router();
router.use(requireAdmin);
router.use(requireUser);
// ---------- Songs ----------
router.get('/', (req, res) => res.redirect('/admin/songs'));
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) => {
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) => {
@ -191,7 +209,7 @@ module.exports = function adminRoutes(csrfProtection) {
durationSeconds: tags.duration,
audioPath: relMedia(audioFile, 'audio'),
coverPath,
isPublic: !!parsed.data.is_public,
visibility: parsed.data.visibility,
uploadedBy: req.session.user.id,
});
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) => {
const song = songs.findById(Number(req.params.id));
if (!song) return next();
if (!canManage(song, req.session.user)) return forbidden(next);
res.render('admin/song_form', {
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 existing = songs.findById(id);
if (!existing) return next();
if (!canManage(existing, req.session.user)) return forbidden(next);
const parsed = songEditSchema.safeParse(req.body);
if (!parsed.success) {
const errors = {};
@ -225,7 +245,7 @@ module.exports = function adminRoutes(csrfProtection) {
album: parsed.data.album || null,
genre: parsed.data.genre || null,
year: parsed.data.year ?? null,
isPublic: !!parsed.data.is_public,
visibility: parsed.data.visibility,
});
req.flash('success', 'Song updated.');
res.redirect('/admin/songs');
@ -237,6 +257,7 @@ module.exports = function adminRoutes(csrfProtection) {
const id = Number(req.params.id);
const existing = songs.findById(id);
if (!existing) return next();
if (!canManage(existing, req.session.user)) return forbidden(next);
songs.remove(id);
// Best-effort cleanup of media.
if (existing.audioPath) unlinkQuiet(path.join(mediaDir, existing.audioPath));
@ -248,28 +269,34 @@ module.exports = function adminRoutes(csrfProtection) {
// ---------- Playlists ----------
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) => {
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) => {
try {
const user = req.session.user;
const parsed = playlistSchema.safeParse(req.body);
if (!parsed.success) {
const errors = {};
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
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 created = playlists.create({
title: parsed.data.title,
description: parsed.data.description || null,
coverPath: coverFile ? relMedia(coverFile, 'covers') : null,
isPublic: !!parsed.data.is_public,
visibility: parsed.data.visibility,
createdBy: req.session.user.id,
});
req.flash('success', 'Playlist created.');
@ -281,13 +308,16 @@ module.exports = function adminRoutes(csrfProtection) {
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 user = req.session.user;
const allSongs = user.role === 'admin' ? songs.listAll() : songs.listForManagement(user.id);
res.render('admin/playlist_form', {
title: 'Edit playlist',
playlist,
tracks: playlists.getTracks(id),
allSongs: songs.listAll(),
allSongs,
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 existing = playlists.findById(id);
if (!existing) return next();
if (!canManage(existing, req.session.user)) return forbidden(next);
const user = req.session.user;
const parsed = playlistSchema.safeParse(req.body);
if (!parsed.success) {
const errors = {};
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
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', {
title: 'Edit playlist', playlist: existing,
tracks: playlists.getTracks(id), allSongs: songs.listAll(),
tracks: playlists.getTracks(id), allSongs,
errors, values: req.body,
});
}
playlists.update(id, {
title: parsed.data.title,
description: parsed.data.description || null,
isPublic: !!parsed.data.is_public,
visibility: parsed.data.visibility,
});
const coverFile = req.files?.cover?.[0];
if (coverFile) {
@ -326,8 +359,15 @@ module.exports = function adminRoutes(csrfProtection) {
try {
const id = Number(req.params.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);
// 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`);
} catch (err) { next(err); }
});
@ -336,6 +376,9 @@ module.exports = function adminRoutes(csrfProtection) {
try {
const id = Number(req.params.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);
res.redirect(`/admin/playlists/${id}/edit`);
} catch (err) { next(err); }
@ -344,6 +387,9 @@ module.exports = function adminRoutes(csrfProtection) {
router.post('/playlists/:id/reorder', csrfProtection, (req, res, next) => {
try {
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 || '')
.split(',').map((x) => Number(x.trim())).filter(Number.isFinite);
playlists.setOrder(id, order);
@ -356,6 +402,7 @@ module.exports = function adminRoutes(csrfProtection) {
const id = Number(req.params.id);
const existing = playlists.findById(id);
if (!existing) return next();
if (!canManage(existing, req.session.user)) return forbidden(next);
playlists.remove(id);
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
req.flash('success', 'Playlist deleted.');

View File

@ -15,21 +15,43 @@ const MIME = {
'.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) => {
const loggedIn = !!req.session.user;
res.render('public/home', {
title: 'Tunes',
featured: playlists.featured(6),
recent: songs.recentPublic(8),
featured: playlists.featured(6, loggedIn),
recent: songs.recent(8, loggedIn),
});
});
router.get('/songs', (req, res) => {
const loggedIn = !!req.session.user;
const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = 25;
const { rows, total } = songs.listPublic({
const { rows, total } = songs.list({
q: req.query.q || '',
limit: pageSize,
offset: (page - 1) * pageSize,
loggedIn,
});
res.render('public/songs', {
title: 'Songs',
@ -44,29 +66,39 @@ router.get('/songs', (req, res) => {
router.get('/songs/:id', (req, res, next) => {
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 });
});
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) => {
const id = Number(req.params.id);
const playlist = playlists.findById(id);
if (!playlist || !playlist.isPublic) return next();
res.render('public/playlist', {
title: playlist.title,
playlist,
tracks: playlists.getTracks(id),
});
if (!playlist) return next();
const userId = req.session.user?.id;
// Use effective visibility (most restrictive of playlist + all its tracks).
const effectiveVisibility = playlists.getEffectiveVisibility(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.
router.get('/stream/:id', (req, res, next) => {
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);
fs.stat(absPath, (err, stat) => {
if (err) return next();

View File

@ -1,20 +1,78 @@
const { db } = require('../../db');
const insertStmt = db.prepare(`
INSERT INTO playlists (title, description, cover_path, is_public, created_by)
VALUES (@title, @description, @cover_path, @is_public, @created_by)
INSERT INTO playlists (title, description, cover_path, visibility, created_by)
VALUES (@title, @description, @cover_path, @visibility, @created_by)
`);
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
`);
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 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 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(`
SELECT s.*, ps.position FROM playlist_songs ps
@ -35,7 +93,7 @@ function toView(row) {
title: row.title,
description: row.description,
coverPath: row.cover_path,
isPublic: !!row.is_public,
visibility: row.visibility || 'logged_in',
createdBy: row.created_by,
createdAt: row.created_at,
};
@ -49,6 +107,8 @@ function trackView(row) {
album: row.album,
durationSeconds: row.duration_seconds,
coverPath: row.cover_path,
visibility: row.visibility || 'logged_in',
uploadedBy: row.uploaded_by,
position: row.position,
};
}
@ -58,7 +118,7 @@ function create(data) {
title: data.title,
description: data.description || null,
cover_path: data.coverPath || null,
is_public: data.isPublic ? 1 : 0,
visibility: data.visibility || 'logged_in',
created_by: data.createdBy || null,
});
return toView(findByIdStmt.get(info.lastInsertRowid));
@ -69,7 +129,7 @@ function update(id, data) {
id,
title: data.title,
description: data.description || null,
is_public: data.isPublic ? 1 : 0,
visibility: data.visibility || 'logged_in',
});
return toView(findByIdStmt.get(id));
}
@ -78,6 +138,10 @@ function setCover(id, coverPath) {
updateCoverStmt.run(coverPath, id);
}
function setVisibility(id, visibility) {
setVisibilityStmt.run(visibility, id);
}
function remove(id) {
deleteStmt.run(id);
}
@ -86,16 +150,25 @@ function findById(id) {
return toView(findByIdStmt.get(id));
}
function listPublic() {
return listPublicStmt.all().map(toView);
function getEffectiveVisibility(id) {
const row = effectiveVisibilityStmt.get(id);
return row ? row.effective_visibility : null;
}
function listVisible(loggedIn = false) {
return (loggedIn ? listUserStmt : listGuestStmt).all().map(toView);
}
function listAll() {
return listAllStmt.all().map(toView);
}
function featured(n = 6) {
return featuredStmt.all(n).map(toView);
function listByUser(userId) {
return listByUserStmt.all(userId).map(toView);
}
function featured(n = 6, loggedIn = false) {
return (loggedIn ? featuredUserStmt : featuredGuestStmt).all(n).map(toView);
}
function getTracks(playlistId) {
@ -124,7 +197,7 @@ function setOrder(playlistId, songIds) {
}
module.exports = {
create, update, setCover, remove, findById,
listPublic, listAll, featured,
create, update, setCover, setVisibility, remove, findById, getEffectiveVisibility,
listVisible, listAll, listByUser, featured,
getTracks, trackCount, addTrack, removeTrack, setOrder,
};

View File

@ -1,35 +1,39 @@
const { db } = require('../../db');
const insertStmt = db.prepare(`
INSERT INTO songs (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, @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, @visibility, @uploaded_by)
`);
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
`);
const updateCoverStmt = db.prepare('UPDATE songs SET cover_path = ? WHERE id = ?');
const deleteStmt = db.prepare('DELETE FROM songs WHERE id = ?');
const findByIdStmt = db.prepare('SELECT * FROM songs WHERE id = ?');
const listPublicStmt = db.prepare(`
SELECT * FROM songs WHERE is_public = 1
ORDER BY created_at DESC LIMIT ? OFFSET ?
`);
const countPublicStmt = db.prepare('SELECT COUNT(*) AS c FROM songs WHERE is_public = 1');
// Guest (not logged in): public only
const listGuestStmt = db.prepare(`SELECT * FROM songs WHERE visibility = 'public' 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 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(`
SELECT * FROM songs
WHERE is_public = 1 AND (title LIKE @q OR artist LIKE @q OR album LIKE @q)
ORDER BY created_at DESC LIMIT @limit OFFSET @offset
`);
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)
`);
// Logged-in user: public + logged_in
const listUserStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in') ORDER BY created_at DESC LIMIT ? OFFSET ?`);
const countUserStmt = db.prepare(`SELECT COUNT(*) AS c FROM songs WHERE visibility IN ('public', 'logged_in')`);
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 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) {
if (!row) return null;
@ -43,7 +47,7 @@ function toView(row) {
durationSeconds: row.duration_seconds,
audioPath: row.audio_path,
coverPath: row.cover_path,
isPublic: !!row.is_public,
visibility: row.visibility || 'logged_in',
uploadedBy: row.uploaded_by,
createdAt: row.created_at,
};
@ -59,7 +63,7 @@ function create(data) {
duration_seconds: data.durationSeconds || null,
audio_path: data.audioPath,
cover_path: data.coverPath || null,
is_public: data.isPublic ? 1 : 0,
visibility: data.visibility || 'logged_in',
uploaded_by: data.uploadedBy || null,
});
return toView(findByIdStmt.get(info.lastInsertRowid));
@ -73,7 +77,7 @@ function update(id, data) {
album: data.album || null,
genre: data.genre || null,
year: data.year || null,
is_public: data.isPublic ? 1 : 0,
visibility: data.visibility || 'logged_in',
});
return toView(findByIdStmt.get(id));
}
@ -92,24 +96,35 @@ function findById(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()) {
const like = `%${q.trim()}%`;
const rows = searchPublicStmt.all({ q: like, limit, offset });
const total = countSearchPublicStmt.get({ q: like }).c;
return { rows: rows.map(toView), total };
if (loggedIn) {
return { rows: searchUserStmt.all({ q: like, limit, offset }).map(toView), total: countSearchUserStmt.get({ q: like }).c };
}
return { rows: searchGuestStmt.all({ q: like, limit, offset }).map(toView), total: countSearchGuestStmt.get({ q: like }).c };
}
const rows = listPublicStmt.all(limit, offset);
const total = countPublicStmt.get().c;
return { rows: rows.map(toView), total };
if (loggedIn) {
return { rows: listUserStmt.all(limit, offset).map(toView), total: countUserStmt.get().c };
}
return { rows: listGuestStmt.all(limit, offset).map(toView), total: countGuestStmt.get().c };
}
function listAll() {
return listAllStmt.all().map(toView);
}
function recentPublic(n = 8) {
return recentPublicStmt.all(n).map(toView);
function listByUser(userId) {
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 };

View File

@ -17,10 +17,22 @@
Cover art
<input type="file" name="cover" accept="image/*">
</label>
<label class="checkbox">
<input type="checkbox" name="is_public" <%= values.is_public ? 'checked' : '' %>>
Public
</label>
<fieldset class="visibility-field">
<legend>Visibility</legend>
<label class="checkbox">
<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">
<button type="submit" class="btn-primary"><%= playlist ? 'Save' : 'Create' %></button>
<a href="/admin/playlists" class="btn-link">Cancel</a>

View File

@ -1,5 +1,5 @@
<div class="row-between">
<h1>Admin · Playlists</h1>
<h1>My Music · Playlists</h1>
<a href="/admin/playlists/new" class="btn-primary">New playlist</a>
</div>
@ -12,12 +12,20 @@
<p class="muted">No playlists yet.</p>
<% } else { %>
<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>
<% playlists.forEach((p) => { %>
<tr>
<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="actions">
<a href="/admin/playlists/<%= p.id %>/edit">Edit</a>

View File

@ -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>
<% } %>
<label class="checkbox">
<input type="checkbox" name="is_public" <%= values.is_public ? 'checked' : '' %>>
Public (visible to everyone)
</label>
<fieldset class="visibility-field">
<legend>Visibility</legend>
<label class="checkbox">
<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">
<button type="submit" class="btn-primary"><%= song ? 'Save' : 'Upload' %></button>

View File

@ -1,5 +1,5 @@
<div class="row-between">
<h1>Admin · Songs</h1>
<h1>My Music · Songs</h1>
<a href="/admin/songs/new" class="btn-primary">Upload song</a>
</div>
@ -12,14 +12,22 @@
<p class="muted">No songs yet.</p>
<% } else { %>
<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>
<% songs.forEach((s) => { %>
<tr>
<td><a href="/songs/<%= s.id %>"><%= s.title %></a></td>
<td><%= s.artist %></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">
<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?');">

View File

@ -6,6 +6,11 @@
<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/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">
<script type="importmap">
{ "imports": { "three": "/static/vendor/three/three.module.min.js" } }
@ -18,14 +23,16 @@
<div class="boot-inner" id="boot-text"></div>
</div>
<%- include('partials/header') %>
<%- include('partials/flash') %>
<main class="container">
<%- body %>
</main>
<div id="page-shell">
<%- include('partials/header') %>
<%- include('partials/flash') %>
<main class="container">
<%- body %>
</main>
</div>
<%- 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 type="module" src="/static/js/bg-grid.js"></script>
<script type="module" src="/static/js/player-viz.js"></script>

View File

@ -5,9 +5,7 @@
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
<% if (user) { %>
<% if (user.role === 'admin') { %>
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') ? 'active' : '' %>">Admin</a>
<% } %>
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') ? 'active' : '' %>">My Music</a>
<a href="/account">Account</a>
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">

View File

@ -8,6 +8,8 @@
<% } %>
<div class="song-main">
<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>
<div class="song-meta muted small">

View File

@ -17,7 +17,10 @@
<div class="cover-fallback">🎧</div>
<% } %>
<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><% } %>
</div>
</a>

View File

@ -11,7 +11,10 @@
<div class="cover-fallback">🎧</div>
<% } %>
<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><% } %>
</div>
</a>

View File

@ -5,7 +5,10 @@
<div class="song-detail-cover cover-fallback">♪</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 small">
<% if (song.genre) { %><%= song.genre %> · <% } %>