215 lines
7.3 KiB
TypeScript
215 lines
7.3 KiB
TypeScript
"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";
|
|
import { Clamp } from "@/components/ui/clamp";
|
|
|
|
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="block text-primary hover:underline"
|
|
>
|
|
<Clamp>{s.long_url}</Clamp>
|
|
</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>
|
|
);
|
|
}
|