mirror of
https://github.com/ThisTine/Snip.git
synced 2026-10-18 02:51:38 +07:00
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:
1 parent
e2327207e5
commit
2e1133e39c
25 files changed
+753
-814
No files matched your search
+45
-90
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user