Files
Snip/src/pages/Dashboard.tsx
T
sittichok OuamsiriandClaude Opus 5.5 2e1133e39c feat: redesign UI and UX around design-engineering principles
- Ink-first palette, drop gradients/glow; tabular numbers
- Home: form stays put, results stack below and auto-copy; paste shortens instantly
- Hold-to-delete replaces the delete confirm modal
- Copy buttons morph to a check instead of toasting
- Motion: CSS press feedback, custom ease-out, origin-aware menu, faster exits,
  no animation on frequent/keyboard actions, reduced-motion respected
- Fix: scheme-less URLs ("example.com") were blocked by native validation

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 00:11:22 +07:00

389 lines
14 KiB
TypeScript

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<View, number> = { comfortable: 5, compact: 8 };
function useDebouncedValue<T>(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 (
<div className="flex shrink-0 gap-0.5 rounded-xl bg-surface-2 p-1">
{opts.map((o) => (
<button
key={o.value}
onClick={() => onChange(o.value)}
aria-label={o.label}
aria-pressed={view === o.value}
className={`press focusable grid h-8 w-9 place-items-center rounded-lg ${
view === o.value
? "bg-surface text-ink shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_var(--line)]"
: "text-muted hover:text-ink"
}`}
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5">
{Array.from({ length: o.rows }).map((_, i) => (
<rect
key={i}
x="2"
y={2 + i * (11 / o.rows)}
width="11"
height={11 / o.rows - 1.5}
rx="1"
/>
))}
</svg>
</button>
))}
</div>
);
}
export function Dashboard() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const toast = useToast();
const [view, setView] = useState<View>(
() => (localStorage.getItem("snip-view") as View) || "comfortable",
);
const [searchParams, setSearchParams] = useSearchParams();
const [pinTarget, setPinTarget] = useState<ShortLink | null>(null);
const [editTarget, setEditTarget] = useState<ShortLink | null>(null);
const [qrTarget, setQrTarget] = useState<ShortLink | null>(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<OwnerStats | null>(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<HTMLInputElement>(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 (
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8">
{/* header */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-[-0.02em] text-ink sm:text-[28px]">
Links
</h1>
</div>
<Button size="md" onClick={() => navigate("/")}>
<Plus size={16} /> New link
</Button>
</div>
{/* stat tiles */}
<div className="card mt-6 grid grid-cols-3 divide-x divide-[var(--line)]">
{statTiles.map((s) => (
<div key={s.label} className="px-4 py-3.5 sm:px-5 sm:py-4">
<p className="text-[12px] text-muted">{s.label}</p>
<p
className={`mt-0.5 text-xl font-semibold tabular-nums tracking-[-0.02em] sm:text-2xl ${
s.accent ? "text-accent" : "text-ink"
}`}
>
{stats ? s.value : <span className="text-[var(--ring)]">—</span>}
</p>
</div>
))}
</div>
{/* toolbar: search + view toggle */}
{hasAnyLinks && (
<div className="mt-6 flex items-center gap-2.5">
<div className="field flex h-10 flex-1 items-center gap-2 px-3">
<Search size={16} className="shrink-0 text-muted" />
<input
ref={searchRef}
value={query}
onChange={(e) => 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 && (
<kbd className="hidden h-5 shrink-0 place-items-center rounded border border-line px-1.5 text-[11px] text-muted sm:grid">
/
</kbd>
)}
{query && (
<button
onClick={() => updateSearch("")}
aria-label="Clear search"
className="press focusable grid h-6 w-6 shrink-0 place-items-center rounded-md text-muted hover:bg-surface-2 hover:text-ink"
>
<X size={14} />
</button>
)}
</div>
<ViewToggle view={view} onChange={changeView} />
</div>
)}
{/* result meta */}
{hasAnyLinks && (
<p className="mt-3 px-0.5 text-xs tabular-nums text-muted">
{query ? (
<>
<span className="font-medium text-ink">{total}</span> of {totalLinks}{" "}
{totalLinks === 1 ? "link" : "links"} match “{query}”
</>
) : (
<>
Showing <span className="font-medium text-ink">{items.length}</span> of{" "}
{totalLinks} {totalLinks === 1 ? "link" : "links"}
</>
)}
</p>
)}
{/* list */}
<div className="mt-4">
{pageData === null ? (
<div className="space-y-3">
{[0, 1, 2].map((i) => (
<div
key={i}
className="card animate-pulse opacity-60"
style={{ animationDelay: `${i * 120}ms`, height: view === "compact" ? 64 : 172 }}
/>
))}
</div>
) : total === 0 && !debouncedQuery ? (
<div className="card grid place-items-center gap-2 p-12 text-center">
<p className="text-base font-semibold text-ink">No links yet</p>
<p className="max-w-xs text-sm text-muted">
Links you shorten while signed in show up here, with clicks per day.
</p>
<Button className="mt-2" onClick={() => navigate("/")}>
Shorten a link
</Button>
</div>
) : total === 0 ? (
<div className="card grid place-items-center gap-1.5 p-10 text-center">
<p className="text-base font-semibold text-ink">No matches</p>
<p className="text-sm text-muted">Nothing matches “{query}”.</p>
<button
onClick={() => updateSearch("")}
className="focusable mt-1 rounded-lg text-sm font-medium text-ink underline underline-offset-2"
>
Clear search
</button>
</div>
) : (
<>
{/* 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. */}
<div>
<AnimatePresence initial={false}>
{items.map((link, i) => {
const props = {
link,
onEdit: setEditTarget,
onDelete: deleteLink,
onDeleteTap: () => toast("Hold to delete", "info"),
onManagePin: setPinTarget,
onShowQr: setQrTarget,
};
return (
<motion.div
key={link.id}
exit={{ opacity: 0, height: 0, transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] } }}
className="overflow-hidden"
>
<div
className={`${view === "compact" ? "pb-2" : "pb-3"} ${query ? "" : "rise"}`}
style={{ animationDelay: `${i * 40}ms` }}
>
{view === "compact" ? <UrlRow {...props} /> : <UrlCard {...props} />}
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
<Pagination page={safePage} pageCount={pageCount} onPage={goToPage} />
</>
)}
</div>
<EditLinkModal
link={editTarget}
onClose={() => setEditTarget(null)}
onSaved={(updated, message) => {
applyUpdate(updated, message);
setEditTarget(null);
}}
/>
<PinManager
link={pinTarget}
onClose={() => setPinTarget(null)}
onSaved={(updated, message) => {
applyUpdate(updated, message);
setPinTarget(null);
}}
/>
<QrModal link={qrTarget} onClose={() => setQrTarget(null)} />
</div>
);
}