feat: url dashboard
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
FolderClosed,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
Link2,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Users,
|
||||
@@ -22,6 +23,7 @@ const NAV = [
|
||||
{ href: "/users", label: "Users", icon: Users },
|
||||
{ href: "/posts", label: "Posts", icon: LayoutGrid },
|
||||
{ href: "/collections", label: "Collections", icon: FolderClosed },
|
||||
{ href: "/short-urls", label: "Short URLs", icon: Link2 },
|
||||
{ href: "/feedback", label: "Feedback", icon: MessageSquare },
|
||||
{ href: "/reports", label: "Reports", icon: Flag },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type AdminShortUrl, type ShortUrlStats } from "@/lib/api";
|
||||
import { usePagedList } from "@/lib/use-paged-list";
|
||||
import { ListShell, EmptyRow } from "@/components/list-shell";
|
||||
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
|
||||
import { fmtDate } from "@/lib/utils";
|
||||
|
||||
export default function ShortUrlsPage() {
|
||||
// ── Stats ────────────────────────────────────────────────────────────────
|
||||
const [stats, setStats] = useState<ShortUrlStats | null>(null);
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch once both custom dates are set, or once both are cleared (fixed
|
||||
// windows only). A half-filled range waits for the second date.
|
||||
const ready = (!!from && !!to) || (!from && !to);
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.shortUrlStats(from || undefined, to || undefined)
|
||||
.then((s) => {
|
||||
if (!cancelled) setStats(s);
|
||||
})
|
||||
.catch(() => {
|
||||
/* leave the previous stats in place on error */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [from, to]);
|
||||
|
||||
// ── Paginated list ─────────────────────────────────────────────────────────
|
||||
const [q, setQ] = useState("");
|
||||
const fetcher = useCallback(
|
||||
(limit: number, offset: number) => api.shortUrls(q, limit, offset),
|
||||
[q],
|
||||
);
|
||||
const list = usePagedList<AdminShortUrl>(fetcher, [q]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
Short URL stats
|
||||
</h1>
|
||||
{/* Custom date range (inclusive). Clearing both returns to the fixed
|
||||
windows below. */}
|
||||
<div className="flex items-end gap-2">
|
||||
<label className="flex flex-col text-xs text-muted-foreground">
|
||||
From
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className="mt-1 rounded-md border border-border bg-card px-2.5 py-1.5 text-sm text-foreground"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col text-xs text-muted-foreground">
|
||||
To
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="mt-1 rounded-md border border-border bg-card px-2.5 py-1.5 text-sm text-foreground"
|
||||
/>
|
||||
</label>
|
||||
{(from || to) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFrom("");
|
||||
setTo("");
|
||||
}}
|
||||
className="rounded-md px-2.5 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="Today" created={stats?.today.created} clicks={stats?.today.clicks} />
|
||||
<StatCard label="This week" created={stats?.week.created} clicks={stats?.week.clicks} />
|
||||
<StatCard label="This month" created={stats?.month.created} clicks={stats?.month.clicks} />
|
||||
<StatCard
|
||||
label="All time"
|
||||
created={stats?.total_urls}
|
||||
clicks={stats?.total_clicks}
|
||||
/>
|
||||
{stats?.range && (
|
||||
<StatCard
|
||||
label={`Custom · ${from} → ${to}`}
|
||||
created={stats.range.created}
|
||||
clicks={stats.range.clicks}
|
||||
highlight
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ListShell
|
||||
title="Shortened URLs"
|
||||
total={list.total}
|
||||
searchPlaceholder="Search by code or target URL…"
|
||||
onQueryChange={setQ}
|
||||
list={list}
|
||||
>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Code</TH>
|
||||
<TH>Target</TH>
|
||||
<TH>Type</TH>
|
||||
<TH>Clicks</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Created</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<tbody>
|
||||
{list.items.length === 0 && !list.loading ? (
|
||||
<EmptyRow cols={6} label="No shortened URLs found." />
|
||||
) : (
|
||||
list.items.map((s) => (
|
||||
<TR key={s.code}>
|
||||
<TD className="whitespace-nowrap font-mono text-xs">
|
||||
{s.code}
|
||||
</TD>
|
||||
<TD className="max-w-md">
|
||||
<a
|
||||
href={s.long_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="line-clamp-1 break-all text-primary hover:underline"
|
||||
title={s.long_url}
|
||||
>
|
||||
{s.long_url}
|
||||
</a>
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap text-muted-foreground">
|
||||
{s.content_type || "—"}
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap">
|
||||
<span className="font-medium">
|
||||
{s.click_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({s.human_click_count.toLocaleString()} human)
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap">
|
||||
<span
|
||||
className={
|
||||
s.disabled ? "text-destructive" : "text-foreground"
|
||||
}
|
||||
>
|
||||
{s.disabled ? "Disabled" : "Active"}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap">{fmtDate(s.created_at)}</TD>
|
||||
</TR>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ListShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
created,
|
||||
clicks,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
created?: number;
|
||||
clicks?: number;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"rounded-xl border bg-card p-4 " +
|
||||
(highlight ? "border-primary/40" : "border-border")
|
||||
}
|
||||
>
|
||||
<div className="truncate text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{(created ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">created</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{(clicks ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">clicks</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+31
@@ -92,6 +92,28 @@ export type Stats = {
|
||||
feedback_by_category: { category: string; count: number }[];
|
||||
};
|
||||
|
||||
export type AdminShortUrl = {
|
||||
id: number;
|
||||
code: string;
|
||||
long_url: string;
|
||||
content_type: string;
|
||||
click_count: number;
|
||||
human_click_count: number;
|
||||
disabled: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ShortUrlRangeStat = { created: number; clicks: number };
|
||||
|
||||
export type ShortUrlStats = {
|
||||
today: ShortUrlRangeStat;
|
||||
week: ShortUrlRangeStat;
|
||||
month: ShortUrlRangeStat;
|
||||
total_urls: number;
|
||||
total_clicks: number;
|
||||
range?: ShortUrlRangeStat; // present only when a custom from/to is requested
|
||||
};
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
@@ -289,6 +311,15 @@ export const api = {
|
||||
collections(q: string, limit: number, offset: number) {
|
||||
return listed<AdminCollection>(`/admin/collections${qs({ q, limit, offset })}`);
|
||||
},
|
||||
shortUrls(q: string, limit: number, offset: number) {
|
||||
return listed<AdminShortUrl>(`/admin/short-urls${qs({ q, limit, offset })}`);
|
||||
},
|
||||
async shortUrlStats(from?: string, to?: string): Promise<ShortUrlStats> {
|
||||
const { data } = await call<ShortUrlStats>(
|
||||
`/admin/short-urls/stats${qs({ from, to })}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
feedback(status: string, limit: number, offset: number) {
|
||||
return listed<AdminFeedback>(`/admin/feedback${qs({ status, limit, offset })}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user