feat: improve ui

This commit is contained in:
sittichok Ouamsiri
2026-07-26 14:56:21 +07:00
parent e177c7775a
commit e2327207e5
14 changed files with 333 additions and 243 deletions
+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>
+2 -1
View File
@@ -51,7 +51,8 @@ export function DeleteLinkModal({ link, onClose, onDeleted }: Props) {
<Button <Button
onClick={confirm} onClick={confirm}
disabled={busy} disabled={busy}
className="!bg-red-500 !text-white hover:!opacity-90" // bg-none clears the primary variant's gradient image so red shows through.
className="!bg-none !bg-red-500 !text-white hover:!opacity-90"
> >
{busy ? "Deleting…" : "Delete link"} {busy ? "Deleting…" : "Delete link"}
</Button> </Button>
+1 -1
View File
@@ -8,7 +8,7 @@ export function Logo({ onClick }: { onClick?: () => void }) {
onClick={onClick} onClick={onClick}
className="focusable inline-flex items-center gap-2 rounded-lg" className="focusable inline-flex items-center gap-2 rounded-lg"
> >
<span className="grid h-7 w-7 place-items-center rounded-lg bg-[var(--ink)] text-[var(--bg)]"> <span className="grad-bg grid h-7 w-7 place-items-center rounded-lg">
<Scissors size={15} /> <Scissors size={15} />
</span> </span>
<span className="text-[17px] font-semibold tracking-tight text-ink"> <span className="text-[17px] font-semibold tracking-tight text-ink">
+3 -2
View File
@@ -42,7 +42,7 @@ 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) => (
<input <input
key={i} key={i}
@@ -51,9 +51,10 @@ export function PinInput({ value, onChange, length = 6 }: Props) {
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}`}
className="focusable h-12 w-full rounded-lg border border-line bg-surface-2 text-center font-mono text-lg font-semibold text-ink outline-none focus:border-[var(--ink)]" className="field h-12 w-full min-w-0 text-center font-mono text-lg font-semibold"
/> />
))} ))}
</div> </div>
+1 -1
View File
@@ -73,7 +73,7 @@ export function PinManager({ link, onClose, onSaved }: Props) {
— only replaced or removed. */} — only replaced or removed. */}
{hasPin && ( {hasPin && (
<div className="mb-4 flex items-center gap-3 rounded-xl border 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-8 w-8 place-items-center rounded-lg bg-[var(--ink)] text-[var(--bg)]"> <span className="grad-bg grid h-8 w-8 place-items-center rounded-lg">
<Lock size={15} /> <Lock size={15} />
</span> </span>
<div> <div>
+11 -5
View File
@@ -42,7 +42,7 @@ export function ResultCard({ link, onReset, onCopied }: Props) {
className="card p-5 sm:p-6" className="card p-5 sm:p-6"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-full bg-[var(--ink)] text-[var(--bg)]"> <span className="grad-bg grid h-6 w-6 place-items-center rounded-full">
<Check size={14} /> <Check size={14} />
</span> </span>
<span className="text-sm font-medium text-ink">Your link is live</span> <span className="text-sm font-medium text-ink">Your link is live</span>
@@ -53,20 +53,26 @@ export function ResultCard({ link, onReset, onCopied }: Props) {
<div className="mt-4 rounded-xl border border-line bg-surface-2 p-4"> <div className="mt-4 rounded-xl border border-line bg-surface-2 p-4">
<p className="truncate text-[13px] text-muted">{prettyHost(link.longUrl)}</p> <p className="truncate text-[13px] text-muted">{prettyHost(link.longUrl)}</p>
<p className="mt-1 break-all font-mono text-xl font-semibold text-ink"> <p className="grad-text mt-1 break-all font-mono text-lg font-semibold sm:text-xl">
{shortUrl(link.code)} {shortUrl(link.code)}
</p> </p>
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
<Button onClick={copy} size="sm"> <Button onClick={copy} size="md" className="flex-1 sm:flex-none">
{copied ? <Check size={15} /> : <Copy size={15} />} {copied ? <Check size={15} /> : <Copy size={15} />}
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy"}
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setShowQr((v) => !v)}> <Button
variant="outline"
size="md"
onClick={() => setShowQr((v) => !v)}
aria-expanded={showQr}
className="flex-1 sm:flex-none"
>
<Qr size={15} /> <Qr size={15} />
{showQr ? "Hide QR" : "QR code"} {showQr ? "Hide QR" : "QR code"}
</Button> </Button>
{link.hasPin && ( {link.hasPin && (
<span className="inline-flex items-center gap-1 text-[12px] text-muted"> <span className="inline-flex w-full items-center gap-1 text-[12px] text-muted sm:w-auto">
<Lock size={13} /> PIN protected <Lock size={13} /> PIN protected
</span> </span>
)} )}
+20 -24
View File
@@ -1,11 +1,8 @@
import type { ReactNode } from "react";
import { Lock } from "./icons"; 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;
} }
@@ -15,13 +12,28 @@ interface Props<T extends string> {
onChange: (value: T) => void; onChange: (value: T) => void;
} }
// 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="grid grid-cols-3 gap-1.5"> <div className="relative flex rounded-xl border border-line bg-surface-2 p-1">
<span
aria-hidden
className="grad-tint absolute inset-y-1 left-1 rounded-lg shadow-sm transition-transform duration-200 ease-out"
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 (
@@ -30,28 +42,12 @@ export function SegmentedControl<T extends string>({
type="button" type="button"
onClick={() => onChange(seg.value)} onClick={() => onChange(seg.value)}
aria-pressed={active} aria-pressed={active}
className={`focusable flex flex-col items-start gap-1 rounded-xl border p-3 text-left transition-colors ${ className={`focusable relative z-10 flex flex-1 items-center justify-center gap-1 rounded-lg py-2 text-[13px] font-medium transition-colors ${
active active ? "text-ink" : "text-muted hover:text-ink"
? "border-[var(--ink)] bg-surface-2"
: "border-line hover:bg-surface-2"
}`} }`}
> >
<span className={active ? "text-ink" : "text-muted"}> {seg.label}
{seg.icon} {seg.locked && <Lock size={12} />}
</span>
<span className="flex items-center gap-1">
<span
className={`text-[13px] font-semibold ${active ? "text-ink" : "text-muted"}`}
>
{seg.label}
</span>
{seg.locked && <Lock size={11} className="text-muted" />}
</span>
{seg.hint && (
<span className="text-[11px] leading-tight text-muted">
{seg.hint}
</span>
)}
</button> </button>
); );
})} })}
+120 -120
View File
@@ -15,9 +15,24 @@ 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 { Shuffle, Sparkle, Type, Lock } from "./icons"; import { Lock, ArrowRight } from "./icons";
const ease = { duration: 0.22, ease: "easeOut" } as const; const ease = { duration: 0.2, ease: "easeOut" } 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: 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();
@@ -44,17 +59,18 @@ export function ShortenForm() {
} }
const segments: Segment<UrlMode>[] = [ const segments: Segment<UrlMode>[] = [
{ value: "random", label: "Random", icon: <Shuffle size={17} />, hint: "Shortest code" }, { value: "random", label: "Random" },
{ value: "memorable", label: "Memorable", icon: <Sparkle size={17} />, hint: "Three words" }, { value: "memorable", label: "Memorable" },
{ { value: "custom", label: "Custom", locked: !user },
value: "custom",
label: "Custom",
icon: <Type size={17} />,
hint: user ? "You choose" : "Sign in",
locked: !user,
},
]; ];
// Kept short so the caption row never wraps at 320px and shifts the layout.
const hints: Record<UrlMode, string> = {
random: "Shortest code",
memorable: "Three words",
custom: user ? "Your own ending" : "Sign in to choose",
};
function onMode(value: UrlMode) { function onMode(value: UrlMode) {
if (value === "custom" && !user) { if (value === "custom" && !user) {
toast("Sign in to create custom links", "info"); toast("Sign in to create custom links", "info");
@@ -117,8 +133,7 @@ export function ShortenForm() {
clearError(); clearError();
} }
const preview = const preview = mode === "custom" ? alias || previews.custom : previews[mode];
mode === "random" ? "x7Qk" : mode === "memorable" ? "amber-otter-loop" : alias || "your-name";
return ( return (
<div className="relative"> <div className="relative">
@@ -138,53 +153,68 @@ export function ShortenForm() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }} exit={{ opacity: 0, y: -8 }}
transition={ease} transition={ease}
className="card p-4 sm:p-5" className="card p-3 sm:p-4"
> >
{/* URL field */} {/* Primary action: paste, shorten. Stacks on mobile, one row on desktop. */}
<label className="mb-1.5 block text-[13px] font-medium text-muted"> <div className="flex flex-col gap-2 sm:flex-row">
Long URL <input
</label> value={url}
<input onChange={(e) => {
value={url} setUrl(e.target.value);
onChange={(e) => { if (errorInput === "url") clearError();
setUrl(e.target.value); }}
if (errorInput === "url") clearError(); placeholder="Paste a long link"
}} aria-label="Long URL"
placeholder="https://example.com/a/very/long/path" type="url"
autoComplete="off" inputMode="url"
spellCheck={false} autoComplete="off"
className={`h-12 w-full rounded-xl border bg-surface-2 px-3.5 font-mono text-sm text-ink outline-none transition-colors placeholder:font-sans placeholder:text-muted ${ autoCapitalize="off"
errorInput === "url" ? "border-red-400" : "border-line focus:border-[var(--ink)]" spellCheck={false}
}`} // 16px on mobile: anything smaller makes iOS Safari zoom on focus.
/> // flex-1 only from sm — in the mobile column it would set the
// flex-basis on the height axis and collapse the field.
className={`field h-12 w-full min-w-0 shrink-0 px-3.5 font-mono text-[16px] placeholder:font-sans placeholder:text-muted sm:flex-1 sm:text-sm ${
errorInput === "url" ? "field-error" : ""
}`}
/>
<Button
type="submit"
size="lg"
disabled={busy}
className="w-full sm:w-auto sm:shrink-0"
>
{busy ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<>
Shorten
<ArrowRight size={16} className="hidden sm:block" />
</>
)}
</Button>
</div>
{/* Mode selector */} {/* Link style */}
<div className="mt-4"> <div className="mt-3">
<div className="mb-1.5 flex items-center justify-between"> <SegmentedControl segments={segments} value={mode} onChange={onMode} />
<span className="text-[13px] font-medium text-muted">Link style</span> <div className="mt-2 flex items-baseline justify-between gap-3 px-0.5 text-[12px]">
<span className="font-mono text-[12px] text-muted"> <span className="whitespace-nowrap text-muted">{hints[mode]}</span>
<span className="truncate font-mono text-muted">
{getShortDomain()}/<span className="text-ink">{preview}</span> {getShortDomain()}/<span className="text-ink">{preview}</span>
</span> </span>
</div> </div>
<SegmentedControl segments={segments} value={mode} onChange={onMode} />
</div> </div>
{/* Custom alias */} {/* Custom alias */}
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{mode === "custom" && ( {mode === "custom" && (
<motion.div <motion.div key="alias" {...collapse}>
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={ease}
className="overflow-hidden"
>
<div <div
className={`mt-3 flex items-center overflow-hidden rounded-xl border bg-surface-2 transition-colors ${ className={`field mt-3 flex items-center overflow-hidden ${
errorInput === "alias" ? "border-red-400" : "border-line focus-within:border-[var(--ink)]" errorInput === "alias" ? "field-error" : ""
}`} }`}
> >
<span className="select-none pl-3.5 font-mono text-sm text-muted"> <span className="select-none pl-3.5 font-mono text-[15px] text-muted sm:text-sm">
{getShortDomain()}/ {getShortDomain()}/
</span> </span>
<input <input
@@ -194,7 +224,10 @@ export function ShortenForm() {
if (errorInput === "alias") clearError(); if (errorInput === "alias") clearError();
}} }}
placeholder="spring-launch" placeholder="spring-launch"
className="h-11 w-full bg-transparent pr-3.5 font-mono text-sm text-ink outline-none placeholder:text-muted" aria-label="Custom alias"
autoCapitalize="off"
spellCheck={false}
className="h-12 w-full min-w-0 bg-transparent pr-3.5 font-mono text-[16px] text-ink outline-none placeholder:text-muted sm:text-sm"
/> />
</div> </div>
</motion.div> </motion.div>
@@ -202,91 +235,58 @@ export function ShortenForm() {
</AnimatePresence> </AnimatePresence>
{/* PIN protection */} {/* PIN protection */}
<div className="mt-4 rounded-xl border border-line bg-surface-2 p-3.5"> {user ? (
{user ? ( <>
<> <button
<button type="button"
type="button" onClick={() => setPinOn((v) => !v)}
onClick={() => setPinOn((v) => !v)} aria-pressed={pinOn}
className="focusable flex w-full items-center gap-3 rounded-lg text-left" className={`focusable mt-3 inline-flex h-9 items-center gap-2 rounded-full border px-3 text-[13px] font-medium transition-colors ${
> pinOn
<span ? "grad-tint border-transparent text-accent"
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${ : "border-line text-muted hover:text-ink"
pinOn ? "bg-[var(--ink)]" : "bg-[var(--ring)]" }`}
}`} >
> <Lock size={14} />
<motion.span {pinOn ? "PIN protected" : "Add a PIN"}
layout </button>
transition={{ type: "spring", stiffness: 600, damping: 32 }} <AnimatePresence initial={false}>
className={`absolute top-0.5 h-4 w-4 rounded-full bg-[var(--surface)] ${ {pinOn && (
pinOn ? "right-0.5" : "left-0.5" <motion.div key="pin" {...collapse}>
}`} <div className="pt-3">
/> <PinInput value={pin} onChange={setPin} />
</span> </div>
<span className="flex-1"> </motion.div>
<span className="block text-[13px] font-medium text-ink"> )}
Protect with a 6-digit PIN </AnimatePresence>
</span> </>
<span className="block text-xs text-muted"> ) : (
Visitors enter it before redirect <p className="mt-3 px-0.5 text-[12px] text-muted">
</span> <button
</span> type="button"
</button> onClick={() => navigate("/login")}
<AnimatePresence initial={false}> className="focusable rounded font-medium text-ink underline underline-offset-2"
{pinOn && ( >
<motion.div Sign in
initial={{ opacity: 0, height: 0 }} </button>{" "}
animate={{ opacity: 1, height: "auto" }} for custom aliases and PIN protection.
exit={{ opacity: 0, height: 0 }} </p>
transition={ease} )}
className="overflow-hidden"
>
<div className="pt-3.5">
<PinInput value={pin} onChange={setPin} />
</div>
</motion.div>
)}
</AnimatePresence>
</>
) : (
<div className="flex items-center gap-2.5 text-muted">
<Lock size={16} />
<p className="text-[13px]">
<button
type="button"
onClick={() => navigate("/login")}
className="focusable rounded font-medium text-ink underline underline-offset-2"
>
Sign in
</button>{" "}
for custom aliases and PIN protection.
</p>
</div>
)}
</div>
{/* Error */} {/* Error */}
<AnimatePresence> <AnimatePresence>
{error && ( {error && (
<motion.p <motion.p
role="alert"
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-[13px] font-medium text-red-500" className="mt-3 px-0.5 text-[13px] font-medium text-red-500"
> >
{error} {error}
</motion.p> </motion.p>
)} )}
</AnimatePresence> </AnimatePresence>
{/* Submit */}
<Button type="submit" size="lg" block disabled={busy} className="mt-4">
{busy ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
"Shorten link"
)}
</Button>
</motion.form> </motion.form>
)} )}
</AnimatePresence> </AnimatePresence>
+3 -3
View File
@@ -18,9 +18,9 @@ const sizes: Record<Size, string> = {
}; };
const variants: Record<Variant, string> = { const variants: Record<Variant, string> = {
// solid ink — the one strong action, inverts with the theme // brand gradient — the one strong action per view
primary: primary: "grad-bg font-medium hover:opacity-90",
"bg-[var(--ink)] text-[var(--bg)] font-medium hover:opacity-90", // solid ink, for when a neutral strong action reads better
dark: "bg-[var(--ink)] text-[var(--bg)] font-medium hover:opacity-90", dark: "bg-[var(--ink)] text-[var(--bg)] font-medium hover:opacity-90",
outline: outline:
"bg-transparent text-ink font-medium border border-line hover:bg-surface-2", "bg-transparent text-ink font-medium border border-line hover:bg-surface-2",
+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(
+61
View File
@@ -14,6 +14,15 @@
--accent: #2563eb; --accent: #2563eb;
--accent-soft: rgba(37, 99, 235, 0.1); --accent-soft: rgba(37, 99, 235, 0.1);
--accent-ink: #ffffff; --accent-ink: #ffffff;
/* One brand gradient, used at full strength on the hero + CTAs and as a soft
tint everywhere else. Both stops clear 4.5:1 against --grad-ink. */
--grad-from: #2563eb;
--grad-to: #7c3aed;
--grad-ink: #ffffff;
--grad-soft: linear-gradient(135deg, rgba(37, 99, 235, 0.1), rgba(124, 58, 237, 0.1));
--glow-1: rgba(37, 99, 235, 0.11);
--glow-2: rgba(124, 58, 237, 0.1);
} }
.dark { .dark {
@@ -28,6 +37,14 @@
--accent: #60a5fa; --accent: #60a5fa;
--accent-soft: rgba(96, 165, 250, 0.14); --accent-soft: rgba(96, 165, 250, 0.14);
--accent-ink: #09090b; --accent-ink: #09090b;
/* Lighter stops on near-black, so --grad-ink flips to dark. */
--grad-from: #60a5fa;
--grad-to: #a78bfa;
--grad-ink: #09090b;
--grad-soft: linear-gradient(135deg, rgba(96, 165, 250, 0.14), rgba(167, 139, 250, 0.14));
--glow-1: rgba(96, 165, 250, 0.13);
--glow-2: rgba(167, 139, 250, 0.12);
} }
* { * {
@@ -47,6 +64,11 @@ body {
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
min-height: 100vh; min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease;
/* Ambient colour that never sits under text — fixed so it doesn't scroll. */
background-image: radial-gradient(50% 32% at 8% 0%, var(--glow-1), transparent 70%),
radial-gradient(45% 30% at 96% 6%, var(--glow-2), transparent 70%);
background-attachment: fixed;
background-repeat: no-repeat;
} }
/* Semantic token utilities (kept stable so components stay theme-driven) */ /* Semantic token utilities (kept stable so components stay theme-driven) */
@@ -65,6 +87,45 @@ body {
border-radius: 1rem; border-radius: 1rem;
} }
.grad-bg {
background-image: linear-gradient(135deg, var(--grad-from), var(--grad-to));
color: var(--grad-ink);
}
/* Soft tint over a surface — background-image layers on top of the colour. */
.grad-tint {
background-color: var(--surface);
background-image: var(--grad-soft);
}
.grad-text {
background-image: linear-gradient(135deg, var(--grad-from), var(--grad-to));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* One field look for every input + input wrapper. */
.field {
background-color: var(--surface-2);
border: 1px solid var(--line);
border-radius: 0.75rem;
color: var(--ink);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field:focus,
.field:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.field-error,
.field-error:focus,
.field-error:focus-within {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.16);
}
::selection { ::selection {
background: var(--accent); background: var(--accent);
color: var(--accent-ink); color: var(--accent-ink);
+20 -38
View File
@@ -2,50 +2,32 @@ 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 fade = {
hidden: { opacity: 0, y: 10 },
show: (i: number) => ({
opacity: 1,
y: 0,
transition: { delay: i * 0.05, duration: 0.3, ease: "easeOut" },
}),
};
export function Home() { export function Home() {
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<div className="mx-auto max-w-xl px-4 pb-16 pt-7 sm:pb-24 sm:pt-20"> <motion.div
{/* Compact hero on mobile so the input is reachable; full size on desktop. */} initial={{ opacity: 0, y: 10 }}
<motion.p animate={{ opacity: 1, y: 0 }}
custom={0} transition={{ duration: 0.3, ease: "easeOut" }}
variants={fade} // Compact hero on mobile keeps the input above the fold; roomier on desktop.
initial="hidden" className="mx-auto w-full max-w-xl px-4 pb-20 pt-8 sm:pb-28 sm:pt-20"
animate="show" >
className="text-center text-[13px] font-medium text-muted" <h1 className="text-center text-[28px] font-semibold leading-[1.1] tracking-tight text-ink sm:text-5xl">
> Long links,{" "}
{user ? `Welcome back, ${user.name.split(" ")[0]}` : "No account needed to start"} {/* pb-[0.1em] so descenders aren't clipped by the background-clip box */}
</motion.p> <span className="grad-text inline-block pb-[0.1em]">made tiny.</span>
</h1>
<motion.h1 <p className="mt-2 text-center text-[13px] text-muted sm:mt-3 sm:text-[15px]">
custom={1} {user
variants={fade} ? `Welcome back, ${user.name.split(" ")[0]}.`
initial="hidden" : "Paste a link, get a short one. No account needed."}
animate="show" </p>
className="mt-1.5 text-center text-[26px] font-semibold leading-[1.1] tracking-tight text-ink sm:mt-3 sm:text-5xl"
>
Long links, made tiny.
</motion.h1>
<motion.div <div className="mt-6 sm:mt-8">
custom={2}
variants={fade}
initial="hidden"
animate="show"
className="mt-5 sm:mt-8"
>
<ShortenForm /> <ShortenForm />
</motion.div> </div>
</div> </motion.div>
); );
} }
+1 -1
View File
@@ -36,7 +36,7 @@ export function Login() {
transition={{ duration: 0.25, ease: "easeOut" }} transition={{ duration: 0.25, ease: "easeOut" }}
className="card w-full p-6 sm:p-7" className="card w-full p-6 sm:p-7"
> >
<span className="grid h-10 w-10 place-items-center rounded-xl bg-[var(--ink)] text-[var(--bg)]"> <span className="grad-bg grid h-10 w-10 place-items-center rounded-xl">
<Scissors size={18} /> <Scissors size={18} />
</span> </span>