9e387a0a09
Per Codex. A branded recovery card in app.html shows if the app hasn't mounted in 7s, or on a pre-mount JS error/unhandledrejection — with a "Refresh Upbeat Bytes" button. A chunk/preload failure (vite:preloadError) reloads once (sessionStorage-guarded). +layout calls window.__ubBooted() on mount to clear the card + timer. A pre-mount failure also fires a tiny anonymous client_error beacon; the admin Overview now shows "Load errors today" (red if >0) so we can see if blank-risk is happening in the wild. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1040 lines
54 KiB
Svelte
1040 lines
54 KiB
Svelte
<script>
|
||
import { onMount } from 'svelte';
|
||
import { goto } from '$app/navigation';
|
||
import { page } from '$app/stores';
|
||
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
||
import { auth, refresh } from '$lib/auth.svelte.js';
|
||
|
||
let stats = $state(null);
|
||
let feedback = $state([]);
|
||
let error = $state('');
|
||
|
||
// Analytics window (days). Windowed metrics — visitors, retention, funnel,
|
||
// sharing, daily trend, top lists — rescale to this; corpus totals don't.
|
||
let range = $state(30);
|
||
async function loadStats() {
|
||
stats = await getJSON('/api/admin/stats?days=' + range);
|
||
}
|
||
async function setRange(n) {
|
||
if (n === range) return;
|
||
range = n;
|
||
try { await loadStats(); } catch { error = "Couldn't load stats."; }
|
||
}
|
||
|
||
onMount(async () => {
|
||
if (!auth.ready) await refresh();
|
||
if (!auth.user || !auth.user.is_admin) {
|
||
goto('/', { replaceState: true });
|
||
return;
|
||
}
|
||
try {
|
||
await loadStats();
|
||
feedback = await getJSON('/api/admin/feedback');
|
||
candidates = await getJSON('/api/admin/candidates');
|
||
wpPool = await getJSON('/api/admin/word/pool');
|
||
} catch {
|
||
error = "Couldn't load stats.";
|
||
}
|
||
});
|
||
|
||
// --- Games: Daily Word pool ---
|
||
let wpWord = $state('');
|
||
let wpResult = $state(null); // lookup result for the current input
|
||
let wpPool = $state(null); // { '5': {curated, added[], total}, '6': {...} }
|
||
let wpMsg = $state('');
|
||
let wpTimer;
|
||
const canAddWord = $derived(!!wpResult && !!wpResult.variant && wpResult.in_dictionary && !wpResult.in_pool);
|
||
|
||
function onWpInput() {
|
||
wpMsg = '';
|
||
clearTimeout(wpTimer);
|
||
const w = wpWord.trim();
|
||
if (!w) { wpResult = null; return; }
|
||
wpTimer = setTimeout(async () => {
|
||
try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); }
|
||
catch { wpResult = null; }
|
||
}, 200);
|
||
}
|
||
async function addWord() {
|
||
if (!canAddWord) return;
|
||
try {
|
||
const res = await postJSON('/api/admin/word/pool', { word: wpWord.trim() });
|
||
wpPool = res.pool; wpWord = ''; wpResult = null; wpMsg = `Added “${res.word}” to the ${res.variant}-letter pool.`;
|
||
} catch (e) { wpMsg = e.message || 'Could not add that word.'; }
|
||
}
|
||
async function removeWord(w) {
|
||
try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); } catch { /* ignore */ }
|
||
}
|
||
|
||
const TABS = [
|
||
{ key: 'overview', label: 'Overview' },
|
||
{ key: 'content', label: 'Content' },
|
||
{ key: 'sources', label: 'Sources' },
|
||
{ key: 'audience', label: 'Audience' },
|
||
{ key: 'feedback', label: 'Feedback' },
|
||
{ key: 'games', label: 'Games' },
|
||
];
|
||
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
||
// Unknown ?section= values fall back to Overview so the page never renders blank.
|
||
let section = $derived(
|
||
VALID_SECTIONS.has($page.url.searchParams.get('section')) ? $page.url.searchParams.get('section') : 'overview'
|
||
);
|
||
|
||
function fdate(s) {
|
||
try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
|
||
catch { return s; }
|
||
}
|
||
// A UTC "YYYY-MM-DD HH:MM:SS" stamp → the viewer's local date + time.
|
||
function fwhen(s) {
|
||
if (!s) return 'now';
|
||
const d = new Date(s.replace(' ', 'T') + 'Z');
|
||
if (isNaN(d)) return s;
|
||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||
}
|
||
|
||
let sources = $derived(stats?.sources ?? []);
|
||
// status falls back to the legacy active flag during the migration window.
|
||
const sstatus = (s) => s.status || (s.active ? 'active' : 'paused');
|
||
// A 429 rest window that hasn't elapsed — polite wait, not breakage.
|
||
function rateLimited(s) {
|
||
if (!s.retry_after_at) return false;
|
||
const d = new Date(s.retry_after_at.replace(' ', 'T') + 'Z');
|
||
return !isNaN(d) && d > new Date();
|
||
}
|
||
let healthy = $derived(sources.filter((s) => sstatus(s) === 'active' && !s.failures && !s.review_flag).length);
|
||
let resting = $derived(sources.filter((s) => sstatus(s) === 'active' && s.failures > 0).length);
|
||
let flagged = $derived(sources.filter((s) => s.review_flag).length);
|
||
let paused = $derived(sources.filter((s) => sstatus(s) === 'paused').length);
|
||
let retired = $derived(sources.filter((s) => sstatus(s) === 'retired').length);
|
||
|
||
// Sources table filter.
|
||
let srcFilter = $state('all');
|
||
let shownSources = $derived(
|
||
srcFilter === 'healthy' ? sources.filter((s) => sstatus(s) === 'active' && !s.failures && !s.review_flag)
|
||
: srcFilter === 'resting' ? sources.filter((s) => sstatus(s) === 'active' && s.failures > 0)
|
||
: srcFilter === 'flagged' ? sources.filter((s) => s.review_flag)
|
||
: srcFilter === 'paused' ? sources.filter((s) => sstatus(s) === 'paused')
|
||
: srcFilter === 'retired' ? sources.filter((s) => sstatus(s) === 'retired')
|
||
: sources
|
||
);
|
||
|
||
// Lifecycle (status: active/paused/retired) keeps `active` mirrored server-side;
|
||
// visibility hides existing articles. Both optimistic with revert-on-failure.
|
||
async function setStatus(s, status) {
|
||
const prev = { status: s.status, active: s.active };
|
||
s.status = status; s.active = status === 'active' ? 1 : 0;
|
||
try { await postJSON(`/api/admin/sources/${s.id}/status`, { status }); }
|
||
catch { s.status = prev.status; s.active = prev.active; }
|
||
}
|
||
async function toggleVisible(s) {
|
||
const next = !s.content_visible;
|
||
if (!next && !confirm(`Hide all of “${s.name}”’s articles from the public feed and brief?\n(Reversible — nothing is deleted.)`)) return;
|
||
const prev = s.content_visible;
|
||
s.content_visible = next ? 1 : 0;
|
||
try { await postJSON(`/api/admin/sources/${s.id}/visibility`, { visible: next }); }
|
||
catch { s.content_visible = prev; }
|
||
}
|
||
// Flagging opens a small inline popover for the reason; clearing is immediate.
|
||
let flagging = $state(null); // the source being flagged
|
||
let flagReason = $state('');
|
||
function openFlag(s) { flagging = s; flagReason = ''; }
|
||
function cancelFlag() { flagging = null; flagReason = ''; }
|
||
async function confirmFlag() {
|
||
const s = flagging;
|
||
const reason = flagReason.trim() || null;
|
||
s.review_flag = 1; s.review_reason = reason; // optimistic
|
||
flagging = null; flagReason = '';
|
||
try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: true, reason }); }
|
||
catch { s.review_flag = 0; s.review_reason = null; }
|
||
}
|
||
async function clearReview(s) {
|
||
const prevR = s.review_reason;
|
||
s.review_flag = 0; s.review_reason = null; // optimistic
|
||
try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: false }); }
|
||
catch { s.review_flag = 1; s.review_reason = prevR; }
|
||
}
|
||
// Read-only spot-check of a live source — never mutates source state.
|
||
async function checkSource(s) {
|
||
s._checking = true; s._checkErr = ''; s._check = null;
|
||
try { s._check = await postJSON(`/api/admin/sources/${s.id}/preview`); }
|
||
catch (e) { s._checkErr = e?.message || 'Could not preview.'; }
|
||
finally { s._checking = false; }
|
||
}
|
||
function dismissCheck(s) { s._check = null; s._checkErr = ''; }
|
||
|
||
// --- Source candidates: supervised "add a source" pipeline ---
|
||
let candidates = $state([]);
|
||
let newFeedUrl = $state('');
|
||
let newFeedName = $state('');
|
||
let addBusy = $state(false);
|
||
let addErr = $state('');
|
||
let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
|
||
|
||
async function addCandidate() {
|
||
const url = newFeedUrl.trim();
|
||
if (!url || addBusy) return;
|
||
addBusy = true; addErr = '';
|
||
try {
|
||
const c = await postJSON('/api/admin/candidates', { feed_url: url, name: newFeedName.trim() || null });
|
||
candidates = [c, ...candidates.filter((x) => x.id !== c.id)];
|
||
newFeedUrl = ''; newFeedName = '';
|
||
} catch (e) {
|
||
addErr = e?.message || "Couldn't preview that feed.";
|
||
} finally {
|
||
addBusy = false;
|
||
}
|
||
}
|
||
// Merge the server-returned candidate (keeps status/updated_at/preview in sync)
|
||
// while preserving the transient UI-only fields (_cat/_activate/_err).
|
||
async function repreviewCandidate(c) {
|
||
c._err = '';
|
||
try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/preview`)); }
|
||
catch (e) { c._err = e?.message || 'Re-preview failed.'; }
|
||
}
|
||
async function promoteCandidate(c) {
|
||
c._err = '';
|
||
try {
|
||
const res = await postJSON(`/api/admin/candidates/${c.id}/promote`, {
|
||
default_category: (c._cat || '').trim() || null,
|
||
active: !!c._activate,
|
||
});
|
||
Object.assign(c, res.candidate);
|
||
await loadStats(); // surface the new (paused) source in the table below
|
||
} catch (e) {
|
||
c._err = e?.message || 'Promote failed.';
|
||
}
|
||
}
|
||
async function rejectCandidate(c) {
|
||
try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/reject`)); }
|
||
catch { /* leave as-is */ }
|
||
}
|
||
|
||
// Feedback inbox: filter + read/unread + delete.
|
||
let fbCat = $state('all'); // 'all' | 'unread' | a category
|
||
let shownFeedback = $derived(
|
||
fbCat === 'all' ? feedback
|
||
: fbCat === 'unread' ? feedback.filter((f) => !f.read_at)
|
||
: feedback.filter((f) => f.category === fbCat)
|
||
);
|
||
let fbCats = $derived([...new Set(feedback.map((f) => f.category))]);
|
||
let unreadCount = $derived(feedback.filter((f) => !f.read_at).length);
|
||
|
||
async function markRead(f, read) {
|
||
const prev = f.read_at;
|
||
f.read_at = read ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; // optimistic
|
||
feedback = [...feedback];
|
||
try {
|
||
await postJSON(`/api/admin/feedback/${f.id}/read`, { read });
|
||
} catch {
|
||
f.read_at = prev; // revert on failure
|
||
feedback = [...feedback];
|
||
}
|
||
}
|
||
async function removeFeedback(f) {
|
||
if (!confirm('Delete this feedback message? This cannot be undone.')) return;
|
||
const snapshot = feedback;
|
||
feedback = feedback.filter((x) => x.id !== f.id); // optimistic
|
||
try {
|
||
await delJSON(`/api/admin/feedback/${f.id}`);
|
||
} catch {
|
||
feedback = snapshot; // revert on failure
|
||
}
|
||
}
|
||
|
||
// In-site reply: small admin-only WYSIWYG (contenteditable + execCommand).
|
||
// The editor's HTML is sanitized SERVER-SIDE; the client just collects it.
|
||
let replyingId = $state(null);
|
||
let replyBusy = $state(false);
|
||
let replyErr = $state('');
|
||
let replyEmpty = $state(true);
|
||
let replyEditor = $state(null);
|
||
const SIZE_PX = { small: 13, normal: 15, large: 18, xlarge: 22 };
|
||
|
||
function openReply(f) { replyingId = f.id; replyErr = ''; replyEmpty = true; }
|
||
function cancelReply() { replyingId = null; replyErr = ''; }
|
||
function focusEditor(node) { queueMicrotask(() => node.focus()); }
|
||
function onEditorInput() { replyEmpty = !(replyEditor?.textContent || '').trim(); }
|
||
// Paste as plain text — never let rich/pasted HTML into the editor.
|
||
function onPaste(e) {
|
||
e.preventDefault();
|
||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||
document.execCommand('insertText', false, text);
|
||
}
|
||
// Bold/italic/lists via execCommand; styleWithCSS=false → semantic <b>/<i>/<ul>.
|
||
function cmd(c) {
|
||
replyEditor?.focus();
|
||
try { document.execCommand('styleWithCSS', false, false); } catch { /* not all browsers */ }
|
||
document.execCommand(c, false, null);
|
||
onEditorInput();
|
||
}
|
||
// Font size: wrap the selection in a span with a fixed, whitelisted px.
|
||
function setSize(key) {
|
||
const px = SIZE_PX[key];
|
||
if (!px) return;
|
||
replyEditor?.focus();
|
||
const sel = window.getSelection();
|
||
if (!sel || !sel.rangeCount) return;
|
||
const range = sel.getRangeAt(0);
|
||
if (range.collapsed) return; // need a selection to size
|
||
const span = document.createElement('span');
|
||
span.style.fontSize = px + 'px';
|
||
try { span.appendChild(range.extractContents()); range.insertNode(span); } catch { /* ignore */ }
|
||
sel.removeAllRanges();
|
||
onEditorInput();
|
||
}
|
||
async function sendReply(f) {
|
||
if (replyEmpty || replyBusy) return;
|
||
const html = replyEditor?.innerHTML || '';
|
||
replyBusy = true; replyErr = '';
|
||
try {
|
||
const res = await postJSON(`/api/admin/feedback/${f.id}/reply`, { html });
|
||
f.replies = [...(f.replies || []), res.reply];
|
||
f.read_at = f.read_at || res.reply.sent_at; // server marked it read
|
||
replyingId = null;
|
||
} catch (e) {
|
||
replyErr = e?.message || 'Could not send — your draft is kept.';
|
||
} finally {
|
||
replyBusy = false;
|
||
}
|
||
}
|
||
|
||
const SHARE_LABEL = {
|
||
share_ub: 'Copied UB link',
|
||
native_share: 'Native share',
|
||
copy_source: 'Copied source',
|
||
source_click: 'Source clicks (from /a)',
|
||
};
|
||
function pct(v, m) { return m > 0 ? Math.max(4, Math.round((v / m) * 100)) : 0; }
|
||
const max = (rows, key) => rows.reduce((m, r) => Math.max(m, r[key]), 0);
|
||
function coverage(part, whole) { return whole > 0 ? Math.round((100 * part) / whole) : 0; }
|
||
</script>
|
||
|
||
<header class="bar">
|
||
<div class="container inner">
|
||
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
||
<a class="back" href="/account"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Account</a>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="container page">
|
||
<h1>Dashboard</h1>
|
||
{#if error}
|
||
<p class="muted">{error}</p>
|
||
{:else if !stats}
|
||
<p class="muted">Loading…</p>
|
||
{:else}
|
||
<nav class="tabs" aria-label="Dashboard sections">
|
||
{#each TABS as t (t.key)}
|
||
<a href={'?section=' + t.key} class:active={section === t.key}>
|
||
{t.label}{#if t.key === 'feedback' && unreadCount}<span class="badge">{unreadCount}</span>{/if}
|
||
</a>
|
||
{/each}
|
||
</nav>
|
||
|
||
{#if section === 'overview' || section === 'content' || section === 'audience'}
|
||
<div class="rangepick">
|
||
<span class="rl">Window</span>
|
||
{#each [7, 30, 90] as d (d)}
|
||
<button class="chip" class:on={range === d} onclick={() => setRange(d)}>{d}d</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if section === 'overview'}
|
||
<!-- Attention Needed -->
|
||
{#if (stats.attention ?? []).length}
|
||
<div class="attn-strip">
|
||
{#each stats.attention as a}
|
||
<div class="attn {a.level}">{a.text}</div>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<div class="attn ok">✓ All clear — nothing needs attention right now.</div>
|
||
{/if}
|
||
|
||
<h2>Pulse</h2>
|
||
<div class="cards">
|
||
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visitors today</span></div>
|
||
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Visitors (7d)</span></div>
|
||
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Visitors ({range}d)</span></div>
|
||
<div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
|
||
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh (7d)</span></div>
|
||
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
|
||
<div class="stat"><span class="n">{healthy}/{sources.length}</span><span class="l">Sources healthy</span></div>
|
||
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Accounts</span></div>
|
||
{#if stats.client_errors}
|
||
<div class="stat" class:alert={stats.client_errors.today > 0}>
|
||
<span class="n">{stats.client_errors.today}</span><span class="l">Load errors today</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{:else if section === 'content'}
|
||
<h2>Corpus</h2>
|
||
<div class="cards">
|
||
<div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
|
||
<div class="stat"><span class="n">{stats.content.rejected}</span><span class="l">Filtered out</span></div>
|
||
<div class="stat"><span class="n">{stats.content.added_24h}</span><span class="l">Ingested (24h)</span></div>
|
||
<div class="stat"><span class="n">{stats.content.added_7d}</span><span class="l">Ingested (7d)</span></div>
|
||
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh & live (7d)</span></div>
|
||
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
|
||
</div>
|
||
|
||
<h2>Coverage</h2>
|
||
<div class="cards">
|
||
<div class="stat"><span class="n">{coverage(stats.content.with_image, stats.content.served)}%</span><span class="l">Articles with image ({stats.content.with_image}/{stats.content.served})</span></div>
|
||
<div class="stat"><span class="n">{coverage(stats.content.summaries, stats.content.served)}%</span><span class="l">Summaries ({stats.content.summaries}/{stats.content.served})</span></div>
|
||
<div class="stat"><span class="n">{coverage(stats.content.summaries_with_image, stats.content.summaries)}%</span><span class="l">Summary pages w/ image</span></div>
|
||
<div class="stat"><span class="n">{stats.content.brief_with_image}/{stats.content.latest_brief_size}</span><span class="l">Brief w/ image</span></div>
|
||
<div class="stat"><span class="n">{stats.content.recent_enrich_fail}</span><span class="l">Image misses (24h)</span></div>
|
||
</div>
|
||
|
||
<section>
|
||
<h2>Most-opened articles</h2>
|
||
{#if stats.top_articles.length}
|
||
<ul class="bars">
|
||
{#each stats.top_articles as a (a.id)}
|
||
<li>
|
||
<a href={'/a/' + a.id} class="lbl" title={a.title}>{a.title}</a>
|
||
<span class="bar" style="width:{pct(a.opens, max(stats.top_articles, 'opens'))}%"></span>
|
||
<span class="v">{a.opens}</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else}<p class="muted">No opens yet.</p>{/if}
|
||
</section>
|
||
|
||
<div class="two">
|
||
<section>
|
||
<h2>Popular groupings</h2>
|
||
{#if stats.top_groupings.length}
|
||
<ul class="bars">
|
||
{#each stats.top_groupings as g (g.tag)}
|
||
<li>
|
||
<span class="lbl">{g.tag.replace('-', ' ')}</span>
|
||
<span class="bar" style="width:{pct(g.opens, max(stats.top_groupings, 'opens'))}%"></span>
|
||
<span class="v">{g.opens}</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else}<p class="muted">No data yet.</p>{/if}
|
||
</section>
|
||
<section>
|
||
<h2>Popular topics</h2>
|
||
{#if stats.top_topics.length}
|
||
<ul class="bars">
|
||
{#each stats.top_topics as t (t.topic)}
|
||
<li>
|
||
<span class="lbl">{t.topic}</span>
|
||
<span class="bar" style="width:{pct(t.opens, max(stats.top_topics, 'opens'))}%"></span>
|
||
<span class="v">{t.opens}</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else}<p class="muted">No data yet.</p>{/if}
|
||
</section>
|
||
</div>
|
||
|
||
{:else if section === 'sources'}
|
||
<section class="addsrc">
|
||
<h2>Add a source</h2>
|
||
<div class="addrow">
|
||
<input type="url" placeholder="Feed URL (RSS / Atom)" bind:value={newFeedUrl} onkeydown={(e) => e.key === 'Enter' && addCandidate()} />
|
||
<input type="text" placeholder="Name (optional)" bind:value={newFeedName} />
|
||
<button class="csend" onclick={addCandidate} disabled={addBusy || !newFeedUrl.trim()}>{addBusy ? 'Previewing…' : 'Preview & add'}</button>
|
||
</div>
|
||
{#if addErr}<p class="cerr">{addErr}</p>{/if}
|
||
<p class="legend2">Fetches a sample (safely) and stages it as a candidate — nothing is added live until you Promote.</p>
|
||
</section>
|
||
|
||
{#if pendingCandidates.length}
|
||
<section>
|
||
<h2>Candidate queue <span class="count">({pendingCandidates.length})</span></h2>
|
||
<ul class="candlist">
|
||
{#each pendingCandidates as c (c.id)}
|
||
<li>
|
||
<div class="chead">
|
||
<span class="cname">{c.name || c.feed_url}</span>
|
||
<span class="cstatus">{c.status}</span>
|
||
</div>
|
||
<div class="curl">{c.feed_url}</div>
|
||
{#if c.preview}
|
||
<div class="cprev">
|
||
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
|
||
{#if c.preview.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
|
||
</div>
|
||
{/if}
|
||
{#if c._err}<p class="cerr">{c._err}</p>{/if}
|
||
<div class="cactions">
|
||
<input class="ccat" type="text" placeholder="category (optional)" bind:value={c._cat} />
|
||
<label class="cchk"><input type="checkbox" bind:checked={c._activate} /> Activate immediately</label>
|
||
<button class="csend" onclick={() => promoteCandidate(c)}>Promote{c._activate ? '' : ' as paused'}</button>
|
||
<button class="act" onclick={() => repreviewCandidate(c)}>Re-preview</button>
|
||
<button class="act del" onclick={() => rejectCandidate(c)}>Reject</button>
|
||
</div>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</section>
|
||
{/if}
|
||
|
||
<h2>Sources <a class="exportlink" href="/api/admin/export/sources.csv" download>export CSV ↓</a></h2>
|
||
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
|
||
<div class="filterchips">
|
||
{#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged'], ['paused', 'Paused'], ['retired', 'Retired']] as [key, label] (key)}
|
||
<button class="chip" class:on={srcFilter === key} onclick={() => (srcFilter = key)}>{label}</button>
|
||
{/each}
|
||
</div>
|
||
<div class="tablewrap">
|
||
<table class="srctable">
|
||
<thead>
|
||
<tr>
|
||
<th>Source</th><th class="num">Served</th><th class="num">Accept</th><th class="num">Dup</th><th class="num">Media</th>
|
||
<th>Last success</th><th>Next poll</th><th class="num">Fails</th><th>Status</th><th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each shownSources as s (s.id)}
|
||
{@const st = sstatus(s)}
|
||
<tr class:warn={st === 'active' && s.failures > 0} class:flag={s.review_flag} class:paused={st !== 'active'}>
|
||
<td class="sname">
|
||
{s.name}{#if s.category}<span class="cat">{s.category}</span>{/if}
|
||
{#if s.review_flag && s.review_reason}<span class="rr">“{s.review_reason}”</span>{/if}
|
||
</td>
|
||
<td class="num">{s.served}</td>
|
||
<td class="num">{s.acceptance_rate != null ? s.acceptance_rate + '%' : '—'}</td>
|
||
<td class="num" title={s.accepted_dup_rate != null ? `${s.accepted_dup_rate}% of accepted were duplicates` : ''}>{s.duplicate_rate != null ? s.duplicate_rate + '%' : '—'}</td>
|
||
<td class="num media" title={`${s.image_coverage != null ? s.image_coverage + '% of served have an image' : 'no served articles yet'}${s.paywalled ? ' · subscription / paywalled domain' : ''}`}>
|
||
{s.image_coverage != null ? s.image_coverage + '%' : '—'}{#if s.paywalled} <span class="pw">🔒</span>{/if}
|
||
</td>
|
||
<td class="dim">{s.last_success_at ? fwhen(s.last_success_at) : '—'}</td>
|
||
<td class="dim">{st === 'active' ? fwhen(s.next_due_at) : '—'}</td>
|
||
<td class="num">{s.failures || ''}</td>
|
||
<td class="status">
|
||
{#if st === 'retired'}<span class="paustxt">retired</span>
|
||
{:else if st === 'paused'}<span class="paustxt">paused</span>
|
||
{:else if rateLimited(s)}<span class="bad" title={s.last_error || ''}>rate-limited · rests until {fwhen(s.retry_after_at)}{#if s.review_flag} · review{/if}</span>
|
||
{:else if s.failures > 0}<span class="bad" title={s.last_error || ''}>⚠ resting{#if s.review_flag} · review{/if}</span>
|
||
{:else if s.review_flag}<span class="flagtxt">⚑ review</span>
|
||
{:else}<span class="good">✓ ok</span>{/if}
|
||
{#if !s.content_visible}<span class="hidtxt" title="Articles hidden from the public feed">· hidden</span>{/if}
|
||
</td>
|
||
<td class="rowactions">
|
||
{#if st === 'retired'}
|
||
<button class="act" onclick={() => setStatus(s, 'active')}>Restore</button>
|
||
{:else}
|
||
<button class="act" onclick={() => setStatus(s, st === 'active' ? 'paused' : 'active')}>{st === 'active' ? 'Pause' : 'Resume'}</button>
|
||
<button class="act" onclick={() => setStatus(s, 'retired')}>Retire</button>
|
||
{/if}
|
||
<button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button>
|
||
<button class="act" onclick={() => toggleVisible(s)}>{s.content_visible ? 'Hide' : 'Show'}</button>
|
||
<button class="act" title="Read-only spot-check of the live feed" onclick={() => checkSource(s)} disabled={s._checking}>{s._checking ? 'Checking…' : 'Check'}</button>
|
||
</td>
|
||
</tr>
|
||
{#if s._checking || s._check || s._checkErr}
|
||
<tr class="checkrow">
|
||
<td colspan="10">
|
||
{#if s._checking}
|
||
<span class="muted">Checking {s.name}…</span>
|
||
{:else if s._checkErr}
|
||
<span class="cerr">Couldn't preview: {s._checkErr} <button class="act" onclick={() => dismissCheck(s)}>dismiss</button></span>
|
||
{:else if s._check}
|
||
<div class="chkhead">
|
||
<strong>{s._check.accepted ?? 0}/{s._check.sampled ?? 0}</strong> would pass{#if s._check.acceptance_rate != null} · {Math.round(s._check.acceptance_rate * 100)}%{/if}{#if s._check.recent_7d != null} · {s._check.recent_7d} in last 7d{/if}
|
||
<span class="chkwhen">checked just now</span>
|
||
<button class="act" onclick={() => dismissCheck(s)}>dismiss</button>
|
||
</div>
|
||
{#if s._check.examples_accepted?.length}<div class="chkex"><span class="chklbl">would accept:</span> {s._check.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
|
||
{#if s._check.examples_rejected?.length}<div class="chkex chkrej"><span class="chklbl">would skip:</span> {s._check.examples_rejected.slice(0, 3).map((r) => r.title).join(' · ')}</div>{/if}
|
||
{/if}
|
||
</td>
|
||
</tr>
|
||
{/if}
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p class="legend2">“served” = accepted, non-duplicate articles live · accept/dup % is of all ingested · pausing stops polling but keeps existing articles live · times in your local zone</p>
|
||
|
||
{:else if section === 'audience'}
|
||
<p class="exporthdr"><a class="exportlink" href={'/api/admin/export/audience.csv?days=' + range} download>export audience CSV ({range}d) ↓</a></p>
|
||
<section>
|
||
<h2>Visitors</h2>
|
||
<div class="cards">
|
||
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Today</span></div>
|
||
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
|
||
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last {range} days</span></div>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h2>Returning visitors ({range}d)</h2>
|
||
<div class="cards">
|
||
<div class="stat"><span class="n">{stats.retention.new}</span><span class="l">New (1 day)</span></div>
|
||
<div class="stat"><span class="n">{stats.retention['2-3']}</span><span class="l">2–3 days</span></div>
|
||
<div class="stat"><span class="n">{stats.retention['4-7']}</span><span class="l">4–7 days</span></div>
|
||
<div class="stat"><span class="n">{stats.retention['8+']}</span><span class="l">8+ days</span></div>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h2>Accounts</h2>
|
||
<div class="cards">
|
||
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Total</span></div>
|
||
<div class="stat"><span class="n">{stats.accounts.new_today}</span><span class="l">New today</span></div>
|
||
<div class="stat"><span class="n">{stats.accounts.new_7d}</span><span class="l">New this week</span></div>
|
||
<div class="stat"><span class="n">{stats.accounts.active_7d}</span><span class="l">Active this week</span></div>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h2>Reading funnel</h2>
|
||
<div class="cards">
|
||
<div class="stat"><span class="n">{stats.funnel.summary_viewed}</span><span class="l">Summaries read</span></div>
|
||
<div class="stat"><span class="n">{stats.funnel.source_click}</span><span class="l">→ Source (from summary)</span></div>
|
||
<div class="stat"><span class="n">{stats.funnel.source_rate}%</span><span class="l">Summary → source rate</span></div>
|
||
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h2>Emotional mix & friction</h2>
|
||
<div class="cards">
|
||
<div class="stat"><span class="n">{stats.emotional_mix.not_today}</span><span class="l">Not today</span></div>
|
||
<div class="stat"><span class="n">{stats.emotional_mix.less_like_this}</span><span class="l">Less like this</span></div>
|
||
<div class="stat"><span class="n">{stats.emotional_mix.hide_topic}</span><span class="l">Hide topic</span></div>
|
||
<div class="stat"><span class="n">{stats.replace.used}</span><span class="l">Replaces</span></div>
|
||
<div class="stat"><span class="n">{stats.paywall.paywalled_source_open}</span><span class="l">Paywalled opens</span></div>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h2>Sharing</h2>
|
||
<div class="cards">
|
||
{#each Object.entries(stats.shares) as [kind, n]}
|
||
<div class="stat"><span class="n">{n}</span><span class="l">{SHARE_LABEL[kind] ?? kind}</span></div>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h2>Daily trend</h2>
|
||
{#if stats.daily.length}
|
||
{@const dmax = Math.max(1, ...stats.daily.map((d) => Math.max(d.opens, d.visits)))}
|
||
<div class="trend">
|
||
{#each stats.daily as d (d.day)}
|
||
<div class="col" title={`${d.day}: ${d.visits} visits, ${d.opens} opens`}>
|
||
<span class="visits" style="height:{pct(d.visits, dmax)}%"></span>
|
||
<span class="opens" style="height:{pct(d.opens, dmax)}%"></span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
<p class="legend"><span class="sw visits"></span> visits <span class="sw opens"></span> opens</p>
|
||
{:else}<p class="muted">No data yet.</p>{/if}
|
||
</section>
|
||
|
||
{:else if section === 'feedback'}
|
||
<h2>Feedback {#if feedback.length}<span class="count">({unreadCount} unread / {feedback.length})</span>{/if}</h2>
|
||
{#if feedback.length}
|
||
<div class="filterchips">
|
||
<button class="chip" class:on={fbCat === 'all'} onclick={() => (fbCat = 'all')}>All</button>
|
||
<button class="chip" class:on={fbCat === 'unread'} onclick={() => (fbCat = 'unread')}>Unread{#if unreadCount} ({unreadCount}){/if}</button>
|
||
{#each fbCats as cat (cat)}
|
||
<button class="chip" class:on={fbCat === cat} onclick={() => (fbCat = cat)}>{cat}</button>
|
||
{/each}
|
||
</div>
|
||
{#if shownFeedback.length}
|
||
<ul class="fb">
|
||
{#each shownFeedback as f (f.id)}
|
||
<li class:unread={!f.read_at}>
|
||
<div class="fhead">
|
||
{#if !f.read_at}<span class="dot" title="Unread"></span>{/if}
|
||
<span class="cat">{f.category}</span>
|
||
<span class="when">{fdate(f.created_at)}</span>
|
||
{#if f.contact_email}<span class="addr">{f.contact_email}</span>{:else}<span class="anon">anonymous</span>{/if}
|
||
</div>
|
||
<p class="msg">{f.message}</p>
|
||
|
||
{#if f.replies?.length}
|
||
<div class="thread">
|
||
{#each f.replies as r (r.id)}
|
||
<div class="rep">
|
||
<span class="rl">↪ Replied · {fwhen(r.sent_at)}</span>
|
||
{#if r.message_html}
|
||
<!-- server-sanitized HTML (tiny Markdown subset) -->
|
||
<div class="repmsg">{@html r.message_html}</div>
|
||
{:else}
|
||
<p class="repmsg">{r.message}</p>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if replyingId === f.id}
|
||
<div class="composer">
|
||
<div class="rtbar">
|
||
<button type="button" class="rtbtn" title="Bold" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('bold')}><b>B</b></button>
|
||
<button type="button" class="rtbtn" title="Italic" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('italic')}><i>I</i></button>
|
||
<select class="szsel" title="Text size" onmousedown={(e) => e.stopPropagation()} onchange={(e) => { setSize(e.currentTarget.value); e.currentTarget.value = ''; }}>
|
||
<option value="">Size</option>
|
||
<option value="small">Small</option>
|
||
<option value="normal">Normal</option>
|
||
<option value="large">Large</option>
|
||
<option value="xlarge">X-Large</option>
|
||
</select>
|
||
<button type="button" class="rtbtn" title="Bullet list" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('insertUnorderedList')}>• List</button>
|
||
<button type="button" class="rtbtn" title="Numbered list" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('insertOrderedList')}>1. List</button>
|
||
</div>
|
||
<div
|
||
class="rtedit"
|
||
contenteditable="true"
|
||
bind:this={replyEditor}
|
||
use:focusEditor
|
||
oninput={onEditorInput}
|
||
onpaste={onPaste}
|
||
role="textbox"
|
||
aria-multiline="true"
|
||
aria-label="Write a reply"
|
||
></div>
|
||
{#if replyErr}<p class="cerr">{replyErr}</p>{/if}
|
||
<div class="cbtns">
|
||
<button class="csend" onclick={() => sendReply(f)} disabled={replyBusy || replyEmpty}>
|
||
{replyBusy ? 'Sending…' : 'Send reply'}
|
||
</button>
|
||
<button class="act" onclick={cancelReply}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="factions">
|
||
{#if f.contact_email && replyingId !== f.id}
|
||
<button class="act" onclick={() => openReply(f)}>Reply</button>
|
||
{:else if !f.contact_email}
|
||
<span class="noreply">No reply address</span>
|
||
{/if}
|
||
<button class="act" onclick={() => markRead(f, !f.read_at)}>{f.read_at ? 'Mark unread' : 'Mark read'}</button>
|
||
<button class="act del" onclick={() => removeFeedback(f)}>Delete</button>
|
||
</div>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else}<p class="muted">Nothing in this filter.</p>{/if}
|
||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||
|
||
{:else if section === 'games'}
|
||
<h2>Daily Word pool</h2>
|
||
<p class="muted">Look up a word and add it to the answer pool. Only real, 5- or 6-letter
|
||
words in the guess dictionary qualify, so the daily answer is always solvable.</p>
|
||
|
||
<div class="wp-lookup">
|
||
<input type="text" bind:value={wpWord} oninput={onWpInput} maxlength="6" autocapitalize="off"
|
||
autocomplete="off" spellcheck="false" placeholder="Type a word to check…" />
|
||
{#if wpResult && wpResult.word}
|
||
{#if !wpResult.variant}
|
||
<span class="wp-tag bad">Must be 5 or 6 letters</span>
|
||
{:else if wpResult.in_pool}
|
||
<span class="wp-tag ok">Already in the pool</span>
|
||
{:else if wpResult.in_dictionary}
|
||
<span class="wp-tag ok">Valid {wpResult.variant}-letter word</span>
|
||
<button class="wp-add" onclick={addWord}>Add to pool</button>
|
||
{:else}
|
||
<span class="wp-tag bad">Not in the guess dictionary</span>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
{#if wpMsg}<p class="wp-msg">{wpMsg}</p>{/if}
|
||
|
||
{#if wpPool}
|
||
<div class="wp-cols">
|
||
{#each ['5', '6'] as v (v)}
|
||
<div class="wp-col">
|
||
<h3>{v === '6' ? 'Long Word' : 'Daily Word'} <span class="count">· {v} letters · {wpPool[v].total} words</span></h3>
|
||
<p class="muted small">{wpPool[v].curated} curated + {wpPool[v].added.length} added by you</p>
|
||
{#if wpPool[v].added.length}
|
||
<ul class="wp-added">
|
||
{#each wpPool[v].added as w (w)}
|
||
<li>{w}<button class="x" onclick={() => removeWord(w)} aria-label={'Remove ' + w}>×</button></li>
|
||
{/each}
|
||
</ul>
|
||
{:else}<p class="muted small">No words added yet — the curated pool is in use.</p>{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
{/if}
|
||
</main>
|
||
|
||
{#if flagging}
|
||
<!-- Flag-reason popover: backdrop closes; Escape/Cancel too (keyboard path). -->
|
||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||
<div class="modal-overlay" role="presentation" onclick={(e) => e.target === e.currentTarget && cancelFlag()}>
|
||
<div class="modal" role="dialog" aria-modal="true" aria-label="Flag source for review" tabindex="-1">
|
||
<h3>Flag “{flagging.name}” for review</h3>
|
||
<p class="msub">An optional note on why — it shows under the source in the table.</p>
|
||
<input
|
||
type="text"
|
||
bind:value={flagReason}
|
||
placeholder="e.g. acceptance dropping, off-topic lately"
|
||
onkeydown={(e) => (e.key === 'Enter' ? confirmFlag() : e.key === 'Escape' ? cancelFlag() : null)}
|
||
/>
|
||
<div class="mbtns">
|
||
<button class="msend" onclick={confirmFlag}>Flag for review</button>
|
||
<button class="act" onclick={cancelFlag}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
||
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||
.logo { height: 40px; display: block; }
|
||
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; }
|
||
.back svg { width: 17px; height: 17px; display: block; }
|
||
.page { padding: 22px 20px 70px; }
|
||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 14px; }
|
||
h2 { font-size: 1.1rem; margin: 28px 0 12px; }
|
||
.muted { color: var(--muted); }
|
||
|
||
/* Sticky section tabs */
|
||
.tabs {
|
||
display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 22px;
|
||
position: sticky; top: 64px; z-index: 15; background: var(--bg);
|
||
padding: 10px 0; border-bottom: 1px solid var(--line);
|
||
}
|
||
.tabs a {
|
||
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 6px;
|
||
}
|
||
.tabs a:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||
.tabs a.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
.tabs .badge { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 0 7px; font-size: 0.74rem; }
|
||
.tabs a.active .badge { background: rgba(255,255,255,0.25); color: #fff; }
|
||
|
||
/* Attention Needed — soft amber/blue, never alarming red unless truly broken */
|
||
.attn-strip { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
|
||
.attn { border-radius: 12px; padding: 11px 15px; font-size: 0.9rem; border-left: 3px solid; }
|
||
.attn.warn { background: #fdf3e3; color: #875a16; border-color: #e0a648; }
|
||
.attn.info { background: #eef4f8; color: var(--accent-deep); border-color: var(--accent); }
|
||
.attn.ok { background: #eef5ee; color: #3f7048; border-left: 3px solid #6aa86a; border-radius: 12px; padding: 11px 15px; font-size: 0.9rem; }
|
||
|
||
.cards { display: flex; flex-wrap: wrap; gap: 12px; }
|
||
.stat {
|
||
flex: 1 1 130px; background: var(--surface); border: 1px solid var(--line);
|
||
border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 3px;
|
||
}
|
||
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
|
||
.stat .l { color: var(--muted); font-size: 0.8rem; }
|
||
|
||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
||
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
|
||
|
||
ul.bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
|
||
ul.bars li { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; position: relative; }
|
||
ul.bars .lbl {
|
||
grid-column: 1; z-index: 1; font-size: 0.86rem; color: var(--ink);
|
||
text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 0;
|
||
}
|
||
a.lbl { text-decoration: none; }
|
||
a.lbl:hover { color: var(--accent-deep); }
|
||
ul.bars .bar {
|
||
grid-column: 1 / 2; grid-row: 1; height: 100%; min-height: 26px; align-self: stretch;
|
||
background: var(--accent-soft); border-radius: 8px; z-index: 0;
|
||
}
|
||
ul.bars .v { grid-column: 2; color: var(--muted); font-size: 0.82rem; font-variant-numeric: tabular-nums; }
|
||
|
||
.trend { display: flex; align-items: flex-end; gap: 3px; height: 120px; }
|
||
.trend .col { flex: 1; display: flex; align-items: flex-end; gap: 1px; height: 100%; }
|
||
.trend .visits { flex: 1; background: var(--accent-soft); border-radius: 2px 2px 0 0; }
|
||
.trend .opens { flex: 1; background: var(--accent); border-radius: 2px 2px 0 0; }
|
||
.legend { color: var(--muted); font-size: 0.78rem; margin-top: 8px; }
|
||
.legend .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: middle; }
|
||
.legend .sw.visits { background: var(--accent-soft); }
|
||
.legend .sw.opens { background: var(--accent); }
|
||
|
||
.sub2 { color: var(--muted); font-size: 0.84rem; margin: 0 0 12px; }
|
||
.exporthdr { margin: 0 0 14px; }
|
||
.exportlink { font-size: 0.78rem; font-weight: 400; color: var(--accent-deep); margin-left: 10px; }
|
||
.exportlink:hover { text-decoration: underline; }
|
||
.legend2 { color: var(--muted); font-size: 0.76rem; margin: 10px 0 0; font-style: italic; }
|
||
|
||
/* Analytics window picker */
|
||
.rangepick { display: flex; align-items: center; gap: 7px; margin: 0 0 18px; }
|
||
.rangepick .rl { font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 2px; }
|
||
.rangepick .chip {
|
||
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||
border-radius: 999px; padding: 4px 12px; font-size: 0.8rem; cursor: pointer;
|
||
}
|
||
.rangepick .chip:hover { border-color: var(--accent); }
|
||
.rangepick .chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
|
||
/* Filter chips (sources + feedback) */
|
||
.filterchips { display: flex; gap: 7px; flex-wrap: wrap; margin: 0 0 14px; }
|
||
.filterchips .chip {
|
||
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||
border-radius: 999px; padding: 5px 13px; font-size: 0.82rem; cursor: pointer; text-transform: capitalize;
|
||
}
|
||
.filterchips .chip:hover { border-color: var(--accent); }
|
||
.filterchips .chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
|
||
/* Add a source + candidate queue */
|
||
.addsrc { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; margin-bottom: 6px; }
|
||
.addrow { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.addrow input { flex: 1 1 200px; box-sizing: border-box; font: inherit; font-size: 0.9rem; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); }
|
||
.addrow input:focus { outline: none; border-color: var(--accent); }
|
||
ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||
ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
||
.chead { display: flex; align-items: baseline; gap: 10px; }
|
||
.chead .cname { font-weight: 600; color: var(--ink); }
|
||
.chead .cstatus { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
|
||
.curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
|
||
.cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
|
||
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
|
||
.cactions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
|
||
.cactions .ccat { font: inherit; font-size: 0.8rem; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); width: 150px; }
|
||
.cactions .cchk { font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }
|
||
|
||
/* Source health table */
|
||
.tablewrap { overflow-x: auto; }
|
||
.srctable { width: 100%; border-collapse: collapse; font-size: 0.86rem; min-width: 560px; }
|
||
.srctable th {
|
||
text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||
color: var(--muted); font-weight: 600; padding: 6px 10px; border-bottom: 1px solid var(--line);
|
||
}
|
||
.srctable td { padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: baseline; }
|
||
.srctable .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||
.srctable .media { white-space: nowrap; }
|
||
.srctable .media .pw { font-size: 0.78rem; opacity: 0.75; }
|
||
.srctable .dim { color: var(--muted); white-space: nowrap; font-size: 0.82rem; }
|
||
.srctable .sname { font-weight: 600; color: var(--ink); }
|
||
.srctable .sname .cat { display: block; font-weight: 400; font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
|
||
.srctable .sname .rr { display: block; font-weight: 400; font-size: 0.72rem; color: var(--accent-deep); font-style: italic; }
|
||
.srctable tr.warn { background: #fdf3e3; }
|
||
.srctable tr.flag { background: #eef4f8; }
|
||
.srctable tr.paused { opacity: 0.6; }
|
||
.srctable .status .bad { color: #875a16; }
|
||
.srctable .status .flagtxt { color: var(--accent-deep); }
|
||
.srctable .status .good { color: #3f7048; }
|
||
.srctable .status .paustxt { color: var(--muted); font-style: italic; }
|
||
.srctable .status .hidtxt { color: #9a3b3b; font-size: 0.78rem; }
|
||
.srctable tr.checkrow td { background: var(--bg); font-size: 0.85rem; padding: 10px 12px; }
|
||
.chkhead { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||
.chkhead .chkwhen { color: var(--muted); font-size: 0.76rem; }
|
||
.chkex { margin-top: 5px; color: var(--ink); }
|
||
.chkex .chklbl { color: var(--muted); }
|
||
.chkex.chkrej { color: var(--muted); }
|
||
.srctable .rowactions { white-space: nowrap; }
|
||
.srctable .rowactions .act {
|
||
background: none; border: 1px solid var(--line); color: var(--accent-deep);
|
||
border-radius: 999px; padding: 3px 11px; font-size: 0.76rem; cursor: pointer; margin-right: 5px;
|
||
}
|
||
.srctable .rowactions .act:hover { border-color: var(--accent); }
|
||
|
||
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
|
||
ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||
ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
||
ul.fb li.unread { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
|
||
.fhead { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; font-size: 0.78rem; }
|
||
.fhead .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||
.fhead .cat { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 2px 9px; text-transform: capitalize; }
|
||
.fhead .when { color: var(--muted); }
|
||
.fhead .addr { color: var(--accent-deep); margin-left: auto; }
|
||
.fhead .anon { color: var(--muted); margin-left: auto; font-style: italic; }
|
||
.msg { margin: 0; color: var(--ink); font-size: 0.92rem; white-space: pre-wrap; }
|
||
|
||
/* Reply thread + composer */
|
||
.thread { margin: 10px 0 0; display: flex; flex-direction: column; gap: 8px; }
|
||
.rep { border-left: 2px solid var(--accent-soft); padding: 2px 0 2px 12px; }
|
||
.rep .rl { font-size: 0.72rem; color: var(--muted); }
|
||
.rep .repmsg { margin: 2px 0 0; font-size: 0.9rem; color: var(--ink); }
|
||
.rep .repmsg :global(p) { margin: 0 0 6px; white-space: pre-wrap; }
|
||
.rep .repmsg :global(ul) { margin: 4px 0; padding-left: 20px; }
|
||
.rep .repmsg :global(h3),
|
||
.rep .repmsg :global(h4),
|
||
.rep .repmsg :global(h5) { margin: 6px 0 4px; font-size: 0.95rem; }
|
||
.composer { margin: 10px 0 0; }
|
||
.rtbar { display: flex; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; align-items: center; }
|
||
.rtbtn {
|
||
font: inherit; font-size: 0.82rem; background: var(--surface); border: 1px solid var(--line);
|
||
color: var(--ink); border-radius: 7px; padding: 3px 10px; cursor: pointer; line-height: 1.4; min-width: 30px;
|
||
}
|
||
.rtbtn:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||
.szsel {
|
||
font: inherit; font-size: 0.8rem; background: var(--surface); border: 1px solid var(--line);
|
||
color: var(--ink); border-radius: 7px; padding: 3px 6px; cursor: pointer;
|
||
}
|
||
.rtedit {
|
||
min-height: 90px; box-sizing: border-box; font-size: 0.92rem; line-height: 1.5;
|
||
padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px;
|
||
background: var(--bg); color: var(--ink);
|
||
}
|
||
.rtedit:focus { outline: none; border-color: var(--accent); }
|
||
.rtedit :global(ul), .rtedit :global(ol) { margin: 4px 0; padding-left: 22px; }
|
||
.rtedit :global(p) { margin: 0 0 6px; }
|
||
.cerr { margin: 6px 0 0; color: #9a3b3b; font-size: 0.82rem; }
|
||
.cbtns { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
|
||
.csend {
|
||
font: inherit; font-size: 0.82rem; font-weight: 600; background: var(--accent); color: #fff;
|
||
border: none; border-radius: 999px; padding: 7px 18px; cursor: pointer;
|
||
}
|
||
.csend:hover { background: var(--accent-deep); }
|
||
.csend:disabled { opacity: 0.55; cursor: default; }
|
||
.noreply { color: var(--muted); font-size: 0.76rem; font-style: italic; }
|
||
|
||
/* Flag-reason popover */
|
||
.modal-overlay {
|
||
position: fixed; inset: 0; background: rgba(10, 22, 38, 0.32);
|
||
display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 60;
|
||
}
|
||
.modal {
|
||
background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
|
||
box-shadow: 0 12px 34px rgba(40, 38, 28, 0.16); width: 100%; max-width: 420px; padding: 22px 24px;
|
||
}
|
||
.modal h3 { margin: 0 0 6px; font-size: 1.2rem; }
|
||
.modal .msub { margin: 0 0 14px; color: var(--muted); font-size: 0.85rem; }
|
||
.modal input {
|
||
width: 100%; box-sizing: border-box; font: inherit; font-size: 0.92rem;
|
||
padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--ink);
|
||
}
|
||
.modal input:focus { outline: none; border-color: var(--accent); }
|
||
.mbtns { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
|
||
.msend {
|
||
font: inherit; font-size: 0.85rem; font-weight: 600; background: var(--accent); color: #fff;
|
||
border: none; border-radius: 999px; padding: 8px 18px; cursor: pointer;
|
||
}
|
||
.msend:hover { background: var(--accent-deep); }
|
||
.factions { display: flex; gap: 14px; margin-top: 9px; }
|
||
.factions .act {
|
||
background: none; border: none; padding: 0; cursor: pointer;
|
||
color: var(--muted); font-size: 0.76rem; border-bottom: 1px dotted var(--line);
|
||
}
|
||
.factions .act:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
|
||
.factions .act.del:hover { color: #9a3b3b; border-bottom-color: #9a3b3b; }
|
||
|
||
.stat.alert { background: #f3e0e0; }
|
||
.stat.alert .n { color: #9a3b3b; }
|
||
|
||
/* Games — Daily Word pool */
|
||
.wp-lookup { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 14px 0 6px; }
|
||
.wp-lookup input {
|
||
font: inherit; font-size: 1.05rem; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
|
||
text-transform: uppercase; letter-spacing: 0.06em; width: 210px; background: var(--surface); color: var(--ink);
|
||
}
|
||
.wp-tag { font-size: 0.84rem; font-weight: 600; padding: 4px 11px; border-radius: 999px; }
|
||
.wp-tag.ok { background: var(--accent-soft); color: var(--accent-deep); }
|
||
.wp-tag.bad { background: #f3e0e0; color: #9a3b3b; }
|
||
.wp-add { background: var(--accent); color: #fff; border: none; border-radius: 999px; padding: 8px 18px;
|
||
font: inherit; font-weight: 600; cursor: pointer; }
|
||
.wp-add:hover { background: var(--accent-deep); }
|
||
.wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
|
||
.wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; }
|
||
.wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; }
|
||
.wp-col .count { font-size: 0.85rem; }
|
||
.small { font-size: 0.82rem; }
|
||
.wp-added { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 10px 0 0; }
|
||
.wp-added li {
|
||
display: inline-flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
|
||
border-radius: 999px; padding: 4px 6px 4px 12px; font-family: var(--label); text-transform: uppercase;
|
||
font-size: 0.82rem; letter-spacing: 0.04em; color: var(--ink);
|
||
}
|
||
.wp-added .x { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.15rem;
|
||
line-height: 1; padding: 0 5px; border-radius: 50%; }
|
||
.wp-added .x:hover { color: #9a3b3b; }
|
||
</style>
|