7.6 KiB
snip — a fast, friendly URL shortener
A minimal, delightful link shortener. This phase is frontend-only, running entirely on a mock backend (localStorage + simulated latency) so the UI/UX can be built and reviewed before the Go service exists.
Run it
npm install
npm run dev # http://localhost:5173
npm run build # typecheck + production build
There's a seeded demo account — click Sign in → Continue with demo account
(any email works, no password). Data persists in localStorage; clear it to
reset.
What it does
- Create short links on the home page with three code styles:
- Random (default) — shortest possible unique code via base62 (see
src/lib/shortcode.ts). - Memorable — three easy words, e.g.
amber-otter-loop. - Custom (signed-in only) — pick your own alias.
- Random (default) — shortest possible unique code via base62 (see
- PIN protection (signed-in only) — a 6-digit gate before redirect.
- Result card — one-tap copy + QR code.
- Dashboard — your links with search, pagination, and a
comfortable ↔ compact view toggle (persisted) for large lists. Search and
page are kept in the URL (
?q=&page=) so they survive a refresh or share, and paging scrolls back to the top. Each link opens a consistent popup for edit, delete (with confirmation), PIN management (view / change / remove), and a QR code (copy + download SVG), plus total clicks and a 7-day click chart (or sparkline in compact mode). - Dark mode + spring-physics ("bouncy") motion throughout, fully responsive.
Design
- Type: Clash Display (headings) · General Sans (body) · Space Mono (codes).
- Palette: warm paper / near-black ink with an electric-lime accent —
deliberately not the purple-on-white AI default. All colors are CSS variables
in
src/index.csswith a.darkoverride. - Motion:
framer-motionspring transitions — the segmented mode selector (sharedlayoutId), result-card pop, staggered hero reveal, chart bars, and toasts.
Project structure
src/
lib/
mockApi.ts ← THE BACKEND SEAM. Swap these methods for fetch() calls.
shortcode.ts ← base62 code generation, URL + alias validation
words.ts ← memorable-word pools
types.ts ← shared API types (mirror the Go structs)
format.ts ← display helpers (compact numbers, relative time…)
context/ ← Auth + Theme providers
components/ ← Navbar, ShortenForm, ResultCard, UrlCard, BarChart, PinInput…
pages/ ← Home, Login, Dashboard
Swapping in the real backend
Every server interaction goes through the single api object in
src/lib/mockApi.ts. Replacing each method body with a
fetch() to the Go service — keeping the same signatures and the types in
src/lib/types.ts — is the entire integration.
Backend
Built — see
backend/and backend/README.md. Go (hexagonal + DI), Postgres, Redis. One binary with three commands (api/frontend/redirect), packed into one Dockerfile and wired up by docker-compose.yml behind a Caddy gateway. Run the whole stack withdocker compose up --build, then open http://localhost:8080. The brief's priority — minimize DB hits, cache in Redis, serve the fastest redirect — is implemented as described below.
The SPA talks to the real API (src/lib/api.ts) — there's no mock backend
anymore. Anonymous random/memorable links work immediately; custom aliases,
PINs, the dashboard, and stats require signing in.
Logging in locally (mock OIDC)
Compose bundles a mock OpenID Connect provider (navikt/mock-oauth2-server)
so login works with no real Google/OIDC credentials. Because an OIDC issuer URL
must be identical from the browser and the backend, add one line to your hosts
file so the browser can resolve the provider's docker name:
# /etc/hosts
127.0.0.1 mock-oidc
Then docker compose up --build, open http://localhost:8080, click Sign in →
Continue with SSO, and you're logged in as the mock "Demo User" ([email protected]).
To use real providers instead, set GOOGLE_* / OIDC_* env vars (and remove the
mock) — the login screen shows a button per configured provider automatically.
Stack: Go (HTTP server) · PostgreSQL (source of truth) · Redis (hot cache).
Data model (Postgres)
CREATE TABLE links (
id BIGSERIAL PRIMARY KEY, -- drives the base62 random code
code TEXT UNIQUE NOT NULL, -- random | memorable | custom alias
long_url TEXT NOT NULL,
mode SMALLINT NOT NULL,
pin_hash TEXT, -- bcrypt; NULL = no PIN
owner_id BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON links (owner_id);
-- Clicks are append-only and aggregated for the 7-day chart.
CREATE TABLE click_daily (
code TEXT NOT NULL,
day DATE NOT NULL,
count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (code, day)
);
The redirect hot path (the part that must be fast)
GET /{code} is the highest-traffic route and must rarely touch Postgres:
GET code:{code}from Redis → on hit,302immediately. No DB.- On miss, read Postgres once, then
SET code:{code} = long_urlwith a TTL (e.g. 24h). Subsequent hits are pure cache. - Negative cache unknown codes briefly (
SET code:{code} = "" EX 60) so bot/scanner traffic on non-existent codes can't hammer Postgres. - Clicks never block the redirect. Fire-and-forget
INCR clicks:{code}:{yyyy-mm-dd}in Redis; a background worker flushes counters intoclick_dailyevery ~10s. The user sees the redirect at Redis latency. - PIN-protected codes return the unlock page instead of a 302; the PIN is
checked against
pin_hashand never leaves the server.
Code generation
- random: insert the row to get the
BIGSERIAL id, then base62-encode a scrambled id → shortest globally-unique string, no collision checks. (The mock mirrors this inshortcode.ts.) - memorable: pick 3 words; on the rare unique-constraint violation, retry with a different combo or a short numeric suffix.
- custom: validate against
^[a-zA-Z0-9_-]{3,32}$, rely on theUNIQUEconstraint to reject collisions.
Dashboard reads (list, search, pagination)
GET /api/links?q=&page=&perPage=&view= is owner-scoped. Search runs as a
Postgres WHERE owner_id = $1 AND (code ILIKE $2 OR long_url ILIKE $2) with
LIMIT/OFFSET (or keyset pagination on created_at for large accounts); a
pg_trgm index on code/long_url keeps ILIKE fast. The list response can
use a short-TTL per-user cache keyed user:{id}:links (the frontend already
paginates/filters in-memory, so the API can also return the full owned set and
let the client slice — fine until a user has thousands of links).
PINs
pin_hash is bcrypt and never returned. The UI's "reveal" is a
prototype-only convenience (see getPin in mockApi.ts); against the real
service the PIN manager would only set / replace / remove
(PUT /api/links/{id}/pin, DELETE /api/links/{id}/pin), never display an
existing value.
Cache invalidation
On PUT/DELETE of a link (or its PIN), delete code:{code} and
user:{id}:links from Redis so the next read repopulates from Postgres.