mirror of
https://github.com/ThisTine/Snip.git
synced 2026-10-18 02:51:38 +07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e1133e39c |
No files matched your search
+3
-3
@@ -11,9 +11,9 @@ import { Dashboard } from "./pages/Dashboard";
|
||||
function Page({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.main
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.main>
|
||||
|
||||
+11
-19
@@ -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<number | null>(null);
|
||||
const max = Math.max(1, ...data.map((d) => d.count));
|
||||
@@ -20,28 +21,19 @@ export function BarChart({ data, height = 84 }: Props) {
|
||||
return (
|
||||
<div
|
||||
key={d.date}
|
||||
className="group relative flex h-full flex-1 flex-col items-center justify-end gap-1.5"
|
||||
className="relative flex h-full flex-1 flex-col items-center justify-end gap-1.5"
|
||||
onMouseEnter={() => setHover(i)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={false}
|
||||
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>
|
||||
|
||||
{active && (
|
||||
<div 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 tabular-nums text-[var(--bg)]">
|
||||
{d.count} {d.count === 1 ? "click" : "clicks"}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-1 items-end">
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: `${Math.max(ratio * 100, 4)}%` }}
|
||||
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: 3 }}
|
||||
<div
|
||||
className={`w-full rounded-[3px] ${active ? "bg-[var(--ink)]" : "bg-[var(--ring)]"}`}
|
||||
style={{ height: `${Math.max(ratio * 100, 4)}%`, minHeight: 3 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted">{weekdayLabel(d.date)}</span>
|
||||
|
||||
@@ -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 <Modal open={false} onClose={onClose} children={null} />;
|
||||
|
||||
async function confirm() {
|
||||
if (!link) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteLink(link.id);
|
||||
onDeleted(link.id, "Link deleted");
|
||||
} catch {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Delete link">
|
||||
<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> 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.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<Button
|
||||
onClick={confirm}
|
||||
disabled={busy}
|
||||
// bg-none clears the primary variant's gradient image so red shows through.
|
||||
className="!bg-none !bg-red-500 !text-white hover:!opacity-90"
|
||||
>
|
||||
{busy ? "Deleting…" : "Delete link"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,10 @@ export function Logo({ onClick }: { onClick?: () => void }) {
|
||||
onClick={onClick}
|
||||
className="focusable inline-flex items-center gap-2 rounded-lg"
|
||||
>
|
||||
<span className="grad-bg grid h-7 w-7 place-items-center rounded-lg">
|
||||
<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">
|
||||
<span className="text-[16px] font-semibold tracking-[-0.02em] text-ink">
|
||||
snip
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
+12
-10
@@ -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 (
|
||||
<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">
|
||||
<header className="sticky top-0 z-50 -mx-4 border-b border-transparent 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 />
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span className="grid h-7 w-7 place-items-center rounded-md bg-[var(--ink)] text-[11px] font-semibold text-[var(--bg)]">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-full bg-surface-2 text-[12px] font-semibold text-ink ring-1 ring-[var(--line)]">
|
||||
{user.name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`text-muted transition-transform ${menuOpen ? "rotate-180" : ""}`}
|
||||
className={`text-muted transition-transform duration-200 ease-out ${menuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -78,11 +78,13 @@ export function Navbar() {
|
||||
{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)]"
|
||||
// Scales out of the avatar button (top-right), not from its own centre.
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.1 } }}
|
||||
transition={{ duration: 0.16, ease: [0.23, 1, 0.32, 1] }}
|
||||
style={{ transformOrigin: "top right" }}
|
||||
className="card absolute right-0 top-[calc(100%+6px)] w-56 overflow-hidden p-1 shadow-[var(--shadow-lg)]"
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
<p className="truncate text-sm font-medium text-ink">
|
||||
|
||||
@@ -73,7 +73,7 @@ export function PinManager({ link, onClose, onSaved }: Props) {
|
||||
— only replaced or removed. */}
|
||||
{hasPin && (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
|
||||
<span className="grad-bg grid h-8 w-8 place-items-center rounded-lg">
|
||||
<span className="bg-[var(--ink)] text-[var(--bg)] grid h-8 w-8 place-items-center rounded-lg">
|
||||
<Lock size={15} />
|
||||
</span>
|
||||
<div>
|
||||
|
||||
+18
-31
@@ -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<HTMLDivElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!link) return <Modal open={false} onClose={onClose} children={null} />;
|
||||
|
||||
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 (
|
||||
<Modal open={open} onClose={onClose} title="QR code">
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="rounded-xl border border-line bg-white p-4"
|
||||
>
|
||||
<div ref={wrapRef} className="rounded-xl bg-white p-4 ring-1 ring-black/5">
|
||||
<QRCodeSVG
|
||||
value={fullShortUrl(link.code)}
|
||||
size={184}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#18181b"
|
||||
fgColor="#111113"
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 font-mono text-base font-semibold text-ink">
|
||||
<p className="mt-4 max-w-full truncate 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="max-w-full truncate text-[13px] text-muted">
|
||||
→ {prettyHost(link.longUrl)}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex w-full items-center gap-2">
|
||||
<Button block onClick={copy}>
|
||||
{copied ? <Check size={15} /> : <Copy size={15} />}
|
||||
{copied ? "Copied" : "Copy link"}
|
||||
</Button>
|
||||
<Button block variant="outline" onClick={download}>
|
||||
<div className="mt-5 grid w-full grid-cols-2 gap-2">
|
||||
<CopyButton
|
||||
text={fullShortUrl(link.code)}
|
||||
label="Copy link"
|
||||
className="h-10 rounded-xl bg-[var(--ink)] text-sm text-[var(--bg)] hover:opacity-85"
|
||||
/>
|
||||
<Button variant="outline" onClick={download}>
|
||||
<Download size={15} />
|
||||
SVG
|
||||
</Button>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grad-bg grid h-6 w-6 place-items-center rounded-full">
|
||||
<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="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="grad-text mt-1 break-all font-mono text-lg font-semibold sm:text-xl">
|
||||
<div className="card flex items-center gap-3 p-3 pl-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-mono text-[15px] font-semibold text-ink">
|
||||
{shortUrl(link.code)}
|
||||
{link.hasPin && <Lock size={12} className="ml-1.5 inline text-muted" />}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button onClick={copy} size="md" className="flex-1 sm:flex-none">
|
||||
{copied ? <Check size={15} /> : <Copy size={15} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => setShowQr((v) => !v)}
|
||||
aria-expanded={showQr}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<Qr size={15} />
|
||||
{showQr ? "Hide QR" : "QR code"}
|
||||
</Button>
|
||||
{link.hasPin && (
|
||||
<span className="inline-flex w-full items-center gap-1 text-[12px] text-muted sm:w-auto">
|
||||
<Lock size={13} /> PIN protected
|
||||
<p className="truncate text-[12px] text-muted" title={link.longUrl}>
|
||||
{autoCopied ? (
|
||||
<span className="inline-flex items-center gap-1 text-accent">
|
||||
<Check size={12} /> Copied to clipboard
|
||||
</span>
|
||||
) : (
|
||||
<>→ {prettyHost(link.longUrl)}</>
|
||||
)}
|
||||
</p>
|
||||
</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"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShowQr(link)}
|
||||
aria-label="Show QR code"
|
||||
className="press focusable grid h-9 w-9 shrink-0 place-items-center rounded-lg text-muted hover:bg-surface-2 hover:text-ink"
|
||||
>
|
||||
<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"
|
||||
<Qr size={16} />
|
||||
</button>
|
||||
<CopyButton
|
||||
text={fullShortUrl(link.code)}
|
||||
label="Copy"
|
||||
className="h-9 shrink-0 rounded-lg border border-line bg-surface px-3 text-[13px] text-ink hover:bg-surface-2"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="focusable mt-4 inline-flex items-center gap-1.5 rounded-lg text-[13px] font-medium text-muted hover:text-ink"
|
||||
>
|
||||
<Plus size={15} /> Shorten another
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -25,10 +25,10 @@ export function SegmentedControl<T extends string>({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex rounded-xl border border-line bg-surface-2 p-1">
|
||||
<div className="relative flex rounded-xl bg-surface-2 p-1">
|
||||
<span
|
||||
aria-hidden
|
||||
className="grad-tint absolute inset-y-1 left-1 rounded-lg shadow-sm transition-transform duration-200 ease-out"
|
||||
className="absolute inset-y-1 left-1 rounded-lg bg-surface shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_var(--line)] transition-transform duration-200 ease-out"
|
||||
style={{
|
||||
width: `calc((100% - 0.5rem) / ${segments.length})`,
|
||||
transform: `translateX(${index * 100}%)`,
|
||||
@@ -42,7 +42,7 @@ export function SegmentedControl<T extends string>({
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
|
||||
+138
-121
@@ -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<HTMLInputElement>(null);
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [mode, setMode] = useState<UrlMode>("random");
|
||||
@@ -47,7 +49,16 @@ export function ShortenForm() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorInput, setErrorInput] = useState<"url" | "alias" | null>(null);
|
||||
const [result, setResult] = useState<ShortLink | null>(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<ShortLink | null>(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<UrlMode>[] = [
|
||||
{ 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<UrlMode, string> = {
|
||||
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,47 +137,45 @@ 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<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className="relative">
|
||||
<AnimatePresence mode="wait">
|
||||
{result ? (
|
||||
<ResultCard
|
||||
key="result"
|
||||
link={result}
|
||||
onReset={reset}
|
||||
onCopied={() => toast("Copied to clipboard", "success")}
|
||||
/>
|
||||
) : (
|
||||
<motion.form
|
||||
key="form"
|
||||
onSubmit={submit}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={ease}
|
||||
className="card p-3 sm:p-4"
|
||||
<div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
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. */}
|
||||
<div
|
||||
className={`field flex flex-col gap-2 p-1.5 sm:flex-row sm:items-center ${
|
||||
errorInput === "url" ? "field-error" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Primary action: paste, shorten. Stacks on mobile, one row on desktop. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
if (errorInput === "url") clearError();
|
||||
}}
|
||||
placeholder="Paste a long link"
|
||||
onPaste={onPaste}
|
||||
placeholder="Paste a long link…"
|
||||
aria-label="Long URL"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
@@ -171,50 +183,53 @@ export function ShortenForm() {
|
||||
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" : ""
|
||||
}`}
|
||||
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]"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={busy}
|
||||
className="w-full sm:w-auto sm:shrink-0"
|
||||
>
|
||||
{busy ? (
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<>
|
||||
Shorten
|
||||
<ArrowRight size={16} className="hidden sm:block" />
|
||||
</>
|
||||
)}
|
||||
<Button type="submit" disabled={busy} className="h-11 w-full sm:w-auto sm:shrink-0">
|
||||
<span className="swap">
|
||||
<span data-off={busy} className="inline-flex items-center justify-center gap-2">
|
||||
Shorten <ArrowRight size={16} />
|
||||
</span>
|
||||
<span data-off={!busy} className="grid place-items-center">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent [animation-duration:600ms]" />
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Link style */}
|
||||
<div className="mt-3">
|
||||
{/* Options */}
|
||||
<div className="flex flex-col gap-2 px-1.5 pb-1.5 pt-3 sm:flex-row sm:items-center">
|
||||
<div className="sm:w-72">
|
||||
<SegmentedControl segments={segments} value={mode} onChange={onMode} />
|
||||
<div className="mt-2 flex items-baseline justify-between gap-3 px-0.5 text-[12px]">
|
||||
<span className="whitespace-nowrap text-muted">{hints[mode]}</span>
|
||||
<span className="truncate font-mono text-muted">
|
||||
</div>
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPinOn((v) => !v)}
|
||||
aria-pressed={pinOn}
|
||||
className={`press focusable inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-[13px] font-medium ${
|
||||
pinOn ? "bg-surface-2 text-ink" : "text-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{pinOn ? "PIN on" : "Add PIN"}
|
||||
</button>
|
||||
)}
|
||||
<span className="truncate px-1 font-mono text-[12px] text-muted sm:ml-auto">
|
||||
{getShortDomain()}/<span className="text-ink">{preview}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom alias */}
|
||||
<AnimatePresence initial={false}>
|
||||
{mode === "custom" && (
|
||||
<motion.div key="alias" {...collapse}>
|
||||
<div className="px-1.5 pb-1.5 pt-1">
|
||||
<div
|
||||
className={`field mt-3 flex items-center overflow-hidden ${
|
||||
className={`field flex items-center overflow-hidden ${
|
||||
errorInput === "alias" ? "field-error" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="select-none pl-3.5 font-mono text-[15px] text-muted sm:text-sm">
|
||||
<span className="select-none pl-3 font-mono text-[15px] text-muted sm:text-sm">
|
||||
{getShortDomain()}/
|
||||
</span>
|
||||
<input
|
||||
@@ -224,72 +239,74 @@ export function ShortenForm() {
|
||||
if (errorInput === "alias") clearError();
|
||||
}}
|
||||
placeholder="spring-launch"
|
||||
aria-label="Custom alias"
|
||||
aria-label="Custom ending"
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* PIN protection */}
|
||||
{user ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPinOn((v) => !v)}
|
||||
aria-pressed={pinOn}
|
||||
className={`focusable mt-3 inline-flex h-9 items-center gap-2 rounded-full border px-3 text-[13px] font-medium transition-colors ${
|
||||
pinOn
|
||||
? "grad-tint border-transparent text-accent"
|
||||
: "border-line text-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{pinOn ? "PIN protected" : "Add a PIN"}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{pinOn && (
|
||||
<motion.div key="pin" {...collapse}>
|
||||
<div className="pt-3">
|
||||
<div className="px-1.5 pb-1.5 pt-1">
|
||||
<PinInput value={pin} onChange={setPin} />
|
||||
<p className="mt-2 px-0.5 text-[12px] text-muted">
|
||||
Visitors enter this before they're redirected.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-3 px-0.5 text-[12px] text-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="focusable rounded font-medium text-ink underline underline-offset-2"
|
||||
>
|
||||
Sign in
|
||||
</button>{" "}
|
||||
for custom aliases and PIN protection.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{error && (
|
||||
<motion.p
|
||||
role="alert"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="mt-3 px-0.5 text-[13px] font-medium text-red-500"
|
||||
>
|
||||
<motion.p key="error" role="alert" {...collapse}>
|
||||
<span className="block px-2.5 pb-2 pt-1 text-[13px] font-medium text-danger">
|
||||
{error}
|
||||
</span>
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.form>
|
||||
</form>
|
||||
|
||||
{!user && results.length === 0 && (
|
||||
<p className="mt-4 text-center text-[13px] text-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="focusable rounded font-medium text-ink underline decoration-[var(--ring)] underline-offset-4 hover:decoration-current"
|
||||
>
|
||||
Sign in
|
||||
</button>{" "}
|
||||
to pick your own ending, add a PIN, and track clicks.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Results, newest on top. Height + opacity so the list below makes room smoothly. */}
|
||||
<div className="mt-3">
|
||||
<AnimatePresence initial={false}>
|
||||
{results.map(({ link, copied }) => (
|
||||
<motion.div
|
||||
key={link.id}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
transition={ease}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pb-2">
|
||||
<ResultCard link={link} autoCopied={copied} onShowQr={setQrTarget} />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<QrModal link={qrTarget} onClose={() => setQrTarget(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg width={width} height={height} className="overflow-visible text-muted">
|
||||
<motion.polyline
|
||||
<polyline
|
||||
points={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
{last && <circle cx={last[0]} cy={last[1]} r={2.4} className="fill-[var(--ink)]" />}
|
||||
</svg>
|
||||
|
||||
@@ -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() {
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
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"
|
||||
className="press focusable grid h-9 w-9 place-items-center rounded-lg text-muted hover:bg-surface-2 hover:text-ink"
|
||||
>
|
||||
<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>
|
||||
<span className="swap">
|
||||
<Sun size={17} data-off={dark} />
|
||||
<Moon size={17} data-off={!dark} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+43
-74
@@ -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<string, string> = {
|
||||
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 (
|
||||
<motion.div
|
||||
layout
|
||||
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"
|
||||
>
|
||||
<div className="card flex flex-col gap-5 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 text-[11px] text-muted">
|
||||
<span className="rounded-full border border-line px-2 py-0.5 font-medium uppercase tracking-wide">
|
||||
{link.mode}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[12px] text-muted">
|
||||
<span>{modeLabel[link.mode] ?? link.mode}</span>
|
||||
{link.hasPin && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Lock size={12} /> PIN
|
||||
<Lock size={11} /> PIN
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto">{relativeTime(link.createdAt)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{relativeTime(link.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={fullShortUrl(link.code)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className="focusable mt-2 inline-block truncate rounded font-mono text-lg font-semibold text-ink hover:text-accent"
|
||||
>
|
||||
<p className="mt-1.5 truncate font-mono text-lg font-semibold text-ink">
|
||||
{shortUrl(link.code)}
|
||||
</a>
|
||||
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[13px] text-muted" title={link.longUrl}>
|
||||
{prettyHost(link.longUrl)}
|
||||
→ {prettyHost(link.longUrl)}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-4">
|
||||
<Button size="sm" variant="outline" onClick={copy}>
|
||||
Copy
|
||||
</Button>
|
||||
<div className="mt-auto flex flex-wrap items-center gap-1 pt-4">
|
||||
<CopyButton
|
||||
text={fullShortUrl(link.code)}
|
||||
label="Copy"
|
||||
size={14}
|
||||
className="h-8 rounded-lg bg-[var(--ink)] px-3 text-[13px] text-[var(--bg)] hover:opacity-85"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={() => onShowQr(link)}>
|
||||
QR
|
||||
</Button>
|
||||
@@ -87,37 +63,30 @@ export function UrlCard({
|
||||
<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 hover:!text-red-500"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<HoldToDelete
|
||||
label="Delete"
|
||||
onConfirm={() => onDelete(link)}
|
||||
onTap={onDeleteTap}
|
||||
className="ml-auto h-8 rounded-lg px-3 text-[13px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: stats */}
|
||||
<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 className="flex w-full flex-col rounded-xl bg-surface-2 p-4 sm:w-64">
|
||||
<div className="mb-3 flex items-end justify-between tabular-nums">
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted">
|
||||
Total clicks
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-ink">
|
||||
<p className="text-[12px] text-muted">Clicks</p>
|
||||
<p className="text-2xl font-semibold tracking-[-0.02em] text-ink">
|
||||
{compactNumber(link.totalClicks)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted">7 days</p>
|
||||
<p className="text-base font-semibold text-accent">
|
||||
+{compactNumber(weekTotal)}
|
||||
<p className="text-[13px] font-medium text-accent">
|
||||
+{compactNumber(weekTotal)} <span className="text-muted">7d</span>
|
||||
</p>
|
||||
</div>
|
||||
<BarChart data={link.last7Days} height={64} />
|
||||
</div>
|
||||
<BarChart data={link.last7Days} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
+39
-84
@@ -1,127 +1,82 @@
|
||||
import { motion } from "framer-motion";
|
||||
import type { ShortLink } from "../lib/types";
|
||||
import {
|
||||
compactNumber,
|
||||
fullShortUrl,
|
||||
prettyHost,
|
||||
shortUrl,
|
||||
} from "../lib/format";
|
||||
import { compactNumber, fullShortUrl, prettyHost, shortUrl } from "../lib/format";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
import { Copy, Qr, Lock, Edit, Trash } from "./icons";
|
||||
import { CopyButton } from "./ui/CopyButton";
|
||||
import { HoldToDelete } from "./ui/HoldToDelete";
|
||||
import { Qr, Lock, Edit } from "./icons";
|
||||
|
||||
interface Props {
|
||||
export interface LinkItemProps {
|
||||
link: ShortLink;
|
||||
onEdit: (link: ShortLink) => void;
|
||||
onDelete: (link: ShortLink) => void;
|
||||
onCopy: () => void;
|
||||
onDeleteTap: () => void;
|
||||
onManagePin: (link: ShortLink) => void;
|
||||
onShowQr: (link: ShortLink) => void;
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
danger,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={`focusable grid h-8 w-8 place-items-center rounded-lg border border-line bg-surface text-muted transition-colors ${
|
||||
danger ? "hover:border-red-400 hover:text-red-500" : "hover:bg-surface-2 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
export const iconBtn =
|
||||
"press focusable grid h-8 w-8 place-items-center rounded-lg text-muted hover:bg-surface-2 hover:text-ink";
|
||||
|
||||
export function UrlRow({
|
||||
link,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCopy,
|
||||
onDeleteTap,
|
||||
onManagePin,
|
||||
onShowQr,
|
||||
}: Props) {
|
||||
}: LinkItemProps) {
|
||||
const weekTotal = link.last7Days.reduce((s, d) => s + d.count, 0);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullShortUrl(link.code));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
onCopy();
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.12 } }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="card flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
|
||||
>
|
||||
<div className="card flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
{/* identity */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={fullShortUrl(link.code)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className="focusable truncate rounded font-mono text-sm font-semibold text-ink hover:text-accent"
|
||||
>
|
||||
<p className="truncate font-mono text-sm font-semibold text-ink">
|
||||
{shortUrl(link.code)}
|
||||
</a>
|
||||
</p>
|
||||
{link.hasPin && <Lock size={12} className="shrink-0 text-muted" />}
|
||||
</div>
|
||||
<p className="truncate text-[12px] text-muted" title={link.longUrl}>
|
||||
{prettyHost(link.longUrl)}
|
||||
→ {prettyHost(link.longUrl)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* stats */}
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold leading-none text-ink">
|
||||
{compactNumber(link.totalClicks)}
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted">clicks</p>
|
||||
</div>
|
||||
<span className="hidden text-[12px] font-medium text-accent sm:inline">
|
||||
+{compactNumber(weekTotal)}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-3 tabular-nums">
|
||||
<div className="hidden sm:block">
|
||||
<Sparkline data={link.last7Days} />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-ink sm:w-14 sm:text-right">
|
||||
{compactNumber(link.totalClicks)}
|
||||
{weekTotal > 0 && (
|
||||
<span className="block text-[11px] font-medium text-accent">
|
||||
+{compactNumber(weekTotal)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* actions */}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<IconButton label="Copy link" onClick={copy}>
|
||||
<Copy size={15} />
|
||||
</IconButton>
|
||||
<IconButton label="Show QR code" onClick={() => onShowQr(link)}>
|
||||
<div className="-mr-1.5 flex shrink-0 items-center">
|
||||
<CopyButton text={fullShortUrl(link.code)} className={iconBtn} />
|
||||
<button onClick={() => onShowQr(link)} aria-label="Show QR code" className={iconBtn}>
|
||||
<Qr size={15} />
|
||||
</IconButton>
|
||||
<IconButton label="Manage PIN" onClick={() => onManagePin(link)}>
|
||||
</button>
|
||||
<button onClick={() => onManagePin(link)} aria-label="Manage PIN" className={iconBtn}>
|
||||
<Lock size={15} />
|
||||
</IconButton>
|
||||
<IconButton label="Edit destination" onClick={() => onEdit(link)}>
|
||||
</button>
|
||||
<button onClick={() => onEdit(link)} aria-label="Edit destination" className={iconBtn}>
|
||||
<Edit size={15} />
|
||||
</IconButton>
|
||||
<IconButton label="Delete link" danger onClick={() => onDelete(link)}>
|
||||
<Trash size={15} />
|
||||
</IconButton>
|
||||
</button>
|
||||
<HoldToDelete
|
||||
onConfirm={() => onDelete(link)}
|
||||
onTap={onDeleteTap}
|
||||
className="h-8 w-8 rounded-lg hover:bg-surface-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLMotionProps<"button">, "children"> {
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
children: ReactNode;
|
||||
@@ -12,36 +11,33 @@ interface Props extends Omit<HTMLMotionProps<"button">, "children"> {
|
||||
}
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
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<Variant, string> = {
|
||||
// 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<HTMLButtonElement, Props>(function Button(
|
||||
{ variant = "primary", size = "md", block, className = "", children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<motion.button
|
||||
<button
|
||||
ref={ref}
|
||||
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 ${
|
||||
className={`press focusable inline-flex select-none items-center justify-center font-medium disabled:opacity-40 ${
|
||||
sizes[size]
|
||||
} ${variants[variant]} ${block ? "w-full" : ""} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</motion.button>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -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<number>();
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
aria-label={label ? undefined : copied ? "Copied" : "Copy link"}
|
||||
className={`press focusable inline-flex items-center justify-center gap-2 font-medium ${className}`}
|
||||
>
|
||||
<span className="swap" aria-hidden>
|
||||
<Copy size={size} data-off={copied} />
|
||||
<Check size={size} data-off={!copied} />
|
||||
</span>
|
||||
{label && (
|
||||
<span className="swap">
|
||||
<span data-off={copied}>{label}</span>
|
||||
<span data-off={!copied} className="text-center">
|
||||
Copied
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{copied ? "Copied to clipboard" : ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
data-holding={holding}
|
||||
aria-label={label ? undefined : "Hold to delete"}
|
||||
title="Hold to delete"
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
start();
|
||||
}}
|
||||
onPointerUp={stop}
|
||||
onPointerCancel={stop}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
|
||||
e.preventDefault();
|
||||
start();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => (e.key === " " || e.key === "Enter") && stop()}
|
||||
onBlur={stop}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onTransitionEnd={(e) => {
|
||||
// Fires when the fill reaches 100% while still held.
|
||||
if (held.current && e.propertyName === "clip-path" && !done.current) {
|
||||
done.current = true;
|
||||
set(false);
|
||||
onConfirm();
|
||||
}
|
||||
}}
|
||||
className={`press focusable relative isolate inline-flex select-none items-center justify-center gap-1.5 overflow-hidden font-medium text-muted hover:text-[var(--danger)] ${className}`}
|
||||
>
|
||||
<Trash size={15} />
|
||||
{label && <span>{label}</span>}
|
||||
{/* Overlay copy of the content in white on red, revealed left→right. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="hold-fill absolute inset-0 flex items-center justify-center gap-1.5 bg-[var(--danger)] text-white"
|
||||
>
|
||||
<Trash size={15} />
|
||||
{label && <span>{label}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+12
-13
@@ -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) {
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[150] grid place-items-center p-4"
|
||||
className="fixed inset-0 z-[150] grid place-items-end p-3 sm:place-items-center sm:p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.2 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
{/* Centred, so it scales from its own centre. Exit is quicker than enter. */}
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
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)]"
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0, transition: { duration: 0.24, ease: out } }}
|
||||
exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.12, ease: "easeOut" } }}
|
||||
className="card relative z-10 w-full max-w-md p-6 shadow-[var(--shadow-lg)]"
|
||||
>
|
||||
{title && (
|
||||
<h2 className="mb-4 text-lg font-semibold tracking-tight text-ink">
|
||||
<h2 className="mb-4 text-[17px] font-semibold tracking-[-0.01em] text-ink">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
@@ -47,16 +47,17 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 16, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
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)]"
|
||||
// Enters and leaves through the same (bottom) edge.
|
||||
initial={{ opacity: 0, y: "100%" }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: "60%", transition: { duration: 0.15, ease: "easeOut" } }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="card pointer-events-auto flex items-center gap-2.5 rounded-full py-2 pl-2.5 pr-4 shadow-[var(--shadow-lg)]"
|
||||
>
|
||||
<span
|
||||
className={`grid h-5 w-5 place-items-center rounded-full ${
|
||||
t.kind === "error"
|
||||
? "bg-red-500/15 text-red-500"
|
||||
? "bg-[var(--danger)] text-white"
|
||||
: t.kind === "info"
|
||||
? "bg-[var(--surface-2)] text-muted"
|
||||
: "bg-[var(--ink)] text-[var(--bg)]"
|
||||
|
||||
+97
-78
@@ -3,72 +3,53 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Light — clean neutral paper */
|
||||
--bg: #fbfbfa;
|
||||
/* Light — ink on paper. One strong colour (ink) for actions, one quiet
|
||||
colour (green) reserved for growth numbers. */
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f4f4f5;
|
||||
--ink: #18181b;
|
||||
--muted: #71717a;
|
||||
--line: #e9e9eb;
|
||||
--ink: #111113;
|
||||
--muted: #6f6f78;
|
||||
--line: #e7e7ea;
|
||||
--ring: #d4d4d8;
|
||||
--accent: #2563eb;
|
||||
--accent-soft: rgba(37, 99, 235, 0.1);
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
/* One brand gradient, used at full strength on the hero + CTAs and as a soft
|
||||
tint everywhere else. Both stops clear 4.5:1 against --grad-ink. */
|
||||
--grad-from: #2563eb;
|
||||
--grad-to: #7c3aed;
|
||||
--grad-ink: #ffffff;
|
||||
--grad-soft: linear-gradient(135deg, rgba(37, 99, 235, 0.1), rgba(124, 58, 237, 0.1));
|
||||
--glow-1: rgba(37, 99, 235, 0.11);
|
||||
--glow-2: rgba(124, 58, 237, 0.1);
|
||||
--accent: #15803d;
|
||||
--accent-soft: rgba(17, 17, 19, 0.08);
|
||||
--danger: #dc2626;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 2px 8px -2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 24px 48px -16px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* 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;
|
||||
|
||||
/* Lighter stops on near-black, so --grad-ink flips to dark. */
|
||||
--grad-from: #60a5fa;
|
||||
--grad-to: #a78bfa;
|
||||
--grad-ink: #09090b;
|
||||
--grad-soft: linear-gradient(135deg, rgba(96, 165, 250, 0.14), rgba(167, 139, 250, 0.14));
|
||||
--glow-1: rgba(96, 165, 250, 0.13);
|
||||
--glow-2: rgba(167, 139, 250, 0.12);
|
||||
--bg: #0a0a0b;
|
||||
--surface: #121214;
|
||||
--surface-2: #1a1a1d;
|
||||
--ink: #f4f4f5;
|
||||
--muted: #9b9ba4;
|
||||
--line: #242428;
|
||||
--ring: #33333a;
|
||||
--accent: #4ade80;
|
||||
--accent-soft: rgba(244, 244, 245, 0.12);
|
||||
--danger: #f87171;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 24px 48px -16px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
/* Inter's straight-sided 1, open 4/6/9, single-storey a read cleaner in codes. */
|
||||
font-feature-settings: "cv01", "cv09", "cv11", "ss03";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
min-height: 100vh;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
/* Ambient colour that never sits under text — fixed so it doesn't scroll. */
|
||||
background-image: radial-gradient(50% 32% at 8% 0%, var(--glow-1), transparent 70%),
|
||||
radial-gradient(45% 30% at 96% 6%, var(--glow-2), transparent 70%);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
min-height: 100dvh;
|
||||
/* ponytail: no colour transition on theme switch — the body fading while
|
||||
every card snaps looks broken. Instant is correct. */
|
||||
}
|
||||
|
||||
/* Semantic token utilities (kept stable so components stay theme-driven) */
|
||||
@@ -77,58 +58,90 @@ body {
|
||||
.text-ink { color: var(--ink); }
|
||||
.text-muted { color: var(--muted); }
|
||||
.border-line { border-color: var(--line); }
|
||||
.bg-accent { background-color: var(--accent); }
|
||||
.text-accent { color: var(--accent); }
|
||||
.text-accent-ink { color: var(--accent-ink); }
|
||||
.text-danger { color: var(--danger); }
|
||||
|
||||
.card {
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.grad-bg {
|
||||
background-image: linear-gradient(135deg, var(--grad-from), var(--grad-to));
|
||||
color: var(--grad-ink);
|
||||
}
|
||||
|
||||
/* Soft tint over a surface — background-image layers on top of the colour. */
|
||||
.grad-tint {
|
||||
background-color: var(--surface);
|
||||
background-image: var(--grad-soft);
|
||||
}
|
||||
|
||||
.grad-text {
|
||||
background-image: linear-gradient(135deg, var(--grad-from), var(--grad-to));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* One field look for every input + input wrapper. */
|
||||
.field {
|
||||
background-color: var(--surface-2);
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.field:focus,
|
||||
.field:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 4px var(--accent-soft);
|
||||
}
|
||||
.field-error,
|
||||
.field-error:focus,
|
||||
.field-error:focus-within {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.16);
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
}
|
||||
|
||||
/* Anything pressable: instant feedback on press, no hover motion. */
|
||||
.press {
|
||||
transition-property: transform, background-color, color, border-color, opacity;
|
||||
transition-duration: 160ms;
|
||||
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.press:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Two stacked states that crossfade through a blur, so the swap reads as one
|
||||
object changing rather than two objects trading places. */
|
||||
.swap {
|
||||
display: grid;
|
||||
}
|
||||
.swap > * {
|
||||
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; } }
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
{/* Honour the OS "reduce motion" setting: framer drops transforms, keeps fades. */}
|
||||
<MotionConfig reducedMotion="user">
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
@@ -21,6 +24,7 @@ function render() {
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</MotionConfig>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
+89
-74
@@ -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 (
|
||||
<div className="flex shrink-0 gap-1 rounded-xl border border-line bg-surface p-1">
|
||||
<div className="flex shrink-0 gap-0.5 rounded-xl bg-surface-2 p-1">
|
||||
{opts.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
aria-label={o.label}
|
||||
aria-pressed={view === o.value}
|
||||
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"
|
||||
className={`press focusable grid h-8 w-9 place-items-center rounded-lg ${
|
||||
view === o.value
|
||||
? "bg-surface text-ink shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_var(--line)]"
|
||||
: "text-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -74,7 +75,6 @@ export function Dashboard() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [pinTarget, setPinTarget] = useState<ShortLink | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<ShortLink | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ShortLink | null>(null);
|
||||
const [qrTarget, setQrTarget] = useState<ShortLink | null>(null);
|
||||
|
||||
// Server-driven page + aggregate stats (the client never loads all links).
|
||||
@@ -159,12 +159,34 @@ export function Dashboard() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
async function handleDeleted(_id: string, message: string) {
|
||||
setDeleteTarget(null);
|
||||
toast(message, "info");
|
||||
// Hold-to-delete already asked for intent, so remove the row immediately
|
||||
// and reconcile with the server after.
|
||||
async function deleteLink(link: ShortLink) {
|
||||
setPageData((pd) =>
|
||||
pd ? { items: pd.items.filter((l) => l.id !== link.id), total: pd.total - 1 } : pd,
|
||||
);
|
||||
try {
|
||||
await api.deleteLink(link.id);
|
||||
toast(`Deleted ${link.code}`, "info");
|
||||
} catch {
|
||||
toast("Couldn't delete that link", "error");
|
||||
}
|
||||
await Promise.all([loadPage(), loadStats()]);
|
||||
}
|
||||
|
||||
// "/" jumps to search from anywhere on the page.
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (e.key !== "/" || t.closest("input, textarea, [contenteditable]")) return;
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// Edit / PIN: patch the row in place (totals are unaffected).
|
||||
function applyUpdate(updated: ShortLink, message: string) {
|
||||
setPageData((pd) =>
|
||||
@@ -196,33 +218,26 @@ export function Dashboard() {
|
||||
{/* header */}
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-ink sm:text-3xl">
|
||||
Your links
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.02em] text-ink sm:text-[28px]">
|
||||
Links
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Search, edit destinations, manage PINs, or retire a link.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/">
|
||||
<Button size="md">
|
||||
<Button size="md" onClick={() => navigate("/")}>
|
||||
<Plus size={16} /> New link
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* stat tiles */}
|
||||
<div className="mt-6 grid grid-cols-3 gap-3">
|
||||
<div className="card mt-6 grid grid-cols-3 divide-x divide-[var(--line)]">
|
||||
{statTiles.map((s) => (
|
||||
<div key={s.label} className="card p-4">
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted">
|
||||
{s.label}
|
||||
</p>
|
||||
<div key={s.label} className="px-4 py-3.5 sm:px-5 sm:py-4">
|
||||
<p className="text-[12px] text-muted">{s.label}</p>
|
||||
<p
|
||||
className={`mt-1 text-2xl font-semibold sm:text-3xl ${
|
||||
className={`mt-0.5 text-xl font-semibold tabular-nums tracking-[-0.02em] sm:text-2xl ${
|
||||
s.accent ? "text-accent" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{s.value}
|
||||
{stats ? s.value : <span className="text-[var(--ring)]">—</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -231,19 +246,26 @@ export function Dashboard() {
|
||||
{/* toolbar: search + view toggle */}
|
||||
{hasAnyLinks && (
|
||||
<div className="mt-6 flex items-center gap-2.5">
|
||||
<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" />
|
||||
<div className="field flex h-10 flex-1 items-center gap-2 px-3">
|
||||
<Search size={16} className="shrink-0 text-muted" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => 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 && (
|
||||
<kbd className="hidden h-5 shrink-0 place-items-center rounded border border-line px-1.5 text-[11px] text-muted sm:grid">
|
||||
/
|
||||
</kbd>
|
||||
)}
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => updateSearch("")}
|
||||
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"
|
||||
className="press focusable grid h-6 w-6 shrink-0 place-items-center rounded-md text-muted hover:bg-surface-2 hover:text-ink"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -255,7 +277,7 @@ export function Dashboard() {
|
||||
|
||||
{/* result meta */}
|
||||
{hasAnyLinks && (
|
||||
<p className="mt-3 text-xs text-muted">
|
||||
<p className="mt-3 px-0.5 text-xs tabular-nums text-muted">
|
||||
{query ? (
|
||||
<>
|
||||
<span className="font-medium text-ink">{total}</span> of {totalLinks}{" "}
|
||||
@@ -277,21 +299,20 @@ export function Dashboard() {
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="card h-24 animate-pulse opacity-60"
|
||||
style={{ animationDelay: `${i * 120}ms` }}
|
||||
className="card animate-pulse opacity-60"
|
||||
style={{ animationDelay: `${i * 120}ms`, height: view === "compact" ? 64 : 172 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : total === 0 && !debouncedQuery ? (
|
||||
<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="text-base 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.
|
||||
Links you shorten while signed in show up here, with clicks per day.
|
||||
</p>
|
||||
<Link to="/" className="mt-2">
|
||||
<Button>Create a link</Button>
|
||||
</Link>
|
||||
<Button className="mt-2" onClick={() => navigate("/")}>
|
||||
Shorten a link
|
||||
</Button>
|
||||
</div>
|
||||
) : total === 0 ? (
|
||||
<div className="card grid place-items-center gap-1.5 p-10 text-center">
|
||||
@@ -306,33 +327,37 @@ export function Dashboard() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<motion.div layout className={view === "compact" ? "space-y-2.5" : "space-y-3"}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{items.map((link) =>
|
||||
view === "compact" ? (
|
||||
<UrlRow
|
||||
{/* Rows rise in (staggered) when a page loads, but not while typing a
|
||||
search — animation on every keystroke reads as lag. Deleting
|
||||
collapses the row so the ones below slide up instead of jumping. */}
|
||||
<div>
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((link, i) => {
|
||||
const props = {
|
||||
link,
|
||||
onEdit: setEditTarget,
|
||||
onDelete: deleteLink,
|
||||
onDeleteTap: () => toast("Hold to delete", "info"),
|
||||
onManagePin: setPinTarget,
|
||||
onShowQr: setQrTarget,
|
||||
};
|
||||
return (
|
||||
<motion.div
|
||||
key={link.id}
|
||||
link={link}
|
||||
onEdit={setEditTarget}
|
||||
onDelete={setDeleteTarget}
|
||||
onCopy={() => toast("Copied to clipboard", "success")}
|
||||
onManagePin={setPinTarget}
|
||||
onShowQr={setQrTarget}
|
||||
/>
|
||||
) : (
|
||||
<UrlCard
|
||||
key={link.id}
|
||||
link={link}
|
||||
onEdit={setEditTarget}
|
||||
onDelete={setDeleteTarget}
|
||||
onCopy={() => toast("Copied to clipboard", "success")}
|
||||
onManagePin={setPinTarget}
|
||||
onShowQr={setQrTarget}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</AnimatePresence>
|
||||
exit={{ opacity: 0, height: 0, transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] } }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className={`${view === "compact" ? "pb-2" : "pb-3"} ${query ? "" : "rise"}`}
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
{view === "compact" ? <UrlRow {...props} /> : <UrlCard {...props} />}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<Pagination page={safePage} pageCount={pageCount} onPage={goToPage} />
|
||||
</>
|
||||
@@ -357,17 +382,7 @@ export function Dashboard() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<QrModal
|
||||
link={qrTarget}
|
||||
onClose={() => setQrTarget(null)}
|
||||
onCopied={() => toast("Copied to clipboard", "success")}
|
||||
/>
|
||||
|
||||
<DeleteLinkModal
|
||||
link={deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
<QrModal link={qrTarget} onClose={() => setQrTarget(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+8
-17
@@ -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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
// Compact hero on mobile keeps the input above the fold; roomier on desktop.
|
||||
className="mx-auto w-full max-w-xl px-4 pb-20 pt-8 sm:pb-28 sm:pt-20"
|
||||
>
|
||||
<h1 className="text-center text-[28px] font-semibold leading-[1.1] tracking-tight text-ink sm:text-5xl">
|
||||
Long links,{" "}
|
||||
{/* pb-[0.1em] so descenders aren't clipped by the background-clip box */}
|
||||
<span className="grad-text inline-block pb-[0.1em]">made tiny.</span>
|
||||
<div className="mx-auto w-full max-w-xl px-4 pb-20 pt-10 sm:pb-28 sm:pt-24">
|
||||
<h1 className="text-center text-[30px] font-semibold leading-[1.1] tracking-[-0.03em] text-ink sm:text-[44px]">
|
||||
Shorten a link.
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-center text-[13px] text-muted sm:mt-3 sm:text-[15px]">
|
||||
<p className="mt-2.5 text-center text-[15px] text-muted">
|
||||
{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."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 sm:mt-8">
|
||||
<div className="mt-8 sm:mt-10">
|
||||
<ShortenForm />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-9
@@ -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 (
|
||||
<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: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="card w-full p-6 sm:p-7"
|
||||
>
|
||||
<span className="grad-bg grid h-10 w-10 place-items-center rounded-xl">
|
||||
<div className="card w-full p-6 sm:p-7">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-xl bg-[var(--ink)] text-[var(--bg)]">
|
||||
<Scissors size={18} />
|
||||
</span>
|
||||
|
||||
@@ -81,7 +75,7 @@ export function Login() {
|
||||
<p className="mt-5 text-center text-xs text-muted">
|
||||
You can still create random & memorable links without an account.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: [],
|
||||
|
||||
Reference in new issue
Block a user