4 Commits
Author SHA1 Message Date
sittichok OuamsiriandClaude Opus 5.5 2e1133e39c feat: redesign UI and UX around design-engineering principles
- Ink-first palette, drop gradients/glow; tabular numbers
- Home: form stays put, results stack below and auto-copy; paste shortens instantly
- Hold-to-delete replaces the delete confirm modal
- Copy buttons morph to a check instead of toasting
- Motion: CSS press feedback, custom ease-out, origin-aware menu, faster exits,
  no animation on frequent/keyboard actions, reduced-motion respected
- Fix: scheme-less URLs ("example.com") were blocked by native validation

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 00:11:22 +07:00
sittichok Ouamsiri e2327207e5 feat: improve ui 2026-07-26 14:56:21 +07:00
sittichok Ouamsiri e177c7775a feat: frontend redesign 2026-06-20 23:01:41 +07:00
sittichok Ouamsiri faf2c97462 fix: updates 2026-06-15 22:56:09 +07:00
34 changed files with 1322 additions and 1444 deletions

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
uses: infras/workflow-templates/.gitea/workflows/bump-compose-version.yml@736cd715b4b9c80f20ec51ef8445cb630c8299f5 uses: infras/workflow-templates/.gitea/workflows/bump-compose-version.yml@736cd715b4b9c80f20ec51ef8445cb630c8299f5
with: with:
app: snip app: snip
update: | updates: |
snip-migrate:${{ gitea.ref_name }} snip-migrate:${{ gitea.ref_name }}
snip-api:${{ gitea.ref_name }} snip-api:${{ gitea.ref_name }}
snip-frontend:${{ gitea.ref_name }} snip-frontend:${{ gitea.ref_name }}
+21 -9
View File
@@ -8,16 +8,28 @@ func notFoundHTML(shortHost, homeURL string) string {
href := html.EscapeString(homeURL) href := html.EscapeString(homeURL)
return `<!doctype html><html lang="en"><head><meta charset="utf-8"> return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#fbfbfa" media="(prefers-color-scheme:light)">
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme:dark)">
<title>Link not found</title><style> <title>Link not found</title><style>
:root{--bg:#f4f2ea;--surface:#fffdf7;--ink:#16170f;--muted:#6b6c5e;--line:#e2decf;--accent:#c6f24e} :root{--bg:#fbfbfa;--surface:#fff;--ink:#18181b;--muted:#71717a;--line:#e9e9eb;
@media(prefers-color-scheme:dark){:root{--bg:#0e100a;--surface:#181b11;--ink:#f1efe3;--muted:#9b9d8a;--line:#2c3020}} --grad-from:#2563eb;--grad-to:#7c3aed;--grad-ink:#fff;--glow-1:rgba(37,99,235,.16);--glow-2:rgba(124,58,237,.14)}
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--ink); @media(prefers-color-scheme:dark){:root{--bg:#09090b;--surface:#111113;--ink:#fafafa;--muted:#a1a1aa;--line:#232327;
font-family:ui-sans-serif,system-ui,sans-serif;text-align:center;padding:24px} --grad-from:#60a5fa;--grad-to:#a78bfa;--grad-ink:#09090b;--glow-1:rgba(96,165,250,.13);--glow-2:rgba(167,139,250,.12)}}
.card{background:var(--surface);border:1.5px solid var(--line);border-radius:24px;padding:36px;max-width:360px} body{margin:0;min-height:100vh;display:grid;place-items:center;background-color:var(--bg);color:var(--ink);
h1{font-size:54px;margin:0;letter-spacing:-.03em} font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased;
p{color:var(--muted);margin:8px 0 20px} text-align:center;padding:20px;
a{display:inline-block;background:var(--accent);color:#16170f;text-decoration:none;font-weight:600; background-image:radial-gradient(60% 45% at 12% 0%,var(--glow-1),transparent 70%),
padding:12px 20px;border-radius:14px} radial-gradient(55% 40% at 92% 100%,var(--glow-2),transparent 70%);
background-attachment:fixed;background-repeat:no-repeat}
.card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:32px;width:100%;max-width:360px}
h1{font-size:44px;font-weight:600;margin:0;letter-spacing:-.03em;
background-image:linear-gradient(135deg,var(--grad-from),var(--grad-to));
-webkit-background-clip:text;background-clip:text;color:transparent}
p{color:var(--muted);font-size:14px;line-height:1.5;margin:8px 0 20px}
a{display:block;background-image:linear-gradient(135deg,var(--grad-from),var(--grad-to));color:var(--grad-ink);
text-decoration:none;font-size:15px;font-weight:500;
padding:13px 20px;border-radius:12px;transition:opacity .15s}
a:hover{opacity:.9}
</style></head><body><div class="card"><h1>404</h1> </style></head><body><div class="card"><h1>404</h1>
<p>This short link doesn't exist or was removed.</p> <p>This short link doesn't exist or was removed.</p>
<a href="` + href + `">Go to ` + h + `</a></div></body></html>` <a href="` + href + `">Go to ` + h + `</a></div></body></html>`
+67 -37
View File
@@ -14,51 +14,76 @@ type pinData struct {
} }
// The page is fully self-contained (no external CSS/JS/fonts) so it paints in a // The page is fully self-contained (no external CSS/JS/fonts) so it paints in a
// single round trip — the redirect path must stay fast. It mirrors the snip // single round trip — the redirect path must stay fast. Palette and radii mirror
// look: warm paper + ink, electric-lime accent, with a dark-mode variant. // src/index.css so it reads as the same product; dark mode follows the OS since
// there is no app shell (and so no theme toggle) on this route.
var pinTmpl = template.Must(template.New("pin").Parse(`<!doctype html> var pinTmpl = template.Must(template.New("pin").Parse(`<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#16170f"> <meta name="theme-color" content="#fbfbfa" media="(prefers-color-scheme:light)">
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme:dark)">
<meta name="robots" content="noindex">
<title>Enter PIN · {{.ShortHost}}/{{.Code}}</title> <title>Enter PIN · {{.ShortHost}}/{{.Code}}</title>
<style> <style>
:root{--bg:#f4f2ea;--surface:#fffdf7;--ink:#16170f;--muted:#6b6c5e;--line:#e2decf;--accent:#c6f24e;--accent-ink:#16170f} :root{--bg:#fbfbfa;--surface:#fff;--surface-2:#f4f4f5;--ink:#18181b;--muted:#71717a;
@media (prefers-color-scheme:dark){:root{--bg:#0e100a;--surface:#181b11;--ink:#f1efe3;--muted:#9b9d8a;--line:#2c3020;--accent:#c6f24e}} --line:#e9e9eb;--accent:#2563eb;--accent-soft:rgba(37,99,235,.1);--danger:#dc2626;
*{box-sizing:border-box} --grad-from:#2563eb;--grad-to:#7c3aed;--grad-ink:#fff;
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px; --glow-1:rgba(37,99,235,.16);--glow-2:rgba(124,58,237,.14)}
background:var(--bg);color:var(--ink); @media(prefers-color-scheme:dark){:root{--bg:#09090b;--surface:#111113;--surface-2:#19191c;--ink:#fafafa;
--muted:#a1a1aa;--line:#232327;--accent:#60a5fa;--accent-soft:rgba(96,165,250,.14);--danger:#f87171;
--grad-from:#60a5fa;--grad-to:#a78bfa;--grad-ink:#09090b;
--glow-1:rgba(96,165,250,.13);--glow-2:rgba(167,139,250,.12)}}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:20px;
background-color:var(--bg);color:var(--ink);
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
background-image:radial-gradient(60% 50% at 12% 8%,rgba(198,242,78,.30),transparent 60%),radial-gradient(60% 50% at 90% 90%,rgba(198,242,78,.22),transparent 60%)} -webkit-font-smoothing:antialiased;
.card{width:100%;max-width:380px;background:var(--surface);border:1.5px solid var(--line); background-image:radial-gradient(60% 45% at 12% 0%,var(--glow-1),transparent 70%),
border-radius:24px;padding:28px;box-shadow:0 30px 80px -30px rgba(0,0,0,.45); radial-gradient(55% 40% at 92% 100%,var(--glow-2),transparent 70%);
animation:pop .45s cubic-bezier(.2,.9,.25,1.2)} background-attachment:fixed;background-repeat:no-repeat}
@keyframes pop{from{opacity:0;transform:translateY(16px) scale(.96)}to{opacity:1;transform:none}} .card{width:100%;max-width:380px;background:var(--surface);border:1px solid var(--line);
.lock{width:48px;height:48px;border-radius:16px;background:var(--ink);display:grid;place-items:center;margin-bottom:18px} border-radius:16px;padding:24px;animation:rise .3s ease-out}
.lock svg{width:24px;height:24px} @media(min-width:420px){.card{padding:28px}}
h1{font-size:24px;margin:0 0 6px;letter-spacing:-.02em} @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
p{margin:0 0 20px;color:var(--muted);font-size:14px;line-height:1.5} .lock{width:40px;height:40px;border-radius:12px;color:var(--grad-ink);
p b{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace} background-image:linear-gradient(135deg,var(--grad-from),var(--grad-to));
.pins{display:flex;gap:8px;margin-bottom:14px} display:grid;place-items:center;margin-bottom:16px}
.pins input{flex:1;width:100%;height:54px;text-align:center;font-size:22px;font-weight:700; .lock svg{width:20px;height:20px}
h1{font-size:20px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px}
h1 span{background-image:linear-gradient(135deg,var(--grad-from),var(--grad-to));
-webkit-background-clip:text;background-clip:text;color:transparent}
.sub{margin:0 0 20px;color:var(--muted);font-size:14px;line-height:1.5}
.sub b{color:var(--ink);font-weight:500;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}
.pins{display:flex;gap:6px}
@media(min-width:420px){.pins{gap:8px}}
.pins input{flex:1;min-width:0;height:52px;padding:0;text-align:center;font-size:20px;font-weight:600;
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink); font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink);
background:var(--bg);border:1.5px solid var(--line);border-radius:14px;outline:none;transition:transform .12s,border-color .12s} background:var(--surface-2);border:1px solid var(--line);border-radius:12px;outline:none;
.pins input:focus{transform:translateY(-2px) scale(1.05);border-color:var(--accent)} transition:border-color .15s,box-shadow .15s}
.err{color:#ef4444;font-size:13px;font-weight:600;margin:0 0 14px;min-height:18px} .pins input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
button{width:100%;height:52px;border:0;border-radius:16px;background:var(--accent);color:var(--accent-ink); .err{color:var(--danger);font-size:13px;font-weight:500;margin:10px 0 0;min-height:19px}
font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 8px 24px -8px rgba(198,242,78,.6);transition:transform .12s} form.bad .pins input{border-color:var(--danger)}
button:active{transform:scale(.96)} form.bad .pins{animation:shake .4s}
.foot{margin-top:16px;text-align:center;font-size:12px;color:var(--muted)} @keyframes shake{25%{transform:translateX(-5px)}50%{transform:translateX(5px)}75%{transform:translateX(-3px)}}
.foot b{color:var(--ink)} button{width:100%;height:48px;margin-top:14px;border:0;border-radius:12px;
background-image:linear-gradient(135deg,var(--grad-from),var(--grad-to));color:var(--grad-ink);
font-size:15px;font-weight:500;font-family:inherit;
cursor:pointer;transition:opacity .15s,transform .1s}
button:hover{opacity:.9}
button:active{transform:scale(.99)}
:focus-visible{outline:none;box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--accent)}
.foot{margin-top:18px;text-align:center;font-size:12px;color:var(--muted)}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<div class="lock"><svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="11" rx="3" fill="#c6f24e"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="#c6f24e" stroke-width="2.4" fill="none"/><circle cx="12" cy="15.5" r="1.7" fill="#16170f"/></svg></div> <div class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></div>
<h1>This link is protected</h1> <h1>This link is <span>protected</span></h1>
<p>Enter the 6-digit PIN to continue to <b>{{.ShortHost}}/{{.Code}}</b>.</p> <p class="sub">Enter the 6-digit PIN to continue to <b>{{.ShortHost}}/{{.Code}}</b>.</p>
<form method="post" action="{{.ActionPath}}" id="f" autocomplete="off"> <form method="post" action="{{.ActionPath}}" id="f" autocomplete="off"{{if .HasError}} class="bad"{{end}}>
<div class="pins" id="pins"> <div class="pins" id="pins">
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 1" required> <input inputmode="numeric" maxlength="1" aria-label="PIN digit 1" required>
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 2" required> <input inputmode="numeric" maxlength="1" aria-label="PIN digit 2" required>
@@ -67,31 +92,36 @@ button:active{transform:scale(.96)}
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 5" required> <input inputmode="numeric" maxlength="1" aria-label="PIN digit 5" required>
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 6" required> <input inputmode="numeric" maxlength="1" aria-label="PIN digit 6" required>
</div> </div>
<p class="err">{{if .HasError}}That PIN didn't match. Try again.{{end}}</p> <p class="err" role="alert">{{if .HasError}}That PIN didn't match. Try again.{{end}}</p>
<input type="hidden" name="pin" id="pin"> <input type="hidden" name="pin" id="pin">
<button type="submit">Unlock &rarr;</button> <button type="submit">Unlock</button>
</form> </form>
<div class="foot">Secured by <b>{{.ShortHost}}</b></div> <div class="foot">{{.ShortHost}}</div>
</div> </div>
<script> <script>
(function(){ (function(){
var boxes=[].slice.call(document.querySelectorAll('#pins input')),hidden=document.getElementById('pin'),f=document.getElementById('f'); var boxes=[].slice.call(document.querySelectorAll('#pins input')),hidden=document.getElementById('pin'),f=document.getElementById('f');
function sync(){hidden.value=boxes.map(function(b){return b.value}).join('')} function sync(){hidden.value=boxes.map(function(b){return b.value}).join('')}
// Clear the error styling as soon as the visitor starts a fresh attempt.
function fresh(){f.className=''}
boxes.forEach(function(b,i){ boxes.forEach(function(b,i){
b.addEventListener('input',function(){ b.addEventListener('input',function(){
fresh();
b.value=b.value.replace(/\D/g,'').slice(0,1); b.value=b.value.replace(/\D/g,'').slice(0,1);
if(b.value&&i<boxes.length-1)boxes[i+1].focus(); if(b.value&&i<boxes.length-1)boxes[i+1].focus();
sync(); sync();
if(hidden.value.length===6)f.submit(); if(hidden.value.length===6)f.submit();
}); });
b.addEventListener('keydown',function(e){if(e.key==='Backspace'&&!b.value&&i>0)boxes[i-1].focus()}); b.addEventListener('focus',function(){b.select()});
b.addEventListener('keydown',function(e){if(e.key==='Backspace'&&!b.value&&i>0){boxes[i-1].focus();fresh()}});
b.addEventListener('paste',function(e){ b.addEventListener('paste',function(e){
var d=(e.clipboardData.getData('text')||'').replace(/\D/g,'').slice(0,6); var d=(e.clipboardData.getData('text')||'').replace(/\D/g,'').slice(0,6);
if(!d)return;e.preventDefault(); if(!d)return;e.preventDefault();fresh();
d.split('').forEach(function(c,j){if(boxes[j])boxes[j].value=c}); d.split('').forEach(function(c,j){if(boxes[j])boxes[j].value=c});
boxes[Math.min(d.length,5)].focus();sync();if(d.length===6)f.submit(); boxes[Math.min(d.length,5)].focus();sync();if(d.length===6)f.submit();
}); });
}); });
f.addEventListener('submit',sync);
if(boxes[0])boxes[0].focus(); if(boxes[0])boxes[0].focus();
})(); })();
</script> </script>
+5 -4
View File
@@ -4,11 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#16170F" /> <meta name="theme-color" content="#fbfbfa" />
<title>snip — A short link service</title> <title>snip — short links, minimal</title>
<link rel="preconnect" href="https://api.fontshare.com" crossorigin /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://api.fontshare.com/v2/css?f[]=clash-display@600,700&f[]=general-sans@400,500,600&f[]=space-mono@400,700&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<script> <script>
+15 -55
View File
@@ -1,17 +1,19 @@
import { AnimatePresence, motion } from "framer-motion"; import { motion } from "framer-motion";
import { Route, Routes, useLocation } from "react-router-dom"; import { Route, Routes, useLocation } from "react-router-dom";
import { Navbar } from "./components/Navbar"; import { Navbar } from "./components/Navbar";
import { Home } from "./pages/Home"; import { Home } from "./pages/Home";
import { Login } from "./pages/Login"; import { Login } from "./pages/Login";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
// Each route remounts on navigation (keyed below), so a plain mount animation
// gives a smooth fade-in without AnimatePresence "wait" exits, which can stall
// when an exiting page holds its own AnimatePresence / layout children.
function Page({ children }: { children: React.ReactNode }) { function Page({ children }: { children: React.ReactNode }) {
return ( return (
<motion.main <motion.main
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.18, ease: "easeOut" }}
transition={{ type: "spring", stiffness: 320, damping: 30 }}
> >
{children} {children}
</motion.main> </motion.main>
@@ -22,59 +24,17 @@ export default function App() {
const location = useLocation(); const location = useLocation();
return ( return (
<div className="app-bg grain min-h-screen"> <div className="min-h-screen">
{/* drifting decorative blobs */} <div className="px-4 sm:px-5">
<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 /> <Navbar />
</div> </div>
<AnimatePresence mode="wait"> <Routes location={location} key={location.pathname}>
<Routes location={location} key={location.pathname}> <Route path="/" element={<Page><Home /></Page>} />
<Route <Route path="/login" element={<Page><Login /></Page>} />
path="/" <Route path="/dashboard" element={<Page><Dashboard /></Page>} />
element={ <Route path="*" element={<Page><Home /></Page>} />
<Page> </Routes>
<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> </div>
); );
} }
+13 -36
View File
@@ -1,4 +1,3 @@
import { motion } from "framer-motion";
import { useState } from "react"; import { useState } from "react";
import type { DayCount } from "../lib/types"; import type { DayCount } from "../lib/types";
import { weekdayLabel } from "../lib/format"; import { weekdayLabel } from "../lib/format";
@@ -8,7 +7,9 @@ interface Props {
height?: number; height?: number;
} }
export function BarChart({ data, height = 92 }: Props) { // ponytail: no grow-in animation — this renders on every dashboard page view,
// and motion seen tens of times a day just reads as lag.
export function BarChart({ data, height = 84 }: Props) {
const [hover, setHover] = useState<number | null>(null); const [hover, setHover] = useState<number | null>(null);
const max = Math.max(1, ...data.map((d) => d.count)); const max = Math.max(1, ...data.map((d) => d.count));
@@ -16,50 +17,26 @@ export function BarChart({ data, height = 92 }: Props) {
<div className="flex items-end gap-1.5" style={{ height }}> <div className="flex items-end gap-1.5" style={{ height }}>
{data.map((d, i) => { {data.map((d, i) => {
const ratio = d.count / max; const ratio = d.count / max;
const isPeak = d.count === max && max > 0;
const active = hover === i; const active = hover === i;
return ( return (
<div <div
key={d.date} key={d.date}
className="group relative flex h-full flex-1 flex-col items-center justify-end gap-1.5" className="relative flex h-full flex-1 flex-col items-center justify-end gap-1.5"
onMouseEnter={() => setHover(i)} onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
> >
{/* tooltip */} {active && (
<motion.div <div className="pointer-events-none absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded-md bg-[var(--ink)] px-2 py-1 text-[11px] font-medium tabular-nums text-[var(--bg)]">
initial={false} {d.count} {d.count === 1 ? "click" : "clicks"}
animate={{ </div>
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"> <div className="flex w-full flex-1 items-end">
<motion.div <div
initial={{ height: 0 }} className={`w-full rounded-[3px] ${active ? "bg-[var(--ink)]" : "bg-[var(--ring)]"}`}
animate={{ height: `${Math.max(ratio * 100, 4)}%` }} style={{ height: `${Math.max(ratio * 100, 4)}%`, minHeight: 3 }}
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> </div>
<span className="text-[10px] font-medium text-muted"> <span className="text-[10px] text-muted">{weekdayLabel(d.date)}</span>
{weekdayLabel(d.date)}
</span>
</div> </div>
); );
})} })}
-71
View File
@@ -1,71 +0,0 @@
import { useState } from "react";
import { api } from "../lib/api";
import type { ShortLink } from "../lib/types";
import { compactNumber, prettyHost, shortUrl } from "../lib/format";
import { Modal } from "./ui/Modal";
import { Button } from "./ui/Button";
interface Props {
link: ShortLink | null;
onClose: () => void;
onDeleted: (id: string, message: string) => void;
}
export function DeleteLinkModal({ link, onClose, onDeleted }: Props) {
const open = Boolean(link);
const [busy, setBusy] = useState(false);
if (!link) return <Modal open={false} onClose={onClose} children={null} />;
async function confirm() {
if (!link) return;
setBusy(true);
try {
await api.deleteLink(link.id);
onDeleted(link.id, "Link deleted");
} catch {
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title="Delete link">
<div className="-mt-2 mb-4 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>
);
}
+20 -28
View File
@@ -52,44 +52,36 @@ export function EditLinkModal({ link, onClose, onSaved }: Props) {
return ( return (
<Modal open={open} onClose={onClose} title="Edit link"> <Modal open={open} onClose={onClose} title="Edit link">
{/* Short code is permanent — shown read-only so existing shares keep working. */} {/* Short code is permanent — shown read-only so existing shares keep working. */}
<div className="-mt-2 mb-4 flex items-center justify-between rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3"> <div className="-mt-2 mb-4 flex items-center justify-between gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted"> <p className="text-[11px] uppercase tracking-wide text-muted">Short link</p>
Short link <p className="truncate font-mono text-sm font-semibold text-ink">
</p>
<p className="truncate font-mono text-base font-bold text-ink">
{shortUrl(link.code)} {shortUrl(link.code)}
</p> </p>
</div> </div>
<span className="shrink-0 rounded-full border-[1.5px] border-line px-2 py-0.5 text-[10px] font-medium text-muted"> <span className="shrink-0 rounded-full border border-line px-2 py-0.5 text-[10px] text-muted">
can't change permanent
</span> </span>
</div> </div>
<label className="mb-2 block text-sm font-semibold text-ink"> <label className="mb-1.5 block text-[13px] font-medium text-muted">
Destination URL Destination URL
</label> </label>
<div <input
className={`flex items-center gap-2 rounded-2xl border-[1.5px] bg-surface-2 px-3.5 transition-colors ${ autoFocus
error ? "border-red-400/70" : "border-line" value={draft}
onChange={(e) => {
setDraft(e.target.value);
if (error) setError(null);
}}
onKeyDown={(e) => e.key === "Enter" && save()}
spellCheck={false}
className={`h-11 w-full rounded-xl border bg-surface-2 px-3.5 font-mono text-sm text-ink outline-none transition-colors ${
error ? "border-red-400" : "border-line focus:border-[var(--ink)]"
}`} }`}
> />
<span className="text-base opacity-50">🔗</span>
<input
autoFocus
value={draft}
onChange={(e) => {
setDraft(e.target.value);
if (error) setError(null);
}}
onKeyDown={(e) => e.key === "Enter" && save()}
spellCheck={false}
className="h-12 w-full bg-transparent font-mono text-sm text-ink outline-none"
/>
</div>
<p className="mt-2 text-xs text-muted"> <p className="mt-2 text-xs text-muted">
Where <span className="font-mono text-ink">{shortUrl(link.code)}</span>{" "} Currently points to {prettyHost(link.longUrl)}.
sends visitors — currently {prettyHost(link.longUrl)}.
</p> </p>
<AnimatePresence> <AnimatePresence>
@@ -98,7 +90,7 @@ export function EditLinkModal({ link, onClose, onSaved }: Props) {
initial={{ opacity: 0, y: -4 }} initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="mt-3 text-sm font-medium text-red-500" className="mt-3 text-[13px] font-medium text-red-500"
> >
{error} {error}
</motion.p> </motion.p>
+6 -17
View File
@@ -1,29 +1,18 @@
import { motion } from "framer-motion";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTheme } from "../context/ThemeContext"; import { Scissors } from "./icons";
export function Logo({ onClick }: { onClick?: () => void }) { export function Logo({ onClick }: { onClick?: () => void }) {
const { theme } = useTheme();
return ( return (
<Link <Link
to="/" to="/"
onClick={onClick} onClick={onClick}
className="focusable group inline-flex items-center gap-2.5 rounded-xl" className="focusable inline-flex items-center gap-2 rounded-lg"
> >
<motion.span <span className="grid h-7 w-7 place-items-center rounded-lg bg-[var(--ink)] text-[var(--bg)]">
whileHover={{ scale: 1.08 }} <Scissors size={15} />
transition={{ type: "spring", stiffness: 420, damping: 12 }} </span>
className="flex items-center" <span className="text-[16px] font-semibold tracking-[-0.02em] text-ink">
>
<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 snip
<span className="text-accent">.</span>
</span> </span>
</Link> </Link>
); );
+90 -22
View File
@@ -1,55 +1,123 @@
import { motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { Grid, ChevronDown, LogOut } from "./icons";
// Shared pill language so every nav control reads as one family.
const navPill =
"press focusable inline-flex h-9 items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-muted hover:bg-surface-2 hover:text-ink";
export function Navbar() { export function Navbar() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { pathname } = useLocation(); const { pathname } = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Dismiss the account menu on outside click or Escape.
useEffect(() => {
if (!menuOpen) return;
function onDown(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setMenuOpen(false);
}
window.addEventListener("mousedown", onDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("mousedown", onDown);
window.removeEventListener("keydown", onKey);
};
}, [menuOpen]);
// Close the menu whenever the route changes.
useEffect(() => setMenuOpen(false), [pathname]);
return ( return (
<motion.header <header className="sticky top-0 z-50 -mx-4 border-b border-transparent bg-[var(--bg)]/80 px-4 backdrop-blur-md sm:-mx-5 sm:px-5">
initial={{ y: -64, opacity: 0 }} <div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-3">
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 /> <Logo />
<nav className="flex items-center gap-1.5 sm:gap-2"> <nav className="flex items-center gap-2">
<ThemeToggle /> <ThemeToggle />
{user ? ( {user ? (
<> <>
{pathname !== "/dashboard" && ( {pathname !== "/dashboard" && (
<Link to="/dashboard" className="hidden sm:block"> <Link to="/dashboard" className={navPill} aria-label="Dashboard">
<Button variant="ghost" size="sm"> <Grid size={16} />
Dashboard <span className="hidden sm:inline">Dashboard</span>
</Button>
</Link> </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"> <div ref={menuRef} className="relative">
{user.name.slice(0, 1).toUpperCase()}
</span>
<button <button
onClick={() => logout()} onClick={() => setMenuOpen((v) => !v)}
className="focusable rounded-md text-xs font-medium text-muted hover:text-ink" aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label="Account menu"
className="press focusable inline-flex h-9 items-center gap-1 rounded-lg py-1 pl-1 pr-1.5 hover:bg-surface-2"
> >
Sign out <span className="grid h-7 w-7 place-items-center rounded-full bg-surface-2 text-[12px] font-semibold text-ink ring-1 ring-[var(--line)]">
{user.name.slice(0, 1).toUpperCase()}
</span>
<ChevronDown
size={15}
className={`text-muted transition-transform duration-200 ease-out ${menuOpen ? "rotate-180" : ""}`}
/>
</button> </button>
<AnimatePresence>
{menuOpen && (
<motion.div
role="menu"
// Scales out of the avatar button (top-right), not from its own centre.
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.1 } }}
transition={{ duration: 0.16, ease: [0.23, 1, 0.32, 1] }}
style={{ transformOrigin: "top right" }}
className="card absolute right-0 top-[calc(100%+6px)] w-56 overflow-hidden p-1 shadow-[var(--shadow-lg)]"
>
<div className="px-3 py-2">
<p className="truncate text-sm font-medium text-ink">
{user.name}
</p>
{user.email && (
<p className="truncate text-xs text-muted">{user.email}</p>
)}
</div>
<div className="my-1 border-t border-line" />
<button
role="menuitem"
onClick={() => {
setMenuOpen(false);
logout();
}}
className="focusable flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium text-ink transition-colors hover:bg-surface-2"
>
<LogOut size={16} className="text-muted" />
Sign out
</button>
</motion.div>
)}
</AnimatePresence>
</div> </div>
</> </>
) : ( ) : (
<Button size="sm" variant="dark" onClick={() => navigate("/login")}> <Button size="sm" onClick={() => navigate("/login")}>
Sign in Sign in
</Button> </Button>
)} )}
</nav> </nav>
</div> </div>
</motion.header> </header>
); );
} }
+11 -20
View File
@@ -1,4 +1,4 @@
import { motion } from "framer-motion"; import { ChevronLeft, ChevronRight } from "./icons";
interface Props { interface Props {
page: number; // 1-based page: number; // 1-based
@@ -28,10 +28,10 @@ export function Pagination({ page, pageCount, onPage }: Props) {
<button <button
onClick={() => onPage(page - 1)} onClick={() => onPage(page - 1)}
disabled={page === 1} disabled={page === 1}
className="focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-ink transition-opacity hover:bg-surface-2 disabled:opacity-30" className="focusable grid h-8 w-8 place-items-center rounded-lg border border-line bg-surface text-muted transition-colors hover:bg-surface-2 hover:text-ink disabled:opacity-30"
aria-label="Previous page" aria-label="Previous page"
> >
‹ <ChevronLeft size={16} />
</button> </button>
{items.map((it, i) => {items.map((it, i) =>
@@ -44,22 +44,13 @@ export function Pagination({ page, pageCount, onPage }: Props) {
key={it} key={it}
onClick={() => onPage(it)} onClick={() => onPage(it)}
aria-current={it === page} aria-current={it === page}
className="focusable relative grid h-9 min-w-9 place-items-center rounded-xl px-2 text-sm font-semibold" className={`focusable grid h-8 min-w-8 place-items-center rounded-lg px-2 text-[13px] font-medium transition-colors ${
it === page
? "bg-[var(--ink)] text-[var(--bg)]"
: "text-muted hover:bg-surface-2 hover:text-ink"
}`}
> >
{it === page && ( {it}
<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>
), ),
)} )}
@@ -67,10 +58,10 @@ export function Pagination({ page, pageCount, onPage }: Props) {
<button <button
onClick={() => onPage(page + 1)} onClick={() => onPage(page + 1)}
disabled={page === pageCount} disabled={page === pageCount}
className="focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-ink transition-opacity hover:bg-surface-2 disabled:opacity-30" className="focusable grid h-8 w-8 place-items-center rounded-lg border border-line bg-surface text-muted transition-colors hover:bg-surface-2 hover:text-ink disabled:opacity-30"
aria-label="Next page" aria-label="Next page"
> >
› <ChevronRight size={16} />
</button> </button>
</div> </div>
); );
+4 -6
View File
@@ -1,4 +1,3 @@
import { motion } from "framer-motion";
import { useRef } from "react"; import { useRef } from "react";
interface Props { interface Props {
@@ -43,20 +42,19 @@ export function PinInput({ value, onChange, length = 6 }: Props) {
} }
return ( return (
<div className="flex gap-2"> <div className="flex gap-1.5 sm:gap-2">
{Array.from({ length }).map((_, i) => ( {Array.from({ length }).map((_, i) => (
<motion.input <input
key={i} key={i}
ref={(el) => (refs.current[i] = el)} ref={(el) => (refs.current[i] = el)}
value={digits[i] ?? ""} value={digits[i] ?? ""}
onChange={(e) => handleChange(i, e)} onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKey(i, e)} onKeyDown={(e) => handleKey(i, e)}
inputMode="numeric" inputMode="numeric"
autoComplete="off"
maxLength={1} maxLength={1}
aria-label={`PIN digit ${i + 1}`} aria-label={`PIN digit ${i + 1}`}
whileFocus={{ scale: 1.08, y: -2 }} className="field h-12 w-full min-w-0 text-center font-mono text-lg font-semibold"
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> </div>
+10 -9
View File
@@ -6,6 +6,7 @@ import { shortUrl } from "../lib/format";
import { Modal } from "./ui/Modal"; import { Modal } from "./ui/Modal";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { PinInput } from "./PinInput"; import { PinInput } from "./PinInput";
import { Lock } from "./icons";
interface Props { interface Props {
link: ShortLink | null; link: ShortLink | null;
@@ -68,15 +69,15 @@ export function PinManager({ link, onClose, onSaved }: Props) {
: "add a 6-digit PIN that visitors enter before redirect."} : "add a 6-digit PIN that visitors enter before redirect."}
</p> </p>
{/* Current state. The server stores PINs hashed, so an existing value can {/* The server stores PINs hashed, so an existing value can never be shown
never be shown — only replaced or removed. */} — only replaced or removed. */}
{hasPin && ( {hasPin && (
<div className="mb-4 flex items-center gap-3 rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3"> <div className="mb-4 flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-accent text-base text-accent-ink"> <span className="bg-[var(--ink)] text-[var(--bg)] grid h-8 w-8 place-items-center rounded-lg">
🔒 <Lock size={15} />
</span> </span>
<div> <div>
<p className="text-sm font-semibold text-ink">PIN is active</p> <p className="text-[13px] font-medium text-ink">PIN is active</p>
<p className="text-[12px] text-muted"> <p className="text-[12px] text-muted">
Stored encrypted — set a new one below to change it. Stored encrypted — set a new one below to change it.
</p> </p>
@@ -84,7 +85,7 @@ export function PinManager({ link, onClose, onSaved }: Props) {
</div> </div>
)} )}
<label className="mb-2 block text-sm font-semibold text-ink"> <label className="mb-2 block text-[13px] font-medium text-muted">
{hasPin ? "Set a new PIN" : "Choose a PIN"} {hasPin ? "Set a new PIN" : "Choose a PIN"}
</label> </label>
<PinInput value={draft} onChange={setDraft} /> <PinInput value={draft} onChange={setDraft} />
@@ -95,7 +96,7 @@ export function PinManager({ link, onClose, onSaved }: Props) {
initial={{ opacity: 0, y: -4 }} initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="mt-3 text-sm font-medium text-red-500" className="mt-3 text-[13px] font-medium text-red-500"
> >
{error} {error}
</motion.p> </motion.p>
@@ -114,7 +115,7 @@ export function PinManager({ link, onClose, onSaved }: Props) {
variant="ghost" variant="ghost"
onClick={remove} onClick={remove}
disabled={busy !== null} disabled={busy !== null}
className="ml-auto text-muted hover:!text-red-500" className="ml-auto hover:!text-red-500"
> >
{busy === "remove" ? "Removing…" : "Remove PIN"} {busy === "remove" ? "Removing…" : "Remove PIN"}
</Button> </Button>
+20 -32
View File
@@ -1,35 +1,23 @@
import { useRef, useState } from "react"; import { useRef } from "react";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import type { ShortLink } from "../lib/types"; import type { ShortLink } from "../lib/types";
import { fullShortUrl, prettyHost, shortUrl } from "../lib/format"; import { fullShortUrl, prettyHost, shortUrl } from "../lib/format";
import { Modal } from "./ui/Modal"; import { Modal } from "./ui/Modal";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { CopyButton } from "./ui/CopyButton";
import { Download } from "./icons";
interface Props { interface Props {
link: ShortLink | null; link: ShortLink | null;
onClose: () => void; onClose: () => void;
onCopied: () => void;
} }
export function QrModal({ link, onClose, onCopied }: Props) { export function QrModal({ link, onClose }: Props) {
const open = Boolean(link); const open = Boolean(link);
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
if (!link) return <Modal open={false} onClose={onClose} children={null} />; 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() { function download() {
if (!link) return; if (!link) return;
const svg = wrapRef.current?.querySelector("svg"); const svg = wrapRef.current?.querySelector("svg");
@@ -45,34 +33,34 @@ export function QrModal({ link, onClose, onCopied }: Props) {
} }
return ( return (
<Modal open={open} onClose={onClose} title="QR code"> <Modal open={open} onClose={onClose}>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div ref={wrapRef} className="rounded-xl bg-white p-4 ring-1 ring-black/5">
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 <QRCodeSVG
value={fullShortUrl(link.code)} value={fullShortUrl(link.code)}
size={188} size={184}
bgColor="#ffffff" bgColor="#ffffff"
fgColor="#16170f" fgColor="#111113"
level="M" level="M"
/> />
</div> </div>
<p className="mt-4 font-mono text-lg font-bold text-ink"> <p className="mt-4 max-w-full truncate font-mono text-base font-semibold text-ink">
{shortUrl(link.code)} {shortUrl(link.code)}
</p> </p>
<p className="text-[13px] text-muted"> <p className="max-w-full truncate text-[13px] text-muted">
Points to {prettyHost(link.longUrl)} → {prettyHost(link.longUrl)}
</p> </p>
<div className="mt-5 flex w-full items-center gap-2"> <div className="mt-5 grid w-full grid-cols-2 gap-2">
<Button block onClick={copy}> <CopyButton
{copied ? "Copied ✓" : "Copy link"} text={fullShortUrl(link.code)}
</Button> label="Copy link"
<Button block variant="outline" onClick={download}> className="h-10 rounded-xl bg-[var(--ink)] text-sm text-[var(--bg)] hover:opacity-85"
Download SVG />
<Button variant="outline" onClick={download}>
<Download size={15} />
SVG
</Button> </Button>
</div> </div>
</div> </div>
+32 -111
View File
@@ -1,125 +1,46 @@
import { motion } from "framer-motion";
import { QRCodeSVG } from "qrcode.react";
import { useState } from "react";
import type { ShortLink } from "../lib/types"; import type { ShortLink } from "../lib/types";
import { fullShortUrl, prettyHost, shortUrl } from "../lib/format"; import { fullShortUrl, prettyHost, shortUrl } from "../lib/format";
import { useTheme } from "../context/ThemeContext"; import { CopyButton } from "./ui/CopyButton";
import { Button } from "./ui/Button"; import { Qr, Lock, Check } from "./icons";
interface Props { interface Props {
link: ShortLink; link: ShortLink;
onReset: () => void; /** True when the link was copied automatically on creation. */
onCopied: () => void; autoCopied: boolean;
onShowQr: (link: ShortLink) => void;
} }
const modeBadge: Record<string, string> = { export function ResultCard({ link, autoCopied, onShowQr }: Props) {
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 ( return (
<motion.div <div className="card flex items-center gap-3 p-3 pl-4">
initial={{ opacity: 0, y: 24, scale: 0.96 }} <div className="min-w-0 flex-1">
animate={{ opacity: 1, y: 0, scale: 1 }} <p className="truncate font-mono text-[15px] font-semibold text-ink">
exit={{ opacity: 0, y: -16, scale: 0.96 }} {shortUrl(link.code)}
transition={{ type: "spring", stiffness: 320, damping: 24 }} {link.hasPin && <Lock size={12} className="ml-1.5 inline text-muted" />}
className="card relative overflow-hidden p-5 sm:p-7" </p>
> <p className="truncate text-[12px] text-muted" title={link.longUrl}>
{/* confetti-ish accent corner */} {autoCopied ? (
<div className="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full bg-accent opacity-20 blur-2xl" /> <span className="inline-flex items-center gap-1 text-accent">
<Check size={12} /> Copied to clipboard
<div className="mb-4 flex items-center gap-2"> </span>
<motion.span ) : (
initial={{ scale: 0, rotate: -40 }} <>→ {prettyHost(link.longUrl)}</>
animate={{ scale: 1, rotate: 0 }} )}
transition={{ type: "spring", stiffness: 500, damping: 12, delay: 0.1 }} </p>
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>
<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 <button
onClick={onReset} type="button"
className="focusable mt-4 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-muted hover:text-ink" onClick={() => onShowQr(link)}
aria-label="Show QR code"
className="press focusable grid h-9 w-9 shrink-0 place-items-center rounded-lg text-muted hover:bg-surface-2 hover:text-ink"
> >
<span className="text-base">+</span> Shorten another link <Qr size={16} />
</button> </button>
</motion.div> <CopyButton
text={fullShortUrl(link.code)}
label="Copy"
className="h-9 shrink-0 rounded-lg border border-line bg-surface px-3 text-[13px] text-ink hover:bg-surface-2"
/>
</div>
); );
} }
+22 -35
View File
@@ -1,10 +1,8 @@
import type { ReactNode } from "react"; import { Lock } from "./icons";
export interface Segment<T extends string> { export interface Segment<T extends string> {
value: T; value: T;
label: string; label: string;
icon?: ReactNode;
hint?: string;
locked?: boolean; locked?: boolean;
} }
@@ -12,16 +10,30 @@ interface Props<T extends string> {
segments: Segment<T>[]; segments: Segment<T>[];
value: T; value: T;
onChange: (value: T) => void; onChange: (value: T) => void;
layoutId?: string;
} }
// ponytail: the sliding pill is a CSS transform, not framer-motion `layoutId` —
// layoutId inside an `AnimatePresence mode="wait"` subtree never finishes exiting.
export function SegmentedControl<T extends string>({ export function SegmentedControl<T extends string>({
segments, segments,
value, value,
onChange, onChange,
}: Props<T>) { }: Props<T>) {
const index = Math.max(
0,
segments.findIndex((s) => s.value === value),
);
return ( return (
<div className="flex flex-col gap-1.5 rounded-2xl border-[1.5px] border-line bg-surface-2 p-1.5 sm:flex-row"> <div className="relative flex rounded-xl bg-surface-2 p-1">
<span
aria-hidden
className="absolute inset-y-1 left-1 rounded-lg bg-surface shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_var(--line)] transition-transform duration-200 ease-out"
style={{
width: `calc((100% - 0.5rem) / ${segments.length})`,
transform: `translateX(${index * 100}%)`,
}}
/>
{segments.map((seg) => { {segments.map((seg) => {
const active = seg.value === value; const active = seg.value === value;
return ( return (
@@ -29,38 +41,13 @@ export function SegmentedControl<T extends string>({
key={seg.value} key={seg.value}
type="button" type="button"
onClick={() => onChange(seg.value)} onClick={() => onChange(seg.value)}
className={`focusable relative flex-1 rounded-xl px-3 py-2.5 text-left transition-all duration-200 ${ aria-pressed={active}
active className={`focusable relative z-10 flex flex-1 items-center justify-center gap-1 rounded-lg py-1.5 text-[13px] font-medium transition-colors ${
? "bg-[var(--surface)] shadow-[0_4px_14px_-6px_rgba(0,0,0,0.3)] ring-[1.5px] ring-[var(--ring)]" active ? "text-ink" : "text-muted hover:text-ink"
: ""
}`} }`}
> >
<span className="relative z-10 flex items-center gap-2"> {seg.label}
<span {seg.locked && <Lock size={12} />}
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> </button>
); );
})} })}
+211 -231
View File
@@ -1,5 +1,5 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { api } from "../lib/api"; import { api } from "../lib/api";
@@ -8,20 +8,38 @@ import {
normalizeUrl, normalizeUrl,
validateCustomAlias, validateCustomAlias,
} from "../lib/shortcode"; } from "../lib/shortcode";
import { getShortDomain } from "../lib/format"; import { fullShortUrl, getShortDomain } from "../lib/format";
import type { ShortLink, UrlMode } from "../lib/types"; import type { ShortLink, UrlMode } from "../lib/types";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { useToast } from "./ui/Toast"; import { useToast } from "./ui/Toast";
import { SegmentedControl, type Segment } from "./SegmentedControl"; import { SegmentedControl, type Segment } from "./SegmentedControl";
import { PinInput } from "./PinInput"; import { PinInput } from "./PinInput";
import { ResultCard } from "./ResultCard"; import { ResultCard } from "./ResultCard";
import { QrModal } from "./QrModal";
import { Lock, ArrowRight } from "./icons";
const spring = { type: "spring", stiffness: 300, damping: 26 } as const; const ease = { duration: 0.22, ease: [0.23, 1, 0.32, 1] } as const;
// Shared reveal for the optional rows (alias, PIN) so they open the same way.
const collapse = {
initial: { opacity: 0, height: 0 },
animate: { opacity: 1, height: "auto" },
exit: { opacity: 0, height: 0, transition: { duration: 0.15 } },
transition: ease,
className: "overflow-hidden",
} as const;
const previews: Record<UrlMode, string> = {
random: "x7Qk",
memorable: "amber-otter-loop",
custom: "your-name",
};
export function ShortenForm() { export function ShortenForm() {
const { user } = useAuth(); const { user } = useAuth();
const toast = useToast(); const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [mode, setMode] = useState<UrlMode>("random"); const [mode, setMode] = useState<UrlMode>("random");
@@ -31,7 +49,16 @@ export function ShortenForm() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [errorInput, setErrorInput] = useState<"url" | "alias" | null>(null); const [errorInput, setErrorInput] = useState<"url" | "alias" | null>(null);
const [result, setResult] = useState<ShortLink | null>(null); // Links made this visit, newest first. ponytail: in-memory only; persist
// to localStorage if people ask to see them after a reload.
const [results, setResults] = useState<{ link: ShortLink; copied: boolean }[]>([]);
const [qrTarget, setQrTarget] = useState<ShortLink | null>(null);
// Focus the field on devices with a keyboard; on phones that would pop the
// keyboard over the page before the user has read it.
useEffect(() => {
if (window.matchMedia("(pointer: fine)").matches) inputRef.current?.focus();
}, []);
function showError(msg: string, input: "url" | "alias" | null = null) { function showError(msg: string, input: "url" | "alias" | null = null) {
setError(msg); setError(msg);
@@ -43,25 +70,14 @@ export function ShortenForm() {
} }
const segments: Segment<UrlMode>[] = [ const segments: Segment<UrlMode>[] = [
{ value: "random", label: "Random", icon: "🎲", hint: "Shortest code" }, { value: "random", label: "Random" },
{ { value: "memorable", label: "Words" },
value: "memorable", { value: "custom", label: "Custom", locked: !user },
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) { function onMode(value: UrlMode) {
if (value === "custom" && !user) { if (value === "custom" && !user) {
toast("Sign in to create custom links", "info"); toast("Sign in to choose your own ending", "info");
navigate("/login"); navigate("/login");
return; return;
} }
@@ -69,16 +85,15 @@ export function ShortenForm() {
setMode(value); setMode(value);
} }
async function submit(e: React.FormEvent) { async function create(rawUrl: string) {
e.preventDefault();
clearError(); clearError();
if (!url.trim()) { if (!rawUrl.trim()) {
showError("Paste a link to shorten.", "url"); showError("Paste a link to shorten.", "url");
return; return;
} }
if (!isValidUrl(url)) { if (!isValidUrl(rawUrl)) {
showError("Hmm, that doesn't look like a valid URL.", "url"); showError("That doesn't look like a link.", "url");
return; return;
} }
if (mode === "custom") { if (mode === "custom") {
@@ -95,18 +110,26 @@ export function ShortenForm() {
setBusy(true); setBusy(true);
try { try {
console.log("[snip] createLink start", { mode });
const link = await api.createLink({ const link = await api.createLink({
longUrl: normalizeUrl(url), longUrl: normalizeUrl(rawUrl),
mode, mode,
customAlias: mode === "custom" ? alias : undefined, customAlias: mode === "custom" ? alias : undefined,
pin: pinOn && user ? pin : undefined, pin: pinOn && user ? pin : undefined,
}); });
console.log("[snip] createLink ok", link.code); // Copy straight away — the next thing anyone does with a short link is paste it.
setResult(link); let copied = true;
toast("Link created — ready to share", "success"); try {
await navigator.clipboard.writeText(fullShortUrl(link.code));
} catch {
copied = false;
}
setResults((r) => [{ link, copied }, ...r.map((x) => ({ ...x, copied: false }))]);
setUrl("");
setAlias("");
setPin("");
setPinOn(false);
inputRef.current?.focus();
} catch (err) { } catch (err) {
console.log("[snip] createLink error", err);
const msg = err instanceof Error ? err.message : "Something went wrong."; const msg = err instanceof Error ? err.message : "Something went wrong.";
showError(msg, mode === "custom" ? "alias" : null); showError(msg, mode === "custom" ? "alias" : null);
} finally { } finally {
@@ -114,219 +137,176 @@ export function ShortenForm() {
} }
} }
function reset() { // Pasting a link into an empty field is the whole job — do it immediately,
setResult(null); // unless the user still has choices to make (custom ending, PIN).
setUrl(""); function onPaste(e: React.ClipboardEvent<HTMLInputElement>) {
setAlias(""); const pasted = e.clipboardData.getData("text").trim();
setPin(""); if (url || busy || mode === "custom" || pinOn || !isValidUrl(pasted)) return;
setPinOn(false); e.preventDefault();
setMode("random"); setUrl(pasted);
clearError(); create(pasted);
} }
const preview = mode === "custom" ? alias || previews.custom : previews[mode];
return ( return (
<div className="relative"> <div>
<AnimatePresence mode="wait"> <form
{result ? ( onSubmit={(e) => {
<ResultCard e.preventDefault();
key="result" create(url);
link={result} }}
onReset={reset} // Our own validation accepts "example.com" (we add https://); the
onCopied={() => toast("Copied to clipboard", "success")} // browser's type=url check would reject it.
noValidate
className="card p-2"
>
{/* Primary action: paste, shorten. */}
<div
className={`field flex flex-col gap-2 p-1.5 sm:flex-row sm:items-center ${
errorInput === "url" ? "field-error" : ""
}`}
>
<input
ref={inputRef}
value={url}
onChange={(e) => {
setUrl(e.target.value);
if (errorInput === "url") clearError();
}}
onPaste={onPaste}
placeholder="Paste a long link…"
aria-label="Long URL"
type="url"
inputMode="url"
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
// 16px on mobile: anything smaller makes iOS Safari zoom on focus.
className="h-11 w-full min-w-0 bg-transparent px-2.5 font-mono text-[16px] text-ink outline-none placeholder:font-sans placeholder:text-muted sm:flex-1 sm:text-[15px]"
/> />
) : ( <Button type="submit" disabled={busy} className="h-11 w-full sm:w-auto sm:shrink-0">
<motion.form <span className="swap">
key="form" <span data-off={busy} className="inline-flex items-center justify-center gap-2">
onSubmit={submit} Shorten <ArrowRight size={16} />
initial={{ opacity: 0, y: 18 }} </span>
animate={{ opacity: 1, y: 0 }} <span data-off={!busy} className="grid place-items-center">
exit={{ opacity: 0, y: -16, scale: 0.98 }} <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent [animation-duration:600ms]" />
transition={spring} </span>
className="card p-4 sm:p-6" </span>
> </Button>
{/* URL field */} </div>
<label className="mb-2 block text-sm font-semibold text-ink">
Long URL {/* Options */}
</label> <div className="flex flex-col gap-2 px-1.5 pb-1.5 pt-3 sm:flex-row sm:items-center">
<div <div className="sm:w-72">
className={`flex items-center gap-2 rounded-2xl border-[1.5px] bg-surface-2 px-3.5 transition-colors ${ <SegmentedControl segments={segments} value={mode} onChange={onMode} />
errorInput === "url" ? "border-red-400/70" : "border-line" </div>
{user && (
<button
type="button"
onClick={() => setPinOn((v) => !v)}
aria-pressed={pinOn}
className={`press focusable inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-[13px] font-medium ${
pinOn ? "bg-surface-2 text-ink" : "text-muted hover:text-ink"
}`} }`}
> >
<span className="text-lg opacity-50">🔗</span> <Lock size={14} />
<input {pinOn ? "PIN on" : "Add PIN"}
value={url} </button>
onChange={(e) => { )}
setUrl(e.target.value); <span className="truncate px-1 font-mono text-[12px] text-muted sm:ml-auto">
if (errorInput === "url") clearError(); {getShortDomain()}/<span className="text-ink">{preview}</span>
}} </span>
placeholder="paste a long link, e.g. acme.com/spring/launch…" </div>
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 */} <AnimatePresence initial={false}>
<div className="mt-5"> {mode === "custom" && (
<div className="mb-2 flex items-center justify-between"> <motion.div key="alias" {...collapse}>
<span className="text-sm font-semibold text-ink"> <div className="px-1.5 pb-1.5 pt-1">
Link style <div
</span> className={`field flex items-center overflow-hidden ${
<span className="text-xs text-muted"> errorInput === "alias" ? "field-error" : ""
{getShortDomain()}/ }`}
<span className="text-accent"> >
{mode === "random" <span className="select-none pl-3 font-mono text-[15px] text-muted sm:text-sm">
? "x7Qk" {getShortDomain()}/
: mode === "memorable"
? "amber-otter-loop"
: alias || "your-name"}
</span> </span>
</span> <input
</div> value={alias}
<SegmentedControl onChange={(e) => {
segments={segments} setAlias(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""));
value={mode} if (errorInput === "alias") clearError();
onChange={onMode} }}
layoutId="mode-pill" placeholder="spring-launch"
/> aria-label="Custom ending"
</div> autoCapitalize="off"
spellCheck={false}
{/* Custom alias */} className="h-11 w-full min-w-0 bg-transparent pr-3 font-mono text-[16px] text-ink outline-none placeholder:text-muted sm:text-sm"
<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>
)} </div>
</div> </motion.div>
)}
</AnimatePresence>
{/* Error */} <AnimatePresence initial={false}>
<AnimatePresence> {pinOn && (
{error && ( <motion.div key="pin" {...collapse}>
<motion.p <div className="px-1.5 pb-1.5 pt-1">
initial={{ opacity: 0, x: -6 }} <PinInput value={pin} onChange={setPin} />
animate={{ <p className="mt-2 px-0.5 text-[12px] text-muted">
opacity: 1, Visitors enter this before they're redirected.
x: [0, -6, 6, -4, 4, 0], </p>
}} </div>
exit={{ opacity: 0 }} </motion.div>
transition={{ duration: 0.4 }} )}
className="mt-3 text-sm font-medium text-red-500" </AnimatePresence>
>
{error}
</motion.p>
)}
</AnimatePresence>
{/* Submit */} <AnimatePresence initial={false}>
<Button {error && (
type="submit" <motion.p key="error" role="alert" {...collapse}>
size="lg" <span className="block px-2.5 pb-2 pt-1 text-[13px] font-medium text-danger">
block {error}
disabled={busy} </span>
className="mt-5" </motion.p>
)}
</AnimatePresence>
</form>
{!user && results.length === 0 && (
<p className="mt-4 text-center text-[13px] text-muted">
<button
type="button"
onClick={() => navigate("/login")}
className="focusable rounded font-medium text-ink underline decoration-[var(--ring)] underline-offset-4 hover:decoration-current"
>
Sign in
</button>{" "}
to pick your own ending, add a PIN, and track clicks.
</p>
)}
{/* Results, newest on top. Height + opacity so the list below makes room smoothly. */}
<div className="mt-3">
<AnimatePresence initial={false}>
{results.map(({ link, copied }) => (
<motion.div
key={link.id}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
transition={ease}
className="overflow-hidden"
> >
{busy ? ( <div className="pb-2">
<span className="inline-block h-5 w-5 animate-spin rounded-full border-[2.5px] border-current border-t-transparent" /> <ResultCard link={link} autoCopied={copied} onShowQr={setQrTarget} />
) : ( </div>
<> </motion.div>
Shorten it <span className="text-lg">✦</span> ))}
</> </AnimatePresence>
)} </div>
</Button>
</motion.form> <QrModal link={qrTarget} onClose={() => setQrTarget(null)} />
)}
</AnimatePresence>
</div> </div>
); );
} }
+6 -22
View File
@@ -1,4 +1,3 @@
import { motion } from "framer-motion";
import type { DayCount } from "../lib/types"; import type { DayCount } from "../lib/types";
interface Props { interface Props {
@@ -7,7 +6,7 @@ interface Props {
height?: number; height?: number;
} }
export function Sparkline({ data, width = 84, height = 30 }: Props) { export function Sparkline({ data, width = 64, height = 24 }: Props) {
const max = Math.max(1, ...data.map((d) => d.count)); const max = Math.max(1, ...data.map((d) => d.count));
const n = data.length; const n = data.length;
const stepX = n > 1 ? width / (n - 1) : width; const stepX = n > 1 ? width / (n - 1) : width;
@@ -21,34 +20,19 @@ export function Sparkline({ data, width = 84, height = 30 }: Props) {
}); });
const line = pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" "); 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]; const last = pts[pts.length - 1];
return ( return (
<svg width={width} height={height} className="overflow-visible"> <svg width={width} height={height} className="overflow-visible text-muted">
<polygon points={area} fill="var(--accent)" opacity={0.16} /> <polyline
<motion.polyline
points={line} points={line}
fill="none" fill="none"
stroke="var(--accent)" stroke="currentColor"
strokeWidth={2} strokeWidth={1.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 120, damping: 20 }}
/> />
{last && ( {last && <circle cx={last[0]} cy={last[1]} r={2.4} className="fill-[var(--ink)]" />}
<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> </svg>
); );
} }
+8 -14
View File
@@ -1,26 +1,20 @@
import { motion } from "framer-motion";
import { useTheme } from "../context/ThemeContext"; import { useTheme } from "../context/ThemeContext";
import { Sun, Moon } from "./icons";
export function ThemeToggle() { export function ThemeToggle() {
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const dark = theme === "dark"; const dark = theme === "dark";
return ( return (
<motion.button <button
onClick={toggle} onClick={toggle}
whileTap={{ scale: 0.9 }}
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"} aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
className="focusable relative grid h-10 w-10 place-items-center overflow-hidden rounded-xl border-[1.5px] border-line bg-surface" className="press focusable grid h-9 w-9 place-items-center rounded-lg text-muted hover:bg-surface-2 hover:text-ink"
> >
<motion.span <span className="swap">
key={theme} <Sun size={17} data-off={dark} />
initial={{ y: 18, rotate: -90, opacity: 0, scale: 0.5 }} <Moon size={17} data-off={!dark} />
animate={{ y: 0, rotate: 0, opacity: 1, scale: 1 }} </span>
transition={{ type: "spring", stiffness: 500, damping: 14 }} </button>
className="text-[17px]"
>
{dark ? "🌙" : "☀️"}
</motion.span>
</motion.button>
); );
} }
+48 -98
View File
@@ -1,142 +1,92 @@
import { motion } from "framer-motion"; import { compactNumber, fullShortUrl, prettyHost, relativeTime, shortUrl } from "../lib/format";
import type { ShortLink } from "../lib/types";
import {
compactNumber,
fullShortUrl,
prettyHost,
relativeTime,
shortUrl,
} from "../lib/format";
import { BarChart } from "./BarChart"; import { BarChart } from "./BarChart";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { CopyButton } from "./ui/CopyButton";
interface Props { import { HoldToDelete } from "./ui/HoldToDelete";
link: ShortLink; import { Lock } from "./icons";
onEdit: (link: ShortLink) => void; import type { LinkItemProps } from "./UrlRow";
onDelete: (link: ShortLink) => void;
onCopy: () => void;
onManagePin: (link: ShortLink) => void;
onShowQr: (link: ShortLink) => void;
}
const modeLabel: Record<string, string> = { const modeLabel: Record<string, string> = {
random: "random", random: "Random",
memorable: "memorable", memorable: "Words",
custom: "custom", custom: "Custom",
}; };
export function UrlCard({ export function UrlCard({
link, link,
onEdit, onEdit,
onDelete, onDelete,
onCopy, onDeleteTap,
onManagePin, onManagePin,
onShowQr, onShowQr,
}: Props) { }: LinkItemProps) {
const weekTotal = link.last7Days.reduce((s, d) => s + d.count, 0); 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 ( return (
<motion.div <div className="card flex flex-col gap-5 p-5 sm:flex-row sm:items-stretch sm:gap-6">
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 */} {/* Left: identity + actions */}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 text-[12px] text-muted">
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-accent-ink"> <span>{modeLabel[link.mode] ?? link.mode}</span>
{modeLabel[link.mode]}
</span>
{link.hasPin && ( {link.hasPin && (
<span className="rounded-full border-[1.5px] border-line px-2 py-0.5 text-[10px] font-medium text-muted"> <>
🔒 PIN <span aria-hidden>·</span>
</span> <span className="inline-flex items-center gap-1">
<Lock size={11} /> PIN
</span>
</>
)} )}
<span className="ml-auto text-[11px] text-muted"> <span aria-hidden>·</span>
{relativeTime(link.createdAt)} <span>{relativeTime(link.createdAt)}</span>
</span>
</div> </div>
<a <p className="mt-1.5 truncate font-mono text-lg font-semibold text-ink">
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)} {shortUrl(link.code)}
</a> </p>
<p className="mt-0.5 truncate text-[13px] text-muted" title={link.longUrl}>
<p
className="mt-1 truncate text-[13px] text-muted"
title={link.longUrl}
>
→ {prettyHost(link.longUrl)} → {prettyHost(link.longUrl)}
<span className="opacity-60">{new URL(link.longUrl).pathname}</span>
</p> </p>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-4"> <div className="mt-auto flex flex-wrap items-center gap-1 pt-4">
<Button size="sm" variant="outline" onClick={copy}> <CopyButton
Copy text={fullShortUrl(link.code)}
</Button> label="Copy"
size={14}
className="h-8 rounded-lg bg-[var(--ink)] px-3 text-[13px] text-[var(--bg)] hover:opacity-85"
/>
<Button size="sm" variant="ghost" onClick={() => onShowQr(link)}> <Button size="sm" variant="ghost" onClick={() => onShowQr(link)}>
QR QR
</Button> </Button>
<Button size="sm" variant="ghost" onClick={() => onEdit(link)}> <Button size="sm" variant="ghost" onClick={() => onEdit(link)}>
Edit Edit
</Button> </Button>
<Button <Button size="sm" variant="ghost" onClick={() => onManagePin(link)}>
size="sm" {link.hasPin ? "PIN" : "Add PIN"}
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> </Button>
<HoldToDelete
label="Delete"
onConfirm={() => onDelete(link)}
onTap={onDeleteTap}
className="ml-auto h-8 rounded-lg px-3 text-[13px]"
/>
</div> </div>
</div> </div>
{/* Right: stats */} {/* Right: stats */}
<div className="flex w-full flex-col rounded-2xl border-[1.5px] border-line bg-surface-2 p-4 sm:w-72"> <div className="flex w-full flex-col rounded-xl bg-surface-2 p-4 sm:w-64">
<div className="mb-3 flex items-end justify-between"> <div className="mb-3 flex items-end justify-between tabular-nums">
<div> <div>
<p className="text-[11px] font-medium uppercase tracking-wide text-muted"> <p className="text-[12px] text-muted">Clicks</p>
Total clicks <p className="text-2xl font-semibold tracking-[-0.02em] text-ink">
</p>
<p className="font-display text-2xl font-bold text-ink">
{compactNumber(link.totalClicks)} {compactNumber(link.totalClicks)}
</p> </p>
</div> </div>
<div className="text-right"> <p className="text-[13px] font-medium text-accent">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted"> +{compactNumber(weekTotal)} <span className="text-muted">7d</span>
Last 7 days </p>
</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> </div>
<BarChart data={link.last7Days} /> <BarChart data={link.last7Days} height={64} />
</div> </div>
</motion.div> </div>
); );
} }
+53 -127
View File
@@ -1,156 +1,82 @@
import { motion } from "framer-motion";
import type { ShortLink } from "../lib/types"; import type { ShortLink } from "../lib/types";
import { import { compactNumber, fullShortUrl, prettyHost, shortUrl } from "../lib/format";
compactNumber,
fullShortUrl,
prettyHost,
shortUrl,
} from "../lib/format";
import { Sparkline } from "./Sparkline"; import { Sparkline } from "./Sparkline";
import { CopyButton } from "./ui/CopyButton";
import { HoldToDelete } from "./ui/HoldToDelete";
import { Qr, Lock, Edit } from "./icons";
interface Props { export interface LinkItemProps {
link: ShortLink; link: ShortLink;
onEdit: (link: ShortLink) => void; onEdit: (link: ShortLink) => void;
onDelete: (link: ShortLink) => void; onDelete: (link: ShortLink) => void;
onCopy: () => void; onDeleteTap: () => void;
onManagePin: (link: ShortLink) => void; onManagePin: (link: ShortLink) => void;
onShowQr: (link: ShortLink) => void; onShowQr: (link: ShortLink) => void;
} }
function QrGlyph() { export const iconBtn =
return ( "press focusable grid h-8 w-8 place-items-center rounded-lg text-muted hover:bg-surface-2 hover:text-ink";
<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({ export function UrlRow({
link, link,
onEdit, onEdit,
onDelete, onDelete,
onCopy, onDeleteTap,
onManagePin, onManagePin,
onShowQr, onShowQr,
}: Props) { }: LinkItemProps) {
const weekTotal = link.last7Days.reduce((s, d) => s + d.count, 0); 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 ( return (
<motion.div <div className="card flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:gap-4">
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 */} {/* identity */}
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="min-w-0 flex-1">
<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"> <div className="flex items-center gap-1.5">
{link.mode === "custom" ? "✏️" : link.mode === "memorable" ? "🌿" : "🎲"} <p className="truncate font-mono text-sm font-semibold text-ink">
</span> {shortUrl(link.code)}
<div className="min-w-0"> </p>
<div className="flex items-center gap-1.5"> {link.hasPin && <Lock size={12} className="shrink-0 text-muted" />}
<a </div>
href={fullShortUrl(link.code)} <p className="truncate text-[12px] text-muted" title={link.longUrl}>
onClick={(e) => e.preventDefault()} → {prettyHost(link.longUrl)}
className="focusable truncate rounded font-mono text-[15px] font-bold text-ink hover:text-accent" </p>
> </div>
{shortUrl(link.code)}
</a> <div className="flex items-center justify-between gap-4">
{link.hasPin && <span className="text-[11px]">🔒</span>} {/* stats */}
<div className="flex shrink-0 items-center gap-3 tabular-nums">
<div className="hidden sm:block">
<Sparkline data={link.last7Days} />
</div> </div>
<p className="truncate text-[12px] text-muted" title={link.longUrl}> <p className="text-sm font-semibold text-ink sm:w-14 sm:text-right">
{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)} {compactNumber(link.totalClicks)}
</p> {weekTotal > 0 && (
<p className="text-[10px] font-medium uppercase tracking-wide text-muted"> <span className="block text-[11px] font-medium text-accent">
clicks +{compactNumber(weekTotal)}
</span>
)}
</p> </p>
</div> </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 */} {/* actions */}
<div className="flex shrink-0 items-center gap-1.5"> <div className="-mr-1.5 flex shrink-0 items-center">
<IconButton label="Copy link" onClick={copy}> <CopyButton text={fullShortUrl(link.code)} className={iconBtn} />
⧉ <button onClick={() => onShowQr(link)} aria-label="Show QR code" className={iconBtn}>
</IconButton> <Qr size={15} />
<IconButton label="Show QR code" onClick={() => onShowQr(link)}> </button>
<QrGlyph /> <button onClick={() => onManagePin(link)} aria-label="Manage PIN" className={iconBtn}>
</IconButton> <Lock size={15} />
<IconButton label="Manage PIN" onClick={() => onManagePin(link)}> </button>
🔒 <button onClick={() => onEdit(link)} aria-label="Edit destination" className={iconBtn}>
</IconButton> <Edit size={15} />
<IconButton label="Edit destination" onClick={() => onEdit(link)}> </button>
✏️ <HoldToDelete
</IconButton> onConfirm={() => onDelete(link)}
<IconButton label="Delete link" danger onClick={() => onDelete(link)}> onTap={onDeleteTap}
🗑 className="h-8 w-8 rounded-lg hover:bg-surface-2"
</IconButton> />
</div>
</div> </div>
</motion.div> </div>
); );
} }
+178
View File
@@ -0,0 +1,178 @@
// Minimal stroke icons (lucide-style), themed via currentColor.
import type { SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement> & { size?: number };
function Svg({ size = 18, children, ...rest }: IconProps & { children: React.ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
{...rest}
>
{children}
</svg>
);
}
export const Link = (p: IconProps) => (
<Svg {...p}>
<path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</Svg>
);
export const Copy = (p: IconProps) => (
<Svg {...p}>
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</Svg>
);
export const Check = (p: IconProps) => (
<Svg {...p}>
<path d="m20 6-11 11-5-5" />
</Svg>
);
export const Qr = (p: IconProps) => (
<Svg {...p}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<path d="M14 14h3v3M21 14v.01M14 21h.01M17 21h4v-4" />
</Svg>
);
export const Edit = (p: IconProps) => (
<Svg {...p}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</Svg>
);
export const Trash = (p: IconProps) => (
<Svg {...p}>
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6M14 11v6" />
</Svg>
);
export const Lock = (p: IconProps) => (
<Svg {...p}>
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</Svg>
);
export const Plus = (p: IconProps) => (
<Svg {...p}>
<path d="M12 5v14M5 12h14" />
</Svg>
);
export const Search = (p: IconProps) => (
<Svg {...p}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</Svg>
);
export const X = (p: IconProps) => (
<Svg {...p}>
<path d="M18 6 6 18M6 6l12 12" />
</Svg>
);
export const Sun = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</Svg>
);
export const Moon = (p: IconProps) => (
<Svg {...p}>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
</Svg>
);
export const ChevronLeft = (p: IconProps) => (
<Svg {...p}>
<path d="m15 18-6-6 6-6" />
</Svg>
);
export const ChevronRight = (p: IconProps) => (
<Svg {...p}>
<path d="m9 18 6-6-6-6" />
</Svg>
);
export const ChevronDown = (p: IconProps) => (
<Svg {...p}>
<path d="m6 9 6 6 6-6" />
</Svg>
);
export const LogOut = (p: IconProps) => (
<Svg {...p}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5M21 12H9" />
</Svg>
);
export const ArrowRight = (p: IconProps) => (
<Svg {...p}>
<path d="M5 12h14M13 5l7 7-7 7" />
</Svg>
);
export const Shuffle = (p: IconProps) => (
<Svg {...p}>
<path d="M18 4h3v3M3 20l18-16M21 16v3h-3M14 14l5 5M4 4l6 6" />
</Svg>
);
export const Sparkle = (p: IconProps) => (
<Svg {...p}>
<path d="M12 3v18M3 12h18M6.5 6.5l11 11M17.5 6.5l-11 11" opacity={0.55} />
<path d="M12 7v10M7 12h10" />
</Svg>
);
export const Type = (p: IconProps) => (
<Svg {...p}>
<path d="M4 7V5h16v2M9 19h6M12 5v14" />
</Svg>
);
export const Download = (p: IconProps) => (
<Svg {...p}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</Svg>
);
export const Grid = (p: IconProps) => (
<Svg {...p}>
<rect x="3" y="3" width="7" height="9" rx="1" />
<rect x="14" y="3" width="7" height="5" rx="1" />
<rect x="14" y="12" width="7" height="9" rx="1" />
<rect x="3" y="16" width="7" height="5" rx="1" />
</Svg>
);
export const Scissors = (p: IconProps) => (
<Svg {...p}>
<circle cx="6" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12" />
</Svg>
);
+15 -19
View File
@@ -1,10 +1,9 @@
import { motion, type HTMLMotionProps } from "framer-motion"; import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { forwardRef, type ReactNode } from "react";
type Variant = "primary" | "ghost" | "outline" | "dark"; type Variant = "primary" | "ghost" | "outline" | "danger";
type Size = "sm" | "md" | "lg"; type Size = "sm" | "md" | "lg";
interface Props extends Omit<HTMLMotionProps<"button">, "children"> { interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant; variant?: Variant;
size?: Size; size?: Size;
children: ReactNode; children: ReactNode;
@@ -12,36 +11,33 @@ interface Props extends Omit<HTMLMotionProps<"button">, "children"> {
} }
const sizes: Record<Size, string> = { const sizes: Record<Size, string> = {
sm: "h-9 px-3.5 text-sm gap-1.5", sm: "h-8 px-3 text-[13px] gap-1.5 rounded-lg",
md: "h-11 px-5 text-[15px] gap-2", md: "h-10 px-4 text-sm gap-2 rounded-xl",
lg: "h-14 px-7 text-base gap-2.5", lg: "h-12 px-5 text-[15px] gap-2 rounded-xl",
}; };
const variants: Record<Variant, string> = { const variants: Record<Variant, string> = {
primary: // solid ink — the one strong action per view
"bg-accent text-accent-ink font-semibold shadow-[0_8px_24px_-8px_var(--glow)] hover:brightness-105", primary: "bg-[var(--ink)] text-[var(--bg)] hover:opacity-85",
dark: "bg-[var(--ink)] text-[var(--bg)] font-semibold hover:opacity-90", danger: "bg-[var(--danger)] text-white hover:opacity-90",
outline: outline: "bg-surface text-ink border border-line hover:bg-surface-2",
"bg-transparent text-ink font-medium border-[1.5px] border-line hover:bg-surface-2", ghost: "text-muted hover:bg-surface-2 hover:text-ink",
ghost: "bg-transparent text-ink font-medium hover:bg-surface-2",
}; };
// CSS press feedback (not framer-motion) — runs off the main thread and needs no JS.
export const Button = forwardRef<HTMLButtonElement, Props>(function Button( export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
{ variant = "primary", size = "md", block, className = "", children, ...rest }, { variant = "primary", size = "md", block, className = "", children, ...rest },
ref, ref,
) { ) {
return ( return (
<motion.button <button
ref={ref} ref={ref}
whileTap={{ scale: 0.94 }} className={`press focusable inline-flex select-none items-center justify-center font-medium disabled:opacity-40 ${
whileHover={{ y: -2 }}
transition={{ type: "spring", stiffness: 520, damping: 16 }}
className={`focusable inline-flex select-none items-center justify-center rounded-2xl ${
sizes[size] sizes[size]
} ${variants[variant]} ${block ? "w-full" : ""} ${className}`} } ${variants[variant]} ${block ? "w-full" : ""} ${className}`}
{...rest} {...rest}
> >
{children} {children}
</motion.button> </button>
); );
}); });
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from "react";
import { Check, Copy } from "../icons";
interface Props {
text: string;
/** Visible label; omit for an icon-only button. */
label?: string;
className?: string;
size?: number;
}
/** Copies on click and morphs its icon to a check. The button itself is the
* confirmation, so no toast is needed. */
export function CopyButton({ text, label, className = "", size = 15 }: Props) {
const [copied, setCopied] = useState(false);
const timer = useRef<number>();
useEffect(() => () => clearTimeout(timer.current), []);
async function copy() {
try {
await navigator.clipboard.writeText(text);
} catch {
return; // clipboard blocked — don't claim success
}
setCopied(true);
clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), 1500);
}
return (
<button
type="button"
onClick={copy}
aria-label={label ? undefined : copied ? "Copied" : "Copy link"}
className={`press focusable inline-flex items-center justify-center gap-2 font-medium ${className}`}
>
<span className="swap" aria-hidden>
<Copy size={size} data-off={copied} />
<Check size={size} data-off={!copied} />
</span>
{label && (
<span className="swap">
<span data-off={copied}>{label}</span>
<span data-off={!copied} className="text-center">
Copied
</span>
</span>
)}
<span className="sr-only" aria-live="polite">
{copied ? "Copied to clipboard" : ""}
</span>
</button>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useRef, useState } from "react";
import { Trash } from "../icons";
interface Props {
onConfirm: () => void;
/** Called on a quick tap, so the user learns they need to hold. */
onTap?: () => void;
label?: string;
className?: string;
}
/** Press and hold to delete. Replaces a confirm modal: deliberate (slow fill,
* 1s linear) where the user is deciding, instant snap-back on release.
* Works with pointer and with Space/Enter held down. */
export function HoldToDelete({ onConfirm, onTap, label, className = "" }: Props) {
const [holding, setHolding] = useState(false);
// Ref mirrors state so a press + release in the same frame can't leave the
// button "held" (state would still read false in the release handler).
const held = useRef(false);
const started = useRef(0);
const done = useRef(false);
function set(v: boolean) {
held.current = v;
setHolding(v);
}
function start() {
if (held.current) return; // ignore extra fingers / key repeat
done.current = false;
started.current = Date.now();
set(true);
}
function stop() {
if (!held.current) return;
set(false);
if (!done.current && Date.now() - started.current < 250) onTap?.();
}
return (
<button
type="button"
data-holding={holding}
aria-label={label ? undefined : "Hold to delete"}
title="Hold to delete"
onPointerDown={(e) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
start();
}}
onPointerUp={stop}
onPointerCancel={stop}
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
e.preventDefault();
start();
}
}}
onKeyUp={(e) => (e.key === " " || e.key === "Enter") && stop()}
onBlur={stop}
onContextMenu={(e) => e.preventDefault()}
onTransitionEnd={(e) => {
// Fires when the fill reaches 100% while still held.
if (held.current && e.propertyName === "clip-path" && !done.current) {
done.current = true;
set(false);
onConfirm();
}
}}
className={`press focusable relative isolate inline-flex select-none items-center justify-center gap-1.5 overflow-hidden font-medium text-muted hover:text-[var(--danger)] ${className}`}
>
<Trash size={15} />
{label && <span>{label}</span>}
{/* Overlay copy of the content in white on red, revealed left→right. */}
<span
aria-hidden
className="hold-fill absolute inset-0 flex items-center justify-center gap-1.5 bg-[var(--danger)] text-white"
>
<Trash size={15} />
{label && <span>{label}</span>}
</span>
</button>
);
}
+12 -13
View File
@@ -8,6 +8,8 @@ interface Props {
children: ReactNode; children: ReactNode;
} }
const out = [0.23, 1, 0.32, 1] as const;
export function Modal({ open, onClose, title, children }: Props) { export function Modal({ open, onClose, title, children }: Props) {
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -24,26 +26,23 @@ export function Modal({ open, onClose, title, children }: Props) {
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<motion.div <motion.div
className="fixed inset-0 z-[150] grid place-items-center p-4" className="fixed inset-0 z-[150] grid place-items-end p-3 sm:place-items-center sm:p-4"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1, transition: { duration: 0.2 } }}
exit={{ opacity: 0 }} exit={{ opacity: 0, transition: { duration: 0.15 } }}
> >
<motion.div <div className="absolute inset-0 bg-black/40" onClick={onClose} />
className="absolute inset-0 bg-black/45 backdrop-blur-sm" {/* Centred, so it scales from its own centre. Exit is quicker than enter. */}
onClick={onClose}
/>
<motion.div <motion.div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
initial={{ opacity: 0, y: 28, scale: 0.92 }} initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, scale: 1, y: 0, transition: { duration: 0.24, ease: out } }}
exit={{ opacity: 0, y: 16, scale: 0.95 }} exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.12, ease: "easeOut" } }}
transition={{ type: "spring", stiffness: 360, damping: 26 }} className="card relative z-10 w-full max-w-md p-6 shadow-[var(--shadow-lg)]"
className="card relative z-10 w-full max-w-md p-6 shadow-[0_30px_80px_-30px_rgba(0,0,0,0.7)]"
> >
{title && ( {title && (
<h2 className="mb-4 font-display text-xl font-bold tracking-tight text-ink"> <h2 className="mb-4 text-[17px] font-semibold tracking-[-0.01em] text-ink">
{title} {title}
</h2> </h2>
)} )}
+14 -12
View File
@@ -6,6 +6,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { Check, X, ArrowRight } from "../icons";
type ToastKind = "success" | "error" | "info"; type ToastKind = "success" | "error" | "info";
interface Toast { interface Toast {
@@ -21,9 +22,9 @@ const Ctx = createContext<(message: string, kind?: ToastKind) => void>(
let counter = 0; let counter = 0;
const icons: Record<ToastKind, ReactNode> = { const icons: Record<ToastKind, ReactNode> = {
success: "✓", success: <Check size={13} />,
error: "✕", error: <X size={13} />,
info: "→", info: <ArrowRight size={13} />,
}; };
export function ToastProvider({ children }: { children: ReactNode }) { export function ToastProvider({ children }: { children: ReactNode }) {
@@ -46,19 +47,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<motion.div <motion.div
key={t.id} key={t.id}
layout layout
initial={{ opacity: 0, y: 28, scale: 0.85 }} // Enters and leaves through the same (bottom) edge.
animate={{ opacity: 1, y: 0, scale: 1 }} initial={{ opacity: 0, y: "100%" }}
exit={{ opacity: 0, scale: 0.85, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 480, damping: 24 }} exit={{ opacity: 0, y: "60%", transition: { duration: 0.15, ease: "easeOut" } }}
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)]" transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="card pointer-events-auto flex items-center gap-2.5 rounded-full py-2 pl-2.5 pr-4 shadow-[var(--shadow-lg)]"
> >
<span <span
className={`grid h-5 w-5 place-items-center rounded-full text-[11px] font-bold ${ className={`grid h-5 w-5 place-items-center rounded-full ${
t.kind === "error" t.kind === "error"
? "bg-red-500/15 text-red-500" ? "bg-[var(--danger)] text-white"
: t.kind === "info" : t.kind === "info"
? "bg-[var(--surface-2)] text-ink" ? "bg-[var(--surface-2)] text-muted"
: "bg-accent text-accent-ink" : "bg-[var(--ink)] text-[var(--bg)]"
}`} }`}
> >
{icons[t.kind]} {icons[t.kind]}
+2 -1
View File
@@ -30,7 +30,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
/* ignore */ /* ignore */
} }
const meta = document.querySelector('meta[name="theme-color"]'); const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", theme === "dark" ? "#0e100a" : "#f4f2ea"); // Must track --bg in index.css.
if (meta) meta.setAttribute("content", theme === "dark" ? "#09090b" : "#fbfbfa");
}, [theme]); }, [theme]);
const toggle = useCallback( const toggle = useCallback(
+118 -85
View File
@@ -3,131 +3,164 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
/* Light theme — warm paper + ink */ /* Light — ink on paper. One strong colour (ink) for actions, one quiet
--bg: #f4f2ea; colour (green) reserved for growth numbers. */
--bg-grain: #ece9dd; --bg: #fafafa;
--surface: #fffdf7; --surface: #ffffff;
--surface-2: #f6f3e9; --surface-2: #f4f4f5;
--ink: #16170f; --ink: #111113;
--ink-solid: #16170f; --muted: #6f6f78;
--muted: #6b6c5e; --line: #e7e7ea;
--line: #e2decf; --ring: #d4d4d8;
--ring: #16170f; --accent: #15803d;
--accent: #c6f24e; --accent-soft: rgba(17, 17, 19, 0.08);
--accent-ink: #16170f; --danger: #dc2626;
--glow: rgba(198, 242, 78, 0.45); --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 2px 8px -2px rgba(0, 0, 0, 0.04);
--dot: rgba(22, 23, 15, 0.06); --shadow-lg: 0 24px 48px -16px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.04);
} }
.dark { .dark {
/* Dark theme — deep moss + lime */ --bg: #0a0a0b;
--bg: #0e100a; --surface: #121214;
--bg-grain: #14160e; --surface-2: #1a1a1d;
--surface: #181b11; --ink: #f4f4f5;
--surface-2: #1f2317; --muted: #9b9ba4;
--ink: #f1efe3; --line: #242428;
--ink-solid: #000000; --ring: #33333a;
--muted: #9b9d8a; --accent: #4ade80;
--line: #2c3020; --accent-soft: rgba(244, 244, 245, 0.12);
--ring: #3a3f2a; --danger: #f87171;
--accent: #c6f24e; --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
--accent-ink: #16170f; --shadow-lg: 0 24px 48px -16px rgba(0, 0, 0, 0.7);
--glow: rgba(198, 242, 78, 0.22);
--dot: rgba(241, 239, 227, 0.05);
} }
* { * {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
html {
scroll-behavior: smooth;
}
body { body {
margin: 0; margin: 0;
background-color: var(--bg); background-color: var(--bg);
color: var(--ink); color: var(--ink);
font-family: "General Sans", ui-sans-serif, system-ui, sans-serif; font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
/* Inter's straight-sided 1, open 4/6/9, single-storey a read cleaner in codes. */
font-feature-settings: "cv01", "cv09", "cv11", "ss03";
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
min-height: 100vh; min-height: 100dvh;
transition: background-color 0.5s ease, color 0.4s ease; /* ponytail: no colour transition on theme switch — the body fading while
every card snaps looks broken. Instant is correct. */
} }
/* Atmospheric layered background: dotted grid + soft lime glows */ /* Semantic token utilities (kept stable so components stay theme-driven) */
.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 { background-color: var(--surface); }
.bg-surface-2 { background-color: var(--surface-2); } .bg-surface-2 { background-color: var(--surface-2); }
.text-ink { color: var(--ink); } .text-ink { color: var(--ink); }
.text-muted { color: var(--muted); } .text-muted { color: var(--muted); }
.border-line { border-color: var(--line); } .border-line { border-color: var(--line); }
.bg-accent { background-color: var(--accent); }
.text-accent { color: var(--accent); } .text-accent { color: var(--accent); }
.text-accent-ink { color: var(--accent-ink); } .text-danger { color: var(--danger); }
.ring-ink { --tw-ring-color: var(--ring); }
.card { .card {
background-color: var(--surface); background-color: var(--surface);
border: 1.5px solid var(--line); border: 1px solid var(--line);
border-radius: 1.5rem; border-radius: 1rem;
box-shadow: var(--shadow);
}
/* One field look for every input + input wrapper. */
.field {
background-color: var(--surface);
border: 1px solid var(--line);
border-radius: 0.75rem;
color: var(--ink);
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.field:focus,
.field:focus-within {
border-color: var(--ring);
box-shadow: 0 0 0 4px var(--accent-soft);
}
.field-error,
.field-error:focus,
.field-error:focus-within {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 14%, transparent);
}
/* Anything pressable: instant feedback on press, no hover motion. */
.press {
transition-property: transform, background-color, color, border-color, opacity;
transition-duration: 160ms;
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
.press:active:not(:disabled) {
transform: scale(0.97);
}
/* Two stacked states that crossfade through a blur, so the swap reads as one
object changing rather than two objects trading places. */
.swap {
display: grid;
}
.swap > * {
grid-area: 1 / 1;
transition: opacity 200ms ease, filter 200ms ease, transform 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.swap > [data-off="true"] {
opacity: 0;
filter: blur(4px);
transform: scale(0.8);
}
/* Enter for list items — CSS keyframes, so it stays smooth while React is busy. */
@keyframes rise {
from {
opacity: 0;
transform: translateY(6px);
}
}
.rise {
animation: rise 280ms cubic-bezier(0.23, 1, 0.32, 1) backwards;
}
/* Hold-to-delete: the fill sweeps slowly while held, snaps back on release. */
.hold-fill {
clip-path: inset(0 100% 0 0);
transition: clip-path 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
[data-holding="true"] > .hold-fill {
clip-path: inset(0 0 0 0);
transition: clip-path 1000ms linear;
}
kbd {
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
} }
/* Selection */
::selection { ::selection {
background: var(--accent); background: var(--ink);
color: var(--accent-ink); color: var(--bg);
} }
/* Custom scrollbar */ ::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--line); background: var(--line);
border-radius: 99px; border-radius: 99px;
border: 3px solid var(--bg); border: 3px solid var(--bg);
} }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* Focus ring */
.focusable:focus-visible { .focusable:focus-visible {
outline: none; outline: none;
box-shadow: 0 0 0 3px var(--bg), 0 0 0 5.5px var(--accent); box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ink);
} }
/* Grain overlay for texture */ /* Reduced motion = no movement, keep the fades that explain state. */
.grain::before { @media (prefers-reduced-motion: reduce) {
content: ""; .press:active:not(:disabled) { transform: none; }
position: fixed; .swap > [data-off="true"] { transform: none; }
inset: 0; .rise { animation-name: fade; }
z-index: 9999; @keyframes fade { from { opacity: 0; } }
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");
} }
+4
View File
@@ -1,6 +1,7 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { MotionConfig } from "framer-motion";
import App from "./App"; import App from "./App";
import { ThemeProvider } from "./context/ThemeContext"; import { ThemeProvider } from "./context/ThemeContext";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
@@ -12,6 +13,8 @@ import "./index.css";
function render() { function render() {
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
{/* Honour the OS "reduce motion" setting: framer drops transforms, keeps fades. */}
<MotionConfig reducedMotion="user">
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<ToastProvider> <ToastProvider>
@@ -21,6 +24,7 @@ function render() {
</ToastProvider> </ToastProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</MotionConfig>
</StrictMode>, </StrictMode>,
); );
} }
+123 -145
View File
@@ -1,6 +1,6 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { compactNumber } from "../lib/format"; import { compactNumber } from "../lib/format";
@@ -10,24 +10,14 @@ import { UrlRow } from "../components/UrlRow";
import { Pagination } from "../components/Pagination"; import { Pagination } from "../components/Pagination";
import { PinManager } from "../components/PinManager"; import { PinManager } from "../components/PinManager";
import { EditLinkModal } from "../components/EditLinkModal"; import { EditLinkModal } from "../components/EditLinkModal";
import { DeleteLinkModal } from "../components/DeleteLinkModal";
import { QrModal } from "../components/QrModal"; import { QrModal } from "../components/QrModal";
import { Button } from "../components/ui/Button"; import { Button } from "../components/ui/Button";
import { useToast } from "../components/ui/Toast"; import { useToast } from "../components/ui/Toast";
import { Search, X, Plus } from "../components/icons";
type View = "comfortable" | "compact"; type View = "comfortable" | "compact";
const PAGE_SIZE: Record<View, number> = { comfortable: 5, compact: 8 }; 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 { function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState(value);
useEffect(() => { useEffect(() => {
@@ -38,32 +28,36 @@ function useDebouncedValue<T>(value: T, delay: number): T {
} }
function ViewToggle({ view, onChange }: { view: View; onChange: (v: View) => void }) { function ViewToggle({ view, onChange }: { view: View; onChange: (v: View) => void }) {
const opts: { value: View; icon: string; label: string }[] = [ const opts: { value: View; label: string; rows: number }[] = [
{ value: "comfortable", icon: "▦", label: "Comfortable view" }, { value: "comfortable", label: "Comfortable view", rows: 2 },
{ value: "compact", icon: "≣", label: "Compact view" }, { value: "compact", label: "Compact view", rows: 3 },
]; ];
return ( return (
<div className="flex shrink-0 gap-1 rounded-xl border-[1.5px] border-line bg-surface-2 p-1"> <div className="flex shrink-0 gap-0.5 rounded-xl bg-surface-2 p-1">
{opts.map((o) => ( {opts.map((o) => (
<button <button
key={o.value} key={o.value}
onClick={() => onChange(o.value)} onClick={() => onChange(o.value)}
aria-label={o.label} aria-label={o.label}
aria-pressed={view === o.value} aria-pressed={view === o.value}
className="focusable relative grid h-8 w-9 place-items-center rounded-lg text-[15px]" className={`press focusable grid h-8 w-9 place-items-center rounded-lg ${
view === o.value
? "bg-surface text-ink shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_var(--line)]"
: "text-muted hover:text-ink"
}`}
> >
{view === o.value && ( <svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5">
<motion.span {Array.from({ length: o.rows }).map((_, i) => (
layoutId="view-pill" <rect
transition={{ type: "spring", stiffness: 480, damping: 32 }} key={i}
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)]" x="2"
/> y={2 + i * (11 / o.rows)}
)} width="11"
<span height={11 / o.rows - 1.5}
className={`relative z-10 ${view === o.value ? "text-ink" : "text-muted"}`} rx="1"
> />
{o.icon} ))}
</span> </svg>
</button> </button>
))} ))}
</div> </div>
@@ -81,7 +75,6 @@ export function Dashboard() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [pinTarget, setPinTarget] = useState<ShortLink | null>(null); const [pinTarget, setPinTarget] = useState<ShortLink | null>(null);
const [editTarget, setEditTarget] = 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); const [qrTarget, setQrTarget] = useState<ShortLink | null>(null);
// Server-driven page + aggregate stats (the client never loads all links). // Server-driven page + aggregate stats (the client never loads all links).
@@ -166,12 +159,34 @@ export function Dashboard() {
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
} }
async function handleDeleted(_id: string, message: string) { // Hold-to-delete already asked for intent, so remove the row immediately
setDeleteTarget(null); // and reconcile with the server after.
toast(message, "info"); async function deleteLink(link: ShortLink) {
setPageData((pd) =>
pd ? { items: pd.items.filter((l) => l.id !== link.id), total: pd.total - 1 } : pd,
);
try {
await api.deleteLink(link.id);
toast(`Deleted ${link.code}`, "info");
} catch {
toast("Couldn't delete that link", "error");
}
await Promise.all([loadPage(), loadStats()]); await Promise.all([loadPage(), loadStats()]);
} }
// "/" jumps to search from anywhere on the page.
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement;
if (e.key !== "/" || t.closest("input, textarea, [contenteditable]")) return;
e.preventDefault();
searchRef.current?.focus();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Edit / PIN: patch the row in place (totals are unaffected). // Edit / PIN: patch the row in place (totals are unaffected).
function applyUpdate(updated: ShortLink, message: string) { function applyUpdate(updated: ShortLink, message: string) {
setPageData((pd) => setPageData((pd) =>
@@ -201,73 +216,58 @@ export function Dashboard() {
return ( return (
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8"> <div className="mx-auto max-w-4xl px-4 pb-24 pt-8">
{/* header */} {/* header */}
<motion.div <div className="flex flex-wrap items-end justify-between gap-3">
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> <div>
<h1 className="font-display text-3xl font-bold tracking-tight text-ink sm:text-4xl"> <h1 className="text-2xl font-semibold tracking-[-0.02em] text-ink sm:text-[28px]">
Your links Links
</h1> </h1>
<p className="mt-1 text-sm text-muted">
Search, edit destinations, manage PINs, or retire a link.
</p>
</div> </div>
<Link to="/"> <Button size="md" onClick={() => navigate("/")}>
<Button size="md"> <Plus size={16} /> New link
<span className="text-lg">+</span> New link </Button>
</Button> </div>
</Link>
</motion.div>
{/* stat tiles */} {/* stat tiles */}
<div className="mt-6 grid grid-cols-3 gap-3"> <div className="card mt-6 grid grid-cols-3 divide-x divide-[var(--line)]">
{statTiles.map((s, i) => ( {statTiles.map((s) => (
<motion.div <div key={s.label} className="px-4 py-3.5 sm:px-5 sm:py-4">
key={s.label} <p className="text-[12px] text-muted">{s.label}</p>
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 <p
className={`mt-1 font-display text-2xl font-bold sm:text-3xl ${ className={`mt-0.5 text-xl font-semibold tabular-nums tracking-[-0.02em] sm:text-2xl ${
s.accent ? "text-accent-ink" : "text-ink" s.accent ? "text-accent" : "text-ink"
}`} }`}
> >
{s.accent ? ( {stats ? s.value : <span className="text-[var(--ring)]">—</span>}
<span className="rounded-lg bg-accent px-2">{s.value}</span>
) : (
s.value
)}
</p> </p>
</motion.div> </div>
))} ))}
</div> </div>
{/* toolbar: search + view toggle */} {/* toolbar: search + view toggle */}
{hasAnyLinks && ( {hasAnyLinks && (
<div className="mt-6 flex items-center gap-2.5"> <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"> <div className="field flex h-10 flex-1 items-center gap-2 px-3">
<span className="text-muted">⌕</span> <Search size={16} className="shrink-0 text-muted" />
<input <input
ref={searchRef}
value={query} value={query}
onChange={(e) => updateSearch(e.target.value)} onChange={(e) => updateSearch(e.target.value)}
placeholder="Search by code or URL…" placeholder="Search links…"
className="h-full w-full bg-transparent text-[15px] text-ink outline-none placeholder:text-muted" aria-label="Search links"
className="h-full w-full bg-transparent text-[16px] text-ink outline-none placeholder:text-muted sm:text-sm"
/> />
{!query && (
<kbd className="hidden h-5 shrink-0 place-items-center rounded border border-line px-1.5 text-[11px] text-muted sm:grid">
/
</kbd>
)}
{query && ( {query && (
<button <button
onClick={() => updateSearch("")} onClick={() => updateSearch("")}
aria-label="Clear search" aria-label="Clear search"
className="focusable grid h-6 w-6 place-items-center rounded-md text-muted hover:bg-surface-2 hover:text-ink" className="press focusable grid h-6 w-6 shrink-0 place-items-center rounded-md text-muted hover:bg-surface-2 hover:text-ink"
> >
✕ <X size={14} />
</button> </button>
)} )}
</div> </div>
@@ -277,15 +277,15 @@ export function Dashboard() {
{/* result meta */} {/* result meta */}
{hasAnyLinks && ( {hasAnyLinks && (
<p className="mt-3 text-xs text-muted"> <p className="mt-3 px-0.5 text-xs tabular-nums text-muted">
{query ? ( {query ? (
<> <>
<span className="font-semibold text-ink">{total}</span> of {totalLinks}{" "} <span className="font-medium text-ink">{total}</span> of {totalLinks}{" "}
{totalLinks === 1 ? "link" : "links"} match “{query}” {totalLinks === 1 ? "link" : "links"} match “{query}”
</> </>
) : ( ) : (
<> <>
Showing <span className="font-semibold text-ink">{items.length}</span> of{" "} Showing <span className="font-medium text-ink">{items.length}</span> of{" "}
{totalLinks} {totalLinks === 1 ? "link" : "links"} {totalLinks} {totalLinks === 1 ? "link" : "links"}
</> </>
)} )}
@@ -295,81 +295,69 @@ export function Dashboard() {
{/* list */} {/* list */}
<div className="mt-4"> <div className="mt-4">
{pageData === null ? ( {pageData === null ? (
<div className="space-y-4"> <div className="space-y-3">
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
<div <div
key={i} key={i}
className="card h-28 animate-pulse opacity-60" className="card animate-pulse opacity-60"
style={{ animationDelay: `${i * 120}ms` }} style={{ animationDelay: `${i * 120}ms`, height: view === "compact" ? 64 : 172 }}
/> />
))} ))}
</div> </div>
) : total === 0 && !debouncedQuery ? ( ) : total === 0 && !debouncedQuery ? (
<motion.div <div className="card grid place-items-center gap-2 p-12 text-center">
variants={pop} <p className="text-base font-semibold text-ink">No links yet</p>
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"> <p className="max-w-xs text-sm text-muted">
Create your first short link and it'll show up here with live click Links you shorten while signed in show up here, with clicks per day.
stats.
</p> </p>
<Link to="/" className="mt-1"> <Button className="mt-2" onClick={() => navigate("/")}>
<Button>Create a link</Button> Shorten a link
</Link> </Button>
</motion.div> </div>
) : total === 0 ? ( ) : total === 0 ? (
<motion.div <div className="card grid place-items-center gap-1.5 p-10 text-center">
variants={pop} <p className="text-base font-semibold text-ink">No matches</p>
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> <p className="text-sm text-muted">Nothing matches “{query}”.</p>
<button <button
onClick={() => updateSearch("")} onClick={() => updateSearch("")}
className="focusable mt-1 rounded-lg text-sm font-semibold text-ink underline decoration-accent decoration-2 underline-offset-2" className="focusable mt-1 rounded-lg text-sm font-medium text-ink underline underline-offset-2"
> >
Clear search Clear search
</button> </button>
</motion.div> </div>
) : ( ) : (
<> <>
<motion.div {/* Rows rise in (staggered) when a page loads, but not while typing a
layout search — animation on every keystroke reads as lag. Deleting
className={view === "compact" ? "space-y-2.5" : "space-y-4"} collapses the row so the ones below slide up instead of jumping. */}
> <div>
<AnimatePresence mode="popLayout"> <AnimatePresence initial={false}>
{items.map((link) => {items.map((link, i) => {
view === "compact" ? ( const props = {
<UrlRow link,
onEdit: setEditTarget,
onDelete: deleteLink,
onDeleteTap: () => toast("Hold to delete", "info"),
onManagePin: setPinTarget,
onShowQr: setQrTarget,
};
return (
<motion.div
key={link.id} key={link.id}
link={link} exit={{ opacity: 0, height: 0, transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] } }}
onEdit={setEditTarget} className="overflow-hidden"
onDelete={setDeleteTarget} >
onCopy={() => toast("Copied to clipboard", "success")} <div
onManagePin={setPinTarget} className={`${view === "compact" ? "pb-2" : "pb-3"} ${query ? "" : "rise"}`}
onShowQr={setQrTarget} style={{ animationDelay: `${i * 40}ms` }}
/> >
) : ( {view === "compact" ? <UrlRow {...props} /> : <UrlCard {...props} />}
<UrlCard </div>
key={link.id} </motion.div>
link={link} );
onEdit={setEditTarget} })}
onDelete={setDeleteTarget}
onCopy={() => toast("Copied to clipboard", "success")}
onManagePin={setPinTarget}
onShowQr={setQrTarget}
/>
),
)}
</AnimatePresence> </AnimatePresence>
</motion.div> </div>
<Pagination page={safePage} pageCount={pageCount} onPage={goToPage} /> <Pagination page={safePage} pageCount={pageCount} onPage={goToPage} />
</> </>
@@ -394,17 +382,7 @@ export function Dashboard() {
}} }}
/> />
<QrModal <QrModal link={qrTarget} onClose={() => setQrTarget(null)} />
link={qrTarget}
onClose={() => setQrTarget(null)}
onCopied={() => toast("Copied to clipboard", "success")}
/>
<DeleteLinkModal
link={deleteTarget}
onClose={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
</div> </div>
); );
} }
+13 -90
View File
@@ -1,101 +1,24 @@
import { motion } from "framer-motion";
import { ShortenForm } from "../components/ShortenForm"; import { ShortenForm } from "../components/ShortenForm";
import { useAuth } from "../context/AuthContext"; 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() { export function Home() {
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<div className="mx-auto max-w-2xl px-4 pb-24 pt-10 sm:pt-16"> // Compact hero on mobile keeps the input above the fold; roomier on desktop.
<motion.div variants={container} initial="hidden" animate="show"> <div className="mx-auto w-full max-w-xl px-4 pb-20 pt-10 sm:pb-28 sm:pt-24">
{/* eyebrow */} <h1 className="text-center text-[30px] font-semibold leading-[1.1] tracking-[-0.03em] text-ink sm:text-[44px]">
<motion.div variants={pop} className="mb-5 flex justify-center"> Shorten a link.
<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"> </h1>
<span className="relative flex h-2 w-2"> <p className="mt-2.5 text-center text-[15px] text-muted">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent opacity-75" /> {user
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent" /> ? `Welcome back, ${user.name.split(" ")[0]}. Paste and it's copied.`
</span> : "Paste it, and the short one is on your clipboard."}
{user ? `Welcome back, ${user.name.split(" ")[0]}` : "No account needed to start"} </p>
</span>
</motion.div>
{/* headline */} <div className="mt-8 sm:mt-10">
<h1 className="text-center font-display text-5xl font-bold leading-[0.95] tracking-tight text-ink sm:text-7xl"> <ShortenForm />
<motion.span variants={pop} className="block"> </div>
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> </div>
); );
} }
+35 -62
View File
@@ -1,17 +1,17 @@
import { motion } from "framer-motion";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { Button } from "../components/ui/Button"; import { Button } from "../components/ui/Button";
import { Scissors } from "../components/icons";
const META: Record<string, { label: string; icon: string }> = { const LABELS: Record<string, string> = {
google: { label: "Continue with Google", icon: "🇬" }, google: "Continue with Google",
oidc: { label: "Continue with SSO", icon: "🔐" }, oidc: "Continue with SSO",
}; };
function providerMeta(name: string) { function providerLabel(name: string) {
return META[name] ?? { label: `Continue with ${name}`, icon: "🔐" }; return LABELS[name] ?? `Continue with ${name}`;
} }
export function Login() { export function Login() {
@@ -28,81 +28,54 @@ export function Login() {
}, []); }, []);
return ( return (
<div className="mx-auto grid min-h-[70vh] max-w-md place-items-center px-4 pb-24 pt-10"> <div className="mx-auto grid min-h-[70vh] max-w-sm place-items-center px-4 pb-24 pt-10">
<motion.div <div className="card w-full p-6 sm:p-7">
initial={{ opacity: 0, y: 30, scale: 0.94 }} <span className="grid h-10 w-10 place-items-center rounded-xl bg-[var(--ink)] text-[var(--bg)]">
animate={{ opacity: 1, y: 0, scale: 1 }} <Scissors size={18} />
transition={{ type: "spring", stiffness: 280, damping: 22 }} </span>
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"> <h1 className="mt-5 text-2xl font-semibold tracking-tight text-ink">
Welcome to snip Welcome to snip
</h1> </h1>
<p className="mt-1.5 text-sm text-muted"> <p className="mt-1.5 text-sm text-muted">
Sign in to manage your links, lock them with a PIN, and watch the Sign in to manage your links, lock them with a PIN, and watch the clicks
clicks roll in. roll in.
</p> </p>
<div className="mt-6 space-y-2.5"> <div className="mt-6 space-y-2.5">
{providers === null ? ( {providers === null ? (
<> <>
<div className="h-14 animate-pulse rounded-2xl bg-surface-2" /> <div className="h-12 animate-pulse rounded-xl bg-surface-2" />
<div className="h-14 animate-pulse rounded-2xl bg-surface-2 opacity-60" /> <div className="h-12 animate-pulse rounded-xl bg-surface-2 opacity-60" />
</> </>
) : providers.length === 0 ? ( ) : 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"> <div className="rounded-xl border border-dashed border-line bg-surface-2 p-4 text-center text-[13px] text-muted">
No login providers are configured on the server yet. Set{" "} No login providers are configured yet. Set{" "}
<span className="font-mono text-ink">GOOGLE_*</span> or{" "} <span className="font-mono text-ink">GOOGLE_*</span> or{" "}
<span className="font-mono text-ink">OIDC_*</span> env vars to <span className="font-mono text-ink">OIDC_*</span> env vars to enable
enable sign-in. sign-in.
</div> </div>
) : ( ) : (
providers.map((p, i) => { providers.map((p, i) => (
const meta = providerMeta(p); <Button
return ( key={p}
<motion.div variant={i === 0 ? "primary" : "outline"}
key={p} size="lg"
initial={{ opacity: 0, y: 10 }} block
animate={{ opacity: 1, y: 0 }} onClick={() => {
transition={{ delay: 0.05 * i, type: "spring", stiffness: 320, damping: 22 }} window.location.href = api.loginUrl(p);
> }}
<Button >
variant={i === 0 ? "primary" : "outline"} {providerLabel(p)}
size="lg" </Button>
block ))
onClick={() => {
window.location.href = api.loginUrl(p);
}}
>
<span className="text-lg">{meta.icon}</span> {meta.label}
</Button>
</motion.div>
);
})
)} )}
</div> </div>
<p className="mt-5 text-center text-xs text-muted"> <p className="mt-5 text-center text-xs text-muted">
You can still create random &amp; memorable links without an account — You can still create random &amp; memorable links without an account.
sign in only to customize, protect, and track them.
</p> </p>
</motion.div> </div>
</div> </div>
); );
} }
+8 -32
View File
@@ -2,44 +2,20 @@
export default { export default {
darkMode: "class", darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"], content: ["./index.html", "./src/**/*.{ts,tsx}"],
// hover: only applies on devices that can actually hover — no sticky taps on phones.
future: { hoverOnlyWhenSupported: true },
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
display: ['"Clash Display"', "ui-sans-serif", "system-ui", "sans-serif"], sans: ['"Inter"', "ui-sans-serif", "system-ui", "sans-serif"],
sans: ['"General Sans"', "ui-sans-serif", "system-ui", "sans-serif"], mono: ['"JetBrains Mono"', "ui-monospace", "SFMono-Regular", "monospace"],
mono: ['"Space Mono"', "ui-monospace", "SFMono-Regular", "monospace"],
},
colors: {
// Warm paper + ink, electric lime accent. Deliberately not purple-on-white.
paper: "#F4F2EA",
ink: "#16170F",
lime: {
DEFAULT: "#C6F24E",
400: "#D2F76B",
500: "#C6F24E",
600: "#A9DB2E",
700: "#7FA614",
},
},
boxShadow: {
pop: "0 2px 0 0 var(--ring), 0 10px 30px -12px rgba(0,0,0,0.35)",
hard: "4px 4px 0 0 var(--ink-solid)",
}, },
borderRadius: { borderRadius: {
"4xl": "2rem", xl: "0.75rem",
"2xl": "1rem",
}, },
keyframes: { transitionTimingFunction: {
float: { out: "cubic-bezier(0.23, 1, 0.32, 1)",
"0%, 100%": { transform: "translateY(0)" },
"50%": { transform: "translateY(-8px)" },
},
"spin-slow": {
to: { transform: "rotate(360deg)" },
},
},
animation: {
float: "float 6s ease-in-out infinite",
"spin-slow": "spin-slow 18s linear infinite",
}, },
}, },
}, },