diff --git a/src/App.tsx b/src/App.tsx index cbb2ff9..21ff4c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,9 +11,9 @@ import { Dashboard } from "./pages/Dashboard"; function Page({ children }: { children: React.ReactNode }) { return ( {children} diff --git a/src/components/BarChart.tsx b/src/components/BarChart.tsx index 7e29c6d..d185a4f 100644 --- a/src/components/BarChart.tsx +++ b/src/components/BarChart.tsx @@ -1,4 +1,3 @@ -import { motion } from "framer-motion"; import { useState } from "react"; import type { DayCount } from "../lib/types"; import { weekdayLabel } from "../lib/format"; @@ -8,6 +7,8 @@ interface Props { height?: number; } +// ponytail: no grow-in animation — this renders on every dashboard page view, +// and motion seen tens of times a day just reads as lag. export function BarChart({ data, height = 84 }: Props) { const [hover, setHover] = useState(null); const max = Math.max(1, ...data.map((d) => d.count)); @@ -20,28 +21,19 @@ export function BarChart({ data, height = 84 }: Props) { return (
setHover(i)} onMouseLeave={() => setHover(null)} > - - {d.count} clicks - - + {active && ( +
+ {d.count} {d.count === 1 ? "click" : "clicks"} +
+ )}
-
{weekdayLabel(d.date)} diff --git a/src/components/DeleteLinkModal.tsx b/src/components/DeleteLinkModal.tsx deleted file mode 100644 index a72f78f..0000000 --- a/src/components/DeleteLinkModal.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from "react"; -import { api } from "../lib/api"; -import type { ShortLink } from "../lib/types"; -import { compactNumber, prettyHost, shortUrl } from "../lib/format"; -import { Modal } from "./ui/Modal"; -import { Button } from "./ui/Button"; - -interface Props { - link: ShortLink | null; - onClose: () => void; - onDeleted: (id: string, message: string) => void; -} - -export function DeleteLinkModal({ link, onClose, onDeleted }: Props) { - const open = Boolean(link); - const [busy, setBusy] = useState(false); - - if (!link) return ; - - async function confirm() { - if (!link) return; - setBusy(true); - try { - await api.deleteLink(link.id); - onDeleted(link.id, "Link deleted"); - } catch { - setBusy(false); - } - } - - return ( - -
-

- {shortUrl(link.code)} -

-

{prettyHost(link.longUrl)}

-
- -

- This permanently deletes the link. Anyone who opens{" "} - {shortUrl(link.code)} hits a dead - end, and its{" "} - - {compactNumber(link.totalClicks)} clicks - {" "} - of history go with it. This can't be undone. -

- -
- - -
-
- ); -} diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx index 1da0397..5a8ffdb 100644 --- a/src/components/Logo.tsx +++ b/src/components/Logo.tsx @@ -8,10 +8,10 @@ export function Logo({ onClick }: { onClick?: () => void }) { onClick={onClick} className="focusable inline-flex items-center gap-2 rounded-lg" > - + - + snip diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 8e561f5..242d444 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -9,7 +9,7 @@ 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"; + "press focusable inline-flex h-9 items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-muted hover:bg-surface-2 hover:text-ink"; export function Navbar() { const { user, logout } = useAuth(); @@ -41,7 +41,7 @@ export function Navbar() { useEffect(() => setMenuOpen(false), [pathname]); return ( -
+
@@ -63,14 +63,14 @@ export function Navbar() { 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" + className="press focusable inline-flex h-9 items-center gap-1 rounded-lg py-1 pl-1 pr-1.5 hover:bg-surface-2" > - + {user.name.slice(0, 1).toUpperCase()} @@ -78,11 +78,13 @@ export function Navbar() { {menuOpen && (

diff --git a/src/components/PinManager.tsx b/src/components/PinManager.tsx index de76bea..e5f1817 100644 --- a/src/components/PinManager.tsx +++ b/src/components/PinManager.tsx @@ -73,7 +73,7 @@ export function PinManager({ link, onClose, onSaved }: Props) { — only replaced or removed. */} {hasPin && (

- +
diff --git a/src/components/QrModal.tsx b/src/components/QrModal.tsx index 50ae5d0..f8ead45 100644 --- a/src/components/QrModal.tsx +++ b/src/components/QrModal.tsx @@ -1,36 +1,23 @@ -import { useRef, useState } from "react"; +import { useRef } from "react"; import { QRCodeSVG } from "qrcode.react"; 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"; +import { CopyButton } from "./ui/CopyButton"; +import { Download } from "./icons"; interface Props { link: ShortLink | null; onClose: () => void; - onCopied: () => void; } -export function QrModal({ link, onClose, onCopied }: Props) { +export function QrModal({ link, onClose }: Props) { const open = Boolean(link); const wrapRef = useRef(null); - const [copied, setCopied] = useState(false); if (!link) return ; - async function copy() { - if (!link) return; - try { - await navigator.clipboard.writeText(fullShortUrl(link.code)); - } catch { - /* clipboard may be blocked */ - } - setCopied(true); - onCopied(); - setTimeout(() => setCopied(false), 1800); - } - function download() { if (!link) return; const svg = wrapRef.current?.querySelector("svg"); @@ -46,32 +33,32 @@ export function QrModal({ link, onClose, onCopied }: Props) { } return ( - +
-
+
-

+

{shortUrl(link.code)}

-

Points to {prettyHost(link.longUrl)}

+

+ → {prettyHost(link.longUrl)} +

-
- - diff --git a/src/components/ResultCard.tsx b/src/components/ResultCard.tsx index c91b312..4fee403 100644 --- a/src/components/ResultCard.tsx +++ b/src/components/ResultCard.tsx @@ -1,112 +1,46 @@ -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 { Button } from "./ui/Button"; -import { Check, Copy, Qr, Lock, Plus } from "./icons"; +import { CopyButton } from "./ui/CopyButton"; +import { Qr, Lock, Check } from "./icons"; interface Props { link: ShortLink; - onReset: () => void; - onCopied: () => void; + /** True when the link was copied automatically on creation. */ + autoCopied: boolean; + onShowQr: (link: ShortLink) => void; } -const modeBadge: Record = { - random: "Random code", - memorable: "Memorable words", - custom: "Custom alias", -}; - -export function ResultCard({ link, onReset, onCopied }: Props) { - const [copied, setCopied] = useState(false); - const [showQr, setShowQr] = useState(false); - - async function copy() { - try { - await navigator.clipboard.writeText(fullShortUrl(link.code)); - } catch { - /* clipboard may be blocked in some contexts */ - } - setCopied(true); - onCopied(); - setTimeout(() => setCopied(false), 1800); - } - +export function ResultCard({ link, autoCopied, onShowQr }: Props) { return ( - -
- - - - Your link is live - - {modeBadge[link.mode]} - -
- -
-

{prettyHost(link.longUrl)}

-

+

+
+

{shortUrl(link.code)} + {link.hasPin && }

-
- - - {link.hasPin && ( - - PIN protected +

+ {autoCopied ? ( + + Copied to clipboard + ) : ( + <>→ {prettyHost(link.longUrl)} )} -

- - - {showQr && ( - -
- -
-
- )} -
+

- - + +
); } diff --git a/src/components/SegmentedControl.tsx b/src/components/SegmentedControl.tsx index 0a26ef7..9705110 100644 --- a/src/components/SegmentedControl.tsx +++ b/src/components/SegmentedControl.tsx @@ -25,10 +25,10 @@ export function SegmentedControl({ ); return ( -
+
({ type="button" onClick={() => onChange(seg.value)} aria-pressed={active} - className={`focusable relative z-10 flex flex-1 items-center justify-center gap-1 rounded-lg py-2 text-[13px] font-medium transition-colors ${ + className={`focusable relative z-10 flex flex-1 items-center justify-center gap-1 rounded-lg py-1.5 text-[13px] font-medium transition-colors ${ active ? "text-ink" : "text-muted hover:text-ink" }`} > diff --git a/src/components/ShortenForm.tsx b/src/components/ShortenForm.tsx index 5f155af..8af528c 100644 --- a/src/components/ShortenForm.tsx +++ b/src/components/ShortenForm.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, motion } from "framer-motion"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; import { api } from "../lib/api"; @@ -8,22 +8,23 @@ import { normalizeUrl, validateCustomAlias, } from "../lib/shortcode"; -import { getShortDomain } from "../lib/format"; +import { fullShortUrl, getShortDomain } from "../lib/format"; import type { ShortLink, UrlMode } from "../lib/types"; import { Button } from "./ui/Button"; import { useToast } from "./ui/Toast"; import { SegmentedControl, type Segment } from "./SegmentedControl"; import { PinInput } from "./PinInput"; import { ResultCard } from "./ResultCard"; +import { QrModal } from "./QrModal"; import { Lock, ArrowRight } from "./icons"; -const ease = { duration: 0.2, ease: "easeOut" } as const; +const ease = { duration: 0.22, ease: [0.23, 1, 0.32, 1] } as const; // Shared reveal for the optional rows (alias, PIN) so they open the same way. const collapse = { initial: { opacity: 0, height: 0 }, animate: { opacity: 1, height: "auto" }, - exit: { opacity: 0, height: 0 }, + exit: { opacity: 0, height: 0, transition: { duration: 0.15 } }, transition: ease, className: "overflow-hidden", } as const; @@ -38,6 +39,7 @@ export function ShortenForm() { const { user } = useAuth(); const toast = useToast(); const navigate = useNavigate(); + const inputRef = useRef(null); const [url, setUrl] = useState(""); const [mode, setMode] = useState("random"); @@ -47,7 +49,16 @@ export function ShortenForm() { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [errorInput, setErrorInput] = useState<"url" | "alias" | null>(null); - const [result, setResult] = useState(null); + // Links made this visit, newest first. ponytail: in-memory only; persist + // to localStorage if people ask to see them after a reload. + const [results, setResults] = useState<{ link: ShortLink; copied: boolean }[]>([]); + const [qrTarget, setQrTarget] = useState(null); + + // Focus the field on devices with a keyboard; on phones that would pop the + // keyboard over the page before the user has read it. + useEffect(() => { + if (window.matchMedia("(pointer: fine)").matches) inputRef.current?.focus(); + }, []); function showError(msg: string, input: "url" | "alias" | null = null) { setError(msg); @@ -60,20 +71,13 @@ export function ShortenForm() { const segments: Segment[] = [ { value: "random", label: "Random" }, - { value: "memorable", label: "Memorable" }, + { value: "memorable", label: "Words" }, { value: "custom", label: "Custom", locked: !user }, ]; - // Kept short so the caption row never wraps at 320px and shifts the layout. - const hints: Record = { - random: "Shortest code", - memorable: "Three words", - custom: user ? "Your own ending" : "Sign in to choose", - }; - function onMode(value: UrlMode) { if (value === "custom" && !user) { - toast("Sign in to create custom links", "info"); + toast("Sign in to choose your own ending", "info"); navigate("/login"); return; } @@ -81,16 +85,15 @@ export function ShortenForm() { setMode(value); } - async function submit(e: React.FormEvent) { - e.preventDefault(); + async function create(rawUrl: string) { clearError(); - if (!url.trim()) { + if (!rawUrl.trim()) { showError("Paste a link to shorten.", "url"); return; } - if (!isValidUrl(url)) { - showError("That doesn't look like a valid URL.", "url"); + if (!isValidUrl(rawUrl)) { + showError("That doesn't look like a link.", "url"); return; } if (mode === "custom") { @@ -108,13 +111,24 @@ export function ShortenForm() { setBusy(true); try { const link = await api.createLink({ - longUrl: normalizeUrl(url), + longUrl: normalizeUrl(rawUrl), mode, customAlias: mode === "custom" ? alias : undefined, pin: pinOn && user ? pin : undefined, }); - setResult(link); - toast("Link created — ready to share", "success"); + // Copy straight away — the next thing anyone does with a short link is paste it. + let copied = true; + try { + await navigator.clipboard.writeText(fullShortUrl(link.code)); + } catch { + copied = false; + } + setResults((r) => [{ link, copied }, ...r.map((x) => ({ ...x, copied: false }))]); + setUrl(""); + setAlias(""); + setPin(""); + setPinOn(false); + inputRef.current?.focus(); } catch (err) { const msg = err instanceof Error ? err.message : "Something went wrong."; showError(msg, mode === "custom" ? "alias" : null); @@ -123,173 +137,176 @@ export function ShortenForm() { } } - function reset() { - setResult(null); - setUrl(""); - setAlias(""); - setPin(""); - setPinOn(false); - setMode("random"); - clearError(); + // Pasting a link into an empty field is the whole job — do it immediately, + // unless the user still has choices to make (custom ending, PIN). + function onPaste(e: React.ClipboardEvent) { + const pasted = e.clipboardData.getData("text").trim(); + if (url || busy || mode === "custom" || pinOn || !isValidUrl(pasted)) return; + e.preventDefault(); + setUrl(pasted); + create(pasted); } const preview = mode === "custom" ? alias || previews.custom : previews[mode]; return ( -
- - {result ? ( - toast("Copied to clipboard", "success")} +
+
{ + e.preventDefault(); + create(url); + }} + // Our own validation accepts "example.com" (we add https://); the + // browser's type=url check would reject it. + noValidate + className="card p-2" + > + {/* Primary action: paste, shorten. */} +
+ { + setUrl(e.target.value); + if (errorInput === "url") clearError(); + }} + onPaste={onPaste} + placeholder="Paste a long link…" + aria-label="Long URL" + type="url" + inputMode="url" + autoComplete="off" + autoCapitalize="off" + spellCheck={false} + // 16px on mobile: anything smaller makes iOS Safari zoom on focus. + className="h-11 w-full min-w-0 bg-transparent px-2.5 font-mono text-[16px] text-ink outline-none placeholder:font-sans placeholder:text-muted sm:flex-1 sm:text-[15px]" /> - ) : ( - - {/* Primary action: paste, shorten. Stacks on mobile, one row on desktop. */} -
- { - setUrl(e.target.value); - if (errorInput === "url") clearError(); - }} - placeholder="Paste a long link" - aria-label="Long URL" - type="url" - inputMode="url" - autoComplete="off" - autoCapitalize="off" - spellCheck={false} - // 16px on mobile: anything smaller makes iOS Safari zoom on focus. - // flex-1 only from sm — in the mobile column it would set the - // flex-basis on the height axis and collapse the field. - className={`field h-12 w-full min-w-0 shrink-0 px-3.5 font-mono text-[16px] placeholder:font-sans placeholder:text-muted sm:flex-1 sm:text-sm ${ - errorInput === "url" ? "field-error" : "" - }`} - /> - -
+ +
- {/* Link style */} -
- -
- {hints[mode]} - - {getShortDomain()}/{preview} - -
-
+ {/* Options */} +
+
+ +
+ {user && ( + + )} + + {getShortDomain()}/{preview} + +
- {/* Custom alias */} - - {mode === "custom" && ( - -
- - {getShortDomain()}/ - - { - setAlias(e.target.value.replace(/[^a-zA-Z0-9_-]/g, "")); - if (errorInput === "alias") clearError(); - }} - placeholder="spring-launch" - aria-label="Custom alias" - autoCapitalize="off" - spellCheck={false} - className="h-12 w-full min-w-0 bg-transparent pr-3.5 font-mono text-[16px] text-ink outline-none placeholder:text-muted sm:text-sm" - /> -
-
- )} -
- - {/* PIN protection */} - {user ? ( - <> - - - {pinOn && ( - -
- -
-
- )} -
- - ) : ( -

- {" "} - for custom aliases and PIN protection. -

- )} + + {getShortDomain()}/ + + { + setAlias(e.target.value.replace(/[^a-zA-Z0-9_-]/g, "")); + if (errorInput === "alias") clearError(); + }} + placeholder="spring-launch" + aria-label="Custom ending" + autoCapitalize="off" + spellCheck={false} + className="h-11 w-full min-w-0 bg-transparent pr-3 font-mono text-[16px] text-ink outline-none placeholder:text-muted sm:text-sm" + /> +
+
+ + )} + - {/* Error */} - - {error && ( - - {error} - - )} - - - )} - + + {pinOn && ( + +
+ +

+ Visitors enter this before they're redirected. +

+
+
+ )} +
+ + + {error && ( + + + {error} + + + )} + + + + {!user && results.length === 0 && ( +

+ {" "} + to pick your own ending, add a PIN, and track clicks. +

+ )} + + {/* Results, newest on top. Height + opacity so the list below makes room smoothly. */} +
+ + {results.map(({ link, copied }) => ( + +
+ +
+
+ ))} +
+
+ + setQrTarget(null)} />
); } diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx index 2df30dc..816af8c 100644 --- a/src/components/Sparkline.tsx +++ b/src/components/Sparkline.tsx @@ -1,4 +1,3 @@ -import { motion } from "framer-motion"; import type { DayCount } from "../lib/types"; interface Props { @@ -7,7 +6,7 @@ interface Props { height?: number; } -export function Sparkline({ data, width = 80, height = 28 }: Props) { +export function Sparkline({ data, width = 64, height = 24 }: Props) { const max = Math.max(1, ...data.map((d) => d.count)); const n = data.length; const stepX = n > 1 ? width / (n - 1) : width; @@ -25,16 +24,13 @@ export function Sparkline({ data, width = 80, height = 28 }: Props) { return ( - {last && } diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index fac6d58..0d32d4b 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -1,4 +1,3 @@ -import { AnimatePresence, motion } from "framer-motion"; import { useTheme } from "../context/ThemeContext"; import { Sun, Moon } from "./icons"; @@ -10,20 +9,12 @@ export function ThemeToggle() { ); } diff --git a/src/components/UrlCard.tsx b/src/components/UrlCard.tsx index 2a0410b..7f200b6 100644 --- a/src/components/UrlCard.tsx +++ b/src/components/UrlCard.tsx @@ -1,83 +1,59 @@ -import { motion } from "framer-motion"; -import type { ShortLink } from "../lib/types"; -import { - compactNumber, - fullShortUrl, - prettyHost, - relativeTime, - shortUrl, -} from "../lib/format"; +import { compactNumber, fullShortUrl, prettyHost, relativeTime, shortUrl } from "../lib/format"; import { BarChart } from "./BarChart"; import { Button } from "./ui/Button"; +import { CopyButton } from "./ui/CopyButton"; +import { HoldToDelete } from "./ui/HoldToDelete"; import { Lock } from "./icons"; +import type { LinkItemProps } from "./UrlRow"; -interface Props { - link: ShortLink; - onEdit: (link: ShortLink) => void; - onDelete: (link: ShortLink) => void; - onCopy: () => void; - onManagePin: (link: ShortLink) => void; - onShowQr: (link: ShortLink) => void; -} +const modeLabel: Record = { + random: "Random", + memorable: "Words", + custom: "Custom", +}; export function UrlCard({ link, onEdit, onDelete, - onCopy, + onDeleteTap, onManagePin, onShowQr, -}: Props) { +}: LinkItemProps) { const weekTotal = link.last7Days.reduce((s, d) => s + d.count, 0); - async function copy() { - try { - await navigator.clipboard.writeText(fullShortUrl(link.code)); - } catch { - /* ignore */ - } - onCopy(); - } - return ( - +
{/* Left: identity + actions */}
-
- - {link.mode} - +
+ {modeLabel[link.mode] ?? link.mode} {link.hasPin && ( - - PIN - + <> + · + + PIN + + )} - {relativeTime(link.createdAt)} + · + {relativeTime(link.createdAt)}
- e.preventDefault()} - className="focusable mt-2 inline-block truncate rounded font-mono text-lg font-semibold text-ink hover:text-accent" - > +

{shortUrl(link.code)} - - +

- {prettyHost(link.longUrl)} + → {prettyHost(link.longUrl)}

-
- +
+ @@ -87,37 +63,30 @@ export function UrlCard({ - + onDelete(link)} + onTap={onDeleteTap} + className="ml-auto h-8 rounded-lg px-3 text-[13px]" + />
{/* Right: stats */} -
-
+
+
-

- Total clicks -

-

+

Clicks

+

{compactNumber(link.totalClicks)}

-
-

7 days

-

- +{compactNumber(weekTotal)} -

-
+

+ +{compactNumber(weekTotal)} 7d +

- +
- +
); } diff --git a/src/components/UrlRow.tsx b/src/components/UrlRow.tsx index 41d56be..7a52fe9 100644 --- a/src/components/UrlRow.tsx +++ b/src/components/UrlRow.tsx @@ -1,127 +1,82 @@ -import { motion } from "framer-motion"; import type { ShortLink } from "../lib/types"; -import { - compactNumber, - fullShortUrl, - prettyHost, - shortUrl, -} from "../lib/format"; +import { compactNumber, fullShortUrl, prettyHost, shortUrl } from "../lib/format"; import { Sparkline } from "./Sparkline"; -import { Copy, Qr, Lock, Edit, Trash } from "./icons"; +import { CopyButton } from "./ui/CopyButton"; +import { HoldToDelete } from "./ui/HoldToDelete"; +import { Qr, Lock, Edit } from "./icons"; -interface Props { +export interface LinkItemProps { link: ShortLink; onEdit: (link: ShortLink) => void; onDelete: (link: ShortLink) => void; - onCopy: () => void; + onDeleteTap: () => void; onManagePin: (link: ShortLink) => void; onShowQr: (link: ShortLink) => void; } -function IconButton({ - label, - onClick, - danger, - children, -}: { - label: string; - onClick: () => void; - danger?: boolean; - children: React.ReactNode; -}) { - return ( - - ); -} +export const iconBtn = + "press focusable grid h-8 w-8 place-items-center rounded-lg text-muted hover:bg-surface-2 hover:text-ink"; export function UrlRow({ link, onEdit, onDelete, - onCopy, + onDeleteTap, onManagePin, onShowQr, -}: Props) { +}: LinkItemProps) { const weekTotal = link.last7Days.reduce((s, d) => s + d.count, 0); - async function copy() { - try { - await navigator.clipboard.writeText(fullShortUrl(link.code)); - } catch { - /* ignore */ - } - onCopy(); - } - return ( - +
{/* identity */} - {/* stats */} -
-
-

+

+ {/* stats */} +
+
+ +
+

{compactNumber(link.totalClicks)} + {weekTotal > 0 && ( + + +{compactNumber(weekTotal)} + + )}

-

clicks

- - +{compactNumber(weekTotal)} - -
- -
-
- {/* actions */} -
- - - - onShowQr(link)}> - - - onManagePin(link)}> - - - onEdit(link)}> - - - onDelete(link)}> - - + {/* actions */} +
+ + + + + onDelete(link)} + onTap={onDeleteTap} + className="h-8 w-8 rounded-lg hover:bg-surface-2" + /> +
- +
); } diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 66a09cc..0d8bc1a 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1,10 +1,9 @@ -import { motion, type HTMLMotionProps } from "framer-motion"; -import { forwardRef, type ReactNode } from "react"; +import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react"; -type Variant = "primary" | "ghost" | "outline" | "dark"; +type Variant = "primary" | "ghost" | "outline" | "danger"; type Size = "sm" | "md" | "lg"; -interface Props extends Omit, "children"> { +interface Props extends ButtonHTMLAttributes { variant?: Variant; size?: Size; children: ReactNode; @@ -12,36 +11,33 @@ interface Props extends Omit, "children"> { } const sizes: Record = { - 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", + sm: "h-8 px-3 text-[13px] gap-1.5 rounded-lg", + md: "h-10 px-4 text-sm gap-2 rounded-xl", + lg: "h-12 px-5 text-[15px] gap-2 rounded-xl", }; const variants: Record = { - // brand gradient — the one strong action per view - primary: "grad-bg font-medium hover:opacity-90", - // solid ink, for when a neutral strong action reads better - dark: "bg-[var(--ink)] text-[var(--bg)] font-medium hover:opacity-90", - outline: - "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", + // solid ink — the one strong action per view + primary: "bg-[var(--ink)] text-[var(--bg)] hover:opacity-85", + danger: "bg-[var(--danger)] text-white hover:opacity-90", + outline: "bg-surface text-ink border border-line hover:bg-surface-2", + ghost: "text-muted hover:bg-surface-2 hover:text-ink", }; +// CSS press feedback (not framer-motion) — runs off the main thread and needs no JS. export const Button = forwardRef(function Button( { variant = "primary", size = "md", block, className = "", children, ...rest }, ref, ) { return ( - {children} - + ); }); diff --git a/src/components/ui/CopyButton.tsx b/src/components/ui/CopyButton.tsx new file mode 100644 index 0000000..ced3b43 --- /dev/null +++ b/src/components/ui/CopyButton.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from "react"; +import { Check, Copy } from "../icons"; + +interface Props { + text: string; + /** Visible label; omit for an icon-only button. */ + label?: string; + className?: string; + size?: number; +} + +/** Copies on click and morphs its icon to a check. The button itself is the + * confirmation, so no toast is needed. */ +export function CopyButton({ text, label, className = "", size = 15 }: Props) { + const [copied, setCopied] = useState(false); + const timer = useRef(); + useEffect(() => () => clearTimeout(timer.current), []); + + async function copy() { + try { + await navigator.clipboard.writeText(text); + } catch { + return; // clipboard blocked — don't claim success + } + setCopied(true); + clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), 1500); + } + + return ( + + ); +} diff --git a/src/components/ui/HoldToDelete.tsx b/src/components/ui/HoldToDelete.tsx new file mode 100644 index 0000000..22fd7c4 --- /dev/null +++ b/src/components/ui/HoldToDelete.tsx @@ -0,0 +1,83 @@ +import { useRef, useState } from "react"; +import { Trash } from "../icons"; + +interface Props { + onConfirm: () => void; + /** Called on a quick tap, so the user learns they need to hold. */ + onTap?: () => void; + label?: string; + className?: string; +} + +/** Press and hold to delete. Replaces a confirm modal: deliberate (slow fill, + * 1s linear) where the user is deciding, instant snap-back on release. + * Works with pointer and with Space/Enter held down. */ +export function HoldToDelete({ onConfirm, onTap, label, className = "" }: Props) { + const [holding, setHolding] = useState(false); + // Ref mirrors state so a press + release in the same frame can't leave the + // button "held" (state would still read false in the release handler). + const held = useRef(false); + const started = useRef(0); + const done = useRef(false); + + function set(v: boolean) { + held.current = v; + setHolding(v); + } + function start() { + if (held.current) return; // ignore extra fingers / key repeat + done.current = false; + started.current = Date.now(); + set(true); + } + function stop() { + if (!held.current) return; + set(false); + if (!done.current && Date.now() - started.current < 250) onTap?.(); + } + + return ( + + ); +} diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index eaad231..9bfcc95 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -8,6 +8,8 @@ interface Props { children: ReactNode; } +const out = [0.23, 1, 0.32, 1] as const; + export function Modal({ open, onClose, title, children }: Props) { useEffect(() => { if (!open) return; @@ -24,26 +26,23 @@ export function Modal({ open, onClose, title, children }: Props) { {open && ( - +
+ {/* Centred, so it scales from its own centre. Exit is quicker than enter. */} {title && ( -

+

{title}

)} diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index 9872d25..cf3088e 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -47,16 +47,17 @@ export function ToastProvider({ children }: { children: ReactNode }) { * { + grid-area: 1 / 1; + transition: opacity 200ms ease, filter 200ms ease, transform 200ms cubic-bezier(0.23, 1, 0.32, 1); +} +.swap > [data-off="true"] { + opacity: 0; + filter: blur(4px); + transform: scale(0.8); +} + +/* Enter for list items — CSS keyframes, so it stays smooth while React is busy. */ +@keyframes rise { + from { + opacity: 0; + transform: translateY(6px); + } +} +.rise { + animation: rise 280ms cubic-bezier(0.23, 1, 0.32, 1) backwards; +} + +/* Hold-to-delete: the fill sweeps slowly while held, snaps back on release. */ +.hold-fill { + clip-path: inset(0 100% 0 0); + transition: clip-path 200ms cubic-bezier(0.23, 1, 0.32, 1); +} +[data-holding="true"] > .hold-fill { + clip-path: inset(0 0 0 0); + transition: clip-path 1000ms linear; +} + +kbd { + font-family: "Inter", ui-sans-serif, system-ui, sans-serif; } ::selection { - background: var(--accent); - color: var(--accent-ink); + background: var(--ink); + color: var(--bg); } ::-webkit-scrollbar { width: 10px; height: 10px; } @@ -138,10 +151,16 @@ body { border-radius: 99px; border: 3px solid var(--bg); } -::-webkit-scrollbar-thumb:hover { background: var(--ring); } -/* Focus ring — single subtle accent ring */ .focusable:focus-visible { outline: none; - box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent); + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ink); +} + +/* Reduced motion = no movement, keep the fades that explain state. */ +@media (prefers-reduced-motion: reduce) { + .press:active:not(:disabled) { transform: none; } + .swap > [data-off="true"] { transform: none; } + .rise { animation-name: fade; } + @keyframes fade { from { opacity: 0; } } } diff --git a/src/main.tsx b/src/main.tsx index 8c4233b..22ba2b7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; +import { MotionConfig } from "framer-motion"; import App from "./App"; import { ThemeProvider } from "./context/ThemeContext"; import { AuthProvider } from "./context/AuthContext"; @@ -12,6 +13,8 @@ import "./index.css"; function render() { createRoot(document.getElementById("root")!).render( + {/* Honour the OS "reduce motion" setting: framer drops transforms, keeps fades. */} + @@ -21,6 +24,7 @@ function render() { + , ); } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 664fddf..5dce91a 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; import { api } from "../lib/api"; import { compactNumber } from "../lib/format"; @@ -10,7 +10,6 @@ import { UrlRow } from "../components/UrlRow"; import { Pagination } from "../components/Pagination"; import { PinManager } from "../components/PinManager"; import { EditLinkModal } from "../components/EditLinkModal"; -import { DeleteLinkModal } from "../components/DeleteLinkModal"; import { QrModal } from "../components/QrModal"; import { Button } from "../components/ui/Button"; import { useToast } from "../components/ui/Toast"; @@ -34,15 +33,17 @@ function ViewToggle({ view, onChange }: { view: View; onChange: (v: View) => voi { value: "compact", label: "Compact view", rows: 3 }, ]; return ( -
+
{opts.map((o) => ( - +
{/* stat tiles */} -
+
{statTiles.map((s) => ( -
-

- {s.label} -

+
+

{s.label}

- {s.value} + {stats ? s.value : —}

))} @@ -231,19 +246,26 @@ export function Dashboard() { {/* toolbar: search + view toggle */} {hasAnyLinks && (
-
- +
+ updateSearch(e.target.value)} - placeholder="Search by code or URL…" - className="h-full w-full bg-transparent text-sm text-ink outline-none placeholder:text-muted" + placeholder="Search links…" + aria-label="Search links" + className="h-full w-full bg-transparent text-[16px] text-ink outline-none placeholder:text-muted sm:text-sm" /> + {!query && ( + + / + + )} {query && ( @@ -255,7 +277,7 @@ export function Dashboard() { {/* result meta */} {hasAnyLinks && ( -

+

{query ? ( <> {total} of {totalLinks}{" "} @@ -277,21 +299,20 @@ export function Dashboard() { {[0, 1, 2].map((i) => (

))}
) : total === 0 && !debouncedQuery ? (
-

No links yet

+

No links yet

- Create your first short link and it'll show up here with live click - stats. + Links you shorten while signed in show up here, with clicks per day.

- - - +
) : total === 0 ? (
@@ -306,33 +327,37 @@ export function Dashboard() {
) : ( <> - - - {items.map((link) => - view === "compact" ? ( - + + {items.map((link, i) => { + const props = { + link, + onEdit: setEditTarget, + onDelete: deleteLink, + onDeleteTap: () => toast("Hold to delete", "info"), + onManagePin: setPinTarget, + onShowQr: setQrTarget, + }; + return ( + toast("Copied to clipboard", "success")} - onManagePin={setPinTarget} - onShowQr={setQrTarget} - /> - ) : ( - toast("Copied to clipboard", "success")} - onManagePin={setPinTarget} - onShowQr={setQrTarget} - /> - ), - )} + exit={{ opacity: 0, height: 0, transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] } }} + className="overflow-hidden" + > +
+ {view === "compact" ? : } +
+
+ ); + })}
-
+
@@ -357,17 +382,7 @@ export function Dashboard() { }} /> - setQrTarget(null)} - onCopied={() => toast("Copied to clipboard", "success")} - /> - - setDeleteTarget(null)} - onDeleted={handleDeleted} - /> + setQrTarget(null)} />
); } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 697daee..fd7b2a0 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,4 +1,3 @@ -import { motion } from "framer-motion"; import { ShortenForm } from "../components/ShortenForm"; import { useAuth } from "../context/AuthContext"; @@ -6,28 +5,20 @@ export function Home() { const { user } = useAuth(); return ( - -

- Long links,{" "} - {/* pb-[0.1em] so descenders aren't clipped by the background-clip box */} - made tiny. + // Compact hero on mobile keeps the input above the fold; roomier on desktop. +
+

+ Shorten a link.

- -

+

{user - ? `Welcome back, ${user.name.split(" ")[0]}.` - : "Paste a link, get a short one. No account needed."} + ? `Welcome back, ${user.name.split(" ")[0]}. Paste and it's copied.` + : "Paste it, and the short one is on your clipboard."}

-
+
- +
); } diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 98ab9e8..5ea1ca0 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,4 +1,3 @@ -import { motion } from "framer-motion"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; @@ -30,13 +29,8 @@ export function Login() { return (
- - +
+ @@ -81,7 +75,7 @@ export function Login() {

You can still create random & memorable links without an account.

- +
); } diff --git a/tailwind.config.js b/tailwind.config.js index 5208737..c9f9a55 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -2,6 +2,8 @@ export default { darkMode: "class", content: ["./index.html", "./src/**/*.{ts,tsx}"], + // hover: only applies on devices that can actually hover — no sticky taps on phones. + future: { hoverOnlyWhenSupported: true }, theme: { extend: { fontFamily: { @@ -12,6 +14,9 @@ export default { xl: "0.75rem", "2xl": "1rem", }, + transitionTimingFunction: { + out: "cubic-bezier(0.23, 1, 0.32, 1)", + }, }, }, plugins: [],