diff --git a/extension/popup/popup.css b/extension/popup/popup.css
new file mode 100644
index 0000000..7f796f8
--- /dev/null
+++ b/extension/popup/popup.css
@@ -0,0 +1,94 @@
+:root {
+ --bg: #14171A;
+ --surface: #1F2428;
+ --on-surface: #E8E4D8;
+ --on-surface-variant: rgba(232, 228, 216, 0.65);
+ --accent: #F4BA7A;
+ --error: #DC5050;
+ --success: #4CAF50;
+ --warning: #FFB300;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ min-width: 340px;
+ font: 14px/1.4 system-ui, sans-serif;
+ background: var(--bg);
+ color: var(--on-surface);
+}
+.hidden { display: none !important; }
+.topbar {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 10px 14px; background: var(--surface);
+}
+.brand { font-family: Georgia, serif; font-size: 16px; font-weight: 500; color: var(--accent); }
+.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--on-surface-variant); }
+.dot.connected { background: var(--success); }
+
+.setup { padding: 20px; text-align: center; }
+.alert {
+ background: var(--surface); padding: 12px; border-radius: 6px;
+ margin-bottom: 12px; border-left: 3px solid var(--accent);
+}
+.alert.alert-error { border-left-color: var(--error); }
+.alert strong { display: block; margin-bottom: 4px; }
+.alert p { margin: 0; color: var(--on-surface-variant); font-size: 13px; }
+
+.tabs { display: flex; border-bottom: 1px solid var(--surface); }
+.tab {
+ flex: 1; padding: 10px 0; background: none; border: none;
+ color: var(--on-surface-variant); cursor: pointer; font: inherit;
+ border-bottom: 2px solid transparent;
+}
+.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
+.tab-panel { padding: 10px; }
+
+.platform-card, .source-row {
+ display: flex; align-items: center; gap: 10px;
+ padding: 10px; margin-bottom: 6px; background: var(--surface);
+ border-radius: 6px; cursor: pointer;
+}
+.platform-card.disabled { opacity: 0.5; cursor: not-allowed; }
+.platform-card.loading { opacity: 0.6; cursor: wait; }
+.platform-icon {
+ width: 28px; height: 28px; border-radius: 50%;
+ display: grid; place-items: center; font-weight: 600; color: white;
+}
+.info { flex: 1; min-width: 0; }
+.info .name { font-weight: 500; }
+.info .status { font-size: 12px; color: var(--on-surface-variant); }
+.info .status.ready { color: var(--success); }
+.info .status.error { color: var(--error); }
+.info .status.no-cookies { color: var(--warning); }
+.action-icon { color: var(--on-surface-variant); }
+
+.btn {
+ padding: 10px 14px; border: none; border-radius: 6px; cursor: pointer;
+ font: 500 14px/1 system-ui, sans-serif;
+}
+.btn.primary { background: var(--accent); color: var(--bg); }
+.btn.primary:hover { filter: brightness(1.05); }
+.btn.primary:disabled { opacity: 0.5; cursor: not-allowed; }
+.btn.block { display: block; width: 100%; margin-top: 8px; }
+.btn.link { background: none; color: var(--on-surface-variant); padding: 4px; }
+.btn.link:hover { color: var(--accent); }
+
+.source-row .play {
+ background: none; border: none; color: var(--on-surface-variant);
+ cursor: pointer; padding: 4px;
+}
+.source-row .play:hover { color: var(--accent); }
+.source-row .url { font-size: 11px; color: var(--on-surface-variant); word-break: break-all; }
+
+.status-message {
+ padding: 10px 14px; margin: 8px 10px; border-radius: 6px;
+ background: var(--surface); border-left: 3px solid;
+}
+.status-message.success { border-left-color: var(--success); }
+.status-message.error { border-left-color: var(--error); }
+.status-message.warning { border-left-color: var(--warning); }
+
+.footer {
+ border-top: 1px solid var(--surface);
+ padding: 6px 10px; text-align: right;
+}
diff --git a/extension/popup/popup.html b/extension/popup/popup.html
new file mode 100644
index 0000000..d9d9ddf
--- /dev/null
+++ b/extension/popup/popup.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+ FabledCurator
+
+
+
+
+
+
+
Setup required
+
Configure the FC URL and extension API key.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extension/popup/popup.js b/extension/popup/popup.js
new file mode 100644
index 0000000..cc0ca7c
--- /dev/null
+++ b/extension/popup/popup.js
@@ -0,0 +1,239 @@
+document.addEventListener('DOMContentLoaded', init);
+
+const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000;
+
+async function init() {
+ try {
+ const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' });
+ if (!cfg || !cfg.apiUrl || !cfg.apiKey) {
+ showSetupRequired();
+ return;
+ }
+ document.getElementById('setup-required').classList.add('hidden');
+ document.getElementById('main-content').classList.remove('hidden');
+ setupEventListeners();
+ showPlatformsLoading();
+ testConnectionIfNeeded();
+ loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
+ } catch (e) {
+ showSetupRequired();
+ const alert = document.querySelector('#setup-required .alert');
+ alert.textContent = '';
+ const s = document.createElement('strong'); s.textContent = 'Error';
+ const p = document.createElement('p'); p.textContent = e.message;
+ alert.appendChild(s); alert.appendChild(p);
+ alert.classList.add('alert-error');
+ }
+}
+
+function showSetupRequired() {
+ document.getElementById('setup-required').classList.remove('hidden');
+ document.getElementById('main-content').classList.add('hidden');
+ document.getElementById('open-settings-btn').addEventListener('click', () => {
+ browser.runtime.openOptionsPage();
+ });
+}
+
+function showPlatformsLoading() {
+ const c = document.getElementById('platforms-list');
+ c.textContent = '';
+ const d = document.createElement('div');
+ d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
+ d.textContent = 'Loading platforms…';
+ c.appendChild(d);
+}
+
+async function testConnectionIfNeeded() {
+ const stored = await browser.storage.local.get(['lastConnectionTest', 'lastConnectionStatus']);
+ const now = Date.now();
+ if (now - (stored.lastConnectionTest || 0) < CONNECTION_TEST_INTERVAL && stored.lastConnectionStatus !== undefined) {
+ updateConnectionDot(stored.lastConnectionStatus);
+ if (!stored.lastConnectionStatus) showError('Cannot connect to backend (cached).');
+ return;
+ }
+ const r = await browser.runtime.sendMessage({ type: 'TEST_CONNECTION' });
+ await browser.storage.local.set({ lastConnectionTest: now, lastConnectionStatus: r.connected });
+ updateConnectionDot(r.connected);
+ if (!r.connected) showError(`Cannot connect to backend: ${r.error}`);
+}
+
+function updateConnectionDot(connected) {
+ const d = document.getElementById('connection-status');
+ d.classList.toggle('connected', connected);
+ d.title = connected ? 'Connected to FabledCurator' : 'Disconnected';
+}
+
+async function loadPlatformStatus() {
+ const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
+ const c = document.getElementById('platforms-list');
+ c.textContent = '';
+ for (const [key, platform] of Object.entries(PLATFORMS)) {
+ c.appendChild(createPlatformCard(key, platform, status[key] || {}));
+ }
+}
+
+function createPlatformCard(key, platform, status) {
+ const card = document.createElement('div');
+ card.className = 'platform-card';
+ card.dataset.platform = key;
+
+ const isTokenOnly = platform.authType === 'token' && !['discord', 'pixiv'].includes(key);
+ const discordNeedsToken = key === 'discord' && !status.hasToken;
+ if (isTokenOnly || discordNeedsToken) card.classList.add('disabled');
+
+ const icon = document.createElement('div');
+ icon.className = 'platform-icon';
+ icon.style.background = platform.color;
+ icon.textContent = platform.name[0];
+
+ const info = document.createElement('div');
+ info.className = 'info';
+ const name = document.createElement('div');
+ name.className = 'name';
+ name.textContent = platform.name;
+ const st = document.createElement('div');
+ st.className = `status ${statusClass(status, platform, key)}`;
+ st.textContent = statusText(status, platform, key);
+ info.appendChild(name); info.appendChild(st);
+
+ const act = document.createElement('span');
+ act.className = 'action-icon';
+ act.textContent = '↥';
+
+ card.appendChild(icon); card.appendChild(info); card.appendChild(act);
+
+ if (!isTokenOnly && !discordNeedsToken) {
+ card.addEventListener('click', () => exportPlatformCookies(key, card));
+ }
+ return card;
+}
+
+function statusText(s, platform, key) {
+ if (key === 'discord') return s.hasToken ? 'Token captured — ready' : 'Open Discord to capture token';
+ if (key === 'pixiv') return s.hasToken ? 'Token captured — ready' : 'Click to authenticate via OAuth';
+ if (platform.authType === 'token') return 'Manual token entry required';
+ if (s.error) return 'Error checking cookies';
+ if (!s.hasCookies || !s.cookieCount) return 'No cookies — log in first';
+ return `${s.cookieCount} cookies ready`;
+}
+function statusClass(s, platform, key) {
+ if (key === 'discord') return s.hasToken ? 'ready' : 'no-cookies';
+ if (key === 'pixiv') return s.hasToken ? 'ready' : 'no-cookies';
+ if (platform.authType === 'token') return 'no-cookies';
+ if (s.error) return 'error';
+ if (!s.hasCookies || !s.cookieCount) return 'no-cookies';
+ return 'ready';
+}
+
+async function exportPlatformCookies(key, card) {
+ card.classList.add('loading'); hideStatusMessage();
+ try {
+ const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key });
+ if (r.error) showError(r.error);
+ else {
+ const n = r.cookieCount ?? null;
+ const msg = n !== null
+ ? `${PLATFORMS[key].name}: ${n} cookies exported`
+ : `${PLATFORMS[key].name}: token exported`;
+ showSuccess(msg);
+ await loadPlatformStatus();
+ }
+ } catch (e) { showError(e.message); }
+ finally { card.classList.remove('loading'); }
+}
+
+async function exportAllCookies() {
+ const btn = document.getElementById('export-all-btn');
+ btn.disabled = true; btn.textContent = 'Exporting…'; hideStatusMessage();
+ try {
+ const r = await browser.runtime.sendMessage({ type: 'EXPORT_ALL_COOKIES' });
+ const wins = Object.values(r).filter(x => x.success).length;
+ const fails = Object.values(r).filter(x => !x.success && !x.skipped).length;
+ if (wins && !fails) showSuccess(`Exported ${wins} platforms`);
+ else if (wins) showWarning(`${wins} succeeded, ${fails} failed`);
+ else if (fails) showError('All exports failed. Are you logged in?');
+ else showWarning('Nothing to export');
+ await loadPlatformStatus();
+ } catch (e) { showError(e.message); }
+ finally { btn.disabled = false; btn.textContent = 'Export all platforms'; }
+}
+
+async function loadSources() {
+ const c = document.getElementById('sources-list');
+ c.textContent = '';
+ const d = document.createElement('div');
+ d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
+ d.textContent = 'Loading sources…';
+ c.appendChild(d);
+ const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' });
+ c.textContent = '';
+ if (r.error) {
+ const e = document.createElement('div');
+ e.style.cssText = 'padding:12px;color:var(--error);';
+ e.textContent = r.error;
+ c.appendChild(e);
+ return;
+ }
+ if (!r.sources || r.sources.length === 0) {
+ const empty = document.createElement('div');
+ empty.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
+ empty.textContent = 'No sources yet.';
+ c.appendChild(empty);
+ return;
+ }
+ for (const src of r.sources) c.appendChild(createSourceRow(src));
+}
+
+function createSourceRow(src) {
+ const row = document.createElement('div');
+ row.className = 'source-row';
+ const info = document.createElement('div');
+ info.className = 'info';
+ const name = document.createElement('div');
+ name.className = 'name';
+ name.textContent = `${src.platform} · #${src.id}`;
+ const url = document.createElement('div');
+ url.className = 'url';
+ url.textContent = src.url;
+ info.appendChild(name); info.appendChild(url);
+ const play = document.createElement('button');
+ play.className = 'play';
+ play.textContent = '▶';
+ play.title = 'Check now';
+ play.addEventListener('click', async () => {
+ play.disabled = true;
+ const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id });
+ play.disabled = false;
+ if (r.error) showError(r.error);
+ else showSuccess(`Triggered check for source #${src.id}`);
+ });
+ row.appendChild(info); row.appendChild(play);
+ return row;
+}
+
+function setupEventListeners() {
+ document.getElementById('export-all-btn').addEventListener('click', exportAllCookies);
+ document.getElementById('settings-btn').addEventListener('click', () => browser.runtime.openOptionsPage());
+ for (const tab of document.querySelectorAll('.tab')) {
+ tab.addEventListener('click', () => {
+ for (const t of document.querySelectorAll('.tab')) t.classList.remove('active');
+ tab.classList.add('active');
+ for (const p of document.querySelectorAll('.tab-panel')) p.classList.add('hidden');
+ document.getElementById(`tab-${tab.dataset.tab}`).classList.remove('hidden');
+ if (tab.dataset.tab === 'sources') loadSources();
+ });
+ }
+}
+
+function showSuccess(m) { showStatusMessage(m, 'success'); }
+function showError(m) { showStatusMessage(m, 'error'); }
+function showWarning(m) { showStatusMessage(m, 'warning'); }
+function showStatusMessage(text, kind) {
+ const el = document.getElementById('status-message');
+ el.textContent = text;
+ el.className = `status-message ${kind}`;
+ el.classList.remove('hidden');
+}
+function hideStatusMessage() {
+ document.getElementById('status-message').classList.add('hidden');
+}