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

177 lines
7.6 KiB
Markdown

# 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" (`[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)
```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.
```