patx/youtube-clone

inspired by X, one shot a youtube clone, i didnt review shit!

Commit c19a214 · patx · 2026-09-07T13:09:34-04:00

Changeset
c19a21485944c3e229a8deebc0dc8446d17ffe53

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..807a2fa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+data/metube.db
+data/metube.db-*
+data/media/
+*.log
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0a79307
--- /dev/null
+++ b/README.md
@@ -0,0 +1,124 @@
+# MeTube
+
+A YouTube-style video platform: accounts, uploads, a browsable catalogue, likes,
+threaded comments, and channel subscriptions.
+
+It runs on **Node's standard library alone** — no npm dependencies. The HTTP
+server, router, session auth, multipart upload parser and range-request media
+server are all hand-rolled, and the database is Node 22's built-in
+`node:sqlite`. The front end is a vanilla ES-module SPA with no build step.
+
+## Running it
+
+```bash
+npm run seed     # creates the database and renders sample videos with ffmpeg
+npm start        # http://localhost:3000
+```
+
+`npm run dev` restarts on file changes. `npm run reset` wipes the database and
+all media, then re-seeds from scratch.
+
+Requirements: **Node 22.5+** (for `node:sqlite`) and **ffmpeg/ffprobe** on your
+PATH — used to probe durations and cut thumbnails on upload, and to generate the
+sample clips.
+
+### Demo accounts
+
+The seed builds six channels and five viewers. Sign in as any of them with the
+password `password123`:
+
+| Channel | About |
+| --- | --- |
+| `pixelforge` | Generative art and shader breakdowns |
+| `quietkitchen` | Slow cooking, sharp knives |
+| `northbound` | Long walks in cold places |
+| `lowlatency` | Competitive play and frame data |
+| `roomtone` | Home recording on a budget |
+| `thedailyloop` | A five-minute news digest |
+
+Viewer accounts: `marta`, `dev_haruki`, `clara_b`, `sam.reads`, `tuesday`.
+
+## What it does
+
+**Accounts** — sign up, sign in, sign out. Passwords are hashed with scrypt
+(16384/8/1) and compared in constant time; sessions are opaque 32-byte tokens in
+an `HttpOnly`, `SameSite=Lax` cookie, expiring after 30 days.
+
+**Browse** — a paginated home grid, category filters, search across titles,
+descriptions and channel names, a trending sort, and a subscriptions feed.
+Hovering a card cross-fades the still into the real video, muted, while an amber
+hairline sweeps the frame and the duration badge counts down.
+
+**Watch** — an HTML5 player served over HTTP range requests, so seeking works
+against the file on disk. Views are counted once playback actually starts and
+recorded to your history. Likes and dislikes render as a two-sided meter with a
+ratio bar underneath.
+
+**Comments** — post, reply one level deep, like, and delete. A video's owner can
+remove any comment on it; everyone can remove their own. Comments from the
+channel that published the video are badged `CREATOR`. Sort by top or newest.
+
+**Subscriptions** — subscribe from a watch page or a channel page; your
+subscriptions appear in the sidebar with video counts and drive the feed.
+
+**Upload** — drag-and-drop or browse, with a real progress bar (XHR) and a
+streaming server-side parser that writes straight to disk rather than buffering
+the file in memory. Duration is probed and a thumbnail is cut with ffmpeg. Limit
+512 MB; MP4, WebM, MOV and OGG.
+
+**Your channel** — edit your display name, bio and avatar colour; edit or delete
+your videos (deleting removes the media files too).
+
+**Library** — history (with a clear button), liked videos, and watch later.
+
+Plus: light and dark themes, keyboard focus styles throughout, `/` to focus
+search, `prefers-reduced-motion` respected (hover previews are disabled), and a
+layout that works down to a phone.
+
+## Layout
+
+```
+server/
+  index.js      HTTP server, routing, static + media serving
+  routes.js     every API endpoint
+  model.js      queries and JSON shaping
+  db.js         schema and connection
+  auth.js       password hashing, sessions, cookies
+  multipart.js  streaming multipart/form-data parser
+  static.js     file serving with Range support
+  http.js       request/response helpers
+public/
+  index.html    app shell
+  css/app.css   design tokens and components
+  js/           router, store, API client, DOM helpers, views
+scripts/
+  seed.js       demo data
+  seeddata.js   channels, videos, comments
+  videoclips.js ffmpeg recipes for the sample clips
+data/           SQLite database and uploaded media (gitignored)
+```
+
+## Design
+
+The interface borrows the vocabulary of an editing suite rather than a social
+app: deep slate panels, a single phosphor-amber accent, and every number —
+durations, view counts, timestamps, subscriber totals — set in tabular monospace
+like timecode. Type is Bricolage Grotesque for display, Public Sans for body,
+JetBrains Mono for figures.
+
+## Notes and limits
+
+- Sessions live in SQLite; expired rows are ignored on lookup but not swept.
+- Uploads are stored as-is — there is no transcoding, so playback depends on the
+  browser supporting the uploaded codec.
+- Everything is served over HTTP for local use. Behind TLS you would want the
+  `Secure` flag on the session cookie.
+- There is no rate limiting, moderation or email verification.
+
+## The sample videos
+
+The clips are generated, not downloaded: each is an ffmpeg render of a
+generative source — Mandelbrot drifts, Conway's life, rule-110 automata,
+Sierpinski carpets, gradient fields — graded and captioned, with a tone bed for
+audio. Recipes live in `scripts/videoclips.js`, and each clip's variant picks
+different coordinates, seeds and palettes so no two look alike.
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3868093
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "metube",
+  "version": "1.0.0",
+  "description": "A YouTube-style video sharing app: accounts, uploads, likes, comments, and subscriptions.",
+  "type": "module",
+  "private": true,
+  "engines": { "node": ">=22.5.0" },
+  "scripts": {
+    "start": "node --no-warnings server/index.js",
+    "dev": "node --no-warnings --watch server/index.js",
+    "seed": "node --no-warnings scripts/seed.js",
+    "reset": "rm -rf data/metube.db data/media/videos/* data/media/thumbs/* data/media/avatars/* && npm run seed"
+  }
+}
diff --git a/public/css/app.css b/public/css/app.css
new file mode 100644
index 0000000..039c1ea
--- /dev/null
+++ b/public/css/app.css
@@ -0,0 +1,855 @@
+/* =========================================================================
+   MeTube — an editing-suite identity: slate panels, phosphor amber, and
+   timecode-style monospace for every number in the interface.
+   ========================================================================= */
+
+:root {
+  --ink:        #0E1420;
+  --ink-sunk:   #0A0F19;
+  --panel:      #161E2C;
+  --panel-2:    #1D2637;
+  --line:       #26314A;
+  --line-soft:  #1E2739;
+  --paper:      #E8EDF5;
+  --haze:       #8A97AC;
+  --haze-dim:   #64708A;
+  --amber:      #F5A524;
+  --amber-dim:  #B87A16;
+  --amber-wash: rgba(245, 165, 36, 0.14);
+  --mint:       #3DD9A4;
+  --rose:       #FF5D73;
+  --shadow:     0 18px 44px rgba(0, 0, 0, 0.5);
+
+  --font-display: "Bricolage Grotesque", "Segoe UI", system-ui, sans-serif;
+  --font-body:    "Public Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
+  --font-mono:    "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
+
+  --topbar-h: 60px;
+  --rail-w: 236px;
+  --rail-mini: 0px;
+  --radius: 14px;
+  --radius-sm: 9px;
+  --gutter: 24px;
+  --ease: cubic-bezier(0.32, 0.72, 0.29, 1);
+}
+
+:root[data-theme="light"] {
+  --ink:        #F4F6FA;
+  --ink-sunk:   #E9EDF4;
+  --panel:      #FFFFFF;
+  --panel-2:    #F0F3F9;
+  --line:       #D8DFEA;
+  --line-soft:  #E4E9F1;
+  --paper:      #131A26;
+  --haze:       #5C6880;
+  --haze-dim:   #7B879E;
+  --amber:      #B76E00;
+  --amber-dim:  #8A5300;
+  --amber-wash: rgba(183, 110, 0, 0.12);
+  --mint:       #007F5F;
+  --rose:       #C81E3C;
+  --shadow:     0 14px 34px rgba(20, 30, 50, 0.13);
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; }
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  background: var(--ink);
+  color: var(--paper);
+  font-family: var(--font-body);
+  font-size: 14px;
+  line-height: 1.55;
+  -webkit-font-smoothing: antialiased;
+}
+
+img, video { display: block; max-width: 100%; }
+button, input, select, textarea { font: inherit; color: inherit; }
+a { color: inherit; text-decoration: none; }
+
+svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
+
+:focus-visible {
+  outline: 2px solid var(--amber);
+  outline-offset: 2px;
+  border-radius: 4px;
+}
+
+::selection { background: var(--amber); color: #0A0F19; }
+
+.skip-link {
+  position: fixed; top: 8px; left: 8px; z-index: 200;
+  padding: 10px 16px; border-radius: var(--radius-sm);
+  background: var(--amber); color: #0A0F19; font-weight: 700;
+  transform: translateY(-160%);
+}
+.skip-link:focus { transform: none; }
+
+/* -------------------------------------------------------------- numerics -- */
+/* Every count, duration and timestamp is set as timecode. */
+.num {
+  font-family: var(--font-mono);
+  font-size: 0.92em;
+  font-variant-numeric: tabular-nums;
+  letter-spacing: -0.02em;
+}
+
+/* --------------------------------------------------------------- topbar -- */
+
+.topbar {
+  position: sticky; top: 0; z-index: 60;
+  display: flex; align-items: center; gap: 16px;
+  height: var(--topbar-h); padding: 0 16px;
+  background: color-mix(in srgb, var(--ink) 86%, transparent);
+  backdrop-filter: blur(14px);
+  border-bottom: 1px solid var(--line-soft);
+}
+
+.topbar__left { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
+.topbar__right { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: 0 0 auto; }
+
+.iconbtn {
+  display: grid; place-items: center;
+  width: 38px; height: 38px; padding: 0;
+  border: 0; border-radius: 50%;
+  background: transparent; color: var(--paper);
+  cursor: pointer; transition: background 0.16s var(--ease);
+}
+.iconbtn:hover { background: var(--panel-2); }
+
+#theme-toggle .icon-sun { display: none; }
+:root[data-theme="light"] #theme-toggle .icon-moon { display: none; }
+:root[data-theme="light"] #theme-toggle .icon-sun { display: block; }
+
+.wordmark { display: flex; align-items: center; gap: 9px; padding: 4px 6px; }
+
+/* Three bars that read as a filmstrip; the last one is the lit amber frame. */
+.wordmark__mark { display: flex; align-items: flex-end; gap: 3px; height: 20px; }
+.wordmark__mark i {
+  display: block; width: 5px; border-radius: 2px; background: var(--haze-dim);
+  transition: height 0.3s var(--ease), background 0.3s var(--ease);
+}
+.wordmark__mark i:nth-child(1) { height: 10px; }
+.wordmark__mark i:nth-child(2) { height: 16px; }
+.wordmark__mark i:nth-child(3) { height: 20px; background: var(--amber); }
+.wordmark:hover .wordmark__mark i:nth-child(1) { height: 20px; background: var(--amber); }
+.wordmark:hover .wordmark__mark i:nth-child(2) { height: 10px; }
+.wordmark:hover .wordmark__mark i:nth-child(3) { height: 15px; background: var(--haze-dim); }
+
+.wordmark__text {
+  font-family: var(--font-display);
+  font-size: 21px; font-weight: 800; letter-spacing: -0.035em;
+}
+
+.search {
+  position: relative; flex: 1 1 auto; max-width: 620px;
+  display: flex; align-items: center;
+}
+.search__icon {
+  position: absolute; left: 14px; width: 18px; height: 18px;
+  color: var(--haze-dim); pointer-events: none;
+}
+.search__input {
+  width: 100%; height: 40px;
+  padding: 0 40px 0 42px;
+  border: 1px solid var(--line); border-radius: 999px;
+  background: var(--ink-sunk); color: var(--paper);
+  transition: border-color 0.16s var(--ease), background 0.16s var(--ease);
+}
+.search__input::placeholder { color: var(--haze-dim); }
+.search__input:focus { outline: none; border-color: var(--amber); background: var(--panel); }
+.search__input::-webkit-search-cancel-button { display: none; }
+.search__clear {
+  position: absolute; right: 8px;
+  width: 26px; height: 26px; padding: 0;
+  border: 0; border-radius: 50%; background: transparent;
+  color: var(--haze); font-size: 19px; line-height: 1; cursor: pointer;
+}
+.search__clear:hover { background: var(--panel-2); color: var(--paper); }
+
+/* --------------------------------------------------------------- shell --- */
+
+.shell { display: flex; align-items: flex-start; }
+
+.sidebar {
+  position: sticky; top: var(--topbar-h);
+  flex: 0 0 var(--rail-w); width: var(--rail-w);
+  height: calc(100vh - var(--topbar-h));
+  padding: 14px 10px 40px;
+  overflow-y: auto; overscroll-behavior: contain;
+  scrollbar-width: thin;
+}
+.sidebar.is-hidden { display: none; }
+
+.main {
+  flex: 1 1 auto; min-width: 0;
+  padding: 22px var(--gutter) 72px;
+}
+.main:focus { outline: none; }
+
+.navgroup { padding: 6px 0; }
+.navgroup + .navgroup { border-top: 1px solid var(--line-soft); margin-top: 6px; }
+
+.navgroup__title {
+  padding: 12px 12px 6px;
+  font-family: var(--font-mono);
+  font-size: 10.5px; font-weight: 700;
+  letter-spacing: 0.16em; text-transform: uppercase;
+  color: var(--haze-dim);
+}
+
+.navlink {
+  position: relative;
+  display: flex; align-items: center; gap: 14px;
+  padding: 9px 12px; margin-bottom: 2px;
+  border-radius: var(--radius-sm);
+  color: var(--paper);
+  transition: background 0.14s var(--ease);
+}
+.navlink:hover { background: var(--panel-2); }
+.navlink svg { width: 21px; height: 21px; color: var(--haze); flex: 0 0 auto; }
+.navlink span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.navlink.is-active { background: var(--amber-wash); font-weight: 600; }
+.navlink.is-active svg { color: var(--amber); }
+.navlink.is-active::before {
+  content: ""; position: absolute; left: 0; top: 50%;
+  width: 3px; height: 20px; margin-top: -10px;
+  border-radius: 0 3px 3px 0; background: var(--amber);
+}
+
+.navlink__count { margin-left: auto; color: var(--haze-dim); }
+
+.sidebar-scrim {
+  position: fixed; inset: var(--topbar-h) 0 0; z-index: 40;
+  background: rgba(6, 10, 18, 0.62);
+}
+
+/* -------------------------------------------------------------- avatars -- */
+
+.avatar {
+  position: relative; flex: 0 0 auto;
+  display: grid; place-items: center;
+  width: 36px; height: 36px; border-radius: 50%;
+  background: linear-gradient(150deg, hsl(var(--hue) 62% 52%), hsl(calc(var(--hue) + 38) 58% 38%));
+  color: #0A0F19;
+  font-family: var(--font-mono); font-weight: 700; font-size: 13px;
+  letter-spacing: -0.03em; text-transform: uppercase;
+  user-select: none;
+}
+.avatar--sm { width: 28px; height: 28px; font-size: 11px; }
+.avatar--lg { width: 78px; height: 78px; font-size: 27px; }
+.avatar--xl { width: 112px; height: 112px; font-size: 38px; }
+
+/* -------------------------------------------------------------- buttons -- */
+
+.btn {
+  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
+  height: 38px; padding: 0 17px;
+  border: 1px solid transparent; border-radius: 999px;
+  background: var(--panel-2); color: var(--paper);
+  font-weight: 600; white-space: nowrap; cursor: pointer;
+  transition: background 0.16s var(--ease), border-color 0.16s var(--ease), color 0.16s var(--ease), transform 0.1s var(--ease);
+}
+.btn:hover { background: var(--line); }
+.btn:active { transform: scale(0.975); }
+.btn svg { width: 19px; height: 19px; }
+.btn[disabled] { opacity: 0.5; pointer-events: none; }
+
+.btn--primary { background: var(--amber); color: #0A0F19; }
+.btn--primary:hover { background: color-mix(in srgb, var(--amber) 84%, #fff); }
+.btn--ghost { background: transparent; border-color: var(--line); }
+.btn--ghost:hover { background: var(--panel-2); }
+.btn--sm { height: 32px; padding: 0 13px; font-size: 13px; }
+.btn--full { width: 100%; }
+.btn--danger { color: var(--rose); border-color: color-mix(in srgb, var(--rose) 40%, transparent); background: transparent; }
+.btn--danger:hover { background: color-mix(in srgb, var(--rose) 14%, transparent); }
+
+/* Subscribe reads as "armed" when off and "locked in" when on. */
+.btn--subscribe { background: var(--paper); color: var(--ink); }
+.btn--subscribe:hover { background: color-mix(in srgb, var(--paper) 86%, var(--amber)); }
+.btn--subscribe.is-on { background: var(--panel-2); color: var(--paper); border-color: var(--line); }
+.btn--subscribe.is-on:hover { background: var(--line); }
+
+.upload-link span { display: inline; }
+
+/* ---------------------------------------------------------------- chips -- */
+
+.chips {
+  display: flex; gap: 9px;
+  margin: -4px 0 22px;
+  padding-bottom: 4px;
+  overflow-x: auto; scrollbar-width: none;
+}
+.chips::-webkit-scrollbar { display: none; }
+.chip {
+  flex: 0 0 auto;
+  height: 33px; padding: 0 15px;
+  border: 1px solid var(--line); border-radius: 999px;
+  background: var(--panel); color: var(--paper);
+  font-size: 13px; font-weight: 500; cursor: pointer;
+  transition: background 0.14s var(--ease), color 0.14s var(--ease), border-color 0.14s var(--ease);
+}
+.chip:hover { background: var(--panel-2); }
+.chip.is-on { background: var(--paper); color: var(--ink); border-color: var(--paper); font-weight: 600; }
+
+/* ----------------------------------------------------------- video grid -- */
+
+.grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(288px, 1fr));
+  gap: 30px 18px;
+}
+.grid--tight { grid-template-columns: repeat(auto-fill, minmax(236px, 1fr)); gap: 24px 16px; }
+
+.card { display: flex; flex-direction: column; gap: 11px; }
+
+.card__frame {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  border-radius: var(--radius);
+  overflow: hidden;
+  background: var(--ink-sunk);
+  isolation: isolate;
+}
+.card__frame img,
+.card__frame video {
+  position: absolute; inset: 0;
+  width: 100%; height: 100%;
+  object-fit: cover;
+}
+.card__frame video { opacity: 0; transition: opacity 0.28s var(--ease); }
+.card.is-previewing .card__frame video { opacity: 1; }
+.card.is-previewing .card__frame img { opacity: 0.001; }
+
+.card__placeholder {
+  position: absolute; inset: 0;
+  display: grid; place-items: center;
+  background: linear-gradient(140deg, var(--panel-2), var(--ink-sunk));
+  color: var(--haze-dim);
+}
+
+/* Signature: an amber hairline sweeps the frame while the preview plays. */
+.card__sweep {
+  position: absolute; left: 0; bottom: 0; z-index: 3;
+  height: 3px; width: 0;
+  background: var(--amber);
+  box-shadow: 0 0 10px color-mix(in srgb, var(--amber) 60%, transparent);
+}
+
+.card__time {
+  position: absolute; right: 8px; bottom: 8px; z-index: 2;
+  padding: 2px 6px; border-radius: 5px;
+  background: rgba(8, 12, 20, 0.86); color: #fff;
+  font-family: var(--font-mono); font-size: 12px; font-weight: 500;
+  font-variant-numeric: tabular-nums;
+}
+.card.is-previewing .card__time { background: var(--amber); color: #0A0F19; }
+
+.card__save {
+  position: absolute; right: 8px; top: 8px; z-index: 3;
+  display: grid; place-items: center;
+  width: 32px; height: 32px; padding: 0;
+  border: 0; border-radius: 8px;
+  background: rgba(8, 12, 20, 0.78); color: #fff;
+  cursor: pointer; opacity: 0;
+  transition: opacity 0.16s var(--ease), background 0.16s var(--ease);
+}
+.card:hover .card__save, .card__save:focus-visible { opacity: 1; }
+.card__save:hover { background: var(--amber); color: #0A0F19; }
+.card__save.is-on { opacity: 1; color: var(--amber); }
+.card__save.is-on:hover { color: #0A0F19; }
+.card__save svg { width: 17px; height: 17px; }
+
+.card__body { display: flex; gap: 12px; align-items: flex-start; }
+
+.card__title {
+  margin: 0 0 4px;
+  font-family: var(--font-display);
+  font-size: 15px; font-weight: 600; line-height: 1.32;
+  letter-spacing: -0.012em;
+  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+.card:hover .card__title { color: var(--amber); }
+
+.card__channel { display: block; color: var(--haze); font-size: 13px; }
+.card__channel:hover { color: var(--paper); }
+.card__meta { color: var(--haze-dim); font-size: 13px; }
+.card__meta .num { color: var(--haze); }
+
+.card__menu { margin-left: auto; }
+
+/* Compact card used in the watch-page rail and list views. */
+.rowcard { display: flex; gap: 12px; }
+.rowcard .card__frame { flex: 0 0 168px; width: 168px; border-radius: 10px; }
+.rowcard__body { min-width: 0; padding-top: 1px; }
+.rowcard .card__title { font-size: 14px; -webkit-line-clamp: 2; }
+
+/* ---------------------------------------------------------- watch page --- */
+
+.watch {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 396px;
+  gap: 28px;
+  max-width: 1720px;
+  margin: 0 auto;
+}
+
+.player {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  border-radius: var(--radius);
+  overflow: hidden;
+  background: #000;
+  box-shadow: var(--shadow);
+}
+.player video { width: 100%; height: 100%; object-fit: contain; background: #000; }
+
+.watch__title {
+  margin: 18px 0 14px;
+  font-family: var(--font-display);
+  font-size: 21px; font-weight: 700; line-height: 1.28;
+  letter-spacing: -0.02em;
+}
+
+.watch__bar {
+  display: flex; align-items: center; gap: 14px;
+  flex-wrap: wrap;
+  padding-bottom: 16px;
+  border-bottom: 1px solid var(--line-soft);
+}
+
+.channelline { display: flex; align-items: center; gap: 12px; min-width: 0; }
+.channelline__name { font-weight: 600; display: block; letter-spacing: -0.01em; }
+.channelline__name:hover { color: var(--amber); }
+.channelline__subs { color: var(--haze); font-size: 12.5px; }
+
+.watch__actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-wrap: wrap; }
+
+/* The vote control is a two-sided meter: amber for up, rose for down. */
+.votes {
+  display: flex; align-items: stretch;
+  height: 38px;
+  border: 1px solid var(--line); border-radius: 999px;
+  background: var(--panel-2);
+  overflow: hidden;
+}
+.vote {
+  display: inline-flex; align-items: center; gap: 8px;
+  padding: 0 15px; border: 0; background: transparent;
+  color: var(--paper); font-weight: 600; cursor: pointer;
+  transition: background 0.14s var(--ease), color 0.14s var(--ease);
+}
+.vote:hover { background: var(--line); }
+.vote + .vote { border-left: 1px solid var(--line); }
+.vote svg { width: 19px; height: 19px; }
+.vote.is-on { color: var(--amber); }
+.vote.is-on svg { fill: color-mix(in srgb, var(--amber) 26%, transparent); }
+.vote--down.is-on { color: var(--rose); }
+.vote--down.is-on svg { fill: color-mix(in srgb, var(--rose) 26%, transparent); }
+
+.ratio {
+  position: relative;
+  height: 3px; margin: 10px 0 0;
+  border-radius: 2px;
+  background: var(--line);
+  overflow: hidden;
+}
+.ratio__fill { position: absolute; inset: 0 auto 0 0; background: var(--amber); border-radius: 2px; }
+
+.descbox {
+  margin-top: 14px; padding: 14px 16px;
+  border-radius: var(--radius);
+  background: var(--panel);
+  border: 1px solid var(--line-soft);
+}
+.descbox__meta {
+  display: flex; flex-wrap: wrap; gap: 10px;
+  margin-bottom: 8px;
+  font-weight: 600; font-size: 13.5px;
+}
+.descbox__meta .num { color: var(--paper); }
+.descbox__tag {
+  padding: 1px 9px; border-radius: 999px;
+  background: var(--amber-wash); color: var(--amber);
+  font-family: var(--font-mono); font-size: 11px; font-weight: 700;
+  letter-spacing: 0.06em; text-transform: uppercase;
+}
+.descbox__body { white-space: pre-wrap; color: var(--paper); font-size: 13.5px; line-height: 1.62; }
+.descbox__body.is-clamped {
+  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
+}
+.descbox__more {
+  margin-top: 8px; padding: 0; border: 0; background: none;
+  color: var(--haze); font-weight: 700; font-size: 13px; cursor: pointer;
+}
+.descbox__more:hover { color: var(--amber); }
+
+/* ------------------------------------------------------------- comments -- */
+
+.comments { margin-top: 28px; }
+
+.comments__head {
+  display: flex; align-items: center; gap: 18px;
+  margin-bottom: 20px;
+}
+.comments__count { font-family: var(--font-display); font-size: 17px; font-weight: 700; letter-spacing: -0.015em; }
+.sortlink {
+  padding: 4px 10px; border: 0; border-radius: 999px;
+  background: transparent; color: var(--haze);
+  font-family: var(--font-mono); font-size: 11px; font-weight: 700;
+  letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer;
+}
+.sortlink:hover { color: var(--paper); }
+.sortlink.is-on { background: var(--panel-2); color: var(--amber); }
+
+.composer { display: flex; gap: 13px; margin-bottom: 30px; }
+.composer__main { flex: 1 1 auto; min-width: 0; }
+.composer__input {
+  width: 100%; padding: 7px 0; resize: none;
+  border: 0; border-bottom: 1px solid var(--line);
+  background: transparent; color: var(--paper);
+  font-family: inherit; line-height: 1.55;
+  transition: border-color 0.16s var(--ease);
+}
+.composer__input::placeholder { color: var(--haze-dim); }
+.composer__input:focus { outline: none; border-bottom-color: var(--amber); }
+.composer__actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 10px; }
+
+.comment { display: flex; gap: 13px; margin-bottom: 22px; }
+.comment__main { flex: 1 1 auto; min-width: 0; }
+.comment__head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 3px; }
+.comment__author { font-weight: 600; font-size: 13px; }
+.comment__author:hover { color: var(--amber); }
+.comment__badge {
+  padding: 1px 7px; border-radius: 999px;
+  background: var(--amber); color: #0A0F19;
+  font-family: var(--font-mono); font-size: 10px; font-weight: 700;
+  letter-spacing: 0.05em; text-transform: uppercase;
+}
+.comment__when { color: var(--haze-dim); font-size: 12px; }
+.comment__body { white-space: pre-wrap; line-height: 1.6; overflow-wrap: anywhere; }
+.comment__actions { display: flex; align-items: center; gap: 4px; margin-top: 6px; }
+.comment__replies { margin-top: 14px; padding-left: 6px; border-left: 1px solid var(--line-soft); }
+.comment__replies .comment { padding-left: 14px; margin-bottom: 16px; }
+
+.tinybtn {
+  display: inline-flex; align-items: center; gap: 6px;
+  height: 28px; padding: 0 10px;
+  border: 0; border-radius: 999px;
+  background: transparent; color: var(--haze);
+  font-size: 12.5px; font-weight: 600; cursor: pointer;
+  transition: background 0.14s var(--ease), color 0.14s var(--ease);
+}
+.tinybtn:hover { background: var(--panel-2); color: var(--paper); }
+.tinybtn svg { width: 16px; height: 16px; }
+.tinybtn.is-on { color: var(--amber); }
+.tinybtn.is-on svg { fill: color-mix(in srgb, var(--amber) 26%, transparent); }
+
+/* -------------------------------------------------------------- channel -- */
+
+.channelhero {
+  display: flex; align-items: center; gap: 26px;
+  padding: 26px;
+  margin-bottom: 24px;
+  border-radius: 20px;
+  background:
+    radial-gradient(120% 160% at 8% 0%, hsl(var(--hue) 58% 46% / 0.34), transparent 62%),
+    var(--panel);
+  border: 1px solid var(--line-soft);
+}
+.channelhero__info { min-width: 0; }
+.channelhero__name {
+  margin: 0 0 4px;
+  font-family: var(--font-display);
+  font-size: 30px; font-weight: 800; letter-spacing: -0.03em;
+}
+.channelhero__handle { color: var(--haze); }
+.channelhero__stats { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 8px; color: var(--haze); font-size: 13px; }
+.channelhero__stats .num { color: var(--paper); font-weight: 500; }
+.channelhero__bio { margin: 12px 0 0; max-width: 62ch; color: var(--haze); font-size: 13.5px; line-height: 1.6; }
+.channelhero__actions { margin-left: auto; display: flex; gap: 9px; flex-wrap: wrap; }
+
+.tabs {
+  display: flex; gap: 4px;
+  margin-bottom: 22px;
+  border-bottom: 1px solid var(--line-soft);
+}
+.tab {
+  padding: 10px 16px;
+  border: 0; border-bottom: 2px solid transparent;
+  background: none; color: var(--haze);
+  font-weight: 600; cursor: pointer;
+}
+.tab:hover { color: var(--paper); }
+.tab.is-on { color: var(--paper); border-bottom-color: var(--amber); }
+
+/* ------------------------------------------------------- page furniture -- */
+
+.pagehead {
+  display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap;
+  margin-bottom: 22px;
+}
+.pagehead__title {
+  margin: 0;
+  font-family: var(--font-display);
+  font-size: 27px; font-weight: 800; letter-spacing: -0.03em;
+}
+.pagehead__sub { color: var(--haze); font-size: 13.5px; }
+.pagehead__actions { margin-left: auto; display: flex; gap: 9px; }
+
+.eyebrow {
+  display: block; margin-bottom: 6px;
+  font-family: var(--font-mono);
+  font-size: 11px; font-weight: 700;
+  letter-spacing: 0.18em; text-transform: uppercase;
+  color: var(--amber);
+}
+
+.empty {
+  display: grid; place-items: center; gap: 8px;
+  padding: 72px 24px; text-align: center;
+  border: 1px dashed var(--line); border-radius: var(--radius);
+  background: var(--panel);
+}
+.empty__title { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; }
+.empty__text { color: var(--haze); max-width: 46ch; }
+.empty svg { width: 34px; height: 34px; color: var(--amber); }
+
+.spinner {
+  width: 26px; height: 26px; margin: 60px auto;
+  border: 2.5px solid var(--line); border-top-color: var(--amber);
+  border-radius: 50%;
+  animation: spin 0.7s linear infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.skeleton { border-radius: var(--radius); background: var(--panel-2); animation: pulse 1.5s ease-in-out infinite; }
+@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
+
+/* ---------------------------------------------------------------- forms -- */
+
+.panel {
+  padding: 24px;
+  border: 1px solid var(--line-soft); border-radius: var(--radius);
+  background: var(--panel);
+}
+
+.field { margin-bottom: 17px; }
+.field__label {
+  display: block; margin-bottom: 6px;
+  font-family: var(--font-mono);
+  font-size: 11px; font-weight: 700;
+  letter-spacing: 0.12em; text-transform: uppercase;
+  color: var(--haze);
+}
+.field__hint { margin-top: 6px; color: var(--haze-dim); font-size: 12.5px; }
+.input, .textarea, .select {
+  width: 100%; padding: 10px 13px;
+  border: 1px solid var(--line); border-radius: var(--radius-sm);
+  background: var(--ink-sunk); color: var(--paper);
+  transition: border-color 0.16s var(--ease);
+}
+.input:focus, .textarea:focus, .select:focus { outline: none; border-color: var(--amber); }
+.textarea { resize: vertical; min-height: 108px; line-height: 1.6; font-family: inherit; }
+.select { appearance: none; cursor: pointer; background-image: none; }
+
+.formerror {
+  margin-bottom: 15px; padding: 10px 13px;
+  border-radius: var(--radius-sm);
+  border: 1px solid color-mix(in srgb, var(--rose) 45%, transparent);
+  background: color-mix(in srgb, var(--rose) 12%, transparent);
+  color: var(--rose); font-size: 13px; font-weight: 500;
+}
+
+/* ----------------------------------------------------------------- auth -- */
+
+.auth { display: grid; place-items: center; min-height: calc(100vh - var(--topbar-h) - 60px); padding: 20px 0; }
+.auth__card { width: 100%; max-width: 412px; }
+.auth__head { text-align: center; margin-bottom: 22px; }
+.auth__title { margin: 0 0 6px; font-family: var(--font-display); font-size: 25px; font-weight: 800; letter-spacing: -0.03em; }
+.auth__sub { color: var(--haze); font-size: 13.5px; }
+.auth__switch { margin-top: 18px; text-align: center; color: var(--haze); font-size: 13px; }
+.auth__switch button {
+  padding: 0; border: 0; background: none;
+  color: var(--amber); font-weight: 700; cursor: pointer;
+}
+.auth__switch button:hover { text-decoration: underline; }
+.auth__demo {
+  margin-top: 18px; padding: 12px 14px;
+  border: 1px dashed var(--line); border-radius: var(--radius-sm);
+  color: var(--haze); font-size: 12.5px; line-height: 1.6;
+}
+.auth__demo b { color: var(--paper); }
+.auth__demo code {
+  font-family: var(--font-mono); font-size: 12px;
+  color: var(--amber);
+}
+
+/* ------------------------------------------------------------- uploader -- */
+
+.dropzone {
+  display: grid; place-items: center; gap: 10px;
+  padding: 52px 24px; text-align: center;
+  border: 2px dashed var(--line); border-radius: var(--radius);
+  background: var(--ink-sunk); cursor: pointer;
+  transition: border-color 0.16s var(--ease), background 0.16s var(--ease);
+}
+.dropzone:hover, .dropzone.is-over { border-color: var(--amber); background: var(--amber-wash); }
+.dropzone svg { width: 38px; height: 38px; color: var(--amber); }
+.dropzone__title { font-family: var(--font-display); font-size: 17px; font-weight: 700; }
+.dropzone__hint { color: var(--haze); font-size: 13px; }
+
+.filecard {
+  display: flex; align-items: center; gap: 14px;
+  padding: 14px;
+  border: 1px solid var(--line); border-radius: var(--radius);
+  background: var(--ink-sunk);
+}
+.filecard__icon {
+  display: grid; place-items: center;
+  width: 46px; height: 46px; border-radius: 10px;
+  background: var(--amber-wash); color: var(--amber);
+}
+.filecard__name { font-weight: 600; word-break: break-all; }
+.filecard__size { color: var(--haze); }
+
+.progress { height: 6px; margin-top: 14px; border-radius: 3px; background: var(--line); overflow: hidden; }
+.progress__fill { height: 100%; width: 0; background: var(--amber); transition: width 0.2s var(--ease); }
+.progress__label { display: flex; justify-content: space-between; margin-top: 8px; color: var(--haze); font-size: 12.5px; }
+
+/* --------------------------------------------------------------- toasts -- */
+
+.toasts {
+  position: fixed; left: 50%; bottom: 26px; z-index: 120;
+  display: flex; flex-direction: column; gap: 9px; align-items: center;
+  transform: translateX(-50%);
+  pointer-events: none;
+}
+.toast {
+  display: flex; align-items: center; gap: 10px;
+  max-width: min(90vw, 460px);
+  padding: 11px 17px;
+  border-radius: 999px;
+  border: 1px solid var(--line);
+  background: var(--panel-2); color: var(--paper);
+  box-shadow: var(--shadow);
+  font-size: 13.5px; font-weight: 500;
+  animation: toast-in 0.24s var(--ease);
+}
+.toast--error { border-color: color-mix(in srgb, var(--rose) 55%, transparent); color: var(--rose); }
+.toast--good { border-color: color-mix(in srgb, var(--mint) 45%, transparent); }
+.toast.is-out { animation: toast-out 0.22s var(--ease) forwards; }
+@keyframes toast-in { from { opacity: 0; transform: translateY(12px); } }
+@keyframes toast-out { to { opacity: 0; transform: translateY(8px); } }
+
+/* --------------------------------------------------------------- modals -- */
+
+.modal-root:empty { display: none; }
+.modal {
+  position: fixed; inset: 0; z-index: 100;
+  display: grid; place-items: center;
+  padding: 20px;
+  background: rgba(6, 10, 18, 0.68);
+  backdrop-filter: blur(3px);
+  animation: fade-in 0.16s var(--ease);
+}
+.modal__card {
+  width: 100%; max-width: 420px;
+  padding: 24px;
+  border: 1px solid var(--line); border-radius: 18px;
+  background: var(--panel);
+  box-shadow: var(--shadow);
+  animation: rise 0.22s var(--ease);
+}
+.modal__title { margin: 0 0 8px; font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; }
+.modal__text { color: var(--haze); margin: 0 0 20px; line-height: 1.6; }
+.modal__actions { display: flex; justify-content: flex-end; gap: 9px; }
+@keyframes fade-in { from { opacity: 0; } }
+@keyframes rise { from { opacity: 0; transform: translateY(10px) scale(0.985); } }
+
+/* ---------------------------------------------------------------- menus -- */
+
+.menu { position: relative; }
+.menu__panel {
+  position: absolute; right: 0; top: calc(100% + 6px); z-index: 70;
+  min-width: 196px; padding: 6px;
+  border: 1px solid var(--line); border-radius: 12px;
+  background: var(--panel-2);
+  box-shadow: var(--shadow);
+  animation: rise 0.14s var(--ease);
+}
+.menu__item {
+  display: flex; align-items: center; gap: 11px; width: 100%;
+  padding: 9px 11px;
+  border: 0; border-radius: 8px;
+  background: none; color: var(--paper);
+  font-size: 13.5px; text-align: left; cursor: pointer;
+}
+.menu__item:hover { background: var(--line); }
+.menu__item svg { width: 17px; height: 17px; color: var(--haze); }
+.menu__item--danger { color: var(--rose); }
+.menu__item--danger svg { color: var(--rose); }
+.menu__sep { height: 1px; margin: 5px 4px; background: var(--line); }
+.menu__head { padding: 10px 11px; border-bottom: 1px solid var(--line); margin-bottom: 5px; }
+.menu__name { font-weight: 700; }
+.menu__handle { color: var(--haze); font-size: 12.5px; }
+
+/* ------------------------------------------------------------ responsive -- */
+
+@media (max-width: 1300px) {
+  .watch { grid-template-columns: minmax(0, 1fr) 340px; }
+}
+
+@media (max-width: 1100px) {
+  .watch { grid-template-columns: minmax(0, 1fr); }
+  .watch__rail { margin-top: 8px; }
+}
+
+@media (max-width: 1000px) {
+  :root { --gutter: 18px; }
+  .sidebar {
+    position: fixed; top: var(--topbar-h); left: 0; z-index: 50;
+    width: 268px; flex-basis: 268px;
+    background: var(--ink);
+    border-right: 1px solid var(--line-soft);
+    transform: translateX(-100%);
+    transition: transform 0.24s var(--ease);
+  }
+  .sidebar.is-open { transform: none; }
+  .sidebar.is-hidden { display: block; transform: translateX(-100%); }
+}
+
+@media (max-width: 760px) {
+  :root { --gutter: 14px; }
+  .topbar { gap: 10px; padding: 0 10px; }
+  .upload-link span { display: none; }
+  .upload-link { padding: 0; width: 38px; }
+  .wordmark__text { display: none; }
+  .grid { grid-template-columns: 1fr; gap: 26px 0; }
+  .grid--tight { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
+  .channelhero { flex-direction: column; align-items: flex-start; gap: 16px; padding: 20px; }
+  .channelhero__actions { margin-left: 0; }
+  .channelhero__name { font-size: 24px; }
+  .watch__title { font-size: 18px; }
+  .rowcard { flex-direction: row; }
+  .rowcard .card__frame { flex-basis: 148px; width: 148px; }
+  .main { padding: 16px var(--gutter) 60px; }
+}
+
+@media (max-width: 460px) {
+  .rowcard { flex-direction: column; }
+  .rowcard .card__frame { flex-basis: auto; width: 100%; }
+  .watch__actions { margin-left: 0; width: 100%; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *, *::before, *::after {
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: 0.01ms !important;
+  }
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..1f98bd2
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+  <rect width="32" height="32" rx="8" fill="#0E1420"/>
+  <rect x="6" y="9" width="14" height="14" rx="3" fill="#F5A524"/>
+  <path d="M22 13.2 27 10v12l-5-3.2z" fill="#3DD9A4"/>
+</svg>
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..5916804
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,59 @@
+<!doctype html>
+<html lang="en" data-theme="dark">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+  <title>MeTube</title>
+  <meta name="description" content="MeTube — watch, upload and follow independent video channels.">
+  <meta name="color-scheme" content="dark light">
+  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
+  <link rel="preconnect" href="https://fonts.googleapis.com">
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,[email protected],500;12..96,600;12..96,700;12..96,800&family=Public+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500;700&display=swap">
+  <link rel="stylesheet" href="/css/app.css">
+</head>
+<body>
+  <a class="skip-link" href="#view">Skip to content</a>
+
+  <header class="topbar">
+    <div class="topbar__left">
+      <button class="iconbtn" id="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
+        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
+      </button>
+      <a class="wordmark" href="/" aria-label="MeTube home">
+        <span class="wordmark__mark" aria-hidden="true"><i></i><i></i><i></i></span>
+        <span class="wordmark__text">MeTube</span>
+      </a>
+    </div>
+
+    <form class="search" id="search-form" role="search">
+      <svg class="search__icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
+      <input class="search__input" id="search-input" type="search" name="q" placeholder="Search videos and channels" aria-label="Search videos and channels" autocomplete="off">
+      <button class="search__clear" id="search-clear" type="button" aria-label="Clear search" hidden>&times;</button>
+    </form>
+
+    <div class="topbar__right">
+      <button class="iconbtn" id="theme-toggle" type="button" aria-label="Switch to light theme">
+        <svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/></svg>
+        <svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.6v2.2M12 19.2v2.2M2.6 12h2.2M19.2 12h2.2M5.4 5.4l1.5 1.5M17.1 17.1l1.5 1.5M18.6 5.4l-1.5 1.5M6.9 17.1l-1.5 1.5"/></svg>
+      </button>
+      <a class="btn btn--ghost upload-link" href="/upload">
+        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V4M7.5 8.5 12 4l4.5 4.5M4 16v2.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V16"/></svg>
+        <span>Upload</span>
+      </a>
+      <div id="account-slot"></div>
+    </div>
+  </header>
+
+  <div class="shell">
+    <nav class="sidebar" id="sidebar" aria-label="Main"></nav>
+    <div class="sidebar-scrim" id="sidebar-scrim" hidden></div>
+    <main class="main" id="view" tabindex="-1"></main>
+  </div>
+
+  <div class="toasts" id="toasts" role="status" aria-live="polite"></div>
+  <div class="modal-root" id="modal-root"></div>
+
+  <script type="module" src="/js/app.js"></script>
+</body>
+</html>
diff --git a/public/js/api.js b/public/js/api.js
new file mode 100644
index 0000000..bc0958d
--- /dev/null
+++ b/public/js/api.js
@@ -0,0 +1,94 @@
+/** Thin fetch wrapper. Every failure surfaces as an Error with the server's message. */
+
+export class ApiError extends Error {
+  constructor(message, status) {
+    super(message);
+    this.status = status;
+  }
+}
+
+async function request(method, path, body) {
+  const options = { method, credentials: 'same-origin', headers: {} };
+  if (body !== undefined) {
+    options.headers['Content-Type'] = 'application/json';
+    options.body = JSON.stringify(body);
+  }
+
+  let res;
+  try {
+    res = await fetch(path, options);
+  } catch {
+    throw new ApiError('Cannot reach the server. Check your connection and try again.', 0);
+  }
+
+  const text = await res.text();
+  const data = text ? safeParse(text) : {};
+  if (!res.ok) throw new ApiError(data?.error || `Request failed (${res.status}).`, res.status);
+  return data;
+}
+
+const safeParse = (text) => { try { return JSON.parse(text); } catch { return {}; } };
+
+const qs = (params = {}) => {
+  const search = new URLSearchParams();
+  for (const [key, value] of Object.entries(params)) {
+    if (value !== null && value !== undefined && value !== '') search.set(key, value);
+  }
+  const out = search.toString();
+  return out ? `?${out}` : '';
+};
+
+export const api = {
+  me: () => request('GET', '/api/auth/me'),
+  signup: (payload) => request('POST', '/api/auth/signup', payload),
+  login: (payload) => request('POST', '/api/auth/login', payload),
+  logout: () => request('POST', '/api/auth/logout', {}),
+  updateMe: (payload) => request('PATCH', '/api/me', payload),
+
+  categories: () => request('GET', '/api/categories'),
+  videos: (params) => request('GET', `/api/videos${qs(params)}`),
+  video: (id) => request('GET', `/api/videos/${id}`),
+  countView: (id) => request('POST', `/api/videos/${id}/view`, {}),
+  vote: (id, value) => request('POST', `/api/videos/${id}/like`, { value }),
+  toggleSave: (id) => request('POST', `/api/videos/${id}/save`, {}),
+  updateVideo: (id, payload) => request('PATCH', `/api/videos/${id}`, payload),
+  deleteVideo: (id) => request('DELETE', `/api/videos/${id}`),
+
+  comments: (id, sort) => request('GET', `/api/videos/${id}/comments${qs({ sort })}`),
+  addComment: (id, body, parentId = null) => request('POST', `/api/videos/${id}/comments`, { body, parentId }),
+  likeComment: (id, value) => request('POST', `/api/comments/${id}/like`, { value }),
+  deleteComment: (id) => request('DELETE', `/api/comments/${id}`),
+
+  channel: (username, params) => request('GET', `/api/channels/${encodeURIComponent(username)}${qs(params)}`),
+  subscribe: (username) => request('POST', `/api/channels/${encodeURIComponent(username)}/subscribe`, {}),
+  subscriptions: () => request('GET', '/api/subscriptions'),
+  channels: () => request('GET', '/api/channels'),
+
+  liked: () => request('GET', '/api/library/liked'),
+  history: () => request('GET', '/api/library/history'),
+  saved: () => request('GET', '/api/library/saved'),
+  clearHistory: () => request('DELETE', '/api/library/history'),
+};
+
+/** Uploads need progress reporting, so this one goes through XHR. */
+export function uploadVideo(formData, onProgress) {
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open('POST', '/api/videos');
+    xhr.withCredentials = true;
+
+    xhr.upload.addEventListener('progress', (event) => {
+      if (event.lengthComputable) onProgress?.(event.loaded / event.total, event.loaded, event.total);
+    });
+    xhr.addEventListener('load', () => {
+      const data = safeParse(xhr.responseText);
+      if (xhr.status >= 200 && xhr.status < 300) resolve(data);
+      else reject(new ApiError(data?.error || `Upload failed (${xhr.status}).`, xhr.status));
+    });
+    xhr.addEventListener('error', () => reject(new ApiError('The upload was interrupted.', 0)));
+    xhr.addEventListener('abort', () => reject(new ApiError('Upload cancelled.', 0)));
+
+    xhr.send(formData);
+    resolve.xhr = xhr;
+  });
+}
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..5a3ab0e
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,259 @@
+/** Bootstraps MeTube: routes, chrome (sidebar + account menu) and theme. */
+import { h, icon, mount, clear } from './dom.js';
+import { api } from './api.js';
+import { state, bootstrap, onChange, setUser } from './store.js';
+import { define, resolve, start, navigate, currentPath, currentQuery } from './router.js';
+import { avatar, dropdown, menuItem, toast, teardown, spinner, emptyState } from './ui.js';
+import { compact } from './format.js';
+import { homeView, searchView, trendingView, subscriptionsView, channelsView, libraryView } from './views/browse.js';
+import { watchView } from './views/watch.js';
+import { channelView } from './views/channel.js';
+import { authView } from './views/auth.js';
+import { uploadView, editView } from './views/upload.js';
+
+/* ---------------------------------------------------------------- routes -- */
+
+define('/', homeView);
+define('/search', searchView);
+define('/trending', trendingView);
+define('/subscriptions', subscriptionsView);
+define('/channels', channelsView);
+define('/history', libraryView('history'));
+define('/liked', libraryView('liked'));
+define('/later', libraryView('saved'));
+define('/watch/:id', watchView);
+define('/upload', uploadView);
+define('/edit/:id', editView);
+define('/signin', authView('signin'));
+define('/signup', authView('signup'));
+define('/@:username', channelView);
+
+/* ----------------------------------------------------------------- theme -- */
+
+const themeToggle = document.getElementById('theme-toggle');
+
+function applyTheme(theme) {
+  document.documentElement.dataset.theme = theme;
+  document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', theme === 'dark' ? 'dark light' : 'light dark');
+  themeToggle.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`);
+  try { localStorage.setItem('metube:theme', theme); } catch { /* private mode */ }
+}
+
+function initTheme() {
+  let stored = null;
+  try { stored = localStorage.getItem('metube:theme'); } catch { /* private mode */ }
+  const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
+  applyTheme(stored ?? (prefersLight ? 'light' : 'dark'));
+}
+
+themeToggle.addEventListener('click', () => {
+  applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
+});
+
+/* --------------------------------------------------------------- sidebar -- */
+
+const sidebar = document.getElementById('sidebar');
+const scrim = document.getElementById('sidebar-scrim');
+const navToggle = document.getElementById('nav-toggle');
+const isNarrow = () => window.matchMedia('(max-width: 1000px)').matches;
+
+const closeDrawer = () => {
+  sidebar.classList.remove('is-open');
+  scrim.hidden = true;
+  navToggle.setAttribute('aria-expanded', 'false');
+};
+
+navToggle.addEventListener('click', () => {
+  if (isNarrow()) {
+    const open = sidebar.classList.toggle('is-open');
+    scrim.hidden = !open;
+    navToggle.setAttribute('aria-expanded', String(open));
+  } else {
+    sidebar.classList.toggle('is-hidden');
+    navToggle.setAttribute('aria-expanded', String(!sidebar.classList.contains('is-hidden')));
+  }
+});
+scrim.addEventListener('click', closeDrawer);
+
+const NAV_MAIN = [
+  ['/', 'home', 'Home'],
+  ['/subscriptions', 'subs', 'Subscriptions'],
+  ['/trending', 'fire', 'Trending'],
+  ['/channels', 'channel', 'Channels'],
+];
+
+const NAV_LIBRARY = [
+  ['/history', 'history', 'History'],
+  ['/liked', 'like', 'Liked videos'],
+  ['/later', 'clock', 'Watch later'],
+];
+
+function navLink(href, iconName, label, extra = null) {
+  const active = currentPath() === href && !(href === '/' && currentQuery().get('category'));
+  return h('a.navlink', { href, class: active ? 'is-active' : '', 'aria-current': active ? 'page' : null },
+    icon(iconName, 21), h('span', { text: label }), extra);
+}
+
+function renderSidebar() {
+  const path = currentPath();
+  const groups = [
+    h('div.navgroup', {}, NAV_MAIN.map(([href, ic, label]) => navLink(href, ic, label))),
+    h('div.navgroup', {},
+      h('div.navgroup__title', { text: 'Library' }),
+      NAV_LIBRARY.map(([href, ic, label]) => navLink(href, ic, label)),
+      state.user
+        ? navLink(`/@${state.user.username}`, 'film', 'Your channel')
+        : null,
+      state.user
+        ? navLink('/upload', 'upload', 'Upload')
+        : null,
+    ),
+  ];
+
+  if (state.user && state.subscriptions.length) {
+    groups.push(h('div.navgroup', {},
+      h('div.navgroup__title', { text: 'Subscriptions' }),
+      state.subscriptions.map((channel) => {
+        const href = `/@${channel.username}`;
+        const active = decodeURIComponent(path) === href;
+        return h('a.navlink', { href, class: active ? 'is-active' : '' },
+          avatar(channel, 'sm'),
+          h('span', { text: channel.displayName }),
+          h('span.navlink__count.num', { text: compact(channel.videoCount) }),
+        );
+      }),
+    ));
+  }
+
+  if (!state.user) {
+    groups.push(h('div.navgroup', {},
+      h('div', { style: { padding: '14px 12px', color: 'var(--haze)', fontSize: '13px', lineHeight: '1.6' } },
+        'Sign in to like videos, comment and subscribe.',
+        h('a.btn.btn--ghost.btn--full', { href: '/signin', style: { marginTop: '12px' } }, icon('channel', 18), 'Sign in'),
+      ),
+    ));
+  }
+
+  mount(sidebar, groups);
+}
+
+/* --------------------------------------------------------- account menu -- */
+
+function renderAccount() {
+  const slot = document.getElementById('account-slot');
+  if (!state.user) {
+    mount(slot, h('a.btn.btn--primary.btn--sm', { href: '/signin' }, 'Sign in'));
+    return;
+  }
+
+  const trigger = h('button.iconbtn', { type: 'button', 'aria-label': 'Your account' }, avatar(state.user));
+  mount(slot, dropdown(trigger, (close) => [
+    h('div.menu__head', {},
+      h('div.menu__name', { text: state.user.displayName }),
+      h('div.menu__handle', { text: `@${state.user.username}` }),
+    ),
+    menuItem('Your channel', 'film', () => { close(); navigate(`/@${state.user.username}`); }),
+    menuItem('Upload a video', 'upload', () => { close(); navigate('/upload'); }),
+    menuItem('Watch later', 'clock', () => { close(); navigate('/later'); }),
+    menuItem('Liked videos', 'like', () => { close(); navigate('/liked'); }),
+    h('div.menu__sep'),
+    menuItem('Sign out', 'signout', async () => {
+      close();
+      try {
+        await api.logout();
+        setUser(null);
+        toast('Signed out');
+        navigate('/');
+      } catch (err) {
+        toast(err.message, 'error');
+      }
+    }),
+  ]));
+}
+
+/* ---------------------------------------------------------------- search -- */
+
+const searchForm = document.getElementById('search-form');
+const searchInput = document.getElementById('search-input');
+const searchClear = document.getElementById('search-clear');
+
+searchForm.addEventListener('submit', (event) => {
+  event.preventDefault();
+  const q = searchInput.value.trim();
+  navigate(q ? `/search?q=${encodeURIComponent(q)}` : '/');
+  searchInput.blur();
+});
+searchInput.addEventListener('input', () => { searchClear.hidden = !searchInput.value; });
+searchClear.addEventListener('click', () => {
+  searchInput.value = '';
+  searchClear.hidden = true;
+  searchInput.focus();
+});
+
+document.addEventListener('keydown', (event) => {
+  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName) || event.target.isContentEditable;
+  if (event.key === '/' && !typing) {
+    event.preventDefault();
+    searchInput.focus();
+    searchInput.select();
+  }
+});
+
+/* ----------------------------------------------------------------- render -- */
+
+const view = document.getElementById('view');
+let renderToken = 0;
+
+async function render() {
+  const token = ++renderToken;
+  const path = currentPath();
+  const match = resolve(path);
+
+  closeDrawer();
+  renderSidebar();
+
+  const q = currentQuery().get('q');
+  searchInput.value = path === '/search' && q ? q : '';
+  searchClear.hidden = !searchInput.value;
+
+  teardown(view);
+  mount(view, spinner());
+  document.title = 'MeTube';
+
+  if (!match) {
+    mount(view, emptyState('Page not found',
+      `There is nothing at ${path}. It may have been moved or deleted.`,
+      h('a.btn.btn--primary', { href: '/' }, 'Back to home')));
+    return;
+  }
+
+  let node;
+  try {
+    node = await match.view(match.params);
+  } catch (err) {
+    console.error(err);
+    node = emptyState('Something went wrong', err.message || 'Try reloading the page.',
+      h('button.btn.btn--primary', { type: 'button', onclick: () => render() }, 'Try again'));
+  }
+
+  if (token !== renderToken) return; // a newer navigation won
+  mount(view, node);
+  renderSidebar();
+
+  if (!window.history.state?.keepScroll) window.scrollTo({ top: 0, behavior: 'instant' });
+}
+
+window.addEventListener('metube:rerender', () => render());
+onChange(() => { renderSidebar(); renderAccount(); });
+
+initTheme();
+renderAccount();
+renderSidebar();
+
+// Resolve the session first so the first paint already knows who is signed in.
+await bootstrap().catch((err) => {
+  console.error('Could not load your session', err);
+  state.ready = true;
+});
+renderAccount();
+start(render);
diff --git a/public/js/dom.js b/public/js/dom.js
new file mode 100644
index 0000000..ac55f6f
--- /dev/null
+++ b/public/js/dom.js
@@ -0,0 +1,99 @@
+/** Tiny DOM builder. Everything goes in as text, never as HTML. */
+
+const SVG_NS = 'http://www.w3.org/2000/svg';
+
+/**
+ * h('div.card#id', { class: 'extra', text: 'hi', onclick: fn, data: {…} }, ...children)
+ * Falsy children are skipped, so `cond && node` works inline.
+ */
+export function h(spec, props = null, ...children) {
+  const [tag, ...rest] = String(spec).split(/(?=[.#])/);
+  const el = document.createElement(tag || 'div');
+
+  for (const token of rest) {
+    if (token[0] === '.') el.classList.add(token.slice(1));
+    else el.id = token.slice(1);
+  }
+
+  if (props && (typeof props !== 'object' || props.nodeType || Array.isArray(props))) {
+    children.unshift(props);
+    props = null;
+  }
+
+  for (const [key, value] of Object.entries(props ?? {})) {
+    if (value == null || value === false) continue;
+    if (key === 'class') el.classList.add(...String(value).split(/\s+/).filter(Boolean));
+    else if (key === 'text') el.textContent = value;
+    else if (key === 'style' && typeof value === 'object') Object.assign(el.style, value);
+    else if (key === 'data') for (const [k, v] of Object.entries(value)) el.dataset[k] = v;
+    else if (key.startsWith('on') && typeof value === 'function') el.addEventListener(key.slice(2), value);
+    else if (key === 'value' || key === 'checked' || key === 'disabled') el[key] = value;
+    else el.setAttribute(key, value === true ? '' : value);
+  }
+
+  append(el, children);
+  return el;
+}
+
+export function append(parent, children) {
+  for (const child of children.flat(Infinity)) {
+    if (child == null || child === false || child === '') continue;
+    parent.append(child.nodeType ? child : document.createTextNode(String(child)));
+  }
+  return parent;
+}
+
+/** Inline icon from a path spec; keeps markup out of the rest of the app. */
+export function icon(name, size = 22) {
+  const svg = document.createElementNS(SVG_NS, 'svg');
+  svg.setAttribute('viewBox', '0 0 24 24');
+  svg.setAttribute('aria-hidden', 'true');
+  svg.setAttribute('width', size);
+  svg.setAttribute('height', size);
+  for (const d of ICONS[name] ?? ICONS.play) {
+    const node = document.createElementNS(SVG_NS, d.startsWith('circle:') ? 'circle' : 'path');
+    if (d.startsWith('circle:')) {
+      const [cx, cy, r] = d.slice(7).split(',');
+      node.setAttribute('cx', cx); node.setAttribute('cy', cy); node.setAttribute('r', r);
+    } else {
+      node.setAttribute('d', d);
+    }
+    svg.append(node);
+  }
+  return svg;
+}
+
+const ICONS = {
+  home: ['M4 10.6 12 4l8 6.6V20a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z'],
+  subs: ['M4 7h16M6 11h12M4.5 15h15a1 1 0 0 1 .8 1.6l-2 2.6a1.5 1.5 0 0 1-1.2.6H6.9a1.5 1.5 0 0 1-1.2-.6l-2-2.6A1 1 0 0 1 4.5 15z'],
+  fire: ['M12 3s5 4 5 8.5a5 5 0 0 1-10 0C7 9 9 7.5 9 7.5s.5 2 1.5 2S12 6 12 3z'],
+  history: ['M3.2 12a8.8 8.8 0 1 0 2.6-6.2', 'M3 4v4.5h4.5', 'M12 8v4.4l3 1.8'],
+  like: ['M7 21V10l5-7 1 .6a2 2 0 0 1 .9 2.2L13 10h5.4a2 2 0 0 1 2 2.5l-1.7 7A2 2 0 0 1 16.7 21z', 'M7 10H4v11h3z'],
+  dislike: ['M17 3v11l-5 7-1-.6a2 2 0 0 1-.9-2.2L11 14H5.6a2 2 0 0 1-2-2.5l1.7-7A2 2 0 0 1 7.3 3z', 'M17 14h3V3h-3z'],
+  clock: ['circle:12,12,8.6', 'M12 7v5.2l3.2 1.9'],
+  bookmark: ['M6.5 4h11a1 1 0 0 1 1 1v15.2a.6.6 0 0 1-.94.5L12 16.8l-5.56 3.9a.6.6 0 0 1-.94-.5V5a1 1 0 0 1 1-1z'],
+  channel: ['circle:12,8,4', 'M4.5 20a7.5 7.5 0 0 1 15 0'],
+  upload: ['M12 16V4', 'M7.5 8.5 12 4l4.5 4.5', 'M4 16v2.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V16'],
+  comment: ['M20 4H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h3v4l5-4h8a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z'],
+  reply: ['M9 8 4 12.5 9 17', 'M4 12.5h9a7 7 0 0 1 7 7V21'],
+  share: ['circle:18,5.5,2.6', 'circle:6,12,2.6', 'circle:18,18.5,2.6', 'M8.3 10.7 15.7 6.8', 'M8.3 13.3l7.4 3.9'],
+  trash: ['M4 7h16', 'M9.5 7V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v2', 'M6.5 7l.9 12a1 1 0 0 0 1 .9h7.2a1 1 0 0 0 1-.9L17.5 7'],
+  edit: ['M4 20h4L20 8l-4-4L4 16z', 'M14.5 5.5 18.5 9.5'],
+  play: ['M8 5.5v13l11-6.5z'],
+  film: ['M3 5h18v14H3z', 'M8 5v14M16 5v14', 'M3 12h18'],
+  search: ['circle:11,11,7', 'm20 20-3.5-3.5'],
+  signout: ['M15 7V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-2', 'M10 12h10', 'm17 9 3 3-3 3'],
+  check: ['m5 12.5 4.5 4.5L19 7'],
+  x: ['M6 6 18 18M18 6 6 18'],
+  bell: ['M18 15V10a6 6 0 1 0-12 0v5l-2 3h16z', 'M10 21h4'],
+  eye: ['M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12z', 'circle:12,12,3.2'],
+  dots: ['circle:12,5,1.6', 'circle:12,12,1.6', 'circle:12,19,1.6'],
+};
+
+export const clear = (node) => { while (node.firstChild) node.firstChild.remove(); return node; };
+
+export function mount(node, ...children) {
+  clear(node);
+  append(node, children);
+  return node;
+}
diff --git a/public/js/format.js b/public/js/format.js
new file mode 100644
index 0000000..43a4238
--- /dev/null
+++ b/public/js/format.js
@@ -0,0 +1,72 @@
+/** Display formatting. Numbers are shown compactly; time as broadcast timecode. */
+
+export function compact(n) {
+  const value = Number(n) || 0;
+  if (value < 1000) return String(value);
+  if (value < 1_000_000) {
+    const k = value / 1000;
+    return `${k < 10 ? k.toFixed(1).replace(/\.0$/, '') : Math.round(k)}K`;
+  }
+  const m = value / 1_000_000;
+  return `${m < 10 ? m.toFixed(1).replace(/\.0$/, '') : Math.round(m)}M`;
+}
+
+export const full = (n) => (Number(n) || 0).toLocaleString();
+
+/** 74 -> "1:14", 3675 -> "1:01:15" */
+export function timecode(seconds) {
+  const total = Math.max(0, Math.round(Number(seconds) || 0));
+  const s = total % 60;
+  const m = Math.floor(total / 60) % 60;
+  const hrs = Math.floor(total / 3600);
+  const pad = (x) => String(x).padStart(2, '0');
+  return hrs ? `${hrs}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
+}
+
+const UNITS = [
+  ['year', 31536000],
+  ['month', 2592000],
+  ['week', 604800],
+  ['day', 86400],
+  ['hour', 3600],
+  ['minute', 60],
+];
+
+/** SQLite stores UTC without a zone marker; normalise before parsing. */
+export function parseDate(value) {
+  if (value instanceof Date) return value;
+  const text = String(value ?? '').trim();
+  const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)
+    ? `${text.replace(' ', 'T')}Z`
+    : text;
+  const date = new Date(iso);
+  return Number.isNaN(date.getTime()) ? new Date() : date;
+}
+
+export function timeAgo(value) {
+  const seconds = Math.max(0, (Date.now() - parseDate(value).getTime()) / 1000);
+  if (seconds < 45) return 'just now';
+  for (const [unit, size] of UNITS) {
+    if (seconds >= size) {
+      const n = Math.floor(seconds / size);
+      return `${n} ${unit}${n === 1 ? '' : 's'} ago`;
+    }
+  }
+  return 'just now';
+}
+
+export const longDate = (value) =>
+  parseDate(value).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
+
+export function fileSize(bytes) {
+  const n = Number(bytes) || 0;
+  if (n < 1024) return `${n} B`;
+  if (n < 1024 ** 2) return `${(n / 1024).toFixed(0)} KB`;
+  if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
+  return `${(n / 1024 ** 3).toFixed(2)} GB`;
+}
+
+export const initials = (name = '') =>
+  name.trim().split(/\s+/).slice(0, 2).map((w) => w[0] ?? '').join('') || '?';
+
+export const plural = (n, word, suffix = 's') => `${compact(n)} ${word}${n === 1 ? '' : suffix}`;
diff --git a/public/js/router.js b/public/js/router.js
new file mode 100644
index 0000000..9d05c51
--- /dev/null
+++ b/public/js/router.js
@@ -0,0 +1,55 @@
+/** History-API router. Routes are patterns like '/watch/:id'. */
+
+const routes = [];
+let onRender = () => {};
+
+export function define(pattern, view) {
+  const names = [];
+  // `:name` is picked up anywhere in the pattern, so '/@:username' works too.
+  const source = pattern
+    .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+    .replace(/:(\w+)/g, (_, name) => { names.push(name); return '([^/]+)'; });
+  routes.push({ regex: new RegExp(`^${source}/?$`), names, view });
+}
+
+export function resolve(pathname) {
+  for (const route of routes) {
+    const m = route.regex.exec(pathname);
+    if (!m) continue;
+    const params = {};
+    route.names.forEach((name, i) => { params[name] = decodeURIComponent(m[i + 1]); });
+    return { view: route.view, params };
+  }
+  return null;
+}
+
+export const currentPath = () => window.location.pathname.replace(/\/+$/, '') || '/';
+export const currentQuery = () => new URLSearchParams(window.location.search);
+
+export function navigate(href, { replace = false, keepScroll = false } = {}) {
+  const url = new URL(href, window.location.origin);
+  const same = url.pathname === window.location.pathname && url.search === window.location.search;
+  if (same) return;
+  window.history[replace ? 'replaceState' : 'pushState']({ keepScroll }, '', url);
+  onRender();
+}
+
+export function start(render) {
+  onRender = render;
+
+  window.addEventListener('popstate', render);
+
+  // Intercept same-origin links so navigation stays in the SPA.
+  document.addEventListener('click', (event) => {
+    if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+    const link = event.target.closest?.('a[href]');
+    if (!link || link.target === '_blank' || link.hasAttribute('download')) return;
+    const url = new URL(link.href, window.location.origin);
+    if (url.origin !== window.location.origin) return;
+    if (link.getAttribute('href')?.startsWith('#')) return;
+    event.preventDefault();
+    navigate(url.pathname + url.search);
+  });
+
+  render();
+}
diff --git a/public/js/store.js b/public/js/store.js
new file mode 100644
index 0000000..52e39b4
--- /dev/null
+++ b/public/js/store.js
@@ -0,0 +1,50 @@
+/** Session state shared across views, with a tiny subscribe/notify. */
+import { api } from './api.js';
+
+const listeners = new Set();
+
+export const state = {
+  user: null,
+  subscriptions: [],
+  categories: ['All'],
+  ready: false,
+};
+
+export function onChange(fn) {
+  listeners.add(fn);
+  return () => listeners.delete(fn);
+}
+
+export function notify() {
+  for (const fn of listeners) fn(state);
+}
+
+export function setUser(user) {
+  state.user = user;
+  if (!user) state.subscriptions = [];
+  notify();
+}
+
+export async function refreshSubscriptions() {
+  if (!state.user) {
+    state.subscriptions = [];
+    notify();
+    return;
+  }
+  try {
+    const { channels } = await api.subscriptions();
+    state.subscriptions = channels;
+  } catch {
+    state.subscriptions = [];
+  }
+  notify();
+}
+
+export async function bootstrap() {
+  const [me, cats] = await Promise.allSettled([api.me(), api.categories()]);
+  state.user = me.status === 'fulfilled' ? me.value.user : null;
+  state.categories = cats.status === 'fulfilled' ? cats.value.categories : ['All'];
+  state.ready = true;
+  if (state.user) await refreshSubscriptions();
+  else notify();
+}
diff --git a/public/js/ui.js b/public/js/ui.js
new file mode 100644
index 0000000..cfa0f5a
--- /dev/null
+++ b/public/js/ui.js
@@ -0,0 +1,296 @@
+/** Shared components: cards, avatars, buttons, toasts, modals, menus. */
+import { h, icon, mount, clear } from './dom.js';
+import { compact, timecode, timeAgo, initials, plural } from './format.js';
+import { api } from './api.js';
+import { state, refreshSubscriptions } from './store.js';
+import { navigate } from './router.js';
+
+const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
+
+/* ---------------------------------------------------------------- toasts -- */
+
+export function toast(message, kind = '') {
+  const root = document.getElementById('toasts');
+  const node = h(`div.toast${kind ? `.toast--${kind}` : ''}`, {}, message);
+  root.append(node);
+  setTimeout(() => {
+    node.classList.add('is-out');
+    node.addEventListener('animationend', () => node.remove(), { once: true });
+    setTimeout(() => node.remove(), 400);
+  }, 3200);
+}
+
+/* ---------------------------------------------------------------- modals -- */
+
+export function confirmDialog({ title, text, confirmLabel = 'Confirm', danger = false }) {
+  return new Promise((resolve) => {
+    const root = document.getElementById('modal-root');
+    const close = (answer) => {
+      clear(root);
+      document.removeEventListener('keydown', onKey);
+      resolve(answer);
+    };
+    const onKey = (event) => { if (event.key === 'Escape') close(false); };
+    document.addEventListener('keydown', onKey);
+
+    const confirmBtn = h('button.btn', {
+      class: danger ? 'btn--danger' : 'btn--primary',
+      type: 'button',
+      onclick: () => close(true),
+    }, confirmLabel);
+
+    mount(root, h('div.modal', {
+      role: 'dialog',
+      'aria-modal': 'true',
+      onclick: (event) => { if (event.target.classList.contains('modal')) close(false); },
+    },
+      h('div.modal__card', {},
+        h('h2.modal__title', { text: title }),
+        h('p.modal__text', { text }),
+        h('div.modal__actions', {},
+          h('button.btn.btn--ghost', { type: 'button', onclick: () => close(false) }, 'Cancel'),
+          confirmBtn,
+        ),
+      ),
+    ));
+    confirmBtn.focus();
+  });
+}
+
+/* --------------------------------------------------------------- avatars -- */
+
+export function avatar(person, size = '') {
+  const node = h(`div.avatar${size ? `.avatar--${size}` : ''}`, {
+    'aria-hidden': 'true',
+    text: initials(person?.displayName || person?.username || '?'),
+  });
+  node.style.setProperty('--hue', String(person?.avatarHue ?? 210));
+  return node;
+}
+
+export const channelAvatarLink = (channel, size = '') =>
+  h('a', { href: `/@${channel.username}`, 'aria-label': channel.displayName }, avatar(channel, size));
+
+/* ------------------------------------------------------------ empty state -- */
+
+export const emptyState = (title, text, action = null) =>
+  h('div.empty', {}, icon('film', 34), h('div.empty__title', { text: title }), h('p.empty__text', { text }), action);
+
+export const spinner = () => h('div.spinner', { role: 'status', 'aria-label': 'Loading' });
+
+/* ------------------------------------------------------ subscribe button -- */
+
+export function subscribeButton(channel, { onUpdate } = {}) {
+  const btn = h('button.btn.btn--subscribe', { type: 'button' });
+  let subscribed = !!channel.isSubscribed;
+
+  const paint = () => {
+    btn.classList.toggle('is-on', subscribed);
+    mount(btn, subscribed ? icon('check', 18) : icon('bell', 18), subscribed ? 'Subscribed' : 'Subscribe');
+    btn.setAttribute('aria-pressed', String(subscribed));
+  };
+
+  btn.addEventListener('click', async () => {
+    if (!state.user) return requireSignIn('Sign in to subscribe to channels.');
+    btn.disabled = true;
+    try {
+      const result = await api.subscribe(channel.username);
+      subscribed = result.isSubscribed;
+      channel.isSubscribed = subscribed;
+      channel.subscriberCount = result.subscriberCount;
+      paint();
+      onUpdate?.(result);
+      await refreshSubscriptions();
+      toast(subscribed ? `Subscribed to ${channel.displayName}` : `Unsubscribed from ${channel.displayName}`, subscribed ? 'good' : '');
+    } catch (err) {
+      toast(err.message, 'error');
+    } finally {
+      btn.disabled = false;
+    }
+  });
+
+  paint();
+  return channel.isSelf ? h('a.btn.btn--ghost', { href: `/@${channel.username}` }, icon('channel', 18), 'Your channel') : btn;
+}
+
+export function requireSignIn(message = 'Sign in to continue.') {
+  toast(message);
+  navigate(`/signin?next=${encodeURIComponent(location.pathname + location.search)}`);
+}
+
+/* ----------------------------------------------------------- video cards -- */
+
+/**
+ * The signature interaction: hovering a card cross-fades the still into the
+ * real video, muted, while an amber hairline sweeps the frame in time with it.
+ */
+function attachPreview(card, frame, video, sweep, timeBadge, duration) {
+  if (reducedMotion.matches) return;
+  let timer = null;
+  let raf = null;
+
+  const tick = () => {
+    if (video.duration) {
+      const pct = Math.min(1, video.currentTime / video.duration);
+      sweep.style.width = `${pct * 100}%`;
+      timeBadge.textContent = timecode(Math.max(0, video.duration - video.currentTime));
+    }
+    raf = requestAnimationFrame(tick);
+  };
+
+  const stop = () => {
+    clearTimeout(timer);
+    cancelAnimationFrame(raf);
+    raf = null;
+    card.classList.remove('is-previewing');
+    sweep.style.width = '0';
+    timeBadge.textContent = timecode(duration);
+    video.pause();
+    if (video.src) { video.removeAttribute('src'); video.load(); }
+  };
+
+  const start = () => {
+    timer = setTimeout(() => {
+      if (!video.src) video.src = video.dataset.src;
+      video.currentTime = 0;
+      video.play().then(() => {
+        card.classList.add('is-previewing');
+        if (raf == null) tick();
+      }).catch(() => {});
+    }, 420);
+  };
+
+  frame.addEventListener('pointerenter', (event) => { if (event.pointerType !== 'touch') start(); });
+  frame.addEventListener('pointerleave', stop);
+  frame.addEventListener('focusin', start);
+  frame.addEventListener('focusout', stop);
+  card.addEventListener('metube:teardown', stop);
+}
+
+export function videoCard(video, { compactRow = false, onRemove = null } = {}) {
+  const href = `/watch/${video.id}`;
+  const sweep = h('div.card__sweep');
+  const timeBadge = h('span.card__time.num', { text: timecode(video.duration) });
+
+  const still = video.thumb
+    ? h('img', { src: video.thumb, alt: '', loading: 'lazy', decoding: 'async' })
+    : h('div.card__placeholder', {}, icon('film', 30));
+
+  const preview = h('video', {
+    muted: true,
+    loop: true,
+    playsinline: true,
+    preload: 'none',
+    tabindex: '-1',
+    'aria-hidden': 'true',
+    data: { src: video.src },
+  });
+  preview.muted = true;
+
+  const frame = h('a.card__frame', {
+    href,
+    'aria-label': `Watch ${video.title}`,
+  }, still, preview, sweep, timeBadge);
+
+  const card = h(`article.card${compactRow ? '.rowcard' : ''}`, {}, frame);
+
+  if (state.user) {
+    const saveBtn = h('button.card__save', {
+      type: 'button',
+      title: video.saved ? 'Remove from Watch later' : 'Save to Watch later',
+      'aria-label': video.saved ? 'Remove from Watch later' : 'Save to Watch later',
+      onclick: async (event) => {
+        event.preventDefault();
+        event.stopPropagation();
+        try {
+          const { saved } = await api.toggleSave(video.id);
+          video.saved = saved;
+          saveBtn.classList.toggle('is-on', saved);
+          saveBtn.title = saved ? 'Remove from Watch later' : 'Save to Watch later';
+          toast(saved ? 'Saved to Watch later' : 'Removed from Watch later', saved ? 'good' : '');
+          if (!saved) onRemove?.(video);
+        } catch (err) {
+          toast(err.message, 'error');
+        }
+      },
+    }, icon('bookmark', 17));
+    saveBtn.classList.toggle('is-on', !!video.saved);
+    frame.append(saveBtn);
+  }
+
+  const meta = h('div', {},
+    h('a.card__channel', { href: `/@${video.channel.username}`, text: video.channel.displayName }),
+    h('div.card__meta', {},
+      h('span.num', { text: compact(video.views) }),
+      video.views === 1 ? ' view · ' : ' views · ',
+      timeAgo(video.createdAt),
+    ),
+  );
+
+  const body = compactRow
+    ? h('div.rowcard__body', {},
+        h('h3.card__title', {}, h('a', { href, text: video.title })),
+        meta,
+      )
+    : h('div.card__body', {},
+        channelAvatarLink(video.channel),
+        h('div', { style: { minWidth: 0 } },
+          h('h3.card__title', {}, h('a', { href, text: video.title })),
+          meta,
+        ),
+      );
+
+  card.append(body);
+  attachPreview(card, frame, preview, sweep, timeBadge, video.duration);
+  return card;
+}
+
+export const videoGrid = (videos, options = {}) =>
+  h(`div.grid${options.tight ? '.grid--tight' : ''}`, {}, videos.map((v) => videoCard(v, options)));
+
+/** Stop every in-flight preview before a view is swapped out. */
+export function teardown(container) {
+  for (const card of container.querySelectorAll('.card')) {
+    card.dispatchEvent(new CustomEvent('metube:teardown'));
+  }
+}
+
+/* ----------------------------------------------------------------- menus -- */
+
+export function dropdown(trigger, buildItems) {
+  const wrap = h('div.menu', {}, trigger);
+  let panel = null;
+
+  const close = () => {
+    panel?.remove();
+    panel = null;
+    document.removeEventListener('click', onDocClick, true);
+    document.removeEventListener('keydown', onKey);
+    trigger.setAttribute('aria-expanded', 'false');
+  };
+  const onDocClick = (event) => { if (!wrap.contains(event.target)) close(); };
+  const onKey = (event) => { if (event.key === 'Escape') { close(); trigger.focus(); } };
+
+  trigger.setAttribute('aria-haspopup', 'menu');
+  trigger.setAttribute('aria-expanded', 'false');
+  trigger.addEventListener('click', (event) => {
+    event.preventDefault();
+    event.stopPropagation();
+    if (panel) return close();
+    panel = h('div.menu__panel', { role: 'menu' }, buildItems(close));
+    wrap.append(panel);
+    trigger.setAttribute('aria-expanded', 'true');
+    document.addEventListener('click', onDocClick, true);
+    document.addEventListener('keydown', onKey);
+  });
+
+  return wrap;
+}
+
+export const menuItem = (label, iconName, onClick, { danger = false } = {}) =>
+  h(`button.menu__item${danger ? '.menu__item--danger' : ''}`, { type: 'button', role: 'menuitem', onclick: onClick },
+    icon(iconName, 17), label);
+
+export const statLine = (...parts) => h('div.channelhero__stats', {}, parts.filter(Boolean));
+
+export { plural };
diff --git a/public/js/views/auth.js b/public/js/views/auth.js
new file mode 100644
index 0000000..1aeb5f7
--- /dev/null
+++ b/public/js/views/auth.js
@@ -0,0 +1,108 @@
+/** Sign in and create account, on one switchable card. */
+import { h } from '../dom.js';
+import { api } from '../api.js';
+import { setUser, refreshSubscriptions } from '../store.js';
+import { navigate, currentQuery } from '../router.js';
+import { toast } from '../ui.js';
+
+export function authView(mode) {
+  return async () => {
+    const next = currentQuery().get('next') || '/';
+    const isSignup = mode === 'signup';
+    document.title = isSignup ? 'Create account · MeTube' : 'Sign in · MeTube';
+
+    const error = h('div.formerror', { hidden: true, role: 'alert' });
+    const submit = h('button.btn.btn--primary.btn--full', { type: 'submit' },
+      isSignup ? 'Create account' : 'Sign in');
+
+    const fields = isSignup
+      ? {
+          username: h('input.input', { name: 'username', autocomplete: 'username', required: true,
+            placeholder: 'yourhandle', maxlength: '24', pattern: '[a-zA-Z0-9_.]{3,24}' }),
+          displayName: h('input.input', { name: 'displayName', autocomplete: 'name',
+            placeholder: 'How your name appears', maxlength: '40' }),
+          email: h('input.input', { name: 'email', type: 'email', autocomplete: 'email', required: true,
+            placeholder: '[email protected]' }),
+          password: h('input.input', { name: 'password', type: 'password', autocomplete: 'new-password',
+            required: true, minlength: '8', placeholder: 'At least 8 characters' }),
+        }
+      : {
+          identifier: h('input.input', { name: 'identifier', autocomplete: 'username', required: true,
+            placeholder: 'Username or email' }),
+          password: h('input.input', { name: 'password', type: 'password', autocomplete: 'current-password',
+            required: true, placeholder: 'Your password' }),
+        };
+
+    const form = h('form.panel', {
+      novalidate: true,
+      onsubmit: async (event) => {
+        event.preventDefault();
+        error.hidden = true;
+        submit.disabled = true;
+        submit.textContent = isSignup ? 'Creating account…' : 'Signing in…';
+        try {
+          const payload = Object.fromEntries(
+            Object.entries(fields).map(([key, input]) => [key, input.value.trim()]),
+          );
+          const { user } = isSignup ? await api.signup(payload) : await api.login(payload);
+          setUser(user);
+          await refreshSubscriptions();
+          toast(isSignup ? `Welcome to MeTube, ${user.displayName}` : `Signed in as ${user.displayName}`, 'good');
+          navigate(next.startsWith('/') ? next : '/', { replace: true });
+        } catch (err) {
+          error.textContent = err.message;
+          error.hidden = false;
+          submit.disabled = false;
+          submit.textContent = isSignup ? 'Create account' : 'Sign in';
+        }
+      },
+    },
+      error,
+      ...(isSignup
+        ? [
+            field('Username', fields.username, 'Letters, numbers, dot and underscore. This becomes your @handle.'),
+            field('Display name', fields.displayName, 'Optional — defaults to your username.'),
+            field('Email', fields.email),
+            field('Password', fields.password, 'At least 8 characters.'),
+          ]
+        : [
+            field('Username or email', fields.identifier),
+            field('Password', fields.password),
+          ]),
+      submit,
+    );
+
+    return h('div.auth', {},
+      h('div.auth__card', {},
+        h('div.auth__head', {},
+          h('h1.auth__title', { text: isSignup ? 'Create your channel' : 'Welcome back' }),
+          h('div.auth__sub', {
+            text: isSignup
+              ? 'One account to upload, comment and subscribe.'
+              : 'Sign in to pick up where you left off.',
+          }),
+        ),
+        form,
+        h('div.auth__switch', {},
+          isSignup ? 'Already have an account? ' : 'New to MeTube? ',
+          h('button', {
+            type: 'button',
+            onclick: () => navigate(`/${isSignup ? 'signin' : 'signup'}?next=${encodeURIComponent(next)}`),
+          }, isSignup ? 'Sign in' : 'Create an account'),
+        ),
+        isSignup ? null : h('div.auth__demo', {},
+          h('b', {}, 'Trying the demo?'), ' Sign in as ',
+          h('code', {}, 'pixelforge'), ', ', h('code', {}, 'quietkitchen'), ' or ', h('code', {}, 'marta'),
+          ' with the password ', h('code', {}, 'password123'), '.',
+        ),
+      ),
+    );
+  };
+}
+
+const field = (label, input, hint) =>
+  h('div.field', {},
+    h('label.field__label', { text: label }),
+    input,
+    hint ? h('div.field__hint', { text: hint }) : null,
+  );
diff --git a/public/js/views/browse.js b/public/js/views/browse.js
new file mode 100644
index 0000000..00184b7
--- /dev/null
+++ b/public/js/views/browse.js
@@ -0,0 +1,307 @@
+/** Home, search results, subscriptions feed, trending and the library lists. */
+import { h, icon, mount } from '../dom.js';
+import { api } from '../api.js';
+import { state } from '../store.js';
+import { navigate, currentQuery } from '../router.js';
+import { videoGrid, videoCard, emptyState, spinner, toast, confirmDialog, avatar } from '../ui.js';
+import { compact, plural } from '../format.js';
+
+const PAGE = 24;
+
+function categoryChips(active, onPick) {
+  return h('div.chips', { role: 'tablist', 'aria-label': 'Categories' },
+    state.categories.map((name) => h('button.chip', {
+      type: 'button',
+      role: 'tab',
+      class: name === active ? 'is-on' : '',
+      'aria-selected': String(name === active),
+      onclick: () => onPick(name),
+    }, name)),
+  );
+}
+
+/** Grid plus a "Load more" button that appends without re-rendering the page. */
+function paginatedGrid(container, fetchPage, { emptyTitle, emptyText, emptyAction }) {
+  let offset = 0;
+  let total = 0;
+
+  const grid = h('div.grid');
+  const moreWrap = h('div', { style: { display: 'flex', justifyContent: 'center', marginTop: '34px' } });
+  const loadMore = h('button.btn.btn--ghost', { type: 'button' }, 'Load more videos');
+
+  const load = async (first = false) => {
+    loadMore.disabled = true;
+    loadMore.textContent = 'Loading…';
+    try {
+      const data = await fetchPage(offset, PAGE);
+      total = data.total ?? data.videos.length;
+      offset += data.videos.length;
+
+      if (first && !data.videos.length) {
+        mount(container, emptyState(emptyTitle, emptyText, emptyAction));
+        return;
+      }
+      grid.append(...data.videos.map((v) => videoCard(v)));
+      mount(moreWrap, offset < total ? loadMore : null);
+    } catch (err) {
+      if (first) mount(container, emptyState('That did not load', err.message));
+      else toast(err.message, 'error');
+    } finally {
+      loadMore.disabled = false;
+      loadMore.textContent = 'Load more videos';
+    }
+  };
+
+  loadMore.addEventListener('click', () => load(false));
+  container.append(grid, moreWrap);
+  load(true);
+  return container;
+}
+
+export async function homeView() {
+  const category = currentQuery().get('category') || 'All';
+  document.title = category === 'All' ? 'MeTube' : `${category} · MeTube`;
+  const root = h('div');
+  const body = h('div');
+
+  root.append(
+    categoryChips(category, (name) => navigate(name === 'All' ? '/' : `/?category=${encodeURIComponent(name)}`)),
+    body,
+  );
+
+  paginatedGrid(body, (offset, limit) => api.videos({ category, offset, limit, sort: 'new' }), {
+    emptyTitle: category === 'All' ? 'No videos yet' : `Nothing in ${category} yet`,
+    emptyText: category === 'All'
+      ? 'Upload the first one and it will appear here straight away.'
+      : 'Try another category, or upload something that fits.',
+    emptyAction: h('a.btn.btn--primary', { href: '/upload' }, icon('upload', 18), 'Upload a video'),
+  });
+
+  return root;
+}
+
+export async function trendingView() {
+  document.title = 'Trending · MeTube';
+  const root = h('div', {},
+    h('div.pagehead', {},
+      h('div', {},
+        h('span.eyebrow', { text: 'Most watched' }),
+        h('h1.pagehead__title', { text: 'Trending' }),
+      ),
+    ),
+  );
+  const body = h('div');
+  root.append(body);
+  paginatedGrid(body, (offset, limit) => api.videos({ sort: 'popular', offset, limit }), {
+    emptyTitle: 'Nothing trending yet',
+    emptyText: 'Once videos start collecting views they will show up here.',
+  });
+  return root;
+}
+
+export async function searchView() {
+  const q = currentQuery().get('q') || '';
+  const sort = currentQuery().get('sort') || 'popular';
+  document.title = q ? `${q} · MeTube` : 'Search · MeTube';
+  const root = h('div');
+
+  const sorter = h('select.select', {
+    'aria-label': 'Sort results',
+    style: { width: 'auto' },
+    onchange: (event) => navigate(`/search?q=${encodeURIComponent(q)}&sort=${event.target.value}`),
+  },
+    [['popular', 'Most viewed'], ['new', 'Newest first'], ['liked', 'Most liked'], ['oldest', 'Oldest first']]
+      .map(([value, label]) => h('option', { value, selected: value === sort }, label)),
+  );
+
+  const head = h('div.pagehead', {},
+    h('div', {},
+      h('span.eyebrow', { text: 'Search' }),
+      h('h1.pagehead__title', { text: q ? `“${q}”` : 'Search' }),
+      h('div.pagehead__sub', { id: 'result-count' }),
+    ),
+    h('div.pagehead__actions', {}, sorter),
+  );
+  root.append(head);
+
+  if (!q) {
+    root.append(emptyState('Search MeTube', 'Type a title, a topic or a channel name in the box above.'));
+    return root;
+  }
+
+  const body = h('div');
+  root.append(body, spinner());
+
+  const [{ videos, total }, { channels }] = await Promise.all([
+    api.videos({ q, sort, limit: 40 }),
+    api.channels().catch(() => ({ channels: [] })),
+  ]);
+  root.lastChild.remove();
+
+  const needle = q.toLowerCase();
+  const matchedChannels = channels.filter(
+    (c) => c.displayName.toLowerCase().includes(needle) || c.username.toLowerCase().includes(needle),
+  ).slice(0, 3);
+
+  head.querySelector('#result-count').textContent =
+    `${total} ${total === 1 ? 'video' : 'videos'}${matchedChannels.length ? ` · ${matchedChannels.length} ${matchedChannels.length === 1 ? 'channel' : 'channels'}` : ''}`;
+
+  if (matchedChannels.length) {
+    body.append(h('div', { style: { marginBottom: '28px' } },
+      matchedChannels.map((c) => h('a.channelhero', {
+        href: `/@${c.username}`,
+        style: { marginBottom: '10px', padding: '16px 18px' },
+      },
+        avatar(c, 'lg'),
+        h('div.channelhero__info', {},
+          h('div', { style: { fontWeight: 700, fontSize: '17px' }, text: c.displayName }),
+          h('div.channelhero__handle', { text: `@${c.username}` }),
+          h('div.channelhero__stats', {},
+            h('span', {}, h('span.num', { text: compact(c.subscriberCount) }), ` ${c.subscriberCount === 1 ? 'subscriber' : 'subscribers'}`),
+            h('span', {}, h('span.num', { text: compact(c.videoCount) }), ` ${c.videoCount === 1 ? 'video' : 'videos'}`),
+          ),
+        ),
+      )),
+    ));
+  }
+
+  body.append(videos.length
+    ? videoGrid(videos)
+    : emptyState('No videos matched', `Nothing came back for “${q}”. Try fewer words or a different spelling.`));
+  return root;
+}
+
+export async function subscriptionsView() {
+  document.title = 'Subscriptions · MeTube';
+  if (!state.user) return signedOutPrompt('Subscriptions', 'Sign in to see the latest from channels you follow.');
+
+  const root = h('div', {},
+    h('div.pagehead', {},
+      h('div', {},
+        h('span.eyebrow', { text: `${state.subscriptions.length} ${state.subscriptions.length === 1 ? 'channel' : 'channels'}` }),
+        h('h1.pagehead__title', { text: 'Subscriptions' }),
+      ),
+    ),
+  );
+  const body = h('div');
+  root.append(body);
+  paginatedGrid(body, (offset, limit) => api.videos({ feed: 'subscriptions', offset, limit, sort: 'new' }), {
+    emptyTitle: 'No subscriptions yet',
+    emptyText: 'Subscribe to a channel and its newest videos will collect here.',
+    emptyAction: h('a.btn.btn--primary', { href: '/channels' }, 'Browse channels'),
+  });
+  return root;
+}
+
+export async function channelsView() {
+  document.title = 'Channels · MeTube';
+  const root = h('div', {},
+    h('div.pagehead', {},
+      h('div', {},
+        h('span.eyebrow', { text: 'Everyone publishing' }),
+        h('h1.pagehead__title', { text: 'Channels' }),
+      ),
+    ),
+    spinner(),
+  );
+
+  const { channels } = await api.channels();
+  root.lastChild.remove();
+
+  if (!channels.length) {
+    root.append(emptyState('No channels yet', 'Once someone uploads a video their channel appears here.'));
+    return root;
+  }
+
+  root.append(h('div.grid.grid--tight', {},
+    channels.map((c) => h('a.panel', {
+      href: `/@${c.username}`,
+      style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px', textAlign: 'center' },
+    },
+      avatar(c, 'lg'),
+      h('div', { style: { fontWeight: 700, letterSpacing: '-0.01em' }, text: c.displayName }),
+      h('div', { style: { color: 'var(--haze)', fontSize: '13px' }, text: `@${c.username}` }),
+      h('div', { style: { color: 'var(--haze-dim)', fontSize: '12.5px' } },
+        h('span.num', { text: compact(c.subscriberCount) }),
+        ` ${c.subscriberCount === 1 ? 'subscriber' : 'subscribers'} · `,
+        h('span.num', { text: compact(c.videoCount) }),
+        ` ${c.videoCount === 1 ? 'video' : 'videos'}`,
+      ),
+    )),
+  ));
+  return root;
+}
+
+const LIBRARY = {
+  history: {
+    title: 'History', eyebrow: 'Recently watched', fetch: () => api.history(),
+    empty: ['Nothing watched yet', 'Videos you play show up here so you can find them again.'],
+    clearable: true,
+  },
+  liked: {
+    title: 'Liked videos', eyebrow: 'Your likes', fetch: () => api.liked(),
+    empty: ['No liked videos yet', 'Hit like on a video and it will be listed here.'],
+  },
+  saved: {
+    title: 'Watch later', eyebrow: 'Saved for later', fetch: () => api.saved(),
+    empty: ['Nothing saved yet', 'Use the bookmark on any video thumbnail to save it for later.'],
+  },
+};
+
+export function libraryView(kind) {
+  return async () => {
+    const config = LIBRARY[kind];
+    document.title = `${config.title} · MeTube`;
+    if (!state.user) return signedOutPrompt(config.title, `Sign in to see your ${config.title.toLowerCase()}.`);
+
+    const root = h('div');
+    const head = h('div.pagehead', {},
+      h('div', {},
+        h('span.eyebrow', { text: config.eyebrow }),
+        h('h1.pagehead__title', { text: config.title }),
+      ),
+    );
+    root.append(head, spinner());
+
+    const { videos } = await config.fetch();
+    root.lastChild.remove();
+
+    if (config.clearable && videos.length) {
+      head.append(h('div.pagehead__actions', {},
+        h('button.btn.btn--ghost', {
+          type: 'button',
+          onclick: async () => {
+            const ok = await confirmDialog({
+              title: 'Clear watch history?',
+              text: 'This removes every video from your history. It cannot be undone.',
+              confirmLabel: 'Clear history',
+              danger: true,
+            });
+            if (!ok) return;
+            await api.clearHistory();
+            toast('Watch history cleared', 'good');
+            navigate('/history', { replace: true });
+            window.dispatchEvent(new CustomEvent('metube:rerender'));
+          },
+        }, icon('trash', 18), 'Clear history'),
+      ));
+    }
+
+    head.querySelector('.eyebrow').textContent = videos.length
+      ? `${plural(videos.length, 'video')}`
+      : config.eyebrow;
+
+    root.append(videos.length
+      ? videoGrid(videos, { compactRow: false })
+      : emptyState(config.empty[0], config.empty[1], h('a.btn.btn--primary', { href: '/' }, 'Browse videos')));
+    return root;
+  };
+}
+
+export function signedOutPrompt(title, text) {
+  return h('div', {},
+    h('div.pagehead', {}, h('div', {}, h('h1.pagehead__title', { text }))),
+    emptyState(title, text,
+      h('a.btn.btn--primary', { href: `/signin?next=${encodeURIComponent(location.pathname)}` }, 'Sign in')),
+  );
+}
diff --git a/public/js/views/channel.js b/public/js/views/channel.js
new file mode 100644
index 0000000..8797136
--- /dev/null
+++ b/public/js/views/channel.js
@@ -0,0 +1,153 @@
+/** Channel page: hero, tabs for videos/about, and inline profile editing. */
+import { h, icon, mount } from '../dom.js';
+import { api } from '../api.js';
+import { state, setUser, refreshSubscriptions } from '../store.js';
+import { navigate, currentQuery } from '../router.js';
+import { videoGrid, avatar, subscribeButton, emptyState, spinner, toast } from '../ui.js';
+import { compact, full, longDate } from '../format.js';
+
+export async function channelView({ username }) {
+  const handle = String(username).replace(/^@/, '');
+  const tab = currentQuery().get('tab') || 'videos';
+  const sort = currentQuery().get('sort') || 'new';
+
+  let data;
+  try {
+    data = await api.channel(handle, { sort });
+  } catch (err) {
+    return emptyState('Channel not found', err.message, h('a.btn.btn--primary', { href: '/' }, 'Back to home'));
+  }
+
+  const { channel, videos, totalViews } = data;
+  document.title = `${channel.displayName} · MeTube`;
+
+  const hero = h('header.channelhero', {},
+    avatar(channel, 'xl'),
+    h('div.channelhero__info', {},
+      h('h1.channelhero__name', { text: channel.displayName }),
+      h('div.channelhero__handle', { text: `@${channel.username}` }),
+      h('div.channelhero__stats', {},
+        h('span', {}, h('span.num', { text: compact(channel.subscriberCount) }), ` ${channel.subscriberCount === 1 ? 'subscriber' : 'subscribers'}`),
+        h('span', {}, h('span.num', { text: compact(channel.videoCount) }), ` ${channel.videoCount === 1 ? 'video' : 'videos'}`),
+        h('span', {}, h('span.num', { text: compact(totalViews) }), ' total views'),
+      ),
+      channel.bio ? h('p.channelhero__bio', { text: channel.bio }) : null,
+    ),
+    h('div.channelhero__actions', {},
+      channel.isSelf ? h('a.btn.btn--primary', { href: '/upload' }, icon('upload', 18), 'Upload') : null,
+      channel.isSelf
+        ? h('button.btn.btn--ghost', { type: 'button', onclick: () => openEditor(channel, hero) }, icon('edit', 18), 'Edit profile')
+        : subscribeButton(channel),
+    ),
+  );
+  hero.style.setProperty('--hue', String(channel.avatarHue));
+
+  const link = (key, label) => h('button.tab', {
+    type: 'button',
+    class: key === tab ? 'is-on' : '',
+    'aria-current': key === tab ? 'page' : null,
+    onclick: () => navigate(`/@${channel.username}${key === 'videos' ? '' : `?tab=${key}`}`),
+  }, label);
+
+  const root = h('div', {}, hero, h('div.tabs', {}, link('videos', 'Videos'), link('about', 'About')));
+
+  if (tab === 'about') {
+    root.append(h('div.panel', { style: { maxWidth: '720px' } },
+      h('span.eyebrow', { text: 'About' }),
+      h('p', { style: { whiteSpace: 'pre-wrap', margin: '0 0 18px', lineHeight: '1.65' },
+        text: channel.bio || 'This channel has not written a description yet.' }),
+      h('div.channelhero__stats', { style: { marginTop: 0 } },
+        h('span', {}, 'Joined ', longDate(channel.createdAt)),
+        h('span', {}, h('span.num', { text: full(totalViews) }), ' total views'),
+      ),
+    ));
+    return root;
+  }
+
+  const sorter = h('select.select', {
+    'aria-label': 'Sort videos',
+    style: { width: 'auto', marginBottom: '18px' },
+    onchange: (event) => navigate(`/@${channel.username}?sort=${event.target.value}`),
+  },
+    [['new', 'Newest first'], ['popular', 'Most viewed'], ['liked', 'Most liked'], ['oldest', 'Oldest first']]
+      .map(([value, label]) => h('option', { value, selected: value === sort }, label)),
+  );
+
+  root.append(
+    videos.length ? sorter : null,
+    videos.length
+      ? videoGrid(videos)
+      : emptyState(
+          channel.isSelf ? 'You have not uploaded anything yet' : 'No videos yet',
+          channel.isSelf
+            ? 'Your uploads will appear here as soon as they finish processing.'
+            : `${channel.displayName} has not published a video yet. Subscribe to hear about the first one.`,
+          channel.isSelf ? h('a.btn.btn--primary', { href: '/upload' }, icon('upload', 18), 'Upload a video') : null,
+        ),
+  );
+  return root;
+}
+
+/** Inline profile editor, swapped in place of the hero. */
+function openEditor(channel, hero) {
+  const name = h('input.input', { value: channel.displayName, maxlength: '40', required: true });
+  const bio = h('textarea.textarea', { maxlength: '600', placeholder: 'Tell people what this channel is about.' }, channel.bio);
+  const hue = h('input', {
+    type: 'range', min: '0', max: '359', value: String(channel.avatarHue),
+    style: { width: '100%', accentColor: 'var(--amber)' },
+    'aria-label': 'Avatar colour',
+  });
+
+  const preview = avatar(channel, 'lg');
+  const syncPreview = () => {
+    preview.style.setProperty('--hue', hue.value);
+    preview.textContent = (name.value.trim() || channel.username).split(/\s+/).slice(0, 2).map((w) => w[0] ?? '').join('').toUpperCase() || '?';
+  };
+  hue.addEventListener('input', syncPreview);
+  name.addEventListener('input', syncPreview);
+
+  const error = h('div.formerror', { hidden: true });
+  const save = h('button.btn.btn--primary', { type: 'submit' }, 'Save changes');
+
+  const form = h('form.panel', {
+    style: { marginBottom: '24px' },
+    onsubmit: async (event) => {
+      event.preventDefault();
+      save.disabled = true;
+      error.hidden = true;
+      try {
+        const { user } = await api.updateMe({
+          displayName: name.value.trim(),
+          bio: bio.value.trim(),
+          avatarHue: Number(hue.value),
+        });
+        setUser(user);
+        await refreshSubscriptions();
+        toast('Profile updated', 'good');
+        window.dispatchEvent(new CustomEvent('metube:rerender'));
+      } catch (err) {
+        error.textContent = err.message;
+        error.hidden = false;
+        save.disabled = false;
+      }
+    },
+  },
+    h('span.eyebrow', { text: 'Edit profile' }),
+    error,
+    h('div', { style: { display: 'flex', gap: '22px', alignItems: 'flex-start', flexWrap: 'wrap' } },
+      h('div', { style: { display: 'grid', gap: '12px', justifyItems: 'center' } }, preview, h('div', { style: { width: '120px' } }, hue)),
+      h('div', { style: { flex: '1 1 300px', minWidth: '260px' } },
+        h('div.field', {}, h('label.field__label', { text: 'Display name' }), name),
+        h('div.field', {}, h('label.field__label', { text: 'About' }), bio),
+      ),
+    ),
+    h('div', { style: { display: 'flex', gap: '9px', justifyContent: 'flex-end' } },
+      h('button.btn.btn--ghost', { type: 'button', onclick: () => window.dispatchEvent(new CustomEvent('metube:rerender')) }, 'Cancel'),
+      save,
+    ),
+  );
+
+  syncPreview();
+  hero.replaceWith(form);
+  name.focus();
+}
diff --git a/public/js/views/upload.js b/public/js/views/upload.js
new file mode 100644
index 0000000..e8bb125
--- /dev/null
+++ b/public/js/views/upload.js
@@ -0,0 +1,238 @@
+/** Upload a video, and edit the details of one you already own. */
+import { h, icon, mount } from '../dom.js';
+import { api, uploadVideo } from '../api.js';
+import { state } from '../store.js';
+import { navigate } from '../router.js';
+import { toast, emptyState, spinner } from '../ui.js';
+import { fileSize, timecode } from '../format.js';
+
+const ACCEPT = 'video/mp4,video/webm,video/quicktime,video/x-m4v,video/ogg,.mp4,.webm,.mov,.m4v,.ogg';
+const MAX_BYTES = 512 * 1024 * 1024;
+
+export async function uploadView() {
+  if (!state.user) {
+    return emptyState('Sign in to upload', 'You need an account before you can publish a video.',
+      h('a.btn.btn--primary', { href: '/signin?next=%2Fupload' }, 'Sign in'));
+  }
+  document.title = 'Upload · MeTube';
+
+  let file = null;
+
+  const input = h('input', { type: 'file', accept: ACCEPT, hidden: true });
+  const dropzone = h('div.dropzone', { tabindex: '0', role: 'button', 'aria-label': 'Choose a video file' },
+    icon('upload', 38),
+    h('div.dropzone__title', { text: 'Drop a video here' }),
+    h('div.dropzone__hint', { text: 'or click to browse · MP4, WebM, MOV or OGG · up to 512 MB' }),
+  );
+  const fileSlot = h('div', {}, dropzone);
+
+  const title = h('input.input', { maxlength: '120', placeholder: 'Give it a clear, specific title', required: true });
+  const description = h('textarea.textarea', { maxlength: '5000', placeholder: 'What is in this video? Add chapters, links or credits.' });
+  const category = h('select.select', {},
+    state.categories.filter((c) => c !== 'All').map((c) => h('option', { value: c, selected: c === 'General' }, c)),
+  );
+
+  const error = h('div.formerror', { hidden: true, role: 'alert' });
+  const progressFill = h('div.progress__fill');
+  const progressPct = h('span.num', { text: '0%' });
+  const progressBytes = h('span', { text: '' });
+  const progressBox = h('div', { hidden: true },
+    h('div.progress', {}, progressFill),
+    h('div.progress__label', {}, h('span', {}, 'Uploading… ', progressPct), progressBytes),
+  );
+
+  const submit = h('button.btn.btn--primary', { type: 'submit', disabled: true }, icon('upload', 18), 'Publish video');
+
+  const setFile = (chosen) => {
+    if (!chosen) return;
+    if (chosen.size > MAX_BYTES) {
+      showError(`That file is ${fileSize(chosen.size)}. The limit is 512 MB.`);
+      return;
+    }
+    if (!/^video\//.test(chosen.type) && !/\.(mp4|webm|mov|m4v|ogg)$/i.test(chosen.name)) {
+      showError('That is not a video file. Choose an MP4, WebM, MOV or OGG.');
+      return;
+    }
+    file = chosen;
+    error.hidden = true;
+    submit.disabled = false;
+    if (!title.value.trim()) title.value = chosen.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').slice(0, 120);
+
+    const media = h('video', { src: URL.createObjectURL(chosen), muted: true, preload: 'metadata',
+      style: { width: '104px', borderRadius: '8px', background: '#000' } });
+    const meta = h('div.filecard__size', { text: fileSize(chosen.size) });
+    media.addEventListener('loadedmetadata', () => {
+      meta.textContent = `${fileSize(chosen.size)} · ${timecode(media.duration)}`;
+    });
+
+    mount(fileSlot, h('div.filecard', {},
+      media,
+      h('div', { style: { minWidth: 0, flex: '1 1 auto' } },
+        h('div.filecard__name', { text: chosen.name }),
+        meta,
+      ),
+      h('button.btn.btn--ghost.btn--sm', {
+        type: 'button',
+        onclick: () => {
+          URL.revokeObjectURL(media.src);
+          file = null;
+          submit.disabled = true;
+          input.value = '';
+          mount(fileSlot, dropzone);
+        },
+      }, 'Change'),
+    ));
+  };
+
+  const showError = (message) => {
+    error.textContent = message;
+    error.hidden = false;
+  };
+
+  dropzone.addEventListener('click', () => input.click());
+  dropzone.addEventListener('keydown', (event) => {
+    if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); input.click(); }
+  });
+  input.addEventListener('change', () => setFile(input.files[0]));
+
+  for (const type of ['dragenter', 'dragover']) {
+    dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.add('is-over'); });
+  }
+  for (const type of ['dragleave', 'drop']) {
+    dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.remove('is-over'); });
+  }
+  dropzone.addEventListener('drop', (event) => setFile(event.dataTransfer?.files?.[0]));
+
+  const form = h('form.panel', {
+    onsubmit: async (event) => {
+      event.preventDefault();
+      if (!file) return showError('Choose a video file first.');
+      if (!title.value.trim()) return showError('Give your video a title.');
+
+      error.hidden = true;
+      submit.disabled = true;
+      progressBox.hidden = false;
+
+      const data = new FormData();
+      data.append('title', title.value.trim());
+      data.append('description', description.value.trim());
+      data.append('category', category.value);
+      data.append('video', file, file.name);
+
+      try {
+        const { video } = await uploadVideo(data, (fraction, loaded, total) => {
+          const pct = Math.round(fraction * 100);
+          progressFill.style.width = `${pct}%`;
+          progressPct.textContent = `${pct}%`;
+          progressBytes.textContent = `${fileSize(loaded)} of ${fileSize(total)}`;
+          if (pct === 100) progressBytes.textContent = 'Processing on the server…';
+        });
+        toast('Published', 'good');
+        navigate(`/watch/${video.id}`);
+      } catch (err) {
+        showError(err.message);
+        submit.disabled = false;
+        progressBox.hidden = true;
+        progressFill.style.width = '0';
+      }
+    },
+  },
+    error,
+    h('div.field', {}, h('label.field__label', { text: 'Video file' }), fileSlot, input),
+    h('div.field', {}, h('label.field__label', { text: 'Title' }), title),
+    h('div.field', {}, h('label.field__label', { text: 'Description' }), description),
+    h('div.field', {}, h('label.field__label', { text: 'Category' }), category),
+    progressBox,
+    h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '9px', marginTop: '18px' } },
+      h('a.btn.btn--ghost', { href: '/' }, 'Cancel'),
+      submit,
+    ),
+  );
+
+  return h('div', { style: { maxWidth: '760px', margin: '0 auto' } },
+    h('div.pagehead', {},
+      h('div', {},
+        h('span.eyebrow', { text: `Publishing as @${state.user.username}` }),
+        h('h1.pagehead__title', { text: 'Upload a video' }),
+      ),
+    ),
+    form,
+  );
+}
+
+export async function editView({ id }) {
+  if (!state.user) {
+    return emptyState('Sign in to edit', 'You need to be signed in as the owner of this video.',
+      h('a.btn.btn--primary', { href: `/signin?next=%2Fedit%2F${id}` }, 'Sign in'));
+  }
+
+  const root = h('div', { style: { maxWidth: '760px', margin: '0 auto' } }, spinner());
+  let video;
+  try {
+    ({ video } = await api.video(id));
+  } catch (err) {
+    return emptyState('That video is not here', err.message, h('a.btn.btn--primary', { href: '/' }, 'Back to home'));
+  }
+  if (!video.isOwner) {
+    return emptyState('You cannot edit this video', 'Only the channel that uploaded a video can change its details.',
+      h('a.btn.btn--primary', { href: `/watch/${video.id}` }, 'Watch it instead'));
+  }
+  mount(root);
+  document.title = `Edit · ${video.title}`;
+
+  const title = h('input.input', { value: video.title, maxlength: '120', required: true });
+  const description = h('textarea.textarea', { maxlength: '5000' }, video.description);
+  const category = h('select.select', {},
+    state.categories.filter((c) => c !== 'All').map((c) => h('option', { value: c, selected: c === video.category }, c)),
+  );
+  const error = h('div.formerror', { hidden: true, role: 'alert' });
+  const save = h('button.btn.btn--primary', { type: 'submit' }, 'Save changes');
+
+  const form = h('form.panel', {
+    onsubmit: async (event) => {
+      event.preventDefault();
+      save.disabled = true;
+      error.hidden = true;
+      try {
+        await api.updateVideo(video.id, {
+          title: title.value.trim(),
+          description: description.value.trim(),
+          category: category.value,
+        });
+        toast('Details saved', 'good');
+        navigate(`/watch/${video.id}`);
+      } catch (err) {
+        error.textContent = err.message;
+        error.hidden = false;
+        save.disabled = false;
+      }
+    },
+  },
+    error,
+    h('div.field', {}, h('label.field__label', { text: 'Title' }), title),
+    h('div.field', {}, h('label.field__label', { text: 'Description' }), description),
+    h('div.field', {}, h('label.field__label', { text: 'Category' }), category),
+    h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '9px', marginTop: '18px' } },
+      h('a.btn.btn--ghost', { href: `/watch/${video.id}` }, 'Cancel'),
+      save,
+    ),
+  );
+
+  root.append(
+    h('div.pagehead', {},
+      h('div', {},
+        h('span.eyebrow', { text: 'Edit details' }),
+        h('h1.pagehead__title', { text: video.title }),
+      ),
+    ),
+    h('div', { style: { display: 'flex', gap: '16px', marginBottom: '20px', alignItems: 'center' } },
+      video.thumb ? h('img', { src: video.thumb, alt: '', style: { width: '190px', borderRadius: '10px' } }) : null,
+      h('div', { style: { color: 'var(--haze)', fontSize: '13px' } },
+        h('div', {}, 'Thumbnail is generated from the video.'),
+        h('a', { href: `/watch/${video.id}`, style: { color: 'var(--amber)', fontWeight: '600' } }, 'Open watch page'),
+      ),
+    ),
+    form,
+  );
+  return root;
+}
diff --git a/public/js/views/watch.js b/public/js/views/watch.js
new file mode 100644
index 0000000..b50d487
--- /dev/null
+++ b/public/js/views/watch.js
@@ -0,0 +1,435 @@
+/** Watch page: player, vote meter, description, comments and the up-next rail. */
+import { h, icon, mount, clear } from '../dom.js';
+import { api } from '../api.js';
+import { state } from '../store.js';
+import { navigate } from '../router.js';
+import {
+  videoCard, avatar, channelAvatarLink, subscribeButton, toast, confirmDialog,
+  dropdown, menuItem, emptyState, requireSignIn, spinner,
+} from '../ui.js';
+import { compact, full, timeAgo, longDate, plural } from '../format.js';
+
+export async function watchView({ id }) {
+  const root = h('div', {}, spinner());
+  let data;
+  try {
+    data = await api.video(id);
+  } catch (err) {
+    return emptyState('That video is not here', err.message,
+      h('a.btn.btn--primary', { href: '/' }, 'Back to home'));
+  }
+  clear(root);
+
+  const { video, related } = data;
+  document.title = `${video.title} · MeTube`;
+
+  const player = buildPlayer(video);
+  const primary = h('div.watch__primary', {},
+    player,
+    h('h1.watch__title', { text: video.title }),
+    buildActionBar(video),
+    buildDescription(video),
+    buildComments(video),
+  );
+
+  const rail = h('aside.watch__rail', {},
+    h('div.comments__head', { style: { marginBottom: '14px' } },
+      h('span.comments__count', { text: 'Up next' }),
+    ),
+    related.length
+      ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '14px' } },
+          related.map((v) => videoCard(v, { compactRow: true })))
+      : h('div', { style: { color: 'var(--haze)' }, text: 'Nothing else to show yet.' }),
+  );
+
+  root.append(h('div.watch', {}, primary, rail));
+  return root;
+}
+
+function buildPlayer(video) {
+  const media = h('video', {
+    src: video.src,
+    poster: video.thumb || null,
+    controls: true,
+    autoplay: true,
+    playsinline: true,
+    preload: 'metadata',
+  });
+
+  // A view counts once playback actually starts.
+  let counted = false;
+  media.addEventListener('playing', async () => {
+    if (counted) return;
+    counted = true;
+    try {
+      const { views } = await api.countView(video.id);
+      document.querySelector('#view-count')?.replaceChildren(document.createTextNode(full(views)));
+    } catch { /* a missed view is not worth interrupting playback for */ }
+  });
+  media.addEventListener('error', () => {
+    toast('This video could not be played.', 'error');
+  });
+
+  return h('div.player', {}, media);
+}
+
+function buildActionBar(video) {
+  const channel = video.channel;
+
+  const likeCount = h('span.num', { text: compact(video.likes) });
+  const dislikeCount = h('span.num', { text: compact(video.dislikes) });
+  const likeBtn = h('button.vote.vote--up', { type: 'button', 'aria-label': 'Like this video' },
+    icon('like', 19), likeCount);
+  const dislikeBtn = h('button.vote.vote--down', { type: 'button', 'aria-label': 'Dislike this video' },
+    icon('dislike', 19), dislikeCount);
+
+  const ratioFill = h('div.ratio__fill');
+  const ratio = h('div.ratio', { title: 'Share of votes that are likes' }, ratioFill);
+
+  const paintVotes = () => {
+    likeBtn.classList.toggle('is-on', video.myVote === 1);
+    dislikeBtn.classList.toggle('is-on', video.myVote === -1);
+    likeBtn.setAttribute('aria-pressed', String(video.myVote === 1));
+    dislikeBtn.setAttribute('aria-pressed', String(video.myVote === -1));
+    likeCount.textContent = compact(video.likes);
+    dislikeCount.textContent = compact(video.dislikes);
+    const total = video.likes + video.dislikes;
+    ratio.style.opacity = total ? '1' : '0.35';
+    ratioFill.style.width = total ? `${(video.likes / total) * 100}%` : '0%';
+  };
+
+  const castVote = async (value) => {
+    if (!state.user) return requireSignIn('Sign in to like videos.');
+    const next = video.myVote === value ? 0 : value;
+    likeBtn.disabled = dislikeBtn.disabled = true;
+    try {
+      const result = await api.vote(video.id, next);
+      Object.assign(video, result);
+      paintVotes();
+    } catch (err) {
+      toast(err.message, 'error');
+    } finally {
+      likeBtn.disabled = dislikeBtn.disabled = false;
+    }
+  };
+
+  likeBtn.addEventListener('click', () => castVote(1));
+  dislikeBtn.addEventListener('click', () => castVote(-1));
+  paintVotes();
+
+  const saveBtn = h('button.btn.btn--ghost', { type: 'button' });
+  const paintSave = () => {
+    mount(saveBtn, icon('bookmark', 18), video.saved ? 'Saved' : 'Save');
+    saveBtn.classList.toggle('is-on', !!video.saved);
+    saveBtn.style.color = video.saved ? 'var(--amber)' : '';
+  };
+  saveBtn.addEventListener('click', async () => {
+    if (!state.user) return requireSignIn('Sign in to save videos for later.');
+    try {
+      const { saved } = await api.toggleSave(video.id);
+      video.saved = saved;
+      paintSave();
+      toast(saved ? 'Saved to Watch later' : 'Removed from Watch later', saved ? 'good' : '');
+    } catch (err) {
+      toast(err.message, 'error');
+    }
+  });
+  paintSave();
+
+  const shareBtn = h('button.btn.btn--ghost', { type: 'button' }, icon('share', 18), 'Share');
+  shareBtn.addEventListener('click', async () => {
+    const url = `${location.origin}/watch/${video.id}`;
+    try {
+      if (navigator.share) await navigator.share({ title: video.title, url });
+      else {
+        await navigator.clipboard.writeText(url);
+        toast('Link copied to clipboard', 'good');
+      }
+    } catch { /* the person dismissed the share sheet */ }
+  });
+
+  const actions = h('div.watch__actions', {},
+    h('div', {}, h('div.votes', {}, likeBtn, dislikeBtn), ratio),
+    saveBtn,
+    shareBtn,
+    video.isOwner ? ownerMenu(video) : null,
+  );
+
+  const subsLabel = h('span.channelline__subs', {},
+    h('span.num', { text: compact(channel.subscriberCount) }),
+    ` ${channel.subscriberCount === 1 ? 'subscriber' : 'subscribers'}`,
+  );
+
+  return h('div.watch__bar', {},
+    h('div.channelline', {},
+      channelAvatarLink(channel),
+      h('div', { style: { minWidth: 0 } },
+        h('a.channelline__name', { href: `/@${channel.username}`, text: channel.displayName }),
+        subsLabel,
+      ),
+      h('div', { style: { marginLeft: '8px' } },
+        subscribeButton(channel, {
+          onUpdate: ({ subscriberCount, isSubscribed }) => {
+            mount(subsLabel,
+              h('span.num', { text: compact(subscriberCount) }),
+              ` ${subscriberCount === 1 ? 'subscriber' : 'subscribers'}`);
+            if (isSubscribed) toast(`You will see new videos from ${channel.displayName}`, 'good');
+          },
+        }),
+      ),
+    ),
+    actions,
+  );
+}
+
+function ownerMenu(video) {
+  const trigger = h('button.iconbtn', { type: 'button', 'aria-label': 'Video options' }, icon('dots', 20));
+  return dropdown(trigger, (close) => [
+    menuItem('Edit details', 'edit', () => { close(); navigate(`/edit/${video.id}`); }),
+    h('div.menu__sep'),
+    menuItem('Delete video', 'trash', async () => {
+      close();
+      const ok = await confirmDialog({
+        title: 'Delete this video?',
+        text: `“${video.title}” and all of its comments will be removed permanently.`,
+        confirmLabel: 'Delete',
+        danger: true,
+      });
+      if (!ok) return;
+      try {
+        await api.deleteVideo(video.id);
+        toast('Video deleted', 'good');
+        navigate('/');
+      } catch (err) {
+        toast(err.message, 'error');
+      }
+    }, { danger: true }),
+  ]);
+}
+
+function buildDescription(video) {
+  const body = h('div.descbox__body.is-clamped', { text: video.description || 'No description.' });
+  const more = h('button.descbox__more', { type: 'button' }, 'Show more');
+  more.addEventListener('click', () => {
+    const clamped = body.classList.toggle('is-clamped');
+    more.textContent = clamped ? 'Show more' : 'Show less';
+  });
+
+  const box = h('div.descbox', {},
+    h('div.descbox__meta', {},
+      h('span.descbox__tag', { text: video.category }),
+      h('span', {}, h('span.num#view-count', { text: full(video.views) }), ` ${video.views === 1 ? 'view' : 'views'}`),
+      h('span', { text: longDate(video.createdAt) }),
+      h('span', {}, h('span.num', { text: compact(video.commentCount) }), ` ${video.commentCount === 1 ? 'comment' : 'comments'}`),
+    ),
+    body,
+  );
+
+  // Only offer the toggle when there is something hidden to reveal.
+  requestAnimationFrame(() => {
+    if (body.scrollHeight > body.clientHeight + 4) box.append(more);
+  });
+  return box;
+}
+
+/* -------------------------------------------------------------- comments -- */
+
+function buildComments(video) {
+  const section = h('section.comments', { 'aria-label': 'Comments' });
+  const list = h('div');
+  let sort = 'top';
+
+  const countLabel = h('span.comments__count', { text: plural(video.commentCount, 'comment') });
+  const sortButtons = ['top', 'new'].map((key) =>
+    h('button.sortlink', {
+      type: 'button',
+      class: key === sort ? 'is-on' : '',
+      onclick: async (event) => {
+        sort = key;
+        for (const btn of event.target.parentElement.querySelectorAll('.sortlink')) btn.classList.remove('is-on');
+        event.target.classList.add('is-on');
+        await load();
+      },
+    }, key === 'top' ? 'Top' : 'Newest'),
+  );
+
+  const head = h('div.comments__head', {}, countLabel, h('div', {}, sortButtons));
+
+  const bumpCount = (delta) => {
+    video.commentCount = Math.max(0, video.commentCount + delta);
+    countLabel.textContent = plural(video.commentCount, 'comment');
+  };
+
+  const blankSlate = () =>
+    h('div', { data: { blank: 'true' }, style: { color: 'var(--haze)', padding: '10px 0' },
+      text: 'No comments yet. Start the conversation.' });
+
+  const load = async () => {
+    mount(list, spinner());
+    try {
+      const { comments } = await api.comments(video.id, sort);
+      mount(list, comments.length
+        ? comments.map((c) => commentNode(c, video, { onCountChange: bumpCount, reload: load }))
+        : blankSlate());
+    } catch (err) {
+      mount(list, h('div', { style: { color: 'var(--rose)' }, text: err.message }));
+    }
+  };
+
+  section.append(head, composer(video, {
+    onPosted: (comment) => {
+      bumpCount(1);
+      if (list.firstElementChild?.dataset.blank) clear(list);
+      list.prepend(commentNode(comment, video, { onCountChange: bumpCount, reload: load }));
+    },
+  }), list);
+  load();
+  return section;
+}
+
+function composer(video, { onPosted, parentId = null, onCancel = null, autofocus = false }) {
+  if (!state.user) {
+    return h('div.composer', {},
+      avatar({ displayName: '?' }),
+      h('div.composer__main', {},
+        h('div', { style: { color: 'var(--haze)', padding: '7px 0' } },
+          h('a', { href: `/signin?next=${encodeURIComponent(location.pathname)}`, style: { color: 'var(--amber)', fontWeight: '700' } }, 'Sign in'),
+          ' to join the conversation.',
+        ),
+      ),
+    );
+  }
+
+  const input = h('textarea.composer__input', {
+    rows: '1',
+    placeholder: parentId ? 'Write a reply…' : 'Add a comment…',
+    maxlength: '2000',
+    'aria-label': parentId ? 'Write a reply' : 'Add a comment',
+  });
+
+  const submit = h('button.btn.btn--primary.btn--sm', { type: 'submit', disabled: true }, parentId ? 'Reply' : 'Comment');
+  const cancel = h('button.btn.btn--ghost.btn--sm', { type: 'button' }, 'Cancel');
+
+  const autosize = () => {
+    input.style.height = 'auto';
+    input.style.height = `${Math.min(input.scrollHeight, 260)}px`;
+  };
+  input.addEventListener('input', () => {
+    submit.disabled = !input.value.trim();
+    autosize();
+  });
+  input.addEventListener('keydown', (event) => {
+    if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') form.requestSubmit();
+    if (event.key === 'Escape' && onCancel) onCancel();
+  });
+
+  const form = h('form.composer__main', {
+    onsubmit: async (event) => {
+      event.preventDefault();
+      const body = input.value.trim();
+      if (!body) return;
+      submit.disabled = true;
+      try {
+        const { comment } = await api.addComment(video.id, body, parentId);
+        input.value = '';
+        autosize();
+        onPosted(comment);
+        onCancel?.();
+      } catch (err) {
+        toast(err.message, 'error');
+      } finally {
+        submit.disabled = !input.value.trim();
+      }
+    },
+  }, input, h('div.composer__actions', {}, onCancel ? cancel : null, submit));
+
+  cancel.addEventListener('click', () => onCancel?.());
+  if (autofocus) requestAnimationFrame(() => input.focus());
+
+  return h('div.composer', {}, avatar(state.user), form);
+}
+
+function commentNode(comment, video, { onCountChange, reload, isReply = false }) {
+  const likeCount = h('span.num', { text: comment.likes ? compact(comment.likes) : '' });
+  const likeBtn = h('button.tinybtn', { type: 'button', 'aria-label': 'Like this comment' }, icon('like', 16), likeCount);
+
+  const paint = () => {
+    likeBtn.classList.toggle('is-on', comment.myVote === 1);
+    likeBtn.setAttribute('aria-pressed', String(comment.myVote === 1));
+    likeCount.textContent = comment.likes ? compact(comment.likes) : '';
+  };
+  likeBtn.addEventListener('click', async () => {
+    if (!state.user) return requireSignIn('Sign in to like comments.');
+    try {
+      const result = await api.likeComment(comment.id, comment.myVote === 1 ? 0 : 1);
+      Object.assign(comment, result);
+      paint();
+    } catch (err) {
+      toast(err.message, 'error');
+    }
+  });
+  paint();
+
+  const repliesWrap = h('div.comment__replies');
+  const replySlot = h('div');
+
+  const renderReplies = () => {
+    mount(repliesWrap, comment.replies.map((r) => commentNode(r, video, { onCountChange, reload, isReply: true })));
+    repliesWrap.hidden = comment.replies.length === 0;
+  };
+
+  const replyBtn = h('button.tinybtn', { type: 'button' }, icon('reply', 16), 'Reply');
+  replyBtn.addEventListener('click', () => {
+    if (!state.user) return requireSignIn('Sign in to reply.');
+    if (replySlot.firstChild) return clear(replySlot);
+    mount(replySlot, composer(video, {
+      parentId: comment.parentId ?? comment.id,
+      autofocus: true,
+      onCancel: () => clear(replySlot),
+      onPosted: (created) => {
+        comment.replies.push(created);
+        renderReplies();
+        clear(replySlot);
+        onCountChange(1);
+      },
+    }));
+  });
+
+  const deleteBtn = comment.canDelete
+    ? h('button.tinybtn', { type: 'button', onclick: async () => {
+        const ok = await confirmDialog({
+          title: 'Delete this comment?',
+          text: 'The comment and any replies to it will be removed.',
+          confirmLabel: 'Delete',
+          danger: true,
+        });
+        if (!ok) return;
+        try {
+          await api.deleteComment(comment.id);
+          onCountChange(-(1 + comment.replies.length));
+          toast('Comment deleted', 'good');
+          reload();
+        } catch (err) {
+          toast(err.message, 'error');
+        }
+      } }, icon('trash', 16), 'Delete')
+    : null;
+
+  const node = h('article.comment', {},
+    h('a', { href: `/@${comment.author.username}` }, avatar(comment.author, isReply ? 'sm' : '')),
+    h('div.comment__main', {},
+      h('div.comment__head', {},
+        h('a.comment__author', { href: `/@${comment.author.username}`, text: comment.author.displayName }),
+        comment.isCreator ? h('span.comment__badge', { text: 'Creator' }) : null,
+        h('span.comment__when', { text: timeAgo(comment.createdAt) }),
+      ),
+      h('div.comment__body', { text: comment.body }),
+      h('div.comment__actions', {}, likeBtn, isReply ? null : replyBtn, deleteBtn),
+      replySlot,
+      isReply ? null : repliesWrap,
+    ),
+  );
+
+  if (!isReply) renderReplies();
+  return node;
+}
diff --git a/scripts/seed.js b/scripts/seed.js
new file mode 100644
index 0000000..d817d20
--- /dev/null
+++ b/scripts/seed.js
@@ -0,0 +1,223 @@
+#!/usr/bin/env node
+/**
+ * Populate the database and render sample clips with ffmpeg.
+ * Safe to re-run: it skips channels that already exist.
+ */
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+import { db, get, run } from '../server/db.js';
+import { hashPassword } from '../server/auth.js';
+import { VIDEO_DIR, THUMB_DIR } from '../server/paths.js';
+import { RECIPES, TONES } from './videoclips.js';
+import { CHANNELS, VIEWERS, COMMENTS, REPLIES } from './seeddata.js';
+
+const execFileAsync = promisify(execFile);
+const PASSWORD = 'password123';
+
+for (const dir of [VIDEO_DIR, THUMB_DIR]) mkdirSync(dir, { recursive: true });
+
+// Deterministic PRNG so a reseed produces the same plausible-looking numbers.
+let seed = 1337;
+const rand = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
+const pick = (arr) => arr[Math.floor(rand() * arr.length)];
+const between = (lo, hi) => lo + Math.floor(rand() * (hi - lo + 1));
+
+function upsertUser({ username, displayName, hue, bio }) {
+  const existing = get('SELECT * FROM users WHERE username = ?', username);
+  if (existing) return existing;
+  run(
+    `INSERT INTO users (username, email, password_hash, display_name, bio, avatar_hue)
+     VALUES (?, ?, ?, ?, ?, ?)`,
+    username, `${username}@metube.test`, hashPassword(PASSWORD), displayName, bio ?? '', hue
+  );
+  return get('SELECT * FROM users WHERE username = ?', username);
+}
+
+async function renderClip(spec, storedName, variant) {
+  const out = join(VIDEO_DIR, storedName);
+  // Reuse a previous render, but never trust a truncated one from a failed run.
+  if (existsSync(out) && statSync(out).size > 4096) return out;
+
+  const build = RECIPES[spec.recipe] ?? RECIPES.grade;
+  // Recipes compose at 720p for readable type, then downscale — these are demo
+  // clips, and the fractal detail is what makes the files large.
+  const chain = [...build(spec.title, spec.sub, variant), 'scale=960:540']
+    .join(',')
+    .replaceAll('%D', String(spec.seconds));
+  const audio = (TONES[spec.tone] ?? TONES.quiet).replaceAll('%D', String(spec.seconds));
+
+  try {
+    await execFileAsync('ffmpeg', [
+      '-y', '-loglevel', 'error',
+      '-filter_complex', `${chain}[v];${audio}[a]`,
+      '-map', '[v]', '-map', '[a]',
+      '-t', String(spec.seconds),
+      '-c:v', 'libx264', '-preset', 'medium', '-crf', '33',
+      '-pix_fmt', 'yuv420p', '-r', '30', '-g', '60',
+      '-c:a', 'aac', '-b:a', '64k',
+      '-movflags', '+faststart',
+      out,
+    ], { maxBuffer: 1024 * 1024 * 8 });
+  } catch (err) {
+    rmSync(out, { force: true }); // do not leave a stub the next run would reuse
+    throw err;
+  }
+  return out;
+}
+
+async function makeThumb(videoPath, storedName, seconds) {
+  const name = `${storedName.replace(/\.mp4$/, '')}.jpg`;
+  const dest = join(THUMB_DIR, name);
+  if (!existsSync(dest)) {
+    await execFileAsync('ffmpeg', [
+      '-y', '-loglevel', 'error', '-ss', String(Math.min(seconds / 2, 3)), '-i', videoPath,
+      '-frames:v', '1', '-vf', 'scale=640:-2', '-q:v', '4', dest,
+    ]);
+  }
+  return `/media/thumbs/${name}`;
+}
+
+/** Delete leftover seed-*.mp4/jpg from earlier runs that nothing points at. */
+function pruneOrphanSeedClips() {
+  const referenced = new Set(
+    db.prepare('SELECT src, thumb FROM videos').all()
+      .flatMap((row) => [row.src, row.thumb])
+      .map((path) => String(path).split('/').pop())
+  );
+  let removed = 0;
+  for (const dir of [VIDEO_DIR, THUMB_DIR]) {
+    for (const name of readdirSync(dir)) {
+      if (!name.startsWith('seed-') || referenced.has(name)) continue;
+      rmSync(join(dir, name), { force: true });
+      removed++;
+    }
+  }
+  if (removed) console.log(`  · removed ${removed} orphaned seed file${removed === 1 ? '' : 's'}`);
+}
+
+const START = Date.now();
+
+async function main() {
+  console.log('Seeding MeTube…');
+
+  const viewers = VIEWERS.map(upsertUser);
+  const channels = [];
+
+  for (const [channelIndex, channel] of CHANNELS.entries()) {
+    const user = upsertUser(channel);
+    channels.push(user);
+    const alreadyHasVideos = get('SELECT COUNT(*) AS n FROM videos WHERE user_id = ?', user.id).n > 0;
+    if (alreadyHasVideos) {
+      console.log(`  · @${user.username} already has videos, skipping render`);
+      continue;
+    }
+
+    for (const [videoIndex, spec] of channel.videos.entries()) {
+      // Names and variants are derived from position, not a running counter, so
+      // re-running after a partial seed reuses the same files instead of new ones.
+      const variant = channelIndex * 7 + videoIndex;
+      const storedName = `seed-${channel.username}-${videoIndex}.mp4`;
+      process.stdout.write(`  · rendering ${spec.title.slice(0, 46)}… `);
+      const path = await renderClip(spec, storedName, variant);
+      const thumb = await makeThumb(path, storedName, spec.seconds);
+      const { stdout } = await execFileAsync('ffprobe', [
+        '-v', 'error', '-show_entries', 'format=duration',
+        '-of', 'default=noprint_wrappers=1:nokey=1', path,
+      ]);
+      const duration = Math.round(Number.parseFloat(stdout.trim()) * 10) / 10 || spec.seconds;
+      const ageDays = between(0, 240);
+
+      run(
+        `INSERT INTO videos (user_id, title, description, category, src, thumb, duration, views, created_at)
+         VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', ?))`,
+        user.id, spec.title, spec.description, spec.category,
+        `/media/videos/${storedName}`, thumb, duration,
+        between(180, 480000), `-${ageDays} days`
+      );
+      console.log('done');
+    }
+  }
+
+  // Engagement is only generated once; re-running must not double the comments.
+  if (get('SELECT COUNT(*) AS n FROM comments').n > 0) {
+    console.log('  · engagement already seeded, leaving likes and comments alone');
+    pruneOrphanSeedClips();
+    return report();
+  }
+
+  // Subscriptions: every account follows a few channels.
+  const everyone = [...channels, ...viewers];
+  for (const person of everyone) {
+    for (const channel of channels) {
+      if (channel.id === person.id || rand() > 0.55) continue;
+      run(
+        `INSERT OR IGNORE INTO subscriptions (channel_id, subscriber_id, created_at)
+         VALUES (?, ?, datetime('now', ?))`,
+        channel.id, person.id, `-${between(1, 200)} days`
+      );
+    }
+  }
+
+  // Likes, comments and replies across the catalogue.
+  const videos = db.prepare('SELECT * FROM videos').all();
+  for (const video of videos) {
+    for (const person of everyone) {
+      if (person.id === video.user_id || rand() > 0.62) continue;
+      run(
+        `INSERT OR IGNORE INTO video_likes (video_id, user_id, value, created_at)
+         VALUES (?, ?, ?, datetime('now', ?))`,
+        video.id, person.id, rand() > 0.12 ? 1 : -1, `-${between(0, 90)} days`
+      );
+    }
+
+    const commentCount = between(1, 4);
+    for (let i = 0; i < commentCount; i++) {
+      const author = pick(everyone.filter((p) => p.id !== video.user_id));
+      const { lastInsertRowid } = run(
+        `INSERT INTO comments (video_id, user_id, parent_id, body, created_at)
+         VALUES (?, ?, NULL, ?, datetime('now', ?))`,
+        video.id, author.id, pick(COMMENTS), `-${between(0, 60)} days`
+      );
+      const commentId = Number(lastInsertRowid);
+
+      for (const person of everyone) {
+        if (rand() > 0.3) continue;
+        run('INSERT OR IGNORE INTO comment_likes (comment_id, user_id, value) VALUES (?, ?, 1)', commentId, person.id);
+      }
+      if (rand() > 0.55) {
+        const replier = rand() > 0.5
+          ? get('SELECT * FROM users WHERE id = ?', video.user_id)
+          : pick(everyone);
+        run(
+          `INSERT INTO comments (video_id, user_id, parent_id, body, created_at)
+           VALUES (?, ?, ?, ?, datetime('now', ?))`,
+          video.id, replier.id, commentId, pick(REPLIES), `-${between(0, 20)} days`
+        );
+      }
+    }
+  }
+
+  pruneOrphanSeedClips();
+  report();
+}
+
+function report() {
+  const counts = get(`SELECT
+      (SELECT COUNT(*) FROM users) AS users,
+      (SELECT COUNT(*) FROM videos) AS videos,
+      (SELECT COUNT(*) FROM comments) AS comments,
+      (SELECT COUNT(*) FROM subscriptions) AS subs,
+      (SELECT COUNT(*) FROM video_likes) AS likes`);
+
+  console.log(`\nSeeded in ${((Date.now() - START) / 1000).toFixed(1)}s`);
+  console.log(`  ${counts.users} accounts · ${counts.videos} videos · ${counts.comments} comments · ${counts.subs} subscriptions · ${counts.likes} votes`);
+  console.log(`\nSign in with any username below and the password "${PASSWORD}":`);
+  console.log(`  ${[...CHANNELS, ...VIEWERS].map((c) => c.username).join(', ')}`);
+}
+
+main().catch((err) => {
+  console.error('\nSeed failed:', err.message);
+  process.exit(1);
+});
diff --git a/scripts/seeddata.js b/scripts/seeddata.js
new file mode 100644
index 0000000..eeb18c6
--- /dev/null
+++ b/scripts/seeddata.js
@@ -0,0 +1,127 @@
+/** Channels and their videos for the demo dataset. */
+export const CHANNELS = [
+  {
+    username: 'pixelforge', displayName: 'Pixel Forge', hue: 265,
+    bio: 'Shader experiments, generative art and the maths that makes them move. New breakdowns every Tuesday.',
+    videos: [
+      { title: 'Building a Mandelbrot zoom from scratch', category: 'Tech', recipe: 'mandel', tone: 'hum', seconds: 16,
+        sub: 'Escape-time rendering, step by step',
+        description: 'We start with the escape-time algorithm on a plain 2D grid, add smooth iteration colouring, then push the whole thing onto the GPU. By the end you have a zoom that stays sharp at 10^12 magnification.\n\nChapters:\n00:00 The escape-time idea\n00:05 Smooth colouring\n00:11 Moving to the GPU' },
+      { title: 'Sierpinski carpets and why recursion is beautiful', category: 'Learning', recipe: 'carpet', tone: 'chime', seconds: 14,
+        sub: 'One rule, infinite detail',
+        description: 'A single substitution rule, applied forever, produces a shape with zero area and infinite perimeter. We build it three ways: recursively, iteratively, and with a surprising bitwise trick.' },
+      { title: 'Cellular automata: rule 110 is Turing complete', category: 'Tech', recipe: 'auto', tone: 'pulse', seconds: 15,
+        sub: 'A universal computer in one line of cells',
+        description: 'Rule 110 looks like static until you know what to look for. We trace the gliders, show how they collide, and sketch the argument that this one-dimensional toy can compute anything a laptop can.' },
+    ],
+  },
+  {
+    username: 'quietkitchen', displayName: 'The Quiet Kitchen', hue: 22,
+    bio: 'Slow, unhurried cooking. No shouting, no jump cuts — just good technique and a very sharp knife.',
+    videos: [
+      { title: 'The only omelette technique you need', category: 'Cooking', recipe: 'flow', tone: 'chime', seconds: 13,
+        sub: 'Low heat, constant motion, 90 seconds',
+        description: 'Three eggs, a knob of butter, and a pan you trust. The whole trick is heat control: keep it low enough that you can hold your hand over the pan, and keep the curds moving until the last possible second.' },
+      { title: 'Sourdough without the anxiety', category: 'Cooking', recipe: 'grade', tone: 'quiet', seconds: 18,
+        sub: 'A schedule that fits around a real job',
+        description: 'Feed at breakfast, mix at dinner, bake the next morning. This is the loaf I actually make on weekdays — no 3am shaping, no obsessive temperature logging.' },
+      { title: 'Knife skills: the pinch grip, properly explained', category: 'Learning', recipe: 'spiral', tone: 'quiet', seconds: 12,
+        sub: 'Ten minutes that fix a decade of bad habits',
+        description: 'Most people hold a knife like a hammer. Move your thumb and forefinger onto the blade and everything downstream — speed, safety, consistency — gets better at once.' },
+    ],
+  },
+  {
+    username: 'northbound', displayName: 'Northbound', hue: 198,
+    bio: 'Long walks in cold places. Trip reports, gear that survived, and the occasional weather-related mistake.',
+    videos: [
+      { title: 'Forty-eight hours above the tree line', category: 'Travel', recipe: 'flow', tone: 'hum', seconds: 20,
+        sub: 'Two nights, one very cold ridge',
+        description: 'A short overnight that turned into a lesson about wind. Full gear list in the description, including the jacket that finally earned its place in the pack.' },
+      { title: 'Packing for a week with a 40 litre bag', category: 'Travel', recipe: 'grade', tone: 'quiet', seconds: 14,
+        sub: 'Everything laid out, nothing left behind',
+        description: 'The full spread, item by item, with weights. The short version: your sleep system and your shell are worth the money, everything else can be cheap.' },
+      { title: 'Reading weather like a local', category: 'Nature', recipe: 'mandel', tone: 'hum', seconds: 16,
+        sub: 'Clouds, pressure, and knowing when to turn back',
+        description: 'Forecasts are for planning; the sky is for deciding. What to look for on the morning of, and the three signals that mean you should go home.' },
+    ],
+  },
+  {
+    username: 'lowlatency', displayName: 'Low Latency', hue: 140,
+    bio: 'Competitive play, frame data, and the unglamorous practice routines behind it.',
+    videos: [
+      { title: 'Why your aim is inconsistent (it is not your mouse)', category: 'Gaming', recipe: 'auto', tone: 'pulse', seconds: 15,
+        sub: 'Posture, sensitivity, and the 20-minute warmup',
+        description: 'We put six sensitivities through the same drill and measured the spread. The winner was not the fastest or the slowest — it was the one the player could repeat.' },
+      { title: 'Frame data for people who hate frame data', category: 'Gaming', recipe: 'spiral', tone: 'pulse', seconds: 13,
+        sub: 'Startup, active, recovery — that is it',
+        description: 'You do not need to memorise a spreadsheet. You need three numbers per move and a feel for what beats what. Here is the shortcut.' },
+      { title: 'The practice routine that actually stuck', category: 'Fitness', recipe: 'life', tone: 'chime', seconds: 12,
+        sub: 'Twenty minutes, four blocks, no burnout',
+        description: 'Aim, movement, matchup review, then one real set. Short enough that you do it on a bad day, structured enough that it compounds.' },
+    ],
+  },
+  {
+    username: 'roomtone', displayName: 'Room Tone', hue: 320,
+    bio: 'Home recording on a budget. Mic tests, mixing walkthroughs, and honest gear opinions.',
+    videos: [
+      { title: 'Your room matters more than your microphone', category: 'Music', recipe: 'flow', tone: 'chime', seconds: 17,
+        sub: 'A blanket fort beats a boutique preamp',
+        description: 'Same vocal, same take, four spaces. The differences are not subtle. Treat the room first, then argue about capsules.' },
+      { title: 'Mixing a song in one pass', category: 'Music', recipe: 'mandel', tone: 'chime', seconds: 19,
+        sub: 'Balance, then EQ, then everything else',
+        description: 'A full rough mix start to finish, narrated. The order matters far more than the plugins: get faders right before you touch a single band of EQ.' },
+      { title: 'Compression, finally explained with pictures', category: 'Learning', recipe: 'grade', tone: 'hum', seconds: 15,
+        sub: 'Threshold, ratio, attack, release',
+        description: 'Four knobs, one waveform, and a visual for each. Once you can see what attack is doing, you stop guessing.' },
+    ],
+  },
+  {
+    username: 'thedailyloop', displayName: 'The Daily Loop', hue: 8,
+    bio: 'A five-minute digest of what happened, why it matters, and what to watch next.',
+    videos: [
+      { title: 'This week in open source', category: 'News', recipe: 'bulletin', tone: 'pulse', seconds: 14,
+        sub: 'Licences, forks, and one very large merge',
+        description: 'A quiet week with one loud exception. We cover the licence change everyone is arguing about and what it means if you ship on top of it.' },
+      { title: 'The battery numbers nobody checks', category: 'Tech', recipe: 'bulletin', tone: 'pulse', seconds: 13,
+        sub: 'Marketing hours vs. measured hours',
+        description: 'We ran the same workload on six laptops. Two of them matched their claims. The gap for the rest was bigger than any spec sheet suggests.' },
+      { title: 'A short history of the undo button', category: 'Comedy', recipe: 'spiral', tone: 'chime', seconds: 12,
+        sub: 'The most forgiving idea in software',
+        description: 'From typewriter correction tape to infinite command stacks. A gentle tour of the feature that made experimenting safe.' },
+    ],
+  },
+];
+
+/** Extra accounts so subscriptions and comments have real people behind them. */
+export const VIEWERS = [
+  { username: 'marta', displayName: 'Marta Oyelaran', hue: 44, bio: 'Backend engineer. Here for the fractals and the bread.' },
+  { username: 'dev_haruki', displayName: 'Haruki N.', hue: 172, bio: 'Learning to cook, badly. Improving.' },
+  { username: 'clara_b', displayName: 'Clara Bianchi', hue: 300, bio: 'Trail runner, occasional commenter.' },
+  { username: 'sam.reads', displayName: 'Sam Whitfield', hue: 96, bio: 'I watch tutorials I will never act on.' },
+  { username: 'tuesday', displayName: 'Tuesday Adeyemi', hue: 250, bio: 'Sound engineer. Loud opinions about rooms.' },
+];
+
+export const COMMENTS = [
+  'Genuinely the clearest explanation of this I have found. Bookmarked.',
+  'I have watched three videos on this today and yours is the only one that made it click.',
+  'The pacing here is perfect — no ten minutes of intro before the actual content.',
+  'Tried this over the weekend and it worked first time. Thank you.',
+  'Could you do a follow-up that goes deeper on the second half?',
+  'Small correction: I think the number at the halfway point should be doubled, but it does not change the conclusion.',
+  'This channel deserves so many more subscribers.',
+  'Came for the thumbnail, stayed for the actual substance.',
+  'The part where you show the failure case is what makes this useful. Most videos skip it.',
+  'Sending this to my entire team.',
+  'I disagree with the framing slightly, but the demonstration is excellent.',
+  'Watched twice. Second time with a notebook.',
+  'Any chance of a written version? I would love to reference this later.',
+  'Perfect length. Respect for not padding it out.',
+];
+
+export const REPLIES = [
+  'Same here — the failure case was the useful bit.',
+  'Agreed. The follow-up would be great.',
+  'Glad it helped! There is a longer version planned.',
+  'Good catch, you are right about that number.',
+  'Seconded.',
+];
diff --git a/scripts/videoclips.js b/scripts/videoclips.js
new file mode 100644
index 0000000..c994fdf
--- /dev/null
+++ b/scripts/videoclips.js
@@ -0,0 +1,176 @@
+/**
+ * Recipes for the sample clips. Each builds a real, seekable MP4 out of
+ * ffmpeg's generative sources, so a fresh install has something to play.
+ *
+ * Every recipe takes a `variant` number and uses it to pick colours, seeds and
+ * coordinates, so two videos built from the same recipe never look alike.
+ */
+export const FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
+
+const esc = (text) => text.replace(/[\\:']/g, (m) => `\\${m}`);
+const at = (list, variant) => list[variant % list.length];
+
+/** A dimmed title-safe band, so captions stay readable over busy sources. */
+const scrim = 'drawbox=x=0:y=ih*0.34:w=iw:h=ih*0.32:[email protected]:t=fill';
+
+function line(text, { size, y, from, hold }) {
+  return [
+    `drawtext=fontfile=${FONT}`,
+    `text='${esc(text)}'`,
+    `fontsize=${size}`,
+    'fontcolor=white',
+    'borderw=2',
+    '[email protected]',
+    'x=(w-text_w)/2',
+    `y=${y}`,
+    `alpha='if(lt(t,${from}),0,if(lt(t,${from + 0.5}),(t-${from})/0.5,1))'`,
+  ].join(':');
+}
+
+/** Title + subtitle, fading up over the band. */
+const titleBlock = (title, sub) => [
+  scrim,
+  line(title, { size: 46, y: 'h*0.41', from: 0.5, hold: 4 }),
+  line(sub, { size: 27, y: 'h*0.53', from: 1.1, hold: 3.4 }),
+];
+
+const ticker = (text) => [
+  `drawtext=fontfile=${FONT}`,
+  `text='${esc(text)}'`,
+  'fontsize=25',
+  'fontcolor=white',
+  "x='w - mod(t*130, w+text_w)'",
+  'y=h-72',
+].join(':');
+
+/* Palettes reused across recipes so the whole catalogue feels related. */
+const DUOTONE = [
+  'colorchannelmixer=rr=0.18:rg=0.62:rb=0.86:gr=0.10:gg=0.34:gb=0.72:br=0.34:bg=0.16:bb=0.52', // deep blue
+  'colorchannelmixer=rr=0.86:rg=0.52:rb=0.14:gr=0.46:gg=0.30:gb=0.10:br=0.16:bg=0.20:bb=0.34', // amber
+  'colorchannelmixer=rr=0.20:rg=0.70:rb=0.40:gr=0.14:gg=0.62:gb=0.44:br=0.30:bg=0.36:bb=0.30', // mint
+  'colorchannelmixer=rr=0.72:rg=0.28:rb=0.62:gr=0.22:gg=0.18:gb=0.48:br=0.60:bg=0.34:bb=0.66', // magenta
+];
+
+/** Interesting places to sit in the Mandelbrot set. */
+const MANDEL_SPOTS = [
+  { x: -0.743643887037151, y: 0.13182590420533, scale: 0.0006 },
+  { x: -0.1592, y: -1.0317, scale: 0.0022 },
+  { x: 0.001643721971153, y: -0.822467633298876, scale: 0.0009 },
+  { x: -1.749277, y: 0.000005, scale: 0.0015 },
+  { x: -0.235125, y: 0.827215, scale: 0.0011 },
+];
+
+export const RECIPES = {
+  /** A slow drift through a different corner of the Mandelbrot set each time. */
+  mandel: (title, sub, v) => {
+    const spot = at(MANDEL_SPOTS, v);
+    return [
+      `mandelbrot=size=1280x720:rate=30:maxiter=260:start_x=${spot.x}:start_y=${spot.y}` +
+        `:start_scale=${spot.scale}:end_scale=${spot.scale / 2.2}:inner=period:outer=iteration_count`,
+      `hue=h='${(v * 67) % 360}+t*22':s=1.1`,
+      ...titleBlock(title, sub),
+    ];
+  },
+
+  /** Soft colour fields, like a graded background plate. */
+  flow: (title, sub, v) => {
+    const sets = [
+      { c: ['#0c2a4d', '#1c6b7a', '#f0a04b', '#3b1f4d'], type: 'linear' },
+      { c: ['#2b1b3d', '#7b3f61', '#e4914f', '#12303f'], type: 'radial' },
+      { c: ['#08313a', '#2f7d6b', '#c6d97f', '#123a55'], type: 'circular' },
+    ];
+    const set = at(sets, v);
+    const colors = set.c.map((hex, i) => `c${i}=${hex}`).join(':');
+    return [
+      `gradients=size=1280x720:rate=30:duration=%D:${colors}:n=4:type=${set.type}:speed=0.0${4 + (v % 4)}`,
+      'gblur=sigma=22',
+      ...titleBlock(title, sub),
+    ];
+  },
+
+  /** Tighter, higher-contrast gradient with a spiral sweep. */
+  spiral: (title, sub, v) => {
+    const pairs = [
+      ['#111a2b', '#f5a524'],
+      ['#1a1030', '#3dd9a4'],
+      ['#2a0f18', '#ff5d73'],
+    ];
+    const [a, b] = at(pairs, v);
+    return [
+      `gradients=size=1280x720:rate=30:duration=%D:c0=${a}:c1=${b}:n=2:type=spiral:speed=0.09`,
+      'gblur=sigma=4',
+      ...titleBlock(title, sub),
+    ];
+  },
+
+  /** Conway's life, scaled up hard so the cells read as pixels. */
+  life: (title, sub, v) => {
+    const skins = [
+      { life: '#48d1a0', death: '#0d1b2a', ratio: 0.22 },
+      { life: '#f5a524', death: '#1a1220', ratio: 0.3 },
+      { life: '#7aa2f7', death: '#101426', ratio: 0.18 },
+    ];
+    const skin = at(skins, v);
+    return [
+      `life=size=360x200:rate=30:mold=14:life_color=${skin.life}:death_color=${skin.death}` +
+        `:ratio=${skin.ratio}:random_seed=${1000 + v * 37}`,
+      'scale=1280:720:flags=neighbor',
+      ...titleBlock(title, sub),
+    ];
+  },
+
+  /** Sierpinski, alternating between the carpet and the triangle. */
+  carpet: (title, sub, v) => [
+    `sierpinski=size=1280x720:rate=30:type=${v % 2 ? 'triangle' : 'carpet'}:seed=${500 + v * 13}:jump=${100 + v * 20}`,
+    at(DUOTONE, v),
+    'eq=brightness=0.04:contrast=1.15',
+    ...titleBlock(title, sub),
+  ],
+
+  /** One-dimensional cellular automata, scrolling upward. */
+  auto: (title, sub, v) => {
+    const rules = [110, 30, 90, 150, 54];
+    return [
+      `cellauto=size=1280x720:rate=30:rule=${at(rules, v)}:scroll=1` +
+        `:random_fill_ratio=0.1${v % 7}:random_seed=${200 + v * 29}`,
+      at(DUOTONE, v + 1),
+      ...titleBlock(title, sub),
+    ];
+  },
+
+  /** Test pattern pushed through a heavy grade until it reads as footage. */
+  grade: (title, sub, v) => [
+    'testsrc2=size=1280x720:rate=30:duration=%D',
+    'boxblur=22:2',
+    at(DUOTONE, v + 2),
+    `hue=h='${(v * 53) % 360}+t*10':s=1.25`,
+    'eq=saturation=1.2:brightness=-0.04:contrast=1.1',
+    'vignette=PI/4',
+    ...titleBlock(title, sub),
+  ],
+
+  /** A news bulletin: flat backdrop, lower third, scrolling strap. */
+  bulletin: (title, sub, v) => {
+    const beds = [
+      { bg: '#101826', band: '#c8102e' },
+      { bg: '#0d1f1a', band: '#f5a524' },
+    ];
+    const bed = at(beds, v);
+    return [
+      `color=c=${bed.bg}:size=1280x720:rate=30:duration=%D`,
+      `drawbox=x=0:y=ih-104:w=iw:h=104:color=${bed.band}@0.92:t=fill`,
+      `drawbox=x=0:y=ih-108:w=iw:h=4:[email protected]:t=fill`,
+      line(title, { size: 50, y: 'h*0.36', from: 0.4, hold: 5 }),
+      line(sub, { size: 28, y: 'h*0.48', from: 1.0, hold: 4.4 }),
+      ticker(sub),
+    ];
+  },
+};
+
+/** Audio beds, so the clips are not silent. */
+export const TONES = {
+  chime: 'sine=frequency=523.25:beep_factor=4:duration=%D,volume=0.12',
+  hum: 'sine=frequency=110:duration=%D,volume=0.08',
+  pulse: 'sine=frequency=330:beep_factor=2:duration=%D,volume=0.10',
+  quiet: 'anullsrc=channel_layout=stereo:sample_rate=44100:duration=%D',
+};
diff --git a/server/auth.js b/server/auth.js
new file mode 100644
index 0000000..8ff8074
--- /dev/null
+++ b/server/auth.js
@@ -0,0 +1,61 @@
+import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
+import { get, run } from './db.js';
+
+const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
+const SESSION_DAYS = 30;
+export const COOKIE = 'metube_session';
+
+export function hashPassword(password) {
+  const salt = randomBytes(16).toString('hex');
+  const key = scryptSync(password, salt, SCRYPT.keylen, SCRYPT).toString('hex');
+  return `scrypt$${salt}$${key}`;
+}
+
+export function verifyPassword(password, stored) {
+  const [scheme, salt, key] = String(stored).split('$');
+  if (scheme !== 'scrypt' || !salt || !key) return false;
+  const expected = Buffer.from(key, 'hex');
+  const actual = scryptSync(password, salt, expected.length, SCRYPT);
+  return expected.length === actual.length && timingSafeEqual(expected, actual);
+}
+
+export function createSession(userId) {
+  const token = randomBytes(32).toString('base64url');
+  run(
+    `INSERT INTO sessions (token, user_id, expires_at)
+     VALUES (?, ?, datetime('now', '+${SESSION_DAYS} days'))`,
+    token, userId
+  );
+  return token;
+}
+
+export function destroySession(token) {
+  if (token) run('DELETE FROM sessions WHERE token = ?', token);
+}
+
+export function userForToken(token) {
+  if (!token) return null;
+  return get(
+    `SELECT u.* FROM sessions s
+       JOIN users u ON u.id = s.user_id
+      WHERE s.token = ? AND s.expires_at > datetime('now')`,
+    token
+  ) ?? null;
+}
+
+export function parseCookies(header = '') {
+  const out = {};
+  for (const part of header.split(';')) {
+    const i = part.indexOf('=');
+    if (i < 0) continue;
+    out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
+  }
+  return out;
+}
+
+export function sessionCookie(token) {
+  const maxAge = SESSION_DAYS * 24 * 60 * 60;
+  return `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
+}
+
+export const clearCookie = () => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
diff --git a/server/db.js b/server/db.js
new file mode 100644
index 0000000..3d7da65
--- /dev/null
+++ b/server/db.js
@@ -0,0 +1,114 @@
+import { DatabaseSync } from 'node:sqlite';
+import { mkdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { ROOT } from './paths.js';
+
+const DATA_DIR = join(ROOT, 'data');
+mkdirSync(DATA_DIR, { recursive: true });
+
+export const db = new DatabaseSync(join(DATA_DIR, 'metube.db'));
+
+db.exec('PRAGMA journal_mode = WAL');
+db.exec('PRAGMA foreign_keys = ON');
+
+db.exec(`
+CREATE TABLE IF NOT EXISTS users (
+  id            INTEGER PRIMARY KEY AUTOINCREMENT,
+  username      TEXT NOT NULL UNIQUE COLLATE NOCASE,
+  email         TEXT NOT NULL UNIQUE COLLATE NOCASE,
+  password_hash TEXT NOT NULL,
+  display_name  TEXT NOT NULL,
+  bio           TEXT NOT NULL DEFAULT '',
+  avatar_hue    INTEGER NOT NULL DEFAULT 210,
+  created_at    TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+  token      TEXT PRIMARY KEY,
+  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  created_at TEXT NOT NULL DEFAULT (datetime('now')),
+  expires_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
+
+CREATE TABLE IF NOT EXISTS videos (
+  id          INTEGER PRIMARY KEY AUTOINCREMENT,
+  user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  title       TEXT NOT NULL,
+  description TEXT NOT NULL DEFAULT '',
+  category    TEXT NOT NULL DEFAULT 'General',
+  src         TEXT NOT NULL,
+  thumb       TEXT NOT NULL DEFAULT '',
+  duration    REAL NOT NULL DEFAULT 0,
+  views       INTEGER NOT NULL DEFAULT 0,
+  created_at  TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS idx_videos_user ON videos(user_id);
+CREATE INDEX IF NOT EXISTS idx_videos_created ON videos(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_videos_category ON videos(category);
+
+CREATE TABLE IF NOT EXISTS video_likes (
+  video_id   INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  value      INTEGER NOT NULL,
+  created_at TEXT NOT NULL DEFAULT (datetime('now')),
+  PRIMARY KEY (video_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS comments (
+  id         INTEGER PRIMARY KEY AUTOINCREMENT,
+  video_id   INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  parent_id  INTEGER REFERENCES comments(id) ON DELETE CASCADE,
+  body       TEXT NOT NULL,
+  created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS idx_comments_video ON comments(video_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
+
+CREATE TABLE IF NOT EXISTS comment_likes (
+  comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  value      INTEGER NOT NULL,
+  PRIMARY KEY (comment_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS subscriptions (
+  channel_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  subscriber_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  created_at    TEXT NOT NULL DEFAULT (datetime('now')),
+  PRIMARY KEY (channel_id, subscriber_id)
+);
+CREATE INDEX IF NOT EXISTS idx_subs_subscriber ON subscriptions(subscriber_id);
+
+CREATE TABLE IF NOT EXISTS watch_history (
+  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  video_id   INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+  watched_at TEXT NOT NULL DEFAULT (datetime('now')),
+  PRIMARY KEY (user_id, video_id)
+);
+
+CREATE TABLE IF NOT EXISTS watch_later (
+  user_id  INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+  added_at TEXT NOT NULL DEFAULT (datetime('now')),
+  PRIMARY KEY (user_id, video_id)
+);
+`);
+
+/** Run fn inside a transaction, rolling back on throw. */
+export function tx(fn) {
+  db.exec('BEGIN');
+  try {
+    const out = fn();
+    db.exec('COMMIT');
+    return out;
+  } catch (err) {
+    db.exec('ROLLBACK');
+    throw err;
+  }
+}
+
+export const get = (sql, ...params) => db.prepare(sql).get(...params);
+export const all = (sql, ...params) => db.prepare(sql).all(...params);
+export const run = (sql, ...params) => db.prepare(sql).run(...params);
diff --git a/server/http.js b/server/http.js
new file mode 100644
index 0000000..fc1df0e
--- /dev/null
+++ b/server/http.js
@@ -0,0 +1,62 @@
+/** Small request/response helpers shared by the router. */
+
+export class HttpError extends Error {
+  constructor(status, message) {
+    super(message);
+    this.status = status;
+  }
+}
+
+export const bad = (msg) => { throw new HttpError(400, msg); };
+export const unauthorized = (msg = 'You need to sign in to do that.') => { throw new HttpError(401, msg); };
+export const forbidden = (msg = 'You are not allowed to do that.') => { throw new HttpError(403, msg); };
+export const notFound = (msg = 'Not found.') => { throw new HttpError(404, msg); };
+
+export function sendJson(res, status, payload, headers = {}) {
+  const body = Buffer.from(JSON.stringify(payload));
+  res.writeHead(status, {
+    'Content-Type': 'application/json; charset=utf-8',
+    'Content-Length': body.length,
+    'Cache-Control': 'no-store',
+    ...headers,
+  });
+  res.end(body);
+}
+
+export async function readBody(req, limit = 1024 * 1024) {
+  const chunks = [];
+  let size = 0;
+  for await (const chunk of req) {
+    size += chunk.length;
+    if (size > limit) throw new HttpError(413, 'Request body is too large.');
+    chunks.push(chunk);
+  }
+  return Buffer.concat(chunks);
+}
+
+export async function readJson(req) {
+  const raw = await readBody(req);
+  if (!raw.length) return {};
+  try {
+    const parsed = JSON.parse(raw.toString('utf8'));
+    if (parsed === null || typeof parsed !== 'object') bad('Expected a JSON object.');
+    return parsed;
+  } catch (err) {
+    if (err instanceof HttpError) throw err;
+    throw new HttpError(400, 'Request body was not valid JSON.');
+  }
+}
+
+/** Trim + require a string field, with a max length. */
+export function field(obj, name, { max = 5000, min = 1, label = name } = {}) {
+  const value = typeof obj[name] === 'string' ? obj[name].trim() : '';
+  if (value.length < min) bad(`${label} is required.`);
+  if (value.length > max) bad(`${label} must be ${max} characters or fewer.`);
+  return value;
+}
+
+export function intParam(value, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
+  const n = Number.parseInt(value, 10);
+  if (!Number.isFinite(n)) return fallback;
+  return Math.min(max, Math.max(min, n));
+}
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000..34f374c
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,97 @@
+import { createServer } from 'node:http';
+import { mkdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { routes } from './routes.js';
+import { HttpError, sendJson } from './http.js';
+import { parseCookies, userForToken, COOKIE } from './auth.js';
+import { sendFile, safeJoin } from './static.js';
+import { PUBLIC_DIR, MEDIA_DIR, VIDEO_DIR, THUMB_DIR } from './paths.js';
+
+for (const dir of [VIDEO_DIR, THUMB_DIR]) mkdirSync(dir, { recursive: true });
+
+const PORT = Number(process.env.PORT) || 3000;
+const HOST = process.env.HOST || '127.0.0.1';
+
+/** Compile "GET /api/videos/:id" into a matcher. */
+const table = Object.entries(routes).map(([key, handler]) => {
+  const [method, pattern] = key.split(' ');
+  const names = [];
+  const source = pattern
+    .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+    .replace(/:(\w+)/g, (_, name) => { names.push(name); return '([^/]+)'; });
+  return { method, regex: new RegExp(`^${source}$`), names, handler };
+});
+
+function match(method, pathname) {
+  let pathExists = false;
+  for (const route of table) {
+    const m = route.regex.exec(pathname);
+    if (!m) continue;
+    pathExists = true;
+    if (route.method !== method) continue;
+    const params = {};
+    route.names.forEach((name, i) => { params[name] = decodeURIComponent(m[i + 1]); });
+    return { handler: route.handler, params };
+  }
+  return pathExists ? { methodMismatch: true } : null;
+}
+
+const server = createServer(async (req, res) => {
+  const started = Date.now();
+  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
+  const pathname = url.pathname.replace(/\/+$/, '') || '/';
+
+  res.on('finish', () => {
+    if (process.env.QUIET) return;
+    const ms = Date.now() - started;
+    console.log(`${req.method} ${url.pathname} ${res.statusCode} ${ms}ms`);
+  });
+
+  try {
+    // Media files (videos + thumbnails), served with Range support.
+    if (pathname.startsWith('/media/')) {
+      const file = safeJoin(MEDIA_DIR, pathname.slice('/media'.length));
+      if (file && (await sendFile(req, res, file, { cacheControl: 'public, max-age=86400' }))) return;
+      return sendJson(res, 404, { error: 'Media not found.' });
+    }
+
+    if (pathname.startsWith('/api/')) {
+      const found = match(req.method, pathname);
+      if (!found) return sendJson(res, 404, { error: `No API route for ${pathname}` });
+      if (found.methodMismatch) return sendJson(res, 405, { error: `${req.method} is not allowed here.` });
+
+      const token = parseCookies(req.headers.cookie || '')[COOKIE];
+      const user = userForToken(token);
+      const ctx = {
+        req, res, url, token, user,
+        params: found.params,
+        query: url.searchParams,
+        requireUser() {
+          if (!user) throw new HttpError(401, 'You need to sign in to do that.');
+          return user;
+        },
+      };
+      await found.handler(ctx);
+      if (!res.writableEnded) sendJson(res, 204, {});
+      return;
+    }
+
+    // Static assets, then the SPA shell for every other path.
+    const asset = safeJoin(PUBLIC_DIR, pathname);
+    if (asset && pathname !== '/' && (await sendFile(req, res, asset, { cacheControl: 'no-cache' }))) return;
+    if (await sendFile(req, res, join(PUBLIC_DIR, 'index.html'), { cacheControl: 'no-cache' })) return;
+    sendJson(res, 404, { error: 'Not found.' });
+  } catch (err) {
+    if (res.writableEnded) return;
+    const status = err instanceof HttpError ? err.status : 500;
+    if (status >= 500) console.error(`error on ${req.method} ${pathname}:`, err);
+    sendJson(res, status, { error: status >= 500 ? 'Something went wrong on our end.' : err.message });
+  }
+});
+
+server.headersTimeout = 10 * 60 * 1000;
+server.requestTimeout = 30 * 60 * 1000; // long enough for a big upload
+
+server.listen(PORT, HOST, () => {
+  console.log(`MeTube running at http://${HOST}:${PORT}`);
+});
diff --git a/server/model.js b/server/model.js
new file mode 100644
index 0000000..f0bb6ab
--- /dev/null
+++ b/server/model.js
@@ -0,0 +1,238 @@
+import { all, get, run } from './db.js';
+
+export const CATEGORIES = [
+  'All', 'Music', 'Gaming', 'Learning', 'Tech', 'Cooking',
+  'Travel', 'Fitness', 'Comedy', 'News', 'Nature', 'General',
+];
+
+export function publicUser(row, viewerId = null) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    username: row.username,
+    displayName: row.display_name,
+    bio: row.bio ?? '',
+    avatarHue: row.avatar_hue,
+    createdAt: row.created_at,
+    subscriberCount: countSubscribers(row.id),
+    videoCount: get('SELECT COUNT(*) AS n FROM videos WHERE user_id = ?', row.id).n,
+    isSubscribed: viewerId ? isSubscribed(row.id, viewerId) : false,
+    isSelf: viewerId === row.id,
+  };
+}
+
+export const countSubscribers = (channelId) =>
+  get('SELECT COUNT(*) AS n FROM subscriptions WHERE channel_id = ?', channelId).n;
+
+export const isSubscribed = (channelId, subscriberId) =>
+  !!get('SELECT 1 AS x FROM subscriptions WHERE channel_id = ? AND subscriber_id = ?', channelId, subscriberId);
+
+const VIDEO_SELECT = `
+  SELECT v.*,
+         u.username, u.display_name, u.avatar_hue,
+         (SELECT COUNT(*) FROM video_likes l WHERE l.video_id = v.id AND l.value = 1)  AS likes,
+         (SELECT COUNT(*) FROM video_likes l WHERE l.video_id = v.id AND l.value = -1) AS dislikes,
+         (SELECT COUNT(*) FROM comments c WHERE c.video_id = v.id)                     AS comment_count,
+         (SELECT COUNT(*) FROM subscriptions s WHERE s.channel_id = v.user_id)         AS subscriber_count,
+         (SELECT l.value FROM video_likes l WHERE l.video_id = v.id AND l.user_id = ?) AS my_vote,
+         (SELECT 1 FROM subscriptions s WHERE s.channel_id = v.user_id AND s.subscriber_id = ?) AS subscribed,
+         (SELECT 1 FROM watch_later w WHERE w.video_id = v.id AND w.user_id = ?)       AS saved
+    FROM videos v
+    JOIN users u ON u.id = v.user_id`;
+
+export function shapeVideo(row, viewerId = null) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    title: row.title,
+    description: row.description,
+    category: row.category,
+    src: row.src,
+    thumb: row.thumb,
+    duration: row.duration,
+    views: row.views,
+    createdAt: row.created_at,
+    likes: row.likes ?? 0,
+    dislikes: row.dislikes ?? 0,
+    commentCount: row.comment_count ?? 0,
+    myVote: row.my_vote ?? 0,
+    saved: !!row.saved,
+    isOwner: viewerId === row.user_id,
+    channel: {
+      id: row.user_id,
+      username: row.username,
+      displayName: row.display_name,
+      avatarHue: row.avatar_hue,
+      subscriberCount: row.subscriber_count ?? 0,
+      isSubscribed: !!row.subscribed,
+      isSelf: viewerId === row.user_id,
+    },
+  };
+}
+
+export function findVideo(id, viewerId = null) {
+  const v = viewerId ?? 0;
+  return shapeVideo(get(`${VIDEO_SELECT} WHERE v.id = ?`, v, v, v, id), viewerId);
+}
+
+const SORTS = {
+  new: 'v.created_at DESC, v.id DESC',
+  popular: 'v.views DESC, v.created_at DESC',
+  liked: 'likes DESC, v.views DESC',
+  oldest: 'v.created_at ASC',
+};
+
+export function listVideos({
+  viewerId = null, channelId = null, category = null, q = null,
+  sort = 'new', limit = 24, offset = 0, subscribedBy = null, excludeId = null,
+} = {}) {
+  const v = viewerId ?? 0;
+  const where = [];
+  const params = [v, v, v];
+
+  if (channelId) { where.push('v.user_id = ?'); params.push(channelId); }
+  if (category && category !== 'All') { where.push('v.category = ?'); params.push(category); }
+  if (excludeId) { where.push('v.id <> ?'); params.push(excludeId); }
+  if (subscribedBy) {
+    where.push('v.user_id IN (SELECT channel_id FROM subscriptions WHERE subscriber_id = ?)');
+    params.push(subscribedBy);
+  }
+  if (q) {
+    where.push('(v.title LIKE ? OR v.description LIKE ? OR u.display_name LIKE ? OR u.username LIKE ?)');
+    const like = `%${q.replace(/[%_]/g, (m) => `\\${m}`)}%`;
+    params.push(like, like, like, like);
+  }
+
+  const sql = `${VIDEO_SELECT}
+    ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
+    ORDER BY ${SORTS[sort] ?? SORTS.new}
+    LIMIT ? OFFSET ?`;
+  params.push(limit, offset);
+  return all(sql, ...params).map((row) => shapeVideo(row, viewerId));
+}
+
+export function countVideos({ channelId = null, category = null, q = null, subscribedBy = null } = {}) {
+  const where = [];
+  const params = [];
+  if (channelId) { where.push('v.user_id = ?'); params.push(channelId); }
+  if (category && category !== 'All') { where.push('v.category = ?'); params.push(category); }
+  if (subscribedBy) {
+    where.push('v.user_id IN (SELECT channel_id FROM subscriptions WHERE subscriber_id = ?)');
+    params.push(subscribedBy);
+  }
+  if (q) {
+    where.push('(v.title LIKE ? OR v.description LIKE ? OR u.display_name LIKE ? OR u.username LIKE ?)');
+    const like = `%${q.replace(/[%_]/g, (m) => `\\${m}`)}%`;
+    params.push(like, like, like, like);
+  }
+  const sql = `SELECT COUNT(*) AS n FROM videos v JOIN users u ON u.id = v.user_id
+    ${where.length ? `WHERE ${where.join(' AND ')}` : ''}`;
+  return get(sql, ...params).n;
+}
+
+/** Videos the viewer liked, newest like first. */
+export function likedVideos(viewerId, limit = 60) {
+  return all(
+    `${VIDEO_SELECT}
+      JOIN video_likes ml ON ml.video_id = v.id AND ml.user_id = ? AND ml.value = 1
+     ORDER BY ml.created_at DESC LIMIT ?`,
+    viewerId, viewerId, viewerId, viewerId, limit
+  ).map((row) => shapeVideo(row, viewerId));
+}
+
+export function historyVideos(viewerId, limit = 60) {
+  return all(
+    `${VIDEO_SELECT}
+      JOIN watch_history h ON h.video_id = v.id AND h.user_id = ?
+     ORDER BY h.watched_at DESC LIMIT ?`,
+    viewerId, viewerId, viewerId, viewerId, limit
+  ).map((row) => shapeVideo(row, viewerId));
+}
+
+export function watchLaterVideos(viewerId, limit = 60) {
+  return all(
+    `${VIDEO_SELECT}
+      JOIN watch_later w ON w.video_id = v.id AND w.user_id = ?
+     ORDER BY w.added_at DESC LIMIT ?`,
+    viewerId, viewerId, viewerId, viewerId, limit
+  ).map((row) => shapeVideo(row, viewerId));
+}
+
+/** Related videos: same category first, then anything else recent. */
+export function relatedVideos(video, viewerId, limit = 12) {
+  const same = listVideos({ viewerId, category: video.category, excludeId: video.id, sort: 'popular', limit });
+  if (same.length >= limit) return same;
+  const seen = new Set(same.map((x) => x.id));
+  const filler = listVideos({ viewerId, excludeId: video.id, sort: 'popular', limit: limit * 2 })
+    .filter((x) => !seen.has(x.id))
+    .slice(0, limit - same.length);
+  return [...same, ...filler];
+}
+
+const COMMENT_SELECT = `
+  SELECT c.*, u.username, u.display_name, u.avatar_hue,
+         (SELECT COUNT(*) FROM comment_likes cl WHERE cl.comment_id = c.id AND cl.value = 1) AS likes,
+         (SELECT cl.value FROM comment_likes cl WHERE cl.comment_id = c.id AND cl.user_id = ?) AS my_vote,
+         (SELECT COUNT(*) FROM comments r WHERE r.parent_id = c.id) AS reply_count
+    FROM comments c JOIN users u ON u.id = c.user_id`;
+
+const shapeComment = (row, viewerId, ownerId) => ({
+  id: row.id,
+  videoId: row.video_id,
+  parentId: row.parent_id,
+  body: row.body,
+  createdAt: row.created_at,
+  likes: row.likes ?? 0,
+  myVote: row.my_vote ?? 0,
+  replyCount: row.reply_count ?? 0,
+  canDelete: viewerId != null && (viewerId === row.user_id || viewerId === ownerId),
+  isCreator: row.user_id === ownerId,
+  author: {
+    id: row.user_id,
+    username: row.username,
+    displayName: row.display_name,
+    avatarHue: row.avatar_hue,
+  },
+  replies: [],
+});
+
+/** Top-level comments for a video with their replies nested one level deep. */
+export function videoComments(videoId, viewerId, sort = 'new') {
+  const v = viewerId ?? 0;
+  const ownerId = get('SELECT user_id FROM videos WHERE id = ?', videoId)?.user_id ?? null;
+  const order = sort === 'top' ? 'likes DESC, c.created_at DESC' : 'c.created_at DESC';
+  const tops = all(
+    `${COMMENT_SELECT} WHERE c.video_id = ? AND c.parent_id IS NULL ORDER BY ${order}`,
+    v, videoId
+  ).map((row) => shapeComment(row, viewerId, ownerId));
+
+  if (!tops.length) return tops;
+  const byId = new Map(tops.map((c) => [c.id, c]));
+  const replies = all(
+    `${COMMENT_SELECT} WHERE c.video_id = ? AND c.parent_id IS NOT NULL ORDER BY c.created_at ASC`,
+    v, videoId
+  );
+  for (const row of replies) {
+    byId.get(row.parent_id)?.replies.push(shapeComment(row, viewerId, ownerId));
+  }
+  return tops;
+}
+
+export function findComment(id, viewerId) {
+  const row = get(`${COMMENT_SELECT} WHERE c.id = ?`, viewerId ?? 0, id);
+  if (!row) return null;
+  const ownerId = get('SELECT user_id FROM videos WHERE id = ?', row.video_id)?.user_id ?? null;
+  return shapeComment(row, viewerId, ownerId);
+}
+
+/** Record a view, deduped per user per session-ish window by the caller. */
+export function recordView(videoId, viewerId) {
+  run('UPDATE videos SET views = views + 1 WHERE id = ?', videoId);
+  if (viewerId) {
+    run(
+      `INSERT INTO watch_history (user_id, video_id) VALUES (?, ?)
+       ON CONFLICT(user_id, video_id) DO UPDATE SET watched_at = datetime('now')`,
+      viewerId, videoId
+    );
+  }
+}
diff --git a/server/multipart.js b/server/multipart.js
new file mode 100644
index 0000000..bd0429e
--- /dev/null
+++ b/server/multipart.js
@@ -0,0 +1,148 @@
+import { createWriteStream } from 'node:fs';
+import { unlink } from 'node:fs/promises';
+import { randomBytes } from 'node:crypto';
+import { join, extname } from 'node:path';
+import { HttpError } from './http.js';
+
+const DASH_DASH = Buffer.from('--');
+const CRLF = Buffer.from('\r\n');
+const HEADER_END = Buffer.from('\r\n\r\n');
+
+/**
+ * Streaming multipart/form-data parser.
+ *
+ * Text fields are collected in memory; any part with a filename is streamed
+ * straight to `dir` so a large upload never has to be buffered whole.
+ * Returns { fields, files } where each file is
+ * { field, filename, contentType, path, storedName, size }.
+ */
+export async function parseMultipart(req, { dir, maxFileBytes = 512 * 1024 * 1024, maxFieldBytes = 64 * 1024 }) {
+  const type = req.headers['content-type'] || '';
+  const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(type);
+  if (!/^multipart\/form-data/i.test(type) || !match) {
+    throw new HttpError(400, 'Expected a multipart/form-data upload.');
+  }
+  const boundary = Buffer.from(`--${(match[1] || match[2]).trim()}`);
+
+  const fields = Object.create(null);
+  const files = [];
+  const cleanup = async () => {
+    await Promise.all(files.map((f) => unlink(f.path).catch(() => {})));
+  };
+
+  let buf = Buffer.alloc(0);
+  let state = 'preamble'; // preamble -> headers -> body
+  let part = null;
+  let finished = false;
+
+  const closePart = async (trailing) => {
+    if (!part) return;
+    if (part.file) {
+      part.file.size += trailing.length;
+      if (part.file.size > maxFileBytes) throw new HttpError(413, 'That file is too large.');
+      await new Promise((resolve, reject) => {
+        part.stream.end(trailing, (err) => (err ? reject(err) : resolve()));
+      });
+    } else {
+      part.chunks.push(trailing);
+      const value = Buffer.concat(part.chunks).toString('utf8');
+      if (value.length > maxFieldBytes) throw new HttpError(413, 'A form field was too large.');
+      fields[part.name] = value;
+    }
+    part = null;
+  };
+
+  try {
+    for await (const chunk of req) {
+      buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
+
+      // Keep consuming complete structures out of the buffer.
+      for (;;) {
+        if (finished) break;
+
+        if (state === 'preamble' || state === 'headers') {
+          if (state === 'preamble') {
+            const at = buf.indexOf(boundary);
+            if (at < 0) break;
+            buf = buf.subarray(at + boundary.length);
+            if (buf.length < 2) break;
+            if (buf.subarray(0, 2).equals(DASH_DASH)) { finished = true; break; }
+            if (!buf.subarray(0, 2).equals(CRLF)) break;
+            buf = buf.subarray(2);
+            state = 'headers';
+          }
+          const end = buf.indexOf(HEADER_END);
+          if (end < 0) break;
+          const headerText = buf.subarray(0, end).toString('utf8');
+          buf = buf.subarray(end + HEADER_END.length);
+          part = startPart(headerText, dir);
+          if (part.file) files.push(part.file);
+          state = 'body';
+          continue;
+        }
+
+        // state === 'body': flush everything that cannot contain the boundary.
+        const at = buf.indexOf(boundary);
+        if (at < 0) {
+          const keep = boundary.length + 4; // room for a split boundary + CRLF
+          if (buf.length > keep) {
+            await writeChunk(part, buf.subarray(0, buf.length - keep), maxFileBytes);
+            buf = buf.subarray(buf.length - keep);
+          }
+          break;
+        }
+        // A body ends with the CRLF that precedes its boundary line.
+        const bodyEnd = at >= 2 && buf.subarray(at - 2, at).equals(CRLF) ? at - 2 : at;
+        await closePart(buf.subarray(0, bodyEnd));
+        buf = buf.subarray(at + boundary.length);
+        if (buf.length >= 2 && buf.subarray(0, 2).equals(DASH_DASH)) { finished = true; break; }
+        state = 'preamble';
+        buf = Buffer.concat([boundary, buf]); // re-scan this same boundary as a delimiter
+      }
+    }
+
+    if (part) await closePart(Buffer.alloc(0));
+    return { fields, files, cleanup };
+  } catch (err) {
+    if (part?.stream) part.stream.destroy();
+    await cleanup();
+    throw err;
+  }
+}
+
+function startPart(headerText, dir) {
+  const headers = Object.create(null);
+  for (const line of headerText.split('\r\n')) {
+    const i = line.indexOf(':');
+    if (i > 0) headers[line.slice(0, i).toLowerCase().trim()] = line.slice(i + 1).trim();
+  }
+  const disposition = headers['content-disposition'] || '';
+  const name = /name="([^"]*)"/i.exec(disposition)?.[1] ?? '';
+  const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
+
+  if (filename === undefined || filename === '') {
+    return { name, chunks: [], file: null };
+  }
+  const ext = extname(filename).slice(0, 10).replace(/[^A-Za-z0-9.]/g, '') || '.bin';
+  const storedName = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}${ext}`;
+  const path = join(dir, storedName);
+  const file = {
+    field: name,
+    filename,
+    contentType: headers['content-type'] || 'application/octet-stream',
+    path,
+    storedName,
+    size: 0,
+  };
+  return { name, file, stream: createWriteStream(path) };
+}
+
+async function writeChunk(part, data, maxFileBytes) {
+  if (!part) return;
+  if (!part.file) { part.chunks.push(data); return; }
+  part.file.size += data.length;
+  if (part.file.size > maxFileBytes) throw new HttpError(413, 'That file is too large.');
+  if (!part.stream.write(data)) {
+    await new Promise((resolve) => part.stream.once('drain', resolve));
+  }
+}
diff --git a/server/paths.js b/server/paths.js
new file mode 100644
index 0000000..791347f
--- /dev/null
+++ b/server/paths.js
@@ -0,0 +1,8 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+export const PUBLIC_DIR = join(ROOT, 'public');
+export const MEDIA_DIR = join(ROOT, 'data', 'media');
+export const VIDEO_DIR = join(MEDIA_DIR, 'videos');
+export const THUMB_DIR = join(MEDIA_DIR, 'thumbs');
diff --git a/server/routes.js b/server/routes.js
new file mode 100644
index 0000000..266ca94
--- /dev/null
+++ b/server/routes.js
@@ -0,0 +1,389 @@
+import { unlink } from 'node:fs/promises';
+import { join } from 'node:path';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { get, run, all } from './db.js';
+import {
+  CATEGORIES, publicUser, findVideo, listVideos, countVideos, likedVideos, historyVideos,
+  watchLaterVideos, relatedVideos, videoComments, findComment, recordView, isSubscribed,
+} from './model.js';
+import {
+  hashPassword, verifyPassword, createSession, destroySession, sessionCookie, clearCookie,
+} from './auth.js';
+import { HttpError, bad, notFound, forbidden, sendJson, readJson, field, intParam } from './http.js';
+import { parseMultipart } from './multipart.js';
+import { VIDEO_DIR, THUMB_DIR } from './paths.js';
+
+const execFileAsync = promisify(execFile);
+const VIDEO_TYPES = /^video\/(mp4|webm|quicktime|x-m4v|ogg)$/i;
+const MAX_UPLOAD = 512 * 1024 * 1024;
+
+/* ------------------------------------------------------------------ auth -- */
+
+const authRoutes = {
+  'POST /api/auth/signup': async (ctx) => {
+    const body = await readJson(ctx.req);
+    const username = field(body, 'username', { max: 24, label: 'Username' }).replace(/^@/, '');
+    const email = field(body, 'email', { max: 160, label: 'Email' });
+    const password = field(body, 'password', { min: 8, max: 200, label: 'Password' });
+    const displayName = (typeof body.displayName === 'string' && body.displayName.trim()) || username;
+
+    if (!/^[a-zA-Z0-9_.]{3,24}$/.test(username)) {
+      bad('Username must be 3–24 characters: letters, numbers, underscore or dot.');
+    }
+    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) bad('Enter a valid email address.');
+    if (get('SELECT 1 AS x FROM users WHERE username = ?', username)) bad('That username is already taken.');
+    if (get('SELECT 1 AS x FROM users WHERE email = ?', email)) bad('An account already uses that email.');
+
+    const hue = Math.floor(Math.random() * 360);
+    const { lastInsertRowid } = run(
+      `INSERT INTO users (username, email, password_hash, display_name, avatar_hue)
+       VALUES (?, ?, ?, ?, ?)`,
+      username, email, hashPassword(password), displayName.slice(0, 40), hue
+    );
+    const token = createSession(Number(lastInsertRowid));
+    const user = get('SELECT * FROM users WHERE id = ?', Number(lastInsertRowid));
+    sendJson(ctx.res, 201, { user: publicUser(user, user.id) }, { 'Set-Cookie': sessionCookie(token) });
+  },
+
+  'POST /api/auth/login': async (ctx) => {
+    const body = await readJson(ctx.req);
+    const identifier = field(body, 'identifier', { max: 160, label: 'Username or email' }).replace(/^@/, '');
+    const password = field(body, 'password', { max: 200, label: 'Password' });
+
+    const user = get('SELECT * FROM users WHERE username = ? OR email = ?', identifier, identifier);
+    if (!user || !verifyPassword(password, user.password_hash)) {
+      throw new HttpError(401, 'That username/email and password combination did not match.');
+    }
+    const token = createSession(user.id);
+    sendJson(ctx.res, 200, { user: publicUser(user, user.id) }, { 'Set-Cookie': sessionCookie(token) });
+  },
+
+  'POST /api/auth/logout': (ctx) => {
+    destroySession(ctx.token);
+    sendJson(ctx.res, 200, { ok: true }, { 'Set-Cookie': clearCookie() });
+  },
+
+  'GET /api/auth/me': (ctx) => {
+    sendJson(ctx.res, 200, { user: ctx.user ? publicUser(ctx.user, ctx.user.id) : null });
+  },
+
+  'PATCH /api/me': async (ctx) => {
+    const me = ctx.requireUser();
+    const body = await readJson(ctx.req);
+    if (typeof body.displayName === 'string') {
+      const displayName = field(body, 'displayName', { max: 40, label: 'Display name' });
+      run('UPDATE users SET display_name = ? WHERE id = ?', displayName, me.id);
+    }
+    if (typeof body.bio === 'string') {
+      run('UPDATE users SET bio = ? WHERE id = ?', body.bio.trim().slice(0, 600), me.id);
+    }
+    if (Number.isInteger(body.avatarHue)) {
+      run('UPDATE users SET avatar_hue = ? WHERE id = ?', ((body.avatarHue % 360) + 360) % 360, me.id);
+    }
+    const fresh = get('SELECT * FROM users WHERE id = ?', me.id);
+    sendJson(ctx.res, 200, { user: publicUser(fresh, me.id) });
+  },
+};
+
+/* ---------------------------------------------------------------- videos -- */
+
+const videoRoutes = {
+  'GET /api/categories': (ctx) => sendJson(ctx.res, 200, { categories: CATEGORIES }),
+
+  'GET /api/videos': (ctx) => {
+    const viewerId = ctx.user?.id ?? null;
+    const q = ctx.query.get('q')?.trim() || null;
+    const category = ctx.query.get('category') || null;
+    const sort = ctx.query.get('sort') || (q ? 'popular' : 'new');
+    const limit = intParam(ctx.query.get('limit'), 24, { min: 1, max: 60 });
+    const offset = intParam(ctx.query.get('offset'), 0);
+    const username = ctx.query.get('channel');
+    const channelId = username
+      ? get('SELECT id FROM users WHERE username = ?', username.replace(/^@/, ''))?.id ?? -1
+      : null;
+    const subscribedBy = ctx.query.get('feed') === 'subscriptions' ? ctx.requireUser().id : null;
+
+    const filters = { channelId, category, q, subscribedBy };
+    sendJson(ctx.res, 200, {
+      videos: listVideos({ ...filters, viewerId, sort, limit, offset }),
+      total: countVideos(filters),
+    });
+  },
+
+  'GET /api/videos/:id': (ctx) => {
+    const viewerId = ctx.user?.id ?? null;
+    const video = findVideo(ctx.params.id, viewerId);
+    if (!video) notFound('That video does not exist.');
+    sendJson(ctx.res, 200, { video, related: relatedVideos(video, viewerId) });
+  },
+
+  'POST /api/videos/:id/view': (ctx) => {
+    const video = findVideo(ctx.params.id, ctx.user?.id ?? null);
+    if (!video) notFound('That video does not exist.');
+    recordView(video.id, ctx.user?.id ?? null);
+    sendJson(ctx.res, 200, { views: video.views + 1 });
+  },
+
+  'POST /api/videos/:id/like': async (ctx) => {
+    const me = ctx.requireUser();
+    const body = await readJson(ctx.req);
+    const value = Number(body.value);
+    if (![1, -1, 0].includes(value)) bad('Vote must be 1, -1 or 0.');
+    const video = findVideo(ctx.params.id, me.id);
+    if (!video) notFound('That video does not exist.');
+
+    if (value === 0) {
+      run('DELETE FROM video_likes WHERE video_id = ? AND user_id = ?', video.id, me.id);
+    } else {
+      run(
+        `INSERT INTO video_likes (video_id, user_id, value) VALUES (?, ?, ?)
+         ON CONFLICT(video_id, user_id) DO UPDATE SET value = excluded.value, created_at = datetime('now')`,
+        video.id, me.id, value
+      );
+    }
+    const fresh = findVideo(video.id, me.id);
+    sendJson(ctx.res, 200, { likes: fresh.likes, dislikes: fresh.dislikes, myVote: fresh.myVote });
+  },
+
+  'POST /api/videos/:id/save': (ctx) => {
+    const me = ctx.requireUser();
+    const video = findVideo(ctx.params.id, me.id);
+    if (!video) notFound('That video does not exist.');
+    if (video.saved) {
+      run('DELETE FROM watch_later WHERE user_id = ? AND video_id = ?', me.id, video.id);
+      sendJson(ctx.res, 200, { saved: false });
+    } else {
+      run('INSERT OR IGNORE INTO watch_later (user_id, video_id) VALUES (?, ?)', me.id, video.id);
+      sendJson(ctx.res, 200, { saved: true });
+    }
+  },
+
+  'PATCH /api/videos/:id': async (ctx) => {
+    const me = ctx.requireUser();
+    const row = get('SELECT * FROM videos WHERE id = ?', ctx.params.id);
+    if (!row) notFound('That video does not exist.');
+    if (row.user_id !== me.id) forbidden('You can only edit your own videos.');
+    const body = await readJson(ctx.req);
+    if (typeof body.title === 'string') {
+      run('UPDATE videos SET title = ? WHERE id = ?', field(body, 'title', { max: 120, label: 'Title' }), row.id);
+    }
+    if (typeof body.description === 'string') {
+      run('UPDATE videos SET description = ? WHERE id = ?', body.description.trim().slice(0, 5000), row.id);
+    }
+    if (typeof body.category === 'string' && CATEGORIES.includes(body.category)) {
+      run('UPDATE videos SET category = ? WHERE id = ?', body.category, row.id);
+    }
+    sendJson(ctx.res, 200, { video: findVideo(row.id, me.id) });
+  },
+
+  'DELETE /api/videos/:id': async (ctx) => {
+    const me = ctx.requireUser();
+    const row = get('SELECT * FROM videos WHERE id = ?', ctx.params.id);
+    if (!row) notFound('That video does not exist.');
+    if (row.user_id !== me.id) forbidden('You can only delete your own videos.');
+    run('DELETE FROM videos WHERE id = ?', row.id);
+    for (const [dir, name] of [[VIDEO_DIR, row.src], [THUMB_DIR, row.thumb]]) {
+      const file = name?.split('/').pop();
+      if (file) await unlink(join(dir, file)).catch(() => {});
+    }
+    sendJson(ctx.res, 200, { ok: true });
+  },
+
+  'POST /api/videos': async (ctx) => {
+    const me = ctx.requireUser();
+    const { fields, files, cleanup } = await parseMultipart(ctx.req, {
+      dir: VIDEO_DIR,
+      maxFileBytes: MAX_UPLOAD,
+    });
+    try {
+      const video = files.find((f) => f.field === 'video');
+      if (!video) bad('Choose a video file to upload.');
+      if (!VIDEO_TYPES.test(video.contentType) && !/\.(mp4|webm|mov|m4v|ogg)$/i.test(video.filename)) {
+        bad('Unsupported format — upload an MP4, WebM, MOV or OGG file.');
+      }
+      if (!video.size) bad('That file was empty.');
+
+      const title = field(fields, 'title', { max: 120, label: 'Title' });
+      const description = (fields.description ?? '').trim().slice(0, 5000);
+      const category = CATEGORIES.includes(fields.category) && fields.category !== 'All'
+        ? fields.category
+        : 'General';
+
+      const src = `/media/videos/${video.storedName}`;
+      const duration = await probeDuration(video.path);
+      const thumb = await makeThumbnail(video.path, video.storedName, duration);
+
+      const { lastInsertRowid } = run(
+        `INSERT INTO videos (user_id, title, description, category, src, thumb, duration)
+         VALUES (?, ?, ?, ?, ?, ?, ?)`,
+        me.id, title, description, category, src, thumb, duration
+      );
+      sendJson(ctx.res, 201, { video: findVideo(Number(lastInsertRowid), me.id) });
+    } catch (err) {
+      await cleanup();
+      throw err;
+    }
+  },
+};
+
+/* -------------------------------------------------------------- comments -- */
+
+const commentRoutes = {
+  'GET /api/videos/:id/comments': (ctx) => {
+    if (!get('SELECT 1 AS x FROM videos WHERE id = ?', ctx.params.id)) notFound('That video does not exist.');
+    const sort = ctx.query.get('sort') === 'top' ? 'top' : 'new';
+    sendJson(ctx.res, 200, { comments: videoComments(Number(ctx.params.id), ctx.user?.id ?? null, sort) });
+  },
+
+  'POST /api/videos/:id/comments': async (ctx) => {
+    const me = ctx.requireUser();
+    if (!get('SELECT 1 AS x FROM videos WHERE id = ?', ctx.params.id)) notFound('That video does not exist.');
+    const body = await readJson(ctx.req);
+    const text = field(body, 'body', { max: 2000, label: 'Comment' });
+
+    let parentId = null;
+    if (body.parentId != null) {
+      const parent = get('SELECT * FROM comments WHERE id = ?', body.parentId);
+      if (!parent || parent.video_id !== Number(ctx.params.id)) bad('That comment no longer exists.');
+      parentId = parent.parent_id ?? parent.id; // keep replies one level deep
+    }
+    const { lastInsertRowid } = run(
+      'INSERT INTO comments (video_id, user_id, parent_id, body) VALUES (?, ?, ?, ?)',
+      Number(ctx.params.id), me.id, parentId, text
+    );
+    sendJson(ctx.res, 201, { comment: findComment(Number(lastInsertRowid), me.id) });
+  },
+
+  'POST /api/comments/:id/like': async (ctx) => {
+    const me = ctx.requireUser();
+    const body = await readJson(ctx.req);
+    const value = Number(body.value);
+    if (![1, 0].includes(value)) bad('Vote must be 1 or 0.');
+    if (!get('SELECT 1 AS x FROM comments WHERE id = ?', ctx.params.id)) notFound('That comment no longer exists.');
+    if (value === 0) {
+      run('DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?', ctx.params.id, me.id);
+    } else {
+      run(
+        `INSERT INTO comment_likes (comment_id, user_id, value) VALUES (?, ?, 1)
+         ON CONFLICT(comment_id, user_id) DO UPDATE SET value = 1`,
+        ctx.params.id, me.id
+      );
+    }
+    const fresh = findComment(Number(ctx.params.id), me.id);
+    sendJson(ctx.res, 200, { likes: fresh.likes, myVote: fresh.myVote });
+  },
+
+  'DELETE /api/comments/:id': (ctx) => {
+    const me = ctx.requireUser();
+    const row = get('SELECT * FROM comments WHERE id = ?', ctx.params.id);
+    if (!row) notFound('That comment no longer exists.');
+    const owner = get('SELECT user_id FROM videos WHERE id = ?', row.video_id)?.user_id;
+    if (row.user_id !== me.id && owner !== me.id) forbidden('You can only delete your own comments.');
+    run('DELETE FROM comments WHERE id = ?', row.id);
+    sendJson(ctx.res, 200, { ok: true });
+  },
+};
+
+/* -------------------------------------------------------------- channels -- */
+
+const channelRoutes = {
+  'GET /api/channels/:username': (ctx) => {
+    const viewerId = ctx.user?.id ?? null;
+    const username = String(ctx.params.username).replace(/^@/, '');
+    const row = get('SELECT * FROM users WHERE username = ?', username);
+    if (!row) notFound('That channel does not exist.');
+    const sort = ctx.query.get('sort') || 'new';
+    sendJson(ctx.res, 200, {
+      channel: publicUser(row, viewerId),
+      videos: listVideos({ viewerId, channelId: row.id, sort, limit: 60 }),
+      totalViews: get('SELECT COALESCE(SUM(views), 0) AS n FROM videos WHERE user_id = ?', row.id).n,
+    });
+  },
+
+  'POST /api/channels/:username/subscribe': (ctx) => {
+    const me = ctx.requireUser();
+    const username = String(ctx.params.username).replace(/^@/, '');
+    const channel = get('SELECT * FROM users WHERE username = ?', username);
+    if (!channel) notFound('That channel does not exist.');
+    if (channel.id === me.id) bad('You cannot subscribe to your own channel.');
+
+    const already = isSubscribed(channel.id, me.id);
+    if (already) {
+      run('DELETE FROM subscriptions WHERE channel_id = ? AND subscriber_id = ?', channel.id, me.id);
+    } else {
+      run('INSERT OR IGNORE INTO subscriptions (channel_id, subscriber_id) VALUES (?, ?)', channel.id, me.id);
+    }
+    sendJson(ctx.res, 200, {
+      isSubscribed: !already,
+      subscriberCount: get('SELECT COUNT(*) AS n FROM subscriptions WHERE channel_id = ?', channel.id).n,
+    });
+  },
+
+  'GET /api/subscriptions': (ctx) => {
+    const me = ctx.requireUser();
+    const rows = all(
+      `SELECT u.* FROM subscriptions s JOIN users u ON u.id = s.channel_id
+        WHERE s.subscriber_id = ? ORDER BY s.created_at DESC`,
+      me.id
+    );
+    sendJson(ctx.res, 200, { channels: rows.map((r) => publicUser(r, me.id)) });
+  },
+
+  'GET /api/channels': (ctx) => {
+    const viewerId = ctx.user?.id ?? null;
+    const rows = all(
+      `SELECT u.*, (SELECT COUNT(*) FROM subscriptions s WHERE s.channel_id = u.id) AS subs
+         FROM users u
+        WHERE (SELECT COUNT(*) FROM videos v WHERE v.user_id = u.id) > 0
+        ORDER BY subs DESC, u.display_name ASC LIMIT 40`
+    );
+    sendJson(ctx.res, 200, { channels: rows.map((r) => publicUser(r, viewerId)) });
+  },
+};
+
+/* ------------------------------------------------------------- libraries -- */
+
+const libraryRoutes = {
+  'GET /api/library/liked': (ctx) => sendJson(ctx.res, 200, { videos: likedVideos(ctx.requireUser().id) }),
+  'GET /api/library/history': (ctx) => sendJson(ctx.res, 200, { videos: historyVideos(ctx.requireUser().id) }),
+  'GET /api/library/saved': (ctx) => sendJson(ctx.res, 200, { videos: watchLaterVideos(ctx.requireUser().id) }),
+  'DELETE /api/library/history': (ctx) => {
+    run('DELETE FROM watch_history WHERE user_id = ?', ctx.requireUser().id);
+    sendJson(ctx.res, 200, { ok: true });
+  },
+};
+
+/* ----------------------------------------------------------------- ffmpeg -- */
+
+async function probeDuration(path) {
+  try {
+    const { stdout } = await execFileAsync('ffprobe', [
+      '-v', 'error', '-show_entries', 'format=duration',
+      '-of', 'default=noprint_wrappers=1:nokey=1', path,
+    ]);
+    const seconds = Number.parseFloat(stdout.trim());
+    return Number.isFinite(seconds) ? Math.round(seconds * 10) / 10 : 0;
+  } catch {
+    return 0;
+  }
+}
+
+async function makeThumbnail(videoPath, storedName, duration) {
+  const name = `${storedName.replace(/\.[^.]+$/, '')}.jpg`;
+  const at = duration > 2 ? Math.min(duration / 3, 10) : 0;
+  try {
+    await execFileAsync('ffmpeg', [
+      '-y', '-loglevel', 'error', '-ss', String(at), '-i', videoPath,
+      '-frames:v', '1', '-vf', 'scale=640:-2', '-q:v', '4',
+      join(THUMB_DIR, name),
+    ]);
+    return `/media/thumbs/${name}`;
+  } catch {
+    return '';
+  }
+}
+
+export const routes = {
+  ...authRoutes, ...videoRoutes, ...commentRoutes, ...channelRoutes, ...libraryRoutes,
+};
diff --git a/server/static.js b/server/static.js
new file mode 100644
index 0000000..50b9d7c
--- /dev/null
+++ b/server/static.js
@@ -0,0 +1,90 @@
+import { createReadStream } from 'node:fs';
+import { stat } from 'node:fs/promises';
+import { extname, join, normalize, sep } from 'node:path';
+
+const TYPES = {
+  '.html': 'text/html; charset=utf-8',
+  '.js': 'text/javascript; charset=utf-8',
+  '.css': 'text/css; charset=utf-8',
+  '.json': 'application/json; charset=utf-8',
+  '.svg': 'image/svg+xml',
+  '.png': 'image/png',
+  '.jpg': 'image/jpeg',
+  '.jpeg': 'image/jpeg',
+  '.webp': 'image/webp',
+  '.ico': 'image/x-icon',
+  '.woff2': 'font/woff2',
+  '.mp4': 'video/mp4',
+  '.webm': 'video/webm',
+  '.mov': 'video/quicktime',
+  '.m4v': 'video/x-m4v',
+  '.ogg': 'video/ogg',
+  '.mp3': 'audio/mpeg',
+};
+
+export const contentType = (path) => TYPES[extname(path).toLowerCase()] || 'application/octet-stream';
+
+/** Resolve a URL path inside `root`, refusing anything that escapes it. */
+export function safeJoin(root, urlPath) {
+  const decoded = decodeURIComponent(urlPath).replace(/\0/g, '');
+  const full = normalize(join(root, decoded));
+  if (full !== root && !full.startsWith(root + sep)) return null;
+  return full;
+}
+
+/**
+ * Send a file, honouring a single Range header so <video> can seek.
+ * Returns false when the file does not exist.
+ */
+export async function sendFile(req, res, path, { cacheControl = 'public, max-age=3600' } = {}) {
+  let info;
+  try {
+    info = await stat(path);
+    if (!info.isFile()) return false;
+  } catch {
+    return false;
+  }
+
+  const type = contentType(path);
+  const etag = `W/"${info.size.toString(16)}-${Math.floor(info.mtimeMs).toString(16)}"`;
+  const base = {
+    'Content-Type': type,
+    'Accept-Ranges': 'bytes',
+    'Cache-Control': cacheControl,
+    ETag: etag,
+    'Last-Modified': info.mtime.toUTCString(),
+  };
+
+  if (req.headers['if-none-match'] === etag) {
+    res.writeHead(304, base);
+    res.end();
+    return true;
+  }
+
+  const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
+  if (range) {
+    const [, rawStart, rawEnd] = range;
+    let start = rawStart === '' ? info.size - Number(rawEnd) : Number(rawStart);
+    let end = rawStart === '' || rawEnd === '' ? info.size - 1 : Number(rawEnd);
+    start = Math.max(0, start);
+    end = Math.min(info.size - 1, end);
+    if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= info.size) {
+      res.writeHead(416, { ...base, 'Content-Range': `bytes */${info.size}` });
+      res.end();
+      return true;
+    }
+    res.writeHead(206, {
+      ...base,
+      'Content-Range': `bytes ${start}-${end}/${info.size}`,
+      'Content-Length': end - start + 1,
+    });
+    if (req.method === 'HEAD') return res.end(), true;
+    createReadStream(path, { start, end }).pipe(res);
+    return true;
+  }
+
+  res.writeHead(200, { ...base, 'Content-Length': info.size });
+  if (req.method === 'HEAD') return res.end(), true;
+  createReadStream(path).pipe(res);
+  return true;
+}