mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e177c7775a |
+5
-4
@@ -4,11 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#16170F" />
|
||||
<title>snip — A short link service</title>
|
||||
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
|
||||
<meta name="theme-color" content="#fbfbfa" />
|
||||
<title>snip — short links, minimal</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://api.fontshare.com/v2/css?f[]=clash-display@600,700&f[]=general-sans@400,500,600&f[]=space-mono@400,700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script>
|
||||
|
||||
+14
-54
@@ -1,17 +1,19 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { Route, Routes, useLocation } from "react-router-dom";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { Home } from "./pages/Home";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
|
||||
// Each route remounts on navigation (keyed below), so a plain mount animation
|
||||
// gives a smooth fade-in without AnimatePresence "wait" exits, which can stall
|
||||
// when an exiting page holds its own AnimatePresence / layout children.
|
||||
function Page({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.main
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 30 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.main>
|
||||
@@ -22,59 +24,17 @@ export default function App() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="app-bg grain min-h-screen">
|
||||
{/* drifting decorative blobs */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="animate-float pointer-events-none fixed left-[6%] top-[22%] -z-[1] h-40 w-40 rounded-full bg-accent opacity-[0.07] blur-3xl"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="animate-float pointer-events-none fixed bottom-[12%] right-[8%] -z-[1] h-56 w-56 rounded-full bg-accent opacity-[0.08] blur-3xl"
|
||||
style={{ animationDelay: "2s" }}
|
||||
/>
|
||||
|
||||
<div className="px-3 sm:px-5">
|
||||
<div className="min-h-screen">
|
||||
<div className="px-4 sm:px-5">
|
||||
<Navbar />
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Page>
|
||||
<Home />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<Page>
|
||||
<Login />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<Page>
|
||||
<Dashboard />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Page>
|
||||
<Home />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/" element={<Page><Home /></Page>} />
|
||||
<Route path="/login" element={<Page><Login /></Page>} />
|
||||
<Route path="/dashboard" element={<Page><Dashboard /></Page>} />
|
||||
<Route path="*" element={<Page><Home /></Page>} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ interface Props {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function BarChart({ data, height = 92 }: Props) {
|
||||
export function BarChart({ data, height = 84 }: Props) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const max = Math.max(1, ...data.map((d) => d.count));
|
||||
|
||||
@@ -16,7 +16,6 @@ export function BarChart({ data, height = 92 }: Props) {
|
||||
<div className="flex items-end gap-1.5" style={{ height }}>
|
||||
{data.map((d, i) => {
|
||||
const ratio = d.count / max;
|
||||
const isPeak = d.count === max && max > 0;
|
||||
const active = hover === i;
|
||||
return (
|
||||
<div
|
||||
@@ -25,16 +24,11 @@ export function BarChart({ data, height = 92 }: Props) {
|
||||
onMouseEnter={() => setHover(i)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{/* tooltip */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: active ? 1 : 0,
|
||||
y: active ? 0 : 6,
|
||||
scale: active ? 1 : 0.8,
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 520, damping: 22 }}
|
||||
className="pointer-events-none absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded-lg bg-[var(--ink)] px-2 py-1 text-[11px] font-semibold text-[var(--bg)]"
|
||||
animate={{ opacity: active ? 1 : 0, y: active ? 0 : 4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="pointer-events-none absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded-md bg-[var(--ink)] px-2 py-1 text-[11px] font-medium text-[var(--bg)]"
|
||||
>
|
||||
{d.count} clicks
|
||||
</motion.div>
|
||||
@@ -43,23 +37,14 @@ export function BarChart({ data, height = 92 }: Props) {
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: `${Math.max(ratio * 100, 4)}%` }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 18,
|
||||
delay: i * 0.05,
|
||||
}}
|
||||
className={`w-full rounded-md ${
|
||||
isPeak || active
|
||||
? "bg-accent"
|
||||
: "bg-[var(--ring)] group-hover:bg-[var(--muted)]"
|
||||
transition={{ duration: 0.4, ease: "easeOut", delay: i * 0.04 }}
|
||||
className={`w-full rounded-sm transition-colors ${
|
||||
active ? "bg-[var(--ink)]" : "bg-[var(--ring)]"
|
||||
}`}
|
||||
style={{ minHeight: 4 }}
|
||||
style={{ minHeight: 3 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-muted">
|
||||
{weekdayLabel(d.date)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted">{weekdayLabel(d.date)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -30,25 +30,18 @@ export function DeleteLinkModal({ link, onClose, onDeleted }: Props) {
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Delete link">
|
||||
<div className="-mt-2 mb-4 flex items-center gap-3 rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3">
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-red-500/12 text-lg">
|
||||
🗑
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-base font-bold text-ink">
|
||||
{shortUrl(link.code)}
|
||||
</p>
|
||||
<p className="truncate text-[12px] text-muted">
|
||||
→ {prettyHost(link.longUrl)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="-mt-2 mb-4 rounded-xl border border-line bg-surface-2 px-4 py-3">
|
||||
<p className="truncate font-mono text-sm font-semibold text-ink">
|
||||
{shortUrl(link.code)}
|
||||
</p>
|
||||
<p className="truncate text-[12px] text-muted">{prettyHost(link.longUrl)}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-relaxed text-muted">
|
||||
This permanently deletes the link. Anyone who opens{" "}
|
||||
<span className="font-mono text-ink">{shortUrl(link.code)}</span> will
|
||||
hit a dead end, and its{" "}
|
||||
<span className="font-semibold text-ink">
|
||||
<span className="font-mono text-ink">{shortUrl(link.code)}</span> hits a dead
|
||||
end, and its{" "}
|
||||
<span className="font-medium text-ink">
|
||||
{compactNumber(link.totalClicks)} clicks
|
||||
</span>{" "}
|
||||
of history go with it. This can't be undone.
|
||||
@@ -58,7 +51,7 @@ export function DeleteLinkModal({ link, onClose, onDeleted }: Props) {
|
||||
<Button
|
||||
onClick={confirm}
|
||||
disabled={busy}
|
||||
className="!bg-red-500 !text-white shadow-[0_8px_24px_-8px_rgba(239,68,68,0.6)]"
|
||||
className="!bg-red-500 !text-white hover:!opacity-90"
|
||||
>
|
||||
{busy ? "Deleting…" : "Delete link"}
|
||||
</Button>
|
||||
|
||||
@@ -52,44 +52,36 @@ export function EditLinkModal({ link, onClose, onSaved }: Props) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Edit link">
|
||||
{/* Short code is permanent — shown read-only so existing shares keep working. */}
|
||||
<div className="-mt-2 mb-4 flex items-center justify-between rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3">
|
||||
<div className="-mt-2 mb-4 flex items-center justify-between gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
Short link
|
||||
</p>
|
||||
<p className="truncate font-mono text-base font-bold text-ink">
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted">Short link</p>
|
||||
<p className="truncate font-mono text-sm font-semibold text-ink">
|
||||
{shortUrl(link.code)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full border-[1.5px] border-line px-2 py-0.5 text-[10px] font-medium text-muted">
|
||||
can't change
|
||||
<span className="shrink-0 rounded-full border border-line px-2 py-0.5 text-[10px] text-muted">
|
||||
permanent
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="mb-2 block text-sm font-semibold text-ink">
|
||||
<label className="mb-1.5 block text-[13px] font-medium text-muted">
|
||||
Destination URL
|
||||
</label>
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-2xl border-[1.5px] bg-surface-2 px-3.5 transition-colors ${
|
||||
error ? "border-red-400/70" : "border-line"
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => e.key === "Enter" && save()}
|
||||
spellCheck={false}
|
||||
className={`h-11 w-full rounded-xl border bg-surface-2 px-3.5 font-mono text-sm text-ink outline-none transition-colors ${
|
||||
error ? "border-red-400" : "border-line focus:border-[var(--ink)]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base opacity-50">🔗</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => e.key === "Enter" && save()}
|
||||
spellCheck={false}
|
||||
className="h-12 w-full bg-transparent font-mono text-sm text-ink outline-none"
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Where <span className="font-mono text-ink">{shortUrl(link.code)}</span>{" "}
|
||||
sends visitors — currently {prettyHost(link.longUrl)}.
|
||||
Currently points to {prettyHost(link.longUrl)}.
|
||||
</p>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -98,7 +90,7 @@ export function EditLinkModal({ link, onClose, onSaved }: Props) {
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="mt-3 text-sm font-medium text-red-500"
|
||||
className="mt-3 text-[13px] font-medium text-red-500"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
|
||||
+6
-17
@@ -1,29 +1,18 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { Scissors } from "./icons";
|
||||
|
||||
export function Logo({ onClick }: { onClick?: () => void }) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<Link
|
||||
to="/"
|
||||
onClick={onClick}
|
||||
className="focusable group inline-flex items-center gap-2.5 rounded-xl"
|
||||
className="focusable inline-flex items-center gap-2 rounded-lg"
|
||||
>
|
||||
<motion.span
|
||||
whileHover={{ scale: 1.08 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 12 }}
|
||||
className="flex items-center"
|
||||
>
|
||||
<img
|
||||
src={theme === "dark" ? "/favicon.svg" : "/favicon_dark.svg"}
|
||||
alt=""
|
||||
className="h-7 w-auto"
|
||||
/>
|
||||
</motion.span>
|
||||
<span className="font-display text-[22px] font-bold tracking-tight text-ink">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-[var(--ink)] text-[var(--bg)]">
|
||||
<Scissors size={15} />
|
||||
</span>
|
||||
<span className="text-[17px] font-semibold tracking-tight text-ink">
|
||||
snip
|
||||
<span className="text-accent">.</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
+88
-22
@@ -1,55 +1,121 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Logo } from "./Logo";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Grid, ChevronDown, LogOut } from "./icons";
|
||||
|
||||
// Shared pill language so every nav control reads as one family.
|
||||
const navPill =
|
||||
"focusable inline-flex h-9 items-center gap-1.5 rounded-lg border border-line bg-surface px-2.5 text-[13px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-ink";
|
||||
|
||||
export function Navbar() {
|
||||
const { user, logout } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Dismiss the account menu on outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
}
|
||||
window.addEventListener("mousedown", onDown);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", onDown);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
// Close the menu whenever the route changes.
|
||||
useEffect(() => setMenuOpen(false), [pathname]);
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: -64, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 28, delay: 0.05 }}
|
||||
className="sticky top-0 z-50"
|
||||
>
|
||||
<div className="mx-auto mt-3 flex max-w-5xl items-center justify-between gap-3 rounded-2xl border-[1.5px] border-line bg-[var(--surface)]/80 px-3.5 py-2.5 backdrop-blur-xl sm:px-4">
|
||||
<header className="sticky top-0 z-50 -mx-4 border-b border-line bg-[var(--bg)]/80 px-4 backdrop-blur-md sm:-mx-5 sm:px-5">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-3">
|
||||
<Logo />
|
||||
|
||||
<nav className="flex items-center gap-1.5 sm:gap-2">
|
||||
<nav className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
{pathname !== "/dashboard" && (
|
||||
<Link to="/dashboard" className="hidden sm:block">
|
||||
<Button variant="ghost" size="sm">
|
||||
Dashboard
|
||||
</Button>
|
||||
<Link to="/dashboard" className={navPill} aria-label="Dashboard">
|
||||
<Grid size={16} />
|
||||
<span className="hidden sm:inline">Dashboard</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex items-center gap-2 rounded-xl border-[1.5px] border-line bg-surface px-1 py-1 pr-2.5">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-accent text-xs font-bold text-accent-ink">
|
||||
{user.name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="focusable rounded-md text-xs font-medium text-muted hover:text-ink"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-label="Account menu"
|
||||
className="focusable inline-flex h-9 items-center gap-1 rounded-lg border border-line bg-surface py-1 pl-1 pr-1.5 transition-colors hover:bg-surface-2"
|
||||
>
|
||||
Sign out
|
||||
<span className="grid h-7 w-7 place-items-center rounded-md bg-[var(--ink)] text-[11px] font-semibold text-[var(--bg)]">
|
||||
{user.name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`text-muted transition-transform ${menuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
role="menu"
|
||||
initial={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
className="card absolute right-0 top-[calc(100%+8px)] w-56 overflow-hidden p-1 shadow-[0_16px_40px_-16px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
<p className="truncate text-sm font-medium text-ink">
|
||||
{user.name}
|
||||
</p>
|
||||
{user.email && (
|
||||
<p className="truncate text-xs text-muted">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="my-1 border-t border-line" />
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
logout();
|
||||
}}
|
||||
className="focusable flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium text-ink transition-colors hover:bg-surface-2"
|
||||
>
|
||||
<LogOut size={16} className="text-muted" />
|
||||
Sign out
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" variant="dark" onClick={() => navigate("/login")}>
|
||||
<Button size="sm" onClick={() => navigate("/login")}>
|
||||
Sign in
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</motion.header>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight } from "./icons";
|
||||
|
||||
interface Props {
|
||||
page: number; // 1-based
|
||||
@@ -28,10 +28,10 @@ export function Pagination({ page, pageCount, onPage }: Props) {
|
||||
<button
|
||||
onClick={() => onPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-ink transition-opacity hover:bg-surface-2 disabled:opacity-30"
|
||||
className="focusable grid h-8 w-8 place-items-center rounded-lg border border-line bg-surface text-muted transition-colors hover:bg-surface-2 hover:text-ink disabled:opacity-30"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
‹
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
|
||||
{items.map((it, i) =>
|
||||
@@ -44,22 +44,13 @@ export function Pagination({ page, pageCount, onPage }: Props) {
|
||||
key={it}
|
||||
onClick={() => onPage(it)}
|
||||
aria-current={it === page}
|
||||
className="focusable relative grid h-9 min-w-9 place-items-center rounded-xl px-2 text-sm font-semibold"
|
||||
className={`focusable grid h-8 min-w-8 place-items-center rounded-lg px-2 text-[13px] font-medium transition-colors ${
|
||||
it === page
|
||||
? "bg-[var(--ink)] text-[var(--bg)]"
|
||||
: "text-muted hover:bg-surface-2 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{it === page && (
|
||||
<motion.span
|
||||
layoutId="page-pill"
|
||||
transition={{ type: "spring", stiffness: 480, damping: 32 }}
|
||||
className="absolute inset-0 rounded-xl bg-accent"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`relative z-10 ${
|
||||
it === page ? "text-accent-ink" : "text-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{it}
|
||||
</span>
|
||||
{it}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
@@ -67,10 +58,10 @@ export function Pagination({ page, pageCount, onPage }: Props) {
|
||||
<button
|
||||
onClick={() => onPage(page + 1)}
|
||||
disabled={page === pageCount}
|
||||
className="focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-ink transition-opacity hover:bg-surface-2 disabled:opacity-30"
|
||||
className="focusable grid h-8 w-8 place-items-center rounded-lg border border-line bg-surface text-muted transition-colors hover:bg-surface-2 hover:text-ink disabled:opacity-30"
|
||||
aria-label="Next page"
|
||||
>
|
||||
›
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
@@ -45,7 +44,7 @@ export function PinInput({ value, onChange, length = 6 }: Props) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length }).map((_, i) => (
|
||||
<motion.input
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => (refs.current[i] = el)}
|
||||
value={digits[i] ?? ""}
|
||||
@@ -54,9 +53,7 @@ export function PinInput({ value, onChange, length = 6 }: Props) {
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
aria-label={`PIN digit ${i + 1}`}
|
||||
whileFocus={{ scale: 1.08, y: -2 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 16 }}
|
||||
className="focusable h-12 w-full rounded-xl border-[1.5px] border-line bg-surface text-center font-mono text-lg font-bold text-ink outline-none"
|
||||
className="focusable h-12 w-full rounded-lg border border-line bg-surface-2 text-center font-mono text-lg font-semibold text-ink outline-none focus:border-[var(--ink)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { shortUrl } from "../lib/format";
|
||||
import { Modal } from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { PinInput } from "./PinInput";
|
||||
import { Lock } from "./icons";
|
||||
|
||||
interface Props {
|
||||
link: ShortLink | null;
|
||||
@@ -68,15 +69,15 @@ export function PinManager({ link, onClose, onSaved }: Props) {
|
||||
: "add a 6-digit PIN that visitors enter before redirect."}
|
||||
</p>
|
||||
|
||||
{/* Current state. The server stores PINs hashed, so an existing value can
|
||||
never be shown — only replaced or removed. */}
|
||||
{/* The server stores PINs hashed, so an existing value can never be shown
|
||||
— only replaced or removed. */}
|
||||
{hasPin && (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3">
|
||||
<span className="grid h-9 w-9 place-items-center rounded-xl bg-accent text-base text-accent-ink">
|
||||
🔒
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-[var(--ink)] text-[var(--bg)]">
|
||||
<Lock size={15} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-ink">PIN is active</p>
|
||||
<p className="text-[13px] font-medium text-ink">PIN is active</p>
|
||||
<p className="text-[12px] text-muted">
|
||||
Stored encrypted — set a new one below to change it.
|
||||
</p>
|
||||
@@ -84,7 +85,7 @@ export function PinManager({ link, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="mb-2 block text-sm font-semibold text-ink">
|
||||
<label className="mb-2 block text-[13px] font-medium text-muted">
|
||||
{hasPin ? "Set a new PIN" : "Choose a PIN"}
|
||||
</label>
|
||||
<PinInput value={draft} onChange={setDraft} />
|
||||
@@ -95,7 +96,7 @@ export function PinManager({ link, onClose, onSaved }: Props) {
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="mt-3 text-sm font-medium text-red-500"
|
||||
className="mt-3 text-[13px] font-medium text-red-500"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
@@ -114,7 +115,7 @@ export function PinManager({ link, onClose, onSaved }: Props) {
|
||||
variant="ghost"
|
||||
onClick={remove}
|
||||
disabled={busy !== null}
|
||||
className="ml-auto text-muted hover:!text-red-500"
|
||||
className="ml-auto hover:!text-red-500"
|
||||
>
|
||||
{busy === "remove" ? "Removing…" : "Remove PIN"}
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ShortLink } from "../lib/types";
|
||||
import { fullShortUrl, prettyHost, shortUrl } from "../lib/format";
|
||||
import { Modal } from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Copy, Check, Download } from "./icons";
|
||||
|
||||
interface Props {
|
||||
link: ShortLink | null;
|
||||
@@ -49,30 +50,30 @@ export function QrModal({ link, onClose, onCopied }: Props) {
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="rounded-3xl border-[1.5px] border-line bg-white p-5 shadow-[0_16px_40px_-20px_rgba(0,0,0,0.5)]"
|
||||
className="rounded-xl border border-line bg-white p-4"
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={fullShortUrl(link.code)}
|
||||
size={188}
|
||||
size={184}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#16170f"
|
||||
fgColor="#18181b"
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 font-mono text-lg font-bold text-ink">
|
||||
<p className="mt-4 font-mono text-base font-semibold text-ink">
|
||||
{shortUrl(link.code)}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted">
|
||||
Points to {prettyHost(link.longUrl)}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted">Points to {prettyHost(link.longUrl)}</p>
|
||||
|
||||
<div className="mt-5 flex w-full items-center gap-2">
|
||||
<Button block onClick={copy}>
|
||||
{copied ? "Copied ✓" : "Copy link"}
|
||||
{copied ? <Check size={15} /> : <Copy size={15} />}
|
||||
{copied ? "Copied" : "Copy link"}
|
||||
</Button>
|
||||
<Button block variant="outline" onClick={download}>
|
||||
Download SVG
|
||||
<Download size={15} />
|
||||
SVG
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useState } from "react";
|
||||
import type { ShortLink } from "../lib/types";
|
||||
import { fullShortUrl, prettyHost, shortUrl } from "../lib/format";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Check, Copy, Qr, Lock, Plus } from "./icons";
|
||||
|
||||
interface Props {
|
||||
link: ShortLink;
|
||||
@@ -19,7 +19,6 @@ const modeBadge: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function ResultCard({ link, onReset, onCopied }: Props) {
|
||||
const { theme } = useTheme();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
|
||||
@@ -36,89 +35,71 @@ export function ResultCard({ link, onReset, onCopied }: Props) {
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -16, scale: 0.96 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 24 }}
|
||||
className="card relative overflow-hidden p-5 sm:p-7"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
className="card p-5 sm:p-6"
|
||||
>
|
||||
{/* confetti-ish accent corner */}
|
||||
<div className="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full bg-accent opacity-20 blur-2xl" />
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<motion.span
|
||||
initial={{ scale: 0, rotate: -40 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 12, delay: 0.1 }}
|
||||
className="grid h-7 w-7 place-items-center rounded-full bg-accent text-sm font-bold text-accent-ink"
|
||||
>
|
||||
✓
|
||||
</motion.span>
|
||||
<span className="text-sm font-semibold text-ink">Your link is live</span>
|
||||
<span className="ml-auto rounded-full border-[1.5px] border-line px-2.5 py-0.5 text-[11px] font-medium text-muted">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-6 w-6 place-items-center rounded-full bg-[var(--ink)] text-[var(--bg)]">
|
||||
<Check size={14} />
|
||||
</span>
|
||||
<span className="text-sm font-medium text-ink">Your link is live</span>
|
||||
<span className="ml-auto rounded-full border border-line px-2.5 py-0.5 text-[11px] text-muted">
|
||||
{modeBadge[link.mode]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-stretch">
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between gap-3 rounded-2xl border-[1.5px] border-line bg-surface-2 p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[13px] text-muted">
|
||||
{prettyHost(link.longUrl)}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-xl font-bold text-ink sm:text-2xl">
|
||||
{shortUrl(link.code)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={copy} size="sm" className="min-w-[104px]">
|
||||
<motion.span
|
||||
key={copied ? "y" : "n"}
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 16 }}
|
||||
>
|
||||
{copied ? "Copied ✓" : "Copy link"}
|
||||
</motion.span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowQr((v) => !v)}
|
||||
>
|
||||
{showQr ? "Hide QR" : "QR code"}
|
||||
</Button>
|
||||
{link.hasPin && (
|
||||
<span className="inline-flex items-center gap-1 rounded-lg bg-[var(--surface)] px-2 py-1 text-[11px] font-medium text-muted">
|
||||
🔒 PIN protected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl border border-line bg-surface-2 p-4">
|
||||
<p className="truncate text-[13px] text-muted">{prettyHost(link.longUrl)}</p>
|
||||
<p className="mt-1 break-all font-mono text-xl font-semibold text-ink">
|
||||
{shortUrl(link.code)}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button onClick={copy} size="sm">
|
||||
{copied ? <Check size={15} /> : <Copy size={15} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowQr((v) => !v)}>
|
||||
<Qr size={15} />
|
||||
{showQr ? "Hide QR" : "QR code"}
|
||||
</Button>
|
||||
{link.hasPin && (
|
||||
<span className="inline-flex items-center gap-1 text-[12px] text-muted">
|
||||
<Lock size={13} /> PIN protected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showQr && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.7, rotate: -6 }}
|
||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 18 }}
|
||||
className="grid place-items-center rounded-2xl border-[1.5px] border-line bg-white p-3"
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={fullShortUrl(link.code)}
|
||||
size={120}
|
||||
bgColor="#ffffff"
|
||||
fgColor={theme === "dark" ? "#16170f" : "#16170f"}
|
||||
level="M"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{showQr && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4 grid w-fit place-items-center rounded-xl border border-line bg-white p-3">
|
||||
<QRCodeSVG
|
||||
value={fullShortUrl(link.code)}
|
||||
size={132}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#18181b"
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="focusable mt-4 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-muted hover:text-ink"
|
||||
className="focusable mt-4 inline-flex items-center gap-1.5 rounded-lg text-[13px] font-medium text-muted hover:text-ink"
|
||||
>
|
||||
<span className="text-base">+</span> Shorten another link
|
||||
<Plus size={15} /> Shorten another
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Lock } from "./icons";
|
||||
|
||||
export interface Segment<T extends string> {
|
||||
value: T;
|
||||
@@ -12,7 +13,6 @@ interface Props<T extends string> {
|
||||
segments: Segment<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
layoutId?: string;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
@@ -21,7 +21,7 @@ export function SegmentedControl<T extends string>({
|
||||
onChange,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 rounded-2xl border-[1.5px] border-line bg-surface-2 p-1.5 sm:flex-row">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{segments.map((seg) => {
|
||||
const active = seg.value === value;
|
||||
return (
|
||||
@@ -29,38 +29,29 @@ export function SegmentedControl<T extends string>({
|
||||
key={seg.value}
|
||||
type="button"
|
||||
onClick={() => onChange(seg.value)}
|
||||
className={`focusable relative flex-1 rounded-xl px-3 py-2.5 text-left transition-all duration-200 ${
|
||||
aria-pressed={active}
|
||||
className={`focusable flex flex-col items-start gap-1 rounded-xl border p-3 text-left transition-colors ${
|
||||
active
|
||||
? "bg-[var(--surface)] shadow-[0_4px_14px_-6px_rgba(0,0,0,0.3)] ring-[1.5px] ring-[var(--ring)]"
|
||||
: ""
|
||||
? "border-[var(--ink)] bg-surface-2"
|
||||
: "border-line hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<span
|
||||
className={`text-base leading-none transition-transform ${
|
||||
active ? "scale-110" : "opacity-60"
|
||||
}`}
|
||||
>
|
||||
{seg.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={`flex items-center gap-1.5 text-sm font-semibold ${
|
||||
active ? "text-ink" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{seg.label}
|
||||
{seg.locked && (
|
||||
<span className="text-[10px] opacity-70">🔒</span>
|
||||
)}
|
||||
</span>
|
||||
{seg.hint && (
|
||||
<span className="block truncate text-[11px] text-muted">
|
||||
{seg.hint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={active ? "text-ink" : "text-muted"}>
|
||||
{seg.icon}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className={`text-[13px] font-semibold ${active ? "text-ink" : "text-muted"}`}
|
||||
>
|
||||
{seg.label}
|
||||
</span>
|
||||
{seg.locked && <Lock size={11} className="text-muted" />}
|
||||
</span>
|
||||
{seg.hint && (
|
||||
<span className="text-[11px] leading-tight text-muted">
|
||||
{seg.hint}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
+63
-100
@@ -15,8 +15,9 @@ import { useToast } from "./ui/Toast";
|
||||
import { SegmentedControl, type Segment } from "./SegmentedControl";
|
||||
import { PinInput } from "./PinInput";
|
||||
import { ResultCard } from "./ResultCard";
|
||||
import { Shuffle, Sparkle, Type, Lock } from "./icons";
|
||||
|
||||
const spring = { type: "spring", stiffness: 300, damping: 26 } as const;
|
||||
const ease = { duration: 0.22, ease: "easeOut" } as const;
|
||||
|
||||
export function ShortenForm() {
|
||||
const { user } = useAuth();
|
||||
@@ -43,18 +44,13 @@ export function ShortenForm() {
|
||||
}
|
||||
|
||||
const segments: Segment<UrlMode>[] = [
|
||||
{ value: "random", label: "Random", icon: "🎲", hint: "Shortest code" },
|
||||
{
|
||||
value: "memorable",
|
||||
label: "Memorable",
|
||||
icon: "🌿",
|
||||
hint: "Three easy words",
|
||||
},
|
||||
{ value: "random", label: "Random", icon: <Shuffle size={17} />, hint: "Shortest code" },
|
||||
{ value: "memorable", label: "Memorable", icon: <Sparkle size={17} />, hint: "Three words" },
|
||||
{
|
||||
value: "custom",
|
||||
label: "Custom",
|
||||
icon: "✏️",
|
||||
hint: user ? "You choose it" : "Sign in to use",
|
||||
icon: <Type size={17} />,
|
||||
hint: user ? "You choose" : "Sign in",
|
||||
locked: !user,
|
||||
},
|
||||
];
|
||||
@@ -78,7 +74,7 @@ export function ShortenForm() {
|
||||
return;
|
||||
}
|
||||
if (!isValidUrl(url)) {
|
||||
showError("Hmm, that doesn't look like a valid URL.", "url");
|
||||
showError("That doesn't look like a valid URL.", "url");
|
||||
return;
|
||||
}
|
||||
if (mode === "custom") {
|
||||
@@ -95,18 +91,15 @@ export function ShortenForm() {
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
console.log("[snip] createLink start", { mode });
|
||||
const link = await api.createLink({
|
||||
longUrl: normalizeUrl(url),
|
||||
mode,
|
||||
customAlias: mode === "custom" ? alias : undefined,
|
||||
pin: pinOn && user ? pin : undefined,
|
||||
});
|
||||
console.log("[snip] createLink ok", link.code);
|
||||
setResult(link);
|
||||
toast("Link created — ready to share", "success");
|
||||
} catch (err) {
|
||||
console.log("[snip] createLink error", err);
|
||||
const msg = err instanceof Error ? err.message : "Something went wrong.";
|
||||
showError(msg, mode === "custom" ? "alias" : null);
|
||||
} finally {
|
||||
@@ -124,6 +117,9 @@ export function ShortenForm() {
|
||||
clearError();
|
||||
}
|
||||
|
||||
const preview =
|
||||
mode === "random" ? "x7Qk" : mode === "memorable" ? "amber-otter-loop" : alias || "your-name";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -138,58 +134,39 @@ export function ShortenForm() {
|
||||
<motion.form
|
||||
key="form"
|
||||
onSubmit={submit}
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16, scale: 0.98 }}
|
||||
transition={spring}
|
||||
className="card p-4 sm:p-6"
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={ease}
|
||||
className="card p-4 sm:p-5"
|
||||
>
|
||||
{/* URL field */}
|
||||
<label className="mb-2 block text-sm font-semibold text-ink">
|
||||
<label className="mb-1.5 block text-[13px] font-medium text-muted">
|
||||
Long URL
|
||||
</label>
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-2xl border-[1.5px] bg-surface-2 px-3.5 transition-colors ${
|
||||
errorInput === "url" ? "border-red-400/70" : "border-line"
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
if (errorInput === "url") clearError();
|
||||
}}
|
||||
placeholder="https://example.com/a/very/long/path"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`h-12 w-full rounded-xl border bg-surface-2 px-3.5 font-mono text-sm text-ink outline-none transition-colors placeholder:font-sans placeholder:text-muted ${
|
||||
errorInput === "url" ? "border-red-400" : "border-line focus:border-[var(--ink)]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg opacity-50">🔗</span>
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
if (errorInput === "url") clearError();
|
||||
}}
|
||||
placeholder="paste a long link, e.g. acme.com/spring/launch…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="h-14 w-full bg-transparent font-mono text-[15px] text-ink outline-none placeholder:font-sans placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
|
||||
{/* Mode selector */}
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
Link style
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{getShortDomain()}/
|
||||
<span className="text-accent">
|
||||
{mode === "random"
|
||||
? "x7Qk"
|
||||
: mode === "memorable"
|
||||
? "amber-otter-loop"
|
||||
: alias || "your-name"}
|
||||
</span>
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[13px] font-medium text-muted">Link style</span>
|
||||
<span className="font-mono text-[12px] text-muted">
|
||||
{getShortDomain()}/<span className="text-ink">{preview}</span>
|
||||
</span>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
segments={segments}
|
||||
value={mode}
|
||||
onChange={onMode}
|
||||
layoutId="mode-pill"
|
||||
/>
|
||||
<SegmentedControl segments={segments} value={mode} onChange={onMode} />
|
||||
</div>
|
||||
|
||||
{/* Custom alias */}
|
||||
@@ -199,31 +176,33 @@ export function ShortenForm() {
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={spring}
|
||||
transition={ease}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className={`mt-4 flex items-center gap-0 overflow-hidden rounded-2xl border-[1.5px] bg-surface-2 transition-colors ${errorInput === "alias" ? "border-red-400/70" : "border-line"}`}>
|
||||
<span className="select-none px-3.5 font-mono text-sm text-muted">
|
||||
<div
|
||||
className={`mt-3 flex items-center overflow-hidden rounded-xl border bg-surface-2 transition-colors ${
|
||||
errorInput === "alias" ? "border-red-400" : "border-line focus-within:border-[var(--ink)]"
|
||||
}`}
|
||||
>
|
||||
<span className="select-none pl-3.5 font-mono text-sm text-muted">
|
||||
{getShortDomain()}/
|
||||
</span>
|
||||
<input
|
||||
value={alias}
|
||||
onChange={(e) => {
|
||||
setAlias(
|
||||
e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""),
|
||||
);
|
||||
setAlias(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""));
|
||||
if (errorInput === "alias") clearError();
|
||||
}}
|
||||
placeholder="spring-launch"
|
||||
className="h-12 w-full bg-transparent pr-3.5 font-mono text-[15px] text-ink outline-none placeholder:text-muted"
|
||||
className="h-11 w-full bg-transparent pr-3.5 font-mono text-sm text-ink outline-none placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Authenticated extras: PIN */}
|
||||
<div className="mt-5 rounded-2xl border-[1.5px] border-dashed border-line bg-surface-2/50 p-3.5">
|
||||
{/* PIN protection */}
|
||||
<div className="mt-4 rounded-xl border border-line bg-surface-2 p-3.5">
|
||||
{user ? (
|
||||
<>
|
||||
<button
|
||||
@@ -232,28 +211,24 @@ export function ShortenForm() {
|
||||
className="focusable flex w-full items-center gap-3 rounded-lg text-left"
|
||||
>
|
||||
<span
|
||||
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||||
pinOn ? "bg-accent" : "bg-[var(--ring)]"
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${
|
||||
pinOn ? "bg-[var(--ink)]" : "bg-[var(--ring)]"
|
||||
}`}
|
||||
>
|
||||
<motion.span
|
||||
layout
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 600,
|
||||
damping: 30,
|
||||
}}
|
||||
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow ${
|
||||
transition={{ type: "spring", stiffness: 600, damping: 32 }}
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-[var(--surface)] ${
|
||||
pinOn ? "right-0.5" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-ink">
|
||||
<span className="flex-1">
|
||||
<span className="block text-[13px] font-medium text-ink">
|
||||
Protect with a 6-digit PIN
|
||||
</span>
|
||||
<span className="block text-xs text-muted">
|
||||
Visitors enter it before they're redirected
|
||||
Visitors enter it before redirect
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -263,7 +238,7 @@ export function ShortenForm() {
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={spring}
|
||||
transition={ease}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-3.5">
|
||||
@@ -274,17 +249,17 @@ export function ShortenForm() {
|
||||
</AnimatePresence>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg opacity-60">🔒</span>
|
||||
<p className="text-sm text-muted">
|
||||
<div className="flex items-center gap-2.5 text-muted">
|
||||
<Lock size={16} />
|
||||
<p className="text-[13px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="focusable rounded font-semibold text-ink underline decoration-accent decoration-2 underline-offset-2"
|
||||
className="focusable rounded font-medium text-ink underline underline-offset-2"
|
||||
>
|
||||
Sign in
|
||||
</button>{" "}
|
||||
to add a custom alias and PIN protection.
|
||||
for custom aliases and PIN protection.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -294,14 +269,10 @@ export function ShortenForm() {
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: [0, -6, 6, -4, 4, 0],
|
||||
}}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="mt-3 text-sm font-medium text-red-500"
|
||||
className="mt-3 text-[13px] font-medium text-red-500"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
@@ -309,19 +280,11 @@ export function ShortenForm() {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
block
|
||||
disabled={busy}
|
||||
className="mt-5"
|
||||
>
|
||||
<Button type="submit" size="lg" block disabled={busy} className="mt-4">
|
||||
{busy ? (
|
||||
<span className="inline-block h-5 w-5 animate-spin rounded-full border-[2.5px] border-current border-t-transparent" />
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<>
|
||||
Shorten it <span className="text-lg">✦</span>
|
||||
</>
|
||||
"Shorten link"
|
||||
)}
|
||||
</Button>
|
||||
</motion.form>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function Sparkline({ data, width = 84, height = 30 }: Props) {
|
||||
export function Sparkline({ data, width = 80, height = 28 }: Props) {
|
||||
const max = Math.max(1, ...data.map((d) => d.count));
|
||||
const n = data.length;
|
||||
const stepX = n > 1 ? width / (n - 1) : width;
|
||||
@@ -21,34 +21,22 @@ export function Sparkline({ data, width = 84, height = 30 }: Props) {
|
||||
});
|
||||
|
||||
const line = pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
|
||||
const area = `0,${height} ${line} ${width},${height}`;
|
||||
const last = pts[pts.length - 1];
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polygon points={area} fill="var(--accent)" opacity={0.16} />
|
||||
<svg width={width} height={height} className="overflow-visible text-muted">
|
||||
<motion.polyline
|
||||
points={line}
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 120, damping: 20 }}
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
{last && (
|
||||
<motion.circle
|
||||
cx={last[0]}
|
||||
cy={last[1]}
|
||||
r={2.6}
|
||||
fill="var(--accent)"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.3, type: "spring", stiffness: 500, damping: 14 }}
|
||||
/>
|
||||
)}
|
||||
{last && <circle cx={last[0]} cy={last[1]} r={2.4} className="fill-[var(--ink)]" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { Sun, Moon } from "./icons";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggle } = useTheme();
|
||||
const dark = theme === "dark";
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
<button
|
||||
onClick={toggle}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className="focusable relative grid h-10 w-10 place-items-center overflow-hidden rounded-xl border-[1.5px] border-line bg-surface"
|
||||
className="focusable relative grid h-9 w-9 place-items-center overflow-hidden rounded-lg border border-line bg-surface text-muted hover:text-ink"
|
||||
>
|
||||
<motion.span
|
||||
key={theme}
|
||||
initial={{ y: 18, rotate: -90, opacity: 0, scale: 0.5 }}
|
||||
animate={{ y: 0, rotate: 0, opacity: 1, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 14 }}
|
||||
className="text-[17px]"
|
||||
>
|
||||
{dark ? "🌙" : "☀️"}
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={theme}
|
||||
initial={{ y: 12, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -12, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
{dark ? <Moon size={17} /> : <Sun size={17} />}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
+24
-43
@@ -9,6 +9,7 @@ import {
|
||||
} from "../lib/format";
|
||||
import { BarChart } from "./BarChart";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Lock } from "./icons";
|
||||
|
||||
interface Props {
|
||||
link: ShortLink;
|
||||
@@ -19,12 +20,6 @@ interface Props {
|
||||
onShowQr: (link: ShortLink) => void;
|
||||
}
|
||||
|
||||
const modeLabel: Record<string, string> = {
|
||||
random: "random",
|
||||
memorable: "memorable",
|
||||
custom: "custom",
|
||||
};
|
||||
|
||||
export function UrlCard({
|
||||
link,
|
||||
onEdit,
|
||||
@@ -47,45 +42,39 @@ export function UrlCard({
|
||||
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 }}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.15 } }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
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]}
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted">
|
||||
<span className="rounded-full border border-line px-2 py-0.5 font-medium uppercase tracking-wide">
|
||||
{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 className="inline-flex items-center gap-1">
|
||||
<Lock size={12} /> PIN
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[11px] text-muted">
|
||||
{relativeTime(link.createdAt)}
|
||||
</span>
|
||||
<span className="ml-auto">{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"
|
||||
className="focusable mt-2 inline-block truncate rounded font-mono text-lg font-semibold 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 className="mt-0.5 truncate text-[13px] text-muted" title={link.longUrl}>
|
||||
{prettyHost(link.longUrl)}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-2 pt-4">
|
||||
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-4">
|
||||
<Button size="sm" variant="outline" onClick={copy}>
|
||||
Copy
|
||||
</Button>
|
||||
@@ -95,18 +84,14 @@ export function UrlCard({
|
||||
<Button size="sm" variant="ghost" onClick={() => onEdit(link)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onManagePin(link)}
|
||||
>
|
||||
{link.hasPin ? "🔒 PIN" : "Add PIN"}
|
||||
<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"
|
||||
className="ml-auto hover:!text-red-500"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
@@ -114,24 +99,20 @@ export function UrlCard({
|
||||
</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="flex w-full flex-col rounded-xl border border-line bg-surface-2 p-4 sm:w-64">
|
||||
<div className="mb-3 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted">
|
||||
Total clicks
|
||||
</p>
|
||||
<p className="font-display text-2xl font-bold text-ink">
|
||||
<p className="text-2xl font-semibold 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 className="text-[11px] uppercase tracking-wide text-muted">7 days</p>
|
||||
<p className="text-base font-semibold text-accent">
|
||||
+{compactNumber(weekTotal)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+29
-58
@@ -7,6 +7,7 @@ import {
|
||||
shortUrl,
|
||||
} from "../lib/format";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
import { Copy, Qr, Lock, Edit, Trash } from "./icons";
|
||||
|
||||
interface Props {
|
||||
link: ShortLink;
|
||||
@@ -17,26 +18,6 @@ interface Props {
|
||||
onShowQr: (link: ShortLink) => void;
|
||||
}
|
||||
|
||||
function QrGlyph() {
|
||||
return (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M1 1h5v5H1V1zm1 1v3h3V2H2z" />
|
||||
<path d="M9 1h5v5H9V1zm1 1v3h3V2h-3z" />
|
||||
<path d="M1 9h5v5H1V9zm1 1v3h3v-3H2z" />
|
||||
<rect x="9" y="9" width="2" height="2" />
|
||||
<rect x="12" y="9" width="2" height="2" />
|
||||
<rect x="9" y="12" width="2" height="2" />
|
||||
<rect x="12" y="12" width="2" height="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
@@ -49,19 +30,16 @@ function IconButton({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.88 }}
|
||||
whileHover={{ y: -2 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 16 }}
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={`focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-sm ${
|
||||
danger ? "hover:!border-red-400 hover:text-red-500" : "hover:bg-surface-2"
|
||||
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}
|
||||
</motion.button>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,45 +65,38 @@ export function UrlRow({
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15 } }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 28 }}
|
||||
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"
|
||||
>
|
||||
{/* identity */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="hidden h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent text-xs font-bold text-accent-ink sm:grid">
|
||||
{link.mode === "custom" ? "✏️" : link.mode === "memorable" ? "🌿" : "🎲"}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={fullShortUrl(link.code)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className="focusable truncate rounded font-mono text-[15px] font-bold text-ink hover:text-accent"
|
||||
>
|
||||
{shortUrl(link.code)}
|
||||
</a>
|
||||
{link.hasPin && <span className="text-[11px]">🔒</span>}
|
||||
</div>
|
||||
<p className="truncate text-[12px] text-muted" title={link.longUrl}>
|
||||
{prettyHost(link.longUrl)}
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
{shortUrl(link.code)}
|
||||
</a>
|
||||
{link.hasPin && <Lock size={12} className="shrink-0 text-muted" />}
|
||||
</div>
|
||||
<p className="truncate text-[12px] text-muted" title={link.longUrl}>
|
||||
{prettyHost(link.longUrl)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* stats */}
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="font-display text-base font-bold leading-none text-ink">
|
||||
<p className="text-sm font-semibold leading-none text-ink">
|
||||
{compactNumber(link.totalClicks)}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted">
|
||||
clicks
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted">clicks</p>
|
||||
</div>
|
||||
<span className="hidden items-center rounded-md bg-accent/15 px-1 text-[11px] font-bold text-accent-ink sm:inline-flex">
|
||||
<span className="hidden text-[12px] font-medium text-accent sm:inline">
|
||||
+{compactNumber(weekTotal)}
|
||||
</span>
|
||||
<div className="hidden sm:block">
|
||||
@@ -136,19 +107,19 @@ export function UrlRow({
|
||||
{/* 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)}>
|
||||
<QrGlyph />
|
||||
<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>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Minimal stroke icons (lucide-style), themed via currentColor.
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement> & { size?: number };
|
||||
|
||||
function Svg({ size = 18, children, ...rest }: IconProps & { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const Link = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Copy = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" />
|
||||
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Check = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="m20 6-11 11-5-5" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Qr = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<path d="M14 14h3v3M21 14v.01M14 21h.01M17 21h4v-4" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Edit = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Trash = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Lock = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<rect x="4" y="11" width="16" height="10" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Plus = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Search = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const X = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Sun = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Moon = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const ChevronLeft = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const ChevronRight = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const ChevronDown = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const LogOut = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5M21 12H9" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const ArrowRight = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M5 12h14M13 5l7 7-7 7" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Shuffle = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M18 4h3v3M3 20l18-16M21 16v3h-3M14 14l5 5M4 4l6 6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Sparkle = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 3v18M3 12h18M6.5 6.5l11 11M17.5 6.5l-11 11" opacity={0.55} />
|
||||
<path d="M12 7v10M7 12h10" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Type = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 7V5h16v2M9 19h6M12 5v14" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Download = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Grid = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<rect x="3" y="3" width="7" height="9" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1" />
|
||||
<rect x="3" y="16" width="7" height="5" rx="1" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const Scissors = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12" />
|
||||
</Svg>
|
||||
);
|
||||
@@ -12,18 +12,19 @@ interface Props extends Omit<HTMLMotionProps<"button">, "children"> {
|
||||
}
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: "h-9 px-3.5 text-sm gap-1.5",
|
||||
md: "h-11 px-5 text-[15px] gap-2",
|
||||
lg: "h-14 px-7 text-base gap-2.5",
|
||||
sm: "h-9 px-3.5 text-[13px] gap-1.5",
|
||||
md: "h-10 px-4 text-sm gap-2",
|
||||
lg: "h-12 px-6 text-[15px] gap-2",
|
||||
};
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
// solid ink — the one strong action, inverts with the theme
|
||||
primary:
|
||||
"bg-accent text-accent-ink font-semibold shadow-[0_8px_24px_-8px_var(--glow)] hover:brightness-105",
|
||||
dark: "bg-[var(--ink)] text-[var(--bg)] font-semibold hover:opacity-90",
|
||||
"bg-[var(--ink)] text-[var(--bg)] font-medium hover:opacity-90",
|
||||
dark: "bg-[var(--ink)] text-[var(--bg)] font-medium hover:opacity-90",
|
||||
outline:
|
||||
"bg-transparent text-ink font-medium border-[1.5px] border-line hover:bg-surface-2",
|
||||
ghost: "bg-transparent text-ink font-medium hover:bg-surface-2",
|
||||
"bg-transparent text-ink font-medium border border-line hover:bg-surface-2",
|
||||
ghost: "bg-transparent text-muted font-medium hover:bg-surface-2 hover:text-ink",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
||||
@@ -33,10 +34,9 @@ export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
whileTap={{ scale: 0.94 }}
|
||||
whileHover={{ y: -2 }}
|
||||
transition={{ type: "spring", stiffness: 520, damping: 16 }}
|
||||
className={`focusable inline-flex select-none items-center justify-center rounded-2xl ${
|
||||
whileTap={{ scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className={`focusable inline-flex select-none items-center justify-center rounded-xl transition-colors ${
|
||||
sizes[size]
|
||||
} ${variants[variant]} ${block ? "w-full" : ""} ${className}`}
|
||||
{...rest}
|
||||
|
||||
@@ -30,20 +30,20 @@ export function Modal({ open, onClose, title, children }: Props) {
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/45 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
initial={{ opacity: 0, y: 28, scale: 0.92 }}
|
||||
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 16, scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 26 }}
|
||||
className="card relative z-10 w-full max-w-md p-6 shadow-[0_30px_80px_-30px_rgba(0,0,0,0.7)]"
|
||||
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 30 }}
|
||||
className="card relative z-10 w-full max-w-md p-6 shadow-[0_24px_60px_-24px_rgba(0,0,0,0.45)]"
|
||||
>
|
||||
{title && (
|
||||
<h2 className="mb-4 font-display text-xl font-bold tracking-tight text-ink">
|
||||
<h2 className="mb-4 text-lg font-semibold tracking-tight text-ink">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
+11
-10
@@ -6,6 +6,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Check, X, ArrowRight } from "../icons";
|
||||
|
||||
type ToastKind = "success" | "error" | "info";
|
||||
interface Toast {
|
||||
@@ -21,9 +22,9 @@ const Ctx = createContext<(message: string, kind?: ToastKind) => void>(
|
||||
let counter = 0;
|
||||
|
||||
const icons: Record<ToastKind, ReactNode> = {
|
||||
success: "✓",
|
||||
error: "✕",
|
||||
info: "→",
|
||||
success: <Check size={13} />,
|
||||
error: <X size={13} />,
|
||||
info: <ArrowRight size={13} />,
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
@@ -46,19 +47,19 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 28, scale: 0.85 }}
|
||||
initial={{ opacity: 0, y: 16, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.85, y: 10 }}
|
||||
transition={{ type: "spring", stiffness: 480, damping: 24 }}
|
||||
className="card pointer-events-auto flex items-center gap-2.5 rounded-full px-4 py-2.5 shadow-[0_16px_40px_-18px_rgba(0,0,0,0.6)]"
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ type: "spring", stiffness: 460, damping: 30 }}
|
||||
className="card pointer-events-auto flex items-center gap-2.5 rounded-full px-4 py-2.5 shadow-[0_12px_32px_-16px_rgba(0,0,0,0.5)]"
|
||||
>
|
||||
<span
|
||||
className={`grid h-5 w-5 place-items-center rounded-full text-[11px] font-bold ${
|
||||
className={`grid h-5 w-5 place-items-center rounded-full ${
|
||||
t.kind === "error"
|
||||
? "bg-red-500/15 text-red-500"
|
||||
: t.kind === "info"
|
||||
? "bg-[var(--surface-2)] text-ink"
|
||||
: "bg-accent text-accent-ink"
|
||||
? "bg-[var(--surface-2)] text-muted"
|
||||
: "bg-[var(--ink)] text-[var(--bg)]"
|
||||
}`}
|
||||
>
|
||||
{icons[t.kind]}
|
||||
|
||||
+31
-78
@@ -3,37 +3,31 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Light theme — warm paper + ink */
|
||||
--bg: #f4f2ea;
|
||||
--bg-grain: #ece9dd;
|
||||
--surface: #fffdf7;
|
||||
--surface-2: #f6f3e9;
|
||||
--ink: #16170f;
|
||||
--ink-solid: #16170f;
|
||||
--muted: #6b6c5e;
|
||||
--line: #e2decf;
|
||||
--ring: #16170f;
|
||||
--accent: #c6f24e;
|
||||
--accent-ink: #16170f;
|
||||
--glow: rgba(198, 242, 78, 0.45);
|
||||
--dot: rgba(22, 23, 15, 0.06);
|
||||
/* Light — clean neutral paper */
|
||||
--bg: #fbfbfa;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f4f4f5;
|
||||
--ink: #18181b;
|
||||
--muted: #71717a;
|
||||
--line: #e9e9eb;
|
||||
--ring: #d4d4d8;
|
||||
--accent: #2563eb;
|
||||
--accent-soft: rgba(37, 99, 235, 0.1);
|
||||
--accent-ink: #ffffff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme — deep moss + lime */
|
||||
--bg: #0e100a;
|
||||
--bg-grain: #14160e;
|
||||
--surface: #181b11;
|
||||
--surface-2: #1f2317;
|
||||
--ink: #f1efe3;
|
||||
--ink-solid: #000000;
|
||||
--muted: #9b9d8a;
|
||||
--line: #2c3020;
|
||||
--ring: #3a3f2a;
|
||||
--accent: #c6f24e;
|
||||
--accent-ink: #16170f;
|
||||
--glow: rgba(198, 242, 78, 0.22);
|
||||
--dot: rgba(241, 239, 227, 0.05);
|
||||
/* Dark — near-black */
|
||||
--bg: #09090b;
|
||||
--surface: #111113;
|
||||
--surface-2: #19191c;
|
||||
--ink: #fafafa;
|
||||
--muted: #a1a1aa;
|
||||
--line: #232327;
|
||||
--ring: #2e2e33;
|
||||
--accent: #60a5fa;
|
||||
--accent-soft: rgba(96, 165, 250, 0.14);
|
||||
--accent-ink: #09090b;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -48,41 +42,14 @@ body {
|
||||
margin: 0;
|
||||
background-color: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: "General Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
min-height: 100vh;
|
||||
transition: background-color 0.5s ease, color 0.4s ease;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Atmospheric layered background: dotted grid + soft lime glows */
|
||||
.app-bg::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background-image: radial-gradient(var(--dot) 1.4px, transparent 1.4px);
|
||||
background-size: 26px 26px;
|
||||
background-position: -13px -13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-bg::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -3;
|
||||
background:
|
||||
radial-gradient(60% 50% at 12% 8%, var(--glow), transparent 60%),
|
||||
radial-gradient(50% 40% at 92% 22%, var(--glow), transparent 60%),
|
||||
radial-gradient(70% 60% at 78% 96%, var(--glow), transparent 65%);
|
||||
filter: blur(8px);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
/* Use CSS vars through Tailwind-friendly utility classes */
|
||||
/* Semantic token utilities (kept stable so components stay theme-driven) */
|
||||
.bg-surface { background-color: var(--surface); }
|
||||
.bg-surface-2 { background-color: var(--surface-2); }
|
||||
.text-ink { color: var(--ink); }
|
||||
@@ -91,43 +58,29 @@ body {
|
||||
.bg-accent { background-color: var(--accent); }
|
||||
.text-accent { color: var(--accent); }
|
||||
.text-accent-ink { color: var(--accent-ink); }
|
||||
.ring-ink { --tw-ring-color: var(--ring); }
|
||||
|
||||
.card {
|
||||
background-color: var(--surface);
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 1.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar { width: 11px; height: 11px; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--line);
|
||||
border-radius: 99px;
|
||||
border: 3px solid var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ring); }
|
||||
|
||||
/* Focus ring */
|
||||
/* Focus ring — single subtle accent ring */
|
||||
.focusable:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--bg), 0 0 0 5.5px var(--accent);
|
||||
}
|
||||
|
||||
/* Grain overlay for texture */
|
||||
.grain::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.035;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
|
||||
}
|
||||
|
||||
+48
-85
@@ -14,20 +14,11 @@ import { DeleteLinkModal } from "../components/DeleteLinkModal";
|
||||
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 };
|
||||
|
||||
const pop = {
|
||||
hidden: { opacity: 0, y: 20, scale: 0.95 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring", stiffness: 340, damping: 20 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
@@ -38,32 +29,34 @@ function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
}
|
||||
|
||||
function ViewToggle({ view, onChange }: { view: View; onChange: (v: View) => void }) {
|
||||
const opts: { value: View; icon: string; label: string }[] = [
|
||||
{ value: "comfortable", icon: "▦", label: "Comfortable view" },
|
||||
{ value: "compact", icon: "≣", label: "Compact view" },
|
||||
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-1 rounded-xl border-[1.5px] border-line bg-surface-2 p-1">
|
||||
<div className="flex shrink-0 gap-1 rounded-xl border border-line bg-surface p-1">
|
||||
{opts.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
aria-label={o.label}
|
||||
aria-pressed={view === o.value}
|
||||
className="focusable relative grid h-8 w-9 place-items-center rounded-lg text-[15px]"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{view === o.value && (
|
||||
<motion.span
|
||||
layoutId="view-pill"
|
||||
transition={{ type: "spring", stiffness: 480, damping: 32 }}
|
||||
className="absolute inset-0 rounded-lg bg-[var(--surface)] shadow-[0_2px_8px_-3px_rgba(0,0,0,0.3)] ring-[1.5px] ring-[var(--ring)]"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`relative z-10 ${view === o.value ? "text-ink" : "text-muted"}`}
|
||||
>
|
||||
{o.icon}
|
||||
</span>
|
||||
<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>
|
||||
@@ -201,14 +194,9 @@ export function Dashboard() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8">
|
||||
{/* header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 26 }}
|
||||
className="flex flex-wrap items-end justify-between gap-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold tracking-tight text-ink sm:text-4xl">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-ink sm:text-3xl">
|
||||
Your links
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
@@ -217,49 +205,39 @@ export function Dashboard() {
|
||||
</div>
|
||||
<Link to="/">
|
||||
<Button size="md">
|
||||
<span className="text-lg">+</span> New link
|
||||
<Plus size={16} /> New link
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* stat tiles */}
|
||||
<div className="mt-6 grid grid-cols-3 gap-3">
|
||||
{statTiles.map((s, i) => (
|
||||
<motion.div
|
||||
key={s.label}
|
||||
initial={{ opacity: 0, y: 18, scale: 0.94 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 20, delay: 0.05 * i }}
|
||||
className="card p-4"
|
||||
>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted sm:text-xs">
|
||||
{statTiles.map((s) => (
|
||||
<div key={s.label} className="card p-4">
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted">
|
||||
{s.label}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-1 font-display text-2xl font-bold sm:text-3xl ${
|
||||
s.accent ? "text-accent-ink" : "text-ink"
|
||||
className={`mt-1 text-2xl font-semibold sm:text-3xl ${
|
||||
s.accent ? "text-accent" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{s.accent ? (
|
||||
<span className="rounded-lg bg-accent px-2">{s.value}</span>
|
||||
) : (
|
||||
s.value
|
||||
)}
|
||||
{s.value}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* toolbar: search + view toggle */}
|
||||
{hasAnyLinks && (
|
||||
<div className="mt-6 flex items-center gap-2.5">
|
||||
<div className="flex h-11 flex-1 items-center gap-2 rounded-xl border-[1.5px] border-line bg-surface px-3.5">
|
||||
<span className="text-muted">⌕</span>
|
||||
<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" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => updateSearch(e.target.value)}
|
||||
placeholder="Search by code or URL…"
|
||||
className="h-full w-full bg-transparent text-[15px] text-ink outline-none placeholder:text-muted"
|
||||
className="h-full w-full bg-transparent text-sm text-ink outline-none placeholder:text-muted"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
@@ -267,7 +245,7 @@ export function Dashboard() {
|
||||
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"
|
||||
>
|
||||
✕
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -280,12 +258,12 @@ export function Dashboard() {
|
||||
<p className="mt-3 text-xs text-muted">
|
||||
{query ? (
|
||||
<>
|
||||
<span className="font-semibold text-ink">{total}</span> of {totalLinks}{" "}
|
||||
<span className="font-medium text-ink">{total}</span> of {totalLinks}{" "}
|
||||
{totalLinks === 1 ? "link" : "links"} match “{query}”
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Showing <span className="font-semibold text-ink">{items.length}</span> of{" "}
|
||||
Showing <span className="font-medium text-ink">{items.length}</span> of{" "}
|
||||
{totalLinks} {totalLinks === 1 ? "link" : "links"}
|
||||
</>
|
||||
)}
|
||||
@@ -295,55 +273,40 @@ export function Dashboard() {
|
||||
{/* list */}
|
||||
<div className="mt-4">
|
||||
{pageData === null ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="card h-28 animate-pulse opacity-60"
|
||||
className="card h-24 animate-pulse opacity-60"
|
||||
style={{ animationDelay: `${i * 120}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : total === 0 && !debouncedQuery ? (
|
||||
<motion.div
|
||||
variants={pop}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="card grid place-items-center gap-3 p-12 text-center"
|
||||
>
|
||||
<span className="text-4xl">🪄</span>
|
||||
<p className="font-display text-xl font-bold text-ink">No links yet</p>
|
||||
<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="max-w-xs text-sm text-muted">
|
||||
Create your first short link and it'll show up here with live click
|
||||
stats.
|
||||
</p>
|
||||
<Link to="/" className="mt-1">
|
||||
<Link to="/" className="mt-2">
|
||||
<Button>Create a link</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : total === 0 ? (
|
||||
<motion.div
|
||||
variants={pop}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="card grid place-items-center gap-2 p-10 text-center"
|
||||
>
|
||||
<span className="text-3xl">🔍</span>
|
||||
<p className="font-display text-lg font-bold text-ink">No matches</p>
|
||||
<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-semibold text-ink underline decoration-accent decoration-2 underline-offset-2"
|
||||
className="focusable mt-1 rounded-lg text-sm font-medium text-ink underline underline-offset-2"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
layout
|
||||
className={view === "compact" ? "space-y-2.5" : "space-y-4"}
|
||||
>
|
||||
<motion.div layout className={view === "compact" ? "space-y-2.5" : "space-y-3"}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{items.map((link) =>
|
||||
view === "compact" ? (
|
||||
|
||||
+34
-84
@@ -2,99 +2,49 @@ import { motion } from "framer-motion";
|
||||
import { ShortenForm } from "../components/ShortenForm";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
const container = {
|
||||
hidden: {},
|
||||
show: {
|
||||
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
const pop = {
|
||||
hidden: { opacity: 0, y: 26, scale: 0.9 },
|
||||
show: {
|
||||
const fade = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
show: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring", stiffness: 360, damping: 18 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const features = [
|
||||
{ icon: "⚡", title: "Instant", text: "Links resolve in milliseconds, cached at the edge." },
|
||||
{ icon: "🎯", title: "Three styles", text: "Random, memorable words, or fully your own." },
|
||||
{ icon: "📊", title: "Live stats", text: "See every click with a 7-day breakdown." },
|
||||
];
|
||||
transition: { delay: i * 0.05, duration: 0.3, ease: "easeOut" },
|
||||
}),
|
||||
};
|
||||
|
||||
export function Home() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 pb-24 pt-10 sm:pt-16">
|
||||
<motion.div variants={container} initial="hidden" animate="show">
|
||||
{/* eyebrow */}
|
||||
<motion.div variants={pop} className="mb-5 flex justify-center">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border-[1.5px] border-line bg-surface px-3.5 py-1.5 text-xs font-medium text-muted">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent" />
|
||||
</span>
|
||||
{user ? `Welcome back, ${user.name.split(" ")[0]}` : "No account needed to start"}
|
||||
</span>
|
||||
</motion.div>
|
||||
<div className="mx-auto max-w-xl px-4 pb-16 pt-7 sm:pb-24 sm:pt-20">
|
||||
{/* Compact hero on mobile so the input is reachable; full size on desktop. */}
|
||||
<motion.p
|
||||
custom={0}
|
||||
variants={fade}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="text-center text-[13px] font-medium text-muted"
|
||||
>
|
||||
{user ? `Welcome back, ${user.name.split(" ")[0]}` : "No account needed to start"}
|
||||
</motion.p>
|
||||
|
||||
{/* headline */}
|
||||
<h1 className="text-center font-display text-5xl font-bold leading-[0.95] tracking-tight text-ink sm:text-7xl">
|
||||
<motion.span variants={pop} className="block">
|
||||
Long links,
|
||||
</motion.span>
|
||||
<motion.span variants={pop} className="block">
|
||||
made{" "}
|
||||
<span className="relative inline-block">
|
||||
<span className="relative z-10">tiny</span>
|
||||
<motion.span
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ delay: 0.7, type: "spring", stiffness: 200, damping: 18 }}
|
||||
className="absolute inset-x-[-2px] bottom-1 z-0 h-2.5 origin-left rounded-full bg-accent sm:bottom-1.5 sm:h-3.5"
|
||||
/>
|
||||
</span>
|
||||
.
|
||||
</motion.span>
|
||||
</h1>
|
||||
<motion.h1
|
||||
custom={1}
|
||||
variants={fade}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="mt-1.5 text-center text-[26px] font-semibold leading-[1.1] tracking-tight text-ink sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
Long links, made tiny.
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
variants={pop}
|
||||
className="mx-auto mt-5 max-w-md text-center text-[15px] leading-relaxed text-muted sm:text-base"
|
||||
>
|
||||
Paste a clunky URL and get a clean, shareable link in a tap — with
|
||||
your choice of code, optional PIN, and click stats.
|
||||
</motion.p>
|
||||
|
||||
{/* form */}
|
||||
<motion.div variants={pop} className="mt-9">
|
||||
<ShortenForm />
|
||||
</motion.div>
|
||||
|
||||
{/* features */}
|
||||
<motion.ul
|
||||
variants={pop}
|
||||
className="mt-10 grid grid-cols-1 gap-3 sm:grid-cols-3"
|
||||
>
|
||||
{features.map((f) => (
|
||||
<motion.li
|
||||
key={f.title}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 16 }}
|
||||
className="card p-4"
|
||||
>
|
||||
<span className="text-xl">{f.icon}</span>
|
||||
<p className="mt-2 text-sm font-semibold text-ink">{f.title}</p>
|
||||
<p className="mt-0.5 text-[13px] leading-snug text-muted">
|
||||
{f.text}
|
||||
</p>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
<motion.div
|
||||
custom={2}
|
||||
variants={fade}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="mt-5 sm:mt-8"
|
||||
>
|
||||
<ShortenForm />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+37
-58
@@ -4,14 +4,15 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { api } from "../lib/api";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Scissors } from "../components/icons";
|
||||
|
||||
const META: Record<string, { label: string; icon: string }> = {
|
||||
google: { label: "Continue with Google", icon: "🇬" },
|
||||
oidc: { label: "Continue with SSO", icon: "🔐" },
|
||||
const LABELS: Record<string, string> = {
|
||||
google: "Continue with Google",
|
||||
oidc: "Continue with SSO",
|
||||
};
|
||||
|
||||
function providerMeta(name: string) {
|
||||
return META[name] ?? { label: `Continue with ${name}`, icon: "🔐" };
|
||||
function providerLabel(name: string) {
|
||||
return LABELS[name] ?? `Continue with ${name}`;
|
||||
}
|
||||
|
||||
export function Login() {
|
||||
@@ -28,79 +29,57 @@ export function Login() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid min-h-[70vh] max-w-md place-items-center px-4 pb-24 pt-10">
|
||||
<div className="mx-auto grid min-h-[70vh] max-w-sm place-items-center px-4 pb-24 pt-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30, scale: 0.94 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 280, damping: 22 }}
|
||||
className="card w-full p-7 sm:p-8"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="card w-full p-6 sm:p-7"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -30 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 14, delay: 0.1 }}
|
||||
className="mb-5 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--ink)]"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 64 64" fill="none">
|
||||
<path
|
||||
d="M26 38l12-12M28 20a8 8 0 0 1 11 11l-3 3M36 44a8 8 0 0 1-11-11l3-3"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth="5.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
<span className="grid h-10 w-10 place-items-center rounded-xl bg-[var(--ink)] text-[var(--bg)]">
|
||||
<Scissors size={18} />
|
||||
</span>
|
||||
|
||||
<h1 className="font-display text-3xl font-bold tracking-tight text-ink">
|
||||
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-ink">
|
||||
Welcome to snip
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-muted">
|
||||
Sign in to manage your links, lock them with a PIN, and watch the
|
||||
clicks roll in.
|
||||
Sign in to manage your links, lock them with a PIN, and watch the clicks
|
||||
roll in.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-2.5">
|
||||
{providers === null ? (
|
||||
<>
|
||||
<div className="h-14 animate-pulse rounded-2xl bg-surface-2" />
|
||||
<div className="h-14 animate-pulse rounded-2xl bg-surface-2 opacity-60" />
|
||||
<div className="h-12 animate-pulse rounded-xl bg-surface-2" />
|
||||
<div className="h-12 animate-pulse rounded-xl bg-surface-2 opacity-60" />
|
||||
</>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="rounded-2xl border-[1.5px] border-dashed border-line bg-surface-2/50 p-4 text-center text-sm text-muted">
|
||||
No login providers are configured on the server yet. Set{" "}
|
||||
<div className="rounded-xl border border-dashed border-line bg-surface-2 p-4 text-center text-[13px] text-muted">
|
||||
No login providers are configured yet. Set{" "}
|
||||
<span className="font-mono text-ink">GOOGLE_*</span> or{" "}
|
||||
<span className="font-mono text-ink">OIDC_*</span> env vars to
|
||||
enable sign-in.
|
||||
<span className="font-mono text-ink">OIDC_*</span> env vars to enable
|
||||
sign-in.
|
||||
</div>
|
||||
) : (
|
||||
providers.map((p, i) => {
|
||||
const meta = providerMeta(p);
|
||||
return (
|
||||
<motion.div
|
||||
key={p}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 * i, type: "spring", stiffness: 320, damping: 22 }}
|
||||
>
|
||||
<Button
|
||||
variant={i === 0 ? "primary" : "outline"}
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => {
|
||||
window.location.href = api.loginUrl(p);
|
||||
}}
|
||||
>
|
||||
<span className="text-lg">{meta.icon}</span> {meta.label}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
providers.map((p, i) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={i === 0 ? "primary" : "outline"}
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => {
|
||||
window.location.href = api.loginUrl(p);
|
||||
}}
|
||||
>
|
||||
{providerLabel(p)}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-center text-xs text-muted">
|
||||
You can still create random & memorable links without an account —
|
||||
sign in only to customize, protect, and track them.
|
||||
You can still create random & memorable links without an account.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
+4
-33
@@ -5,41 +5,12 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: ['"Clash Display"', "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
sans: ['"General Sans"', "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
mono: ['"Space Mono"', "ui-monospace", "SFMono-Regular", "monospace"],
|
||||
},
|
||||
colors: {
|
||||
// Warm paper + ink, electric lime accent. Deliberately not purple-on-white.
|
||||
paper: "#F4F2EA",
|
||||
ink: "#16170F",
|
||||
lime: {
|
||||
DEFAULT: "#C6F24E",
|
||||
400: "#D2F76B",
|
||||
500: "#C6F24E",
|
||||
600: "#A9DB2E",
|
||||
700: "#7FA614",
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
pop: "0 2px 0 0 var(--ring), 0 10px 30px -12px rgba(0,0,0,0.35)",
|
||||
hard: "4px 4px 0 0 var(--ink-solid)",
|
||||
sans: ['"Inter"', "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
mono: ['"JetBrains Mono"', "ui-monospace", "SFMono-Regular", "monospace"],
|
||||
},
|
||||
borderRadius: {
|
||||
"4xl": "2rem",
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
"0%, 100%": { transform: "translateY(0)" },
|
||||
"50%": { transform: "translateY(-8px)" },
|
||||
},
|
||||
"spin-slow": {
|
||||
to: { transform: "rotate(360deg)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
float: "float 6s ease-in-out infinite",
|
||||
"spin-slow": "spin-slow 18s linear infinite",
|
||||
xl: "0.75rem",
|
||||
"2xl": "1rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user