Files
upbeatBytes/frontend/src/routes/admin/+page.svelte
T
thejayman77 9e387a0a09 Boot-failure seatbelt: no future crash becomes a silent white screen
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>
2026-06-11 12:10:46 -04:00

1040 lines
54 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">23 days</span></div>
<div class="stat"><span class="n">{stats.retention['4-7']}</span><span class="l">47 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 &amp; 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 &nbsp; <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>