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; }
|
||||
.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 ---------- */
|
||||
.lb-trigger {
|
||||
cursor: zoom-in;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,62 @@
|
|||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
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) => {
|
||||
const btn = e.target.closest('[data-social-action]');
|
||||
if (!btn) return;
|
||||
|
|
@ -32,6 +88,7 @@
|
|||
btn.setAttribute('aria-pressed', String(isOn));
|
||||
const countEl = btn.querySelector('.social-count');
|
||||
if (countEl) countEl.textContent = data.count;
|
||||
invalidateCache(btn.dataset.socialType, btn.dataset.socialId);
|
||||
} catch (err) {
|
||||
console.error('[social] toggle failed:', err);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,21 @@ const playlists = require('../services/playlists');
|
|||
|
||||
module.exports = function socialRoutes(csrfProtection) {
|
||||
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.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);
|
||||
}
|
||||
|
||||
// ── 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 = {
|
||||
toggleSongLike, toggleSongFavorite,
|
||||
togglePlaylistLike, togglePlaylistFavorite,
|
||||
enrichSongs, enrichPlaylists,
|
||||
getLikedSongs, getLikedPlaylists,
|
||||
getRecentlyLikedSongs, getMostPopular,
|
||||
getLikers, getFavoriters,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue