published version in webstore

This commit is contained in:
dwindown
2025-09-06 23:58:42 +07:00
parent a21f9927e4
commit 6b1efab615
11 changed files with 619 additions and 136 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.DS_Store

BIN
assets/icon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

BIN
assets/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
assets/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,6 +1,9 @@
// Let Chrome open the panel when the toolbar icon is clicked
// Guard: some Chromium variants may not expose sidePanel
const hasSidePanel = !!chrome.sidePanel?.setOptions;
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel?.setPanelBehavior?.({ openPanelOnActionClick: true });
if (hasSidePanel) chrome.sidePanel.setPanelBehavior?.({ openPanelOnActionClick: true });
});
// If you still want to ensure the correct path on click, setOptions ONLY:
@@ -19,7 +22,8 @@ chrome.action.onClicked.addListener(async () => {
try { await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] }); } catch {}
}
await chrome.sidePanel.setOptions({ tabId: tab.id, path: "panel.html" }); // Chrome opens it
await chrome.sidePanel.setOptions({ tabId: tab.id, path: "panel.html" });
try { await chrome.sidePanel.open({ tabId: tab.id }); } catch {}
});
// Keyboard shortcut: we do open() here (this is a user gesture)

View File

@@ -6,6 +6,16 @@ if (window.__dewemoji_cs_installed) {
window.__dewemoji_cs_installed = true;
// (keep the rest of your file below as-is)
// Throttle to animation frame to avoid excessive work on rapid events
function rafThrottle(fn){
let scheduled = false;
return function(...args){
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => { scheduled = false; fn.apply(this,args); });
};
}
let lastEditable = null; // HTMLElement (input/textarea/contenteditable root)
let lastRange = null; // Range for contenteditable
let lastStart = 0, lastEnd = 0;
@@ -59,10 +69,17 @@ if (window.__dewemoji_cs_installed) {
captureCaret();
// Use a non-throttled handler for focusin so we snapshot *before* focus leaves to the side panel.
document.addEventListener('focusin', captureCaret, true);
document.addEventListener('keyup', captureCaret, true);
document.addEventListener('mouseup', captureCaret, true);
document.addEventListener('selectionchange', captureCaret, true);
// Throttled handlers for noisy updates
const captureCaretThrottled = rafThrottle(captureCaret);
document.addEventListener('keyup', captureCaretThrottled, true);
document.addEventListener('mouseup', captureCaretThrottled, true);
document.addEventListener('selectionchange', captureCaretThrottled, true);
// Extra safety: when the page is about to hide (e.g., side panel opens), keep the last known caret
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') captureCaret(); }, true);
// Attempt to insert text at the last saved position
function insertAtCaret(text) {

View File

@@ -1,41 +1,65 @@
{
"name": "Dewemoji - Emojis Made Effortless",
"description": "Search emojis and insert into any text field.",
"version": "1.0.0",
"manifest_version": 3,
"name": "Dewemoji - Emojis Made Effortless",
"description": "Find and copy emojis instantly. Optional Pro license unlocks tone lock, insert mode, and more.",
"version": "1.0.0",
"offline_enabled": false,
"permissions": [
"storage",
"scripting",
"activeTab",
"sidePanel"
],
"host_permissions": [
"<all_urls>",
"https://api.dewemoji.com/*"
],
"web_accessible_resources": [
{
"resources": [
"assets/*.png",
"assets/*.svg",
"styles.css"
],
"matches": ["<all_urls>"]
}
],
"homepage_url": "https://dewemoji.com",
"manifest_version": 3,
"permissions": ["storage", "scripting", "activeTab", "sidePanel", "identity", "identity.email"],
"minimum_chrome_version": "114",
"side_panel": {
"default_path": "panel.html"
},
"side_panel": {
"default_path": "panel.html"
},
"host_permissions": ["<all_urls>"],
"action": {
"default_title": "Dewemoji",
"default_icon": {
"16": "assets/icon-16.png",
"32": "assets/icon-32.png",
"48": "assets/icon-48.png",
"128": "assets/icon-128.png"
}
},
"action": {
"default_title": "Emoji Widget"
},
"icons": {
"16": "assets/icon-16.png",
"32": "assets/icon-32.png",
"48": "assets/icon-48.png",
"128": "assets/icon-128.png"
},
"background": {
"service_worker": "background.js"
},
"background": {
"service_worker": "background.js"
},
"commands": {
"toggle-panel": {
"suggested_key": {
"default": "Ctrl+Shift+E",
"mac": "Command+Shift+E"
},
"description": "Toggle Emoji Side Panel"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
]
"commands": {
"toggle-panel": {
"suggested_key": {
"default": "Ctrl+Shift+E",
"mac": "Command+Shift+E"
},
"description": "Toggle Emoji Side Panel"
}
}
}

View File

@@ -11,7 +11,7 @@
<header class="hdr">
<h1 class="ttl">Find Your Emoji <span id="dewemoji-status" class="tag free">Free</span></h1>
<div class="bar">
<input id="q" class="inp" type="text" placeholder="Search (e.g. love)" />
<input id="q" class="inp" type="text" placeholder="Search (e.g. love)" aria-label="Search emojis" />
<button id="clear" class="btn icon" title="Clear" aria-label="Clear"></button>
<button id="theme" class="btn icon" title="Toggle theme" aria-label="Toggle theme">🌙</button>
<button id="settings" class="btn icon" title="Settings" aria-label="Settings">⚙️</button>
@@ -37,7 +37,7 @@
<!-- Settings sheet -->
<div id="sheet-backdrop" class="backdrop" hidden></div>
<aside id="settings-sheet" class="sheet" hidden role="dialog" aria-modal="true" aria-labelledby="settings-title">
<aside id="settings-sheet" class="sheet" hidden role="dialog" aria-modal="true" aria-labelledby="settings-title" aria-describedby="license-status">
<div class="sheet-head">
<h3 id="settings-title">Settings</h3>
<button id="sheet-close" class="btn icon" title="Close" aria-label="Close"></button>
@@ -79,7 +79,7 @@
<div class="field">
<label class="lbl">License</label>
<div class="row">
<input id="license-key" class="inp" placeholder="Enter license key" type="password">
<input id="license-key" class="inp" placeholder="Enter license key" type="password" autocomplete="off">
<button id="license-activate" class="btn">Activate</button>
<button id="license-deactivate" class="btn ghost">Deactivate</button>
</div>

595
panel.js
View File

@@ -4,6 +4,7 @@ let licenseValid = false; // <- default: Free
// Persistent settings
let actionMode = 'copy'; // 'copy' | 'insert' | 'auto'
let licenseKeyCurrent = '';
// Tone settings (persisted in sync storage)
let toneLock = false; // if true, always use preferred tone
@@ -22,17 +23,44 @@ const licenseDeactivateBtn = document.getElementById('license-deactivate');
const licenseEditBtn = document.getElementById('license-edit');
const licenseCancelEditBtn = document.getElementById('license-cancel-edit');
// --- License busy helpers (UI feedback for activate/deactivate) ---
let __licensePrev = { text: '', disabled: false };
function setLicenseBusy(on, label){
const btn = licenseActivateBtn;
if (!btn) return;
if (on) {
__licensePrev.text = btn.textContent;
__licensePrev.disabled = btn.disabled;
btn.textContent = label || 'Verifying…';
btn.disabled = true;
licenseKeyEl && (licenseKeyEl.disabled = true);
licenseDeactivateBtn && (licenseDeactivateBtn.disabled = true);
licenseEditBtn && (licenseEditBtn.disabled = true);
licenseCancelEditBtn && (licenseCancelEditBtn.disabled = true);
licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…');
} else {
btn.textContent = __licensePrev.text || 'Activate';
btn.disabled = __licensePrev.disabled || false;
// Restore enabled state based on current license view
applyLicenseUI();
}
}
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
base: "https://api.dewemoji.com/v1",
list: "/emojis"
};
API.cats = "/categories"; // endpoint for categories map
API.cats = "/categories";
let PAGE_LIMIT = 20; // Free default
function refreshPageLimit(){
PAGE_LIMIT = licenseValid ? 50 : 20; // Pro gets bigger pages
}
refreshPageLimit();
const FRONTEND_ID = 'ext-v1';
let CAT_MAP = null; // { "Category": ["sub1","sub2", ...], ... }
@@ -49,6 +77,13 @@ const PREFERRED_CATEGORY_ORDER = [
"Flags"
];
const CAT_INDEX = Object.fromEntries(PREFERRED_CATEGORY_ORDER.map((c, i) => [c, i]));
// Minimal local subcategories to enable the sub-select even without API
const LOCAL_SUBS = {
'Travel & Places': ['place-religious','place-geographic','transport-water'],
'People & Body': ['hand-fingers-open','hand-fingers-partial','person-gesture'],
'Animals & Nature': ['animal-mammal','animal-bird','animal-bug'],
'Activities': ['sport','game','event'],
};
function categoryComparator(a, b) {
const ia = CAT_INDEX[a];
const ib = CAT_INDEX[b];
@@ -59,14 +94,21 @@ function categoryComparator(a, b) {
}
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);
// Try live endpoint; fall back silently to local list (no subs)
let ok = false;
try {
const res = await fetch(`${API.base}${API.cats}`);
if (res && res.ok) {
CAT_MAP = await res.json();
ok = true;
}
} catch (_) {}
if (!ok) {
// Silent fallback with a minimal subcategory seed
CAT_MAP = Object.fromEntries(PREFERRED_CATEGORY_ORDER.map(c => [c, LOCAL_SUBS[c] || []]));
}
populateCategorySelect();
}
function populateCategorySelect() {
@@ -100,9 +142,12 @@ function populateSubcategorySelect(category) {
subSel.appendChild(opt);
});
subSel.disabled = subs.length === 0;
subSel.classList.toggle('opacity-50', subs.length === 0);
subSel.classList.toggle('pointer-events-none', subs.length === 0);
}
let page = 1, total = 0, items = [];
let lastServerTier = null; // 'pro' | null (free) | undefined (no response yet)
const q = document.getElementById('q');
const cat = document.getElementById('cat');
const sub = document.getElementById('sub');
@@ -125,6 +170,50 @@ function setVersionBadge() {
}
setVersionBadge();
// --- Alert helper for messages (warn/error, light/dark theme) ---
function buildAlert(kind, title, desc){
const isDark = document.documentElement.classList.contains('theme-dark');
const tone = (kind === 'error')
? (isDark
? { bg:'#3b0f0f', border:'#7f1d1d', text:'#fecaca', icon:'‼' }
: { bg:'#fee2e2', border:'#fecaca', text:'#7f1d1d', icon:'‼' })
: (isDark
? { bg:'#3b2f0f', border:'#a16207', text:'#fde68a', icon:'⚠️' }
: { bg:'#fef3c7', border:'#fde68a', text:'#92400e', icon:'⚠️' });
const wrap = document.createElement('div');
wrap.style.gridColumn = '1 / -1';
wrap.style.background = tone.bg;
wrap.style.border = `1px solid ${tone.border}`;
wrap.style.color = tone.text;
wrap.style.borderRadius = '10px';
wrap.style.padding = '10px 12px';
wrap.style.display = 'flex';
wrap.style.alignItems = 'flex-start';
wrap.style.gap = '10px';
wrap.className = 'dewemoji-alert';
const ico = document.createElement('span');
ico.textContent = tone.icon;
ico.style.fontSize = '18px';
ico.style.lineHeight = '20px';
const body = document.createElement('div');
const h = document.createElement('div');
h.textContent = title || '';
h.style.fontWeight = '600';
h.style.marginBottom = desc ? '2px' : '0';
const p = document.createElement('div');
p.textContent = desc || '';
p.style.opacity = '0.9';
p.style.fontSize = '12.5px';
body.appendChild(h);
if (desc) body.appendChild(p);
wrap.append(ico, body);
return wrap;
}
// --- Loading spinner (flip-flop with Load More) ---
let spinnerEl = null;
function showSpinner() {
@@ -223,57 +312,342 @@ async function setToneLock(val){
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());
// === P0: Usage & Cache ===
const CLIENT_FREE_DAILY_LIMIT = 30; // can be tuned; Pro => unlimited
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
const QUERY_CACHE = new Map(); // in-memory cache: key => {ts, data}
const PREFETCHING = new Set(); // tracks in-flight prefetch keys (sig|page)
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 todayKeyUTC(){
const d = new Date();
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth()+1).padStart(2,'0');
const day = String(d.getUTCDate()).padStart(2,'0');
return `usage_${y}${m}${day}`;
}
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 getDailyUsage(){
const key = todayKeyUTC();
const got = await chrome.storage.local.get([key]);
return got[key] || { used: 0, limit: CLIENT_FREE_DAILY_LIMIT };
}
async function setDailyUsage(obj){
const key = todayKeyUTC();
await chrome.storage.local.set({ [key]: obj });
}
function normalize(str){ return String(str||'').trim().toLowerCase(); }
function slugifyLabel(s){
const x = normalize(s).replace(/&/g,'and').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');
return x || 'all';
}
function signatureFor(qVal, catVal, subVal){
const qn = normalize(qVal);
const cats = slugifyLabel(catVal || 'all');
const subs = subVal ? slugifyLabel(subVal) : '';
return `${qn}|${cats}|${subs}`;
}
async function getPersistedCache(){
const got = await chrome.storage.local.get(['searchCache']);
return got.searchCache || {}; // { key: { ts, data } }
}
async function savePersistedCache(cacheObj){
await chrome.storage.local.set({ searchCache: cacheObj });
}
function isFresh(ts){ return (Date.now() - ts) < CACHE_TTL_MS; }
function buildHeaders(){
const base = { 'X-Dewemoji-Frontend': FRONTEND_ID };
if (licenseValid && licenseKeyCurrent) {
try {
const acctPromise = accountId();
return acctPromise.then(acct => ({
...base,
// New preferred auth
'Authorization': `Bearer ${licenseKeyCurrent}`,
// Legacy headers kept for backward compatibility
'X-License-Key': licenseKeyCurrent,
'X-Account-Id': acct.id
})).catch(()=>base);
} catch {
return Promise.resolve({ ...base, 'Authorization': `Bearer ${licenseKeyCurrent}`, 'X-License-Key': licenseKeyCurrent });
}
}
return Promise.resolve(base);
}
async function prefetchNextIfNeeded(currentSig){
try {
// only prefetch when there are more results and we know the next page index
const nextPage = page + 1;
if (!(items.length < total && total > 0)) return;
const key = `${currentSig}|${nextPage}`;
if (QUERY_CACHE.has(key) || PREFETCHING.has(key)) return;
// check persisted cache
const persisted = await getPersistedCache();
const ent = persisted[key];
if (ent && isFresh(ent.ts)) {
QUERY_CACHE.set(key, ent); // lift to memory
return;
}
PREFETCHING.add(key);
// Build URL for next page
const params = new URLSearchParams({ page: String(nextPage), limit: String(PAGE_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()}`;
// headers (with optional license/account)
const headers = await buildHeaders();
// fetch quietly; avoid throwing UI errors — prefetch is best-effort
const res = await fetch(url, { cache: 'no-store', headers });
if (!res.ok) { PREFETCHING.delete(key); return; }
const data = await res.json().catch(()=>null);
if (!data || !Array.isArray(data.items)) { PREFETCHING.delete(key); return; }
const record = { ts: Date.now(), data };
QUERY_CACHE.set(key, record);
const persisted2 = await getPersistedCache();
persisted2[key] = record;
// prune stale
for (const k of Object.keys(persisted2)) { if (!persisted2[k] || !isFresh(persisted2[k].ts)) delete persisted2[k]; }
await savePersistedCache(persisted2);
} catch { /* silent */ }
finally { PREFETCHING.delete(`${currentSig}|${page+1}`); }
}
async function fetchPage(reset=false) {
more.disabled = true;
refreshPageLimit();
if (reset) { page = 1; items = []; list.innerHTML = ""; }
if (reset) { PREFETCHING.clear(); }
if (reset) { autoLoadBusy = false; }
// Build signature and check limits only for page 1
const sig = signatureFor(q.value, cat.value, sub.value);
// Gate on soft cap for Free (Pro unlimited)
if (!licenseValid && page === 1) {
const usage = await getDailyUsage();
const limit = usage.limit ?? CLIENT_FREE_DAILY_LIMIT;
// Check persisted cache first; a cached result does not consume usage
const persisted = await getPersistedCache();
const entry = persisted[sig];
const hasFreshPersist = entry && isFresh(entry.ts);
const hasFreshMem = QUERY_CACHE.has(`${sig}|1`) && isFresh(QUERY_CACHE.get(`${sig}|1`).ts);
if (usage.used >= limit && !hasFreshPersist && !hasFreshMem) {
// At cap and no cache — block network to be fair to server & UX
showToast('Daily limit reached — Upgrade to Pro');
updateFooter();
return;
}
}
try {
showSpinner();
// Cache key per page
const key = `${sig}|${page}`;
// 1) Try memory cache
const mem = QUERY_CACHE.get(key);
if (mem && isFresh(mem.ts)) {
const data = mem.data;
total = data.total || 0;
for (const e of (data.items || [])) { items.push(e); renderCard(e); }
// Kick off background prefetch for the next page (cached only)
const currentSig = signatureFor(q.value, cat.value, sub.value);
prefetchNextIfNeeded(currentSig).catch(()=>{});
try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {}
updateFooter();
return;
}
// 2) Try persisted cache
const persisted = await getPersistedCache();
const ent = persisted[key];
if (ent && isFresh(ent.ts)) {
total = ent.data.total || 0;
for (const e of (ent.data.items || [])) { items.push(e); renderCard(e); }
QUERY_CACHE.set(key, { ts: ent.ts, data: ent.data }); // promote to mem
// Kick off background prefetch for the next page (cached only)
const currentSig = signatureFor(q.value, cat.value, sub.value);
prefetchNextIfNeeded(currentSig).catch(()=>{});
try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {}
updateFooter();
return;
}
// 3) Network fetch
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_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()}`;
// (usage increment moved to after successful fetch)
const headers = await buildHeaders();
// Optional nicety: offline guard (early exit if no network AND no fresh cache)
if (!navigator.onLine) {
const hasFreshMem = mem && isFresh(mem.ts);
const hasFreshPersist = ent && isFresh(ent.ts);
if (!hasFreshMem && !hasFreshPersist) {
throw new Error('offline');
}
}
const res = await fetch(url, { cache: 'no-store', headers });
if (!res.ok) throw new Error(`API ${res.status}`);
lastServerTier = res.headers.get('X-Dewemoji-Tier'); // 'pro' for Pro; null for Free/whitelist
const data = await res.json();
total = data.total || 0;
if (page === 1 && (!Array.isArray(data.items) || data.items.length === 0)) {
list.innerHTML = '';
items = [];
list.appendChild(buildAlert('warn', 'No results', 'Try another keyword, category, or tone.'));
setLoadMore('Load more', { hidden: true, disabled: true });
updateFooter();
return;
}
// Count usage only on successful network responses for Free, page 1, and when not cached
if (!licenseValid && page === 1) {
const cacheHas = !!(mem && isFresh(mem.ts)) || !!(ent && isFresh(ent.ts));
if (!cacheHas) {
const usage = await getDailyUsage();
const limit = usage.limit ?? CLIENT_FREE_DAILY_LIMIT;
usage.used = Math.min(limit, (usage.used || 0) + 1);
await setDailyUsage(usage);
}
}
// Save to caches
const record = { ts: Date.now(), data };
QUERY_CACHE.set(key, record);
const persisted2 = await getPersistedCache();
persisted2[key] = record;
// prune old entries occasionally (simple heuristic)
const now = Date.now();
for (const k of Object.keys(persisted2)) {
if (!persisted2[k] || !isFresh(persisted2[k].ts)) delete persisted2[k];
}
await savePersistedCache(persisted2);
// Render
for (const e of (data.items || [])) { items.push(e); renderCard(e); }
// Kick off background prefetch for the next page (cached only)
const currentSig = signatureFor(q.value, cat.value, sub.value);
prefetchNextIfNeeded(currentSig).catch(()=>{});
try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {}
updateFooter();
} catch (err) {
console.error('Fetch failed', err);
if (reset || page === 1) {
list.innerHTML = '';
items = []; total = 0;
}
list?.appendChild(buildAlert('error', 'Failed to load', 'Check your internet connection and try again.'));
if (page > 1) {
expectRetry = true;
setLoadMore('Retry', { hidden: false, disabled: false });
} else {
setLoadMore('Load more', { hidden: true, disabled: true });
}
} finally {
hideSpinner();
}
}
let autoObserver = null;
let autoLoadBusy = false;
let autoSentinel = null; // thin element placed right after the grid list
function ensureSentinel(){
if (autoSentinel && autoSentinel.isConnected) return autoSentinel;
autoSentinel = document.getElementById('autoload-sentinel');
if (!autoSentinel) {
autoSentinel = document.createElement('div');
autoSentinel.id = 'autoload-sentinel';
autoSentinel.style.cssText = 'width:100%;height:1px;';
}
try {
// Place immediately AFTER the grid list so intersecting means we reached the end
if (list && autoSentinel.parentElement !== list.parentElement) {
list.insertAdjacentElement('afterend', autoSentinel);
}
} catch(_) {}
return autoSentinel;
}
function setupAutoLoadObserver(){
const sentinel = ensureSentinel();
if (!sentinel) return;
if (autoObserver) {
try { autoObserver.unobserve(sentinel); } catch(_) {}
} else {
autoObserver = new IntersectionObserver((entries)=>{
const hit = entries.some(e => e.isIntersecting);
if (!hit) return;
if (autoLoadBusy) return;
const loading = !!spinnerEl;
const canLoad = items.length < total && total > 0;
if (loading || !canLoad) return;
autoLoadBusy = true;
setLoadMore('Loading…', { disabled: true });
page += 1;
fetchPage(false).finally(()=>{ autoLoadBusy = false; });
}, { root: null, rootMargin: '300px', threshold: 0 });
}
try { autoObserver.observe(sentinel); } catch(_) {}
}
// --- Load More helpers ---
let expectRetry = false;
function setLoadMore(label, opts = {}) {
if (!more) return;
if (label != null) more.textContent = label;
if (opts.hidden === true) more.classList.add('hidden');
if (opts.hidden === false) more.classList.remove('hidden');
if (typeof opts.disabled === 'boolean') more.disabled = opts.disabled;
}
async function updateFooter() {
// results text stays
count.textContent = `${items.length} / ${total}`;
// usage text append (right-aligned feel handled by CSS; here we append visually)
try {
const { used, limit } = await getDailyUsage();
const isServerPro = lastServerTier === 'pro';
const suffix = (licenseValid || isServerPro) ? '' : (limit != null ? ` · ${used} / ${limit} today` : '');
count.textContent = `${items.length} / ${total}${suffix}`;
} catch {}
const loading = !!spinnerEl;
const canLoadMore = items.length < total && total > 0;
if (canLoadMore) {
expectRetry = false;
setLoadMore('Load more', { hidden: loading, disabled: loading });
} else {
setLoadMore('Load more', { hidden: true, disabled: true });
}
// ensure auto-load observer is active when more is visible
try { setupAutoLoadObserver(); } catch(_) {}
}
async function ensureContentScript(tabId) {
@@ -344,6 +718,19 @@ async function insert(text, opts = {}) {
}
}
async function migrateDailyLimit(){
try{
const usage = await getDailyUsage();
const target = CLIENT_FREE_DAILY_LIMIT; // 30
if (!licenseValid) {
if (typeof usage.limit !== 'number' || usage.limit > target) {
usage.limit = target;
await setDailyUsage(usage);
}
}
}catch{}
}
function updateClearButtonIcon() {
if (!clearBtn) return;
const hasText = q.value.trim().length > 0;
@@ -388,6 +775,13 @@ clearBtn.addEventListener('click', () => {
}
});
more.addEventListener('click', () => {
if (expectRetry) {
// retry same page without incrementing
expectRetry = false;
setLoadMore('Loading…', { disabled: true });
fetchPage(false);
return;
}
more.disabled = true;
page += 1;
fetchPage(false);
@@ -444,13 +838,22 @@ function buildTonePopover(base, activeIdx, onPick){
// initial
loadCategories().catch(console.error);
fetchPage(true).catch(console.error);
(async () => {
try {
await loadSettings();
await migrateDailyLimit();
} catch(_) {}
await fetchPage(true).catch(console.error);
try { setupAutoLoadObserver(); } catch(_) {}
})();
async function loadSettings() {
const data = await chrome.storage.local.get(['licenseValid', 'licenseKey', 'actionMode']);
licenseValid = !!data.licenseValid;
refreshPageLimit();
actionMode = data.actionMode || 'copy';
if (data.licenseKey) licenseKeyEl && (licenseKeyEl.value = data.licenseKey);
licenseKeyCurrent = data.licenseKey || '';
applyLicenseUI();
applyModeUI();
setStatusTag();
@@ -690,25 +1093,38 @@ modeGroup?.addEventListener('change', async (e) => {
licenseActivateBtn?.addEventListener('click', async () => {
const key = (licenseKeyEl?.value || '').trim();
if (!key) { showToast('Enter a license key'); return; }
// TODO: replace with server activation; for now treat as success in dev
// Example:
// const acct = await accountId();
// const res = await fetch('https://YOUR-API/license/activate', {
// method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ key, account_id: acct.id, version: chrome.runtime.getManifest().version })
// });
// const data = await res.json(); if (!res.ok || !data.ok) throw new Error(data.message || 'Invalid license');
try {
setLicenseBusy(true, 'Verifying…');
// Call your API /license/verify (works for Gumroad and Mayar)
const acct = await accountId();
const res = await fetch('https://api.dewemoji.com/v1/license/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, account_id: acct.id, version: chrome.runtime.getManifest().version })
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data?.error || `Verify ${res.status}`);
licenseValid = true;
refreshPageLimit();
licenseKeyCurrent = key;
await chrome.storage.local.set({ licenseValid: true, licenseKey: key, lastLicenseCheck: Date.now() });
applyLicenseUI();
setLicenseEditMode(false);
setStatusTag();
showToast('License activated ✓');
// Refresh results so Pro headers/tier take effect immediately
fetchPage(true).catch(console.error);
setLicenseBusy(false);
} catch (e) {
setLicenseBusy(false);
licenseValid = false;
await chrome.storage.local.set({ licenseValid: false });
applyLicenseUI();
showToast('Activation failed');
setStatusTag();
showToast(`Activation failed${e?.message ? ': ' + e.message : ''}`);
}
});
@@ -726,6 +1142,7 @@ licenseDeactivateBtn?.addEventListener('click', async () => {
if (!licenseValid) return;
const ok = confirm('Deactivate Pro on this device?');
if (!ok) return;
setLicenseBusy(true, 'Deactivating…');
try {
// (Optional) TODO: call your API /license/deactivate here, e.g.:
// const acct = await accountId();
@@ -734,12 +1151,17 @@ licenseDeactivateBtn?.addEventListener('click', async () => {
// body: JSON.stringify({ key: (licenseKeyEl?.value || '').trim(), account_id: acct.id })
// });
licenseValid = false;
refreshPageLimit();
await chrome.storage.local.set({ licenseValid: false, licenseKey: '' });
if (licenseKeyEl) licenseKeyEl.value = '';
applyLicenseUI();
licenseKeyCurrent = '';
setLicenseEditMode(false);
showToast('License deactivated');
fetchPage(true).catch(console.error);
setLicenseBusy(false);
} catch (e) {
setLicenseBusy(false);
showToast('Could not deactivate');
}
});
@@ -958,23 +1380,24 @@ if (window.twemoji) {
console.log('Canonical from codepoints:', window.twemoji.convert.fromCodePoint(cp));
}
loadSettings().catch(() => {});
// Example verify-on-boot (disabled in dev):
// (async function verifyLicenseOnBoot(){
// const { licenseKey, lastLicenseCheck } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck']);
// if (!licenseKey) return;
// const day = 24*60*60*1000; if (Date.now() - (lastLicenseCheck||0) < day) return;
// try {
// const acct = await accountId();
// const res = await fetch('https://YOUR-API/license/verify', {
// method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ key: licenseKey, account_id: acct.id })
// });
// const data = await res.json();
// const ok = !!data.ok; await chrome.storage.local.set({ licenseValid: ok, lastLicenseCheck: Date.now() });
// licenseValid = ok; applyLicenseUI();
// } catch {}
// })();
(async function verifyLicenseOnBoot(){
try {
const { licenseKey, lastLicenseCheck } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck']);
if (!licenseKey) return;
licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…');
const day = 24*60*60*1000; if (Date.now() - (lastLicenseCheck||0) < day) return;
const acct = await accountId();
const res = await fetch('https://api.dewemoji.com/v1/license/verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: licenseKey, account_id: acct.id, version: chrome.runtime.getManifest().version })
});
const data = await res.json().catch(() => ({}));
const ok = !!data.ok;
await chrome.storage.local.set({ licenseValid: ok, lastLicenseCheck: Date.now() });
licenseValid = ok; refreshPageLimit(); applyLicenseUI(); setStatusTag();
licenseStatusEl && (licenseStatusEl.textContent = licenseValid ? 'Pro active' : 'Free mode — Pro features locked.');
} catch {}
})();
// Re-render tone section when opening settings (in case lock/pref changed during session)
const __openSheet = openSheet;

View File

@@ -18,7 +18,7 @@
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; }
.hdr { position:sticky; top:0; background:var(--bg); padding:10px 10px 6px; border-bottom:1px solid var(--br); }
.hdr { position:sticky; top:0; background:var(--bg); padding:10px 10px 6px; border-bottom:1px solid var(--br); z-index: 9;}
.ttl { margin:0 0 8px; font-size:16px; font-weight:700; }
.bar { display:flex; gap:6px; }
.inp { flex:1; padding:8px 8px; border:1px solid var(--br); border-radius:8px; background:var(--bg); color:var(--fg); }
@@ -36,6 +36,7 @@ html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
.card {
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:6px; padding:10px; border:1px solid var(--br); border-radius:12px; background:var(--bg);
cursor: pointer;
}
.card .emo { font-size:28px; line-height:1; }
.card .nm { font-size:12px; color:var(--mut); text-align:center; max-width:100%;
@@ -243,7 +244,7 @@ html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
/* License subtext wraps neatly */
#license-status { display: inline-block; margin-left: 8px; }
/* Tone palette spacing + buttons */
/* Tone palette spacin.g + buttons */
.tone-palette { display: flex; gap: 8px; flex-wrap: wrap; }
.tone-chip {
min-width: 40px; height: 36px;
@@ -254,3 +255,16 @@ html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
/* Toast a tad higher so it doesnt overlap sheet */
.toast { bottom: 18px; }
/* Make the top controls a proper stacking context above the grid */
.topbar, .filters-row {
position: sticky; /* or relative if not sticky */
z-index: 5;
background: var(--c-bg, #0f172a); /* ensure it has a solid bg in dark & light */
}
/* Or ensure the select dropdown sits above adjacent icons */
select#sub {
position: relative;
z-index: 6;
}