Files
upbeatBytes/frontend/src/routes/admin/+page.svelte
T
thejayman77 1a8d1b3bf1 Promote-candidate UI: add-a-source pipeline in the admin console
Bring the supervised source-candidate flow into Sources (Codex's v1 scope), so
adding feeds no longer needs the CLI.

* feeds.safe_fetch_feed: SSRF-safe fetch for UNTRUSTED (admin-pasted) URLs —
  http(s) only, every redirect hop re-validated via enrich._host_is_public,
  body size-capped, bounded redirects, no cookies. preview_feed gains a
  `fetcher` param; the API path passes safe_fetch_feed (NOT the raw fetch_feed
  used for already-vetted polling).
* API (admin-gated): GET /candidates; POST /candidates (suggest+preview, gated
  before the outbound fetch, no DB conn held during network); /{id}/preview
  (explicit re-preview); /{id}/promote (paused by default, returns the new
  source + updated candidate); /{id}/reject. rejected stays on candidates only.
* Admin Sources tab: "Add a source" field + a candidate queue showing the
  preview (pass rate, recent count, example headlines) with Promote (as paused,
  or Activate immediately) / Re-preview / Reject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:28:00 -04:00

881 lines
45 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');
} catch {
error = "Couldn't load stats.";
}
});
const TABS = [
{ key: 'overview', label: 'Overview' },
{ key: 'content', label: 'Content' },
{ key: 'sources', label: 'Sources' },
{ key: 'audience', label: 'Audience' },
{ key: 'feedback', label: 'Feedback' },
];
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');
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; }
}
// --- 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;
}
}
async function repreviewCandidate(c) {
c._err = '';
try { const u = await postJSON(`/api/admin/candidates/${c.id}/preview`); c.preview = u.preview; }
catch (e) { c._err = e?.message || 'Re-preview failed.'; }
}
async function promoteCandidate(c) {
c._err = '';
try {
await postJSON(`/api/admin/candidates/${c.id}/promote`, {
default_category: (c._cat || '').trim() || null,
active: !!c._activate,
});
c.status = 'promoted';
await loadStats(); // surface the new (paused) source in the table below
} catch (e) {
c._err = e?.message || 'Promote failed.';
}
}
async function rejectCandidate(c) {
try { await postJSON(`/api/admin/candidates/${c.id}/reject`); c.status = 'rejected'; }
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>
</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</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>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="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 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>
</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'}
<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}
{/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; }
.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 .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 .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; }
</style>