mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
feat: first commit
This commit is contained in:
+80
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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" });
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & memorable links without an account —
|
||||
sign in only to customize, protect, and track them.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+10
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user