2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00
2026-06-15 21:25:57 +07:00

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.

stack stack stack stack

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.
  • 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.css with a .dark override.
  • Motion: framer-motion spring transitions — the segmented mode selector (shared layoutId), 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 with docker 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:

  1. GET code:{code} from Redis → on hit, 302 immediately. No DB.
  2. On miss, read Postgres once, then SET code:{code} = long_url with a TTL (e.g. 24h). Subsequent hits are pure cache.
  3. Negative cache unknown codes briefly (SET code:{code} = "" EX 60) so bot/scanner traffic on non-existent codes can't hammer Postgres.
  4. Clicks never block the redirect. Fire-and-forget INCR clicks:{code}:{yyyy-mm-dd} in Redis; a background worker flushes counters into click_daily every ~10s. The user sees the redirect at Redis latency.
  5. PIN-protected codes return the unlock page instead of a 302; the PIN is checked against pin_hash and 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 in shortcode.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 the UNIQUE constraint 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.

S
Description
No description provided
Readme
231 KiB
Languages
Go 51.1%
TypeScript 45.4%
CSS 2%
Dockerfile 0.6%
HTML 0.6%
Other 0.3%