# 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](https://img.shields.io/badge/react-18-000) ![stack](https://img.shields.io/badge/vite-5-000) ![stack](https://img.shields.io/badge/tailwind-3-000) ![stack](https://img.shields.io/badge/motion-11-000) ## Run it ```bash 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`](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`](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`](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`](src/lib/types.ts) — is the entire integration. --- ## Backend > **Built** — see [`backend/`](backend/) and [backend/README.md](backend/README.md). > Go (hexagonal + DI), Postgres, Redis. One binary with three commands > (`api` / `frontend` / `redirect`), packed into one [Dockerfile](Dockerfile) and > wired up by [docker-compose.yml](docker-compose.yml) behind a [Caddy](Caddyfile) > 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" (`demo@snip.to`). 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) ```sql 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. ```