first from cpanel commit

This commit is contained in:
dwindown
2025-08-29 23:47:11 +07:00
commit ce4b6d577e
52 changed files with 1494 additions and 0 deletions

848
assets/script.js Normal file
View File

@@ -0,0 +1,848 @@
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();
});