mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
143 lines
4.2 KiB
TypeScript
143 lines
4.2 KiB
TypeScript
import { motion } from "framer-motion";
|
|
import type { ShortLink } from "../lib/types";
|
|
import {
|
|
compactNumber,
|
|
fullShortUrl,
|
|
prettyHost,
|
|
relativeTime,
|
|
shortUrl,
|
|
} from "../lib/format";
|
|
import { BarChart } from "./BarChart";
|
|
import { Button } from "./ui/Button";
|
|
|
|
interface Props {
|
|
link: ShortLink;
|
|
onEdit: (link: ShortLink) => void;
|
|
onDelete: (link: ShortLink) => void;
|
|
onCopy: () => void;
|
|
onManagePin: (link: ShortLink) => void;
|
|
onShowQr: (link: ShortLink) => void;
|
|
}
|
|
|
|
const modeLabel: Record<string, string> = {
|
|
random: "random",
|
|
memorable: "memorable",
|
|
custom: "custom",
|
|
};
|
|
|
|
export function UrlCard({
|
|
link,
|
|
onEdit,
|
|
onDelete,
|
|
onCopy,
|
|
onManagePin,
|
|
onShowQr,
|
|
}: Props) {
|
|
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: 24, scale: 0.97 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
|
|
transition={{ type: "spring", stiffness: 280, damping: 26 }}
|
|
className="card flex flex-col gap-4 p-5 sm:flex-row sm:items-stretch sm:gap-6"
|
|
>
|
|
{/* Left: identity + actions */}
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-accent-ink">
|
|
{modeLabel[link.mode]}
|
|
</span>
|
|
{link.hasPin && (
|
|
<span className="rounded-full border-[1.5px] border-line px-2 py-0.5 text-[10px] font-medium text-muted">
|
|
🔒 PIN
|
|
</span>
|
|
)}
|
|
<span className="ml-auto text-[11px] text-muted">
|
|
{relativeTime(link.createdAt)}
|
|
</span>
|
|
</div>
|
|
|
|
<a
|
|
href={fullShortUrl(link.code)}
|
|
onClick={(e) => e.preventDefault()}
|
|
className="focusable mt-2 inline-block rounded font-mono text-lg font-bold text-ink hover:text-accent"
|
|
>
|
|
{shortUrl(link.code)}
|
|
</a>
|
|
|
|
<p
|
|
className="mt-1 truncate text-[13px] text-muted"
|
|
title={link.longUrl}
|
|
>
|
|
→ {prettyHost(link.longUrl)}
|
|
<span className="opacity-60">{new URL(link.longUrl).pathname}</span>
|
|
</p>
|
|
|
|
<div className="mt-auto flex flex-wrap items-center gap-2 pt-4">
|
|
<Button size="sm" variant="outline" onClick={copy}>
|
|
Copy
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => onShowQr(link)}>
|
|
QR
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => onEdit(link)}>
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onManagePin(link)}
|
|
>
|
|
{link.hasPin ? "🔒 PIN" : "Add PIN"}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onDelete(link)}
|
|
className="ml-auto text-muted hover:!text-red-500"
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: stats */}
|
|
<div className="flex w-full flex-col rounded-2xl border-[1.5px] border-line bg-surface-2 p-4 sm:w-72">
|
|
<div className="mb-3 flex items-end justify-between">
|
|
<div>
|
|
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
Total clicks
|
|
</p>
|
|
<p className="font-display text-2xl font-bold text-ink">
|
|
{compactNumber(link.totalClicks)}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
Last 7 days
|
|
</p>
|
|
<p className="font-display text-lg font-bold text-accent-ink">
|
|
<span className="rounded-md bg-accent px-1.5">
|
|
+{compactNumber(weekTotal)}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<BarChart data={link.last7Days} />
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|