feat: url dashboard

This commit is contained in:
gosangam
2026-07-11 01:10:55 +05:30
parent 0406a7fe63
commit 5c9a689306
3 changed files with 247 additions and 0 deletions
+2
View File
@@ -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 },
];
+214
View File
@@ -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>
);
}