From b7832c941d94009ccfa4e269751fc1c92b15346c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:35:55 -0400 Subject: [PATCH] =?UTF-8?q?fc3g(ext):=20popup=20UI=20=E2=80=94=20platforms?= =?UTF-8?q?=20+=20sources=20tabs,=20export-all=20CTA,=20status=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/popup/popup.css | 94 +++++++++++++++ extension/popup/popup.html | 47 ++++++++ extension/popup/popup.js | 239 +++++++++++++++++++++++++++++++++++++ 3 files changed, 380 insertions(+) create mode 100644 extension/popup/popup.css create mode 100644 extension/popup/popup.html create mode 100644 extension/popup/popup.js 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 + + +
+ FabledCurator + +
+ + + + + + + + + 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'); +}