From e2327207e562da723c496f7416859fe10af65e78 Mon Sep 17 00:00:00 2001 From: sittichok Ouamsiri Date: Sun, 26 Jul 2026 14:56:21 +0700 Subject: [PATCH] feat: improve ui --- backend/internal/httpx/redirect/notfound.go | 30 ++- backend/internal/httpx/redirect/pin_page.go | 104 ++++++--- src/components/DeleteLinkModal.tsx | 3 +- src/components/Logo.tsx | 2 +- src/components/PinInput.tsx | 5 +- src/components/PinManager.tsx | 2 +- src/components/ResultCard.tsx | 16 +- src/components/SegmentedControl.tsx | 44 ++-- src/components/ShortenForm.tsx | 240 ++++++++++---------- src/components/ui/Button.tsx | 6 +- src/context/ThemeContext.tsx | 3 +- src/index.css | 61 +++++ src/pages/Home.tsx | 58 ++--- src/pages/Login.tsx | 2 +- 14 files changed, 333 insertions(+), 243 deletions(-) diff --git a/backend/internal/httpx/redirect/notfound.go b/backend/internal/httpx/redirect/notfound.go index a474ad2..0021348 100644 --- a/backend/internal/httpx/redirect/notfound.go +++ b/backend/internal/httpx/redirect/notfound.go @@ -8,16 +8,28 @@ func notFoundHTML(shortHost, homeURL string) string { href := html.EscapeString(homeURL) return ` + + Link not found

404

This short link doesn't exist or was removed.

Go to ` + h + `
` diff --git a/backend/internal/httpx/redirect/pin_page.go b/backend/internal/httpx/redirect/pin_page.go index 6ba817b..63de972 100644 --- a/backend/internal/httpx/redirect/pin_page.go +++ b/backend/internal/httpx/redirect/pin_page.go @@ -14,51 +14,76 @@ type pinData struct { } // 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 -// look: warm paper + ink, electric-lime accent, with a dark-mode variant. +// single round trip — the redirect path must stay fast. Palette and radii mirror +// 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(` - + + + Enter PIN · {{.ShortHost}}/{{.Code}}
-
-

This link is protected

-

Enter the 6-digit PIN to continue to {{.ShortHost}}/{{.Code}}.

-
+
+

This link is protected

+

Enter the 6-digit PIN to continue to {{.ShortHost}}/{{.Code}}.

+
@@ -67,31 +92,36 @@ button:active{transform:scale(.96)}
-

{{if .HasError}}That PIN didn't match. Try again.{{end}}

+ - +
-
Secured by {{.ShortHost}}
+
{{.ShortHost}}
diff --git a/src/components/DeleteLinkModal.tsx b/src/components/DeleteLinkModal.tsx index d9830c4..a72f78f 100644 --- a/src/components/DeleteLinkModal.tsx +++ b/src/components/DeleteLinkModal.tsx @@ -51,7 +51,8 @@ export function DeleteLinkModal({ link, onClose, onDeleted }: Props) { diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx index db7a381..1da0397 100644 --- a/src/components/Logo.tsx +++ b/src/components/Logo.tsx @@ -8,7 +8,7 @@ export function Logo({ onClick }: { onClick?: () => void }) { onClick={onClick} className="focusable inline-flex items-center gap-2 rounded-lg" > - + diff --git a/src/components/PinInput.tsx b/src/components/PinInput.tsx index 9a565cb..3b755af 100644 --- a/src/components/PinInput.tsx +++ b/src/components/PinInput.tsx @@ -42,7 +42,7 @@ export function PinInput({ value, onChange, length = 6 }: Props) { } return ( -
+
{Array.from({ length }).map((_, i) => ( handleChange(i, e)} onKeyDown={(e) => handleKey(i, e)} inputMode="numeric" + autoComplete="off" maxLength={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" /> ))}
diff --git a/src/components/PinManager.tsx b/src/components/PinManager.tsx index fb87417..de76bea 100644 --- a/src/components/PinManager.tsx +++ b/src/components/PinManager.tsx @@ -73,7 +73,7 @@ export function PinManager({ link, onClose, onSaved }: Props) { — only replaced or removed. */} {hasPin && (
- +
diff --git a/src/components/ResultCard.tsx b/src/components/ResultCard.tsx index fe64128..c91b312 100644 --- a/src/components/ResultCard.tsx +++ b/src/components/ResultCard.tsx @@ -42,7 +42,7 @@ export function ResultCard({ link, onReset, onCopied }: Props) { className="card p-5 sm:p-6" >
- + Your link is live @@ -53,20 +53,26 @@ export function ResultCard({ link, onReset, onCopied }: Props) {

{prettyHost(link.longUrl)}

-

+

{shortUrl(link.code)}

- - {link.hasPin && ( - + PIN protected )} diff --git a/src/components/SegmentedControl.tsx b/src/components/SegmentedControl.tsx index 510f7dd..0a26ef7 100644 --- a/src/components/SegmentedControl.tsx +++ b/src/components/SegmentedControl.tsx @@ -1,11 +1,8 @@ -import type { ReactNode } from "react"; import { Lock } from "./icons"; export interface Segment { value: T; label: string; - icon?: ReactNode; - hint?: string; locked?: boolean; } @@ -15,13 +12,28 @@ interface Props { 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({ segments, value, onChange, }: Props) { + const index = Math.max( + 0, + segments.findIndex((s) => s.value === value), + ); + return ( -
+
+ {segments.map((seg) => { const active = seg.value === value; return ( @@ -30,28 +42,12 @@ export function SegmentedControl({ type="button" onClick={() => onChange(seg.value)} aria-pressed={active} - className={`focusable flex flex-col items-start gap-1 rounded-xl border p-3 text-left transition-colors ${ - active - ? "border-[var(--ink)] bg-surface-2" - : "border-line hover:bg-surface-2" + 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 ? "text-ink" : "text-muted hover:text-ink" }`} > - - {seg.icon} - - - - {seg.label} - - {seg.locked && } - - {seg.hint && ( - - {seg.hint} - - )} + {seg.label} + {seg.locked && } ); })} diff --git a/src/components/ShortenForm.tsx b/src/components/ShortenForm.tsx index 1925885..5f155af 100644 --- a/src/components/ShortenForm.tsx +++ b/src/components/ShortenForm.tsx @@ -15,9 +15,24 @@ import { useToast } from "./ui/Toast"; import { SegmentedControl, type Segment } from "./SegmentedControl"; import { PinInput } from "./PinInput"; 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 = { + random: "x7Qk", + memorable: "amber-otter-loop", + custom: "your-name", +}; export function ShortenForm() { const { user } = useAuth(); @@ -44,17 +59,18 @@ export function ShortenForm() { } const segments: Segment[] = [ - { value: "random", label: "Random", icon: , hint: "Shortest code" }, - { value: "memorable", label: "Memorable", icon: , hint: "Three words" }, - { - value: "custom", - label: "Custom", - icon: , - hint: user ? "You choose" : "Sign in", - locked: !user, - }, + { value: "random", label: "Random" }, + { value: "memorable", label: "Memorable" }, + { value: "custom", label: "Custom", locked: !user }, ]; + // Kept short so the caption row never wraps at 320px and shifts the layout. + const hints: Record = { + random: "Shortest code", + memorable: "Three words", + custom: user ? "Your own ending" : "Sign in to choose", + }; + function onMode(value: UrlMode) { if (value === "custom" && !user) { toast("Sign in to create custom links", "info"); @@ -117,8 +133,7 @@ export function ShortenForm() { clearError(); } - const preview = - mode === "random" ? "x7Qk" : mode === "memorable" ? "amber-otter-loop" : alias || "your-name"; + const preview = mode === "custom" ? alias || previews.custom : previews[mode]; return (
@@ -138,53 +153,68 @@ export function ShortenForm() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={ease} - className="card p-4 sm:p-5" + className="card p-3 sm:p-4" > - {/* URL field */} - - { - setUrl(e.target.value); - if (errorInput === "url") clearError(); - }} - placeholder="https://example.com/a/very/long/path" - autoComplete="off" - spellCheck={false} - 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 ${ - errorInput === "url" ? "border-red-400" : "border-line focus:border-[var(--ink)]" - }`} - /> + {/* Primary action: paste, shorten. Stacks on mobile, one row on desktop. */} +
+ { + setUrl(e.target.value); + if (errorInput === "url") clearError(); + }} + 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. + // 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" : "" + }`} + /> + +
- {/* Mode selector */} -
-
- Link style - + {/* Link style */} +
+ +
+ {hints[mode]} + {getShortDomain()}/{preview}
-
{/* Custom alias */} {mode === "custom" && ( - +
- + {getShortDomain()}/
@@ -202,91 +235,58 @@ export function ShortenForm() {
{/* PIN protection */} -
- {user ? ( - <> - - - {pinOn && ( - -
- -
-
- )} -
- - ) : ( -
- -

- {" "} - for custom aliases and PIN protection. -

-
- )} -
+ {user ? ( + <> + + + {pinOn && ( + +
+ +
+
+ )} +
+ + ) : ( +

+ {" "} + for custom aliases and PIN protection. +

+ )} {/* Error */} {error && ( {error} )} - - {/* Submit */} - )} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 9d9c838..66a09cc 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -18,9 +18,9 @@ const sizes: Record = { }; const variants: Record = { - // solid ink — the one strong action, inverts with the theme - primary: - "bg-[var(--ink)] text-[var(--bg)] font-medium hover:opacity-90", + // brand gradient — the one strong action per view + primary: "grad-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", outline: "bg-transparent text-ink font-medium border border-line hover:bg-surface-2", diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 82ca76e..fd85168 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -30,7 +30,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) { /* ignore */ } 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]); const toggle = useCallback( diff --git a/src/index.css b/src/index.css index cc972f3..281e8ab 100644 --- a/src/index.css +++ b/src/index.css @@ -14,6 +14,15 @@ --accent: #2563eb; --accent-soft: rgba(37, 99, 235, 0.1); --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 { @@ -28,6 +37,14 @@ --accent: #60a5fa; --accent-soft: rgba(96, 165, 250, 0.14); --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; min-height: 100vh; 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) */ @@ -65,6 +87,45 @@ body { 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 { background: var(--accent); color: var(--accent-ink); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 1b5c88f..697daee 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -2,50 +2,32 @@ import { motion } from "framer-motion"; import { ShortenForm } from "../components/ShortenForm"; 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() { const { user } = useAuth(); return ( -
- {/* Compact hero on mobile so the input is reachable; full size on desktop. */} - - {user ? `Welcome back, ${user.name.split(" ")[0]}` : "No account needed to start"} - + +

+ Long links,{" "} + {/* pb-[0.1em] so descenders aren't clipped by the background-clip box */} + made tiny. +

- - Long links, made tiny. - +

+ {user + ? `Welcome back, ${user.name.split(" ")[0]}.` + : "Paste a link, get a short one. No account needed."} +

- +
- -
+
+ ); } diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index b0eb660..98ab9e8 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -36,7 +36,7 @@ export function Login() { transition={{ duration: 0.25, ease: "easeOut" }} className="card w-full p-6 sm:p-7" > - +