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:
parent
428cfb1008
commit
c71538b45d
|
|
@ -96,7 +96,7 @@ main.container,
|
||||||
.flash { position: relative; z-index: 4; }
|
.flash { position: relative; z-index: 4; }
|
||||||
|
|
||||||
/* ---------- Typography ---------- */
|
/* ---------- 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;
|
font-family: 'Orbitron', 'Inter', sans-serif;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
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-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 { color: var(--muted); margin-left: 0.75rem; font-size: 0.82rem; }
|
||||||
.btn-link:hover { color: var(--neon-magenta); text-shadow: var(--glow-magenta); }
|
.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-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; }
|
.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 ---------- */
|
/* ---------- Forms ---------- */
|
||||||
.form-card {
|
.form-card {
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
|
|
@ -1762,6 +1810,6 @@ a.detail-creator-card:hover {
|
||||||
/* ---------- Reduced motion ---------- */
|
/* ---------- Reduced motion ---------- */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.flash { animation: none; }
|
.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; }
|
#boot-overlay { display: none; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,13 +78,14 @@ router.get('/songs/:slug', (req, res, next) => {
|
||||||
const userId = req.session.user?.id || null;
|
const userId = req.session.user?.id || null;
|
||||||
social.enrichSongs([song], userId);
|
social.enrichSongs([song], userId);
|
||||||
const creator = song.uploadedBy ? users.findById(song.uploadedBy) : null;
|
const creator = song.uploadedBy ? users.findById(song.uploadedBy) : null;
|
||||||
|
const songPlaylists = playlists.getPlaylistsForSong(song.id, !!req.session.user);
|
||||||
const og = {
|
const og = {
|
||||||
title: `${res.locals.siteName}: ${song.title} by ${song.artist}`,
|
title: `${res.locals.siteName}: ${song.title} by ${song.artist}`,
|
||||||
description: `Check out ${song.artist}'s hit ${song.title}, available on ${res.locals.siteName}`,
|
description: `Check out ${song.artist}'s hit ${song.title}, available on ${res.locals.siteName}`,
|
||||||
image: song.coverPath ? `${res.locals.appBaseUrl}/media/${song.coverPath}` : null,
|
image: song.coverPath ? `${res.locals.appBaseUrl}/media/${song.coverPath}` : null,
|
||||||
url: `${res.locals.appBaseUrl}/songs/${song.slug}`,
|
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) => {
|
router.get('/playlists', (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -264,8 +264,26 @@ function listPublicByUser(userId, loggedIn = false) {
|
||||||
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
|
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 = {
|
module.exports = {
|
||||||
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
|
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
|
||||||
listVisible, listAll, listByUser, listPublicByUser, featured,
|
listVisible, listAll, listByUser, listPublicByUser, featured,
|
||||||
getTracks, trackCount, addTrack, removeTrack, setOrder,
|
getTracks, trackCount, addTrack, removeTrack, setOrder, getPlaylistsForSong,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 }))) %>">
|
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
|
⇄ Shuffle all
|
||||||
</button>
|
</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>
|
<a href="/playlists" class="btn-link">← Back</a>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" id="np-reopen-btn" class="btn-nowplaying" style="display:none"
|
<button type="button" id="np-reopen-btn" class="btn-nowplaying" style="display:none"
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@
|
||||||
data-nowplaying-meta="<%= JSON.stringify(npMeta) %>">
|
data-nowplaying-meta="<%= JSON.stringify(npMeta) %>">
|
||||||
▶ Play
|
▶ Play
|
||||||
</button>
|
</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>
|
<a href="/songs" class="btn-link">← Back to songs</a>
|
||||||
<button type="button" id="np-reopen-btn" class="btn-nowplaying" style="display:none"
|
<button type="button" id="np-reopen-btn" class="btn-nowplaying" style="display:none"
|
||||||
data-match-song-id="<%= song.id %>">
|
data-match-song-id="<%= song.id %>">
|
||||||
|
|
@ -167,3 +170,24 @@
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
<% } %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue