849 lines
36 KiB
JavaScript
849 lines
36 KiB
JavaScript
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 = `
|
||
<div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent text-blue-600 rounded-full" role="status" aria-label="loading"></div>
|
||
`;
|
||
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 && i<SKIN_TONES.length) localStorage.setItem('preferredSkinTone', TONE_SLUGS[i]);
|
||
}
|
||
// Append a skin tone modifier to an emoji (browsers ignore invalid combos gracefully)
|
||
function withSkinTone(emojiChar, modifierChar) {
|
||
if (!emojiChar) return emojiChar;
|
||
return `${emojiChar}${modifierChar}`;
|
||
}
|
||
// Helper: strip Fitzpatrick skin tone modifiers from emoji string
|
||
function stripSkinTone(emojiChar) {
|
||
if (!emojiChar) return emojiChar;
|
||
// Remove Fitzpatrick modifiers (\u{1F3FB}-\u{1F3FF})
|
||
return emojiChar.replace(/[\u{1F3FB}-\u{1F3FF}]/gu, '');
|
||
}
|
||
function buildSkinTonePicker(onPick) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'absolute z-50 mt-2 p-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg flex gap-1';
|
||
SKIN_TONES.forEach(t => {
|
||
const b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.className = 'w-6 h-6 rounded-md flex items-center justify-center hover:ring-2 hover:ring-blue-500';
|
||
b.textContent = t.ch;
|
||
b.title = t.label;
|
||
b.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
onPick(t);
|
||
});
|
||
wrap.appendChild(b);
|
||
});
|
||
return wrap;
|
||
}
|
||
|
||
// ---- Pretty URL helpers (category & subcategory slugs)
|
||
const CATEGORY_TO_SLUG = {
|
||
'all': 'all',
|
||
'Smileys & Emotion': 'smileys',
|
||
'People & Body': 'people',
|
||
'Animals & Nature': 'animals',
|
||
'Food & Drink': 'food',
|
||
'Travel & Places': 'travel',
|
||
'Activities': 'activities',
|
||
'Objects': 'objects',
|
||
'Symbols': 'symbols',
|
||
'Flags': 'flags'
|
||
};
|
||
const SLUG_TO_CATEGORY = Object.fromEntries(Object.entries(CATEGORY_TO_SLUG).map(([k,v]) => [v,k]));
|
||
|
||
// Subcategory is best-effort slug (lowercase, hyphen). Most of your subs already match this.
|
||
const subcatToSlug = (s='') => s.toLowerCase()
|
||
.replace(/&/g, 'and')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
// For API use, keep hyphenated slug as canonical
|
||
const slugToSubcat = (s='') => s; // keep hyphenated for API
|
||
|
||
// --- State ---
|
||
let allEmojis = [];
|
||
let currentEmojiList = [];
|
||
let currentPage = 1;
|
||
const EMOJIS_PER_PAGE = 50;
|
||
let indonesianKeywords = {};
|
||
let currentCategory = 'all';
|
||
let categorizedEmojis = {};
|
||
|
||
// --- Search helpers for multi-input support (mobile + desktop) ---
|
||
function getSearchValue() {
|
||
const el = searchInputs.find(n => n && typeof n.value === 'string');
|
||
return (el?.value || '').toLowerCase().trim();
|
||
}
|
||
function setSearchValue(val) {
|
||
searchInputs.forEach(n => { if (n && typeof n.value === 'string') n.value = val; });
|
||
}
|
||
function focusFirstSearch() {
|
||
if (searchInputs[0]) searchInputs[0].focus();
|
||
updateURLFromFilters();
|
||
}
|
||
function wireSearchListeners() {
|
||
searchInputs.forEach(el => {
|
||
el.addEventListener('input', (e) => {
|
||
const v = (e.target.value || '').toLowerCase().trim();
|
||
// keep other inputs in sync visually
|
||
searchInputs.forEach(other => { if (other !== e.target) other.value = e.target.value; });
|
||
currentFilters.q = v;
|
||
updateURLFromFilters();
|
||
resetAndLoadFirstPage();
|
||
});
|
||
});
|
||
}
|
||
|
||
// --- Dark Mode Logic ---
|
||
const applyTheme = (isDark) => {
|
||
document.documentElement.classList.toggle('dark', isDark);
|
||
lightIcons.forEach(el => el.classList.toggle('hidden', !isDark));
|
||
darkIcons.forEach(el => el.classList.toggle('hidden', isDark));
|
||
};
|
||
|
||
const toggleDarkMode = () => {
|
||
const isDark = !document.documentElement.classList.contains('dark');
|
||
localStorage.setItem('darkMode', isDark);
|
||
applyTheme(isDark);
|
||
};
|
||
|
||
themeToggles.forEach(btn => btn.addEventListener('click', toggleDarkMode));
|
||
|
||
// Set initial icon state on load
|
||
const initialIsDark = document.documentElement.classList.contains('dark');
|
||
applyTheme(initialIsDark);
|
||
wireSearchListeners();
|
||
|
||
// --- Data Fetching & Processing ---
|
||
// Switch from loading local JSON files to calling the server API.
|
||
// We keep UI logic intact (pagination buttons etc.), only the data source changes.
|
||
|
||
// Server pagination total for current filter
|
||
let totalAvailable = 0;
|
||
|
||
// Current filters used when requesting data from API
|
||
const currentFilters = {
|
||
q: '',
|
||
category: 'all',
|
||
subcategory: ''
|
||
};
|
||
|
||
// Helper: build query string and fetch a page from the API
|
||
async function fetchEmojisFromAPI({ q = '', category = 'all', subcategory = '', page = 1, limit = EMOJIS_PER_PAGE } = {}) {
|
||
const params = new URLSearchParams();
|
||
if (q) params.set('q', q);
|
||
|
||
// normalize category to slug
|
||
const catSlug = CATEGORY_TO_SLUG[category] || category;
|
||
if (catSlug && catSlug !== 'all') params.set('category', catSlug);
|
||
|
||
if (subcategory) params.set('subcategory', subcategory);
|
||
params.set('page', String(page));
|
||
params.set('limit', String(limit));
|
||
|
||
console.debug('[API] /api/emojis?' + params.toString());
|
||
const res = await fetch('/api/emojis?' + params.toString(), { cache: 'no-store' });
|
||
if (!res.ok) throw new Error('Failed to load emojis from API');
|
||
return res.json();
|
||
}
|
||
|
||
// Helper: load first page for current filters (reset list)
|
||
async function resetAndLoadFirstPage() {
|
||
try {
|
||
// Clear grid immediately to reflect new scope
|
||
emojiGrid.innerHTML = '';
|
||
showSpinner();
|
||
const { total, items, plan } = await fetchEmojisFromAPI({
|
||
q: currentFilters.q,
|
||
category: currentFilters.category,
|
||
subcategory: currentFilters.subcategory,
|
||
page: 1,
|
||
limit: EMOJIS_PER_PAGE
|
||
});
|
||
console.debug('[API] first page loaded:', { total, count: items?.length, plan });
|
||
|
||
let usedScope = { ...currentFilters };
|
||
let data = { total, items };
|
||
|
||
if (data.total === 0 && (usedScope.category !== 'all' || usedScope.subcategory)) {
|
||
const retry = await fetchEmojisFromAPI({
|
||
q: usedScope.q,
|
||
category: 'all',
|
||
subcategory: '',
|
||
page: 1,
|
||
limit: EMOJIS_PER_PAGE
|
||
});
|
||
if (retry.total > 0) {
|
||
showHint(`No matches in "${usedScope.category}${usedScope.subcategory ? ' › ' + usedScope.subcategory : ''}". Showing results across All categories.`);
|
||
data = retry;
|
||
usedScope.category = 'all';
|
||
usedScope.subcategory = '';
|
||
} else {
|
||
clearHint();
|
||
}
|
||
} else {
|
||
clearHint();
|
||
}
|
||
|
||
totalAvailable = data.total || 0;
|
||
allEmojis = Array.isArray(data.items) ? data.items.slice() : [];
|
||
currentEmojiList = allEmojis.slice();
|
||
currentPage = 1;
|
||
|
||
updateCategoryDisplay();
|
||
renderActiveFilters();
|
||
displayPage(1);
|
||
} catch (error) {
|
||
console.error('Error loading first page from API:', error);
|
||
emojiGrid.innerHTML = '<p class="text-red-500 col-span-full text-center">Failed to load emoji data.</p>';
|
||
} 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 = `
|
||
<div class="text-4xl">${emoji.emoji}</div>
|
||
<div class="mt-2 text-xs text-center text-gray-600 dark:text-gray-300 font-semibold w-full truncate">${emoji.name}</div>
|
||
`;
|
||
// Open modal on card click
|
||
emojiCard.addEventListener('click', () => openModal(emoji));
|
||
// If this emoji supports skin tone, add a small trigger to pick a tone
|
||
if (emoji.supports_skin_tone) {
|
||
const trigger = document.createElement('button');
|
||
trigger.type = 'button';
|
||
trigger.className = 'absolute top-1 right-1 rounded-md px-1 text-xs bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600';
|
||
trigger.setAttribute('aria-label', 'Choose skin tone');
|
||
trigger.textContent = '⋯';
|
||
emojiCard.appendChild(trigger);
|
||
let pickerEl = null;
|
||
function closePicker() {
|
||
if (pickerEl) {
|
||
pickerEl.remove();
|
||
pickerEl = null;
|
||
document.removeEventListener('click', outsideClose, true);
|
||
}
|
||
}
|
||
function outsideClose(e) {
|
||
if (pickerEl && !pickerEl.contains(e.target) && e.target !== trigger) closePicker();
|
||
}
|
||
const openPicker = () => {
|
||
if (pickerEl) return;
|
||
pickerEl = buildSkinTonePicker((tone) => {
|
||
// Always use base for variant
|
||
const base = emoji.emoji_base || stripSkinTone(emoji.emoji);
|
||
const variant = withSkinTone(base, tone.ch);
|
||
// persist preferred tone
|
||
const idx = SKIN_TONES.findIndex(x => x.key === tone.key);
|
||
if (idx >= 0) setPreferredToneIndex(idx);
|
||
openModal({ ...emoji, emoji: variant, emoji_base: base });
|
||
closePicker();
|
||
});
|
||
trigger.after(pickerEl);
|
||
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
|
||
};
|
||
trigger.addEventListener('click', (e) => { e.stopPropagation(); pickerEl ? closePicker() : openPicker(); });
|
||
// Optional: long-press support for touch
|
||
let lp;
|
||
trigger.addEventListener('touchstart', () => { lp = setTimeout(openPicker, 350); }, {passive: true});
|
||
trigger.addEventListener('touchend', () => { clearTimeout(lp); }, {passive: true});
|
||
}
|
||
emojiGrid.appendChild(emojiCard);
|
||
});
|
||
|
||
// Show 'No emojis found' only if the grid is still empty after rendering
|
||
if (emojiGrid.innerHTML === '') {
|
||
emojiGrid.innerHTML = '<p class="text-gray-500 dark:text-gray-400 col-span-full text-center">No emojis found.</p>';
|
||
}
|
||
|
||
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 `
|
||
<a href="${href}"
|
||
class="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"
|
||
data-category="${cat.key}"
|
||
data-cat="${cat.name}"
|
||
data-sub="">
|
||
<span class="flex items-center">
|
||
<span class="mr-2">${cat.icon}</span>
|
||
${cat.name}
|
||
</span>
|
||
</a>
|
||
`;
|
||
}).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 =
|
||
'<p class="text-sm text-gray-500 dark:text-gray-400">No tags available.</p>';
|
||
}
|
||
|
||
// 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();
|
||
|
||
});
|