108 lines
3.3 KiB
TypeScript
108 lines
3.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { api, AdminError, type Stats } from "@/lib/api";
|
|
|
|
export default function OverviewPage() {
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
api
|
|
.stats()
|
|
.then((s) => {
|
|
if (!cancelled) setStats(s);
|
|
})
|
|
.catch((e) => {
|
|
if (!cancelled)
|
|
setError(e instanceof AdminError ? e.message : "Could not load stats.");
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<h1 className="text-2xl font-semibold text-foreground">Overview</h1>
|
|
|
|
{error ? (
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
) : !stats ? (
|
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
) : (
|
|
<>
|
|
<section className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
|
<StatCard label="Users" value={stats.users} delta={`+${stats.users_last_7_days} / 7d`} />
|
|
<StatCard label="Posts" value={stats.posts} delta={`+${stats.posts_last_7_days} / 7d`} />
|
|
<StatCard
|
|
label="Collections"
|
|
value={stats.collections}
|
|
delta={`${stats.public_collections} public`}
|
|
/>
|
|
<StatCard label="Bookmarks" value={stats.bookmarks} />
|
|
<StatCard label="Reshares" value={stats.reshares} />
|
|
<StatCard
|
|
label="Feedback"
|
|
value={stats.feedback}
|
|
delta={`+${stats.feedback_last_7_days} / 7d`}
|
|
/>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-semibold text-foreground">
|
|
Feedback by category
|
|
</h2>
|
|
{!stats.feedback_by_category?.length ? (
|
|
<p className="text-sm text-muted-foreground">No feedback yet.</p>
|
|
) : (
|
|
<div className="max-w-md divide-y divide-border rounded-xl border border-border bg-card">
|
|
{stats.feedback_by_category.map((c) => (
|
|
<div
|
|
key={c.category}
|
|
className="flex items-center justify-between px-4 py-2.5 text-sm"
|
|
>
|
|
<span className="capitalize text-foreground">
|
|
{c.category.replace(/_/g, " ")}
|
|
</span>
|
|
<span className="font-medium text-muted-foreground">
|
|
{c.count}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
delta,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
delta?: string;
|
|
}) {
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card p-4">
|
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
{label}
|
|
</div>
|
|
<div className="mt-1 flex items-baseline gap-2">
|
|
<span className="text-2xl font-semibold text-foreground">
|
|
{value.toLocaleString()}
|
|
</span>
|
|
{delta ? (
|
|
<span className="text-xs font-medium text-primary">{delta}</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|