mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
feat: first commit
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
# 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.
|
||||
```
|
||||
Reference in New Issue
Block a user