feat: url dashboard
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
FolderClosed,
|
FolderClosed,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
Link2,
|
||||||
LogOut,
|
LogOut,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Users,
|
Users,
|
||||||
@@ -22,6 +23,7 @@ const NAV = [
|
|||||||
{ href: "/users", label: "Users", icon: Users },
|
{ href: "/users", label: "Users", icon: Users },
|
||||||
{ href: "/posts", label: "Posts", icon: LayoutGrid },
|
{ href: "/posts", label: "Posts", icon: LayoutGrid },
|
||||||
{ href: "/collections", label: "Collections", icon: FolderClosed },
|
{ href: "/collections", label: "Collections", icon: FolderClosed },
|
||||||
|
{ href: "/short-urls", label: "Short URLs", icon: Link2 },
|
||||||
{ href: "/feedback", label: "Feedback", icon: MessageSquare },
|
{ href: "/feedback", label: "Feedback", icon: MessageSquare },
|
||||||
{ href: "/reports", label: "Reports", icon: Flag },
|
{ 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 }[];
|
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 = {
|
export type AdminUser = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -289,6 +311,15 @@ export const api = {
|
|||||||
collections(q: string, limit: number, offset: number) {
|
collections(q: string, limit: number, offset: number) {
|
||||||
return listed<AdminCollection>(`/admin/collections${qs({ q, limit, offset })}`);
|
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) {
|
feedback(status: string, limit: number, offset: number) {
|
||||||
return listed<AdminFeedback>(`/admin/feedback${qs({ status, limit, offset })}`);
|
return listed<AdminFeedback>(`/admin/feedback${qs({ status, limit, offset })}`);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user