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]>
This commit is contained in:
sittichok OuamsiriandClaude Opus 5.5 committed 2026-10-10 00:11:22 +07:00
1 parent e2327207e5
commit 2e1133e39c
25 files changed
+753 -814

No files matched your search

+91 -76
View File
@@ -1,6 +1,6 @@
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { api } from "../lib/api";
import { compactNumber } from "../lib/format";
@@ -10,7 +10,6 @@ import { UrlRow } from "../components/UrlRow";
import { Pagination } from "../components/Pagination";
import { PinManager } from "../components/PinManager";
import { EditLinkModal } from "../components/EditLinkModal";
import { DeleteLinkModal } from "../components/DeleteLinkModal";
import { QrModal } from "../components/QrModal";
import { Button } from "../components/ui/Button";
import { useToast } from "../components/ui/Toast";
@@ -34,15 +33,17 @@ function ViewToggle({ view, onChange }: { view: View; onChange: (v: View) => voi
{ value: "compact", label: "Compact view", rows: 3 },
];
return (
<div className="flex shrink-0 gap-1 rounded-xl border border-line bg-surface p-1">
<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={`focusable grid h-8 w-9 place-items-center rounded-lg transition-colors ${
view === o.value ? "bg-[var(--ink)] text-[var(--bg)]" : "text-muted hover:text-ink"
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">
@@ -74,7 +75,6 @@ export function Dashboard() {
const [searchParams, setSearchParams] = useSearchParams();
const [pinTarget, setPinTarget] = useState<ShortLink | null>(null);
const [editTarget, setEditTarget] = useState<ShortLink | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ShortLink | null>(null);
const [qrTarget, setQrTarget] = useState<ShortLink | null>(null);
// Server-driven page + aggregate stats (the client never loads all links).
@@ -159,12 +159,34 @@ export function Dashboard() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function handleDeleted(_id: string, message: string) {
setDeleteTarget(null);
toast(message, "info");
// 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) =>
@@ -196,33 +218,26 @@ export function Dashboard() {
{/* header */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-ink sm:text-3xl">
Your links
<h1 className="text-2xl font-semibold tracking-[-0.02em] text-ink sm:text-[28px]">
Links
</h1>
<p className="mt-1 text-sm text-muted">
Search, edit destinations, manage PINs, or retire a link.
</p>
</div>
<Link to="/">
<Button size="md">
<Plus size={16} /> New link
</Button>
</Link>
<Button size="md" onClick={() => navigate("/")}>
<Plus size={16} /> New link
</Button>
</div>
{/* stat tiles */}
<div className="mt-6 grid grid-cols-3 gap-3">
<div className="card mt-6 grid grid-cols-3 divide-x divide-[var(--line)]">
{statTiles.map((s) => (
<div key={s.label} className="card p-4">
<p className="text-[11px] uppercase tracking-wide text-muted">
{s.label}
</p>
<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-1 text-2xl font-semibold sm:text-3xl ${
className={`mt-0.5 text-xl font-semibold tabular-nums tracking-[-0.02em] sm:text-2xl ${
s.accent ? "text-accent" : "text-ink"
}`}
>
{s.value}
{stats ? s.value : <span className="text-[var(--ring)]">—</span>}
</p>
</div>
))}
@@ -231,19 +246,26 @@ export function Dashboard() {
{/* toolbar: search + view toggle */}
{hasAnyLinks && (
<div className="mt-6 flex items-center gap-2.5">
<div className="flex h-10 flex-1 items-center gap-2 rounded-xl border border-line bg-surface px-3 focus-within:border-[var(--ink)]">
<Search size={16} className="text-muted" />
<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 by code or URL…"
className="h-full w-full bg-transparent text-sm text-ink outline-none placeholder:text-muted"
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="focusable grid h-6 w-6 place-items-center rounded-md text-muted hover:bg-surface-2 hover:text-ink"
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>
@@ -255,7 +277,7 @@ export function Dashboard() {
{/* result meta */}
{hasAnyLinks && (
<p className="mt-3 text-xs text-muted">
<p className="mt-3 px-0.5 text-xs tabular-nums text-muted">
{query ? (
<>
<span className="font-medium text-ink">{total}</span> of {totalLinks}{" "}
@@ -277,21 +299,20 @@ export function Dashboard() {
{[0, 1, 2].map((i) => (
<div
key={i}
className="card h-24 animate-pulse opacity-60"
style={{ animationDelay: `${i * 120}ms` }}
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-lg font-semibold text-ink">No links yet</p>
<p className="text-base font-semibold text-ink">No links yet</p>
<p className="max-w-xs text-sm text-muted">
Create your first short link and it'll show up here with live click
stats.
Links you shorten while signed in show up here, with clicks per day.
</p>
<Link to="/" className="mt-2">
<Button>Create a link</Button>
</Link>
<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">
@@ -306,33 +327,37 @@ export function Dashboard() {
</div>
) : (
<>
<motion.div layout className={view === "compact" ? "space-y-2.5" : "space-y-3"}>
<AnimatePresence mode="popLayout">
{items.map((link) =>
view === "compact" ? (
<UrlRow
{/* 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}
link={link}
onEdit={setEditTarget}
onDelete={setDeleteTarget}
onCopy={() => toast("Copied to clipboard", "success")}
onManagePin={setPinTarget}
onShowQr={setQrTarget}
/>
) : (
<UrlCard
key={link.id}
link={link}
onEdit={setEditTarget}
onDelete={setDeleteTarget}
onCopy={() => toast("Copied to clipboard", "success")}
onManagePin={setPinTarget}
onShowQr={setQrTarget}
/>
),
)}
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>
</motion.div>
</div>
<Pagination page={safePage} pageCount={pageCount} onPage={goToPage} />
</>
@@ -357,17 +382,7 @@ export function Dashboard() {
}}
/>
<QrModal
link={qrTarget}
onClose={() => setQrTarget(null)}
onCopied={() => toast("Copied to clipboard", "success")}
/>
<DeleteLinkModal
link={deleteTarget}
onClose={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
<QrModal link={qrTarget} onClose={() => setQrTarget(null)} />
</div>
);
}