Admin step B: stats endpoint + /admin dashboard
- users.is_admin (+ migration); admin = is_admin OR email in GOODNEWS_ADMIN_EMAILS (normalized). is_admin exposed on /api/auth/me. Server-authorized GET /api/admin/stats (403 for non-admins). - queries.admin_stats: visitors (today/7d/30d), returning vs one-and-done, top opened articles, popular groupings + topics (derived from article_id at query time), share breakdown, daily opens/visits trend — all aggregate, no PII. - /admin page (gated, redirects non-admins): stat cards, CSS bar lists, a daily trend; "Admin dashboard" link on /account for admins. 129 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
<a href="/?view=saved">Saved</a>
|
||||
<a href="/?open=history">History</a>
|
||||
<a href="/?open=boundaries">Boundaries</a>
|
||||
{#if auth.user.is_admin}<a href="/admin" class="admin">Admin dashboard</a>{/if}
|
||||
</nav>
|
||||
<AccountPanel onclose={() => goto('/')} />
|
||||
{/if}
|
||||
@@ -53,4 +54,5 @@
|
||||
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem;
|
||||
}
|
||||
.quick a:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||
.quick a.admin { border-color: var(--accent); color: var(--accent-deep); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||
|
||||
let stats = $state(null);
|
||||
let error = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
if (!auth.ready) await refresh();
|
||||
if (!auth.user || !auth.user.is_admin) {
|
||||
goto('/', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stats = await getJSON('/api/admin/stats');
|
||||
} catch {
|
||||
error = "Couldn't load stats.";
|
||||
}
|
||||
});
|
||||
|
||||
const SHARE_LABEL = {
|
||||
share_ub: 'Copied UB link',
|
||||
native_share: 'Native share',
|
||||
copy_source: 'Copied source',
|
||||
source_click: 'Source clicks (from /a)',
|
||||
};
|
||||
// Width % for a simple CSS bar, relative to the top value in a list.
|
||||
function pct(v, max) {
|
||||
return max > 0 ? Math.max(4, Math.round((v / max) * 100)) : 0;
|
||||
}
|
||||
const max = (rows, key) => rows.reduce((m, r) => Math.max(m, r[key]), 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">← 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}
|
||||
<p class="sub">Aggregate, anonymous — last {stats.days} days. No personal data.</p>
|
||||
|
||||
<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">Last 7 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.returning}</span><span class="l">Returning</span></div>
|
||||
<div class="stat"><span class="n">{stats.once}</span><span class="l">One-and-done</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>
|
||||
|
||||
<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>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<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; }
|
||||
.page { padding: 22px 20px 70px; }
|
||||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 2px; }
|
||||
.sub { color: var(--muted); font-size: 0.9rem; margin: 0 0 22px; }
|
||||
h2 { font-size: 1.1rem; margin: 30px 0 12px; }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
.cards { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.stat {
|
||||
flex: 1 1 120px; 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); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user