document.addEventListener('DOMContentLoaded', () => { // --- DOM Elements --- const emojiGrid = document.getElementById('emoji-grid'); // Support multiple search inputs (mobile + desktop); class-based selector (no dot in getElementsByClassName) const searchInputs = Array.from(document.querySelectorAll('.search-input')); // Dark mode toggles & icons (support multiple instances) const themeToggles = Array.from(document.querySelectorAll('.theme-toggle')); const lightIcons = Array.from(document.querySelectorAll('.theme-icon-light')); const darkIcons = Array.from(document.querySelectorAll('.theme-icon-dark')); // Check if essential elements exist if (!emojiGrid || searchInputs.length === 0) { console.error('Critical DOM elements missing'); return; } const modal = document.getElementById('emoji-modal'); const modalContent = document.getElementById('modal-content'); const modalCloseBtn = document.getElementById('modal-close-btn'); const modalEmoji = document.getElementById('modal-emoji'); const modalName = document.getElementById('modal-name'); const modalCategory = document.getElementById('modal-category'); const modalKeywords = document.getElementById('modal-keywords'); const modalCopyBtn = document.getElementById('modal-copy-btn'); const loadMoreBtn = document.getElementById('load-more-btn'); const categoryButtons = document.querySelectorAll('.category-btn'); const currentCategoryTitle = document.getElementById('current-category-title'); const currentCategoryCount = document.getElementById('current-category-count'); const offcanvasToggle = document.getElementById('offcanvas-toggle'); const offcanvasOverlay = document.getElementById('offcanvas-overlay'); const offcanvasClose = document.getElementById('offcanvas-close'); const offcanvasBackdrop = document.getElementById('offcanvas-backdrop'); const offcanvasSidebar = document.getElementById('offcanvas-sidebar'); const offcanvasNav = document.getElementById('offcanvas-nav'); // --- Loading state (spinner over grid) --- const gridWrap = emojiGrid.parentElement; let spinnerEl = null; function showSpinner() { if (spinnerEl) return; // Hide Load More while loading to avoid redundancy if (loadMoreBtn) loadMoreBtn.classList.add('hidden'); spinnerEl = document.createElement('div'); spinnerEl.id = 'grid-spinner'; spinnerEl.className = 'w-full flex justify-center py-6'; spinnerEl.innerHTML = `
`; gridWrap.appendChild(spinnerEl); } function hideSpinner() { if (spinnerEl) { spinnerEl.remove(); spinnerEl = null; } // Recompute Load More visibility after loading completes if (typeof updateLoadMoreButton === 'function') { try { updateLoadMoreButton(); } catch(_) {} } else if (loadMoreBtn) { // Fallback: show button; displayPage() will hide it if needed loadMoreBtn.classList.remove('hidden'); } } // --- Skin tone support (Fitzpatrick modifiers) --- const SKIN_TONES = [ { key: '1f3fb', ch: '\u{1F3FB}', label: 'Light' }, { key: '1f3fc', ch: '\u{1F3FC}', label: 'Medium-Light' }, { key: '1f3fd', ch: '\u{1F3FD}', label: 'Medium' }, { key: '1f3fe', ch: '\u{1F3FE}', label: 'Medium-Dark' }, { key: '1f3ff', ch: '\u{1F3FF}', label: 'Dark' }, ]; // Preferred tone helpers const TONE_SLUGS = ['light','medium-light','medium','medium-dark','dark']; function getPreferredToneIndex(){ const slug = localStorage.getItem('preferredSkinTone'); const i = TONE_SLUGS.indexOf(slug); return i >= 0 ? i : -1; } function setPreferredToneIndex(i){ if (i>=0 && iFailed to load emoji data.
'; } finally { hideSpinner(); } reflectScopeInPlaceholders(); } // Helper: append next page (used by Load More) async function appendNextPage() { const nextPage = currentPage + 1; try { showSpinner(); const { items } = await fetchEmojisFromAPI({ q: currentFilters.q, category: currentFilters.category, subcategory: currentFilters.subcategory, page: nextPage, limit: EMOJIS_PER_PAGE }); // Accumulate and mirror into currentEmojiList for UI rendering allEmojis = allEmojis.concat(items); currentEmojiList = allEmojis.slice(); displayPage(nextPage); console.debug('[API] appended page', nextPage, 'count:', items?.length); } catch (error) { console.error('Error loading next page from API:', error); } finally { hideSpinner(); } } function ensureHint() { let el = document.getElementById('search-hint'); if (!el) { el = document.createElement('div'); el.id = 'search-hint'; el.className = 'mb-3 text-sm px-3 py-2 rounded-md bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100'; emojiGrid.parentElement.insertBefore(el, emojiGrid); } return el; } function showHint(msg) { const el = ensureHint(); el.textContent = msg; el.classList.remove('hidden'); } function clearHint() { const el = document.getElementById('search-hint'); if (el) el.classList.add('hidden'); } // Build a pretty path for a given category/subcategory function buildPrettyPathFor(category = 'all', subcategory = '') { const catSlug = CATEGORY_TO_SLUG[category] || 'all'; const segs = []; if (catSlug !== 'all') segs.push(catSlug); if (subcategory) segs.push(subcatToSlug(subcategory)); return '/' + segs.join('/'); } // --- UI Rendering --- function updateLoadMoreButton() { if (currentEmojiList.length < totalAvailable) { loadMoreBtn.classList.remove('hidden'); } else { loadMoreBtn.classList.add('hidden'); } } function displayPage(page) { currentPage = page; const start = (page - 1) * EMOJIS_PER_PAGE; const end = start + EMOJIS_PER_PAGE; const emojisToDisplay = currentEmojiList.slice(start, end); if (page === 1) { emojiGrid.innerHTML = ''; // Clear the grid only for the first page } emojisToDisplay.forEach(emoji => { const emojiCard = document.createElement('div'); emojiCard.className = 'relative flex flex-col items-center justify-center p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm cursor-pointer transition-transform duration-200 hover:scale-105 hover:shadow-md'; emojiCard.innerHTML = `No emojis found.
'; } updateLoadMoreButton(); } loadMoreBtn.addEventListener('click', () => { appendNextPage(); }); // --- Category Logic --- function initializeCategoryMenu() { // Transform existing desktop sidebar buttons into anchors for proper link behavior const desktopButtons = Array.from(document.querySelectorAll('.category-btn')); desktopButtons.forEach((btn) => { const category = btn.getAttribute('data-category') || 'all'; const a = document.createElement('a'); // Preserve inner content (icon + label) a.innerHTML = btn.innerHTML; // Build pretty URL and assign SPA-recognized classes/attributes a.href = (typeof buildPrettyPathFor === 'function') ? buildPrettyPathFor(category, '') : '/'; a.className = 'category-link scope-link block no-underline w-full text-left px-3 py-2 rounded-lg text-sm font-medium ' + 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ' + 'focus:outline-none focus:ring-2 focus:ring-blue-500'; a.setAttribute('data-category', category); a.setAttribute('data-cat', category); a.setAttribute('data-sub', ''); // Replace button with anchor btn.replaceWith(a); }); // Offcanvas category menu const categories = [ { key: 'all', name: 'All Emojis', icon: '🌟' }, { key: 'Smileys & Emotion', name: 'Smileys & Emotion', icon: '😀' }, { key: 'People & Body', name: 'People & Body', icon: '👋' }, { key: 'Animals & Nature', name: 'Animals & Nature', icon: '🐶' }, { key: 'Food & Drink', name: 'Food & Drink', icon: '🍎' }, { key: 'Travel & Places', name: 'Travel & Places', icon: '🌍' }, { key: 'Activities', name: 'Activities', icon: '⚽' }, { key: 'Objects', name: 'Objects', icon: '💡' }, { key: 'Symbols', name: 'Symbols', icon: '❤️' }, { key: 'Flags', name: 'Flags', icon: '🏳️' } ]; offcanvasNav.innerHTML = categories.map(cat => { const href = (typeof buildPrettyPathFor === 'function') ? buildPrettyPathFor(cat.name, '') : '/'; // fallback safeguard return ` ${cat.icon} ${cat.name} `; }).join(''); // Offcanvas menu controls offcanvasToggle.addEventListener('click', openOffcanvas); offcanvasClose.addEventListener('click', closeOffcanvas); offcanvasBackdrop.addEventListener('click', closeOffcanvas); } // Initialize category menu (sidebar + desktop listeners) initializeCategoryMenu(); // Intercept plain left-clicks on category links for SPA behavior. // Modified clicks (Cmd/Ctrl/middle) and no-JS still work as normal links. document.addEventListener('click', (e) => { const a = e.target.closest('a.category-link'); if (!a) return; // Only intercept unmodified left clicks if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); const category = a.dataset.category || 'all'; setActiveCategory(category); // Top-level category navigation clears subcategory currentFilters.subcategory = ''; updateURLFromFilters(); // If off-canvas is open, close it try { closeOffcanvas(); } catch(_) {} }); document.addEventListener('click', (e) => { const a = e.target.closest('a.scope-link'); if (!a) return; if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); currentFilters.category = a.getAttribute('data-cat') || 'all'; currentFilters.subcategory = subcatToSlug(a.getAttribute('data-sub') || ''); updateURLFromFilters(); resetAndLoadFirstPage(); try { closeOffcanvas(); } catch(_) {} }); function setActiveCategory(category) { currentCategory = category; currentFilters.category = category; updateURLFromFilters(); // Update active state for all category links (desktop + offcanvas) document.querySelectorAll('.category-link').forEach(link => { const isActive = (link.dataset.category === category); link.classList.toggle('active', isActive); if (isActive) { link.classList.add('bg-blue-100', 'dark:bg-blue-900', 'text-blue-700', 'dark:text-blue-300'); } else { link.classList.remove('bg-blue-100', 'dark:bg-blue-900', 'text-blue-700', 'dark:text-blue-300'); } }); // Reset to first page and fetch from API for this category (search term preserved) currentFilters.q = getSearchValue(); resetAndLoadFirstPage(); reflectScopeInPlaceholders(); } // Clear search buttons (support multiple: desktop/mobile) const searchClearButtons = Array.from(document.querySelectorAll('#search-clear, .search-clear')); searchClearButtons.forEach(btn => { btn.addEventListener('click', () => { const curr = getSearchValue(); if (!curr) { focusFirstSearch(); return; } setSearchValue(''); currentFilters.q = ''; updateURLFromFilters(); resetAndLoadFirstPage(); focusFirstSearch(); }); }); function updateCategoryDisplay() { const categoryName = currentCategory === 'all' ? 'All Emojis' : currentCategory; currentCategoryTitle.textContent = categoryName; currentCategoryCount.textContent = `${totalAvailable} emojis`; } function ensureFilterUI() { let bar = document.getElementById('active-filter-bar'); if (!bar) { bar = document.createElement('div'); bar.id = 'active-filter-bar'; bar.className = 'mb-3 flex flex-wrap items-center gap-2'; // insert above the grid emojiGrid.parentElement.insertBefore(bar, emojiGrid); } return bar; } function reflectScopeInPlaceholders() { const prettySub = currentFilters.subcategory ? currentFilters.subcategory.replace(/-/g, ' ') : ''; const scope = (currentFilters.category !== 'all' ? currentFilters.category : '') + (prettySub ? ` › ${prettySub}` : ''); const hint = scope ? `Searching in ${scope.toLowerCase()}…` : 'Search for an emoji…'; searchInputs.forEach(el => el.placeholder = hint); } function renderActiveFilters() { const bar = ensureFilterUI(); bar.innerHTML = ''; // reset const mkPill = (label, onClick, color='bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200') => { const b = document.createElement('button'); b.className = `px-2 py-1 rounded-md text-sm ${color} hover:opacity-90 transition`; b.textContent = label; b.addEventListener('click', onClick); return b; }; const hasCat = currentFilters.category && currentFilters.category !== 'all'; const hasSub = !!currentFilters.subcategory; if (hasCat) { bar.appendChild(mkPill( `Category: ${currentFilters.category} ✕`, () => { currentFilters.category = 'all'; updateURLFromFilters(); resetAndLoadFirstPage(); } )); } if (hasSub) { const prettySub = currentFilters.subcategory.replace(/-/g, ' '); bar.appendChild(mkPill( `Subcategory: ${prettySub} ✕`, () => { currentFilters.subcategory = ''; updateURLFromFilters(); resetAndLoadFirstPage(); } )); } if (hasCat || hasSub) { bar.appendChild(mkPill( 'Clear all filters', () => { currentFilters.category = 'all'; currentFilters.subcategory = ''; updateURLFromFilters(); resetAndLoadFirstPage(); }, 'bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200' )); } } function openOffcanvas() { offcanvasOverlay.classList.remove('hidden'); setTimeout(() => { offcanvasSidebar.classList.remove('-translate-x-full'); }, 10); } function closeOffcanvas() { offcanvasSidebar.classList.add('-translate-x-full'); setTimeout(() => { offcanvasOverlay.classList.add('hidden'); }, 300); } // --- Search Logic --- // (handled by wireSearchListeners) // --- Modal Logic --- function openModal(emoji) { const baseForModal = emoji.emoji_base || stripSkinTone(emoji.emoji); const prefIdx = getPreferredToneIndex(); const initial = (emoji.supports_skin_tone && prefIdx>=0) ? withSkinTone(baseForModal, SKIN_TONES[prefIdx].ch) : emoji.emoji; modalEmoji.textContent = initial; // Ensure Copy button always copies the currently shown emoji let currentEmojiForCopy = initial; if (modalCopyBtn) { modalCopyBtn.onclick = () => copyToClipboard(currentEmojiForCopy); } // Tone row placed directly under the big emoji (function renderModalToneRow() { // Prepare base and current const base = emoji.emoji_base || stripSkinTone(emoji.emoji); let current = initial; // what is shown now // Insert tone row after the emoji display let toneRow = document.getElementById('modal-tone-row'); if (!toneRow) { toneRow = document.createElement('div'); toneRow.id = 'modal-tone-row'; toneRow.className = 'mt-2 flex gap-1 flex-wrap justify-center'; modalEmoji.insertAdjacentElement('afterend', toneRow); } toneRow.innerHTML = ''; if (!emoji.supports_skin_tone) return; SKIN_TONES.forEach((t, i) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'w-7 h-7 rounded-md flex items-center justify-center bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'; b.textContent = t.ch; b.title = t.label; if (typeof prefIdx === 'number' && prefIdx === i) { b.classList.add('ring-2','ring-blue-500'); } b.addEventListener('click', () => { const variant = withSkinTone(base, t.ch); current = variant; modalEmoji.textContent = variant; currentEmojiForCopy = variant; // update shared copy target setPreferredToneIndex(i); // update highlight Array.from(toneRow.children).forEach(child => child.classList.remove('ring-2','ring-blue-500')); b.classList.add('ring-2','ring-blue-500'); }); toneRow.appendChild(b); }); // (removed rebinding of modalCopyBtn.onclick here) })(); modalName.textContent = emoji.name; modalCategory.textContent = [emoji.category, emoji.subcategory].filter(Boolean).join(' / '); modalKeywords.innerHTML = ''; const tags = new Set(); if (emoji.category) tags.add({ type: 'category', label: emoji.category }); if (emoji.subcategory) tags.add({ type: 'subcategory', label: emoji.subcategory }); if (tags.size > 0) { [...tags].forEach(({ type, label }) => { // Create an anchor element for pill, so Cmd/Ctrl/middle-click works const link = document.createElement('a'); link.href = buildPrettyPathFor( type === 'category' ? label : (emoji.category || 'all'), type === 'subcategory' ? label : '' ); link.textContent = label; link.className = 'scope-link px-2 py-1 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 ' + 'rounded-md text-sm hover:bg-gray-300 dark:hover:bg-gray-600 ' + 'focus:outline-none focus:ring-2 focus:ring-blue-500'; link.setAttribute('data-cat', type === 'category' ? label : (emoji.category || 'all')); link.setAttribute('data-sub', type === 'subcategory' ? subcatToSlug(label) : ''); modalKeywords.appendChild(link); }); } else { modalKeywords.innerHTML = 'No tags available.
'; } // Copy button (handled in tone row logic above) // modalCopyBtn.onclick = () => copyToClipboard(emoji.emoji); // Ensure & wire "Open page" button const openBtn = ensureModalButtons(); if (openBtn) { // Prefer server-provided slug if present; otherwise build a safe fallback from name const fallbackSlug = (emoji.slug || (emoji.name || 'emoji')) .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); openBtn.onclick = () => { window.location.href = `/emoji/${fallbackSlug}`; }; } modal.classList.remove('hidden'); modal.classList.add('flex'); setTimeout(() => { modalContent.classList.remove('scale-95', 'opacity-0'); }, 10); } function ensureModalButtons() { // We assume modalCopyBtn exists already. // Append a sibling "Open page" button if not present. let openBtn = document.getElementById('modal-open-btn'); if (!openBtn && modalCopyBtn && modalCopyBtn.parentElement) { openBtn = document.createElement('button'); openBtn.id = 'modal-open-btn'; openBtn.type = 'button'; openBtn.className = 'ml-2 inline-flex items-center px-3 py-1.5 rounded-md ' + 'bg-blue-600 text-white hover:bg-blue-700 focus:outline-none ' + 'focus:ring-2 focus:ring-blue-500'; openBtn.textContent = 'Open page'; modalCopyBtn.parentElement.appendChild(openBtn); } return openBtn; } function closeModal() { modalContent.classList.add('scale-95', 'opacity-0'); setTimeout(() => { modal.classList.add('hidden'); modal.classList.remove('flex'); }, 300); } modalCloseBtn.addEventListener('click', closeModal); modal.addEventListener('click', (e) => { if (e.target === modal) { closeModal(); } }); // --- Clipboard Logic --- function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { const originalText = modalCopyBtn.textContent; modalCopyBtn.textContent = 'Copied!'; setTimeout(() => { modalCopyBtn.textContent = originalText; }, 2000); }).catch(err => { console.error('Failed to copy: ', err); const originalText = modalCopyBtn.textContent; modalCopyBtn.textContent = 'Failed!'; setTimeout(() => { modalCopyBtn.textContent = originalText; }, 2000); }); } // ---- Deep link: initialize filters from URL ---- (function initFiltersFromURL() { // 1) Parse pretty path /Category[/Subcategory] → querystring (function parsePrettyPathIntoQuery(fv) { const path = location.pathname; // ignore known non-app paths if (path === '/' || path.startsWith('/emoji') || path.startsWith('/api') || path.startsWith('/assets') || path.startsWith('/api-docs')) return; const parts = path.replace(/^\/+|\/+$/g, '').split('/'); if (!parts[0]) return; const qs = new URLSearchParams(location.search); // slug → display const catSlug = parts[0].toLowerCase(); const category = SLUG_TO_CATEGORY[catSlug] || 'all'; qs.set('category', category); if (parts[1]) { // Keep canonical hyphenated slug for API queries const sub = (parts[1] + '').toLowerCase(); qs.set('subcategory', sub); } history.replaceState(null, '', `/?${qs.toString()}`); })(); // 2) Read params const params = new URLSearchParams(location.search); const qParam = params.get('q') || ''; const catParam = params.get('category') || 'all'; const subParam = params.get('subcategory') || ''; // 3) Put q in both search inputs searchInputs.forEach(el => (el.value = qParam)); // 4) Apply to state, activate buttons, and fetch currentFilters.q = qParam.toLowerCase().trim(); currentFilters.category = catParam; currentFilters.subcategory = subcatToSlug(subParam); // setActiveCategory also fetches, but we need subcategory respected on first load setActiveCategory(catParam); currentFilters.subcategory = subcatToSlug(subParam); if (subParam) resetAndLoadFirstPage(); })(); function updateURLFromFilters() { const hasQ = !!(currentFilters.q && currentFilters.q.trim()); const hasCat = currentFilters.category && currentFilters.category !== 'all'; const hasSub = !!currentFilters.subcategory; // Prefer pretty URLs when no q and we have category/subcategory if (!hasQ && (hasCat || hasSub)) { const segs = []; // map display → slug const catSlug = CATEGORY_TO_SLUG[currentFilters.category] || 'all'; if (hasCat && catSlug !== 'all') segs.push(catSlug); if (hasSub) segs.push(subcatToSlug(currentFilters.subcategory)); const pretty = '/' + segs.join('/'); history.replaceState(null, '', pretty || '/'); return; } // Otherwise keep query form (use display name for category to keep UI-friendly querystring) const qs = new URLSearchParams(); if (hasQ) qs.set('q', currentFilters.q.trim()); if (hasCat) qs.set('category', currentFilters.category); if (hasSub) qs.set('subcategory', currentFilters.subcategory); const url = qs.toString() ? `/?${qs.toString()}` : '/'; history.replaceState(null, '', url); } // Initialize footer with dynamic year function initFooter() { const yearElement = document.getElementById('current-year'); if (yearElement) { const currentYear = new Date().getFullYear(); yearElement.textContent = currentYear; } } // Initialize footer initFooter(); });