Files
emoji-api/assets/script.js
2025-08-29 23:47:11 +07:00

849 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
});