ddcfab3a11
New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.
- Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin,
read-only). queries.source_articles + source_articles_summary — per article:
title, url, date, accepted, reason (the "why"), topic/flavor, paywalled
(domain rule), has_image, duplicate. Summary = counts + source-level paywall
rule.
- Frontend: expandable panel with a summary header ("27 ingested · 18 accepted
· … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No
image/Duplicates), compact rows with title→link + badges + reason, Load more.
So "100% paywall" or "0% images" becomes clickable evidence: open two articles
to tell a real paywall from a mis-flagged domain, or a true image gap from an
enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1448 lines
78 KiB
Svelte
1448 lines
78 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 {
|
||
// Load all panels in PARALLEL — these were sequential awaits, so the page
|
||
// paid the (uncached, origin) round-trip six times back-to-back. One batch
|
||
// instead of a chain.
|
||
const [, fb, cand, wp, ce, ws] = await Promise.all([
|
||
loadStats(),
|
||
getJSON('/api/admin/feedback'),
|
||
getJSON('/api/admin/candidates'),
|
||
getJSON('/api/admin/word/pool'),
|
||
getJSON('/api/admin/client-errors'),
|
||
getJSON('/api/admin/wordsearch/themes'),
|
||
]);
|
||
feedback = fb;
|
||
candidates = cand;
|
||
wpPool = wp;
|
||
clientErrors = ce;
|
||
wsThemes = ws;
|
||
} catch {
|
||
error = "Couldn't load stats.";
|
||
}
|
||
});
|
||
|
||
let clientErrors = $state([]);
|
||
|
||
// --- 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.'; }
|
||
}
|
||
// Refresh the live lookup tag after a pool mutation, so the button flips Remove↔Restore↔Add.
|
||
async function refreshLookup() {
|
||
const w = wpWord.trim();
|
||
if (!w) { wpResult = null; return; }
|
||
try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); }
|
||
catch { wpResult = null; }
|
||
}
|
||
async function removeWord(w) {
|
||
try {
|
||
wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w));
|
||
wpMsg = `Removed “${w}” from future puzzles (today’s answer is already set). Restore it any time below.`;
|
||
await refreshLookup();
|
||
} catch { /* ignore */ }
|
||
}
|
||
async function restoreWord(w) {
|
||
try {
|
||
wpPool = await postJSON('/api/admin/word/pool/restore', { word: w });
|
||
wpMsg = `Restored “${w}” to the pool.`;
|
||
await refreshLookup();
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
// --- Games: bulk import (paste or .txt/.csv upload) ---
|
||
let wpImportText = $state('');
|
||
let wpImportResult = $state(null); // { counts, added[], duplicates[], rejected[] }
|
||
let wpImporting = $state(false);
|
||
function onImportFile(e) {
|
||
const file = e.target.files && e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const text = String(reader.result || '');
|
||
wpImportText = wpImportText.trim() ? wpImportText.trim() + '\n' + text : text;
|
||
};
|
||
reader.readAsText(file);
|
||
e.target.value = ''; // allow re-picking the same file
|
||
}
|
||
async function importWords() {
|
||
if (!wpImportText.trim() || wpImporting) return;
|
||
wpImporting = true; wpImportResult = null;
|
||
try {
|
||
const res = await postJSON('/api/admin/word/pool/import', { text: wpImportText });
|
||
wpPool = res.pool; wpImportResult = res; wpImportText = '';
|
||
await refreshLookup();
|
||
} catch (e) { wpImportResult = { error: e.message || 'Import failed.' }; }
|
||
finally { wpImporting = false; }
|
||
}
|
||
|
||
// --- Games: Word Search themes ---
|
||
const WS_NEEDED = 28;
|
||
let wsThemes = $state([]);
|
||
let wsTheme = $state('');
|
||
let wsWordsText = $state('');
|
||
let wsEditId = $state(null);
|
||
let wsMsg = $state('');
|
||
let wsSuggesting = $state(false);
|
||
|
||
function parseWords(text) {
|
||
return [...new Set((text || '').split(/[\s,]+/).map((w) => w.trim().toUpperCase()).filter(Boolean))];
|
||
}
|
||
let wsParsed = $derived(parseWords(wsWordsText));
|
||
let wsValid = $derived(wsParsed.filter((w) => /^[A-Z]{4,8}$/.test(w)));
|
||
let wsInvalid = $derived(wsParsed.filter((w) => !/^[A-Z]{4,8}$/.test(w)));
|
||
|
||
async function loadWsThemes() { try { wsThemes = await getJSON('/api/admin/wordsearch/themes'); } catch { /* ignore */ } }
|
||
async function suggestWsWord() {
|
||
if (!wsTheme.trim()) { wsMsg = 'Enter a theme first.'; return; }
|
||
wsSuggesting = true; wsMsg = '';
|
||
try {
|
||
const r = await postJSON('/api/admin/wordsearch/suggest', { theme: wsTheme.trim(), existing: wsValid });
|
||
wsWordsText = (wsWordsText.trim() + ' ' + r.word).trim();
|
||
} catch (e) { wsMsg = e.message || 'No suggestion available.'; }
|
||
finally { wsSuggesting = false; }
|
||
}
|
||
async function saveWsTheme() {
|
||
try {
|
||
const res = await postJSON('/api/admin/wordsearch/themes', { theme: wsTheme.trim(), words: wsValid, id: wsEditId });
|
||
wsThemes = res.themes; wsMsg = `Saved “${wsTheme.trim()}” (${res.count} words).`;
|
||
wsTheme = ''; wsWordsText = ''; wsEditId = null;
|
||
} catch (e) { wsMsg = e.message || 'Could not save.'; }
|
||
}
|
||
function editWsTheme(t) { wsEditId = t.id; wsTheme = t.theme; wsWordsText = t.words.join(' '); wsMsg = ''; }
|
||
function cancelWsEdit() { wsEditId = null; wsTheme = ''; wsWordsText = ''; wsMsg = ''; }
|
||
async function removeWsTheme(t) { try { wsThemes = await delJSON('/api/admin/wordsearch/themes/' + t.id); } 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 + free-text search (name / category / feed URL).
|
||
let srcFilter = $state('all');
|
||
let srcSearch = $state('');
|
||
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
|
||
).filter((s) => {
|
||
const q = srcSearch.trim().toLowerCase();
|
||
if (!q) return true;
|
||
return `${s.name} ${s.category || ''} ${s.feed_url || ''} ${s.homepage_url || ''}`.toLowerCase().includes(q);
|
||
})
|
||
);
|
||
|
||
// 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 article inspector: the real articles behind the metrics ---
|
||
async function toggleArticles(s) {
|
||
if (s._showArts) { s._showArts = false; return; }
|
||
s._showArts = true;
|
||
if (!s._arts) await loadArticles(s, 'all', true);
|
||
}
|
||
async function loadArticles(s, filter, reset) {
|
||
s._artBusy = true; s._artErr = '';
|
||
if (reset) { s._artFilter = filter; s._artOffset = 0; s._arts = []; }
|
||
try {
|
||
const q = `filter=${s._artFilter}&limit=25&offset=${s._artOffset}`;
|
||
const r = await getJSON(`/api/admin/sources/${s.id}/articles?${q}`);
|
||
s._arts = reset ? r.articles : [...(s._arts || []), ...r.articles];
|
||
if (r.summary) s._artSummary = r.summary;
|
||
s._artMore = r.has_more;
|
||
s._artOffset += r.articles.length;
|
||
} catch (e) { s._artErr = e?.message || 'Could not load articles.'; }
|
||
finally { s._artBusy = false; }
|
||
}
|
||
const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
|
||
|
||
// --- 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.'; }
|
||
}
|
||
// Deep preview runs the REAL classifier on a small sample (~30-60s) — the
|
||
// model's true acceptance view, not the fast heuristic estimate.
|
||
async function deepPreview(c) {
|
||
c._err = ''; c._deep = true;
|
||
try {
|
||
// ~5-7s/item on the LAN model; bound the wait so a model stall can't pin
|
||
// the button on "Deep-checking…" forever.
|
||
const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 120000 });
|
||
Object.assign(c, res, { _deep: false });
|
||
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; }
|
||
}
|
||
function startRename(c) { c._editName = c.name || ''; c._editing = true; c._err = ''; }
|
||
async function saveRename(c) {
|
||
try {
|
||
Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/rename`, { name: (c._editName || '').trim() }), { _editing: false });
|
||
} catch (e) { c._err = e?.message || 'Rename 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>
|
||
|
||
{#if clientErrors.length}
|
||
<h2>Recent load errors <span class="count">(last {clientErrors.length})</span></h2>
|
||
<ul class="cerrs">
|
||
{#each clientErrors as e (e.created_at + e.reason)}
|
||
<li class:bot={e.bot}>
|
||
<span class="ce-when">{fdate(e.created_at)}</span>
|
||
<span class="ce-reason">{e.reason || '—'}{#if e.bot}<span class="ce-bot">bot</span>{/if}</span>
|
||
<span class="ce-path">{e.path || '/'}</span>
|
||
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
|
||
{: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">
|
||
{#if c._editing}
|
||
<input class="crename" bind:value={c._editName} placeholder="Source name"
|
||
onkeydown={(e) => (e.key === 'Enter' ? saveRename(c) : e.key === 'Escape' ? (c._editing = false) : null)} />
|
||
<button class="act" onclick={() => saveRename(c)}>Save</button>
|
||
<button class="act" onclick={() => (c._editing = false)}>Cancel</button>
|
||
{:else}
|
||
<span class="cname">{c.name || c.feed_url}</span>
|
||
<button class="act mini" onclick={() => startRename(c)}>Rename</button>
|
||
<span class="cstatus">{c.status}</span>
|
||
{/if}
|
||
</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.classified}<span class="vbadge model" title="Scored by the real classifier — the true acceptance view">model-checked</span>{:else}<span class="vbadge fast" title="Fast keyword heuristic — an estimate. Run Deep preview for the model's real verdict.">quick estimate</span>{/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" title="Run the real model on a sample (~30-60s)" onclick={() => deepPreview(c)} disabled={c._deep}>{c._deep ? 'Deep-checking…' : '🔬 Deep 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="srctools">
|
||
<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="srcsearch">
|
||
<input type="search" placeholder="Search name, category, or URL…" bind:value={srcSearch} autocapitalize="off" autocomplete="off" spellcheck="false" />
|
||
{#if srcSearch.trim()}<span class="srccount">{shownSources.length} match{shownSources.length === 1 ? '' : 'es'}</span>{/if}
|
||
</div>
|
||
</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>
|
||
<button class="act" title="Inspect this source's real ingested articles" onclick={() => toggleArticles(s)}>{s._showArts ? 'Hide' : 'Articles'}</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}
|
||
{#if s._showArts}
|
||
<tr class="artrow">
|
||
<td colspan="10">
|
||
{#if s._artSummary}
|
||
<div class="artsum">
|
||
<strong>{s._artSummary.total}</strong> ingested · {s._artSummary.accepted} accepted ·
|
||
{s._artSummary.rejected} rejected · {s._artSummary.no_image} no image ·
|
||
{s._artSummary.duplicates} dup ·
|
||
paywall rule: <span class="pwrule" class:on={s._artSummary.paywalled}>{s._artSummary.paywalled ? 'ON (domain)' : 'off'}</span>
|
||
</div>
|
||
{/if}
|
||
<div class="artfilters">
|
||
{#each ART_FILTERS as [key, label] (key)}
|
||
<button class="chip sm" class:on={(s._artFilter || 'all') === key} onclick={() => loadArticles(s, key, true)}>{label}</button>
|
||
{/each}
|
||
<button class="act" onclick={() => (s._showArts = false)}>close</button>
|
||
</div>
|
||
{#if s._artErr}<p class="cerr">{s._artErr}</p>{/if}
|
||
{#if s._arts?.length}
|
||
<ul class="artlist">
|
||
{#each s._arts as a (a.id)}
|
||
<li>
|
||
<div class="art-row">
|
||
<a class="art-title" href={a.url} target="_blank" rel="noopener">{a.title}</a>
|
||
{#if a.accepted === 1}<span class="badge ok">accepted</span>{:else if a.accepted === 0}<span class="badge no">rejected</span>{/if}
|
||
{#if a.paywalled}<span class="pw" title="domain paywall rule">🔒</span>{/if}
|
||
{#if !a.has_image}<span class="art-flag" title="no image extracted">no img</span>{/if}
|
||
{#if a.duplicate}<span class="art-flag" title="marked duplicate">dup</span>{/if}
|
||
{#if a.topic}<span class="art-cat">{a.topic}</span>{/if}
|
||
<span class="art-when">{a.published_at ? fdate(a.published_at) : ''}</span>
|
||
</div>
|
||
{#if a.reason}<div class="art-reason">{a.reason}</div>{/if}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{#if s._artMore}<button class="act more" onclick={() => loadArticles(s, s._artFilter, false)} disabled={s._artBusy}>{s._artBusy ? 'Loading…' : 'Load more'}</button>{/if}
|
||
{:else if !s._artBusy}
|
||
<p class="muted small">No articles{s._artFilter && s._artFilter !== 'all' ? ' match this filter' : ' yet'}.</p>
|
||
{/if}
|
||
{#if s._artBusy && !s._arts?.length}<p class="muted small">Loading articles…</p>{/if}
|
||
</td>
|
||
</tr>
|
||
{/if}
|
||
{:else}
|
||
<tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}”.` : 'No sources in this view.'}</td></tr>
|
||
{/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 to add or remove it from the answer pool. Only real, 5- or 6-letter
|
||
words in the guess dictionary qualify, so the daily answer is always solvable. Removals take
|
||
effect for future puzzles and can be restored any time.</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">In the pool</span>
|
||
<button class="act del" onclick={() => removeWord(wpResult.word)}>Remove</button>
|
||
{:else if wpResult.removed}
|
||
<span class="wp-tag bad">Removed</span>
|
||
<button class="wp-add" onclick={() => restoreWord(wpResult.word)}>Restore</button>
|
||
{: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}
|
||
{#if wpPool[v].removed && wpPool[v].removed.length}
|
||
<p class="muted small" style="margin-top:10px">Removed ({wpPool[v].removed.length}) — tap to restore:</p>
|
||
<ul class="wp-added removed">
|
||
{#each wpPool[v].removed as w (w)}
|
||
<li>{w}<button class="x" onclick={() => restoreWord(w)} aria-label={'Restore ' + w}>↺</button></li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<h3 style="margin-top:24px">Import a vetted list</h3>
|
||
<p class="muted small">Paste words (any separators) or upload a .txt/.csv. Each is checked —
|
||
real word · 5–6 letters · in the guess dictionary — and duplicates are ignored.</p>
|
||
<div class="wp-import">
|
||
<textarea bind:value={wpImportText} rows="4"
|
||
placeholder="clear, bloom, kindle, ripple… (or upload a file below)"></textarea>
|
||
<div class="wp-import-row">
|
||
<label class="act file">Choose file…
|
||
<input type="file" accept=".txt,.csv,text/plain,text/csv" onchange={onImportFile} hidden />
|
||
</label>
|
||
<button class="wp-add" onclick={importWords} disabled={!wpImportText.trim() || wpImporting}>
|
||
{wpImporting ? 'Importing…' : 'Import'}
|
||
</button>
|
||
</div>
|
||
{#if wpImportResult}
|
||
{#if wpImportResult.error}
|
||
<p class="wp-msg">{wpImportResult.error}</p>
|
||
{:else}
|
||
<p class="wp-msg">Added {wpImportResult.counts.added} · ignored {wpImportResult.counts.duplicates} duplicate(s) · rejected {wpImportResult.counts.rejected}.</p>
|
||
{#if wpImportResult.rejected.length}
|
||
<details class="wp-rejects">
|
||
<summary>{wpImportResult.rejected.length} rejected — see why</summary>
|
||
<ul class="muted small">
|
||
{#each wpImportResult.rejected as r (r.word)}
|
||
<li>{r.word} — {r.reason}</li>
|
||
{/each}
|
||
</ul>
|
||
</details>
|
||
{/if}
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
|
||
<h2 style="margin-top:38px">Word Search themes</h2>
|
||
<p class="muted">Add a theme and its words — the system splits them across the three sizes and places
|
||
them. You need {WS_NEEDED}+ valid words (4–8 letters) to fill all three puzzles. Stuck? Let the AI
|
||
suggest one that fits.</p>
|
||
|
||
<div class="ws-form">
|
||
<input class="ws-name" type="text" bind:value={wsTheme} maxlength="40"
|
||
placeholder="Theme name (e.g. In the kitchen)" />
|
||
<textarea class="ws-words" bind:value={wsWordsText} rows="4"
|
||
placeholder="Words, separated by spaces or commas…"></textarea>
|
||
<div class="ws-row">
|
||
<span class="ws-count" class:ok={wsValid.length >= WS_NEEDED}>
|
||
{wsValid.length} / {WS_NEEDED} valid{#if wsInvalid.length} · {wsInvalid.length} skipped{/if}
|
||
</span>
|
||
<button class="ws-ai" onclick={suggestWsWord} disabled={wsSuggesting}>
|
||
{wsSuggesting ? 'Thinking…' : '✨ Suggest a word'}
|
||
</button>
|
||
<button class="wp-add" onclick={saveWsTheme} disabled={wsValid.length < WS_NEEDED || !wsTheme.trim()}>
|
||
{wsEditId ? 'Update theme' : 'Save theme'}
|
||
</button>
|
||
{#if wsEditId}<button class="act" onclick={cancelWsEdit}>Cancel</button>{/if}
|
||
</div>
|
||
{#if wsInvalid.length}<p class="muted small">Skipped (not 4–8 letters): {wsInvalid.join(', ')}</p>{/if}
|
||
</div>
|
||
{#if wsMsg}<p class="wp-msg">{wsMsg}</p>{/if}
|
||
|
||
{#if wsThemes.length}
|
||
<ul class="ws-themes">
|
||
{#each wsThemes as t (t.id)}
|
||
<li>
|
||
<span class="wt-name">{t.theme}</span>
|
||
<span class="wt-count">{t.count} words</span>
|
||
<button class="act" onclick={() => editWsTheme(t)}>Edit</button>
|
||
<button class="act del" onclick={() => removeWsTheme(t)}>Remove</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/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; }
|
||
/* Sources filter row + search */
|
||
.srctools { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 14px; }
|
||
.srctools .filterchips { margin: 0; }
|
||
.srcsearch { display: inline-flex; align-items: center; gap: 8px; }
|
||
.srcsearch input {
|
||
font: inherit; font-size: 0.84rem; padding: 6px 12px; border: 1px solid var(--line);
|
||
border-radius: 999px; background: var(--surface); color: var(--ink); width: 230px;
|
||
}
|
||
.srcsearch input:focus { outline: none; border-color: var(--accent); }
|
||
.srccount { font-size: 0.78rem; color: var(--muted); white-space: nowrap; }
|
||
.srcempty { text-align: center; color: var(--muted); font-style: italic; padding: 22px 10px; }
|
||
|
||
/* 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; flex-wrap: wrap; }
|
||
.chead .cname { font-weight: 600; color: var(--ink); }
|
||
.chead .cstatus { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
|
||
.chead .crename { font: inherit; font-size: 0.9rem; font-weight: 600; padding: 4px 9px;
|
||
border: 1px solid var(--accent); border-radius: 8px; background: var(--bg); color: var(--ink); }
|
||
.chead .crename:focus { outline: none; }
|
||
.act.mini { font-size: 0.72rem; padding: 2px 8px; color: var(--accent-deep);
|
||
background: none; border: 1px solid var(--line); border-radius: 999px; cursor: pointer; }
|
||
.act.mini:hover { border-color: var(--accent); }
|
||
.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; }
|
||
/* Heuristic-vs-model preview badge */
|
||
.vbadge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
||
font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; cursor: help; }
|
||
.vbadge.model { background: #e3efe4; color: #3f7048; }
|
||
.vbadge.fast { background: var(--line); color: var(--muted); }
|
||
.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 */
|
||
/* Constrained scroll box: the table scrolls BOTH ways inside its own panel, so
|
||
the horizontal scrollbar stays on-screen instead of living at the bottom of a
|
||
46-row table you'd have to page down to reach. Header row stays sticky. */
|
||
.tablewrap {
|
||
max-height: 65vh; overflow: auto;
|
||
border: 1px solid var(--line); border-radius: 12px; background: var(--surface);
|
||
}
|
||
.srctable { width: 100%; border-collapse: collapse; font-size: 0.86rem; min-width: 560px; }
|
||
.srctable thead th {
|
||
position: sticky; top: 0; z-index: 1; background: var(--surface);
|
||
/* box-shadow draws the divider reliably under position:sticky (a plain
|
||
border-bottom can drop out while scrolling with border-collapse). */
|
||
box-shadow: inset 0 -1px 0 var(--line);
|
||
}
|
||
.srctable th {
|
||
text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||
color: var(--muted); font-weight: 600; padding: 8px 10px;
|
||
}
|
||
.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); }
|
||
|
||
/* Source article inspector */
|
||
.srctable tr.artrow td { background: var(--bg); font-size: 0.84rem; padding: 10px 12px; }
|
||
.artsum { color: var(--ink); margin-bottom: 8px; }
|
||
.artsum .pwrule { color: var(--muted); font-weight: 600; }
|
||
.artsum .pwrule.on { color: #9a3b3b; }
|
||
.artfilters { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; }
|
||
.chip.sm { font-size: 0.74rem; padding: 3px 10px; }
|
||
.artlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; max-height: 360px; overflow-y: auto; }
|
||
.artlist li { border-bottom: 1px solid var(--line); padding-bottom: 6px; }
|
||
.art-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.art-title { color: var(--accent-deep); font-weight: 600; text-decoration: none; }
|
||
.art-title:hover { text-decoration: underline; }
|
||
.art-row .badge { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 7px; border-radius: 999px; }
|
||
.badge.ok { background: #e3efe4; color: #3f7048; }
|
||
.badge.no { background: #f3e0e0; color: #9a3b3b; }
|
||
.art-row .pw { font-size: 0.78rem; }
|
||
.art-flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
|
||
.art-cat { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
|
||
.art-when { font-size: 0.72rem; color: var(--muted); margin-left: auto; white-space: nowrap; }
|
||
.art-reason { font-size: 0.76rem; color: var(--muted); font-style: italic; margin-top: 2px; }
|
||
.act.more { margin-top: 8px; }
|
||
.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; }
|
||
.cerrs { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; }
|
||
.cerrs li { display: grid; grid-template-columns: auto 1fr auto; gap: 6px 12px; align-items: baseline;
|
||
font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
|
||
.ce-when { color: var(--muted); white-space: nowrap; }
|
||
.ce-reason { font-family: var(--label); color: #9a3b3b; }
|
||
.cerrs li.bot { opacity: 0.6; }
|
||
.cerrs li.bot .ce-reason { color: var(--muted); }
|
||
.ce-bot { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
||
background: var(--accent-soft); color: var(--accent-deep);
|
||
font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.ce-path { color: var(--accent-deep); white-space: nowrap; }
|
||
.ce-ua { grid-column: 1 / -1; color: var(--muted); font-size: 0.72rem;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
|
||
/* 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;
|
||
max-height: 230px; overflow-y: auto; }
|
||
.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; }
|
||
.wp-added.removed li { opacity: 0.7; border-style: dashed; }
|
||
.wp-added.removed .x { font-size: 0.95rem; }
|
||
.wp-added.removed .x:hover { color: var(--accent-deep); }
|
||
.wp-lookup .act { font: inherit; font-size: 0.82rem; background: var(--surface); border: 1px solid var(--line);
|
||
border-radius: 999px; padding: 7px 16px; cursor: pointer; color: #9a3b3b; }
|
||
.wp-lookup .act:hover { border-color: #9a3b3b; }
|
||
/* Bulk import */
|
||
.wp-import { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 10px 0 6px; }
|
||
.wp-import textarea { font: inherit; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
|
||
background: var(--surface); color: var(--ink); resize: vertical; line-height: 1.6;
|
||
text-transform: uppercase; letter-spacing: 0.03em; }
|
||
.wp-import-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.act.file { font: inherit; font-size: 0.84rem; background: var(--accent-soft); color: var(--accent-deep);
|
||
border: none; border-radius: 999px; padding: 8px 16px; cursor: pointer; }
|
||
.act.file:hover { filter: brightness(0.97); }
|
||
.wp-rejects { margin: 4px 0 0; }
|
||
.wp-rejects summary { cursor: pointer; color: var(--accent-deep); font-size: 0.84rem; }
|
||
.wp-rejects ul { margin: 8px 0 0; padding-left: 18px; }
|
||
|
||
/* Word Search theme authoring */
|
||
.ws-form { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 14px 0 6px; }
|
||
.ws-name { font: inherit; font-size: 1.05rem; padding: 10px 14px; border: 1px solid var(--line);
|
||
border-radius: 10px; background: var(--surface); color: var(--ink); }
|
||
.ws-words { font: inherit; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
|
||
background: var(--surface); color: var(--ink); resize: vertical; line-height: 1.6;
|
||
text-transform: uppercase; letter-spacing: 0.03em; }
|
||
.ws-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.ws-count { font-size: 0.86rem; font-weight: 600; color: var(--muted); }
|
||
.ws-count.ok { color: var(--accent-deep); }
|
||
.ws-ai { background: var(--accent-soft); color: var(--accent-deep); border: none; border-radius: 999px;
|
||
padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
|
||
.ws-ai:hover:not(:disabled) { filter: brightness(0.97); }
|
||
.ws-ai:disabled, .wp-add:disabled { opacity: 0.5; cursor: default; }
|
||
.ws-themes { list-style: none; padding: 0; margin: 14px 0 0; display: flex; flex-direction: column; gap: 8px; max-width: 560px; }
|
||
.ws-themes li { display: flex; align-items: center; gap: 12px; background: var(--surface);
|
||
border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
|
||
.wt-name { font-weight: 600; }
|
||
.wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; }
|
||
</style>
|