fc3g(ext): popup UI — platforms + sources tabs, export-all CTA, status indicator
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
<title>FabledCurator</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="brand">FabledCurator</span>
|
||||
<span id="connection-status" class="dot" title="checking…"></span>
|
||||
</header>
|
||||
|
||||
<section id="setup-required" class="setup hidden">
|
||||
<div class="alert">
|
||||
<strong>Setup required</strong>
|
||||
<p>Configure the FC URL and extension API key.</p>
|
||||
</div>
|
||||
<button id="open-settings-btn" class="btn primary">Open settings</button>
|
||||
</section>
|
||||
|
||||
<section id="main-content" class="main hidden">
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="platforms">Platforms</button>
|
||||
<button class="tab" data-tab="sources">Sources</button>
|
||||
</nav>
|
||||
|
||||
<div id="tab-platforms" class="tab-panel">
|
||||
<div id="platforms-list"></div>
|
||||
<button id="export-all-btn" class="btn primary block">Export all platforms</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-sources" class="tab-panel hidden">
|
||||
<div id="sources-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="status-message" class="status-message hidden"></div>
|
||||
|
||||
<footer class="footer">
|
||||
<button id="settings-btn" class="btn link">Settings</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<script src="../lib/platforms.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user