published version in webstore
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.DS_Store
|
||||||
BIN
assets/icon-128.png
Normal file
BIN
assets/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/icon-16.png
Normal file
BIN
assets/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 798 B |
BIN
assets/icon-32.png
Normal file
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
BIN
assets/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -1,6 +1,9 @@
|
|||||||
// Let Chrome open the panel when the toolbar icon is clicked
|
// 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.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:
|
// 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 {}
|
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)
|
// Keyboard shortcut: we do open() here (this is a user gesture)
|
||||||
|
|||||||
23
content.js
23
content.js
@@ -6,6 +6,16 @@ if (window.__dewemoji_cs_installed) {
|
|||||||
window.__dewemoji_cs_installed = true;
|
window.__dewemoji_cs_installed = true;
|
||||||
// (keep the rest of your file below as-is)
|
// (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 lastEditable = null; // HTMLElement (input/textarea/contenteditable root)
|
||||||
let lastRange = null; // Range for contenteditable
|
let lastRange = null; // Range for contenteditable
|
||||||
let lastStart = 0, lastEnd = 0;
|
let lastStart = 0, lastEnd = 0;
|
||||||
@@ -59,10 +69,17 @@ if (window.__dewemoji_cs_installed) {
|
|||||||
|
|
||||||
captureCaret();
|
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('focusin', captureCaret, true);
|
||||||
document.addEventListener('keyup', captureCaret, true);
|
|
||||||
document.addEventListener('mouseup', captureCaret, true);
|
// Throttled handlers for noisy updates
|
||||||
document.addEventListener('selectionchange', captureCaret, true);
|
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
|
// Attempt to insert text at the last saved position
|
||||||
function insertAtCaret(text) {
|
function insertAtCaret(text) {
|
||||||
|
|||||||
100
manifest.json
100
manifest.json
@@ -1,41 +1,65 @@
|
|||||||
{
|
{
|
||||||
"name": "Dewemoji - Emojis Made Effortless",
|
"name": "Dewemoji - Emojis Made Effortless",
|
||||||
"description": "Search emojis and insert into any text field.",
|
"description": "Find and copy emojis instantly. Optional Pro license unlocks tone lock, insert mode, and more.",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"manifest_version": 3,
|
"offline_enabled": false,
|
||||||
|
"permissions": [
|
||||||
"permissions": ["storage", "scripting", "activeTab", "sidePanel", "identity", "identity.email"],
|
"storage",
|
||||||
|
"scripting",
|
||||||
"side_panel": {
|
"activeTab",
|
||||||
"default_path": "panel.html"
|
"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,
|
||||||
|
|
||||||
"host_permissions": ["<all_urls>"],
|
"minimum_chrome_version": "114",
|
||||||
|
|
||||||
"action": {
|
|
||||||
"default_title": "Emoji Widget"
|
|
||||||
},
|
|
||||||
|
|
||||||
"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": [
|
"side_panel": {
|
||||||
{
|
"default_path": "panel.html"
|
||||||
"matches": ["<all_urls>"],
|
},
|
||||||
"js": ["content.js"],
|
|
||||||
"run_at": "document_idle",
|
"action": {
|
||||||
"all_frames": false
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
|
||||||
|
"commands": {
|
||||||
|
"toggle-panel": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Ctrl+Shift+E",
|
||||||
|
"mac": "Command+Shift+E"
|
||||||
|
},
|
||||||
|
"description": "Toggle Emoji Side Panel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<header class="hdr">
|
<header class="hdr">
|
||||||
<h1 class="ttl">Find Your Emoji <span id="dewemoji-status" class="tag free">Free</span></h1>
|
<h1 class="ttl">Find Your Emoji <span id="dewemoji-status" class="tag free">Free</span></h1>
|
||||||
<div class="bar">
|
<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="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="theme" class="btn icon" title="Toggle theme" aria-label="Toggle theme">🌙</button>
|
||||||
<button id="settings" class="btn icon" title="Settings" aria-label="Settings">⚙️</button>
|
<button id="settings" class="btn icon" title="Settings" aria-label="Settings">⚙️</button>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
<!-- Settings sheet -->
|
<!-- Settings sheet -->
|
||||||
<div id="sheet-backdrop" class="backdrop" hidden></div>
|
<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">
|
<div class="sheet-head">
|
||||||
<h3 id="settings-title">Settings</h3>
|
<h3 id="settings-title">Settings</h3>
|
||||||
<button id="sheet-close" class="btn icon" title="Close" aria-label="Close">✕</button>
|
<button id="sheet-close" class="btn icon" title="Close" aria-label="Close">✕</button>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="lbl">License</label>
|
<label class="lbl">License</label>
|
||||||
<div class="row">
|
<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-activate" class="btn">Activate</button>
|
||||||
<button id="license-deactivate" class="btn ghost">Deactivate</button>
|
<button id="license-deactivate" class="btn ghost">Deactivate</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
597
panel.js
597
panel.js
@@ -4,6 +4,7 @@ let licenseValid = false; // <- default: Free
|
|||||||
|
|
||||||
// Persistent settings
|
// Persistent settings
|
||||||
let actionMode = 'copy'; // 'copy' | 'insert' | 'auto'
|
let actionMode = 'copy'; // 'copy' | 'insert' | 'auto'
|
||||||
|
let licenseKeyCurrent = '';
|
||||||
|
|
||||||
// Tone settings (persisted in sync storage)
|
// Tone settings (persisted in sync storage)
|
||||||
let toneLock = false; // if true, always use preferred tone
|
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 licenseEditBtn = document.getElementById('license-edit');
|
||||||
const licenseCancelEditBtn = document.getElementById('license-cancel-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 diagRunBtn = document.getElementById('diag-run');
|
||||||
const diagSpin = document.getElementById('diag-spin');
|
const diagSpin = document.getElementById('diag-spin');
|
||||||
const diagOut = document.getElementById('diag-out');
|
const diagOut = document.getElementById('diag-out');
|
||||||
|
|
||||||
const API = {
|
const API = {
|
||||||
base: "https://emoji.dewe.pw/api",
|
base: "https://api.dewemoji.com/v1",
|
||||||
list: "/emojis",
|
list: "/emojis"
|
||||||
limit: 20 // lighter for extensions; your API caps at 50, docs try-it at 10
|
|
||||||
};
|
};
|
||||||
|
API.cats = "/categories";
|
||||||
API.cats = "/categories"; // endpoint for categories map
|
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", ...], ... }
|
let CAT_MAP = null; // { "Category": ["sub1","sub2", ...], ... }
|
||||||
|
|
||||||
@@ -49,6 +77,13 @@ const PREFERRED_CATEGORY_ORDER = [
|
|||||||
"Flags"
|
"Flags"
|
||||||
];
|
];
|
||||||
const CAT_INDEX = Object.fromEntries(PREFERRED_CATEGORY_ORDER.map((c, i) => [c, i]));
|
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) {
|
function categoryComparator(a, b) {
|
||||||
const ia = CAT_INDEX[a];
|
const ia = CAT_INDEX[a];
|
||||||
const ib = CAT_INDEX[b];
|
const ib = CAT_INDEX[b];
|
||||||
@@ -59,14 +94,21 @@ function categoryComparator(a, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCategories() {
|
async function loadCategories() {
|
||||||
try {
|
// Try live endpoint; fall back silently to local list (no subs)
|
||||||
const res = await fetch(`${API.base}${API.cats}`);
|
let ok = false;
|
||||||
if (!res.ok) throw new Error(`cats ${res.status}`);
|
try {
|
||||||
CAT_MAP = await res.json();
|
const res = await fetch(`${API.base}${API.cats}`);
|
||||||
populateCategorySelect();
|
if (res && res.ok) {
|
||||||
} catch (e) {
|
CAT_MAP = await res.json();
|
||||||
console.warn("Failed to load categories", e);
|
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() {
|
function populateCategorySelect() {
|
||||||
@@ -100,9 +142,12 @@ function populateSubcategorySelect(category) {
|
|||||||
subSel.appendChild(opt);
|
subSel.appendChild(opt);
|
||||||
});
|
});
|
||||||
subSel.disabled = subs.length === 0;
|
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 page = 1, total = 0, items = [];
|
||||||
|
let lastServerTier = null; // 'pro' | null (free) | undefined (no response yet)
|
||||||
const q = document.getElementById('q');
|
const q = document.getElementById('q');
|
||||||
const cat = document.getElementById('cat');
|
const cat = document.getElementById('cat');
|
||||||
const sub = document.getElementById('sub');
|
const sub = document.getElementById('sub');
|
||||||
@@ -125,6 +170,50 @@ function setVersionBadge() {
|
|||||||
}
|
}
|
||||||
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) ---
|
// --- Loading spinner (flip-flop with Load More) ---
|
||||||
let spinnerEl = null;
|
let spinnerEl = null;
|
||||||
function showSpinner() {
|
function showSpinner() {
|
||||||
@@ -223,57 +312,342 @@ async function setToneLock(val){
|
|||||||
let timer;
|
let timer;
|
||||||
function debounced(fn, delay=250){ clearTimeout(timer); timer = setTimeout(fn, delay); }
|
function debounced(fn, delay=250){ clearTimeout(timer); timer = setTimeout(fn, delay); }
|
||||||
|
|
||||||
async function fetchPage(reset=false) {
|
// === P0: Usage & Cache ===
|
||||||
more.disabled = true;
|
const CLIENT_FREE_DAILY_LIMIT = 30; // can be tuned; Pro => unlimited
|
||||||
if (reset) { page = 1; items = []; list.innerHTML = ""; }
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
||||||
try {
|
const QUERY_CACHE = new Map(); // in-memory cache: key => {ts, data}
|
||||||
showSpinner();
|
const PREFETCHING = new Set(); // tracks in-flight prefetch keys (sig|page)
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(page),
|
function todayKeyUTC(){
|
||||||
limit: String(API.limit)
|
const d = new Date();
|
||||||
});
|
const y = d.getUTCFullYear();
|
||||||
if (q.value.trim()) params.set('q', q.value.trim());
|
const m = String(d.getUTCMonth()+1).padStart(2,'0');
|
||||||
if (cat.value.trim()) params.set('category', cat.value.trim());
|
const day = String(d.getUTCDate()).padStart(2,'0');
|
||||||
if (sub.value.trim()) params.set('subcategory', sub.value.trim());
|
return `usage_${y}${m}${day}`;
|
||||||
|
|
||||||
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() {
|
async function getDailyUsage(){
|
||||||
count.textContent = `${items.length} / ${total}`;
|
const key = todayKeyUTC();
|
||||||
const loading = !!spinnerEl;
|
const got = await chrome.storage.local.get([key]);
|
||||||
if (items.length < total) {
|
return got[key] || { used: 0, limit: CLIENT_FREE_DAILY_LIMIT };
|
||||||
more.textContent = 'Load more';
|
}
|
||||||
more.disabled = loading;
|
async function setDailyUsage(obj){
|
||||||
// Only show button when not loading
|
const key = todayKeyUTC();
|
||||||
more.classList.toggle('hidden', loading);
|
await chrome.storage.local.set({ [key]: obj });
|
||||||
} else {
|
}
|
||||||
more.textContent = 'All loaded';
|
|
||||||
more.disabled = true;
|
function normalize(str){ return String(str||'').trim().toLowerCase(); }
|
||||||
// If fully loaded, keep it visible but disabled, or hide if you prefer:
|
function slugifyLabel(s){
|
||||||
// more.classList.add('hidden');
|
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) {
|
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() {
|
function updateClearButtonIcon() {
|
||||||
if (!clearBtn) return;
|
if (!clearBtn) return;
|
||||||
const hasText = q.value.trim().length > 0;
|
const hasText = q.value.trim().length > 0;
|
||||||
@@ -388,6 +775,13 @@ clearBtn.addEventListener('click', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
more.addEventListener('click', () => {
|
more.addEventListener('click', () => {
|
||||||
|
if (expectRetry) {
|
||||||
|
// retry same page without incrementing
|
||||||
|
expectRetry = false;
|
||||||
|
setLoadMore('Loading…', { disabled: true });
|
||||||
|
fetchPage(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
more.disabled = true;
|
more.disabled = true;
|
||||||
page += 1;
|
page += 1;
|
||||||
fetchPage(false);
|
fetchPage(false);
|
||||||
@@ -444,13 +838,22 @@ function buildTonePopover(base, activeIdx, onPick){
|
|||||||
|
|
||||||
// initial
|
// initial
|
||||||
loadCategories().catch(console.error);
|
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() {
|
async function loadSettings() {
|
||||||
const data = await chrome.storage.local.get(['licenseValid', 'licenseKey', 'actionMode']);
|
const data = await chrome.storage.local.get(['licenseValid', 'licenseKey', 'actionMode']);
|
||||||
licenseValid = !!data.licenseValid;
|
licenseValid = !!data.licenseValid;
|
||||||
|
refreshPageLimit();
|
||||||
actionMode = data.actionMode || 'copy';
|
actionMode = data.actionMode || 'copy';
|
||||||
if (data.licenseKey) licenseKeyEl && (licenseKeyEl.value = data.licenseKey);
|
if (data.licenseKey) licenseKeyEl && (licenseKeyEl.value = data.licenseKey);
|
||||||
|
licenseKeyCurrent = data.licenseKey || '';
|
||||||
applyLicenseUI();
|
applyLicenseUI();
|
||||||
applyModeUI();
|
applyModeUI();
|
||||||
setStatusTag();
|
setStatusTag();
|
||||||
@@ -690,25 +1093,38 @@ modeGroup?.addEventListener('change', async (e) => {
|
|||||||
licenseActivateBtn?.addEventListener('click', async () => {
|
licenseActivateBtn?.addEventListener('click', async () => {
|
||||||
const key = (licenseKeyEl?.value || '').trim();
|
const key = (licenseKeyEl?.value || '').trim();
|
||||||
if (!key) { showToast('Enter a license key'); return; }
|
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 {
|
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;
|
licenseValid = true;
|
||||||
|
refreshPageLimit();
|
||||||
|
licenseKeyCurrent = key;
|
||||||
await chrome.storage.local.set({ licenseValid: true, licenseKey: key, lastLicenseCheck: Date.now() });
|
await chrome.storage.local.set({ licenseValid: true, licenseKey: key, lastLicenseCheck: Date.now() });
|
||||||
|
|
||||||
applyLicenseUI();
|
applyLicenseUI();
|
||||||
setLicenseEditMode(false);
|
setLicenseEditMode(false);
|
||||||
|
setStatusTag();
|
||||||
showToast('License activated ✓');
|
showToast('License activated ✓');
|
||||||
|
|
||||||
|
// Refresh results so Pro headers/tier take effect immediately
|
||||||
|
fetchPage(true).catch(console.error);
|
||||||
|
setLicenseBusy(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLicenseBusy(false);
|
||||||
licenseValid = false;
|
licenseValid = false;
|
||||||
await chrome.storage.local.set({ licenseValid: false });
|
await chrome.storage.local.set({ licenseValid: false });
|
||||||
applyLicenseUI();
|
applyLicenseUI();
|
||||||
showToast('Activation failed');
|
setStatusTag();
|
||||||
|
showToast(`Activation failed${e?.message ? ': ' + e.message : ''}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -726,6 +1142,7 @@ licenseDeactivateBtn?.addEventListener('click', async () => {
|
|||||||
if (!licenseValid) return;
|
if (!licenseValid) return;
|
||||||
const ok = confirm('Deactivate Pro on this device?');
|
const ok = confirm('Deactivate Pro on this device?');
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
setLicenseBusy(true, 'Deactivating…');
|
||||||
try {
|
try {
|
||||||
// (Optional) TODO: call your API /license/deactivate here, e.g.:
|
// (Optional) TODO: call your API /license/deactivate here, e.g.:
|
||||||
// const acct = await accountId();
|
// const acct = await accountId();
|
||||||
@@ -734,12 +1151,17 @@ licenseDeactivateBtn?.addEventListener('click', async () => {
|
|||||||
// body: JSON.stringify({ key: (licenseKeyEl?.value || '').trim(), account_id: acct.id })
|
// body: JSON.stringify({ key: (licenseKeyEl?.value || '').trim(), account_id: acct.id })
|
||||||
// });
|
// });
|
||||||
licenseValid = false;
|
licenseValid = false;
|
||||||
|
refreshPageLimit();
|
||||||
await chrome.storage.local.set({ licenseValid: false, licenseKey: '' });
|
await chrome.storage.local.set({ licenseValid: false, licenseKey: '' });
|
||||||
if (licenseKeyEl) licenseKeyEl.value = '';
|
if (licenseKeyEl) licenseKeyEl.value = '';
|
||||||
applyLicenseUI();
|
applyLicenseUI();
|
||||||
|
licenseKeyCurrent = '';
|
||||||
setLicenseEditMode(false);
|
setLicenseEditMode(false);
|
||||||
showToast('License deactivated');
|
showToast('License deactivated');
|
||||||
|
fetchPage(true).catch(console.error);
|
||||||
|
setLicenseBusy(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLicenseBusy(false);
|
||||||
showToast('Could not deactivate');
|
showToast('Could not deactivate');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -958,23 +1380,24 @@ if (window.twemoji) {
|
|||||||
console.log('Canonical from codepoints:', window.twemoji.convert.fromCodePoint(cp));
|
console.log('Canonical from codepoints:', window.twemoji.convert.fromCodePoint(cp));
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSettings().catch(() => {});
|
(async function verifyLicenseOnBoot(){
|
||||||
// Example verify-on-boot (disabled in dev):
|
try {
|
||||||
// (async function verifyLicenseOnBoot(){
|
const { licenseKey, lastLicenseCheck } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck']);
|
||||||
// const { licenseKey, lastLicenseCheck } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck']);
|
if (!licenseKey) return;
|
||||||
// if (!licenseKey) return;
|
licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…');
|
||||||
// const day = 24*60*60*1000; if (Date.now() - (lastLicenseCheck||0) < day) return;
|
const day = 24*60*60*1000; if (Date.now() - (lastLicenseCheck||0) < day) return;
|
||||||
// try {
|
const acct = await accountId();
|
||||||
// const acct = await accountId();
|
const res = await fetch('https://api.dewemoji.com/v1/license/verify', {
|
||||||
// const res = await fetch('https://YOUR-API/license/verify', {
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
// method: 'POST', headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify({ key: licenseKey, account_id: acct.id, version: chrome.runtime.getManifest().version })
|
||||||
// body: JSON.stringify({ key: licenseKey, account_id: acct.id })
|
});
|
||||||
// });
|
const data = await res.json().catch(() => ({}));
|
||||||
// const data = await res.json();
|
const ok = !!data.ok;
|
||||||
// const ok = !!data.ok; await chrome.storage.local.set({ licenseValid: ok, lastLicenseCheck: Date.now() });
|
await chrome.storage.local.set({ licenseValid: ok, lastLicenseCheck: Date.now() });
|
||||||
// licenseValid = ok; applyLicenseUI();
|
licenseValid = ok; refreshPageLimit(); applyLicenseUI(); setStatusTag();
|
||||||
// } catch {}
|
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)
|
// Re-render tone section when opening settings (in case lock/pref changed during session)
|
||||||
const __openSheet = openSheet;
|
const __openSheet = openSheet;
|
||||||
|
|||||||
20
styles.css
20
styles.css
@@ -18,7 +18,7 @@
|
|||||||
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
|
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; }
|
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; }
|
.ttl { margin:0 0 8px; font-size:16px; font-weight:700; }
|
||||||
.bar { display:flex; gap:6px; }
|
.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); }
|
.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 {
|
.card {
|
||||||
display:flex; flex-direction:column; align-items:center; justify-content:center;
|
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);
|
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 .emo { font-size:28px; line-height:1; }
|
||||||
.card .nm { font-size:12px; color:var(--mut); text-align:center; max-width:100%;
|
.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 subtext wraps neatly */
|
||||||
#license-status { display: inline-block; margin-left: 8px; }
|
#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-palette { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.tone-chip {
|
.tone-chip {
|
||||||
min-width: 40px; height: 36px;
|
min-width: 40px; height: 36px;
|
||||||
@@ -253,4 +254,17 @@ html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
|
|||||||
.tone-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
|
.tone-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
|
||||||
|
|
||||||
/* Toast a tad higher so it doesn’t overlap sheet */
|
/* Toast a tad higher so it doesn’t overlap sheet */
|
||||||
.toast { bottom: 18px; }
|
.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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user