import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; import { api } from "../lib/api"; import { compactNumber } from "../lib/format"; import type { OwnerStats, ShortLink } from "../lib/types"; import { UrlCard } from "../components/UrlCard"; import { UrlRow } from "../components/UrlRow"; import { Pagination } from "../components/Pagination"; import { PinManager } from "../components/PinManager"; import { EditLinkModal } from "../components/EditLinkModal"; import { QrModal } from "../components/QrModal"; import { Button } from "../components/ui/Button"; import { useToast } from "../components/ui/Toast"; import { Search, X, Plus } from "../components/icons"; type View = "comfortable" | "compact"; const PAGE_SIZE: Record = { comfortable: 5, compact: 8 }; function useDebouncedValue(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(t); }, [value, delay]); return debounced; } function ViewToggle({ view, onChange }: { view: View; onChange: (v: View) => void }) { const opts: { value: View; label: string; rows: number }[] = [ { value: "comfortable", label: "Comfortable view", rows: 2 }, { value: "compact", label: "Compact view", rows: 3 }, ]; return (
{opts.map((o) => ( ))}
); } export function Dashboard() { const { user, loading: authLoading } = useAuth(); const navigate = useNavigate(); const toast = useToast(); const [view, setView] = useState( () => (localStorage.getItem("snip-view") as View) || "comfortable", ); const [searchParams, setSearchParams] = useSearchParams(); const [pinTarget, setPinTarget] = useState(null); const [editTarget, setEditTarget] = useState(null); const [qrTarget, setQrTarget] = useState(null); // Server-driven page + aggregate stats (the client never loads all links). const [pageData, setPageData] = useState<{ items: ShortLink[]; total: number } | null>(null); const [stats, setStats] = useState(null); // Search + page live in the URL (?q=&page=) so they survive a refresh / share. const query = searchParams.get("q") ?? ""; const debouncedQuery = useDebouncedValue(query, 300); const rawPage = parseInt(searchParams.get("page") ?? "1", 10); const requestedPage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1; const pageSize = PAGE_SIZE[view]; useEffect(() => { if (!authLoading && !user) navigate("/login"); }, [authLoading, user, navigate]); useEffect(() => { localStorage.setItem("snip-view", view); }, [view]); // Fetch the current page from the API (debounced search, server pagination). const reqId = useRef(0); const loadPage = useCallback(async () => { if (!user) return; const id = ++reqId.current; const res = await api.listLinks({ page: requestedPage, pageSize, q: debouncedQuery.trim() || undefined, }); if (id === reqId.current) { setPageData({ items: res.items, total: res.total }); } }, [user, requestedPage, pageSize, debouncedQuery]); const loadStats = useCallback(async () => { if (!user) return; setStats(await api.stats()); }, [user]); useEffect(() => { loadPage(); }, [loadPage]); useEffect(() => { loadStats(); }, [loadStats]); const pageCount = pageData ? Math.max(1, Math.ceil(pageData.total / pageSize)) : 1; const safePage = Math.min(requestedPage, pageCount); // Keep the URL honest if it points past the last page (deletes, ?page=99…). useEffect(() => { if (pageData && requestedPage > pageCount) { const next = new URLSearchParams(searchParams); if (pageCount <= 1) next.delete("page"); else next.set("page", String(pageCount)); setSearchParams(next, { replace: true }); } }, [pageData, requestedPage, pageCount, searchParams, setSearchParams]); function updateSearch(q: string) { const next = new URLSearchParams(searchParams); if (q) next.set("q", q); else next.delete("q"); next.delete("page"); // a new search jumps back to page 1 setSearchParams(next, { replace: true }); } function changeView(v: View) { setView(v); const next = new URLSearchParams(searchParams); next.delete("page"); // page sizes differ between views setSearchParams(next, { replace: true }); } function goToPage(p: number) { const next = new URLSearchParams(searchParams); if (p <= 1) next.delete("page"); else next.set("page", String(p)); setSearchParams(next); window.scrollTo({ top: 0, behavior: "smooth" }); } // Hold-to-delete already asked for intent, so remove the row immediately // and reconcile with the server after. async function deleteLink(link: ShortLink) { setPageData((pd) => pd ? { items: pd.items.filter((l) => l.id !== link.id), total: pd.total - 1 } : pd, ); try { await api.deleteLink(link.id); toast(`Deleted ${link.code}`, "info"); } catch { toast("Couldn't delete that link", "error"); } await Promise.all([loadPage(), loadStats()]); } // "/" jumps to search from anywhere on the page. const searchRef = useRef(null); useEffect(() => { function onKey(e: KeyboardEvent) { const t = e.target as HTMLElement; if (e.key !== "/" || t.closest("input, textarea, [contenteditable]")) return; e.preventDefault(); searchRef.current?.focus(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); // Edit / PIN: patch the row in place (totals are unaffected). function applyUpdate(updated: ShortLink, message: string) { setPageData((pd) => pd ? { ...pd, items: pd.items.map((l) => (l.id === updated.id ? { ...l, ...updated } : l)) } : pd, ); toast(message, "success"); } const items = pageData?.items ?? []; const total = pageData?.total ?? 0; const totalLinks = stats?.totalLinks ?? 0; const statTiles = [ { label: "Active links", value: compactNumber(totalLinks) }, { label: "Total clicks", value: compactNumber(stats?.totalClicks ?? 0) }, { label: "Clicks this week", value: `+${compactNumber(stats?.weekClicks ?? 0)}`, accent: true, }, ]; const hasAnyLinks = totalLinks > 0; return (
{/* header */}

Links

{/* stat tiles */}
{statTiles.map((s) => (

{s.label}

{stats ? s.value : —}

))}
{/* toolbar: search + view toggle */} {hasAnyLinks && (
updateSearch(e.target.value)} placeholder="Search links…" aria-label="Search links" className="h-full w-full bg-transparent text-[16px] text-ink outline-none placeholder:text-muted sm:text-sm" /> {!query && ( / )} {query && ( )}
)} {/* result meta */} {hasAnyLinks && (

{query ? ( <> {total} of {totalLinks}{" "} {totalLinks === 1 ? "link" : "links"} match “{query}” ) : ( <> Showing {items.length} of{" "} {totalLinks} {totalLinks === 1 ? "link" : "links"} )}

)} {/* list */}
{pageData === null ? (
{[0, 1, 2].map((i) => (
))}
) : total === 0 && !debouncedQuery ? (

No links yet

Links you shorten while signed in show up here, with clicks per day.

) : total === 0 ? (

No matches

Nothing matches “{query}”.

) : ( <> {/* Rows rise in (staggered) when a page loads, but not while typing a search — animation on every keystroke reads as lag. Deleting collapses the row so the ones below slide up instead of jumping. */}
{items.map((link, i) => { const props = { link, onEdit: setEditTarget, onDelete: deleteLink, onDeleteTap: () => toast("Hold to delete", "info"), onManagePin: setPinTarget, onShowQr: setQrTarget, }; return (
{view === "compact" ? : }
); })}
)}
setEditTarget(null)} onSaved={(updated, message) => { applyUpdate(updated, message); setEditTarget(null); }} /> setPinTarget(null)} onSaved={(updated, message) => { applyUpdate(updated, message); setPinTarget(null); }} /> setQrTarget(null)} />
); }