feat: first commit

This commit is contained in:
sittichok Ouamsiri
2026-06-15 21:25:57 +07:00
commit 3395ab6dd3
88 changed files with 10034 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
import { AnimatePresence, motion } from "framer-motion";
import { Route, Routes, useLocation } from "react-router-dom";
import { Navbar } from "./components/Navbar";
import { Home } from "./pages/Home";
import { Login } from "./pages/Login";
import { Dashboard } from "./pages/Dashboard";
function Page({ children }: { children: React.ReactNode }) {
return (
<motion.main
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ type: "spring", stiffness: 320, damping: 30 }}
>
{children}
</motion.main>
);
}
export default function App() {
const location = useLocation();
return (
<div className="app-bg grain min-h-screen">
{/* drifting decorative blobs */}
<div
aria-hidden
className="animate-float pointer-events-none fixed left-[6%] top-[22%] -z-[1] h-40 w-40 rounded-full bg-accent opacity-[0.07] blur-3xl"
/>
<div
aria-hidden
className="animate-float pointer-events-none fixed bottom-[12%] right-[8%] -z-[1] h-56 w-56 rounded-full bg-accent opacity-[0.08] blur-3xl"
style={{ animationDelay: "2s" }}
/>
<div className="px-3 sm:px-5">
<Navbar />
</div>
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route
path="/"
element={
<Page>
<Home />
</Page>
}
/>
<Route
path="/login"
element={
<Page>
<Login />
</Page>
}
/>
<Route
path="/dashboard"
element={
<Page>
<Dashboard />
</Page>
}
/>
<Route
path="*"
element={
<Page>
<Home />
</Page>
}
/>
</Routes>
</AnimatePresence>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { motion } from "framer-motion";
import { useState } from "react";
import type { DayCount } from "../lib/types";
import { weekdayLabel } from "../lib/format";
interface Props {
data: DayCount[];
height?: number;
}
export function BarChart({ data, height = 92 }: Props) {
const [hover, setHover] = useState<number | null>(null);
const max = Math.max(1, ...data.map((d) => d.count));
return (
<div className="flex items-end gap-1.5" style={{ height }}>
{data.map((d, i) => {
const ratio = d.count / max;
const isPeak = d.count === max && max > 0;
const active = hover === i;
return (
<div
key={d.date}
className="group relative flex h-full flex-1 flex-col items-center justify-end gap-1.5"
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
>
{/* tooltip */}
<motion.div
initial={false}
animate={{
opacity: active ? 1 : 0,
y: active ? 0 : 6,
scale: active ? 1 : 0.8,
}}
transition={{ type: "spring", stiffness: 520, damping: 22 }}
className="pointer-events-none absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded-lg bg-[var(--ink)] px-2 py-1 text-[11px] font-semibold text-[var(--bg)]"
>
{d.count} clicks
</motion.div>
<div className="flex w-full flex-1 items-end">
<motion.div
initial={{ height: 0 }}
animate={{ height: `${Math.max(ratio * 100, 4)}%` }}
transition={{
type: "spring",
stiffness: 260,
damping: 18,
delay: i * 0.05,
}}
className={`w-full rounded-md ${
isPeak || active
? "bg-accent"
: "bg-[var(--ring)] group-hover:bg-[var(--muted)]"
}`}
style={{ minHeight: 4 }}
/>
</div>
<span className="text-[10px] font-medium text-muted">
{weekdayLabel(d.date)}
</span>
</div>
);
})}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
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 flex items-center gap-3 rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-red-500/12 text-lg">
🗑
</span>
<div className="min-w-0">
<p className="truncate font-mono text-base font-bold text-ink">
{shortUrl(link.code)}
</p>
<p className="truncate text-[12px] text-muted">
{prettyHost(link.longUrl)}
</p>
</div>
</div>
<p className="text-sm leading-relaxed text-muted">
This permanently deletes the link. Anyone who opens{" "}
<span className="font-mono text-ink">{shortUrl(link.code)}</span> will
hit a dead end, and its{" "}
<span className="font-semibold text-ink">
{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}
className="!bg-red-500 !text-white shadow-[0_8px_24px_-8px_rgba(239,68,68,0.6)]"
>
{busy ? "Deleting…" : "Delete link"}
</Button>
<Button variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
</div>
</Modal>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import type { ShortLink } from "../lib/types";
import { prettyHost, shortUrl } from "../lib/format";
import { isValidUrl, normalizeUrl } from "../lib/shortcode";
import { Modal } from "./ui/Modal";
import { Button } from "./ui/Button";
interface Props {
link: ShortLink | null;
onClose: () => void;
onSaved: (updated: ShortLink, message: string) => void;
}
export function EditLinkModal({ link, onClose, onSaved }: Props) {
const open = Boolean(link);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset the form whenever a different link opens the modal.
useEffect(() => {
setDraft(link?.longUrl ?? "");
setError(null);
setBusy(false);
}, [link?.id, link?.longUrl]);
if (!link) return <Modal open={false} onClose={onClose} children={null} />;
const changed = normalizeUrl(draft) !== link.longUrl;
async function save() {
if (!link) return;
if (!isValidUrl(draft)) {
setError("That doesn't look like a valid URL.");
return;
}
setBusy(true);
setError(null);
try {
const updated = await api.updateLink(link.id, {
longUrl: normalizeUrl(draft),
});
onSaved(updated, "Link updated");
} catch (e) {
setError(e instanceof Error ? e.message : "Could not save changes.");
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title="Edit link">
{/* Short code is permanent — shown read-only so existing shares keep working. */}
<div className="-mt-2 mb-4 flex items-center justify-between rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3">
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
Short link
</p>
<p className="truncate font-mono text-base font-bold text-ink">
{shortUrl(link.code)}
</p>
</div>
<span className="shrink-0 rounded-full border-[1.5px] border-line px-2 py-0.5 text-[10px] font-medium text-muted">
can't change
</span>
</div>
<label className="mb-2 block text-sm font-semibold text-ink">
Destination URL
</label>
<div
className={`flex items-center gap-2 rounded-2xl border-[1.5px] bg-surface-2 px-3.5 transition-colors ${
error ? "border-red-400/70" : "border-line"
}`}
>
<span className="text-base opacity-50">🔗</span>
<input
autoFocus
value={draft}
onChange={(e) => {
setDraft(e.target.value);
if (error) setError(null);
}}
onKeyDown={(e) => e.key === "Enter" && save()}
spellCheck={false}
className="h-12 w-full bg-transparent font-mono text-sm text-ink outline-none"
/>
</div>
<p className="mt-2 text-xs text-muted">
Where <span className="font-mono text-ink">{shortUrl(link.code)}</span>{" "}
sends visitors currently {prettyHost(link.longUrl)}.
</p>
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="mt-3 text-sm font-medium text-red-500"
>
{error}
</motion.p>
)}
</AnimatePresence>
<div className="mt-5 flex items-center gap-2">
<Button onClick={save} disabled={busy || !changed}>
{busy ? "Saving…" : "Save changes"}
</Button>
<Button variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
</div>
</Modal>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { motion } from "framer-motion";
import { Link } from "react-router-dom";
import { useTheme } from "../context/ThemeContext";
export function Logo({ onClick }: { onClick?: () => void }) {
const { theme } = useTheme();
return (
<Link
to="/"
onClick={onClick}
className="focusable group inline-flex items-center gap-2.5 rounded-xl"
>
<motion.span
whileHover={{ scale: 1.08 }}
transition={{ type: "spring", stiffness: 420, damping: 12 }}
className="flex items-center"
>
<img
src={theme === "dark" ? "/favicon.svg" : "/favicon_dark.svg"}
alt=""
className="h-7 w-auto"
/>
</motion.span>
<span className="font-display text-[22px] font-bold tracking-tight text-ink">
snip
<span className="text-accent">.</span>
</span>
</Link>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { motion } from "framer-motion";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { Logo } from "./Logo";
import { ThemeToggle } from "./ThemeToggle";
import { Button } from "./ui/Button";
export function Navbar() {
const { user, logout } = useAuth();
const { pathname } = useLocation();
const navigate = useNavigate();
return (
<motion.header
initial={{ y: -64, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 320, damping: 28, delay: 0.05 }}
className="sticky top-0 z-50"
>
<div className="mx-auto mt-3 flex max-w-5xl items-center justify-between gap-3 rounded-2xl border-[1.5px] border-line bg-[var(--surface)]/80 px-3.5 py-2.5 backdrop-blur-xl sm:px-4">
<Logo />
<nav className="flex items-center gap-1.5 sm:gap-2">
<ThemeToggle />
{user ? (
<>
{pathname !== "/dashboard" && (
<Link to="/dashboard" className="hidden sm:block">
<Button variant="ghost" size="sm">
Dashboard
</Button>
</Link>
)}
<div className="flex items-center gap-2 rounded-xl border-[1.5px] border-line bg-surface px-1 py-1 pr-2.5">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-accent text-xs font-bold text-accent-ink">
{user.name.slice(0, 1).toUpperCase()}
</span>
<button
onClick={() => logout()}
className="focusable rounded-md text-xs font-medium text-muted hover:text-ink"
>
Sign out
</button>
</div>
</>
) : (
<Button size="sm" variant="dark" onClick={() => navigate("/login")}>
Sign in
</Button>
)}
</nav>
</div>
</motion.header>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { motion } from "framer-motion";
interface Props {
page: number; // 1-based
pageCount: number;
onPage: (page: number) => void;
}
/** Build a windowed page list with ellipses, e.g. 1 … 4 5 6 … 12 */
function pageItems(page: number, count: number): (number | "…")[] {
if (count <= 7) return Array.from({ length: count }, (_, i) => i + 1);
const items: (number | "…")[] = [1];
const start = Math.max(2, page - 1);
const end = Math.min(count - 1, page + 1);
if (start > 2) items.push("…");
for (let i = start; i <= end; i++) items.push(i);
if (end < count - 1) items.push("…");
items.push(count);
return items;
}
export function Pagination({ page, pageCount, onPage }: Props) {
if (pageCount <= 1) return null;
const items = pageItems(page, pageCount);
return (
<div className="mt-6 flex items-center justify-center gap-1.5">
<button
onClick={() => onPage(page - 1)}
disabled={page === 1}
className="focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-ink transition-opacity hover:bg-surface-2 disabled:opacity-30"
aria-label="Previous page"
>
</button>
{items.map((it, i) =>
it === "…" ? (
<span key={`e${i}`} className="px-1 text-sm text-muted">
</span>
) : (
<button
key={it}
onClick={() => onPage(it)}
aria-current={it === page}
className="focusable relative grid h-9 min-w-9 place-items-center rounded-xl px-2 text-sm font-semibold"
>
{it === page && (
<motion.span
layoutId="page-pill"
transition={{ type: "spring", stiffness: 480, damping: 32 }}
className="absolute inset-0 rounded-xl bg-accent"
/>
)}
<span
className={`relative z-10 ${
it === page ? "text-accent-ink" : "text-muted hover:text-ink"
}`}
>
{it}
</span>
</button>
),
)}
<button
onClick={() => onPage(page + 1)}
disabled={page === pageCount}
className="focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-ink transition-opacity hover:bg-surface-2 disabled:opacity-30"
aria-label="Next page"
>
</button>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { motion } from "framer-motion";
import { useRef } from "react";
interface Props {
value: string;
onChange: (value: string) => void;
length?: number;
}
export function PinInput({ value, onChange, length = 6 }: Props) {
const refs = useRef<(HTMLInputElement | null)[]>([]);
const digits = value.split("");
function setAt(i: number, char: string) {
const next = value.split("");
next[i] = char;
onChange(next.join("").slice(0, length));
}
function handleKey(i: number, e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Backspace" && !digits[i] && i > 0) {
refs.current[i - 1]?.focus();
}
if (e.key === "ArrowLeft" && i > 0) refs.current[i - 1]?.focus();
if (e.key === "ArrowRight" && i < length - 1) refs.current[i + 1]?.focus();
}
function handleChange(i: number, e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value.replace(/\D/g, "");
if (!raw) {
setAt(i, "");
return;
}
if (raw.length > 1) {
// paste
const chars = raw.slice(0, length).split("");
onChange(chars.join(""));
refs.current[Math.min(chars.length, length - 1)]?.focus();
return;
}
setAt(i, raw);
if (i < length - 1) refs.current[i + 1]?.focus();
}
return (
<div className="flex gap-2">
{Array.from({ length }).map((_, i) => (
<motion.input
key={i}
ref={(el) => (refs.current[i] = el)}
value={digits[i] ?? ""}
onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKey(i, e)}
inputMode="numeric"
maxLength={1}
aria-label={`PIN digit ${i + 1}`}
whileFocus={{ scale: 1.08, y: -2 }}
transition={{ type: "spring", stiffness: 500, damping: 16 }}
className="focusable h-12 w-full rounded-xl border-[1.5px] border-line bg-surface text-center font-mono text-lg font-bold text-ink outline-none"
/>
))}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import type { ShortLink } from "../lib/types";
import { shortUrl } from "../lib/format";
import { Modal } from "./ui/Modal";
import { Button } from "./ui/Button";
import { PinInput } from "./PinInput";
interface Props {
link: ShortLink | null;
onClose: () => void;
onSaved: (updated: ShortLink, message: string) => void;
}
export function PinManager({ link, onClose, onSaved }: Props) {
const open = Boolean(link);
const hasPin = Boolean(link?.hasPin);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState<"save" | "remove" | null>(null);
const [error, setError] = useState<string | null>(null);
// Reset transient state whenever a different link opens the modal.
useEffect(() => {
setDraft("");
setError(null);
setBusy(null);
}, [link?.id]);
if (!link) return <Modal open={false} onClose={onClose} children={null} />;
async function save() {
if (!link) return;
if (draft.length !== 6) {
setError("Enter all 6 digits.");
return;
}
setBusy("save");
setError(null);
try {
const updated = await api.setPin(link.id, draft);
onSaved(updated, hasPin ? "PIN updated" : "PIN protection enabled");
} catch (e) {
setError(e instanceof Error ? e.message : "Could not save PIN.");
setBusy(null);
}
}
async function remove() {
if (!link) return;
setBusy("remove");
try {
const updated = await api.setPin(link.id, null);
onSaved(updated, "PIN protection removed");
} catch (e) {
setError(e instanceof Error ? e.message : "Could not remove PIN.");
setBusy(null);
}
}
return (
<Modal open={open} onClose={onClose} title="PIN protection">
<p className="-mt-2 mb-4 text-sm text-muted">
<span className="font-mono text-ink">{shortUrl(link.code)}</span> {" "}
{hasPin
? "visitors must enter this PIN before being redirected."
: "add a 6-digit PIN that visitors enter before redirect."}
</p>
{/* Current state. The server stores PINs hashed, so an existing value can
never be shown — only replaced or removed. */}
{hasPin && (
<div className="mb-4 flex items-center gap-3 rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-accent text-base text-accent-ink">
🔒
</span>
<div>
<p className="text-sm font-semibold text-ink">PIN is active</p>
<p className="text-[12px] text-muted">
Stored encrypted set a new one below to change it.
</p>
</div>
</div>
)}
<label className="mb-2 block text-sm font-semibold text-ink">
{hasPin ? "Set a new PIN" : "Choose a PIN"}
</label>
<PinInput value={draft} onChange={setDraft} />
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="mt-3 text-sm font-medium text-red-500"
>
{error}
</motion.p>
)}
</AnimatePresence>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Button onClick={save} disabled={busy !== null || draft.length !== 6}>
{busy === "save" ? "Saving…" : hasPin ? "Update PIN" : "Enable PIN"}
</Button>
<Button variant="ghost" onClick={onClose} disabled={busy !== null}>
Cancel
</Button>
{hasPin && (
<Button
variant="ghost"
onClick={remove}
disabled={busy !== null}
className="ml-auto text-muted hover:!text-red-500"
>
{busy === "remove" ? "Removing…" : "Remove PIN"}
</Button>
)}
</div>
</Modal>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useRef, useState } 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";
interface Props {
link: ShortLink | null;
onClose: () => void;
onCopied: () => void;
}
export function QrModal({ link, onClose, onCopied }: 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");
if (!svg) return;
const data = new XMLSerializer().serializeToString(svg);
const blob = new Blob([data], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `snip-${link.code}.svg`;
a.click();
URL.revokeObjectURL(url);
}
return (
<Modal open={open} onClose={onClose} title="QR code">
<div className="flex flex-col items-center">
<div
ref={wrapRef}
className="rounded-3xl border-[1.5px] border-line bg-white p-5 shadow-[0_16px_40px_-20px_rgba(0,0,0,0.5)]"
>
<QRCodeSVG
value={fullShortUrl(link.code)}
size={188}
bgColor="#ffffff"
fgColor="#16170f"
level="M"
/>
</div>
<p className="mt-4 font-mono text-lg font-bold text-ink">
{shortUrl(link.code)}
</p>
<p className="text-[13px] text-muted">
Points to {prettyHost(link.longUrl)}
</p>
<div className="mt-5 flex w-full items-center gap-2">
<Button block onClick={copy}>
{copied ? "Copied ✓" : "Copy link"}
</Button>
<Button block variant="outline" onClick={download}>
Download SVG
</Button>
</div>
</div>
</Modal>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { motion } from "framer-motion";
import { QRCodeSVG } from "qrcode.react";
import { useState } from "react";
import type { ShortLink } from "../lib/types";
import { fullShortUrl, prettyHost, shortUrl } from "../lib/format";
import { useTheme } from "../context/ThemeContext";
import { Button } from "./ui/Button";
interface Props {
link: ShortLink;
onReset: () => void;
onCopied: () => void;
}
const modeBadge: Record<string, string> = {
random: "Random code",
memorable: "Memorable words",
custom: "Custom alias",
};
export function ResultCard({ link, onReset, onCopied }: Props) {
const { theme } = useTheme();
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);
}
return (
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -16, scale: 0.96 }}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
className="card relative overflow-hidden p-5 sm:p-7"
>
{/* confetti-ish accent corner */}
<div className="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full bg-accent opacity-20 blur-2xl" />
<div className="mb-4 flex items-center gap-2">
<motion.span
initial={{ scale: 0, rotate: -40 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 12, delay: 0.1 }}
className="grid h-7 w-7 place-items-center rounded-full bg-accent text-sm font-bold text-accent-ink"
>
</motion.span>
<span className="text-sm font-semibold text-ink">Your link is live</span>
<span className="ml-auto rounded-full border-[1.5px] border-line px-2.5 py-0.5 text-[11px] font-medium text-muted">
{modeBadge[link.mode]}
</span>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-stretch">
<div className="flex min-w-0 flex-1 flex-col justify-between gap-3 rounded-2xl border-[1.5px] border-line bg-surface-2 p-4">
<div className="min-w-0">
<p className="truncate text-[13px] text-muted">
{prettyHost(link.longUrl)}
</p>
<p className="mt-1 break-all font-mono text-xl font-bold text-ink sm:text-2xl">
{shortUrl(link.code)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={copy} size="sm" className="min-w-[104px]">
<motion.span
key={copied ? "y" : "n"}
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 16 }}
>
{copied ? "Copied ✓" : "Copy link"}
</motion.span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowQr((v) => !v)}
>
{showQr ? "Hide QR" : "QR code"}
</Button>
{link.hasPin && (
<span className="inline-flex items-center gap-1 rounded-lg bg-[var(--surface)] px-2 py-1 text-[11px] font-medium text-muted">
🔒 PIN protected
</span>
)}
</div>
</div>
{showQr && (
<motion.div
initial={{ opacity: 0, scale: 0.7, rotate: -6 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 18 }}
className="grid place-items-center rounded-2xl border-[1.5px] border-line bg-white p-3"
>
<QRCodeSVG
value={fullShortUrl(link.code)}
size={120}
bgColor="#ffffff"
fgColor={theme === "dark" ? "#16170f" : "#16170f"}
level="M"
/>
</motion.div>
)}
</div>
<button
onClick={onReset}
className="focusable mt-4 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-muted hover:text-ink"
>
<span className="text-base"></span> Shorten another link
</button>
</motion.div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import type { ReactNode } from "react";
export interface Segment<T extends string> {
value: T;
label: string;
icon?: ReactNode;
hint?: string;
locked?: boolean;
}
interface Props<T extends string> {
segments: Segment<T>[];
value: T;
onChange: (value: T) => void;
layoutId?: string;
}
export function SegmentedControl<T extends string>({
segments,
value,
onChange,
}: Props<T>) {
return (
<div className="flex flex-col gap-1.5 rounded-2xl border-[1.5px] border-line bg-surface-2 p-1.5 sm:flex-row">
{segments.map((seg) => {
const active = seg.value === value;
return (
<button
key={seg.value}
type="button"
onClick={() => onChange(seg.value)}
className={`focusable relative flex-1 rounded-xl px-3 py-2.5 text-left transition-all duration-200 ${
active
? "bg-[var(--surface)] shadow-[0_4px_14px_-6px_rgba(0,0,0,0.3)] ring-[1.5px] ring-[var(--ring)]"
: ""
}`}
>
<span className="relative z-10 flex items-center gap-2">
<span
className={`text-base leading-none transition-transform ${
active ? "scale-110" : "opacity-60"
}`}
>
{seg.icon}
</span>
<span className="min-w-0">
<span
className={`flex items-center gap-1.5 text-sm font-semibold ${
active ? "text-ink" : "text-muted"
}`}
>
{seg.label}
{seg.locked && (
<span className="text-[10px] opacity-70">🔒</span>
)}
</span>
{seg.hint && (
<span className="block truncate text-[11px] text-muted">
{seg.hint}
</span>
)}
</span>
</span>
</button>
);
})}
</div>
);
}
+332
View File
@@ -0,0 +1,332 @@
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { api } from "../lib/api";
import {
isValidUrl,
normalizeUrl,
validateCustomAlias,
} from "../lib/shortcode";
import { 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";
const spring = { type: "spring", stiffness: 300, damping: 26 } as const;
export function ShortenForm() {
const { user } = useAuth();
const toast = useToast();
const navigate = useNavigate();
const [url, setUrl] = useState("");
const [mode, setMode] = useState<UrlMode>("random");
const [alias, setAlias] = useState("");
const [pinOn, setPinOn] = useState(false);
const [pin, setPin] = useState("");
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);
function showError(msg: string, input: "url" | "alias" | null = null) {
setError(msg);
setErrorInput(input);
}
function clearError() {
setError(null);
setErrorInput(null);
}
const segments: Segment<UrlMode>[] = [
{ value: "random", label: "Random", icon: "🎲", hint: "Shortest code" },
{
value: "memorable",
label: "Memorable",
icon: "🌿",
hint: "Three easy words",
},
{
value: "custom",
label: "Custom",
icon: "✏️",
hint: user ? "You choose it" : "Sign in to use",
locked: !user,
},
];
function onMode(value: UrlMode) {
if (value === "custom" && !user) {
toast("Sign in to create custom links", "info");
navigate("/login");
return;
}
clearError();
setMode(value);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
clearError();
if (!url.trim()) {
showError("Paste a link to shorten.", "url");
return;
}
if (!isValidUrl(url)) {
showError("Hmm, that doesn't look like a valid URL.", "url");
return;
}
if (mode === "custom") {
const aliasErr = validateCustomAlias(alias);
if (aliasErr) {
showError(aliasErr, "alias");
return;
}
}
if (pinOn && pin.length !== 6) {
showError("Your PIN needs all 6 digits.");
return;
}
setBusy(true);
try {
console.log("[snip] createLink start", { mode });
const link = await api.createLink({
longUrl: normalizeUrl(url),
mode,
customAlias: mode === "custom" ? alias : undefined,
pin: pinOn && user ? pin : undefined,
});
console.log("[snip] createLink ok", link.code);
setResult(link);
toast("Link created — ready to share", "success");
} catch (err) {
console.log("[snip] createLink error", err);
const msg = err instanceof Error ? err.message : "Something went wrong.";
showError(msg, mode === "custom" ? "alias" : null);
} finally {
setBusy(false);
}
}
function reset() {
setResult(null);
setUrl("");
setAlias("");
setPin("");
setPinOn(false);
setMode("random");
clearError();
}
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: 18 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16, scale: 0.98 }}
transition={spring}
className="card p-4 sm:p-6"
>
{/* URL field */}
<label className="mb-2 block text-sm font-semibold text-ink">
Long URL
</label>
<div
className={`flex items-center gap-2 rounded-2xl border-[1.5px] bg-surface-2 px-3.5 transition-colors ${
errorInput === "url" ? "border-red-400/70" : "border-line"
}`}
>
<span className="text-lg opacity-50">🔗</span>
<input
value={url}
onChange={(e) => {
setUrl(e.target.value);
if (errorInput === "url") clearError();
}}
placeholder="paste a long link, e.g. acme.com/spring/launch…"
autoComplete="off"
spellCheck={false}
className="h-14 w-full bg-transparent font-mono text-[15px] text-ink outline-none placeholder:font-sans placeholder:text-muted"
/>
</div>
{/* Mode selector */}
<div className="mt-5">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-ink">
Link style
</span>
<span className="text-xs text-muted">
{getShortDomain()}/
<span className="text-accent">
{mode === "random"
? "x7Qk"
: mode === "memorable"
? "amber-otter-loop"
: alias || "your-name"}
</span>
</span>
</div>
<SegmentedControl
segments={segments}
value={mode}
onChange={onMode}
layoutId="mode-pill"
/>
</div>
{/* Custom alias */}
<AnimatePresence initial={false}>
{mode === "custom" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={spring}
className="overflow-hidden"
>
<div className={`mt-4 flex items-center gap-0 overflow-hidden rounded-2xl border-[1.5px] bg-surface-2 transition-colors ${errorInput === "alias" ? "border-red-400/70" : "border-line"}`}>
<span className="select-none px-3.5 font-mono text-sm text-muted">
{getShortDomain()}/
</span>
<input
value={alias}
onChange={(e) => {
setAlias(
e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""),
);
if (errorInput === "alias") clearError();
}}
placeholder="spring-launch"
className="h-12 w-full bg-transparent pr-3.5 font-mono text-[15px] text-ink outline-none placeholder:text-muted"
/>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Authenticated extras: PIN */}
<div className="mt-5 rounded-2xl border-[1.5px] border-dashed border-line bg-surface-2/50 p-3.5">
{user ? (
<>
<button
type="button"
onClick={() => setPinOn((v) => !v)}
className="focusable flex w-full items-center gap-3 rounded-lg text-left"
>
<span
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
pinOn ? "bg-accent" : "bg-[var(--ring)]"
}`}
>
<motion.span
layout
transition={{
type: "spring",
stiffness: 600,
damping: 30,
}}
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow ${
pinOn ? "right-0.5" : "left-0.5"
}`}
/>
</span>
<span>
<span className="block text-sm font-semibold text-ink">
Protect with a 6-digit PIN
</span>
<span className="block text-xs text-muted">
Visitors enter it before they're redirected
</span>
</span>
</button>
<AnimatePresence initial={false}>
{pinOn && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={spring}
className="overflow-hidden"
>
<div className="pt-3.5">
<PinInput value={pin} onChange={setPin} />
</div>
</motion.div>
)}
</AnimatePresence>
</>
) : (
<div className="flex items-center gap-3">
<span className="text-lg opacity-60">🔒</span>
<p className="text-sm text-muted">
<button
type="button"
onClick={() => navigate("/login")}
className="focusable rounded font-semibold text-ink underline decoration-accent decoration-2 underline-offset-2"
>
Sign in
</button>{" "}
to add a custom alias and PIN protection.
</p>
</div>
)}
</div>
{/* Error */}
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, x: -6 }}
animate={{
opacity: 1,
x: [0, -6, 6, -4, 4, 0],
}}
exit={{ opacity: 0 }}
transition={{ duration: 0.4 }}
className="mt-3 text-sm font-medium text-red-500"
>
{error}
</motion.p>
)}
</AnimatePresence>
{/* Submit */}
<Button
type="submit"
size="lg"
block
disabled={busy}
className="mt-5"
>
{busy ? (
<span className="inline-block h-5 w-5 animate-spin rounded-full border-[2.5px] border-current border-t-transparent" />
) : (
<>
Shorten it <span className="text-lg"></span>
</>
)}
</Button>
</motion.form>
)}
</AnimatePresence>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { motion } from "framer-motion";
import type { DayCount } from "../lib/types";
interface Props {
data: DayCount[];
width?: number;
height?: number;
}
export function Sparkline({ data, width = 84, height = 30 }: Props) {
const max = Math.max(1, ...data.map((d) => d.count));
const n = data.length;
const stepX = n > 1 ? width / (n - 1) : width;
const pad = 3;
const usable = height - pad * 2;
const pts = data.map((d, i) => {
const x = i * stepX;
const y = pad + usable - (d.count / max) * usable;
return [x, y] as const;
});
const line = pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
const area = `0,${height} ${line} ${width},${height}`;
const last = pts[pts.length - 1];
return (
<svg width={width} height={height} className="overflow-visible">
<polygon points={area} fill="var(--accent)" opacity={0.16} />
<motion.polyline
points={line}
fill="none"
stroke="var(--accent)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 120, damping: 20 }}
/>
{last && (
<motion.circle
cx={last[0]}
cy={last[1]}
r={2.6}
fill="var(--accent)"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.3, type: "spring", stiffness: 500, damping: 14 }}
/>
)}
</svg>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { motion } from "framer-motion";
import { useTheme } from "../context/ThemeContext";
export function ThemeToggle() {
const { theme, toggle } = useTheme();
const dark = theme === "dark";
return (
<motion.button
onClick={toggle}
whileTap={{ scale: 0.9 }}
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
className="focusable relative grid h-10 w-10 place-items-center overflow-hidden rounded-xl border-[1.5px] border-line bg-surface"
>
<motion.span
key={theme}
initial={{ y: 18, rotate: -90, opacity: 0, scale: 0.5 }}
animate={{ y: 0, rotate: 0, opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 14 }}
className="text-[17px]"
>
{dark ? "🌙" : "☀️"}
</motion.span>
</motion.button>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { motion } from "framer-motion";
import type { ShortLink } from "../lib/types";
import {
compactNumber,
fullShortUrl,
prettyHost,
relativeTime,
shortUrl,
} from "../lib/format";
import { BarChart } from "./BarChart";
import { Button } from "./ui/Button";
interface Props {
link: ShortLink;
onEdit: (link: ShortLink) => void;
onDelete: (link: ShortLink) => void;
onCopy: () => void;
onManagePin: (link: ShortLink) => void;
onShowQr: (link: ShortLink) => void;
}
const modeLabel: Record<string, string> = {
random: "random",
memorable: "memorable",
custom: "custom",
};
export function UrlCard({
link,
onEdit,
onDelete,
onCopy,
onManagePin,
onShowQr,
}: Props) {
const weekTotal = link.last7Days.reduce((s, d) => s + d.count, 0);
async function copy() {
try {
await navigator.clipboard.writeText(fullShortUrl(link.code));
} catch {
/* ignore */
}
onCopy();
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 24, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
transition={{ type: "spring", stiffness: 280, damping: 26 }}
className="card flex flex-col gap-4 p-5 sm:flex-row sm:items-stretch sm:gap-6"
>
{/* Left: identity + actions */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-accent-ink">
{modeLabel[link.mode]}
</span>
{link.hasPin && (
<span className="rounded-full border-[1.5px] border-line px-2 py-0.5 text-[10px] font-medium text-muted">
🔒 PIN
</span>
)}
<span className="ml-auto text-[11px] text-muted">
{relativeTime(link.createdAt)}
</span>
</div>
<a
href={fullShortUrl(link.code)}
onClick={(e) => e.preventDefault()}
className="focusable mt-2 inline-block rounded font-mono text-lg font-bold text-ink hover:text-accent"
>
{shortUrl(link.code)}
</a>
<p
className="mt-1 truncate text-[13px] text-muted"
title={link.longUrl}
>
{prettyHost(link.longUrl)}
<span className="opacity-60">{new URL(link.longUrl).pathname}</span>
</p>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-4">
<Button size="sm" variant="outline" onClick={copy}>
Copy
</Button>
<Button size="sm" variant="ghost" onClick={() => onShowQr(link)}>
QR
</Button>
<Button size="sm" variant="ghost" onClick={() => onEdit(link)}>
Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onManagePin(link)}
>
{link.hasPin ? "🔒 PIN" : "Add PIN"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(link)}
className="ml-auto text-muted hover:!text-red-500"
>
Delete
</Button>
</div>
</div>
{/* Right: stats */}
<div className="flex w-full flex-col rounded-2xl border-[1.5px] border-line bg-surface-2 p-4 sm:w-72">
<div className="mb-3 flex items-end justify-between">
<div>
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
Total clicks
</p>
<p className="font-display text-2xl font-bold text-ink">
{compactNumber(link.totalClicks)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
Last 7 days
</p>
<p className="font-display text-lg font-bold text-accent-ink">
<span className="rounded-md bg-accent px-1.5">
+{compactNumber(weekTotal)}
</span>
</p>
</div>
</div>
<BarChart data={link.last7Days} />
</div>
</motion.div>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { motion } from "framer-motion";
import type { ShortLink } from "../lib/types";
import {
compactNumber,
fullShortUrl,
prettyHost,
shortUrl,
} from "../lib/format";
import { Sparkline } from "./Sparkline";
interface Props {
link: ShortLink;
onEdit: (link: ShortLink) => void;
onDelete: (link: ShortLink) => void;
onCopy: () => void;
onManagePin: (link: ShortLink) => void;
onShowQr: (link: ShortLink) => void;
}
function QrGlyph() {
return (
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="currentColor"
aria-hidden
>
<path d="M1 1h5v5H1V1zm1 1v3h3V2H2z" />
<path d="M9 1h5v5H9V1zm1 1v3h3V2h-3z" />
<path d="M1 9h5v5H1V9zm1 1v3h3v-3H2z" />
<rect x="9" y="9" width="2" height="2" />
<rect x="12" y="9" width="2" height="2" />
<rect x="9" y="12" width="2" height="2" />
<rect x="12" y="12" width="2" height="2" />
</svg>
);
}
function IconButton({
label,
onClick,
danger,
children,
}: {
label: string;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<motion.button
whileTap={{ scale: 0.88 }}
whileHover={{ y: -2 }}
transition={{ type: "spring", stiffness: 500, damping: 16 }}
onClick={onClick}
title={label}
aria-label={label}
className={`focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-sm ${
danger ? "hover:!border-red-400 hover:text-red-500" : "hover:bg-surface-2"
}`}
>
{children}
</motion.button>
);
}
export function UrlRow({
link,
onEdit,
onDelete,
onCopy,
onManagePin,
onShowQr,
}: Props) {
const weekTotal = link.last7Days.reduce((s, d) => s + d.count, 0);
async function copy() {
try {
await navigator.clipboard.writeText(fullShortUrl(link.code));
} catch {
/* ignore */
}
onCopy();
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
className="card flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
>
{/* identity */}
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="hidden h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent text-xs font-bold text-accent-ink sm:grid">
{link.mode === "custom" ? "✏️" : link.mode === "memorable" ? "🌿" : "🎲"}
</span>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<a
href={fullShortUrl(link.code)}
onClick={(e) => e.preventDefault()}
className="focusable truncate rounded font-mono text-[15px] font-bold text-ink hover:text-accent"
>
{shortUrl(link.code)}
</a>
{link.hasPin && <span className="text-[11px]">🔒</span>}
</div>
<p className="truncate text-[12px] text-muted" title={link.longUrl}>
{prettyHost(link.longUrl)}
</p>
</div>
</div>
{/* stats */}
<div className="flex shrink-0 items-center gap-3">
<div className="text-right">
<p className="font-display text-base font-bold leading-none text-ink">
{compactNumber(link.totalClicks)}
</p>
<p className="text-[10px] font-medium uppercase tracking-wide text-muted">
clicks
</p>
</div>
<span className="hidden items-center rounded-md bg-accent/15 px-1 text-[11px] font-bold text-accent-ink sm:inline-flex">
+{compactNumber(weekTotal)}
</span>
<div className="hidden sm:block">
<Sparkline data={link.last7Days} />
</div>
</div>
{/* actions */}
<div className="flex shrink-0 items-center gap-1.5">
<IconButton label="Copy link" onClick={copy}>
</IconButton>
<IconButton label="Show QR code" onClick={() => onShowQr(link)}>
<QrGlyph />
</IconButton>
<IconButton label="Manage PIN" onClick={() => onManagePin(link)}>
🔒
</IconButton>
<IconButton label="Edit destination" onClick={() => onEdit(link)}>
</IconButton>
<IconButton label="Delete link" danger onClick={() => onDelete(link)}>
🗑
</IconButton>
</div>
</motion.div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { motion, type HTMLMotionProps } from "framer-motion";
import { forwardRef, type ReactNode } from "react";
type Variant = "primary" | "ghost" | "outline" | "dark";
type Size = "sm" | "md" | "lg";
interface Props extends Omit<HTMLMotionProps<"button">, "children"> {
variant?: Variant;
size?: Size;
children: ReactNode;
block?: boolean;
}
const sizes: Record<Size, string> = {
sm: "h-9 px-3.5 text-sm gap-1.5",
md: "h-11 px-5 text-[15px] gap-2",
lg: "h-14 px-7 text-base gap-2.5",
};
const variants: Record<Variant, string> = {
primary:
"bg-accent text-accent-ink font-semibold shadow-[0_8px_24px_-8px_var(--glow)] hover:brightness-105",
dark: "bg-[var(--ink)] text-[var(--bg)] font-semibold hover:opacity-90",
outline:
"bg-transparent text-ink font-medium border-[1.5px] border-line hover:bg-surface-2",
ghost: "bg-transparent text-ink font-medium hover:bg-surface-2",
};
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
{ variant = "primary", size = "md", block, className = "", children, ...rest },
ref,
) {
return (
<motion.button
ref={ref}
whileTap={{ scale: 0.94 }}
whileHover={{ y: -2 }}
transition={{ type: "spring", stiffness: 520, damping: 16 }}
className={`focusable inline-flex select-none items-center justify-center rounded-2xl ${
sizes[size]
} ${variants[variant]} ${block ? "w-full" : ""} ${className}`}
{...rest}
>
{children}
</motion.button>
);
});
+56
View File
@@ -0,0 +1,56 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, type ReactNode } from "react";
interface Props {
open: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
}
export function Modal({ open, onClose, title, children }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-[150] grid place-items-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="absolute inset-0 bg-black/45 backdrop-blur-sm"
onClick={onClose}
/>
<motion.div
role="dialog"
aria-modal="true"
initial={{ opacity: 0, y: 28, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.95 }}
transition={{ type: "spring", stiffness: 360, damping: 26 }}
className="card relative z-10 w-full max-w-md p-6 shadow-[0_30px_80px_-30px_rgba(0,0,0,0.7)]"
>
{title && (
<h2 className="mb-4 font-display text-xl font-bold tracking-tight text-ink">
{title}
</h2>
)}
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { AnimatePresence, motion } from "framer-motion";
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
type ToastKind = "success" | "error" | "info";
interface Toast {
id: number;
message: string;
kind: ToastKind;
}
const Ctx = createContext<(message: string, kind?: ToastKind) => void>(
() => {},
);
let counter = 0;
const icons: Record<ToastKind, ReactNode> = {
success: "✓",
error: "✕",
info: "→",
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const push = useCallback((message: string, kind: ToastKind = "success") => {
const id = ++counter;
setToasts((t) => [...t, { id, message, kind }]);
setTimeout(() => {
setToasts((t) => t.filter((x) => x.id !== id));
}, 2800);
}, []);
return (
<Ctx.Provider value={push}>
{children}
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-[200] flex flex-col items-center gap-2 px-4">
<AnimatePresence>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 28, scale: 0.85 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.85, y: 10 }}
transition={{ type: "spring", stiffness: 480, damping: 24 }}
className="card pointer-events-auto flex items-center gap-2.5 rounded-full px-4 py-2.5 shadow-[0_16px_40px_-18px_rgba(0,0,0,0.6)]"
>
<span
className={`grid h-5 w-5 place-items-center rounded-full text-[11px] font-bold ${
t.kind === "error"
? "bg-red-500/15 text-red-500"
: t.kind === "info"
? "bg-[var(--surface-2)] text-ink"
: "bg-accent text-accent-ink"
}`}
>
{icons[t.kind]}
</span>
<span className="text-sm font-medium text-ink">{t.message}</span>
</motion.div>
))}
</AnimatePresence>
</div>
</Ctx.Provider>
);
}
export function useToast() {
return useContext(Ctx);
}
+62
View File
@@ -0,0 +1,62 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { api } from "../lib/api";
import type { User } from "../lib/types";
interface AuthCtx {
user: User | null;
loading: boolean;
/** Re-fetch the session (e.g. after returning from an OAuth redirect). */
refresh: () => Promise<void>;
logout: () => Promise<void>;
}
const Ctx = createContext<AuthCtx | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
const u = await api.me();
setUser(u);
}, []);
useEffect(() => {
let alive = true;
api
.me()
.then((u) => {
if (alive) setUser(u);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, []);
const logout = useCallback(async () => {
await api.logout();
setUser(null);
}, []);
return (
<Ctx.Provider value={{ user, loading, refresh, logout }}>
{children}
</Ctx.Provider>
);
}
export function useAuth() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+48
View File
@@ -0,0 +1,48 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
type Theme = "light" | "dark";
interface ThemeCtx {
theme: Theme;
toggle: () => void;
}
const Ctx = createContext<ThemeCtx | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() =>
document.documentElement.classList.contains("dark") ? "dark" : "light",
);
useEffect(() => {
const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
try {
localStorage.setItem("snip-theme", theme);
} catch {
/* ignore */
}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", theme === "dark" ? "#0e100a" : "#f4f2ea");
}, [theme]);
const toggle = useCallback(
() => setTheme((t) => (t === "dark" ? "light" : "dark")),
[],
);
return <Ctx.Provider value={{ theme, toggle }}>{children}</Ctx.Provider>;
}
export function useTheme() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
+133
View File
@@ -0,0 +1,133 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Light theme — warm paper + ink */
--bg: #f4f2ea;
--bg-grain: #ece9dd;
--surface: #fffdf7;
--surface-2: #f6f3e9;
--ink: #16170f;
--ink-solid: #16170f;
--muted: #6b6c5e;
--line: #e2decf;
--ring: #16170f;
--accent: #c6f24e;
--accent-ink: #16170f;
--glow: rgba(198, 242, 78, 0.45);
--dot: rgba(22, 23, 15, 0.06);
}
.dark {
/* Dark theme — deep moss + lime */
--bg: #0e100a;
--bg-grain: #14160e;
--surface: #181b11;
--surface-2: #1f2317;
--ink: #f1efe3;
--ink-solid: #000000;
--muted: #9b9d8a;
--line: #2c3020;
--ring: #3a3f2a;
--accent: #c6f24e;
--accent-ink: #16170f;
--glow: rgba(198, 242, 78, 0.22);
--dot: rgba(241, 239, 227, 0.05);
}
* {
-webkit-tap-highlight-color: transparent;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background-color: var(--bg);
color: var(--ink);
font-family: "General Sans", ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
transition: background-color 0.5s ease, color 0.4s ease;
}
/* Atmospheric layered background: dotted grid + soft lime glows */
.app-bg::before {
content: "";
position: fixed;
inset: 0;
z-index: -2;
background-image: radial-gradient(var(--dot) 1.4px, transparent 1.4px);
background-size: 26px 26px;
background-position: -13px -13px;
pointer-events: none;
}
.app-bg::after {
content: "";
position: fixed;
inset: 0;
z-index: -3;
background:
radial-gradient(60% 50% at 12% 8%, var(--glow), transparent 60%),
radial-gradient(50% 40% at 92% 22%, var(--glow), transparent 60%),
radial-gradient(70% 60% at 78% 96%, var(--glow), transparent 65%);
filter: blur(8px);
opacity: 0.9;
pointer-events: none;
transition: opacity 0.5s ease;
}
/* Use CSS vars through Tailwind-friendly utility classes */
.bg-surface { background-color: var(--surface); }
.bg-surface-2 { background-color: var(--surface-2); }
.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); }
.ring-ink { --tw-ring-color: var(--ring); }
.card {
background-color: var(--surface);
border: 1.5px solid var(--line);
border-radius: 1.5rem;
}
/* Selection */
::selection {
background: var(--accent);
color: var(--accent-ink);
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: 99px;
border: 3px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* Focus ring */
.focusable:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--bg), 0 0 0 5.5px var(--accent);
}
/* Grain overlay for texture */
.grain::before {
content: "";
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Real backend client. Talks to the Go service at `/api/v1`.
*
* In production the SPA is served same-origin behind the gateway, so the
* default base of "" works. For cross-origin dev set VITE_API_BASE.
*
* The method surface mirrors the old mock so swapping was a one-import change —
* except auth, which is now a real OAuth/OIDC redirect (no password login), and
* PIN reveal, which the server intentionally cannot do (PINs are hashed).
*/
import type {
CreateLinkInput,
LinkPage,
OwnerStats,
ShortLink,
User,
} from "./types";
const BASE = import.meta.env.VITE_API_BASE ?? "";
const ROOT = `${BASE}/api/v1`;
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(ROOT + path, {
credentials: "include",
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
...init,
});
if (!res.ok) {
let message = res.statusText;
try {
const body = await res.json();
if (body?.error) message = body.error;
} catch {
/* non-json error */
}
throw new ApiError(message, res.status);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
// --- auth ---
async me(): Promise<User | null> {
try {
return await req<User>("/auth/me");
} catch (e) {
if (e instanceof ApiError && e.status === 401) return null;
throw e;
}
},
async providers(): Promise<string[]> {
try {
const r = await req<{ providers: string[] }>("/auth/providers");
return r.providers ?? [];
} catch {
return [];
}
},
/** Full-page navigation target that starts the OAuth/OIDC dance. */
loginUrl(provider: string): string {
return `${ROOT}/auth/${provider}/login`;
},
async logout(): Promise<void> {
await req<void>("/auth/logout", { method: "POST" });
},
/** Non-secret server settings (e.g. the short-link domain). */
async config(): Promise<{ shortDomain: string }> {
return req<{ shortDomain: string }>("/config");
},
// --- links ---
/** One page of the caller's links, optionally filtered by `q` — paginated and
* searched server-side so the client never downloads every link. */
async listLinks(params: {
page: number;
pageSize: number;
q?: string;
}): Promise<LinkPage> {
const sp = new URLSearchParams({
page: String(params.page),
pageSize: String(params.pageSize),
});
if (params.q) sp.set("q", params.q);
return req<LinkPage>(`/links?${sp.toString()}`);
},
/** Aggregate dashboard tiles, computed in the DB. */
async stats(): Promise<OwnerStats> {
return req<OwnerStats>("/links/stats");
},
async createLink(input: CreateLinkInput): Promise<ShortLink> {
return req<ShortLink>("/links", {
method: "POST",
body: JSON.stringify({
longUrl: input.longUrl,
mode: input.mode,
customAlias: input.customAlias,
pin: input.pin,
}),
});
},
async updateLink(
id: string,
patch: { longUrl: string },
): Promise<ShortLink> {
return req<ShortLink>(`/links/${id}`, {
method: "PATCH",
body: JSON.stringify({ longUrl: patch.longUrl }),
});
},
/** Set or replace a PIN; pass null to remove it. */
async setPin(id: string, pin: string | null): Promise<ShortLink> {
if (pin === null) {
return req<ShortLink>(`/links/${id}/pin`, { method: "DELETE" });
}
return req<ShortLink>(`/links/${id}/pin`, {
method: "PUT",
body: JSON.stringify({ pin }),
});
},
async deleteLink(id: string): Promise<void> {
await req<void>(`/links/${id}`, { method: "DELETE" });
},
};
+57
View File
@@ -0,0 +1,57 @@
// The short-link domain is configurable, not hardcoded. It defaults to the
// build-time env var and can be overridden at runtime from GET /api/v1/config
// (see setShortDomain, called on boot in main.tsx).
let shortDomain = import.meta.env.VITE_SHORT_DOMAIN || "snip.to";
export function getShortDomain(): string {
return shortDomain;
}
export function setShortDomain(domain: string): void {
if (domain) shortDomain = domain;
}
export function shortUrl(code: string): string {
return `${shortDomain}/${code}`;
}
export function fullShortUrl(code: string): string {
return `https://${shortUrl(code)}`;
}
export function compactNumber(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
return `${(n / 1_000_000).toFixed(1)}M`;
}
export function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
const diff = Date.now() - then;
const sec = Math.round(diff / 1000);
const min = Math.round(sec / 60);
const hr = Math.round(min / 60);
const day = Math.round(hr / 24);
if (sec < 45) return "just now";
if (min < 60) return `${min}m ago`;
if (hr < 24) return `${hr}h ago`;
if (day < 30) return `${day}d ago`;
return new Date(iso).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
export function weekdayLabel(iso: string): string {
return new Date(iso + "T00:00:00").toLocaleDateString(undefined, {
weekday: "short",
})[0];
}
export function prettyHost(url: string): string {
try {
return new URL(url).host.replace(/^www\./, "");
} catch {
return url;
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Short-code generation.
*
* Strategy (mirrors what the Go backend will do):
* - "random" → base62 encoding of a monotonically increasing counter. This
* yields the SHORTEST possible string that is still globally
* unique (no collision checks, no wasted length). A counter of
* N needs ceil(log62(N)) chars: 62^4 ≈ 14.7M, 62^5 ≈ 916M.
* To avoid leaking sequence/volume, the counter is passed
* through a reversible bit-scramble before encoding.
* - "memorable" → 3 short, easy words joined by hyphens.
* - "custom" → user supplied (validated, authenticated only).
*/
const BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
export function encodeBase62(num: number): string {
if (num === 0) return BASE62[0];
let n = num;
let out = "";
while (n > 0) {
out = BASE62[n % 62] + out;
n = Math.floor(n / 62);
}
return out;
}
/**
* Reversible scramble so sequential counter values don't produce sequential
* codes. Knuth multiplicative hash over a 31-bit space with a coprime
* multiplier — keeps codes short while looking random.
*/
function scramble(id: number): number {
const PRIME = 2654435761; // 2^32 golden-ratio prime
const MOD = 0x7fffffff; // 2^31 - 1
return ((id + 1) * PRIME) % MOD;
}
export function codeFromCounter(counter: number): string {
// Bump the base so even the first link gets a pleasant 4-char code.
return encodeBase62(scramble(counter + 100_000));
}
const CUSTOM_RE = /^[a-zA-Z0-9_-]{3,32}$/;
export function validateCustomAlias(alias: string): string | null {
if (!alias) return "Pick an alias for your link.";
if (alias.length < 3) return "At least 3 characters.";
if (alias.length > 32) return "Keep it under 32 characters.";
if (!CUSTOM_RE.test(alias))
return "Only letters, numbers, hyphens and underscores.";
return null;
}
const URL_RE = /^https?:\/\/[^\s.]+\.[^\s]{2,}$/i;
export function normalizeUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function isValidUrl(input: string): boolean {
return URL_RE.test(normalizeUrl(input));
}
+50
View File
@@ -0,0 +1,50 @@
export type UrlMode = "random" | "memorable" | "custom";
export interface DayCount {
/** ISO date (YYYY-MM-DD) */
date: string;
count: number;
}
export interface ShortLink {
id: string;
code: string;
longUrl: string;
mode: UrlMode;
/** Whether a PIN protects the destination. PIN itself is never returned by the API. */
hasPin: boolean;
createdAt: string;
totalClicks: number;
/** Click counts for the last 7 days, oldest → newest. */
last7Days: DayCount[];
/** Present only for links the current user owns. */
owned?: boolean;
}
export interface CreateLinkInput {
longUrl: string;
mode: UrlMode;
/** Required when mode === "custom". */
customAlias?: string;
/** 6-digit string, authenticated users only. */
pin?: string;
}
export interface User {
id: string;
email: string;
name: string;
}
export interface OwnerStats {
totalLinks: number;
totalClicks: number;
weekClicks: number;
}
export interface LinkPage {
items: ShortLink[];
total: number;
page: number;
pageSize: number;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Curated word pools for the "easy to remember" mode. Short, concrete,
* unambiguous words → e.g. "amber-otter-loop". 3 words from these pools gives
* ~30 × 38 × 30 ≈ 34k base combinations, expanded with a tiny numeric suffix
* on collision in the real backend.
*/
export const ADJECTIVES = [
"amber", "brave", "calm", "clever", "cosmic", "crisp", "dawn", "eager",
"fizzy", "gentle", "happy", "honey", "ivory", "jolly", "keen", "lucky",
"mellow", "noble", "olive", "plush", "quartz", "rapid", "sunny", "swift",
"teal", "tidal", "vivid", "warm", "zesty", "zen",
];
export const NOUNS = [
"otter", "falcon", "maple", "comet", "pixel", "harbor", "meadow", "ember",
"willow", "lantern", "pebble", "cactus", "marble", "puffin", "ledger",
"cobra", "violet", "thistle", "acorn", "domino", "compass", "raven",
"saffron", "juniper", "lotus", "mango", "narwhal", "orchid", "pelican",
"quokka", "robin", "sparrow", "topaz", "umbra", "walrus", "yarrow", "zephyr",
];
export const VERBS = [
"loop", "dash", "soar", "drift", "glide", "spark", "leap", "flow",
"zoom", "hop", "skip", "roam", "bounce", "swirl", "dive", "climb",
];
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
export function memorableSlug(): string {
return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${pick(VERBS)}`;
}
+34
View File
@@ -0,0 +1,34 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { ThemeProvider } from "./context/ThemeContext";
import { AuthProvider } from "./context/AuthContext";
import { ToastProvider } from "./components/ui/Toast";
import { api } from "./lib/api";
import { setShortDomain } from "./lib/format";
import "./index.css";
function render() {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<AuthProvider>
<ToastProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ToastProvider>
</AuthProvider>
</ThemeProvider>
</StrictMode>,
);
}
// Pull the short-link domain from the server so it isn't hardcoded. Don't block
// first paint on it — render after config resolves or a short timeout.
const config = api
.config()
.then((c) => setShortDomain(c.shortDomain))
.catch(() => {});
Promise.race([config, new Promise((r) => setTimeout(r, 800))]).finally(render);
+410
View File
@@ -0,0 +1,410 @@
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { api } from "../lib/api";
import { compactNumber } from "../lib/format";
import type { OwnerStats, ShortLink } from "../lib/types";
import { UrlCard } from "../components/UrlCard";
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";
type View = "comfortable" | "compact";
const PAGE_SIZE: Record<View, number> = { comfortable: 5, compact: 8 };
const pop = {
hidden: { opacity: 0, y: 20, scale: 0.95 },
show: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring", stiffness: 340, damping: 20 },
},
} as const;
function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
function ViewToggle({ view, onChange }: { view: View; onChange: (v: View) => void }) {
const opts: { value: View; icon: string; label: string }[] = [
{ value: "comfortable", icon: "▦", label: "Comfortable view" },
{ value: "compact", icon: "≣", label: "Compact view" },
];
return (
<div className="flex shrink-0 gap-1 rounded-xl border-[1.5px] border-line 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 relative grid h-8 w-9 place-items-center rounded-lg text-[15px]"
>
{view === o.value && (
<motion.span
layoutId="view-pill"
transition={{ type: "spring", stiffness: 480, damping: 32 }}
className="absolute inset-0 rounded-lg bg-[var(--surface)] shadow-[0_2px_8px_-3px_rgba(0,0,0,0.3)] ring-[1.5px] ring-[var(--ring)]"
/>
)}
<span
className={`relative z-10 ${view === o.value ? "text-ink" : "text-muted"}`}
>
{o.icon}
</span>
</button>
))}
</div>
);
}
export function Dashboard() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const toast = useToast();
const [view, setView] = useState<View>(
() => (localStorage.getItem("snip-view") as View) || "comfortable",
);
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).
const [pageData, setPageData] = useState<{ items: ShortLink[]; total: number } | null>(null);
const [stats, setStats] = useState<OwnerStats | null>(null);
// Search + page live in the URL (?q=&page=) so they survive a refresh / share.
const query = searchParams.get("q") ?? "";
const debouncedQuery = useDebouncedValue(query, 300);
const rawPage = parseInt(searchParams.get("page") ?? "1", 10);
const requestedPage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
const pageSize = PAGE_SIZE[view];
useEffect(() => {
if (!authLoading && !user) navigate("/login");
}, [authLoading, user, navigate]);
useEffect(() => {
localStorage.setItem("snip-view", view);
}, [view]);
// Fetch the current page from the API (debounced search, server pagination).
const reqId = useRef(0);
const loadPage = useCallback(async () => {
if (!user) return;
const id = ++reqId.current;
const res = await api.listLinks({
page: requestedPage,
pageSize,
q: debouncedQuery.trim() || undefined,
});
if (id === reqId.current) {
setPageData({ items: res.items, total: res.total });
}
}, [user, requestedPage, pageSize, debouncedQuery]);
const loadStats = useCallback(async () => {
if (!user) return;
setStats(await api.stats());
}, [user]);
useEffect(() => {
loadPage();
}, [loadPage]);
useEffect(() => {
loadStats();
}, [loadStats]);
const pageCount = pageData ? Math.max(1, Math.ceil(pageData.total / pageSize)) : 1;
const safePage = Math.min(requestedPage, pageCount);
// Keep the URL honest if it points past the last page (deletes, ?page=99…).
useEffect(() => {
if (pageData && requestedPage > pageCount) {
const next = new URLSearchParams(searchParams);
if (pageCount <= 1) next.delete("page");
else next.set("page", String(pageCount));
setSearchParams(next, { replace: true });
}
}, [pageData, requestedPage, pageCount, searchParams, setSearchParams]);
function updateSearch(q: string) {
const next = new URLSearchParams(searchParams);
if (q) next.set("q", q);
else next.delete("q");
next.delete("page"); // a new search jumps back to page 1
setSearchParams(next, { replace: true });
}
function changeView(v: View) {
setView(v);
const next = new URLSearchParams(searchParams);
next.delete("page"); // page sizes differ between views
setSearchParams(next, { replace: true });
}
function goToPage(p: number) {
const next = new URLSearchParams(searchParams);
if (p <= 1) next.delete("page");
else next.set("page", String(p));
setSearchParams(next);
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function handleDeleted(_id: string, message: string) {
setDeleteTarget(null);
toast(message, "info");
await Promise.all([loadPage(), loadStats()]);
}
// Edit / PIN: patch the row in place (totals are unaffected).
function applyUpdate(updated: ShortLink, message: string) {
setPageData((pd) =>
pd
? { ...pd, items: pd.items.map((l) => (l.id === updated.id ? { ...l, ...updated } : l)) }
: pd,
);
toast(message, "success");
}
const items = pageData?.items ?? [];
const total = pageData?.total ?? 0;
const totalLinks = stats?.totalLinks ?? 0;
const statTiles = [
{ label: "Active links", value: compactNumber(totalLinks) },
{ label: "Total clicks", value: compactNumber(stats?.totalClicks ?? 0) },
{
label: "Clicks this week",
value: `+${compactNumber(stats?.weekClicks ?? 0)}`,
accent: true,
},
];
const hasAnyLinks = totalLinks > 0;
return (
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8">
{/* header */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 26 }}
className="flex flex-wrap items-end justify-between gap-3"
>
<div>
<h1 className="font-display text-3xl font-bold tracking-tight text-ink sm:text-4xl">
Your 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">
<span className="text-lg"></span> New link
</Button>
</Link>
</motion.div>
{/* stat tiles */}
<div className="mt-6 grid grid-cols-3 gap-3">
{statTiles.map((s, i) => (
<motion.div
key={s.label}
initial={{ opacity: 0, y: 18, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 360, damping: 20, delay: 0.05 * i }}
className="card p-4"
>
<p className="text-[11px] font-medium uppercase tracking-wide text-muted sm:text-xs">
{s.label}
</p>
<p
className={`mt-1 font-display text-2xl font-bold sm:text-3xl ${
s.accent ? "text-accent-ink" : "text-ink"
}`}
>
{s.accent ? (
<span className="rounded-lg bg-accent px-2">{s.value}</span>
) : (
s.value
)}
</p>
</motion.div>
))}
</div>
{/* toolbar: search + view toggle */}
{hasAnyLinks && (
<div className="mt-6 flex items-center gap-2.5">
<div className="flex h-11 flex-1 items-center gap-2 rounded-xl border-[1.5px] border-line bg-surface px-3.5">
<span className="text-muted"></span>
<input
value={query}
onChange={(e) => updateSearch(e.target.value)}
placeholder="Search by code or URL…"
className="h-full w-full bg-transparent text-[15px] text-ink outline-none placeholder:text-muted"
/>
{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"
>
</button>
)}
</div>
<ViewToggle view={view} onChange={changeView} />
</div>
)}
{/* result meta */}
{hasAnyLinks && (
<p className="mt-3 text-xs text-muted">
{query ? (
<>
<span className="font-semibold text-ink">{total}</span> of {totalLinks}{" "}
{totalLinks === 1 ? "link" : "links"} match {query}
</>
) : (
<>
Showing <span className="font-semibold text-ink">{items.length}</span> of{" "}
{totalLinks} {totalLinks === 1 ? "link" : "links"}
</>
)}
</p>
)}
{/* list */}
<div className="mt-4">
{pageData === null ? (
<div className="space-y-4">
{[0, 1, 2].map((i) => (
<div
key={i}
className="card h-28 animate-pulse opacity-60"
style={{ animationDelay: `${i * 120}ms` }}
/>
))}
</div>
) : total === 0 && !debouncedQuery ? (
<motion.div
variants={pop}
initial="hidden"
animate="show"
className="card grid place-items-center gap-3 p-12 text-center"
>
<span className="text-4xl">🪄</span>
<p className="font-display text-xl font-bold text-ink">No links yet</p>
<p className="max-w-xs text-sm text-muted">
Create your first short link and it'll show up here with live click
stats.
</p>
<Link to="/" className="mt-1">
<Button>Create a link</Button>
</Link>
</motion.div>
) : total === 0 ? (
<motion.div
variants={pop}
initial="hidden"
animate="show"
className="card grid place-items-center gap-2 p-10 text-center"
>
<span className="text-3xl">🔍</span>
<p className="font-display text-lg font-bold text-ink">No matches</p>
<p className="text-sm text-muted">Nothing matches {query}.</p>
<button
onClick={() => updateSearch("")}
className="focusable mt-1 rounded-lg text-sm font-semibold text-ink underline decoration-accent decoration-2 underline-offset-2"
>
Clear search
</button>
</motion.div>
) : (
<>
<motion.div
layout
className={view === "compact" ? "space-y-2.5" : "space-y-4"}
>
<AnimatePresence mode="popLayout">
{items.map((link) =>
view === "compact" ? (
<UrlRow
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>
</motion.div>
<Pagination page={safePage} pageCount={pageCount} onPage={goToPage} />
</>
)}
</div>
<EditLinkModal
link={editTarget}
onClose={() => setEditTarget(null)}
onSaved={(updated, message) => {
applyUpdate(updated, message);
setEditTarget(null);
}}
/>
<PinManager
link={pinTarget}
onClose={() => setPinTarget(null)}
onSaved={(updated, message) => {
applyUpdate(updated, message);
setPinTarget(null);
}}
/>
<QrModal
link={qrTarget}
onClose={() => setQrTarget(null)}
onCopied={() => toast("Copied to clipboard", "success")}
/>
<DeleteLinkModal
link={deleteTarget}
onClose={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { motion } from "framer-motion";
import { ShortenForm } from "../components/ShortenForm";
import { useAuth } from "../context/AuthContext";
const container = {
hidden: {},
show: {
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
};
const pop = {
hidden: { opacity: 0, y: 26, scale: 0.9 },
show: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring", stiffness: 360, damping: 18 },
},
} as const;
const features = [
{ icon: "⚡", title: "Instant", text: "Links resolve in milliseconds, cached at the edge." },
{ icon: "🎯", title: "Three styles", text: "Random, memorable words, or fully your own." },
{ icon: "📊", title: "Live stats", text: "See every click with a 7-day breakdown." },
];
export function Home() {
const { user } = useAuth();
return (
<div className="mx-auto max-w-2xl px-4 pb-24 pt-10 sm:pt-16">
<motion.div variants={container} initial="hidden" animate="show">
{/* eyebrow */}
<motion.div variants={pop} className="mb-5 flex justify-center">
<span className="inline-flex items-center gap-2 rounded-full border-[1.5px] border-line bg-surface px-3.5 py-1.5 text-xs font-medium text-muted">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent" />
</span>
{user ? `Welcome back, ${user.name.split(" ")[0]}` : "No account needed to start"}
</span>
</motion.div>
{/* headline */}
<h1 className="text-center font-display text-5xl font-bold leading-[0.95] tracking-tight text-ink sm:text-7xl">
<motion.span variants={pop} className="block">
Long links,
</motion.span>
<motion.span variants={pop} className="block">
made{" "}
<span className="relative inline-block">
<span className="relative z-10">tiny</span>
<motion.span
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ delay: 0.7, type: "spring", stiffness: 200, damping: 18 }}
className="absolute inset-x-[-2px] bottom-1 z-0 h-2.5 origin-left rounded-full bg-accent sm:bottom-1.5 sm:h-3.5"
/>
</span>
.
</motion.span>
</h1>
<motion.p
variants={pop}
className="mx-auto mt-5 max-w-md text-center text-[15px] leading-relaxed text-muted sm:text-base"
>
Paste a clunky URL and get a clean, shareable link in a tap with
your choice of code, optional PIN, and click stats.
</motion.p>
{/* form */}
<motion.div variants={pop} className="mt-9">
<ShortenForm />
</motion.div>
{/* features */}
<motion.ul
variants={pop}
className="mt-10 grid grid-cols-1 gap-3 sm:grid-cols-3"
>
{features.map((f) => (
<motion.li
key={f.title}
whileHover={{ y: -4 }}
transition={{ type: "spring", stiffness: 400, damping: 16 }}
className="card p-4"
>
<span className="text-xl">{f.icon}</span>
<p className="mt-2 text-sm font-semibold text-ink">{f.title}</p>
<p className="mt-0.5 text-[13px] leading-snug text-muted">
{f.text}
</p>
</motion.li>
))}
</motion.ul>
</motion.div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { api } from "../lib/api";
import { Button } from "../components/ui/Button";
const META: Record<string, { label: string; icon: string }> = {
google: { label: "Continue with Google", icon: "🇬" },
oidc: { label: "Continue with SSO", icon: "🔐" },
};
function providerMeta(name: string) {
return META[name] ?? { label: `Continue with ${name}`, icon: "🔐" };
}
export function Login() {
const { user, loading } = useAuth();
const navigate = useNavigate();
const [providers, setProviders] = useState<string[] | null>(null);
useEffect(() => {
if (!loading && user) navigate("/dashboard");
}, [loading, user, navigate]);
useEffect(() => {
api.providers().then(setProviders);
}, []);
return (
<div className="mx-auto grid min-h-[70vh] max-w-md place-items-center px-4 pb-24 pt-10">
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 280, damping: 22 }}
className="card w-full p-7 sm:p-8"
>
<motion.div
initial={{ scale: 0, rotate: -30 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 14, delay: 0.1 }}
className="mb-5 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--ink)]"
>
<svg width="24" height="24" viewBox="0 0 64 64" fill="none">
<path
d="M26 38l12-12M28 20a8 8 0 0 1 11 11l-3 3M36 44a8 8 0 0 1-11-11l3-3"
stroke="var(--accent)"
strokeWidth="5.5"
strokeLinecap="round"
/>
</svg>
</motion.div>
<h1 className="font-display text-3xl font-bold tracking-tight text-ink">
Welcome to snip
</h1>
<p className="mt-1.5 text-sm text-muted">
Sign in to manage your links, lock them with a PIN, and watch the
clicks roll in.
</p>
<div className="mt-6 space-y-2.5">
{providers === null ? (
<>
<div className="h-14 animate-pulse rounded-2xl bg-surface-2" />
<div className="h-14 animate-pulse rounded-2xl bg-surface-2 opacity-60" />
</>
) : providers.length === 0 ? (
<div className="rounded-2xl border-[1.5px] border-dashed border-line bg-surface-2/50 p-4 text-center text-sm text-muted">
No login providers are configured on the server yet. Set{" "}
<span className="font-mono text-ink">GOOGLE_*</span> or{" "}
<span className="font-mono text-ink">OIDC_*</span> env vars to
enable sign-in.
</div>
) : (
providers.map((p, i) => {
const meta = providerMeta(p);
return (
<motion.div
key={p}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 * i, type: "spring", stiffness: 320, damping: 22 }}
>
<Button
variant={i === 0 ? "primary" : "outline"}
size="lg"
block
onClick={() => {
window.location.href = api.loginUrl(p);
}}
>
<span className="text-lg">{meta.icon}</span> {meta.label}
</Button>
</motion.div>
);
})
)}
</div>
<p className="mt-5 text-center text-xs text-muted">
You can still create random &amp; memorable links without an account
sign in only to customize, protect, and track them.
</p>
</motion.div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
readonly VITE_SHORT_DOMAIN?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}