mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
feat: improve ui
This commit is contained in:
@@ -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>`
|
||||||
|
|||||||
@@ -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 →</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user