feat: add user profiles with slugs and enhance now-playing UI
- Add database migration to create unique `slug` column for users table - Implement profile routes (`/profiles`, `/profiles/:slug`) with views for: • listing verified user profiles with avatars, song/playlist counts • individual profile pages showing user's songs, playlists, liked items - Add helper functions in `users.js`, `songs.js`, `playlists.js`, and `social.js` to support public visibility filtering and profile data enrichment - Enhance now-playing overlay UI with: • vinyl sleeve animation (sleeve + record peeking behind cover) • clickable creator info to open profile page if slug exists • track row click to jump directly to a song in the queue - Update CSS for profiles grid, detail creator links, and overlay styling - Add "Profiles" link to header navigation This enables a full user profile system with vanity URLs and improves the music player experience with visual polish and direct navigation.
This commit is contained in:
parent
a4c1595eaa
commit
25d68e1dcb
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE users ADD COLUMN slug TEXT;
|
||||
CREATE UNIQUE INDEX users_slug_unique ON users (slug);
|
||||
|
|
@ -387,6 +387,14 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
|||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0.35rem; flex-shrink: 0; text-align: center;
|
||||
min-width: 80px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
a.detail-creator-card:hover {
|
||||
background: var(--bg-panel);
|
||||
box-shadow: 0 0 0 1px var(--neon-cyan), 0 0 10px rgba(0, 255, 255, 0.2);
|
||||
}
|
||||
.detail-creator-avatar {
|
||||
width: 72px; height: 72px;
|
||||
|
|
@ -581,7 +589,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
|||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0);
|
||||
z-index: 2000;
|
||||
z-index: 3000;
|
||||
align-items: center; justify-content: center;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
|
@ -656,7 +664,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
|||
box-shadow: 0 0 0 1px var(--border), 0 0 40px rgba(143,91,255,0.45);
|
||||
width: min(940px, 92vw);
|
||||
height: min(640px, 85vh);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
#np-overlay.np-open #np-modal { animation: lb-zoom-in 0.3s cubic-bezier(0.34, 1.4, 0.64, 1) forwards; }
|
||||
#np-overlay.np-closing #np-modal { animation: lb-zoom-out 0.2s ease forwards; }
|
||||
|
|
@ -675,20 +683,62 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
|||
#np-left {
|
||||
width: 38%; flex-shrink: 0;
|
||||
display: flex; flex-direction: column;
|
||||
background: #000; overflow: hidden;
|
||||
background: #000; overflow: visible;
|
||||
border-right: 1px solid var(--border);
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
/* Sleeve scene — handles tilt, hover scale, and record pull-out */
|
||||
#np-sleeve-scene {
|
||||
position: relative;
|
||||
width: 115%;
|
||||
margin: -1.5rem 0 0 -30%;
|
||||
transform: rotate(-10deg);
|
||||
transform-origin: 42% 50%;
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.3, 0.64, 1);
|
||||
}
|
||||
#np-sleeve-scene:hover {
|
||||
transform: rotate(-10deg) scale(1.05);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 1:1 cover using padding-bottom trick */
|
||||
#np-cover-wrap {
|
||||
width: 100%; position: relative; flex-shrink: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
#np-cover-wrap::before { content: ''; display: block; padding-bottom: 100%; }
|
||||
|
||||
/* Vinyl record — peeks out behind the sleeve */
|
||||
#np-record {
|
||||
position: absolute;
|
||||
width: 100%; height: 100%;
|
||||
top: 0; left: 18%;
|
||||
z-index: 0;
|
||||
object-fit: contain;
|
||||
border-radius: 50%;
|
||||
transition: left 0.35s cubic-bezier(0.34, 1.3, 0.64, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
#np-sleeve-scene:hover #np-record {
|
||||
left: 44%;
|
||||
}
|
||||
|
||||
#np-cover {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%; object-fit: cover;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
#np-cover-wrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
border: 8px solid rgba(128, 128, 128, 0.8);
|
||||
pointer-events: none;
|
||||
}
|
||||
#np-cover[data-lightbox] { cursor: pointer; }
|
||||
|
||||
/* Creator info below the square cover */
|
||||
#np-creator-info {
|
||||
|
|
@ -696,6 +746,11 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
|||
align-items: center; justify-content: center;
|
||||
gap: 0.4rem; padding: 0.9rem 1rem;
|
||||
background: var(--bg-inset); overflow: hidden; text-align: center;
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
#np-creator-info[style*="cursor: pointer"]:hover {
|
||||
background: var(--bg-panel);
|
||||
box-shadow: inset 0 0 0 1px var(--neon-cyan), inset 0 0 12px rgba(0, 255, 255, 0.1);
|
||||
}
|
||||
.np-creator-avatar {
|
||||
width: 58px; height: 58px; border-radius: 50%;
|
||||
|
|
@ -713,7 +768,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
|||
.np-creator-name { font-size: 0.82rem; color: var(--fg); font-weight: 600; margin: 0; }
|
||||
|
||||
/* ── Right panel ─────────────────────────────────────────────────── */
|
||||
#np-right { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
#np-right { flex: 1; display: flex; flex-direction: column; min-width: 0; position: relative; z-index: 0; }
|
||||
|
||||
#np-header {
|
||||
padding: 0.9rem 1.25rem 0.8rem;
|
||||
|
|
@ -742,8 +797,9 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
|||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
transition: background 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.np-track:hover { background: rgba(255,255,255,0.04); }
|
||||
.np-track:hover { background: rgba(255,255,255,0.08); }
|
||||
.np-track.np-active { background: rgba(0,229,255,0.07); }
|
||||
|
||||
/* Equalizer bars */
|
||||
|
|
@ -944,6 +1000,136 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
|||
}
|
||||
@keyframes boot-blink { 50% { opacity: 0; } }
|
||||
|
||||
/* ---------- Profiles ---------- */
|
||||
.profiles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.profile-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.25rem 0.75rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--fg);
|
||||
text-align: center;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.profile-card:hover {
|
||||
transform: scale(1.04);
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 10px var(--neon-cyan-dim, rgba(0,255,255,0.25));
|
||||
}
|
||||
.profile-card-avatar-wrap {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-card-avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
.profile-card-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-inset);
|
||||
font-size: 1.6rem;
|
||||
font-weight: bold;
|
||||
color: var(--muted);
|
||||
}
|
||||
.profile-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.profile-card-meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Profile detail header */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem 0 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.profile-header-avatar-wrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-header-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.profile-header-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--muted);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.profile-header-info h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Spacing and dividers between sections on profile page */
|
||||
.profile-header ~ section {
|
||||
margin-top: 3.5rem;
|
||||
padding-top: 3rem;
|
||||
position: relative;
|
||||
}
|
||||
.profile-header ~ section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--neon-cyan), transparent);
|
||||
box-shadow: 0 0 8px var(--neon-cyan), 0 0 20px rgba(0, 255, 255, 0.2);
|
||||
}
|
||||
/* No line/extra space before the first section */
|
||||
.profile-header + section {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0;
|
||||
}
|
||||
.profile-header + section::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 4-wide grid for songs on profile page */
|
||||
.grid.grid-4 {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
/* Section header with inline action button */
|
||||
.section-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.section-header-row h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---------- Mobile ---------- */
|
||||
@media (max-width: 720px) {
|
||||
.player-bar { flex-wrap: wrap; }
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
|
|
@ -18,7 +18,12 @@
|
|||
|
||||
/* Left panel */
|
||||
'<div id="np-left">' +
|
||||
'<div id="np-cover-wrap"><img id="np-cover" src="" alt=""></div>' +
|
||||
'<div id="np-sleeve-scene">' +
|
||||
'<div id="np-cover-wrap">' +
|
||||
'<img id="np-record" src="/static/images/record.png" alt="">' +
|
||||
'<img id="np-cover" src="" alt="">' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="np-creator-info">' +
|
||||
'<div id="np-creator-avatar-wrap"></div>' +
|
||||
'<p class="np-creator-label">Created by</p>' +
|
||||
|
|
@ -100,11 +105,15 @@
|
|||
}
|
||||
|
||||
function setCover(src) {
|
||||
if (!src) { coverImg.style.opacity = '0'; return; }
|
||||
if (!src) {
|
||||
coverImg.style.opacity = '0';
|
||||
coverImg.removeAttribute('data-lightbox');
|
||||
return;
|
||||
}
|
||||
if (coverImg.src === src) return;
|
||||
coverImg.style.opacity = '0';
|
||||
coverImg.onload = () => { coverImg.style.opacity = '1'; };
|
||||
coverImg.onerror = () => { coverImg.style.opacity = '0'; };
|
||||
coverImg.onload = () => { coverImg.style.opacity = '1'; coverImg.setAttribute('data-lightbox', ''); };
|
||||
coverImg.onerror = () => { coverImg.style.opacity = '0'; coverImg.removeAttribute('data-lightbox'); };
|
||||
coverImg.src = src;
|
||||
}
|
||||
|
||||
|
|
@ -137,11 +146,13 @@
|
|||
|
||||
function populateCreator(creatorData) {
|
||||
creatorWrap.innerHTML = '';
|
||||
const creatorInfo = overlay.querySelector('#np-creator-info');
|
||||
if (!creatorData) {
|
||||
overlay.querySelector('#np-creator-info').style.display = 'none';
|
||||
creatorInfo.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
overlay.querySelector('#np-creator-info').style.display = '';
|
||||
creatorInfo.style.display = '';
|
||||
|
||||
if (creatorData.avatarPath) {
|
||||
const img = document.createElement('img');
|
||||
img.src = '/media/' + creatorData.avatarPath;
|
||||
|
|
@ -155,6 +166,15 @@
|
|||
creatorWrap.appendChild(ph);
|
||||
}
|
||||
creatorName.textContent = creatorData.displayName || '';
|
||||
|
||||
// Make the whole creator block a link if we have a profile slug.
|
||||
if (creatorData.slug) {
|
||||
creatorInfo.style.cursor = 'pointer';
|
||||
creatorInfo.onclick = () => { window.open('/profiles/' + creatorData.slug, '_blank'); };
|
||||
} else {
|
||||
creatorInfo.style.cursor = '';
|
||||
creatorInfo.onclick = null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncPlayPause() {
|
||||
|
|
@ -252,6 +272,13 @@
|
|||
reopen();
|
||||
return;
|
||||
}
|
||||
// Click on a track row in the overlay — jump to that song.
|
||||
const trackRow = e.target.closest('.np-track');
|
||||
if (trackRow && isOpen) {
|
||||
const id = trackRow.dataset.trackId;
|
||||
if (id) window.dispatchEvent(new CustomEvent('briTunes:jumpto', { detail: { id } }));
|
||||
return;
|
||||
}
|
||||
// Play All / Shuffle All on a playlist page
|
||||
const btn = e.target.closest('[data-nowplaying-meta]');
|
||||
if (btn) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,15 @@
|
|||
repeatBtn.classList.toggle('active', repeatOn);
|
||||
repeatBtn.setAttribute('aria-pressed', repeatOn);
|
||||
});
|
||||
window.addEventListener('briTunes:jumpto', (e) => {
|
||||
const id = e.detail && e.detail.id;
|
||||
if (!id) return;
|
||||
const idx = state.queue.findIndex(s => String(s.id) === String(id));
|
||||
if (idx === -1) return;
|
||||
state.index = idx;
|
||||
playCurrent();
|
||||
});
|
||||
|
||||
window.addEventListener('briTunes:navigate', () => {
|
||||
if (window.briTunesNowPlaying) highlightNowPlaying(window.briTunesNowPlaying.id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -104,6 +104,43 @@ router.get('/playlists/:slug', (req, res, next) => {
|
|||
res.render('public/playlist', { title: playlist.title, playlist, tracks, creator });
|
||||
});
|
||||
|
||||
router.get('/profiles', (req, res) => {
|
||||
const profiles = users.listVerified();
|
||||
res.render('public/profiles', { title: 'Profiles', profiles });
|
||||
});
|
||||
|
||||
router.get('/profiles/:slug', (req, res, next) => {
|
||||
const profile = users.findBySlug(req.params.slug);
|
||||
if (!profile) return next();
|
||||
const loggedIn = !!req.session.user;
|
||||
const userId = req.session.user?.id || null;
|
||||
|
||||
const userPlaylists = social.enrichPlaylists(playlists.listPublicByUser(profile.id, loggedIn), userId);
|
||||
const userSongs = social.enrichSongs(songs.listPublicByUser(profile.id, loggedIn), userId);
|
||||
|
||||
const likedSongRows = social.getLikedSongs(profile.id, loggedIn);
|
||||
// Map raw DB rows to song view objects
|
||||
const likedSongs = social.enrichSongs(
|
||||
likedSongRows.map(row => songs.findById(row.id)).filter(Boolean),
|
||||
userId
|
||||
);
|
||||
|
||||
const likedPlaylistRows = social.getLikedPlaylists(profile.id, loggedIn);
|
||||
const likedPlaylists = social.enrichPlaylists(
|
||||
likedPlaylistRows.map(row => playlists.findById(row.id)).filter(Boolean),
|
||||
userId
|
||||
);
|
||||
|
||||
res.render('public/profile', {
|
||||
title: profile.displayName + "'s Profile",
|
||||
profile,
|
||||
userPlaylists,
|
||||
userSongs,
|
||||
likedSongs,
|
||||
likedPlaylists,
|
||||
});
|
||||
});
|
||||
|
||||
// Audio streaming with HTTP Range support.
|
||||
router.get('/stream/:id', (req, res, next) => {
|
||||
const song = songs.findById(Number(req.params.id));
|
||||
|
|
|
|||
|
|
@ -239,8 +239,33 @@ function setOrder(playlistId, songIds) {
|
|||
tx();
|
||||
}
|
||||
|
||||
const listPublicByUserGuestStmt = db.prepare(`
|
||||
SELECT p.* FROM playlists p
|
||||
WHERE p.created_by = ? AND 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
|
||||
`);
|
||||
const listPublicByUserLoggedStmt = db.prepare(`
|
||||
SELECT p.* FROM playlists p
|
||||
WHERE p.created_by = ? AND 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
|
||||
`);
|
||||
|
||||
function listPublicByUser(userId, loggedIn = false) {
|
||||
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
|
||||
listVisible, listAll, listByUser, featured,
|
||||
listVisible, listAll, listByUser, listPublicByUser, featured,
|
||||
getTracks, trackCount, addTrack, removeTrack, setOrder,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -71,8 +71,53 @@ function enrichPlaylists(playlists, userId = null) {
|
|||
return playlists;
|
||||
}
|
||||
|
||||
// ── Profile liked/favorited queries ─────────────────────────────────────────
|
||||
const likedSongsGuestStmt = db.prepare(`
|
||||
SELECT s.* FROM song_likes sl
|
||||
JOIN songs s ON s.id = sl.song_id
|
||||
WHERE sl.user_id = ? AND s.visibility = 'public'
|
||||
ORDER BY sl.created_at DESC
|
||||
`);
|
||||
const likedSongsUserStmt = db.prepare(`
|
||||
SELECT s.* FROM song_likes sl
|
||||
JOIN songs s ON s.id = sl.song_id
|
||||
WHERE sl.user_id = ? AND s.visibility IN ('public', 'logged_in')
|
||||
ORDER BY sl.created_at DESC
|
||||
`);
|
||||
const likedPlaylistsGuestStmt = db.prepare(`
|
||||
SELECT p.* FROM playlist_likes pl
|
||||
JOIN playlists p ON p.id = pl.playlist_id
|
||||
WHERE pl.user_id = ? AND 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 pl.created_at DESC
|
||||
`);
|
||||
const likedPlaylistsUserStmt = db.prepare(`
|
||||
SELECT p.* FROM playlist_likes pl
|
||||
JOIN playlists p ON p.id = pl.playlist_id
|
||||
WHERE pl.user_id = ? AND 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 pl.created_at DESC
|
||||
`);
|
||||
|
||||
function getLikedSongs(userId, loggedIn = false) {
|
||||
return (loggedIn ? likedSongsUserStmt : likedSongsGuestStmt).all(userId);
|
||||
}
|
||||
|
||||
function getLikedPlaylists(userId, loggedIn = false) {
|
||||
return (loggedIn ? likedPlaylistsUserStmt : likedPlaylistsGuestStmt).all(userId);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toggleSongLike, toggleSongFavorite,
|
||||
togglePlaylistLike, togglePlaylistFavorite,
|
||||
enrichSongs, enrichPlaylists,
|
||||
getLikedSongs, getLikedPlaylists,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -175,4 +175,11 @@ function recent(n = 8, loggedIn = false) {
|
|||
return (loggedIn ? recentUserStmt : recentGuestStmt).all(n).map(toView);
|
||||
}
|
||||
|
||||
module.exports = { create, update, setCover, remove, findById, findBySlug, list, listAll, listByUser, listForManagement, recent };
|
||||
const listPublicByUserGuestStmt = db.prepare(`SELECT * FROM songs WHERE uploaded_by = ? AND visibility = 'public' ORDER BY created_at DESC`);
|
||||
const listPublicByUserLoggedStmt = db.prepare(`SELECT * FROM songs WHERE uploaded_by = ? AND visibility IN ('public', 'logged_in') ORDER BY created_at DESC`);
|
||||
|
||||
function listPublicByUser(userId, loggedIn = false) {
|
||||
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
|
||||
}
|
||||
|
||||
module.exports = { create, update, setCover, remove, findById, findBySlug, list, listAll, listByUser, listPublicByUser, listForManagement, recent };
|
||||
|
|
|
|||
|
|
@ -29,6 +29,45 @@ const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE crea
|
|||
const deleteSongsByUserStmt = db.prepare('DELETE FROM songs WHERE uploaded_by = ?');
|
||||
const deletePlaylistsByUserStmt = db.prepare('DELETE FROM playlists WHERE created_by = ?');
|
||||
const updateAvatarStmt = db.prepare('UPDATE users SET avatar_path = ? WHERE id = ?');
|
||||
const findBySlugIdStmt = db.prepare('SELECT id FROM users WHERE slug = ?');
|
||||
const findBySlugExactStmt = db.prepare('SELECT * FROM users WHERE slug = ?');
|
||||
const updateSlugStmt = db.prepare('UPDATE users SET slug = ? WHERE id = ?');
|
||||
const listVerifiedStmt = db.prepare(`
|
||||
SELECT u.*,
|
||||
(SELECT COUNT(*) FROM songs WHERE uploaded_by = u.id) AS song_count,
|
||||
(SELECT COUNT(*) FROM playlists WHERE created_by = u.id) AS playlist_count
|
||||
FROM users u
|
||||
WHERE u.email_verified = 1 AND u.disabled = 0
|
||||
ORDER BY u.display_name ASC
|
||||
`);
|
||||
|
||||
function toSlug(text) {
|
||||
return (text || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function generateUniqueUserSlug(displayName, excludeId = null) {
|
||||
const base = toSlug(displayName) || 'user';
|
||||
let candidate = base;
|
||||
let n = 2;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const row = findBySlugIdStmt.get(candidate);
|
||||
if (!row || row.id === excludeId) return candidate;
|
||||
candidate = `${base}-${n++}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill slugs for any users missing one (runs once after migration 009).
|
||||
const _noSlugRows = db.prepare('SELECT id, display_name FROM users WHERE slug IS NULL').all();
|
||||
for (const row of _noSlugRows) {
|
||||
updateSlugStmt.run(generateUniqueUserSlug(row.display_name, row.id), row.id);
|
||||
}
|
||||
|
||||
function publicView(row) {
|
||||
if (!row) return null;
|
||||
|
|
@ -38,6 +77,9 @@ function publicView(row) {
|
|||
emailVerified: row.email_verified === 1,
|
||||
disabled: row.disabled === 1,
|
||||
avatarPath: row.avatar_path || null,
|
||||
slug: row.slug || null,
|
||||
songCount: row.song_count != null ? row.song_count : undefined,
|
||||
playlistCount: row.playlist_count != null ? row.playlist_count : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +93,8 @@ async function createUser({ email, displayName, password }) {
|
|||
role = 'admin';
|
||||
}
|
||||
const info = insertStmt.run(email, displayName.trim(), hash, role);
|
||||
const slug = generateUniqueUserSlug(displayName.trim(), info.lastInsertRowid);
|
||||
updateSlugStmt.run(slug, info.lastInsertRowid);
|
||||
return publicView(findByIdStmt.get(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +119,8 @@ function findById(id) {
|
|||
|
||||
function updateDisplayName(id, displayName) {
|
||||
updateNameStmt.run(displayName.trim(), id);
|
||||
const slug = generateUniqueUserSlug(displayName.trim(), id);
|
||||
updateSlugStmt.run(slug, id);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
|
|
@ -136,16 +182,26 @@ function deleteUserWithContent(id) {
|
|||
return { songs: userSongs, playlists: userPlaylists };
|
||||
}
|
||||
|
||||
function listVerified() {
|
||||
return listVerifiedStmt.all().map(publicView);
|
||||
}
|
||||
|
||||
function findBySlug(slug) {
|
||||
return publicView(findBySlugExactStmt.get(slug));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createUser,
|
||||
verifyCredentials,
|
||||
findByEmail,
|
||||
findById,
|
||||
findBySlug,
|
||||
updateDisplayName,
|
||||
changePassword,
|
||||
createVerificationToken,
|
||||
verifyEmailToken,
|
||||
listAllUsers,
|
||||
listVerified,
|
||||
setDisabled,
|
||||
markVerifiedAdmin,
|
||||
deleteUserWithContent,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<nav class="nav">
|
||||
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
|
||||
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
|
||||
<a href="/profiles" class="<%= currentPath && currentPath.startsWith('/profiles') ? 'active' : '' %>">Profiles</a>
|
||||
<% if (user) { %>
|
||||
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') && !currentPath.startsWith('/admin/users') ? 'active' : '' %>">My Music</a>
|
||||
<% if (user.role === 'admin') { %>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
aria-pressed="<%= playlist.userFavorited ? 'true' : 'false' %>"
|
||||
title="Favorite">⭐ <span class="social-count"><%= playlist.favoriteCount || 0 %></span></button>
|
||||
</div>
|
||||
<% const npMeta = JSON.stringify({ title: playlist.title, description: playlist.description || null, cover: playlist.coverPath ? '/media/' + playlist.coverPath : null, trackCount: tracks.length, creator: creator ? { displayName: creator.displayName, avatarPath: creator.avatarPath || null } : null }); %>
|
||||
<% const npMeta = JSON.stringify({ title: playlist.title, description: playlist.description || null, cover: playlist.coverPath ? '/media/' + playlist.coverPath : null, trackCount: tracks.length, creator: creator ? { displayName: creator.displayName, avatarPath: creator.avatarPath || null, slug: creator.slug || null } : null }); %>
|
||||
<div class="playlist-btn-row">
|
||||
<div class="playlist-btn-left">
|
||||
<button type="button" class="btn-primary"
|
||||
|
|
@ -45,7 +45,11 @@
|
|||
</div>
|
||||
</div>
|
||||
<% if (creator) { %>
|
||||
<div class="detail-creator-card">
|
||||
<% if (creator.slug) { %>
|
||||
<a href="/profiles/<%= creator.slug %>" class="detail-creator-card">
|
||||
<% } else { %>
|
||||
<div class="detail-creator-card">
|
||||
<% } %>
|
||||
<% if (creator.avatarPath) { %>
|
||||
<img src="/media/<%= creator.avatarPath %>" alt="" class="detail-creator-avatar">
|
||||
<% } else { %>
|
||||
|
|
@ -55,7 +59,11 @@
|
|||
<% } %>
|
||||
<p class="detail-creator-label">Created by</p>
|
||||
<p class="detail-creator-name"><%= creator.displayName %></p>
|
||||
</div>
|
||||
<% if (creator.slug) { %>
|
||||
</a>
|
||||
<% } else { %>
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
<div class="profile-header">
|
||||
<div class="profile-header-avatar-wrap">
|
||||
<% if (profile.avatarPath) { %>
|
||||
<img src="/media/<%= profile.avatarPath %>" alt="" class="profile-header-avatar">
|
||||
<% } else { %>
|
||||
<div class="profile-header-avatar profile-header-placeholder">
|
||||
<%= (profile.displayName || '?').charAt(0).toUpperCase() %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="profile-header-info">
|
||||
<h1><%= profile.displayName %></h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2><%= profile.displayName %>'s Playlists</h2>
|
||||
<% if (userPlaylists.length === 0) { %>
|
||||
<p class="muted">No playlists yet.</p>
|
||||
<% } else { %>
|
||||
<div class="grid">
|
||||
<% userPlaylists.forEach((p) => { %>
|
||||
<a class="card" href="/playlists/<%= p.slug %>">
|
||||
<% if (p.coverPath) { %>
|
||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||
<% } else { %>
|
||||
<div class="cover-fallback">🎧</div>
|
||||
<% } %>
|
||||
<div class="card-body">
|
||||
<div class="card-title"><%= p.title %>
|
||||
<% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
|
||||
</div>
|
||||
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
|
||||
<div class="card-footer">
|
||||
<div class="card-social">
|
||||
<button type="button"
|
||||
class="btn-social<%= p.userLiked ? ' active' : '' %>"
|
||||
data-social-action="like" data-social-type="playlist" data-social-id="<%= p.id %>"
|
||||
<% if (!user) { %>data-require-login<% } %>
|
||||
aria-pressed="<%= p.userLiked ? 'true' : 'false' %>"
|
||||
title="Like">👍 <span class="social-count"><%= p.likeCount || 0 %></span></button>
|
||||
<button type="button"
|
||||
class="btn-social<%= p.userFavorited ? ' active' : '' %>"
|
||||
data-social-action="favorite" data-social-type="playlist" data-social-id="<%= p.id %>"
|
||||
<% if (!user) { %>data-require-login<% } %>
|
||||
aria-pressed="<%= p.userFavorited ? 'true' : 'false' %>"
|
||||
title="Favorite">⭐ <span class="social-count"><%= p.favoriteCount || 0 %></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-header-row">
|
||||
<h2><%= profile.displayName %>'s Songs</h2>
|
||||
<% if (userSongs.length > 0) { %>
|
||||
<button type="button" class="btn-primary btn-small"
|
||||
data-shuffle-playlist="<%= JSON.stringify(userSongs.map((s) => ({ id: s.id, title: s.title, artist: s.artist, cover: s.coverPath ? '/media/' + s.coverPath : null, userLiked: !!s.userLiked, userFavorited: !!s.userFavorited, likeCount: s.likeCount || 0, favoriteCount: s.favoriteCount || 0 }))) %>">
|
||||
⇄ Shuffle All
|
||||
</button>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (userSongs.length === 0) { %>
|
||||
<p class="muted">No songs yet.</p>
|
||||
<% } else { %>
|
||||
<div class="grid grid-4">
|
||||
<% userSongs.forEach((s) => { %>
|
||||
<a class="card" href="/songs/<%= s.slug %>">
|
||||
<% if (s.coverPath) { %>
|
||||
<img src="/media/<%= s.coverPath %>" alt="" />
|
||||
<% } else { %>
|
||||
<div class="cover-fallback">♪</div>
|
||||
<% } %>
|
||||
<div class="card-body">
|
||||
<div class="card-title"><%= s.title %></div>
|
||||
<div class="muted small"><%= s.artist %></div>
|
||||
<div class="card-footer">
|
||||
<div class="card-social">
|
||||
<button type="button"
|
||||
class="btn-social<%= s.userLiked ? ' active' : '' %>"
|
||||
data-social-action="like" data-social-type="song" data-social-id="<%= s.id %>"
|
||||
<% if (!user) { %>data-require-login<% } %>
|
||||
aria-pressed="<%= s.userLiked ? 'true' : 'false' %>"
|
||||
title="Like">👍 <span class="social-count"><%= s.likeCount || 0 %></span></button>
|
||||
<button type="button"
|
||||
class="btn-social<%= s.userFavorited ? ' active' : '' %>"
|
||||
data-social-action="favorite" data-social-type="song" data-social-id="<%= s.id %>"
|
||||
<% if (!user) { %>data-require-login<% } %>
|
||||
aria-pressed="<%= s.userFavorited ? 'true' : 'false' %>"
|
||||
title="Favorite">⭐ <span class="social-count"><%= s.favoriteCount || 0 %></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-header-row">
|
||||
<h2><%= profile.displayName %>'s Liked Songs</h2>
|
||||
<% if (likedSongs.length > 0) { %>
|
||||
<button type="button" class="btn-primary btn-small"
|
||||
data-shuffle-playlist="<%= JSON.stringify(likedSongs.map((s) => ({ id: s.id, title: s.title, artist: s.artist, cover: s.coverPath ? '/media/' + s.coverPath : null, userLiked: !!s.userLiked, userFavorited: !!s.userFavorited, likeCount: s.likeCount || 0, favoriteCount: s.favoriteCount || 0 }))) %>">
|
||||
⇄ Shuffle All
|
||||
</button>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (likedSongs.length === 0) { %>
|
||||
<p class="muted">No liked songs.</p>
|
||||
<% } else { %>
|
||||
<%- include('../partials/song_list', { songs: likedSongs }) %>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><%= profile.displayName %>'s Liked Playlists</h2>
|
||||
<% if (likedPlaylists.length === 0) { %>
|
||||
<p class="muted">No liked playlists.</p>
|
||||
<% } else { %>
|
||||
<div class="grid">
|
||||
<% likedPlaylists.forEach((p) => { %>
|
||||
<a class="card" href="/playlists/<%= p.slug %>">
|
||||
<% if (p.coverPath) { %>
|
||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||
<% } else { %>
|
||||
<div class="cover-fallback">🎧</div>
|
||||
<% } %>
|
||||
<div class="card-body">
|
||||
<div class="card-title"><%= p.title %>
|
||||
<% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
|
||||
</div>
|
||||
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
|
||||
<div class="card-footer">
|
||||
<% if (p.createdByName) { %><div class="card-creator">Created by:<br><%= p.createdByName %></div><% } %>
|
||||
<div class="card-social">
|
||||
<button type="button"
|
||||
class="btn-social<%= p.userLiked ? ' active' : '' %>"
|
||||
data-social-action="like" data-social-type="playlist" data-social-id="<%= p.id %>"
|
||||
<% if (!user) { %>data-require-login<% } %>
|
||||
aria-pressed="<%= p.userLiked ? 'true' : 'false' %>"
|
||||
title="Like">👍 <span class="social-count"><%= p.likeCount || 0 %></span></button>
|
||||
<button type="button"
|
||||
class="btn-social<%= p.userFavorited ? ' active' : '' %>"
|
||||
data-social-action="favorite" data-social-type="playlist" data-social-id="<%= p.id %>"
|
||||
<% if (!user) { %>data-require-login<% } %>
|
||||
aria-pressed="<%= p.userFavorited ? 'true' : 'false' %>"
|
||||
title="Favorite">⭐ <span class="social-count"><%= p.favoriteCount || 0 %></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<h1>Profiles</h1>
|
||||
<% if (profiles.length === 0) { %>
|
||||
<p class="muted">No profiles yet.</p>
|
||||
<% } else { %>
|
||||
<div class="profiles-grid">
|
||||
<% profiles.forEach((p) => { %>
|
||||
<a class="profile-card" href="/profiles/<%= p.slug %>">
|
||||
<div class="profile-card-avatar-wrap">
|
||||
<% if (p.avatarPath) { %>
|
||||
<img src="/media/<%= p.avatarPath %>" alt="" class="profile-card-avatar">
|
||||
<% } else { %>
|
||||
<div class="profile-card-avatar profile-card-placeholder">
|
||||
<%= (p.displayName || '?').charAt(0).toUpperCase() %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="profile-card-name"><%= p.displayName %></div>
|
||||
<div class="profile-card-meta muted small">
|
||||
<% if (p.songCount) { %><%= p.songCount %> song<%= p.songCount === 1 ? '' : 's' %><% } %>
|
||||
<% if (p.songCount && p.playlistCount) { %> · <% } %>
|
||||
<% if (p.playlistCount) { %><%= p.playlistCount %> playlist<%= p.playlistCount === 1 ? '' : 's' %><% } %>
|
||||
</div>
|
||||
</a>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
|
@ -37,7 +37,11 @@
|
|||
<a href="/songs" class="btn-link">← Back to songs</a>
|
||||
</div>
|
||||
<% if (creator) { %>
|
||||
<div class="detail-creator-card">
|
||||
<% if (creator.slug) { %>
|
||||
<a href="/profiles/<%= creator.slug %>" class="detail-creator-card">
|
||||
<% } else { %>
|
||||
<div class="detail-creator-card">
|
||||
<% } %>
|
||||
<% if (creator.avatarPath) { %>
|
||||
<img src="/media/<%= creator.avatarPath %>" alt="" class="detail-creator-avatar">
|
||||
<% } else { %>
|
||||
|
|
@ -47,7 +51,11 @@
|
|||
<% } %>
|
||||
<p class="detail-creator-label">Added by</p>
|
||||
<p class="detail-creator-name"><%= creator.displayName %></p>
|
||||
</div>
|
||||
<% if (creator.slug) { %>
|
||||
</a>
|
||||
<% } else { %>
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue