feat: add song playlist appearances section and edit buttons for creators

- Introduce a new "Appears in" section on song pages showing playlists containing the song (public/logged_in visibility)
- Add secondary button styling for Edit links on song and playlist detail pages, visible only to creators
- Implement `getPlaylistsForSong()` service function with guest/user-aware queries
- Update view templates to render edit buttons and playlist appearance cards
This commit is contained in:
Brian Fertig 2026-04-13 21:08:22 -06:00
parent 428cfb1008
commit c71538b45d
5 changed files with 98 additions and 4 deletions

View File

@ -96,7 +96,7 @@ main.container,
.flash { position: relative; z-index: 4; }
/* ---------- Typography ---------- */
h1, h2, h3, .brand, .card-title, .tabs a, .data-table th, .btn-primary, .btn-small, .btn-play {
h1, h2, h3, .brand, .card-title, .tabs a, .data-table th, .btn-primary, .btn-secondary, .btn-small, .btn-play {
font-family: 'Orbitron', 'Inter', sans-serif;
letter-spacing: 0.08em;
text-transform: uppercase;
@ -172,6 +172,24 @@ a:hover { color: #fff; text-shadow: var(--glow-cyan); text-decoration: none; }
}
.btn-small { padding: 0.35rem 0.75rem; font-size: 0.72rem; }
.btn-secondary {
background: transparent;
color: var(--muted);
border: 1px solid var(--muted);
padding: 0.55rem 1rem;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.82rem;
cursor: pointer;
transition: transform .12s, background .15s, color .15s, box-shadow .15s;
}
.btn-secondary:hover {
color: var(--fg);
border-color: var(--fg);
transform: translateY(-1px);
text-decoration: none;
}
.btn-link { color: var(--muted); margin-left: 0.75rem; font-size: 0.82rem; }
.btn-link:hover { color: var(--neon-magenta); text-shadow: var(--glow-magenta); }
@ -467,6 +485,36 @@ a.detail-creator-card:hover {
.detail-creator-label { font-size: 0.7rem; color: var(--muted); margin: 0; text-transform: uppercase; letter-spacing: 0.05em; }
.detail-creator-name { font-size: 0.82rem; color: var(--fg); margin: 0; font-weight: 600; }
/* ---------- Appears-in (song → playlists) ---------- */
.song-appears-in { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
.appears-in-heading {
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.12em;
color: var(--muted); margin: 0 0 1rem;
}
.appears-in-list {
list-style: none; margin: 0; padding: 0;
display: flex; flex-wrap: wrap; gap: 0.75rem;
}
.appears-in-card {
display: flex; align-items: center; gap: 0.6rem;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.5rem 0.75rem;
text-decoration: none; color: var(--fg);
transition: border-color .15s, background .15s;
}
.appears-in-card:hover {
border-color: var(--neon-cyan); background: var(--bg-inset);
color: var(--fg); text-decoration: none;
}
.appears-in-cover {
width: 36px; height: 36px; border-radius: 4px; object-fit: cover; flex-shrink: 0;
}
.appears-in-cover--fallback {
display: flex; align-items: center; justify-content: center;
background: var(--bg-inset); font-size: 1rem;
}
.appears-in-title { font-size: 0.85rem; font-weight: 600; }
/* ---------- Forms ---------- */
.form-card {
background: var(--bg-panel);
@ -1762,6 +1810,6 @@ a.detail-creator-card:hover {
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
.flash { animation: none; }
.card, .btn-primary, .btn-small, .btn-play, #player-next { transition: none; }
.card, .btn-primary, .btn-secondary, .btn-small, .btn-play, #player-next { transition: none; }
#boot-overlay { display: none; }
}

View File

@ -78,13 +78,14 @@ router.get('/songs/:slug', (req, res, next) => {
const userId = req.session.user?.id || null;
social.enrichSongs([song], userId);
const creator = song.uploadedBy ? users.findById(song.uploadedBy) : null;
const songPlaylists = playlists.getPlaylistsForSong(song.id, !!req.session.user);
const og = {
title: `${res.locals.siteName}: ${song.title} by ${song.artist}`,
description: `Check out ${song.artist}'s hit ${song.title}, available on ${res.locals.siteName}`,
image: song.coverPath ? `${res.locals.appBaseUrl}/media/${song.coverPath}` : null,
url: `${res.locals.appBaseUrl}/songs/${song.slug}`,
};
res.render('public/song', { title: song.title, song, creator, og });
res.render('public/song', { title: song.title, song, creator, songPlaylists, og });
});
router.get('/playlists', (req, res) => {

View File

@ -264,8 +264,26 @@ function listPublicByUser(userId, loggedIn = false) {
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
}
// Playlists that contain a given song and are visible to the viewer.
const forSongGuestStmt = db.prepare(`
SELECT p.* FROM playlists p
JOIN playlist_songs ps ON ps.playlist_id = p.id
WHERE ps.song_id = ? AND p.visibility = 'public'
ORDER BY p.created_at DESC
`);
const forSongUserStmt = db.prepare(`
SELECT p.* FROM playlists p
JOIN playlist_songs ps ON ps.playlist_id = p.id
WHERE ps.song_id = ? AND p.visibility IN ('public', 'logged_in')
ORDER BY p.created_at DESC
`);
function getPlaylistsForSong(songId, loggedIn = false) {
return (loggedIn ? forSongUserStmt : forSongGuestStmt).all(songId).map(toView);
}
module.exports = {
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
listVisible, listAll, listByUser, listPublicByUser, featured,
getTracks, trackCount, addTrack, removeTrack, setOrder,
getTracks, trackCount, addTrack, removeTrack, setOrder, getPlaylistsForSong,
};

View File

@ -39,6 +39,9 @@
data-shuffle-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
⇄ Shuffle all
</button>
<% if (user && playlist.createdBy === user.id) { %>
<a href="/mymusic/playlists/<%= playlist.id %>/edit" class="btn-secondary">✎ Edit</a>
<% } %>
<a href="/playlists" class="btn-link">← Back</a>
</div>
<button type="button" id="np-reopen-btn" class="btn-nowplaying" style="display:none"

View File

@ -44,6 +44,9 @@
data-nowplaying-meta="<%= JSON.stringify(npMeta) %>">
▶ Play
</button>
<% if (user && song.uploadedBy === user.id) { %>
<a href="/mymusic/songs/<%= song.id %>/edit" class="btn-secondary">✎ Edit</a>
<% } %>
<a href="/songs" class="btn-link">← Back to songs</a>
<button type="button" id="np-reopen-btn" class="btn-nowplaying" style="display:none"
data-match-song-id="<%= song.id %>">
@ -167,3 +170,24 @@
<% } %>
</div>
</div>
<% if (songPlaylists && songPlaylists.length > 0) { %>
<section class="song-appears-in">
<h2 class="appears-in-heading">Appears in</h2>
<ul class="appears-in-list">
<% songPlaylists.forEach((pl) => { %>
<li>
<a href="/playlists/<%= pl.slug %>" class="appears-in-card">
<% if (pl.coverPath) { %>
<img src="/media/<%= pl.coverPath %>" alt="" class="appears-in-cover">
<% } else { %>
<div class="appears-in-cover appears-in-cover--fallback">🎧</div>
<% } %>
<span class="appears-in-title"><%= pl.title %></span>
<% if (pl.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members only</span><% } %>
</a>
</li>
<% }) %>
</ul>
</section>
<% } %>