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

+45 -90
View File
@@ -1,127 +1,82 @@
import { motion } from "framer-motion";
import type { ShortLink } from "../lib/types";
import {
compactNumber,
fullShortUrl,
prettyHost,
shortUrl,
} from "../lib/format";
import { compactNumber, fullShortUrl, prettyHost, shortUrl } from "../lib/format";
import { Sparkline } from "./Sparkline";
import { Copy, Qr, Lock, Edit, Trash } from "./icons";
import { CopyButton } from "./ui/CopyButton";
import { HoldToDelete } from "./ui/HoldToDelete";
import { Qr, Lock, Edit } from "./icons";
interface Props {
export interface LinkItemProps {
link: ShortLink;
onEdit: (link: ShortLink) => void;
onDelete: (link: ShortLink) => void;
onCopy: () => void;
onDeleteTap: () => void;
onManagePin: (link: ShortLink) => void;
onShowQr: (link: ShortLink) => void;
}
function IconButton({
label,
onClick,
danger,
children,
}: {
label: string;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
title={label}
aria-label={label}
className={`focusable grid h-8 w-8 place-items-center rounded-lg border border-line bg-surface text-muted transition-colors ${
danger ? "hover:border-red-400 hover:text-red-500" : "hover:bg-surface-2 hover:text-ink"
}`}
>
{children}
</button>
);
}
export const iconBtn =
"press focusable grid h-8 w-8 place-items-center rounded-lg text-muted hover:bg-surface-2 hover:text-ink";
export function UrlRow({
link,
onEdit,
onDelete,
onCopy,
onDeleteTap,
onManagePin,
onShowQr,
}: Props) {
}: LinkItemProps) {
const weekTotal = link.last7Days.reduce((s, d) => s + d.count, 0);
async function copy() {
try {
await navigator.clipboard.writeText(fullShortUrl(link.code));
} catch {
/* ignore */
}
onCopy();
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.12 } }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="card flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<div className="card flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:gap-4">
{/* identity */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<a
href={fullShortUrl(link.code)}
onClick={(e) => e.preventDefault()}
className="focusable truncate rounded font-mono text-sm font-semibold text-ink hover:text-accent"
>
<p className="truncate font-mono text-sm font-semibold text-ink">
{shortUrl(link.code)}
</a>
</p>
{link.hasPin && <Lock size={12} className="shrink-0 text-muted" />}
</div>
<p className="truncate text-[12px] text-muted" title={link.longUrl}>
{prettyHost(link.longUrl)}
→ {prettyHost(link.longUrl)}
</p>
</div>
{/* stats */}
<div className="flex shrink-0 items-center gap-3">
<div className="text-right">
<p className="text-sm font-semibold leading-none text-ink">
<div className="flex items-center justify-between gap-4">
{/* stats */}
<div className="flex shrink-0 items-center gap-3 tabular-nums">
<div className="hidden sm:block">
<Sparkline data={link.last7Days} />
</div>
<p className="text-sm font-semibold text-ink sm:w-14 sm:text-right">
{compactNumber(link.totalClicks)}
{weekTotal > 0 && (
<span className="block text-[11px] font-medium text-accent">
+{compactNumber(weekTotal)}
</span>
)}
</p>
<p className="text-[10px] uppercase tracking-wide text-muted">clicks</p>
</div>
<span className="hidden text-[12px] font-medium text-accent sm:inline">
+{compactNumber(weekTotal)}
</span>
<div className="hidden sm:block">
<Sparkline data={link.last7Days} />
</div>
</div>
{/* actions */}
<div className="flex shrink-0 items-center gap-1.5">
<IconButton label="Copy link" onClick={copy}>
<Copy size={15} />
</IconButton>
<IconButton label="Show QR code" onClick={() => onShowQr(link)}>
<Qr size={15} />
</IconButton>
<IconButton label="Manage PIN" onClick={() => onManagePin(link)}>
<Lock size={15} />
</IconButton>
<IconButton label="Edit destination" onClick={() => onEdit(link)}>
<Edit size={15} />
</IconButton>
<IconButton label="Delete link" danger onClick={() => onDelete(link)}>
<Trash size={15} />
</IconButton>
{/* actions */}
<div className="-mr-1.5 flex shrink-0 items-center">
<CopyButton text={fullShortUrl(link.code)} className={iconBtn} />
<button onClick={() => onShowQr(link)} aria-label="Show QR code" className={iconBtn}>
<Qr size={15} />
</button>
<button onClick={() => onManagePin(link)} aria-label="Manage PIN" className={iconBtn}>
<Lock size={15} />
</button>
<button onClick={() => onEdit(link)} aria-label="Edit destination" className={iconBtn}>
<Edit size={15} />
</button>
<HoldToDelete
onConfirm={() => onDelete(link)}
onTap={onDeleteTap}
className="h-8 w-8 rounded-lg hover:bg-surface-2"
/>
</div>
</div>
</motion.div>
</div>
);
}