feat: add hover tooltips for likers/favoriters on social buttons

- Add CSS for social tooltip styling and positioning
- Implement JavaScript to fetch and display likers/favoriters on hover
- Add caching mechanism to reduce API calls
- Create new API endpoint `/api/likers/:type/:id` to fetch user display names
- Add database queries to retrieve likers and favoriters for songs and playlists
- Invalidate cache when social actions are performed
This commit is contained in:
Brian Fertig 2026-04-18 18:40:17 -06:00
parent 569c432fb5
commit fa98236d52
4 changed files with 116 additions and 0 deletions

View File

@ -775,6 +775,33 @@ a.detail-creator-card:hover {
.social-row { display: flex; gap: 4px; margin: 0.4rem 0 0.6rem; } .social-row { display: flex; gap: 4px; margin: 0.4rem 0 0.6rem; }
.card-social { display: flex; gap: 4px; padding-top: 0.4rem; border-top: 1px solid rgba(255,255,255,0.06); } .card-social { display: flex; gap: 4px; padding-top: 0.4rem; border-top: 1px solid rgba(255,255,255,0.06); }
/* ---------- Social hover tooltip ---------- */
.social-tooltip {
position: absolute;
z-index: 800;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
min-width: 120px;
max-width: 220px;
padding: 0.45rem 0.65rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
font-size: 0.72rem;
color: var(--muted);
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.12s;
}
.social-tooltip.visible { opacity: 1; }
.social-tooltip strong { display: block; color: var(--fg); margin-bottom: 0.2rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
.social-tooltip ul { margin: 0; padding: 0; list-style: none; }
.social-tooltip ul li { overflow: hidden; text-overflow: ellipsis; }
.btn-social { position: relative; }
/* ---------- Lightbox ---------- */ /* ---------- Lightbox ---------- */
.lb-trigger { .lb-trigger {
cursor: zoom-in; cursor: zoom-in;

View File

@ -2,6 +2,62 @@
const meta = document.querySelector('meta[name="csrf-token"]'); const meta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = meta ? meta.content : ''; const csrfToken = meta ? meta.content : '';
// ── Liker tooltip on hover ────────────────────────────────────────────────
const cache = {};
let activeTooltip = null;
let hideTimer = null;
function removeTooltip() {
if (activeTooltip) { activeTooltip.remove(); activeTooltip = null; }
}
function showTooltip(btn, data) {
removeTooltip();
const action = btn.dataset.socialAction; // 'like' or 'favorite'
const names = action === 'like' ? data.likers : data.favoriters;
if (!names || names.length === 0) return;
const tip = document.createElement('div');
tip.className = 'social-tooltip';
const label = action === 'like' ? 'Liked by' : 'Favorited by';
const ul = names.map(n => `<li>${n.replace(/</g, '&lt;')}</li>`).join('');
tip.innerHTML = `<strong>${label}</strong><ul>${ul}</ul>`;
btn.appendChild(tip);
activeTooltip = tip;
requestAnimationFrame(() => tip.classList.add('visible'));
}
async function fetchLikers(type, id) {
const key = `${type}:${id}`;
if (cache[key]) return cache[key];
const res = await fetch(`/api/likers/${type}/${id}`);
if (!res.ok) return null;
const data = await res.json();
cache[key] = data;
return data;
}
function invalidateCache(type, id) {
delete cache[`${type}:${id}`];
}
document.addEventListener('mouseenter', async (e) => {
const btn = e.target.closest('[data-social-action]');
if (!btn || btn.hasAttribute('data-require-login')) return;
const countEl = btn.querySelector('.social-count');
if (!countEl || Number(countEl.textContent) === 0) return;
clearTimeout(hideTimer);
const { socialType, socialId } = btn.dataset;
const data = await fetchLikers(socialType, socialId);
if (data) showTooltip(btn, data);
}, true);
document.addEventListener('mouseleave', (e) => {
const btn = e.target.closest('[data-social-action]');
if (!btn) return;
hideTimer = setTimeout(removeTooltip, 100);
}, true);
document.addEventListener('click', async (e) => { document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-social-action]'); const btn = e.target.closest('[data-social-action]');
if (!btn) return; if (!btn) return;
@ -32,6 +88,7 @@
btn.setAttribute('aria-pressed', String(isOn)); btn.setAttribute('aria-pressed', String(isOn));
const countEl = btn.querySelector('.social-count'); const countEl = btn.querySelector('.social-count');
if (countEl) countEl.textContent = data.count; if (countEl) countEl.textContent = data.count;
invalidateCache(btn.dataset.socialType, btn.dataset.socialId);
} catch (err) { } catch (err) {
console.error('[social] toggle failed:', err); console.error('[social] toggle failed:', err);
} finally { } finally {

View File

@ -6,6 +6,21 @@ const playlists = require('../services/playlists');
module.exports = function socialRoutes(csrfProtection) { module.exports = function socialRoutes(csrfProtection) {
const router = express.Router(); const router = express.Router();
// Public: return display names of users who liked/favorited a song or playlist.
router.get('/likers/:type/:id', (req, res, next) => {
try {
const { type, id } = req.params;
if (!['song', 'playlist'].includes(type)) return res.status(400).json({ error: 'Invalid type' });
const numId = Number(id);
if (!Number.isInteger(numId) || numId < 1) return res.status(400).json({ error: 'Invalid id' });
res.json({
likers: social.getLikers(type, numId),
favoriters: social.getFavoriters(type, numId),
});
} catch (err) { next(err); }
});
router.use(requireUser); router.use(requireUser);
router.post('/songs/:id/like', csrfProtection, (req, res, next) => { router.post('/songs/:id/like', csrfProtection, (req, res, next) => {

View File

@ -231,10 +231,27 @@ function getMostPopular(n = 20, loggedIn = false, isVip = false) {
return (isVip ? mostPopularVipStmt : loggedIn ? mostPopularUserStmt : mostPopularGuestStmt).all(n); return (isVip ? mostPopularVipStmt : loggedIn ? mostPopularUserStmt : mostPopularGuestStmt).all(n);
} }
// ── Liker/favoriter name lists ───────────────────────────────────────────────
const songLikersStmt = db.prepare(`SELECT u.display_name FROM song_likes sl JOIN users u ON u.id = sl.user_id WHERE sl.song_id = ? ORDER BY sl.created_at DESC LIMIT 50`);
const songFavoritersStmt = db.prepare(`SELECT u.display_name FROM song_favorites sf JOIN users u ON u.id = sf.user_id WHERE sf.song_id = ? ORDER BY sf.created_at DESC LIMIT 50`);
const plLikersStmt = db.prepare(`SELECT u.display_name FROM playlist_likes pl JOIN users u ON u.id = pl.user_id WHERE pl.playlist_id = ? ORDER BY pl.created_at DESC LIMIT 50`);
const plFavoritersStmt = db.prepare(`SELECT u.display_name FROM playlist_favorites pf JOIN users u ON u.id = pf.user_id WHERE pf.playlist_id = ? ORDER BY pf.created_at DESC LIMIT 50`);
function getLikers(type, id) {
const stmt = type === 'playlist' ? plLikersStmt : songLikersStmt;
return stmt.all(id).map(r => r.display_name);
}
function getFavoriters(type, id) {
const stmt = type === 'playlist' ? plFavoritersStmt : songFavoritersStmt;
return stmt.all(id).map(r => r.display_name);
}
module.exports = { module.exports = {
toggleSongLike, toggleSongFavorite, toggleSongLike, toggleSongFavorite,
togglePlaylistLike, togglePlaylistFavorite, togglePlaylistLike, togglePlaylistFavorite,
enrichSongs, enrichPlaylists, enrichSongs, enrichPlaylists,
getLikedSongs, getLikedPlaylists, getLikedSongs, getLikedPlaylists,
getRecentlyLikedSongs, getMostPopular, getRecentlyLikedSongs, getMostPopular,
getLikers, getFavoriters,
}; };