From e82471924a09574974b4357bced0ca96a28a16c4 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 16 Jul 2026 01:51:05 +0200 Subject: [PATCH] favorites tab for emojis --- public/s/js/comments.js | 53 ++++++++++++++++++++++++++++++++++---- src/inc/routes/emojis.mjs | 54 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/public/s/js/comments.js b/public/s/js/comments.js index 0a225d7..bbda2ed 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -4069,10 +4069,33 @@ class CommentSystem { toggle(name, url) { const favs = this.get(); const idx = favs.findIndex(f => f.name === name); + const action = idx >= 0 ? 'remove' : 'add'; if (idx >= 0) favs.splice(idx, 1); else favs.push({ name, url }); localStorage.setItem(this.KEY, JSON.stringify(favs)); + // Sync to DB for logged-in users (fire-and-forget) + const csrf = window.f0ckSession?.csrf_token; + if (csrf) { + fetch('/api/v2/user/emoji-favorites/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ name, url, action }) + }).catch(() => {}); + } + }, + syncFromDB() { + fetch('/api/v2/user/emoji-favorites', { headers: { 'X-Requested-With': 'XMLHttpRequest' } }) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.success && Array.isArray(data.favorites)) { + localStorage.setItem(this.KEY, JSON.stringify(data.favorites)); + } + }).catch(() => {}); } }; + // Seed from DB on first open if logged in + if (window.f0ckSession?.id) { + window._emojiPickerFavUtils.syncFromDB(); + } } if (!window._favContextMenu) { const menu = document.createElement('div'); @@ -4135,21 +4158,40 @@ class CommentSystem { badge.className = 'fa-solid fa-star ep-fav-badge'; el.appendChild(badge); } - // Hold-to-preview + // Hold-to-preview (400ms) + Long-hold-to-favorite (700ms, mobile-friendly) let holdTimer = null; + let favHoldTimer = null; let holdFired = false; + let favHoldFired = false; const startHold = () => { holdFired = false; + favHoldFired = false; holdTimer = setTimeout(() => { holdFired = true; window.stickerPreview?.show(url, isVideo); }, 400); + // 700ms: transition from preview to favorites menu (mobile long-press) + favHoldTimer = setTimeout(() => { + if (holdFired) { + window.stickerPreview?.hide(); + holdFired = false; + favHoldFired = true; + const rect = el.getBoundingClientRect(); + window._favContextMenu?.show( + rect.left + rect.width / 2, rect.top, + name, url, + () => showTab(activeTabId) + ); + } + }, 700); }; - const cancelHold = () => { clearTimeout(holdTimer); }; + const cancelHold = () => { clearTimeout(holdTimer); clearTimeout(favHoldTimer); }; const endHold = () => { clearTimeout(holdTimer); - if (holdFired) { + clearTimeout(favHoldTimer); + if (holdFired || favHoldFired) { holdFired = false; + favHoldFired = false; el.addEventListener('click', (e) => { e.stopImmediatePropagation(); e.preventDefault(); @@ -4166,12 +4208,13 @@ class CommentSystem { media.addEventListener('touchstart', startHold, { passive: true }); media.addEventListener('touchend', endHold); media.addEventListener('touchcancel', endHold); - // Right-click → favorites context menu + // Right-click (desktop) → favorites; suppressed on mobile when hold gesture is active media.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); + if (holdFired || favHoldFired) return; window._favContextMenu?.show(e.clientX, e.clientY, name, url, () => { - showTab(activeTabId); // refresh to update star badges + showTab(activeTabId); }); }); return el; diff --git a/src/inc/routes/emojis.mjs b/src/inc/routes/emojis.mjs index 55da646..dc734f7 100644 --- a/src/inc/routes/emojis.mjs +++ b/src/inc/routes/emojis.mjs @@ -430,6 +430,60 @@ export default (router, tpl) => { } }); + // ── User Emoji Favorites ───────────────────────────────────────── + // Create table lazily on first use (idempotent) + const ensureFavTable = async () => { + await db` + CREATE TABLE IF NOT EXISTS user_emoji_favorites ( + user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + emoji_name text NOT NULL, + emoji_url text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, emoji_name) + )`; + }; + + // GET /api/v2/user/emoji-favorites — returns logged-in user's favorites + router.get('/api/v2/user/emoji-favorites', lib.loggedin, async (req, res) => { + try { + await ensureFavTable(); + const rows = await db` + SELECT emoji_name AS name, emoji_url AS url + FROM user_emoji_favorites + WHERE user_id = ${req.session.id} + ORDER BY created_at ASC + `; + return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, favorites: rows }) }); + } catch (e) { + console.error('[EMOJI FAVS GET]', e); + return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); + } + }); + + // POST /api/v2/user/emoji-favorites/toggle — add or remove a single favorite + router.post('/api/v2/user/emoji-favorites/toggle', lib.loggedin, async (req, res) => { + const csrfToken = req.headers['x-csrf-token']; + if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) { + return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Invalid CSRF token' }) }); + } + let body = {}; + try { const raw = req.body || req.post || {}; body = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (_) {} + const { name, url, action } = body; + if (!name || !url) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'name and url required' }) }); + try { + await ensureFavTable(); + if (action === 'remove') { + await db`DELETE FROM user_emoji_favorites WHERE user_id = ${req.session.id} AND emoji_name = ${name}`; + } else { + await db`INSERT INTO user_emoji_favorites (user_id, emoji_name, emoji_url) VALUES (${req.session.id}, ${name}, ${url}) ON CONFLICT (user_id, emoji_name) DO NOTHING`; + } + return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) }); + } catch (e) { + console.error('[EMOJI FAVS TOGGLE]', e); + return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); + } + }); + // Edit emoji (Admin only) — handled by bypass middleware in index.mjs return router;