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:
parent
569c432fb5
commit
fa98236d52
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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, '<')}</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 {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue