// === License & mode (stub) === // Flip this to true for local testing of Pro features if you want: let licenseValid = false; // <- default: Free // Persistent settings let actionMode = 'copy'; // 'copy' | 'insert' | 'auto' // Tone settings (persisted in sync storage) let toneLock = false; // if true, always use preferred tone let preferredToneSlug = null; // 'light' | 'medium-light' | 'medium' | 'medium-dark' | 'dark' // Elements const settingsBtn = document.getElementById('settings'); const sheet = document.getElementById('settings-sheet'); const sheetClose = document.getElementById('sheet-close'); const backdrop = document.getElementById('sheet-backdrop'); const modeGroup = document.getElementById('mode-group'); const licenseKeyEl = document.getElementById('license-key'); const licenseActivateBtn = document.getElementById('license-activate'); const licenseStatusEl = document.getElementById('license-status'); const licenseDeactivateBtn = document.getElementById('license-deactivate'); const licenseEditBtn = document.getElementById('license-edit'); const licenseCancelEditBtn = document.getElementById('license-cancel-edit'); const diagRunBtn = document.getElementById('diag-run'); const diagSpin = document.getElementById('diag-spin'); const diagOut = document.getElementById('diag-out'); const API = { base: "https://emoji.dewe.pw/api", list: "/emojis", limit: 20 // lighter for extensions; your API caps at 50, docs try-it at 10 }; API.cats = "/categories"; // endpoint for categories map let CAT_MAP = null; // { "Category": ["sub1","sub2", ...], ... } // Preferred, human-first category order (fallback: alphabetical) const PREFERRED_CATEGORY_ORDER = [ "Smileys & Emotion", "People & Body", "Animals & Nature", "Food & Drink", "Travel & Places", "Activities", "Objects", "Symbols", "Flags" ]; const CAT_INDEX = Object.fromEntries(PREFERRED_CATEGORY_ORDER.map((c, i) => [c, i])); function categoryComparator(a, b) { const ia = CAT_INDEX[a]; const ib = CAT_INDEX[b]; if (ia !== undefined && ib !== undefined) return ia - ib; // both in preferred list if (ia !== undefined) return -1; // a preferred, b not if (ib !== undefined) return 1; // b preferred, a not return a.localeCompare(b, undefined, { sensitivity: "base" }); // both not preferred → A–Z } async function loadCategories() { try { const res = await fetch(`${API.base}${API.cats}`); if (!res.ok) throw new Error(`cats ${res.status}`); CAT_MAP = await res.json(); populateCategorySelect(); } catch (e) { console.warn("Failed to load categories", e); } } function populateCategorySelect() { const catSel = document.getElementById('cat'); const subSel = document.getElementById('sub'); if (!catSel || !CAT_MAP) return; // reset catSel.innerHTML = ``; Object.keys(CAT_MAP).sort(categoryComparator).forEach(cat => { const opt = document.createElement('option'); opt.value = cat; opt.textContent = cat; catSel.appendChild(opt); }); // clear subs initially subSel.innerHTML = ``; subSel.disabled = true; } function populateSubcategorySelect(category) { const subSel = document.getElementById('sub'); if (!subSel) return; subSel.innerHTML = ``; const subs = (CAT_MAP?.[category] || []).slice().sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true })); subs.forEach(sc => { const opt = document.createElement('option'); opt.value = sc; opt.textContent = sc; subSel.appendChild(opt); }); subSel.disabled = subs.length === 0; } let page = 1, total = 0, items = []; const q = document.getElementById('q'); const cat = document.getElementById('cat'); const sub = document.getElementById('sub'); const clearBtn = document.getElementById('clear'); const list = document.getElementById('list'); const more = document.getElementById('more'); const count = document.getElementById('count'); const verEl = document.getElementById('ver'); function setVersionBadge() { try { const { name, version } = chrome.runtime.getManifest(); if (verEl) { verEl.textContent = `v${version}`; verEl.title = `${name} v${version}`; } } catch (e) { // ignore if unavailable } } setVersionBadge(); // --- Loading spinner (flip-flop with Load More) --- let spinnerEl = null; function showSpinner() { if (spinnerEl) return; // Hide Load More while loading if (more) more.classList.add('hidden'); spinnerEl = document.createElement('div'); spinnerEl.id = 'grid-spinner'; spinnerEl.className = 'w-full flex justify-center py-6'; spinnerEl.innerHTML = '
'; // place under the grid (list?.parentElement || document.body).appendChild(spinnerEl); } function hideSpinner() { if (spinnerEl) { spinnerEl.remove(); spinnerEl = null; } // Recompute Load More visibility after loading completes if (typeof updateFooter === 'function') { try { updateFooter(); } catch(_) {} } else if (more) { more.classList.remove('hidden'); } } // THEME — two states only: 'light' | 'dark' const themeBtn = document.getElementById('theme'); const toastEl = document.getElementById('toast'); async function initTheme() { const stored = (await chrome.storage.local.get('theme')).theme; const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; const initial = stored || (prefersDark ? 'dark' : 'light'); await chrome.storage.local.set({ theme: initial }); applyTheme(initial); } function applyTheme(theme) { const clsLight = 'theme-light'; const clsDark = 'theme-dark'; document.body.classList.remove(clsLight, clsDark); document.documentElement.classList.remove(clsLight, clsDark); // <-- add const cls = (theme === 'dark') ? clsDark : clsLight; document.body.classList.add(cls); document.documentElement.classList.add(cls); // <-- add // Button shows the opposite icon so it's clear what tapping will do themeBtn.textContent = theme === 'dark' ? '🌞' : '🌙'; themeBtn.dataset.theme = theme; } async function toggleTheme() { const curr = themeBtn.dataset.theme === 'dark' ? 'dark' : 'light'; const next = curr === 'dark' ? 'light' : 'dark'; await chrome.storage.local.set({ theme: next }); applyTheme(next); } themeBtn?.addEventListener('click', toggleTheme); initTheme(); updateClearButtonIcon(); // --- Skin tone (Pro) ------------------------------------------------------- const SKIN_TONES = [ { key: '1f3fb', ch: '\u{1F3FB}', slug: 'light', label: 'Light' }, { key: '1f3fc', ch: '\u{1F3FC}', slug: 'medium-light', label: 'Medium-Light' }, { key: '1f3fd', ch: '\u{1F3FD}', slug: 'medium', label: 'Medium' }, { key: '1f3fe', ch: '\u{1F3FE}', slug: 'medium-dark', label: 'Medium-Dark' }, { key: '1f3ff', ch: '\u{1F3FF}', slug: 'dark', label: 'Dark' }, ]; const TONE_SLUGS = SKIN_TONES.map(t => t.slug); const STRIP_TONE_RE = /[\u{1F3FB}-\u{1F3FF}]/gu; function stripSkinTone(s){ return (s||'').replace(STRIP_TONE_RE,''); } function withSkinTone(base, ch){ return `${base||''}${ch||''}`; } // Tone helpers: keep globals in sync and expose helpers for slug async function getPreferredToneIndex(){ return new Promise(res => { chrome.storage.sync.get(['preferredSkinTone'], v => { preferredToneSlug = v?.preferredSkinTone || null; const idx = TONE_SLUGS.indexOf(preferredToneSlug); res(idx >= 0 ? idx : -1); }); }); } async function setPreferredToneIndex(i){ preferredToneSlug = TONE_SLUGS[i] || null; return new Promise(res => chrome.storage.sync.set({ preferredSkinTone: preferredToneSlug }, () => res())); } async function getToneLock(){ return new Promise(res => chrome.storage.sync.get(['toneLock'], v => { toneLock = !!v.toneLock; res(toneLock); })); } async function setToneLock(val){ toneLock = !!val; return new Promise(res => chrome.storage.sync.set({ toneLock }, () => res())); } // debounce let timer; function debounced(fn, delay=250){ clearTimeout(timer); timer = setTimeout(fn, delay); } async function fetchPage(reset=false) { more.disabled = true; if (reset) { page = 1; items = []; list.innerHTML = ""; } try { showSpinner(); const params = new URLSearchParams({ page: String(page), limit: String(API.limit) }); if (q.value.trim()) params.set('q', q.value.trim()); if (cat.value.trim()) params.set('category', cat.value.trim()); if (sub.value.trim()) params.set('subcategory', sub.value.trim()); const url = `${API.base}${API.list}?${params.toString()}`; const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`API ${res.status}`); const data = await res.json(); total = data.total || 0; // append for (const e of (data.items || [])) { items.push(e); renderCard(e); } updateFooter(); } catch (err) { console.error('Fetch failed', err); // Show a small notice const msg = document.createElement('div'); msg.className = 'w-full text-center text-red-500 py-2'; msg.textContent = 'Failed to load. Please try again.'; list?.appendChild(msg); } finally { hideSpinner(); } } function updateFooter() { count.textContent = `${items.length} / ${total}`; const loading = !!spinnerEl; if (items.length < total) { more.textContent = 'Load more'; more.disabled = loading; // Only show button when not loading more.classList.toggle('hidden', loading); } else { more.textContent = 'All loaded'; more.disabled = true; // If fully loaded, keep it visible but disabled, or hide if you prefer: // more.classList.add('hidden'); } } async function ensureContentScript(tabId) { // try ping try { const pong = await chrome.tabs.sendMessage(tabId, { type: 'dewemoji_ping' }); if (pong?.ok) return true; } catch {} // inject then ping again try { await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }); const pong2 = await chrome.tabs.sendMessage(tabId, { type: 'dewemoji_ping' }); return !!pong2?.ok; } catch { return false; } } async function insert(text, opts = {}) { const strict = !!opts.strict; // if true, do NOT fallback to copy if (!text) return false; try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) { if (!strict) { await navigator.clipboard.writeText(text); } return false; } // A) Try inline injection (fast path) const [{ result }] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: (txt) => { const el = document.activeElement; const isEditable = el && ( el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && /^(text|search|email|url|tel|number|password)?$/i.test(el.type)) || el.isContentEditable ); if (!isEditable) return false; if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { const start = el.selectionStart ?? el.value.length; const end = el.selectionEnd ?? start; const val = el.value ?? ''; el.value = val.slice(0, start) + txt + val.slice(end); const pos = start + txt.length; try { el.setSelectionRange(pos, pos); } catch {} el.dispatchEvent(new Event('input', { bubbles: true })); return true; } try { document.execCommand('insertText', false, txt); return true; } catch { return false; } }, args: [text] }); if (result) return true; // inserted via inline // B) Ensure content.js present, then ask it (robust path) if (await ensureContentScript(tab.id)) { const res = await chrome.tabs.sendMessage(tab.id, { type: 'dewemoji_insert', text }); if (res?.ok) return true; // inserted via content script } // C) Fallback if (!strict) { await navigator.clipboard.writeText(text); } return false; } catch { if (!strict) { await navigator.clipboard.writeText(text); } return false; } } function updateClearButtonIcon() { if (!clearBtn) return; const hasText = q.value.trim().length > 0; if (hasText) { clearBtn.innerHTML = ``; clearBtn.title = 'Clear'; clearBtn.setAttribute('aria-label', 'Clear'); clearBtn.classList.add('nm'); clearBtn.dataset.mode = 'clear'; } else { clearBtn.innerHTML = ``; // refresh icon clearBtn.title = 'Refresh'; clearBtn.setAttribute('aria-label', 'Refresh'); clearBtn.classList.add('nm'); clearBtn.dataset.mode = 'refresh'; } } // events q.addEventListener('input', () => { updateClearButtonIcon(); debounced(() => fetchPage(true)); }); cat.addEventListener('change', () => { populateSubcategorySelect(cat.value); sub.value = ''; fetchPage(true).catch(console.error); }); sub.addEventListener('change', () => { fetchPage(true).catch(console.error); }); clearBtn.addEventListener('click', () => { const mode = clearBtn.dataset.mode; if (mode === 'clear') { q.value = ''; updateClearButtonIcon(); fetchPage(true); } else { // refresh when empty fetchPage(true); } }); more.addEventListener('click', () => { more.disabled = true; page += 1; fetchPage(false); }); function showToast(text='Done') { if (!toastEl) return; toastEl.textContent = text; toastEl.classList.add('show'); clearTimeout(showToast._t); showToast._t = setTimeout(() => toastEl.classList.remove('show'), 1400); } // (obsolete) placeholder; real renderCard defined later with Windows support + Pro tone UI function renderCard(e) {} // --- Popover helpers for skin tone picker --- function showPopoverNear(anchorEl, popEl){ const r = anchorEl.getBoundingClientRect(); Object.assign(popEl.style, { position: 'fixed', left: `${Math.min(window.innerWidth - 8, Math.max(8, r.left))}px`, top: `${Math.min(window.innerHeight - 8, r.bottom + 6)}px`, zIndex: 9999, }); document.body.appendChild(popEl); } function removePopover(){ const p = document.getElementById('dewemoji-tone-popover'); if (p && p.parentNode) p.parentNode.removeChild(p); document.removeEventListener('click', handleOutsideClose, true); } function handleOutsideClose(e){ const p = document.getElementById('dewemoji-tone-popover'); if (p && !p.contains(e.target)) removePopover(); } function buildTonePopover(base, activeIdx, onPick){ const el = document.createElement('div'); el.id = 'dewemoji-tone-popover'; el.setAttribute('role','dialog'); el.style.cssText = 'background: var(--c-bg, #fff); border: 1px solid var(--c-border, rgba(0,0,0,.12)); padding:6px; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.15); display:flex; gap:6px;'; SKIN_TONES.forEach((t,i) => { const b = document.createElement('button'); b.className = 'tone-btn'; b.textContent = withSkinTone(base, t.ch); b.title = t.label; b.style.cssText = 'min-width:32px;height:32px;border-radius:6px;border:1px solid transparent;display:flex;align-items:center;justify-content:center;background:var(--c-chip,#f3f4f6)'; if (i === activeIdx) b.classList.add('selected'); b.addEventListener('click', () => onPick(i, t)); el.appendChild(b); }); setTimeout(() => document.addEventListener('click', handleOutsideClose, true), 0); return el; } // initial loadCategories().catch(console.error); fetchPage(true).catch(console.error); async function loadSettings() { const data = await chrome.storage.local.get(['licenseValid', 'licenseKey', 'actionMode']); licenseValid = !!data.licenseValid; actionMode = data.actionMode || 'copy'; if (data.licenseKey) licenseKeyEl && (licenseKeyEl.value = data.licenseKey); applyLicenseUI(); applyModeUI(); setStatusTag(); // tone settings from sync storage await getPreferredToneIndex(); await getToneLock(); renderToneSettingsSection(); // ensure license field starts in non-edit mode setLicenseEditMode(false); } async function saveSettings() { await chrome.storage.local.set({ licenseValid, actionMode, licenseKey: licenseKeyEl?.value || '' }); } function setLicenseEditMode(on) { if (!licenseKeyEl) return; const isOn = !!on; // Input enabled only when editing OR not licensed licenseKeyEl.disabled = licenseValid ? !isOn : false; if (licenseValid) { // Licensed: default view hides Activate; editing shows Save/Cancel if (licenseActivateBtn) { licenseActivateBtn.style.display = isOn ? '' : 'none'; licenseActivateBtn.textContent = isOn ? 'Save' : 'Activate'; } if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = licenseActivateBtn.textContent == 'Save' ? 'none' : ''; if (licenseEditBtn) licenseEditBtn.style.display = isOn ? 'none' : ''; if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = isOn ? '' : 'none'; } else { // Free: show Activate only if (licenseActivateBtn) { licenseActivateBtn.style.display = ''; licenseActivateBtn.textContent = 'Activate'; } if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = 'none'; if (licenseEditBtn) licenseEditBtn.style.display = 'none'; if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none'; } } function applyLicenseUI() { // enable/disable Pro radios const radios = modeGroup.querySelectorAll('input[type="radio"][name="actionMode"]'); radios.forEach(r => { const isPro = (r.value === 'insert' || r.value === 'auto'); if (isPro) { r.disabled = !licenseValid; r.closest('.radio').setAttribute('aria-disabled', !licenseValid ? 'true' : 'false'); } }); if (!licenseValid) { // Force visible selection to 'copy' for clarity const copy = modeGroup.querySelector('input[type="radio"][value="copy"]'); if (copy) copy.checked = true; licenseStatusEl && (licenseStatusEl.textContent = 'Free mode — Pro features locked.'); } else { // Restore selected mode if Pro; if current actionMode is pro, check it const target = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`) || modeGroup.querySelector('input[type="radio"][value="auto"]'); if (target) target.checked = true; licenseStatusEl && (licenseStatusEl.textContent = 'Pro active'); } // Reset to non-edit view per license state if (licenseValid) { if (licenseKeyEl) licenseKeyEl.disabled = true; if (licenseActivateBtn) { licenseActivateBtn.style.display = 'none'; licenseActivateBtn.textContent = 'Activate'; } if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = ''; if (licenseEditBtn) licenseEditBtn.style.display = ''; if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none'; } else { if (licenseKeyEl) licenseKeyEl.disabled = false; if (licenseActivateBtn) { licenseActivateBtn.style.display = ''; licenseActivateBtn.textContent = 'Activate'; } if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = 'none'; if (licenseEditBtn) licenseEditBtn.style.display = 'none'; if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none'; } setStatusTag(); } function applyModeUI() { // Ensure the selected radio matches actionMode (if not locked) const input = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`); if (input && !input.disabled) input.checked = true; } // --- License account id helper (best practice: hash + label) --- async function accountId() { // Return a stable, privacy-preserving identifier and a masked label for UI // { id: