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
Comments
No comments yet.
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>×</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;
+}