107 lines
3.5 KiB
JavaScript
107 lines
3.5 KiB
JavaScript
(function () {
|
|
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;
|
|
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>`;
|
|
|
|
// Attach to body so overflow:hidden on ancestor cards can't clip it.
|
|
document.body.appendChild(tip);
|
|
activeTooltip = tip;
|
|
|
|
const rect = btn.getBoundingClientRect();
|
|
tip.style.position = 'fixed';
|
|
tip.style.top = (rect.bottom + 6) + 'px';
|
|
tip.style.left = rect.left + 'px';
|
|
|
|
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;
|
|
|
|
// Guest: redirect to login
|
|
if (btn.hasAttribute('data-require-login')) {
|
|
window.location.href = '/login';
|
|
return;
|
|
}
|
|
|
|
// Prevent card <a> from navigating when button is inside one
|
|
e.stopPropagation();
|
|
|
|
const { socialAction, socialType, socialId } = btn.dataset;
|
|
const url = `/api/${socialType}s/${socialId}/${socialAction}`;
|
|
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': csrfToken },
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
|
|
const isOn = data.liked ?? data.favorited;
|
|
btn.classList.toggle('active', isOn);
|
|
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 {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
})();
|