mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
177 lines
7.6 KiB
Markdown
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.
|
|
|
|
   
|
|
|
|
## 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.
|
|
```
|