diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1947dd1..dab3501 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,28 @@ on: pull_request: branches: [main] +env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/together + ROOM_TOKEN_SECRET: test-secret + NEXT_PUBLIC_APP_URL: http://localhost:3000 + NEXT_PUBLIC_REALTIME_URL: ws://localhost:8787 + jobs: - lint-and-typecheck: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm ci:quality + + build: runs-on: ubuntu-latest + needs: quality steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -20,11 +39,37 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm typecheck - - run: pnpm lint + - run: pnpm ci:build + + unit: + runs-on: ubuntu-latest + needs: quality + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm ci:unit + + db: + runs-on: ubuntu-latest + needs: quality + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm ci:db test: runs-on: ubuntu-latest + needs: [build, unit, db] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -33,16 +78,25 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm --filter @together/web test:install - - run: pnpm --filter @together/web test - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/together - ROOM_TOKEN_SECRET: test-secret - NEXT_PUBLIC_APP_URL: http://localhost:3000 - NEXT_PUBLIC_REALTIME_URL: ws://localhost:8787 + - run: pnpm ci:e2e + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ github.job }}-${{ github.run_id }} + path: apps/web/playwright-report/ + retention-days: 14 + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ github.job }}-${{ github.run_id }} + path: apps/web/test-results/ + retention-days: 14 visual: runs-on: ubuntu-latest + needs: [build, unit, db] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -51,16 +105,18 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm --filter @together/web test:install - - run: pnpm --filter @together/web test:mobile - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/together - ROOM_TOKEN_SECRET: test-secret - NEXT_PUBLIC_APP_URL: http://localhost:3000 - NEXT_PUBLIC_REALTIME_URL: ws://localhost:8787 - - run: pnpm --filter @together/web test:visual - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/together - ROOM_TOKEN_SECRET: test-secret - NEXT_PUBLIC_APP_URL: http://localhost:3000 - NEXT_PUBLIC_REALTIME_URL: ws://localhost:8787 + - run: pnpm ci:visual + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ github.job }}-${{ github.run_id }} + path: apps/web/playwright-report/ + retention-days: 14 + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ github.job }}-${{ github.run_id }} + path: apps/web/test-results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 744905c..bdceec0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ playwright-report test-results coverage .DS_Store +.husky/pre-commit.local +.husky/pre-push.local +# Husky generates .husky/_/ on install (see .husky/_/.gitignore); do not commit that folder. +# Local know-code state (per developer — not committed) +.know-code/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..0eb6551 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +[ "$SKIP_HOOKS" = "1" ] && exit 0 +pnpm ci:pre-commit +# Optional machine-local extensions (gitignored) - e.g. know-code quiz gate +if [ -f "$(dirname -- "$0")/pre-commit.local" ]; then + . "$(dirname -- "$0")/pre-commit.local" +fi diff --git a/.husky/pre-commit.local.example b/.husky/pre-commit.local.example new file mode 100644 index 0000000..658c5b3 --- /dev/null +++ b/.husky/pre-commit.local.example @@ -0,0 +1,7 @@ +# Optional know-code overlay for pre-commit — intentionally empty in range mode. +# +# know-code gates push (pre-push.local), not each commit. While a range is open, +# land all commits first, then teach → quiz → pass → range seal → push. +# +# If you use index mode (quiz per commit) instead of range mode, copy the check +# from pre-push.local.example here — see CONTRIBUTING.md. diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..76fb921 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +[ "$SKIP_HOOKS" = "1" ] && exit 0 +pnpm ci:pre-push +# Optional machine-local extensions (gitignored) - e.g. know-code quiz gate +if [ -f "$(dirname -- "$0")/pre-push.local" ]; then + . "$(dirname -- "$0")/pre-push.local" +fi diff --git a/.husky/pre-push.local.example b/.husky/pre-push.local.example new file mode 100644 index 0000000..f51c9ac --- /dev/null +++ b/.husky/pre-push.local.example @@ -0,0 +1,20 @@ +# Copy to .husky/pre-push.local to enable (file is gitignored). +# +# [know-code](https://kc.chtnnhfoundation.org) blocks push until the range is sealed +# and comprehension checks pass. Runs after shared ci:pre-push checks. +# +# cp .husky/pre-push.local.example .husky/pre-push.local + +run_know_code_check() { + ROOT="$(git rev-parse --show-toplevel)" + if [ -x "$ROOT/node_modules/.bin/know-code" ]; then + "$ROOT/node_modules/.bin/know-code" check + elif command -v know-code >/dev/null 2>&1; then + know-code check + else + echo "know-code: CLI not found. Install: npm i -g @chtnnh/know-code" >&2 + exit 1 + fi +} + +run_know_code_check diff --git a/AGENTS.md b/AGENTS.md index f514ef1..b83def9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,14 +22,21 @@ Monorepo for **Together** (synced YouTube watch/listen rooms). Default branch: * ```bash pnpm install --frozen-lockfile +pnpm lint # Biome check +pnpm format # Biome write pnpm typecheck -pnpm lint +pnpm test:unit # Vitest (all packages) +pnpm ci:local # Full merge gate (same as CI) +pnpm ci:pre-commit # Hook: incremental checks +pnpm ci:pre-push # Hook: quality + affected unit/build (no E2E/visual) pnpm --filter @together/web test:install # first time / CI pnpm --filter @together/web test pnpm --filter @together/realtime dev # requires Node 22+ pnpm --filter @together/web dev ``` +See **`CONTRIBUTING.md`** for merge gate contract, hook tiers, and viewing CI Playwright artifacts. + Env is loaded from repo root `.env` (see `.env.example`). Do not commit secrets. ## Conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9777e05 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,126 @@ +# Contributing to Together + +## Merge gate contract + +**Green CI = safe to merge** (after you review intent). GitHub Actions runs the full suite via `pnpm ci:local` scripts: + +| Job | Script | What it checks | +|-----|--------|----------------| +| `quality` | `pnpm ci:quality` | Biome lint/format + TypeScript | +| `build` | `pnpm ci:build` | Next.js + realtime worker build | +| `unit` | `pnpm ci:unit` | Vitest across all packages | +| `db` | `pnpm ci:db` | Drizzle journal / migration integrity | +| `test` | `pnpm ci:e2e` | Playwright E2E (desktop + mobile) | +| `visual` | `pnpm ci:visual` | Visual regression (Linux Docker baselines) | + +Run the same locally before opening a PR: + +```bash +pnpm install --frozen-lockfile +pnpm ci:local +``` + +## Git hooks (incremental) + +Hooks catch most issues early without running the full ~15-minute suite on every commit. + +**Shared hooks are committed** in `.husky/pre-commit` and `.husky/pre-push` — every contributor gets them after `pnpm install` (the `prepare` script runs Husky and wires Git’s `core.hooksPath`). Only **optional** know-code overlays (`.husky/*.local`) are gitignored. + +| Hook | Command | Typical duration | +|------|---------|------------------| +| **pre-commit** | `pnpm ci:pre-commit` | ~30–90s | +| **pre-push** | `pnpm ci:pre-push` | ~1-5 min | + +**pre-commit:** `lint-staged` (Biome auto-fix), Biome on changed files, affected typecheck, Vitest `--changed`, DB guard if `schema.ts` changed. + +**pre-push:** full Biome, affected typecheck/unit, conditional build, DB guard if schema changed. Large diffs (>30 files) or CI infra changes run full quality + build + unit + db (no E2E/visual; CI covers those). + +Skip hooks when necessary (you are responsible for CI): + +```bash +SKIP_HOOKS=1 git commit +SKIP_HOOKS=1 git push +# or +git push --no-verify +``` + +## Optional: know-code comprehension gate + +[know-code](https://kc.chtnnhfoundation.org) is an optional, **machine-local** layer on top of the shared hooks. It blocks `git commit` / `git push` until you pass a short quiz about the diff (useful when working with coding agents). + +**Not enabled by default** — other contributors are unaffected. + +### Opt in + +```bash +npm i -g @chtnnh/know-code +know-code attest-init # once per machine +bash scripts/enable-know-code-hooks.sh +``` + +This copies `.husky/pre-commit.local.example` → `.husky/pre-commit.local` (and the pre-push variant). Those files are **gitignored**; only your machine runs `know-code check` after the shared `ci:pre-commit` / `ci:pre-push` scripts. + +Manual setup instead of the script: + +```bash +cp .husky/pre-commit.local.example .husky/pre-commit.local +cp .husky/pre-push.local.example .husky/pre-push.local +``` + +### Typical workflow (range mode) + +One quiz covers the **entire feature batch**, not each commit. + +1. `know-code range begin` at the start of a feature batch. +2. Land all commits (`git commit` runs shared `ci:pre-commit` only — know-code does **not** gate each commit). +3. When the batch is complete: agent teaches → you run `know-code taught`. +4. Agent writes `.know-code/quiz.json` from `know-code questions` → `know-code ask` → `know-code grade propose` → `know-code grade --review` → `know-code pass` (quiz covers the **full range diff**). +5. `know-code range seal` (adds verification trailer / receipt). +6. `git push` (shared `ci:pre-push` + know-code `check` on pre-push). + +Use `know-code commit -m "…"` only if you prefer the CLI wrapper after `pass`; regular `git commit` is fine while building the range. See [kc.chtnnhfoundation.org](https://kc.chtnnhfoundation.org) for the tutorial. + +**Hooks:** enable know-code on **pre-push only** (`.husky/pre-push.local`). Do not add know-code to pre-commit — that forces a quiz per commit and breaks range mode. + +### Disable on this machine + +```bash +rm .husky/pre-commit.local .husky/pre-push.local +``` + +Emergency bypass (human TTY): `know-code override`, then `KNOW_CODE_OVERRIDE=1 git commit`. Do not use in CI or agent shells. + +## Viewing CI failure screenshots + +1. Open the failed GitHub Actions run (**CI → test** or **CI → visual**). +2. Scroll to **Artifacts** at the bottom of the summary. +3. Download **`test-results-…`** for failure screenshots, visual diffs (`*-expected.png`, `*-actual.png`, `*-diff.png`), and `trace.zip`. +4. Download **`playwright-report-…`** and open `index.html` in a browser for the interactive report. + +## DB schema changes + +1. Edit `packages/db/src/schema.ts` +2. Run `pnpm db:generate` +3. Commit new SQL under `packages/db/drizzle/` **and** `packages/db/drizzle/meta/` + +## Testing layers + +| Layer | Command | +|-------|---------| +| Unit | `pnpm test:unit` or `pnpm ci:unit` | +| E2E | `pnpm --filter @together/web test` | +| Visual update | `pnpm --filter @together/web test:visual:update` (Docker) | +| Full CI parity | `pnpm ci:local` | + +## Formatting + +```bash +pnpm format # Biome write +pnpm lint # Biome check +``` + +## Coverage notes + +- **Keyboard shortcuts (mobile):** desktop-only interaction; mobile coverage is `keyboard-shortcuts-mobile.spec.ts` (verifies `?` in chat does not open help). +- **OG / favicon:** covered by `og-image.spec.ts` (desktop API); no separate mobile layout. +- **Playback sync visual:** no distinct layout — covered by E2E two-client specs and unit playback math. diff --git a/README.md b/README.md index e201ee3..1d6d17a 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ Most watch-party apps assume desktop, always-on video, and one look for everyone - Service worker caches the app shell; offline fallback page ### Quality -- Playwright E2E and **visual regression** tests (Linux Docker baselines in CI) +- **Biome** lint/format, **Vitest** unit tests, Playwright **E2E** (desktop + mobile), and **visual regression** (Linux Docker baselines in CI) +- Tiered **git hooks**: fast pre-commit, smoke+affected pre-push; full `pnpm ci:local` in CI +- CI uploads Playwright HTML reports and failure screenshots — see [CONTRIBUTING.md](CONTRIBUTING.md) ### Optional (requires Supabase auth) - Sign in to save room settings, **save/load playlists**, and **sync preferences** across devices @@ -194,20 +196,69 @@ pnpm --filter @together/web dev - Realtime health: [http://127.0.0.1:8787/health](http://127.0.0.1:8787/health) - DB health: [http://localhost:3000/api/health/db](http://localhost:3000/api/health/db) +### 6. Git hooks (after `pnpm install`) + +Husky installs shared hooks automatically (`prepare` script). + +| Hook | Command | Typical time | +|------|---------|--------------| +| **pre-commit** | `pnpm ci:pre-commit` | ~30–90s | +| **pre-push** | `pnpm ci:pre-push` | ~1-5 min (incremental) or ~10 min (large/infra diffs) | + +**pre-commit:** `lint-staged` (Biome), Biome on changed files, affected typecheck, Vitest `--changed`, DB guard if `schema.ts` changed. + +**pre-push:** Biome (full repo), affected typecheck/unit/build, DB guard if schema changed. Diffs over 30 files vs `main` or CI infra changes run full quality + build + unit + db. E2E and visual run in CI only (`pnpm ci:local`). When the hook exits 0, `git push` continues automatically. + +Optional [know-code](https://kc.chtnnhfoundation.org) gate: `bash scripts/enable-know-code-hooks.sh` (machine-local). See [CONTRIBUTING.md](CONTRIBUTING.md). + +```bash +SKIP_HOOKS=1 git commit # bypass hooks (you own CI) +git push --no-verify +``` + --- ## Scripts +### Day-to-day + | Command | Description | |---|---| | `pnpm dev` | Start all apps via Turborepo | -| `pnpm --filter @together/web dev` | Next.js dev server | -| `pnpm --filter @together/realtime dev` | Cloudflare Worker (Wrangler dev) | +| `pnpm --filter @together/web dev` | Next.js dev server (`:3000`) | +| `pnpm --filter @together/realtime dev` | Cloudflare Worker via Wrangler (`:8787`, Node 22+) | | `pnpm db:migrate` | Apply Drizzle migrations | | `pnpm db:generate` | Generate migration from schema changes | -| `pnpm --filter @together/web build` | Production build | -| `pnpm --filter @together/web test` | Playwright E2E tests | -| `pnpm typecheck` | Typecheck all packages | +| `pnpm lint` | Biome check (format + lint, fails on warnings) | +| `pnpm format` | Biome auto-fix | +| `pnpm typecheck` | Typecheck all packages (Turborepo) | +| `pnpm test:unit` | Vitest unit tests (all packages) | + +### CI parity (`ci:*`) + +Same scripts GitHub Actions runs — use `pnpm ci:local` for the full merge gate: + +| Command | Description | +|---|---| +| `pnpm ci:local` | Full pipeline: quality → build → unit → db → e2e → visual | +| `pnpm ci:quality` | Biome + typecheck | +| `pnpm ci:build` | Next.js + realtime worker build | +| `pnpm ci:unit` | Vitest (all packages) | +| `pnpm ci:db` | Drizzle journal / migration guard | +| `pnpm ci:e2e` | Playwright E2E (mobile-chrome + chromium) | +| `pnpm ci:visual` | Visual regression (Linux Docker baselines) | +| `pnpm ci:pre-commit` | Hook: incremental (what pre-commit runs) | +| `pnpm ci:pre-push` | Hook: quality + affected unit/build (what pre-push runs) | + +### E2E & visual + +| Command | Description | +|---|---| +| `pnpm --filter @together/web test:install` | Install Playwright browsers (first time / CI) | +| `pnpm --filter @together/web test` | Playwright E2E (mobile-chrome, then chromium) | +| `pnpm --filter @together/web test:visual` | Visual regression (Docker / Linux) | +| `pnpm --filter @together/web test:visual:update` | Regenerate visual baselines (Docker) | +| `pnpm --filter @together/web build` | Production Next.js build | --- @@ -251,12 +302,51 @@ together/ ## Testing +Together uses three layers: **Vitest** (unit), **Playwright E2E** (desktop + mobile), and **visual regression** (Linux Docker baselines in CI). + +### Unit (Vitest) + +```bash +pnpm test:unit # all packages (root vitest workspace) +pnpm ci:unit # same, CI job +``` + +Vitest projects: `packages/shared`, `packages/ui`, `packages/db`, `packages/track-resolver`, `services/realtime`, `apps/web`. + +### E2E (Playwright) + +```bash +pnpm --filter @together/web test:install # first time / CI: browsers + deps +pnpm --filter @together/web test # mobile-chrome, then chromium +pnpm ci:e2e # CI job (same as above) +``` + +Specs live in `apps/web/e2e/`. Playwright starts the web app and realtime worker via `webServer` config. Requires **Node.js 22+** (Wrangler). + +Run a subset: + +```bash +pnpm --filter @together/web exec playwright test --project=chromium --grep @smoke +pnpm --filter @together/web exec playwright test apps/web/e2e/room.spec.ts +``` + +### Visual regression + +```bash +pnpm --filter @together/web test:visual # compare against baselines +pnpm --filter @together/web test:visual:update # regenerate (Docker) +pnpm ci:visual # CI job +``` + +Baselines live under `apps/web/e2e/visual-regression.spec.ts-snapshots/`. CI runs in Linux Docker for deterministic pixels. + +### Full merge gate + ```bash -pnpm --filter @together/web test:install # first time / CI: install Playwright browsers -pnpm --filter @together/web test # starts web + realtime via Playwright webServer +pnpm ci:local # runs ci:quality → build → unit → db → e2e → visual ``` -Playwright specs live in `apps/web/e2e/`. Requires **Node.js 22+** (Wrangler dev server). +See [CONTRIBUTING.md](CONTRIBUTING.md) for the GitHub Actions job mapping and hook behavior. --- diff --git a/apps/web/e2e/account-modal.spec.ts b/apps/web/e2e/account-modal.spec.ts index a4bf699..a23d676 100644 --- a/apps/web/e2e/account-modal.spec.ts +++ b/apps/web/e2e/account-modal.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; test.describe("v0.3 — In-room account modal", () => { diff --git a/apps/web/e2e/admin-ui.spec.ts b/apps/web/e2e/admin-ui.spec.ts new file mode 100644 index 0000000..cdd253e --- /dev/null +++ b/apps/web/e2e/admin-ui.spec.ts @@ -0,0 +1,8 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Admin UI smoke", () => { + test("admin route redirects without superadmin session", async ({ page }) => { + await page.goto("/admin"); + await expect(page).toHaveURL("/"); + }); +}); diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 3ac191a..9d8233a 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; test.describe("v0.3 — Admin API", () => { test("GET /api/admin/stats returns 401 without auth", async ({ request }) => { diff --git a/apps/web/e2e/app.spec.ts b/apps/web/e2e/app.spec.ts index a20acaf..598d7df 100644 --- a/apps/web/e2e/app.spec.ts +++ b/apps/web/e2e/app.spec.ts @@ -1,11 +1,9 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; test.describe("Together landing page", () => { test("shows create and join forms", async ({ page }) => { await page.goto("/"); - await expect( - page.getByRole("heading", { name: /watch and listen together/i }), - ).toBeVisible(); + await expect(page.getByRole("heading", { name: /watch and listen together/i })).toBeVisible(); await page.locator("#get-started").scrollIntoViewIfNeeded(); await expect(page.getByRole("heading", { name: "Create a room" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Join a room" })).toBeVisible(); diff --git a/apps/web/e2e/audio-only.spec.ts b/apps/web/e2e/audio-only.spec.ts index 8cdcc7c..6a88020 100644 --- a/apps/web/e2e/audio-only.spec.ts +++ b/apps/web/e2e/audio-only.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; test.describe("Phase 4.7 — Desktop audio-only hides video", () => { diff --git a/apps/web/e2e/background-playback.spec.ts b/apps/web/e2e/background-playback.spec.ts index 338b54d..692fef9 100644 --- a/apps/web/e2e/background-playback.spec.ts +++ b/apps/web/e2e/background-playback.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { shouldAttemptBackgroundResume, shouldResyncOnForeground, diff --git a/apps/web/e2e/chat-bidi.spec.ts b/apps/web/e2e/chat-bidi.spec.ts new file mode 100644 index 0000000..964b00b --- /dev/null +++ b/apps/web/e2e/chat-bidi.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { createConnectedRoom, openRoomTab } from "./helpers/room"; + +test.describe("Chat bidi (RTL page, LTR chat)", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("chat messages stay LTR on RTL document", async ({ page }) => { + await page.addInitScript(() => { + document.documentElement.setAttribute("dir", "rtl"); + }); + await createConnectedRoom(page, "Bidi"); + await openRoomTab(page, "Chat"); + const messages = page.getByTestId("chat-messages"); + await expect(messages).toHaveAttribute("dir", "ltr"); + await page.getByTestId("chat-input").fill("مرحبا hello"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByTestId("chat-message")).toContainText("hello"); + }); +}); diff --git a/apps/web/e2e/chat-ephemeral.spec.ts b/apps/web/e2e/chat-ephemeral.spec.ts index 2ae2eca..b0030ee 100644 --- a/apps/web/e2e/chat-ephemeral.spec.ts +++ b/apps/web/e2e/chat-ephemeral.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { openRoomTab } from "./helpers/room"; test.describe("v0.3 — Ephemeral chat", () => { test.beforeEach(() => { @@ -13,10 +14,8 @@ test.describe("v0.3 — Ephemeral chat", () => { await page.waitForURL(/\/r\//); await expect(page.getByText(/\d+ listening/)).toBeVisible({ timeout: 15000 }); - await page.getByRole("tab", { name: "Chat" }).click(); - await expect( - page.getByText(/Messages aren't saved/i), - ).toBeVisible({ timeout: 10000 }); + await openRoomTab(page, "Chat"); + await expect(page.getByText(/Messages aren't saved/i)).toBeVisible({ timeout: 10000 }); await expect(page.getByText(/No messages yet. Say hi!/i)).not.toBeVisible(); }); @@ -27,12 +26,12 @@ test.describe("v0.3 — Ephemeral chat", () => { await page.waitForURL(/\/r\//); await expect(page.getByText(/\d+ listening/)).toBeVisible({ timeout: 15000 }); - await page.getByRole("tab", { name: "Chat" }).click(); + await openRoomTab(page, "Chat"); await expect(page.getByText(/Messages aren't saved/i)).toBeVisible({ timeout: 10000, }); - await page.getByPlaceholder("Type a message").fill("hello"); + await page.getByTestId("chat-input").fill("hello"); await page.getByRole("button", { name: "Send" }).click(); await expect(page.getByText(/Messages aren't saved/i)).not.toBeVisible(); await expect(page.getByText("hello")).toBeVisible(); diff --git a/apps/web/e2e/chat-mentions.spec.ts b/apps/web/e2e/chat-mentions.spec.ts new file mode 100644 index 0000000..8bf6f8d --- /dev/null +++ b/apps/web/e2e/chat-mentions.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { createConnectedRoom, openRoomTab } from "./helpers/room"; + +test.describe("Chat @mentions", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("highlights mention when sending @displayName", async ({ page }) => { + await createConnectedRoom(page, "MentionHost"); + await openRoomTab(page, "Chat"); + await page.getByTestId("chat-input").fill("@MentionHost hello"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByTestId("chat-message")).toContainText("hello"); + await expect(page.getByTestId("chat-message")).toContainText("@MentionHost"); + }); +}); diff --git a/apps/web/e2e/chat-mobile.spec.ts b/apps/web/e2e/chat-mobile.spec.ts new file mode 100644 index 0000000..141a415 --- /dev/null +++ b/apps/web/e2e/chat-mobile.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { createConnectedRoom, openRoomTab } from "./helpers/room"; + +test.describe("Chat on mobile", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("mobile nav opens chat tab with input", async ({ page }) => { + await createConnectedRoom(page, "MobileChat"); + await openRoomTab(page, "Chat"); + await expect(page.getByTestId("chat-input")).toBeVisible(); + await expect(page.getByTestId("chat-messages")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/connection-status.spec.ts b/apps/web/e2e/connection-status.spec.ts index 991123b..c0ae5c0 100644 --- a/apps/web/e2e/connection-status.spec.ts +++ b/apps/web/e2e/connection-status.spec.ts @@ -1,22 +1,12 @@ -import { test, expect } from "@playwright/test"; -import { connectionStatusLabel } from "../src/components/connection-status"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; test.describe("Phase 3.3 — Connection indicator", () => { - test("connectionStatusLabel shows Connected when online", () => { - expect( - connectionStatusLabel({ - offline: false, - connected: true, - synced: true, - participantCount: 3, - slug: "abc123", - }), - ).toMatch(/^Connected · 3 listening · abc123$/); + test.beforeEach(() => { + resetRateLimitStoreForTests(); }); test("shows connection status in room header when connected", async ({ page }) => { - resetRateLimitStoreForTests(); await page.goto("/"); await page.locator("#create-name").fill("DJ"); await page.getByRole("button", { name: "Create room" }).click(); diff --git a/apps/web/e2e/crossfade.spec.ts b/apps/web/e2e/crossfade.spec.ts deleted file mode 100644 index e60aa66..0000000 --- a/apps/web/e2e/crossfade.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { CROSSFADE_MS } from "../src/lib/playback-crossfade"; - -test.describe("Phase 4.4 — Crossfade", () => { - test("uses a short crossfade between tracks", () => { - expect(CROSSFADE_MS).toBeGreaterThanOrEqual(300); - expect(CROSSFADE_MS).toBeLessThanOrEqual(500); - }); -}); diff --git a/apps/web/e2e/democratic-promote-flow.spec.ts b/apps/web/e2e/democratic-promote-flow.spec.ts new file mode 100644 index 0000000..9117e36 --- /dev/null +++ b/apps/web/e2e/democratic-promote-flow.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { + addUrlInput, + expectPromoteVoteBarVisible, + openRoomSettings, + promoteVoteBar, +} from "./helpers/room"; + +test.describe("Democratic promote full vote flow", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("guest request gets promote votes from host", async ({ browser }) => { + test.setTimeout(60_000); + const hostContext = await browser.newContext(); + const guestContext = await browser.newContext(); + const hostPage = await hostContext.newPage(); + const guestPage = await guestContext.newPage(); + + await hostPage.goto("/"); + await hostPage.locator("#create-name").fill("PromoteHost"); + await hostPage.getByRole("button", { name: "Create room" }).click(); + await hostPage.waitForURL(/\/r\//); + const roomUrl = hostPage.url(); + await expect(hostPage.getByText(/\d+ listening/)).toBeVisible({ timeout: 15000 }); + + await openRoomSettings(hostPage); + await hostPage.getByRole("switch", { name: /democratic promote/i }).click(); + await hostPage.getByRole("button", { name: "Close settings" }).click(); + + await guestPage.goto(roomUrl); + await guestPage.getByLabel("Display name").fill("PromoteGuest"); + await guestPage.getByRole("button", { name: "Join room" }).click(); + await expect(guestPage.getByText(/\d+ listening/)).toBeVisible({ timeout: 15000 }); + await expect(hostPage.getByText(/2 listening/)).toBeVisible({ timeout: 15000 }); + + await addUrlInput(guestPage).fill("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); + await addUrlInput(guestPage).press("Enter"); + await expect(guestPage.getByRole("status").filter({ hasText: /Added/i })).toBeVisible({ + timeout: 10000, + }); + + await expectPromoteVoteBarVisible(hostPage); + await promoteVoteBar(hostPage).getByRole("button", { name: "Vote to promote" }).click(); + await expect(promoteVoteBar(hostPage).getByRole("button", { name: "Voted" })).toBeVisible({ + timeout: 5000, + }); + + await hostContext.close(); + await guestContext.close(); + }); +}); diff --git a/apps/web/e2e/democratic-promote.spec.ts b/apps/web/e2e/democratic-promote.spec.ts index 17959c3..b0fdc3e 100644 --- a/apps/web/e2e/democratic-promote.spec.ts +++ b/apps/web/e2e/democratic-promote.spec.ts @@ -1,6 +1,11 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; -import { addUrlInput } from "./helpers/room"; +import { + addUrlInput, + expectPromoteVoteBarVisible, + openRoomSettings, + promoteVoteBar, +} from "./helpers/room"; test.describe("Phase 5.1 — Democratic promote UI", () => { test.beforeEach(() => { @@ -20,7 +25,7 @@ test.describe("Phase 5.1 — Democratic promote UI", () => { const roomUrl = hostPage.url(); await expect(hostPage.getByText(/\d+ listening/)).toBeVisible({ timeout: 15000 }); - await hostPage.getByRole("button", { name: "Settings", exact: true }).click(); + await openRoomSettings(hostPage); await hostPage.getByRole("switch", { name: /democratic promote/i }).click(); await hostPage.getByRole("button", { name: "Close settings" }).click(); @@ -36,8 +41,10 @@ test.describe("Phase 5.1 — Democratic promote UI", () => { timeout: 10000, }); - await expect(hostPage.getByTestId("promote-vote-bar")).toBeVisible({ timeout: 10000 }); - await expect(hostPage.getByRole("button", { name: "Vote to promote" })).toBeVisible(); + await expectPromoteVoteBarVisible(hostPage); + await expect( + promoteVoteBar(hostPage).getByRole("button", { name: "Vote to promote" }), + ).toBeVisible(); await hostContext.close(); await guestContext.close(); diff --git a/apps/web/e2e/embed-error.spec.ts b/apps/web/e2e/embed-error.spec.ts index 8777e3c..59671dc 100644 --- a/apps/web/e2e/embed-error.spec.ts +++ b/apps/web/e2e/embed-error.spec.ts @@ -1,9 +1,6 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { embedErrorMessage, isEmbedBlockedError } from "../src/lib/playback-embed-error"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; -import { - embedErrorMessage, - isEmbedBlockedError, -} from "../src/lib/playback-embed-error"; test.describe("Phase 3.2 — YouTube embed error UX", () => { test.beforeEach(() => { diff --git a/apps/web/e2e/empty-states.spec.ts b/apps/web/e2e/empty-states.spec.ts index 4c577ee..2c18fee 100644 --- a/apps/web/e2e/empty-states.spec.ts +++ b/apps/web/e2e/empty-states.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; import { roomSidebar } from "./helpers/room"; diff --git a/apps/web/e2e/helpers/room.ts b/apps/web/e2e/helpers/room.ts index 694ce54..000b399 100644 --- a/apps/web/e2e/helpers/room.ts +++ b/apps/web/e2e/helpers/room.ts @@ -11,11 +11,62 @@ export function addUrlInput(page: Page) { return page.getByPlaceholder(/Paste a video\/playlist link/i).filter({ visible: true }); } +/** Promote vote bar in the active viewport (sidebar + mobile panel can both exist in DOM). */ +export function promoteVoteBar(page: Page) { + return page.getByTestId("promote-vote-bar").filter({ visible: true }); +} + +/** Wait for a guest request to surface the promote bar (WS sync can lag under load). */ +export async function expectPromoteVoteBarVisible(page: Page) { + await expect(async () => { + await openRoomTab(page, "Requests"); + await expect( + promoteVoteBar(page).getByRole("button", { name: "Vote to promote" }), + ).toBeVisible(); + }).toPass({ timeout: 30000 }); +} + /** Visible connection status (single instance in room header). */ export function connectionStatusLocator(page: Page) { return page.getByTestId("connection-status"); } +/** Mobile bottom nav (hidden on desktop). */ +export function mobileNav(page: Page) { + return page.getByTestId("mobile-nav"); +} + +type RoomTab = "Requests" | "Queue" | "History" | "Chat"; + +/** Open a room sidebar tab (desktop) or bottom nav tab (mobile). */ +export async function openRoomTab(page: Page, tab: RoomTab) { + const nav = mobileNav(page); + if (await nav.isVisible()) { + const button = nav.getByRole("button", { name: tab, exact: true }); + const isActive = await button.evaluate((el) => el.className.includes("text-[var(--accent)]")); + if (!isActive) { + // Dev overlay can intercept nav taps under parallel CI load. + await button.click({ force: true }); + } + } else { + const desktopTab = page.getByRole("tab", { name: tab }); + if ((await desktopTab.getAttribute("data-state")) !== "active") { + await desktopTab.click(); + } + } +} + +/** Open in-room settings (header button on desktop, overflow menu on mobile). */ +export async function openRoomSettings(page: Page) { + const settingsButton = page.getByRole("button", { name: "Settings", exact: true }); + if (await settingsButton.isVisible()) { + await settingsButton.click(); + return; + } + await page.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("button", { name: "Settings" }).click(); +} + /** Create a room from the landing page and wait for realtime connection. */ export async function createConnectedRoom(page: Page, displayName = "E2E") { await page.goto("/"); diff --git a/apps/web/e2e/history-tab.spec.ts b/apps/web/e2e/history-tab.spec.ts new file mode 100644 index 0000000..75e32e3 --- /dev/null +++ b/apps/web/e2e/history-tab.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { addUrlInput, createConnectedRoom, openRoomTab } from "./helpers/room"; + +test.describe("History tab", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("history tab shows after playback", async ({ page }) => { + await createConnectedRoom(page, "Hist"); + await openRoomTab(page, "History"); + await expect(page.getByText(/Nothing played yet/i)).toBeVisible({ + timeout: 10000, + }); + await openRoomTab(page, "Queue"); + await addUrlInput(page).fill("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); + await addUrlInput(page).press("Enter"); + await expect(page.getByTestId("now-playing-bar")).toBeVisible({ timeout: 20000 }); + }); +}); diff --git a/apps/web/e2e/import-public.spec.ts b/apps/web/e2e/import-public.spec.ts index 88a3f5d..78cd502 100644 --- a/apps/web/e2e/import-public.spec.ts +++ b/apps/web/e2e/import-public.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; import { addUrlInput } from "./helpers/room"; diff --git a/apps/web/e2e/infra.spec.ts b/apps/web/e2e/infra.spec.ts index 4b3cd1a..dc825f5 100644 --- a/apps/web/e2e/infra.spec.ts +++ b/apps/web/e2e/infra.spec.ts @@ -1,6 +1,6 @@ -import { test, expect } from "@playwright/test"; import fs from "node:fs"; import path from "node:path"; +import { expect, test } from "@playwright/test"; const repoRoot = path.join(__dirname, "../../.."); @@ -34,18 +34,29 @@ test.describe("Phase 1.1 — CI and deploy", () => { }; expect(rootPkg.scripts?.["build:realtime"]).toContain("@together/realtime"); expect(rootPkg.scripts?.["deploy:realtime"]).toContain("wrangler deploy --env production"); - - const realtimePkg = JSON.parse(readRepoFile("services/realtime/package.json")) as { - scripts?: Record; - }; - expect(realtimePkg.scripts?.build).toBeTruthy(); - expect(realtimePkg.scripts?.deploy).toContain("wrangler deploy --env production"); + expect(rootPkg.scripts?.["ci:local"]).toContain("ci-local.sh"); + expect(rootPkg.scripts?.["ci:pre-commit"]).toBeTruthy(); + expect(rootPkg.scripts?.["ci:pre-push"]).toBeTruthy(); }); - test("CI workflow runs typecheck, lint, and Playwright tests", () => { + test("CI workflow runs merge gate jobs and uploads Playwright artifacts", () => { const ci = readWorkflow("ci.yml"); - expect(ci).toContain("pnpm typecheck"); - expect(ci).toContain("pnpm lint"); - expect(ci).toContain("pnpm --filter @together/web test"); + expect(ci).toContain("pnpm ci:quality"); + expect(ci).toContain("pnpm ci:build"); + expect(ci).toContain("pnpm ci:unit"); + expect(ci).toContain("pnpm ci:e2e"); + expect(ci).toContain("pnpm ci:visual"); + expect(ci).toContain("pnpm ci:db"); + expect(ci).toContain("actions/upload-artifact@v4"); + expect(ci).toContain("playwright-report"); + expect(ci).toContain("test-results"); + }); + + test("Biome is the linter", () => { + expect(fs.existsSync(path.join(repoRoot, "biome.json"))).toBe(true); + const rootPkg = JSON.parse(readRepoFile("package.json")) as { + scripts?: Record; + }; + expect(rootPkg.scripts?.lint).toContain("biome"); }); }); diff --git a/apps/web/e2e/internal-snapshot.spec.ts b/apps/web/e2e/internal-snapshot.spec.ts index d28fc97..3ae1845 100644 --- a/apps/web/e2e/internal-snapshot.spec.ts +++ b/apps/web/e2e/internal-snapshot.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; test.describe("v0.3 — Internal room snapshot API", () => { test("POST /api/internal/rooms/[slug]/snapshot returns 401 without token", async ({ @@ -28,9 +28,7 @@ test.describe("v0.3 — Internal room snapshot API", () => { expect(res.status()).toBe(401); }); - test("POST /api/internal/rooms/[slug]/bans returns 401 without token", async ({ - request, - }) => { + test("POST /api/internal/rooms/[slug]/bans returns 401 without token", async ({ request }) => { const res = await request.post("/api/internal/rooms/test-room/bans", { data: { anonId: "anon-1" }, }); diff --git a/apps/web/e2e/join-private.spec.ts b/apps/web/e2e/join-private.spec.ts new file mode 100644 index 0000000..926bfc6 --- /dev/null +++ b/apps/web/e2e/join-private.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; + +test.describe("Join private room", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("password gate renders for private rooms", async ({ page, request }) => { + const res = await request.post("/api/rooms", { + data: { + displayName: "Gate", + privacy: "private", + password: "secret123", + }, + }); + expect(res.ok()).toBeTruthy(); + const room = await res.json(); + + await page.goto(`/r/${room.slug}/join`); + await expect(page.getByRole("heading", { name: "Private room" })).toBeVisible(); + await expect(page.getByLabel("Room password")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/keyboard-shortcuts-mobile.spec.ts b/apps/web/e2e/keyboard-shortcuts-mobile.spec.ts new file mode 100644 index 0000000..1bae5dd --- /dev/null +++ b/apps/web/e2e/keyboard-shortcuts-mobile.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { createConnectedRoom, openRoomTab } from "./helpers/room"; + +test.describe("Keyboard shortcuts on mobile", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("typing ? in chat input does not open help modal", async ({ page }) => { + await createConnectedRoom(page, "MobileKeys"); + await openRoomTab(page, "Chat"); + const chatInput = page.getByTestId("chat-input"); + await chatInput.click(); + await chatInput.press("?"); + await expect(page.getByTestId("keyboard-shortcuts-help")).toBeHidden(); + }); +}); diff --git a/apps/web/e2e/keyboard-shortcuts.spec.ts b/apps/web/e2e/keyboard-shortcuts.spec.ts index 246a663..38ceb6b 100644 --- a/apps/web/e2e/keyboard-shortcuts.spec.ts +++ b/apps/web/e2e/keyboard-shortcuts.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; import { addUrlInput } from "./helpers/room"; diff --git a/apps/web/e2e/mobile-empty-states.spec.ts b/apps/web/e2e/mobile-empty-states.spec.ts new file mode 100644 index 0000000..66f7c70 --- /dev/null +++ b/apps/web/e2e/mobile-empty-states.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { createConnectedRoom, mobileNav } from "./helpers/room"; + +test.describe("Mobile empty states", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("queue empty state on mobile nav", async ({ page }) => { + await createConnectedRoom(page, "Empty"); + await mobileNav(page).getByRole("button", { name: "Queue", exact: true }).click(); + await expect(page.getByText(/Queue is empty/i)).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/mobile-queue.spec.ts b/apps/web/e2e/mobile-queue.spec.ts new file mode 100644 index 0000000..9f898a8 --- /dev/null +++ b/apps/web/e2e/mobile-queue.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { createConnectedRoom, mobileNav } from "./helpers/room"; + +test.describe("Mobile queue", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("mobile nav switches queue tab", async ({ page }) => { + await createConnectedRoom(page, "MQ"); + await mobileNav(page).getByRole("button", { name: "Queue", exact: true }).click(); + await expect(page.getByText("Queue is empty").first()).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/mobile-room.spec.ts b/apps/web/e2e/mobile-room.spec.ts index 92eb557..652a89f 100644 --- a/apps/web/e2e/mobile-room.spec.ts +++ b/apps/web/e2e/mobile-room.spec.ts @@ -1,7 +1,7 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; import { expectLeftBeforeRight, expectNoOverlap } from "./helpers/layout"; -import { createConnectedRoom, connectionStatusLocator, addUrlInput } from "./helpers/room"; +import { addUrlInput, connectionStatusLocator, createConnectedRoom } from "./helpers/room"; const MOBILE_VIEWPORTS = [ { name: "iphone-13", width: 390, height: 844 }, @@ -36,7 +36,9 @@ test.describe("Mobile room UI", () => { await page.setViewportSize({ width: viewport.width, height: viewport.height }); await createConnectedRoom(page, `Mobile ${viewport.name}`); - const mobileNav = page.locator("nav").filter({ has: page.getByText("Queue", { exact: true }) }); + const mobileNav = page + .locator("nav") + .filter({ has: page.getByText("Queue", { exact: true }) }); await expect(mobileNav).toBeVisible(); await expect(page.getByTestId("now-playing-bar")).toBeVisible(); }); @@ -45,7 +47,9 @@ test.describe("Mobile room UI", () => { await page.setViewportSize({ width: viewport.width, height: viewport.height }); await createConnectedRoom(page, `Mobile ${viewport.name}`); - const bottomNav = page.locator("nav").filter({ has: page.getByText("Queue", { exact: true }) }); + const bottomNav = page + .locator("nav") + .filter({ has: page.getByText("Queue", { exact: true }) }); await bottomNav.getByRole("button", { name: "Requests" }).click(); await expect(addUrlInput(page)).toBeVisible(); diff --git a/apps/web/e2e/now-playing-bar.spec.ts b/apps/web/e2e/now-playing-bar.spec.ts index 1a12478..19788b9 100644 --- a/apps/web/e2e/now-playing-bar.spec.ts +++ b/apps/web/e2e/now-playing-bar.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; test.describe("Phase 4.1 — Now-playing bar", () => { diff --git a/apps/web/e2e/og-image.spec.ts b/apps/web/e2e/og-image.spec.ts index 45f6fa7..a939c4f 100644 --- a/apps/web/e2e/og-image.spec.ts +++ b/apps/web/e2e/og-image.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; test.describe("Open Graph images", () => { test("room opengraph-image returns a PNG", async ({ request }) => { diff --git a/apps/web/e2e/participants-moderation.spec.ts b/apps/web/e2e/participants-moderation.spec.ts new file mode 100644 index 0000000..7fe4c42 --- /dev/null +++ b/apps/web/e2e/participants-moderation.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { createConnectedRoom } from "./helpers/room"; + +test.describe("Participants moderation", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("host opens participants panel", async ({ browser }) => { + const hostContext = await browser.newContext(); + const guestContext = await browser.newContext(); + const hostPage = await hostContext.newPage(); + const guestPage = await guestContext.newPage(); + + await createConnectedRoom(hostPage, "ModHost"); + const roomUrl = hostPage.url(); + await guestPage.goto(roomUrl); + await guestPage.getByLabel("Display name").fill("ModGuest"); + await guestPage.getByRole("button", { name: "Join room" }).click(); + await expect(hostPage.getByText(/2 listening/)).toBeVisible({ timeout: 15000 }); + + await hostPage.getByRole("button", { name: "View participants" }).click(); + await expect(hostPage.getByTestId("participants-panel")).toBeVisible(); + + await hostContext.close(); + await guestContext.close(); + }); +}); diff --git a/apps/web/e2e/playback-sync.spec.ts b/apps/web/e2e/playback-sync.spec.ts deleted file mode 100644 index 4c61e62..0000000 --- a/apps/web/e2e/playback-sync.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { - getEffectivePlaybackPosition, - smoothClockOffset, -} from "@together/shared"; - -test.describe("Playback sync", () => { - test("clock offset corrects position when client clock is ahead of server", () => { - const playback = { - videoId: "abc", - positionMs: 10_000, - playing: true, - playbackRate: 1, - version: 1, - updatedAt: 1_000_000, - }; - const clientNow = 1_025_000; - const offsetMs = -25_000; - - expect(getEffectivePlaybackPosition(playback, clientNow, 0)).toBe(35_000); - expect(getEffectivePlaybackPosition(playback, clientNow, offsetMs)).toBe(10_000); - }); - - test("smoothClockOffset averages samples", () => { - expect(smoothClockOffset(0, 1000)).toBe(1000); - expect(smoothClockOffset(1000, 2000)).toBe(1200); - }); -}); diff --git a/apps/web/e2e/playback-two-clients.spec.ts b/apps/web/e2e/playback-two-clients.spec.ts new file mode 100644 index 0000000..0eb0eb4 --- /dev/null +++ b/apps/web/e2e/playback-two-clients.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { addUrlInput, createConnectedRoom } from "./helpers/room"; + +test.describe("Playback two clients", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("second client joins same room and sees connection status", async ({ browser }) => { + const hostContext = await browser.newContext(); + const guestContext = await browser.newContext(); + const hostPage = await hostContext.newPage(); + const guestPage = await guestContext.newPage(); + + await createConnectedRoom(hostPage, "Host2"); + const roomUrl = hostPage.url(); + + await guestPage.goto(roomUrl); + await guestPage.getByLabel("Display name").fill("Guest2"); + await guestPage.getByRole("button", { name: "Join room" }).click(); + await expect(guestPage.getByTestId("connection-status")).toContainText(/Connected/i, { + timeout: 15000, + }); + await expect(hostPage.getByText(/2 listening/)).toBeVisible({ timeout: 15000 }); + + await hostContext.close(); + await guestContext.close(); + }); + + test("host adds track visible in queue area", async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await createConnectedRoom(page, "Sync"); + await addUrlInput(page).fill("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); + await addUrlInput(page).press("Enter"); + await expect(page.getByTestId("now-playing-bar")).toBeVisible({ timeout: 15000 }); + await context.close(); + }); +}); diff --git a/apps/web/e2e/presence.spec.ts b/apps/web/e2e/presence.spec.ts index a196938..15ed5bf 100644 --- a/apps/web/e2e/presence.spec.ts +++ b/apps/web/e2e/presence.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; test.describe("v0.3 — Live participant count", () => { diff --git a/apps/web/e2e/pwa-offline.spec.ts b/apps/web/e2e/pwa-offline.spec.ts new file mode 100644 index 0000000..9124100 --- /dev/null +++ b/apps/web/e2e/pwa-offline.spec.ts @@ -0,0 +1,8 @@ +import { expect, test } from "@playwright/test"; + +test.describe("PWA offline page", () => { + test("offline fallback page renders", async ({ page }) => { + await page.goto("/offline"); + await expect(page.getByRole("heading", { name: /offline|you're offline/i })).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/queue-reorder.spec.ts b/apps/web/e2e/queue-reorder.spec.ts new file mode 100644 index 0000000..4488414 --- /dev/null +++ b/apps/web/e2e/queue-reorder.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { addUrlInput } from "./helpers/room"; + +test.describe("Queue reorder", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("host can drag queue items when multiple tracks queued", async ({ page }) => { + await page.goto("/"); + await page.locator("#create-name").fill("Reorder"); + await page.getByRole("button", { name: "Create room" }).click(); + await page.waitForURL(/\/r\//); + await expect(page.getByText(/\d+ listening/)).toBeVisible({ timeout: 15000 }); + + const urls = [ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com/watch?v=9bZkp7q19f0", + ]; + for (const url of urls) { + await addUrlInput(page).fill(url); + await addUrlInput(page).press("Enter"); + await expect(page.getByRole("status").filter({ hasText: /Added/i })).toBeVisible({ + timeout: 10000, + }); + } + + await expect(page.getByTestId("queue-list").locator("[data-queue-item-id]")).toHaveCount(2, { + timeout: 15000, + }); + }); +}); diff --git a/apps/web/e2e/queue-toasts.spec.ts b/apps/web/e2e/queue-toasts.spec.ts index fb0a78c..64c24aa 100644 --- a/apps/web/e2e/queue-toasts.spec.ts +++ b/apps/web/e2e/queue-toasts.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; import { addUrlInput } from "./helpers/room"; @@ -32,8 +32,8 @@ test.describe("Phase 3.1 — Queue add toasts", () => { await addUrlInput(page).fill("lofi hip hop"); await addUrlInput(page).press("Enter"); - await expect( - page.getByRole("status").filter({ hasText: /YOUTUBE_API_KEY/i }), - ).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole("status").filter({ hasText: /YOUTUBE_API_KEY/i })).toBeVisible({ + timeout: 5000, + }); }); }); diff --git a/apps/web/e2e/queue-touch-dnd.spec.ts b/apps/web/e2e/queue-touch-dnd.spec.ts index f172438..8992c61 100644 --- a/apps/web/e2e/queue-touch-dnd.spec.ts +++ b/apps/web/e2e/queue-touch-dnd.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; import { addUrlInput, roomSidebar } from "./helpers/room"; diff --git a/apps/web/e2e/rate-limit-ux.spec.ts b/apps/web/e2e/rate-limit-ux.spec.ts new file mode 100644 index 0000000..9f456d8 --- /dev/null +++ b/apps/web/e2e/rate-limit-ux.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; + +test.describe("Rate limit UX", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("room create shows validation when name empty", async ({ page }) => { + await page.goto("/"); + await page.locator("#get-started").scrollIntoViewIfNeeded(); + await expect(page.getByRole("button", { name: "Create room" })).toBeDisabled(); + }); +}); diff --git a/apps/web/e2e/rate-limit.spec.ts b/apps/web/e2e/rate-limit.spec.ts index 4485d31..4c22a04 100644 --- a/apps/web/e2e/rate-limit.spec.ts +++ b/apps/web/e2e/rate-limit.spec.ts @@ -1,49 +1,15 @@ -import { test, expect } from "@playwright/test"; -import { - checkRateLimit, - resetRateLimitStoreForTests, - type RateLimitRule, -} from "../src/lib/rate-limit"; +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; -const rateLimitHeaders = { - "x-together-test-rate-limit": "1", - "x-together-test-ip": "rate-limit-suite", -}; - -test.describe("Phase 1.2 — Rate limiting", () => { - test.afterEach(() => { +test.describe("Phase 1.2 — Rate limiting API", () => { + test.beforeEach(() => { resetRateLimitStoreForTests(); }); - test("checkRateLimit allows requests under the limit", () => { - const rule: RateLimitRule = { - name: `unit-${Date.now()}`, - limit: 5, - windowMs: 60_000, - }; - const ip = `test-${Math.random()}`; - - for (let i = 0; i < 5; i++) { - const result = checkRateLimit(ip, rule); - expect(result.allowed).toBe(true); - expect(result.remaining).toBe(4 - i); - } - }); - - test("checkRateLimit blocks requests over the limit", () => { - const rule: RateLimitRule = { - name: `unit-block-${Date.now()}`, - limit: 2, - windowMs: 60_000, - }; - const ip = `test-block-${Math.random()}`; - expect(checkRateLimit(ip, rule).allowed).toBe(true); - expect(checkRateLimit(ip, rule).allowed).toBe(true); - const blocked = checkRateLimit(ip, rule); - expect(blocked.allowed).toBe(false); - expect(blocked.remaining).toBe(0); - expect(blocked.retryAfterSeconds).toBeGreaterThan(0); - }); + const rateLimitHeaders = { + "x-together-test-rate-limit": "1", + "x-together-test-ip": "rate-limit-suite", + }; test("POST /api/rooms returns 429 after exceeding create limit", async ({ request }) => { const displayBase = `Rate${Date.now()}`; @@ -67,9 +33,7 @@ test.describe("Phase 1.2 — Rate limiting", () => { expect(saw429).toBe(true); }); - test("POST /api/import/youtube returns 429 after exceeding import limit", async ({ - request, - }) => { + test("POST /api/import/youtube returns 429 after exceeding import limit", async ({ request }) => { let saw429 = false; for (let i = 0; i < 35; i++) { @@ -87,23 +51,4 @@ test.describe("Phase 1.2 — Rate limiting", () => { expect(saw429).toBe(true); }); - - test("POST /api/import/spotify returns 429 after exceeding import limit", async ({ - request, - }) => { - let saw429 = false; - - for (let i = 0; i < 35; i++) { - const res = await request.post("/api/import/spotify", { - headers: rateLimitHeaders, - data: { playlistId: "test-playlist" }, - }); - if (res.status() === 429) { - saw429 = true; - break; - } - } - - expect(saw429).toBe(true); - }); }); diff --git a/apps/web/e2e/reactions.spec.ts b/apps/web/e2e/reactions.spec.ts index 054efd8..55136f8 100644 --- a/apps/web/e2e/reactions.spec.ts +++ b/apps/web/e2e/reactions.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { REACTION_EMOJIS } from "@together/shared"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; diff --git a/apps/web/e2e/room.spec.ts b/apps/web/e2e/room.spec.ts index d0c92be..dcba4e7 100644 --- a/apps/web/e2e/room.spec.ts +++ b/apps/web/e2e/room.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; import { addUrlInput, roomSidebar } from "./helpers/room"; diff --git a/apps/web/e2e/settings-in-room.spec.ts b/apps/web/e2e/settings-in-room.spec.ts new file mode 100644 index 0000000..621f02f --- /dev/null +++ b/apps/web/e2e/settings-in-room.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { createConnectedRoom, openRoomSettings } from "./helpers/room"; + +test.describe("Settings in room", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("host opens settings drawer", async ({ page }) => { + await createConnectedRoom(page, "Settings"); + await openRoomSettings(page); + await expect(page.getByTestId("settings-drawer")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/settings-sync.spec.ts b/apps/web/e2e/settings-sync.spec.ts index c5d844f..e50be2b 100644 --- a/apps/web/e2e/settings-sync.spec.ts +++ b/apps/web/e2e/settings-sync.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; test.describe("Phase 1.3 — DO ↔ Postgres settings sync", () => { diff --git a/apps/web/e2e/skip-feedback.spec.ts b/apps/web/e2e/skip-feedback.spec.ts deleted file mode 100644 index 50236a9..0000000 --- a/apps/web/e2e/skip-feedback.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { shouldToastTrackSkipped } from "../src/lib/skip-feedback"; - -test.describe("Phase 3.6 — Skip vote feedback", () => { - test("shouldToastTrackSkipped when newest history entry was skipped", () => { - const history = [ - { - id: "1", - source: "youtube" as const, - videoId: "abc", - title: "Track", - addedBy: "DJ", - addedById: "p1", - finishedAt: Date.now(), - reason: "skipped" as const, - }, - ]; - expect(shouldToastTrackSkipped(history, 0)).toBe(true); - expect(shouldToastTrackSkipped(history, 1)).toBe(false); - }); -}); diff --git a/apps/web/e2e/skip-vote.spec.ts b/apps/web/e2e/skip-vote.spec.ts new file mode 100644 index 0000000..5a57ba1 --- /dev/null +++ b/apps/web/e2e/skip-vote.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { createConnectedRoom } from "./helpers/room"; + +test.describe("Skip vote bar", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("skip vote bar appears when track is playing", async ({ page }) => { + await createConnectedRoom(page, "Skip"); + const input = page.getByPlaceholder(/Paste a video\/playlist link/i).filter({ visible: true }); + await input.fill("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); + await input.press("Enter"); + await expect(page.getByTestId("skip-vote-bar")).toBeVisible({ timeout: 20000 }); + }); +}); diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts new file mode 100644 index 0000000..7cdb50b --- /dev/null +++ b/apps/web/e2e/smoke.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; +import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; +import { createConnectedRoom } from "./helpers/room"; + +test.describe("@smoke", () => { + test.beforeEach(() => { + resetRateLimitStoreForTests(); + }); + + test("landing page loads create and join forms", async ({ page }) => { + await page.goto("/"); + await page.locator("#get-started").scrollIntoViewIfNeeded(); + await expect(page.getByRole("heading", { name: "Create a room" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Create room" })).toBeVisible(); + }); + + test("creates room and connects to realtime", async ({ page }) => { + await createConnectedRoom(page, "Smoke"); + await expect(page.getByTestId("connection-status")).toContainText(/Connected/i); + }); + + test("adds a YouTube URL to the request queue", async ({ page }) => { + await createConnectedRoom(page, "SmokeAdd"); + const input = page.getByPlaceholder(/Paste a video\/playlist link/i).filter({ visible: true }); + await input.fill("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); + await input.press("Enter"); + await expect(page.getByRole("status").filter({ hasText: /Added/i })).toBeVisible({ + timeout: 15000, + }); + }); +}); diff --git a/apps/web/e2e/soundcloud-import.spec.ts b/apps/web/e2e/soundcloud-import.spec.ts index 053a7f5..384b736 100644 --- a/apps/web/e2e/soundcloud-import.spec.ts +++ b/apps/web/e2e/soundcloud-import.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; test.describe("Phase 2.2 — SoundCloud import", () => { diff --git a/apps/web/e2e/spotify-import.spec.ts b/apps/web/e2e/spotify-import.spec.ts index 1e947cb..1d6765e 100644 --- a/apps/web/e2e/spotify-import.spec.ts +++ b/apps/web/e2e/spotify-import.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; test.describe("Phase 2.1 — Spotify import", () => { diff --git a/apps/web/e2e/sync-button.spec.ts b/apps/web/e2e/sync-button.spec.ts index fdfddfb..975bef6 100644 --- a/apps/web/e2e/sync-button.spec.ts +++ b/apps/web/e2e/sync-button.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { resetRateLimitStoreForTests } from "../src/lib/rate-limit"; test.describe("Phase 3.4 — Sync button", () => { diff --git a/apps/web/e2e/visual-regression.spec.ts b/apps/web/e2e/visual-regression.spec.ts index f341665..75eadab 100644 --- a/apps/web/e2e/visual-regression.spec.ts +++ b/apps/web/e2e/visual-regression.spec.ts @@ -1,6 +1,6 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { expectLeftBeforeRight, expectNoOverlap } from "./helpers/layout"; -import { createConnectedRoom, waitForRoomUiStable, connectionStatusLocator } from "./helpers/room"; +import { connectionStatusLocator, createConnectedRoom, waitForRoomUiStable } from "./helpers/room"; const MOBILE_PROJECTS = new Set(["visual-pixel-5", "visual-iphone-13"]); const DESKTOP_PROJECTS = new Set([ @@ -96,4 +96,32 @@ test.describe("Visual regression — key screens", () => { await expect(tabs).toHaveScreenshot("room-desktop-tabs.png", screenshotOptions); }); + + test("offline fallback page", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "visual-desktop-chrome"); + await page.goto("/offline"); + await expect(page.getByRole("heading", { name: "You're offline" })).toBeVisible(); + await expect(page).toHaveScreenshot("offline-page.png", screenshotOptions); + }); + + test("private join gate", async ({ page, request }, testInfo) => { + test.skip(testInfo.project.name !== "visual-desktop-chrome"); + const res = await request.post("/api/rooms", { + data: { displayName: "VisualGate", privacy: "private", password: "secret" }, + }); + const room = await res.json(); + await page.goto(`/r/${room.slug}/join`); + await expect(page.getByRole("heading", { name: "Private room" })).toBeVisible(); + await expect(page).toHaveScreenshot("join-private-gate.png", { + ...screenshotOptions, + fullPage: true, + }); + }); + + test("settings page smoke", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "visual-desktop-chrome"); + await page.goto("/settings"); + await expect(page.getByRole("heading", { name: "Account" })).toBeVisible(); + await expect(page).toHaveScreenshot("settings-page.png", screenshotOptions); + }); }); diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/visual-desktop-chrome/join-private-gate.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/visual-desktop-chrome/join-private-gate.png new file mode 100644 index 0000000..40c81b4 Binary files /dev/null and b/apps/web/e2e/visual-regression.spec.ts-snapshots/visual-desktop-chrome/join-private-gate.png differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/visual-desktop-chrome/offline-page.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/visual-desktop-chrome/offline-page.png new file mode 100644 index 0000000..0d421fe Binary files /dev/null and b/apps/web/e2e/visual-regression.spec.ts-snapshots/visual-desktop-chrome/offline-page.png differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/visual-desktop-chrome/settings-page.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/visual-desktop-chrome/settings-page.png new file mode 100644 index 0000000..93f3fa7 Binary files /dev/null and b/apps/web/e2e/visual-regression.spec.ts-snapshots/visual-desktop-chrome/settings-page.png differ diff --git a/apps/web/e2e/volume-normalization.spec.ts b/apps/web/e2e/volume-normalization.spec.ts deleted file mode 100644 index f1099b6..0000000 --- a/apps/web/e2e/volume-normalization.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test.describe("Phase 4.5 — Volume normalization", () => { - test("uses re-apply strategy because YouTube iframe has no loudness API", async () => { - const { VOLUME_NORMALIZATION_STRATEGY: strategy } = await import( - "../src/lib/playback-volume-normalization" - ); - expect(strategy).toBe("reapply-user-volume-on-track-start"); - }); -}); diff --git a/apps/web/e2e/youtube-quality.spec.ts b/apps/web/e2e/youtube-quality.spec.ts deleted file mode 100644 index 388bd03..0000000 --- a/apps/web/e2e/youtube-quality.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { parseSpotifyPlaylistUrl } from "../src/lib/spotify"; -import { - pickBestAvailableQuality, - qualityPreferenceToYoutubeQuality, -} from "../src/lib/youtube-quality"; - -test.describe("parseSpotifyPlaylistUrl", () => { - test("parses open.spotify.com playlist URLs", () => { - expect( - parseSpotifyPlaylistUrl("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"), - ).toBe("37i9dQZF1DXcBWIGoYBM5M"); - }); - - test("parses spotify URI form", () => { - expect(parseSpotifyPlaylistUrl("spotify:playlist:abc123")).toBe("abc123"); - }); - - test("returns null for non-playlist URLs", () => { - expect(parseSpotifyPlaylistUrl("https://open.spotify.com/track/xyz")).toBeNull(); - }); -}); - -test.describe("youtube quality helpers", () => { - test("maps explicit quality preferences", () => { - expect(qualityPreferenceToYoutubeQuality("1080p")).toBe("hd1080"); - expect(qualityPreferenceToYoutubeQuality("auto")).toBeNull(); - expect(qualityPreferenceToYoutubeQuality("max")).toBeNull(); - }); - - test("picks highest quality within cap for max mode", () => { - const available = ["tiny", "medium", "hd720", "hd1080", "hd2160"]; - const picked = pickBestAvailableQuality(available, "max"); - expect(picked).toBe("hd1080"); - }); -}); diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs deleted file mode 100644 index bbd17c7..0000000 --- a/apps/web/eslint.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")]; - -export default eslintConfig; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 8a5ea09..4d5e43b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,6 @@ -import type { NextConfig } from "next"; -import { config as loadEnv } from "dotenv"; import path from "node:path"; +import { config as loadEnv } from "dotenv"; +import type { NextConfig } from "next"; // Load monorepo root .env so YOUTUBE_API_KEY etc. work without duplicating into apps/web if (process.env.TOGETHER_SKIP_ENV_FILE !== "1") { @@ -24,7 +24,12 @@ const nextConfig: NextConfig = { NEXT_PUBLIC_CF_WEB_ANALYTICS_TOKEN: publicEnv("NEXT_PUBLIC_CF_WEB_ANALYTICS_TOKEN"), NEXT_PUBLIC_SPOTIFY_CLIENT_ID: publicEnv("NEXT_PUBLIC_SPOTIFY_CLIENT_ID"), }, - transpilePackages: ["@together/ui", "@together/shared", "@together/db", "@together/track-resolver"], + transpilePackages: [ + "@together/ui", + "@together/shared", + "@together/db", + "@together/track-resolver", + ], headers: async () => [ { source: "/(.*)", diff --git a/apps/web/package.json b/apps/web/package.json index e8e14e3..d5240ff 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,9 +6,10 @@ "dev": "NODE_OPTIONS='--require ./scripts/load-root-env.cjs' next dev --turbopack", "build": "NODE_OPTIONS='--require ./scripts/load-root-env.cjs' next build", "start": "NODE_OPTIONS='--require ./scripts/load-root-env.cjs' next start", - "lint": "tsc --noEmit", + "lint": "biome check --error-on-warnings .", "typecheck": "tsc --noEmit", - "test": "playwright test --project=chromium --project=mobile-chrome", + "test:unit": "vitest run --passWithNoTests", + "test": "playwright test --project=mobile-chrome --project=chromium", "test:visual": "bash scripts/visual-regression-linux.sh test", "test:visual:update": "bash scripts/visual-regression-linux.sh update", "test:mobile": "playwright test mobile-room.spec.ts", @@ -41,8 +42,6 @@ "@types/node": "^24.0.4", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", - "eslint": "^9.29.0", - "eslint-config-next": "^15.3.4", "tailwindcss": "^4.1.11", "typescript": "^5.8.3" } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 704560f..09d81d0 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -14,6 +14,30 @@ const webServerEnv = { NEXT_PUBLIC_REALTIME_URL: "ws://127.0.0.1:8787", }; +const MOBILE_ONLY_SPECS = [ + "**/mobile-queue.spec.ts", + "**/mobile-empty-states.spec.ts", + "**/chat-mobile.spec.ts", + "**/keyboard-shortcuts-mobile.spec.ts", +]; + +const MOBILE_E2E_SPECS = [ + ...MOBILE_ONLY_SPECS, + "**/mobile-room.spec.ts", + "**/join-private.spec.ts", + "**/playback-two-clients.spec.ts", + "**/skip-vote.spec.ts", + "**/pwa-offline.spec.ts", + "**/settings-in-room.spec.ts", + "**/participants-moderation.spec.ts", + "**/chat-bidi.spec.ts", + "**/chat-mentions.spec.ts", + "**/chat-ephemeral.spec.ts", + "**/democratic-promote-flow.spec.ts", + "**/history-tab.spec.ts", + "**/smoke.spec.ts", +]; + const visualProjects = [ { name: "visual-desktop-chrome", @@ -48,25 +72,28 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: 1, - reporter: "list", - // Canonical baselines are Linux amd64 (see scripts/visual-regression-linux.sh). - snapshotPathTemplate: - "{testDir}/{testFilePath}-snapshots/{projectName}/{arg}{ext}", + reporter: process.env.CI + ? [["list"], ["html", { open: "never", outputFolder: "playwright-report" }], ["github"]] + : [["list"]], + snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{projectName}/{arg}{ext}", use: { baseURL: "http://127.0.0.1:3002", - trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + trace: "retain-on-failure", }, projects: [ - { - name: "chromium", - testIgnore: ["**/visual-regression.spec.ts", "**/mobile-room.spec.ts"], - use: { ...devices["Desktop Chrome"] }, - }, { name: "mobile-chrome", - testMatch: "**/mobile-room.spec.ts", + testMatch: MOBILE_E2E_SPECS, + testIgnore: ["**/visual-regression.spec.ts", "**/keyboard-shortcuts.spec.ts"], use: { ...devices["Pixel 5"] }, }, + { + name: "chromium", + testIgnore: ["**/visual-regression.spec.ts", "**/mobile-room.spec.ts", ...MOBILE_ONLY_SPECS], + use: { ...devices["Desktop Chrome"] }, + }, ...visualProjects, ], webServer: [ diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 73717e6..e3d3a93 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -3,15 +3,19 @@ const SHELL = ["/", "/offline", "/manifest.json", "/icon.svg"]; self.addEventListener("install", (event) => { event.waitUntil( - caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()), + caches + .open(CACHE) + .then((cache) => cache.addAll(SHELL)) + .then(() => self.skipWaiting()), ); }); self.addEventListener("activate", (event) => { event.waitUntil( - caches.keys().then((keys) => - Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))), - ).then(() => self.clients.claim()), + caches + .keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), ); }); @@ -30,8 +34,6 @@ self.addEventListener("fetch", (event) => { } if (url.origin === self.location.origin && SHELL.includes(url.pathname)) { - event.respondWith( - caches.match(request).then((cached) => cached ?? fetch(request)), - ); + event.respondWith(caches.match(request).then((cached) => cached ?? fetch(request))); } }); diff --git a/apps/web/src/app/admin/abuse/page.tsx b/apps/web/src/app/admin/abuse/page.tsx index 2034e4e..bc1d98d 100644 --- a/apps/web/src/app/admin/abuse/page.tsx +++ b/apps/web/src/app/admin/abuse/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; import Link from "next/link"; +import { useEffect, useState } from "react"; interface RoomBanSignal { id: string; @@ -141,7 +141,7 @@ function SignalTable({ {rows.map((row) => ( {row.cells.map((cell, index) => ( - + {cell} ))} @@ -149,7 +149,10 @@ function SignalTable({ ))} {rows.length === 0 && ( - + {empty} diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index ed6f3ef..482d0ac 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -1,8 +1,10 @@ -import { redirect } from "next/navigation"; -import Link from "next/link"; import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; import { getAuthedUser, isSuperadminUser } from "@/lib/admin-auth"; +export const dynamic = "force-dynamic"; + export const metadata: Metadata = { title: "Admin", robots: { index: false, follow: false }, diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index 8b02664..7fae0dc 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; import Link from "next/link"; +import { useEffect, useState } from "react"; interface AdminStats { users: number; diff --git a/apps/web/src/app/admin/rooms/page.tsx b/apps/web/src/app/admin/rooms/page.tsx index ac86671..5ad06c2 100644 --- a/apps/web/src/app/admin/rooms/page.tsx +++ b/apps/web/src/app/admin/rooms/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; import { Button } from "@together/ui"; +import { useCallback, useEffect, useState } from "react"; interface AdminRoom { id: string; @@ -17,7 +17,7 @@ export default function AdminRoomsPage() { const [rooms, setRooms] = useState([]); const [error, setError] = useState(null); - const load = () => { + const load = useCallback(() => { fetch("/api/admin/rooms") .then(async (res) => { if (!res.ok) throw new Error("Failed to load rooms"); @@ -26,11 +26,11 @@ export default function AdminRoomsPage() { }) .then(setRooms) .catch(() => setError("Failed to load rooms")); - }; + }, []); useEffect(() => { load(); - }, []); + }, [load]); const handlePurge = async (slug: string) => { if (!confirm(`Purge live state for ${slug}? Connected users will be disconnected.`)) return; @@ -74,9 +74,7 @@ export default function AdminRoomsPage() { {room.title ?? "—"} {room.ownerUserId ? "Yes" : "No"} - {room.lastActiveAt - ? new Date(room.lastActiveAt).toLocaleString() - : "—"} + {room.lastActiveAt ? new Date(room.lastActiveAt).toLocaleString() : "—"}
diff --git a/apps/web/src/app/admin/users/page.tsx b/apps/web/src/app/admin/users/page.tsx index b1a13a2..05e85b7 100644 --- a/apps/web/src/app/admin/users/page.tsx +++ b/apps/web/src/app/admin/users/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; import { Button } from "@together/ui"; +import { useCallback, useEffect, useState } from "react"; import { useSupabaseUser } from "@/hooks/use-supabase-user"; interface AdminUser { @@ -19,7 +19,7 @@ export default function AdminUsersPage() { const [users, setUsers] = useState([]); const [error, setError] = useState(null); - const load = () => { + const load = useCallback(() => { fetch("/api/admin/users") .then(async (res) => { if (!res.ok) throw new Error("Failed to load users"); @@ -28,11 +28,11 @@ export default function AdminUsersPage() { }) .then(setUsers) .catch(() => setError("Failed to load users")); - }; + }, []); useEffect(() => { load(); - }, []); + }, [load]); const toggleBan = async (user: AdminUser) => { if (user.id === userId) return; diff --git a/apps/web/src/app/api/admin/abuse/route.ts b/apps/web/src/app/api/admin/abuse/route.ts index d5d3d31..0d640e2 100644 --- a/apps/web/src/app/api/admin/abuse/route.ts +++ b/apps/web/src/app/api/admin/abuse/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { requireSuperadmin } from "@/lib/admin-auth"; import { listAbuseSignals } from "@/lib/admin-data"; +import { withApiHandler } from "@/lib/api-log"; export const GET = withApiHandler("GET /api/admin/abuse", async (log) => { const auth = await log.span("requireSuperadmin", () => requireSuperadmin()); diff --git a/apps/web/src/app/api/admin/audit/route.ts b/apps/web/src/app/api/admin/audit/route.ts index d1311a4..6c4b684 100644 --- a/apps/web/src/app/api/admin/audit/route.ts +++ b/apps/web/src/app/api/admin/audit/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { requireSuperadmin } from "@/lib/admin-auth"; import { listAdminAuditLog } from "@/lib/admin-data"; +import { withApiHandler } from "@/lib/api-log"; export const GET = withApiHandler("GET /api/admin/audit", async (log) => { const auth = await log.span("requireSuperadmin", () => requireSuperadmin()); diff --git a/apps/web/src/app/api/admin/rooms/[slug]/purge/route.ts b/apps/web/src/app/api/admin/rooms/[slug]/purge/route.ts index 8496ea3..75a742b 100644 --- a/apps/web/src/app/api/admin/rooms/[slug]/purge/route.ts +++ b/apps/web/src/app/api/admin/rooms/[slug]/purge/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { requireSuperadmin, writeAdminAuditLog } from "@/lib/admin-auth"; -import { getRoomBySlug } from "@/lib/rooms"; import { purgeRoomDurableObject } from "@/lib/admin-data"; +import { withApiHandler } from "@/lib/api-log"; +import { getRoomBySlug } from "@/lib/rooms"; export const POST = withApiHandler( "POST /api/admin/rooms/[slug]/purge", @@ -10,15 +10,13 @@ export const POST = withApiHandler( const auth = await log.span("requireSuperadmin", () => requireSuperadmin()); if (auth.error) return auth.error; - const { slug } = await context!.params!; + const { slug } = await context?.params!; const room = await log.span("getRoomBySlug", () => getRoomBySlug(slug)); if (!room) { return NextResponse.json({ error: "Room not found" }, { status: 404 }); } - const purged = await log.span("purgeRoomDurableObject", () => - purgeRoomDurableObject(room.id), - ); + const purged = await log.span("purgeRoomDurableObject", () => purgeRoomDurableObject(room.id)); await log.span("writeAdminAuditLog", () => writeAdminAuditLog({ diff --git a/apps/web/src/app/api/admin/rooms/[slug]/route.ts b/apps/web/src/app/api/admin/rooms/[slug]/route.ts index 6c37a70..a58a217 100644 --- a/apps/web/src/app/api/admin/rooms/[slug]/route.ts +++ b/apps/web/src/app/api/admin/rooms/[slug]/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { requireSuperadmin, writeAdminAuditLog } from "@/lib/admin-auth"; import { deleteRoomBySlug } from "@/lib/admin-data"; +import { withApiHandler } from "@/lib/api-log"; export const DELETE = withApiHandler( "DELETE /api/admin/rooms/[slug]", @@ -9,7 +9,7 @@ export const DELETE = withApiHandler( const auth = await log.span("requireSuperadmin", () => requireSuperadmin()); if (auth.error) return auth.error; - const { slug } = await context!.params!; + const { slug } = await context?.params!; const deleted = await log.span("deleteRoomBySlug", () => deleteRoomBySlug(slug)); if (!deleted) { return NextResponse.json({ error: "Room not found" }, { status: 404 }); diff --git a/apps/web/src/app/api/admin/rooms/route.ts b/apps/web/src/app/api/admin/rooms/route.ts index e959ab1..fde1fd1 100644 --- a/apps/web/src/app/api/admin/rooms/route.ts +++ b/apps/web/src/app/api/admin/rooms/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { requireSuperadmin } from "@/lib/admin-auth"; import { listAdminRooms } from "@/lib/admin-data"; +import { withApiHandler } from "@/lib/api-log"; export const GET = withApiHandler("GET /api/admin/rooms", async (log) => { const auth = await log.span("requireSuperadmin", () => requireSuperadmin()); diff --git a/apps/web/src/app/api/admin/stats/route.ts b/apps/web/src/app/api/admin/stats/route.ts index c4caaf7..62c5cbf 100644 --- a/apps/web/src/app/api/admin/stats/route.ts +++ b/apps/web/src/app/api/admin/stats/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { requireSuperadmin } from "@/lib/admin-auth"; import { getAdminStats } from "@/lib/admin-data"; +import { withApiHandler } from "@/lib/api-log"; export const GET = withApiHandler("GET /api/admin/stats", async (log) => { const auth = await log.span("requireSuperadmin", () => requireSuperadmin()); diff --git a/apps/web/src/app/api/admin/users/[id]/ban/route.ts b/apps/web/src/app/api/admin/users/[id]/ban/route.ts index 62c46f3..0cb3610 100644 --- a/apps/web/src/app/api/admin/users/[id]/ban/route.ts +++ b/apps/web/src/app/api/admin/users/[id]/ban/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { z } from "zod"; import { requireSuperadmin, writeAdminAuditLog } from "@/lib/admin-auth"; import { setUserBanned } from "@/lib/admin-data"; +import { withApiHandler } from "@/lib/api-log"; const schema = z.object({ banned: z.boolean() }); @@ -12,7 +12,7 @@ export const POST = withApiHandler( const auth = await log.span("requireSuperadmin", () => requireSuperadmin()); if (auth.error) return auth.error; - const { id } = await context!.params!; + const { id } = await context?.params!; if (auth.user.id === id) { return NextResponse.json({ error: "You cannot ban yourself" }, { status: 400 }); } diff --git a/apps/web/src/app/api/admin/users/route.ts b/apps/web/src/app/api/admin/users/route.ts index 38570c6..ec7d9bd 100644 --- a/apps/web/src/app/api/admin/users/route.ts +++ b/apps/web/src/app/api/admin/users/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { requireSuperadmin } from "@/lib/admin-auth"; import { listAdminUsers } from "@/lib/admin-data"; +import { withApiHandler } from "@/lib/api-log"; export const GET = withApiHandler("GET /api/admin/users", async (log) => { const auth = await log.span("requireSuperadmin", () => requireSuperadmin()); diff --git a/apps/web/src/app/api/auth/apple/route.ts b/apps/web/src/app/api/auth/apple/route.ts index 0d7453c..07fed93 100644 --- a/apps/web/src/app/api/auth/apple/route.ts +++ b/apps/web/src/app/api/auth/apple/route.ts @@ -1,6 +1,6 @@ +import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import { withApiHandler } from "@/lib/api-log"; -import { cookies } from "next/headers"; import { generateAppleMusicToken } from "@/lib/apple-music"; export const GET = withApiHandler("GET /api/auth/apple", async (_log, request) => { diff --git a/apps/web/src/app/api/auth/spotify/route.ts b/apps/web/src/app/api/auth/spotify/route.ts index 32d630b..acb98c6 100644 --- a/apps/web/src/app/api/auth/spotify/route.ts +++ b/apps/web/src/app/api/auth/spotify/route.ts @@ -1,8 +1,9 @@ // TODO(v0.3): Spotify OAuth — UI not linked until import flow is production-ready. + +import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import { withApiHandler } from "@/lib/api-log"; import { getSpotifyAuthUrl } from "@/lib/spotify"; -import { cookies } from "next/headers"; export const GET = withApiHandler("GET /api/auth/spotify", async (_log, request) => { const url = new URL(request.url); diff --git a/apps/web/src/app/api/import/apple/route.ts b/apps/web/src/app/api/import/apple/route.ts index a4012ea..10c8be2 100644 --- a/apps/web/src/app/api/import/apple/route.ts +++ b/apps/web/src/app/api/import/apple/route.ts @@ -1,17 +1,17 @@ import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { z } from "zod"; +import { withApiHandler } from "@/lib/api-log"; import { - getAppleMusicPlaylistTracks, getAppleMusicPlaylists, + getAppleMusicPlaylistTracks, getCatalogPlaylistTracks, isAppleMusicConfigured, parseAppleMusicPlaylistUrl, } from "@/lib/apple-music"; import { resolveImportTracks } from "@/lib/import-tracks"; +import { enforceRateLimit } from "@/lib/rate-limit"; import { savePlaylist } from "@/lib/rooms"; import { createSupabaseServerClient } from "@/lib/supabase-server"; -import { enforceRateLimit } from "@/lib/rate-limit"; const importRateLimit = { name: "import:apple", diff --git a/apps/web/src/app/api/import/soundcloud/route.ts b/apps/web/src/app/api/import/soundcloud/route.ts index c098960..3fe9aa0 100644 --- a/apps/web/src/app/api/import/soundcloud/route.ts +++ b/apps/web/src/app/api/import/soundcloud/route.ts @@ -1,10 +1,10 @@ // SoundCloud import — public playlist/track URLs via client ID. import { NextResponse } from "next/server"; +import { z } from "zod"; import { withApiHandler } from "@/lib/api-log"; -import { importSoundCloudUrl } from "@/lib/soundcloud"; import { resolveImportTracks } from "@/lib/import-tracks"; import { enforceRateLimit } from "@/lib/rate-limit"; -import { z } from "zod"; +import { importSoundCloudUrl } from "@/lib/soundcloud"; const importRateLimit = { name: "import:soundcloud", @@ -26,7 +26,10 @@ export const POST = withApiHandler("POST /api/import/soundcloud", async (_log, r const resolved = await resolveImportTracks(tracks, "manual"); if (resolved.length === 0) { - return NextResponse.json({ error: "No tracks found at that SoundCloud URL" }, { status: 404 }); + return NextResponse.json( + { error: "No tracks found at that SoundCloud URL" }, + { status: 404 }, + ); } return NextResponse.json(resolved); diff --git a/apps/web/src/app/api/import/spotify/route.ts b/apps/web/src/app/api/import/spotify/route.ts index 1eb12cd..98ff6e1 100644 --- a/apps/web/src/app/api/import/spotify/route.ts +++ b/apps/web/src/app/api/import/spotify/route.ts @@ -1,19 +1,19 @@ +import { cookies } from "next/headers"; import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { z } from "zod"; -import { cookies } from "next/headers"; +import { withApiHandler } from "@/lib/api-log"; +import { resolveImportTracks } from "@/lib/import-tracks"; +import { enforceRateLimit } from "@/lib/rate-limit"; +import { savePlaylist } from "@/lib/rooms"; import { - getPublicSpotifyPlaylistTracks, getPublicSpotifyPlaylistDetails, + getPublicSpotifyPlaylistTracks, getSpotifyPlaylistTracks, isSpotifyConfigured, isSpotifyOAuthEnabled, parseSpotifyPlaylistUrl, } from "@/lib/spotify"; -import { resolveImportTracks } from "@/lib/import-tracks"; -import { savePlaylist } from "@/lib/rooms"; import { createSupabaseServerClient } from "@/lib/supabase-server"; -import { enforceRateLimit } from "@/lib/rate-limit"; const importRateLimit = { name: "import:spotify", diff --git a/apps/web/src/app/api/import/youtube/route.ts b/apps/web/src/app/api/import/youtube/route.ts index 7983605..9c6e250 100644 --- a/apps/web/src/app/api/import/youtube/route.ts +++ b/apps/web/src/app/api/import/youtube/route.ts @@ -1,9 +1,9 @@ +import { parseYouTubePlaylistId, parseYouTubeVideoId } from "@together/track-resolver"; import { NextResponse } from "next/server"; +import { z } from "zod"; import { withApiHandler } from "@/lib/api-log"; -import { getYouTubeClient, importYouTubeUrl } from "@/lib/youtube"; -import { parseYouTubePlaylistId, parseYouTubeVideoId } from "@together/track-resolver"; import { enforceRateLimit } from "@/lib/rate-limit"; -import { z } from "zod"; +import { getYouTubeClient, importYouTubeUrl } from "@/lib/youtube"; const importRateLimit = { name: "import:youtube", diff --git a/apps/web/src/app/api/internal/rooms/[slug]/bans/route.ts b/apps/web/src/app/api/internal/rooms/[slug]/bans/route.ts index c00e3c4..d3b4055 100644 --- a/apps/web/src/app/api/internal/rooms/[slug]/bans/route.ts +++ b/apps/web/src/app/api/internal/rooms/[slug]/bans/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { withApiHandler } from "@/lib/api-log"; import { addRoomBan, getRoomBanIds, getRoomBySlug } from "@/lib/rooms"; -import { z } from "zod"; function authorizeInternalSync(request: Request): boolean { const secret = process.env.ROOM_TOKEN_SECRET; @@ -17,7 +17,7 @@ export const GET = withApiHandler( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { slug } = await context!.params!; + const { slug } = await context?.params!; const room = await getRoomBySlug(slug); if (!room) { return NextResponse.json({ error: "Room not found" }, { status: 404 }); @@ -41,7 +41,7 @@ export const POST = withApiHandler( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { slug } = await context!.params!; + const { slug } = await context?.params!; const room = await getRoomBySlug(slug); if (!room) { return NextResponse.json({ error: "Room not found" }, { status: 404 }); diff --git a/apps/web/src/app/api/internal/rooms/[slug]/settings/route.ts b/apps/web/src/app/api/internal/rooms/[slug]/settings/route.ts index 17c0ffd..d440c63 100644 --- a/apps/web/src/app/api/internal/rooms/[slug]/settings/route.ts +++ b/apps/web/src/app/api/internal/rooms/[slug]/settings/route.ts @@ -1,7 +1,7 @@ +import { roomSettingsSchema } from "@together/shared"; import { NextResponse } from "next/server"; import { withApiHandler } from "@/lib/api-log"; import { getRoomBySlug, updateRoomSettings } from "@/lib/rooms"; -import { roomSettingsSchema } from "@together/shared"; function authorizeInternalSync(request: Request): boolean { const secret = process.env.ROOM_TOKEN_SECRET; @@ -17,7 +17,7 @@ export const POST = withApiHandler( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { slug } = await context!.params!; + const { slug } = await context?.params!; const room = await getRoomBySlug(slug); if (!room) { return NextResponse.json({ error: "Room not found" }, { status: 404 }); diff --git a/apps/web/src/app/api/internal/rooms/[slug]/snapshot/route.ts b/apps/web/src/app/api/internal/rooms/[slug]/snapshot/route.ts index 0470c39..6166248 100644 --- a/apps/web/src/app/api/internal/rooms/[slug]/snapshot/route.ts +++ b/apps/web/src/app/api/internal/rooms/[slug]/snapshot/route.ts @@ -1,7 +1,7 @@ +import { roomLiveSnapshotSchema } from "@together/shared"; import { NextResponse } from "next/server"; import { withApiHandler } from "@/lib/api-log"; import { getRoomBySlug, saveRoomSnapshot } from "@/lib/rooms"; -import { roomLiveSnapshotSchema } from "@together/shared"; function authorizeInternalSync(request: Request): boolean { const secret = process.env.ROOM_TOKEN_SECRET; @@ -17,7 +17,7 @@ export const POST = withApiHandler( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { slug } = await context!.params!; + const { slug } = await context?.params!; const room = await getRoomBySlug(slug); if (!room) { return NextResponse.json({ error: "Room not found" }, { status: 404 }); diff --git a/apps/web/src/app/api/internal/users/[id]/banned/route.ts b/apps/web/src/app/api/internal/users/[id]/banned/route.ts index 0f7a60c..33e3a21 100644 --- a/apps/web/src/app/api/internal/users/[id]/banned/route.ts +++ b/apps/web/src/app/api/internal/users/[id]/banned/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { withApiHandler } from "@/lib/api-log"; import { isUserGloballyBanned } from "@/lib/admin-data"; +import { withApiHandler } from "@/lib/api-log"; function authorizeInternalSync(request: Request): boolean { const secret = process.env.ROOM_TOKEN_SECRET; @@ -16,7 +16,7 @@ export const GET = withApiHandler( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { id } = await context!.params!; + const { id } = await context?.params!; const banned = await isUserGloballyBanned(id); return NextResponse.json({ banned }); }, diff --git a/apps/web/src/app/api/playlists/[id]/route.ts b/apps/web/src/app/api/playlists/[id]/route.ts index a57ff1b..98a8727 100644 --- a/apps/web/src/app/api/playlists/[id]/route.ts +++ b/apps/web/src/app/api/playlists/[id]/route.ts @@ -3,21 +3,18 @@ import { withApiHandler } from "@/lib/api-log"; import { getPlaylistWithItems } from "@/lib/rooms"; import { getSupabaseServerUser } from "@/lib/supabase-server"; -export const GET = withApiHandler( - "GET /api/playlists/[id]", - async (_log, _request, context) => { - const { id } = await context!.params!; - const user = await getSupabaseServerUser(); +export const GET = withApiHandler("GET /api/playlists/[id]", async (_log, _request, context) => { + const { id } = await context?.params!; + const user = await getSupabaseServerUser(); - if (!user) { - return NextResponse.json({ error: "Authentication required" }, { status: 401 }); - } + if (!user) { + return NextResponse.json({ error: "Authentication required" }, { status: 401 }); + } - const playlist = await getPlaylistWithItems(id); - if (!playlist || playlist.userId !== user.id) { - return NextResponse.json({ error: "Not found" }, { status: 404 }); - } + const playlist = await getPlaylistWithItems(id); + if (!playlist || playlist.userId !== user.id) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } - return NextResponse.json(playlist); - }, -); + return NextResponse.json(playlist); +}); diff --git a/apps/web/src/app/api/playlists/route.ts b/apps/web/src/app/api/playlists/route.ts index d85714b..e360cde 100644 --- a/apps/web/src/app/api/playlists/route.ts +++ b/apps/web/src/app/api/playlists/route.ts @@ -1,12 +1,9 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { withApiHandler } from "@/lib/api-log"; -import { - getUserPlaylists, - savePlaylist, -} from "@/lib/rooms"; import { formatPublicDbError } from "@/lib/db-errors"; +import { getUserPlaylists, savePlaylist } from "@/lib/rooms"; import { getSupabaseServerUser } from "@/lib/supabase-server"; -import { z } from "zod"; export const GET = withApiHandler("GET /api/playlists", async (log) => { try { diff --git a/apps/web/src/app/api/resolve/route.ts b/apps/web/src/app/api/resolve/route.ts index ce24416..6a059c2 100644 --- a/apps/web/src/app/api/resolve/route.ts +++ b/apps/web/src/app/api/resolve/route.ts @@ -1,7 +1,7 @@ +import { trackMetadataSchema } from "@together/shared"; import { NextResponse } from "next/server"; import { withApiHandler } from "@/lib/api-log"; import { resolveTrackWithCache } from "@/lib/youtube"; -import { trackMetadataSchema } from "@together/shared"; export const POST = withApiHandler("POST /api/resolve", async (_log, request) => { const metadata = trackMetadataSchema.parse(await request.json()); diff --git a/apps/web/src/app/api/rooms/[slug]/access/route.ts b/apps/web/src/app/api/rooms/[slug]/access/route.ts index 5bc322e..4e3c501 100644 --- a/apps/web/src/app/api/rooms/[slug]/access/route.ts +++ b/apps/web/src/app/api/rooms/[slug]/access/route.ts @@ -1,18 +1,14 @@ import { NextResponse } from "next/server"; import { withApiHandler } from "@/lib/api-log"; +import { cookieOptions, roomAccessCookieName, roomPasswordCookieName } from "@/lib/room-access"; import { getRoomBySlug, verifyRoomPassword } from "@/lib/rooms"; -import { - cookieOptions, - roomAccessCookieName, - roomPasswordCookieName, -} from "@/lib/room-access"; import { verifyRoomToken } from "@/lib/utils"; /** Private rooms — verify password or invite token and set session cookie */ export const POST = withApiHandler( "POST /api/rooms/[slug]/access", async (_log, request, context) => { - const { slug } = await context!.params!; + const { slug } = await context?.params!; const body = (await request.json()) as { password?: string; token?: string }; const room = await getRoomBySlug(slug); @@ -31,11 +27,7 @@ export const POST = withApiHandler( } const response = NextResponse.json({ ok: true }); - response.cookies.set( - roomAccessCookieName(slug), - body.token, - cookieOptions(`/r/${slug}`), - ); + response.cookies.set(roomAccessCookieName(slug), body.token, cookieOptions(`/r/${slug}`)); return response; } diff --git a/apps/web/src/app/api/rooms/[slug]/invite/route.ts b/apps/web/src/app/api/rooms/[slug]/invite/route.ts index 157b429..8e84c01 100644 --- a/apps/web/src/app/api/rooms/[slug]/invite/route.ts +++ b/apps/web/src/app/api/rooms/[slug]/invite/route.ts @@ -6,7 +6,7 @@ import { signRoomToken } from "@/lib/utils"; export const GET = withApiHandler( "GET /api/rooms/[slug]/invite", async (_log, request, context) => { - const { slug } = await context!.params!; + const { slug } = await context?.params!; const room = await getRoomBySlug(slug); if (!room) { return NextResponse.json({ error: "Room not found" }, { status: 404 }); diff --git a/apps/web/src/app/api/rooms/[slug]/route.ts b/apps/web/src/app/api/rooms/[slug]/route.ts index 020cad4..74e011c 100644 --- a/apps/web/src/app/api/rooms/[slug]/route.ts +++ b/apps/web/src/app/api/rooms/[slug]/route.ts @@ -1,31 +1,28 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { withApiHandler } from "@/lib/api-log"; import { getRoomBySlug, updateRoomTitle } from "@/lib/rooms"; -import { z } from "zod"; const updateSchema = z.object({ title: z.string().min(1).max(64).trim(), }); -export const PATCH = withApiHandler( - "PATCH /api/rooms/[slug]", - async (_log, request, context) => { - const { slug } = await context!.params!; - const room = await getRoomBySlug(slug); +export const PATCH = withApiHandler("PATCH /api/rooms/[slug]", async (_log, request, context) => { + const { slug } = await context?.params!; + const room = await getRoomBySlug(slug); - if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); - } + if (!room) { + return NextResponse.json({ error: "Room not found" }, { status: 404 }); + } - const body = updateSchema.parse(await request.json()); - const updated = await updateRoomTitle(room.id, body.title); + const body = updateSchema.parse(await request.json()); + const updated = await updateRoomTitle(room.id, body.title); - if (!updated) { - return NextResponse.json({ error: "Failed to update room" }, { status: 500 }); - } + if (!updated) { + return NextResponse.json({ error: "Failed to update room" }, { status: 500 }); + } - return NextResponse.json({ - title: "title" in updated ? updated.title : body.title, - }); - }, -); + return NextResponse.json({ + title: "title" in updated ? updated.title : body.title, + }); +}); diff --git a/apps/web/src/app/api/rooms/[slug]/settings/route.ts b/apps/web/src/app/api/rooms/[slug]/settings/route.ts index b3ebc2a..e7a0623 100644 --- a/apps/web/src/app/api/rooms/[slug]/settings/route.ts +++ b/apps/web/src/app/api/rooms/[slug]/settings/route.ts @@ -1,20 +1,15 @@ +import { roomSettingsSchema } from "@together/shared"; import { NextResponse } from "next/server"; +import { z } from "zod"; import { withApiHandler } from "@/lib/api-log"; -import { - updateRoomSettings, - getRoomBySlug, - isMemoryStoreEnabled, - ensureUser, -} from "@/lib/rooms"; import { formatPublicDbError } from "@/lib/db-errors"; -import { roomSettingsSchema } from "@together/shared"; +import { ensureUser, getRoomBySlug, isMemoryStoreEnabled, updateRoomSettings } from "@/lib/rooms"; import { getSupabaseServerUser } from "@/lib/supabase-server"; -import { z } from "zod"; export const GET = withApiHandler( "GET /api/rooms/[slug]/settings", async (_log, _request, context) => { - const { slug } = await context!.params!; + const { slug } = await context?.params!; const room = await getRoomBySlug(slug); if (!room) { return NextResponse.json({ error: "Room not found" }, { status: 404 }); @@ -27,7 +22,7 @@ export const PATCH = withApiHandler( "PATCH /api/rooms/[slug]/settings", async (log, request, context) => { try { - const { slug } = await context!.params!; + const { slug } = await context?.params!; const room = await getRoomBySlug(slug); if (!room) { @@ -65,7 +60,7 @@ export const POST = withApiHandler( "POST /api/rooms/[slug]/settings", async (log, _request, context) => { try { - const { slug } = await context!.params!; + const { slug } = await context?.params!; const room = await getRoomBySlug(slug); if (!room) { @@ -93,10 +88,7 @@ export const POST = withApiHandler( const { eq } = await import("drizzle-orm"); const db = getDb(); - await db - .update(rooms) - .set({ ownerUserId: user.id }) - .where(eq(rooms.id, room.id)); + await db.update(rooms).set({ ownerUserId: user.id }).where(eq(rooms.id, room.id)); return NextResponse.json({ ok: true }); } catch (err) { diff --git a/apps/web/src/app/api/rooms/[slug]/transfer/route.ts b/apps/web/src/app/api/rooms/[slug]/transfer/route.ts index 06e5ee3..228cd49 100644 --- a/apps/web/src/app/api/rooms/[slug]/transfer/route.ts +++ b/apps/web/src/app/api/rooms/[slug]/transfer/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { withApiHandler } from "@/lib/api-log"; import { getRoomBySlug, transferRoomOwnership } from "@/lib/rooms"; import { getSupabaseServerUser } from "@/lib/supabase-server"; -import { z } from "zod"; const bodySchema = z.object({ targetUserId: z.string().uuid(), @@ -11,7 +11,7 @@ const bodySchema = z.object({ export const POST = withApiHandler( "POST /api/rooms/[slug]/transfer", async (_log, request, context) => { - const { slug } = await context!.params!; + const { slug } = await context?.params!; const room = await getRoomBySlug(slug); if (!room) { @@ -25,7 +25,10 @@ export const POST = withApiHandler( } if (room.ownerUserId && room.ownerUserId !== user.id) { - return NextResponse.json({ error: "Only the room owner can transfer ownership" }, { status: 403 }); + return NextResponse.json( + { error: "Only the room owner can transfer ownership" }, + { status: 403 }, + ); } const { targetUserId } = bodySchema.parse(await request.json()); diff --git a/apps/web/src/app/api/rooms/[slug]/verify/route.ts b/apps/web/src/app/api/rooms/[slug]/verify/route.ts index 32095fd..38e74e3 100644 --- a/apps/web/src/app/api/rooms/[slug]/verify/route.ts +++ b/apps/web/src/app/api/rooms/[slug]/verify/route.ts @@ -1,15 +1,12 @@ +import { PASSWORD_LOCKOUT_ATTEMPTS, PASSWORD_LOCKOUT_MINUTES } from "@together/shared"; import { NextResponse } from "next/server"; import { withApiHandler } from "@/lib/api-log"; -import { verifyRoomPassword, getRoomBySlug } from "@/lib/rooms"; -import { - PASSWORD_LOCKOUT_ATTEMPTS, - PASSWORD_LOCKOUT_MINUTES, -} from "@together/shared"; +import { getRoomBySlug, verifyRoomPassword } from "@/lib/rooms"; export const POST = withApiHandler( "POST /api/rooms/[slug]/verify", async (_log, request, context) => { - const { slug } = await context!.params!; + const { slug } = await context?.params!; const { password } = (await request.json()) as { password: string }; const room = await getRoomBySlug(slug); diff --git a/apps/web/src/app/api/rooms/public/route.ts b/apps/web/src/app/api/rooms/public/route.ts index 3bf33d0..ad8bf8c 100644 --- a/apps/web/src/app/api/rooms/public/route.ts +++ b/apps/web/src/app/api/rooms/public/route.ts @@ -1,13 +1,11 @@ import { NextResponse } from "next/server"; import { withApiHandler } from "@/lib/api-log"; -import { listPublicRooms } from "@/lib/rooms"; import { enforceRateLimit } from "@/lib/rate-limit"; import { fetchRealtimeJson } from "@/lib/realtime-server"; +import { listPublicRooms } from "@/lib/rooms"; async function fetchParticipantCount(roomId: string): Promise { - const result = await fetchRealtimeJson<{ participantCount?: number }>( - `/room/${roomId}/stats`, - ); + const result = await fetchRealtimeJson<{ participantCount?: number }>(`/room/${roomId}/stats`); if (!result.ok) return 0; return result.data.participantCount ?? 0; } @@ -33,7 +31,5 @@ export const GET = withApiHandler("GET /api/rooms/public", async (_log, request) })), ); - return NextResponse.json( - withCounts.filter((r) => r.participantCount > 0), - ); + return NextResponse.json(withCounts.filter((r) => r.participantCount > 0)); }); diff --git a/apps/web/src/app/api/rooms/route.ts b/apps/web/src/app/api/rooms/route.ts index 45a2d1a..0d28cd5 100644 --- a/apps/web/src/app/api/rooms/route.ts +++ b/apps/web/src/app/api/rooms/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { withApiHandler } from "@/lib/api-log"; -import { createRoom } from "@/lib/rooms"; -import { roomPasswordCookieName, cookieOptions } from "@/lib/room-access"; import { enforceRateLimit } from "@/lib/rate-limit"; -import { z } from "zod"; +import { cookieOptions, roomPasswordCookieName } from "@/lib/room-access"; +import { createRoom } from "@/lib/rooms"; const createRoomRateLimit = { name: "rooms:create", @@ -14,7 +14,12 @@ const createRoomRateLimit = { const createRoomSchema = z.object({ displayName: z.string().min(1).max(24), title: z.string().min(1).max(64).trim().optional(), - slug: z.string().min(3).max(32).regex(/^[a-z0-9-]+$/).optional(), + slug: z + .string() + .min(3) + .max(32) + .regex(/^[a-z0-9-]+$/) + .optional(), privacy: z.enum(["public", "unlisted", "private"]).default("unlisted"), password: z.string().min(4).max(64).optional(), settings: z.record(z.unknown()).optional(), @@ -54,8 +59,7 @@ export const POST = withApiHandler("POST /api/rooms", async (log, request) => { return response; } catch (err) { log.error("create room failed:", err); - const message = - err instanceof Error ? err.message : "Failed to create room"; + const message = err instanceof Error ? err.message : "Failed to create room"; return NextResponse.json({ error: message }, { status: 500 }); } }); diff --git a/apps/web/src/app/api/user/preferences/route.ts b/apps/web/src/app/api/user/preferences/route.ts index f96ac59..9720bd2 100644 --- a/apps/web/src/app/api/user/preferences/route.ts +++ b/apps/web/src/app/api/user/preferences/route.ts @@ -1,10 +1,10 @@ +import { userAccountPreferencesSchema } from "@together/shared"; import { NextResponse } from "next/server"; +import { z } from "zod"; import { withApiHandler } from "@/lib/api-log"; -import { getUserPreferences, saveUserPreferences } from "@/lib/rooms"; import { formatPublicDbError } from "@/lib/db-errors"; +import { getUserPreferences, saveUserPreferences } from "@/lib/rooms"; import { getSupabaseServerUser } from "@/lib/supabase-server"; -import { userAccountPreferencesSchema } from "@together/shared"; -import { z } from "zod"; export const GET = withApiHandler("GET /api/user/preferences", async (log) => { try { diff --git a/apps/web/src/app/auth/callback/route.ts b/apps/web/src/app/auth/callback/route.ts index eaabed0..50bd280 100644 --- a/apps/web/src/app/auth/callback/route.ts +++ b/apps/web/src/app/auth/callback/route.ts @@ -1,4 +1,4 @@ -import { createServerClient, type CookieOptions } from "@supabase/ssr"; +import { type CookieOptions, createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import { ensureUser } from "@/lib/rooms"; @@ -28,9 +28,7 @@ export async function GET(request: Request) { return cookieStore.getAll(); }, setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options), - ); + cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options)); }, }, }); diff --git a/apps/web/src/app/auth/callback/spotify/route.ts b/apps/web/src/app/auth/callback/spotify/route.ts index c93ec09..37811a4 100644 --- a/apps/web/src/app/auth/callback/spotify/route.ts +++ b/apps/web/src/app/auth/callback/spotify/route.ts @@ -1,6 +1,7 @@ // TODO(v0.3): Spotify OAuth callback — UI not linked until import flow is production-ready. -import { redirect } from "next/navigation"; + import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; import { exchangeSpotifyCode } from "@/lib/spotify"; export async function GET(request: Request) { diff --git a/apps/web/src/app/home-client.tsx b/apps/web/src/app/home-client.tsx index 8e6cec3..c1a54f2 100644 --- a/apps/web/src/app/home-client.tsx +++ b/apps/web/src/app/home-client.tsx @@ -1,10 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; import { Button, Input, Label } from "@together/ui"; -import Link from "next/link"; -import { getRecentRooms, type RecentRoom } from "@/lib/recent-rooms"; import { ArrowRight, Clock, @@ -26,11 +22,15 @@ import { Youtube, Zap, } from "lucide-react"; -import { getDisplayName, setDisplayName } from "@/lib/utils"; -import { useSupabaseUser } from "@/hooks/use-supabase-user"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; import { AccountNav } from "@/components/account-nav"; import { SignInModal } from "@/components/sign-in-modal"; +import { useSupabaseUser } from "@/hooks/use-supabase-user"; +import { getRecentRooms, type RecentRoom } from "@/lib/recent-rooms"; import { githubUrl, personalSiteUrl } from "@/lib/seo"; +import { getDisplayName, setDisplayName } from "@/lib/utils"; const FEATURES = [ { @@ -116,12 +116,14 @@ const STEPS = [ { step: "2", title: "Build the queue", - description: "Paste a YouTube link, search, or import a playlist. Requests flow into the DJ queue.", + description: + "Paste a YouTube link, search, or import a playlist. Requests flow into the DJ queue.", }, { step: "3", title: "Listen together", - description: "Playback stays in sync. Chat, vote to skip, and tweak your own theme while you hang out.", + description: + "Playback stays in sync. Chat, vote to skip, and tweak your own theme while you hang out.", }, ] as const; @@ -282,10 +284,7 @@ export default function HomePageClient() { {/* Hero */}
-
+
@@ -321,9 +320,7 @@ export default function HomePageClient() { Start a room -

- Free · Works on desktop and mobile -

+

Free · Works on desktop and mobile

@@ -455,11 +452,16 @@ export default function HomePageClient() { )} {/* Get started */} -
+

Get started

-

Create a new room or join one you were invited to

+

+ Create a new room or join one you were invited to +

@@ -533,7 +535,9 @@ export default function HomePageClient() {

Join a room

Enter the code from your invite link — e.g.{" "} - /r/abc12345 + + /r/abc12345 +

diff --git a/apps/web/src/app/import/soundcloud/page.tsx b/apps/web/src/app/import/soundcloud/page.tsx index 89c7543..e517f98 100644 --- a/apps/web/src/app/import/soundcloud/page.tsx +++ b/apps/web/src/app/import/soundcloud/page.tsx @@ -3,7 +3,9 @@ import SoundCloudImportClient from "./soundcloud-import-client"; export default function SoundCloudImportPage() { return ( - Loading...
}> + Loading...
} + > ); diff --git a/apps/web/src/app/import/soundcloud/soundcloud-import-client.tsx b/apps/web/src/app/import/soundcloud/soundcloud-import-client.tsx index 4624286..904e349 100644 --- a/apps/web/src/app/import/soundcloud/soundcloud-import-client.tsx +++ b/apps/web/src/app/import/soundcloud/soundcloud-import-client.tsx @@ -2,9 +2,9 @@ // TODO(v0.3): SoundCloud import page — not linked from room UI (API requires Artist Pro). -import { useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; import { Button, Input, Label } from "@together/ui"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; export default function SoundCloudImportClient() { const router = useRouter(); diff --git a/apps/web/src/app/import/spotify/page.tsx b/apps/web/src/app/import/spotify/page.tsx index 2afc05c..c95248e 100644 --- a/apps/web/src/app/import/spotify/page.tsx +++ b/apps/web/src/app/import/spotify/page.tsx @@ -3,7 +3,9 @@ import SpotifyImportClient from "./spotify-import-client"; export default function SpotifyImportPage() { return ( - Loading...
}> + Loading...
} + > ); diff --git a/apps/web/src/app/import/spotify/spotify-import-client.tsx b/apps/web/src/app/import/spotify/spotify-import-client.tsx index eb3fee9..60ba496 100644 --- a/apps/web/src/app/import/spotify/spotify-import-client.tsx +++ b/apps/web/src/app/import/spotify/spotify-import-client.tsx @@ -2,9 +2,9 @@ // TODO(v0.3): Spotify import page — not linked from room UI until OAuth flow is production-ready. -import { useEffect, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; import { Button } from "@together/ui"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; interface Playlist { id: string; @@ -53,9 +53,7 @@ export default function SpotifyImportClient() { }); const items = await res.json(); if (!res.ok) { - throw new Error( - typeof items.error === "string" ? items.error : "Spotify import failed", - ); + throw new Error(typeof items.error === "string" ? items.error : "Spotify import failed"); } sessionStorage.setItem("together_import_items", JSON.stringify(items)); @@ -100,11 +98,7 @@ export default function SpotifyImportClient() {

{p.name}

{p.trackCount} tracks

-
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 90d56f8..e9a5607 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,11 +2,11 @@ import "@together/ui/globals.css"; import type { Metadata, Viewport } from "next"; import { AuthConfigProvider } from "@/components/auth-config-provider"; import { CloudflareWebAnalytics } from "@/components/cloudflare-web-analytics"; -import { ToastProvider } from "@/components/toast"; import { ServiceWorkerRegister } from "@/components/service-worker-register"; import { ThemeBootstrap } from "@/components/theme-bootstrap"; -import { getSupabasePublicConfig } from "@/lib/supabase/public-config"; +import { ToastProvider } from "@/components/toast"; import { absoluteUrl, siteUrl } from "@/lib/seo"; +import { getSupabasePublicConfig } from "@/lib/supabase/public-config"; const title = "Together — Watch & Listen Together"; const description = @@ -57,7 +57,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) const authConfig = getSupabasePublicConfig(); return ( - + diff --git a/apps/web/src/app/offline/page.tsx b/apps/web/src/app/offline/page.tsx index 71202d9..7fe19ff 100644 --- a/apps/web/src/app/offline/page.tsx +++ b/apps/web/src/app/offline/page.tsx @@ -7,7 +7,10 @@ export default function OfflinePage() {

Open a room when you're back online. Cached pages may still load from your device.

- + Back to home
diff --git a/apps/web/src/app/opengraph-image.tsx b/apps/web/src/app/opengraph-image.tsx index 8edd33c..fa25b11 100644 --- a/apps/web/src/app/opengraph-image.tsx +++ b/apps/web/src/app/opengraph-image.tsx @@ -6,7 +6,8 @@ export const size = OG_SIZE; export const contentType = OG_CONTENT_TYPE; export default function Image() { - const appHost = process.env.NEXT_PUBLIC_APP_URL?.replace(/^https?:\/\//, "") ?? "together.chtnnhfoundation.org"; + const appHost = + process.env.NEXT_PUBLIC_APP_URL?.replace(/^https?:\/\//, "") ?? "together.chtnnhfoundation.org"; return renderOgImage({ title: "Watch and listen together", diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index a5799ba..cc0375f 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; -import HomePageClient from "./home-client"; import { absoluteUrl, githubUrl, personalSiteUrl, siteUrl } from "@/lib/seo"; +import HomePageClient from "./home-client"; const title = "Together — Watch & Listen Together"; const description = @@ -71,7 +71,9 @@ export default function HomePage() { suppressHydrationWarning dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> - Loading...
}> + Loading...} + > diff --git a/apps/web/src/app/playlists/page.tsx b/apps/web/src/app/playlists/page.tsx index 8c02c3c..349db32 100644 --- a/apps/web/src/app/playlists/page.tsx +++ b/apps/web/src/app/playlists/page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useEffect, useState } from "react"; import { Button } from "@together/ui"; import Link from "next/link"; -import { useSupabaseUser } from "@/hooks/use-supabase-user"; -import { SignInModal } from "@/components/sign-in-modal"; +import { useEffect, useState } from "react"; import { AccountNav } from "@/components/account-nav"; +import { SignInModal } from "@/components/sign-in-modal"; +import { useSupabaseUser } from "@/hooks/use-supabase-user"; interface Playlist { id: string; @@ -125,11 +125,7 @@ export default function PlaylistsPage() { ))} - setSignInOpen(false)} - returnTo="/playlists" - /> + setSignInOpen(false)} returnTo="/playlists" /> ); } diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 414cae0..fcc75f6 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -30,13 +30,13 @@ export default function PrivacyPage() {

Together ("Together," "we," "us," or "our") is a - synced watch-and-listen service available at{" "} - {appHost}. This Privacy Policy explains what information we - collect when you use Together, how we use it, and the choices you have. + synced watch-and-listen service available at {appHost}. This Privacy + Policy explains what information we collect when you use Together, how we use it, and the + choices you have.

- Together is operated by chtnnh. By using the service, you agree to the practices - described here. If you do not agree, please do not use Together. + Together is operated by chtnnh. By using the service, you agree to the practices described + here. If you do not agree, please do not use Together.

@@ -44,20 +44,20 @@ export default function PrivacyPage() {

Summary

  • - You can create and join rooms without creating an account. Optional sign-in uses - email magic links via Supabase. + You can create and join rooms without creating an account. Optional sign-in uses email + magic links via Supabase.
  • - We store room metadata, optional account data, and ephemeral realtime session state - to run the service. + We store room metadata, optional account data, and ephemeral realtime session state to + run the service.
  • - Playback uses YouTube embeds and related APIs. Optional imports may connect to - Spotify or SoundCloud when you choose to use those features. + Playback uses YouTube embeds and related APIs. Optional imports may connect to Spotify + or SoundCloud when you choose to use those features.
  • - Some preferences and identifiers are stored locally in your browser unless you sign - in to sync account preferences. + Some preferences and identifiers are stored locally in your browser unless you sign in + to sync account preferences.
@@ -68,30 +68,30 @@ export default function PrivacyPage() {

Information you provide

  • - Display name — chosen when you create or join a room. Stored in - your browser and sent to room participants while you are connected. + Display name — chosen when you create or join a room. Stored in your + browser and sent to room participants while you are connected.
  • - Room details — room title, privacy setting, optional password - (stored as a hash, not plain text), queue contents, chat messages, reactions, and - moderation actions you take as a host or co-host. + Room details — room title, privacy setting, optional password (stored + as a hash, not plain text), queue contents, chat messages, reactions, and moderation + actions you take as a host or co-host.
  • - Chat and activity — messages, emoji reactions, skip votes, and - similar in-room activity visible to other participants. + Chat and activity — messages, emoji reactions, skip votes, and similar + in-room activity visible to other participants.
  • - Account email (optional) — if you sign in with a magic link, we - receive and store your email address through Supabase to identify your account. + Account email (optional) — if you sign in with a magic link, we receive + and store your email address through Supabase to identify your account.
  • - Saved playlists (optional) — if you are signed in, playlist names - and track metadata you save are stored in our database. + Saved playlists (optional) — if you are signed in, playlist names and + track metadata you save are stored in our database.
  • - Third-party import tokens (optional) — if you connect Spotify to - import a playlist, a short-lived access token may be stored in an HTTP-only cookie - for that import session. + Third-party import tokens (optional) — if you connect Spotify to import + a playlist, a short-lived access token may be stored in an HTTP-only cookie for that + import session.
@@ -102,20 +102,20 @@ export default function PrivacyPage() { (`localStorage`) to distinguish guests for moderation, bans, and queue limits.
  • - Local preferences — theme, audio-only mode, stream quality, - volume, recent rooms, and similar settings stored in your browser unless synced to - your account when signed in. + Local preferences — theme, audio-only mode, stream quality, volume, + recent rooms, and similar settings stored in your browser unless synced to your account + when signed in.
  • - Technical data — IP address, request timestamps, and basic logs - used for security, abuse prevention, and rate limiting (for example, on room - password attempts and API routes). + Technical data — IP address, request timestamps, and basic logs used + for security, abuse prevention, and rate limiting (for example, on room password + attempts and API routes).
  • - Service analytics — aggregated, privacy-oriented usage analytics - via Cloudflare Web Analytics (page views on the web app) and Cloudflare Workers - Analytics Engine (aggregated realtime events such as joins and skips). We do not - use third-party advertising trackers. + Service analytics — aggregated, privacy-oriented usage analytics via + Cloudflare Web Analytics (page views on the web app) and Cloudflare Workers Analytics + Engine (aggregated realtime events such as joins and skips). We do not use third-party + advertising trackers.
  • Realtime session data — while a room is active, playback position, @@ -147,29 +147,29 @@ export default function PrivacyPage() {

    Together uses cookies and browser storage for essential functionality, including:

    • - Authentication cookies — when you sign in via Supabase, session - cookies maintain your logged-in state. + Authentication cookies — when you sign in via Supabase, session cookies + maintain your logged-in state.
    • Room access cookies — for private rooms, cookies remember that you entered the correct password or used a valid invite link for that room.
    • - OAuth cookies — short-lived cookies during optional Spotify import - to complete authorization securely. + OAuth cookies — short-lived cookies during optional Spotify import to + complete authorization securely.
    • - Local storage — display name, anonymous ID, UI preferences, and - recent rooms are stored locally in your browser. + Local storage — display name, anonymous ID, UI preferences, and recent + rooms are stored locally in your browser.
    • - Service worker cache — if you install or use the PWA, cached app - shell assets may be stored on your device for offline fallback. + Service worker cache — if you install or use the PWA, cached app shell + assets may be stored on your device for offline fallback.

    - You can clear cookies and local storage in your browser settings. Doing so may sign - you out, reset local preferences, or require you to re-enter room passwords. + You can clear cookies and local storage in your browser settings. Doing so may sign you + out, reset local preferences, or require you to re-enter room passwords.

    @@ -181,8 +181,7 @@ export default function PrivacyPage() {

    • - YouTube / Google — embedded players, search, and track resolution. - See{" "} + YouTube / Google — embedded players, search, and track resolution. See{" "} Google's Privacy Policy @@ -203,16 +202,15 @@ export default function PrivacyPage() { .
    • - Supabase — optional authentication and Postgres database hosting. - See{" "} + Supabase — optional authentication and Postgres database hosting. See{" "} Supabase's Privacy Policy .
    • - Cloudflare — realtime WebSocket infrastructure, Durable Object - storage, web analytics, and aggregated usage metrics. See{" "} + Cloudflare — realtime WebSocket infrastructure, Durable Object storage, + web analytics, and aggregated usage metrics. See{" "} Cloudflare's Privacy Policy @@ -236,18 +234,18 @@ export default function PrivacyPage() {

      Data retention

      • - Room and account records — kept while the room or account exists - and as needed to operate the service. Room owners may delete rooms; signed-in users - may delete saved playlists. + Room and account records — kept while the room or account exists and as + needed to operate the service. Room owners may delete rooms; signed-in users may delete + saved playlists.
      • - Realtime session data — chat buffers, live queue state, and - participant lists in Durable Object storage are temporary and tied to active or - recently active sessions. + Realtime session data — chat buffers, live queue state, and participant + lists in Durable Object storage are temporary and tied to active or recently active + sessions.
      • - Security logs and rate-limit counters — retained for a limited - period appropriate for abuse prevention, then discarded or aggregated. + Security logs and rate-limit counters — retained for a limited period + appropriate for abuse prevention, then discarded or aggregated.
      • Local browser data — remains on your device until you clear it. @@ -281,19 +279,19 @@ export default function PrivacyPage() {

        International users

        - Together is operated from the United Arab Emirates and uses infrastructure providers - that may process data in other countries. By using the service, you understand that - your information may be transferred to and processed in jurisdictions with different - data protection laws than your own. + Together is operated from the United Arab Emirates and uses infrastructure providers that + may process data in other countries. By using the service, you understand that your + information may be transferred to and processed in jurisdictions with different data + protection laws than your own.

        Changes to this policy

        - We may update this Privacy Policy from time to time. We will revise the effective date - at the top of this page when we do. Continued use of Together after changes become - effective constitutes acceptance of the updated policy. + We may update this Privacy Policy from time to time. We will revise the effective date at + the top of this page when we do. Continued use of Together after changes become effective + constitutes acceptance of the updated policy.

        diff --git a/apps/web/src/app/r/[slug]/invite/route.ts b/apps/web/src/app/r/[slug]/invite/route.ts index b40e515..e0b3eb2 100644 --- a/apps/web/src/app/r/[slug]/invite/route.ts +++ b/apps/web/src/app/r/[slug]/invite/route.ts @@ -2,10 +2,7 @@ import { NextResponse } from "next/server"; import { getRoomBySlug } from "@/lib/rooms"; /** Legacy invite URLs — unlisted rooms just need the slug */ -export async function GET( - request: Request, - { params }: { params: Promise<{ slug: string }> }, -) { +export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const url = new URL(request.url); diff --git a/apps/web/src/app/r/[slug]/join/page.tsx b/apps/web/src/app/r/[slug]/join/page.tsx index 165fa60..81739ca 100644 --- a/apps/web/src/app/r/[slug]/join/page.tsx +++ b/apps/web/src/app/r/[slug]/join/page.tsx @@ -1,5 +1,5 @@ -import { notFound, redirect } from "next/navigation"; import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; import { Suspense } from "react"; import { JoinGateClient } from "@/components/join-gate"; import { getRoomBySlug } from "@/lib/rooms"; @@ -29,7 +29,9 @@ export default async function JoinPage({ params }: JoinPageProps) { } return ( - Loading...}> + Loading...} + > ); diff --git a/apps/web/src/app/r/[slug]/opengraph-image.tsx b/apps/web/src/app/r/[slug]/opengraph-image.tsx index 0bd2637..6c6ef8b 100644 --- a/apps/web/src/app/r/[slug]/opengraph-image.tsx +++ b/apps/web/src/app/r/[slug]/opengraph-image.tsx @@ -8,7 +8,8 @@ export const contentType = OG_CONTENT_TYPE; export default async function Image({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const title = await getRoomOgTitle(slug); - const appHost = process.env.NEXT_PUBLIC_APP_URL?.replace(/^https?:\/\//, "") ?? "together.chtnnhfoundation.org"; + const appHost = + process.env.NEXT_PUBLIC_APP_URL?.replace(/^https?:\/\//, "") ?? "together.chtnnhfoundation.org"; return renderOgImage({ title, diff --git a/apps/web/src/app/r/[slug]/page.tsx b/apps/web/src/app/r/[slug]/page.tsx index c81ea67..195fbfc 100644 --- a/apps/web/src/app/r/[slug]/page.tsx +++ b/apps/web/src/app/r/[slug]/page.tsx @@ -1,12 +1,12 @@ -import { notFound, redirect } from "next/navigation"; import type { Metadata } from "next"; -import { getRoomBySlug } from "@/lib/rooms"; -import { hasPasswordCookie, verifyRoomAccess } from "@/lib/room-access"; +import { notFound, redirect } from "next/navigation"; import { RoomClient } from "@/components/room-client"; -import { getSupabaseServerUser } from "@/lib/supabase-server"; import { isUserGloballyBanned } from "@/lib/admin-data"; import { postRealtimeJson } from "@/lib/realtime-server"; +import { hasPasswordCookie, verifyRoomAccess } from "@/lib/room-access"; +import { getRoomBySlug } from "@/lib/rooms"; import { absoluteUrl } from "@/lib/seo"; +import { getSupabaseServerUser } from "@/lib/supabase-server"; interface RoomPageProps { params: Promise<{ slug: string }>; diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index a865517..51b9855 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -1,13 +1,13 @@ "use client"; -import { useEffect, useState } from "react"; import { Button, Input, Label } from "@together/ui"; import Link from "next/link"; +import { useEffect, useState } from "react"; import { useAuthConfig } from "@/components/auth-config-provider"; -import { createSupabaseBrowserClient } from "@/lib/supabase-client"; import { ThemeSelector } from "@/components/theme-selector"; -import { useUserPreferences } from "@/hooks/use-user-preferences"; import { useSupabaseUser } from "@/hooks/use-supabase-user"; +import { useUserPreferences } from "@/hooks/use-user-preferences"; +import { createSupabaseBrowserClient } from "@/lib/supabase-client"; export default function SettingsPage() { const { configured, url, anonKey } = useAuthConfig(); @@ -22,10 +22,12 @@ export default function SettingsPage() { if (!configured) return; try { const supabase = createSupabaseBrowserClient(url, anonKey); - supabase.auth.getUser().then(({ data }: { data: { user: { id: string; email?: string } | null } }) => { - setUser(data.user); - if (data.user?.email) setEmail(data.user.email); - }); + supabase.auth + .getUser() + .then(({ data }: { data: { user: { id: string; email?: string } | null } }) => { + setUser(data.user); + if (data.user?.email) setEmail(data.user.email); + }); } catch { // Handled by configured gate in UI. } @@ -94,7 +96,9 @@ export default function SettingsPage() {

        Account

        - +
        @@ -106,13 +110,19 @@ export default function SettingsPage() {

        Applies across the app

        - setPrefs({ theme })} /> + setPrefs({ theme })} + />

        Your account lets you save playlists and persist room settings.

        - +

      @@ -122,13 +124,13 @@ export default function TermsPage() {

      6. User content

      You retain ownership of content you submit, such as chat messages, display names, room - titles, and saved playlists. You grant us a non-exclusive, worldwide, royalty-free - license to host, store, reproduce, and display that content solely as needed to operate - and provide Together. + titles, and saved playlists. You grant us a non-exclusive, worldwide, royalty-free license + to host, store, reproduce, and display that content solely as needed to operate and + provide Together.

      - You represent that you have the rights necessary to submit your content and that doing - so does not violate these Terms or any third-party rights. + You represent that you have the rights necessary to submit your content and that doing so + does not violate these Terms or any third-party rights.

      @@ -160,18 +162,17 @@ export default function TermsPage() {

      We do not own third-party videos, music, or other media played in rooms. Rights holders and platform operators control availability, takedowns, and geographic restrictions. We - are not responsible for third-party content, outages, policy changes, or removal of - media. + are not responsible for third-party content, outages, policy changes, or removal of media.

      8. Intellectual property

      - The Together name, branding, website, and original software are owned by the operator - or licensors and protected by applicable intellectual property laws. The project source - code is available under the Apache License 2.0 where published, which governs use of - the code separately from these Terms for use of the hosted service. + The Together name, branding, website, and original software are owned by the operator or + licensors and protected by applicable intellectual property laws. The project source code + is available under the Apache License 2.0 where published, which governs use of the code + separately from these Terms for use of the hosted service.

      You may not copy, modify, distribute, or reverse engineer the hosted service except as @@ -183,13 +184,13 @@ export default function TermsPage() {

      9. Disclaimers

      TO THE FULLEST EXTENT PERMITTED BY LAW, TOGETHER IS PROVIDED WITHOUT WARRANTIES OF ANY - KIND, WHETHER EXPRESS OR IMPLIED, INCLUDING IMPLIED WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. + KIND, WHETHER EXPRESS OR IMPLIED, INCLUDING IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.

      - We do not warrant that the service will be uninterrupted, error-free, secure, or free - of harmful components, or that playback will remain synchronized in all network - conditions or on all devices. + We do not warrant that the service will be uninterrupted, error-free, secure, or free of + harmful components, or that playback will remain synchronized in all network conditions or + on all devices.

      @@ -197,29 +198,28 @@ export default function TermsPage() {

      10. Limitation of liability

      TO THE FULLEST EXTENT PERMITTED BY LAW, CHTNNH AND TOGETHER WILL NOT BE LIABLE FOR ANY - INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF - PROFITS, DATA, GOODWILL, OR OTHER INTANGIBLE LOSSES, ARISING FROM YOUR USE OF OR - INABILITY TO USE THE SERVICE. + INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, + DATA, GOODWILL, OR OTHER INTANGIBLE LOSSES, ARISING FROM YOUR USE OF OR INABILITY TO USE + THE SERVICE.

      - TO THE FULLEST EXTENT PERMITTED BY LAW, OUR TOTAL LIABILITY FOR ANY CLAIM RELATING TO - THE SERVICE WILL NOT EXCEED THE GREATER OF (A) THE AMOUNT YOU PAID US FOR THE SERVICE - IN THE TWELVE MONTHS BEFORE THE CLAIM OR (B) USD $100. TOGETHER IS CURRENTLY OFFERED - FREE OF CHARGE, SO THIS LIMIT WILL TYPICALLY BE USD $100. + TO THE FULLEST EXTENT PERMITTED BY LAW, OUR TOTAL LIABILITY FOR ANY CLAIM RELATING TO THE + SERVICE WILL NOT EXCEED THE GREATER OF (A) THE AMOUNT YOU PAID US FOR THE SERVICE IN THE + TWELVE MONTHS BEFORE THE CLAIM OR (B) USD $100. TOGETHER IS CURRENTLY OFFERED FREE OF + CHARGE, SO THIS LIMIT WILL TYPICALLY BE USD $100.

      - Some jurisdictions do not allow certain limitations of liability, so some of the above - may not apply to you. + Some jurisdictions do not allow certain limitations of liability, so some of the above may + not apply to you.

      11. Indemnification

      - You agree to indemnify and hold harmless chtnnh and Together from claims, damages, - losses, and expenses (including reasonable legal fees) arising from your use of the - service, your content, your rooms, or your violation of these Terms or third-party - rights. + You agree to indemnify and hold harmless chtnnh and Together from claims, damages, losses, + and expenses (including reasonable legal fees) arising from your use of the service, your + content, your rooms, or your violation of these Terms or third-party rights.

      @@ -227,8 +227,7 @@ export default function TermsPage() {

      12. Termination

      You may stop using Together at any time. We may suspend or terminate your access if you - violate these Terms, create risk for other users or the service, or where required by - law. + violate these Terms, create risk for other users or the service, or where required by law.

      Sections that by their nature should survive termination — including disclaimers, @@ -249,9 +248,9 @@ export default function TermsPage() {

      14. Changes to these Terms

      - We may update these Terms from time to time. When we do, we will revise the effective - date at the top of this page. Material changes may also be highlighted on the site. - Continued use after changes take effect constitutes acceptance of the updated Terms. + We may update these Terms from time to time. When we do, we will revise the effective date + at the top of this page. Material changes may also be highlighted on the site. Continued + use after changes take effect constitutes acceptance of the updated Terms.

      diff --git a/apps/web/src/components/account-nav.tsx b/apps/web/src/components/account-nav.tsx index b754870..454f5c8 100644 --- a/apps/web/src/components/account-nav.tsx +++ b/apps/web/src/components/account-nav.tsx @@ -1,8 +1,8 @@ "use client"; -import Link from "next/link"; -import { ListMusic, LogIn, User } from "lucide-react"; import { Button } from "@together/ui"; +import { ListMusic, LogIn, User } from "lucide-react"; +import Link from "next/link"; interface AccountNavProps { signedIn: boolean; @@ -66,11 +66,7 @@ export function AccountNav({ ) : ( - @@ -86,11 +82,7 @@ export function AccountNav({ ) : ( - diff --git a/apps/web/src/components/account-settings-modal.tsx b/apps/web/src/components/account-settings-modal.tsx index 01b9355..0cf3041 100644 --- a/apps/web/src/components/account-settings-modal.tsx +++ b/apps/web/src/components/account-settings-modal.tsx @@ -1,12 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; import { Button, Input, Label } from "@together/ui"; import Link from "next/link"; +import { useEffect, useState } from "react"; import { useAuthConfig } from "@/components/auth-config-provider"; -import { createSupabaseBrowserClient } from "@/lib/supabase-client"; import { ThemeSelector } from "@/components/theme-selector"; import type { UserPreferences } from "@/hooks/use-user-preferences"; +import { createSupabaseBrowserClient } from "@/lib/supabase-client"; interface AccountSettingsModalProps { open: boolean; @@ -138,7 +138,8 @@ export function AccountSettingsModal({ ) : !configured ? (

      - Sign-in isn't available on this server. You can still listen in rooms without an account. + Sign-in isn't available on this server. You can still listen in rooms without an + account.

      ) : ( <> diff --git a/apps/web/src/components/alternate-picker.tsx b/apps/web/src/components/alternate-picker.tsx index c993a31..1823a98 100644 --- a/apps/web/src/components/alternate-picker.tsx +++ b/apps/web/src/components/alternate-picker.tsx @@ -42,6 +42,7 @@ export function AlternatePicker({ request, onPick, onClose }: AlternatePickerPro ) : ( alternates.map((alt) => ( {mentionOpen && ( -
        {mentionMatches.map((p, index) => ( -
      • - -
      • + ))} -
      + )} ); diff --git a/apps/web/src/components/import-playlist-dialog.tsx b/apps/web/src/components/import-playlist-dialog.tsx index f256e8e..43c12fb 100644 --- a/apps/web/src/components/import-playlist-dialog.tsx +++ b/apps/web/src/components/import-playlist-dialog.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState } from "react"; import { Button, Input, Label } from "@together/ui"; -import { importRequestForQuery } from "@/lib/import-url"; +import { useState } from "react"; import { isImportPlaylist, normalizeImportResponse } from "@/lib/import-results"; +import { importRequestForQuery } from "@/lib/import-url"; export type ImportService = "youtube" | "spotify" | "soundcloud" | "apple"; diff --git a/apps/web/src/components/join-gate.tsx b/apps/web/src/components/join-gate.tsx index 6f50120..d225b13 100644 --- a/apps/web/src/components/join-gate.tsx +++ b/apps/web/src/components/join-gate.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; import { Button, Input, Label } from "@together/ui"; import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; interface JoinGateProps { slug: string; diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index 132ead6..b131866 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect } from "react"; import { Button } from "@together/ui"; import { X } from "lucide-react"; +import { useEffect } from "react"; const SHORTCUTS = [ { keys: "Space", action: "Play / pause" }, diff --git a/apps/web/src/components/legal-page.tsx b/apps/web/src/components/legal-page.tsx index 9e50caf..87d878d 100644 --- a/apps/web/src/components/legal-page.tsx +++ b/apps/web/src/components/legal-page.tsx @@ -1,25 +1,16 @@ -import Link from "next/link"; import { Button } from "@together/ui"; import { Music2 } from "lucide-react"; +import Link from "next/link"; import type { ReactNode } from "react"; const EFFECTIVE_DATE = "July 6, 2026"; -export function LegalPage({ - title, - children, -}: { - title: string; - children: ReactNode; -}) { +export function LegalPage({ title, children }: { title: string; children: ReactNode }) { return (
      - + Together @@ -33,9 +24,7 @@ export function LegalPage({

      {title}

      -

      - Effective {EFFECTIVE_DATE} -

      +

      Effective {EFFECTIVE_DATE}

      {children} diff --git a/apps/web/src/components/now-playing-bar.tsx b/apps/web/src/components/now-playing-bar.tsx index 9ac2dcf..30cc913 100644 --- a/apps/web/src/components/now-playing-bar.tsx +++ b/apps/web/src/components/now-playing-bar.tsx @@ -1,12 +1,12 @@ "use client"; -import type { ReactNode } from "react"; import type { PlaybackState, ReactionEmoji, RoomReaction } from "@together/shared"; import { SkipVoteBar, Tooltip, TooltipContent, TooltipTrigger } from "@together/ui"; import { Pause, Play, SkipForward } from "lucide-react"; +import type { ReactNode } from "react"; +import { NowPlayingReactions } from "@/components/now-playing-reactions"; import { PlaybackSeekBar } from "@/components/playback-seek-bar"; import { PlaybackVolumeControl } from "@/components/playback-volume-control"; -import { NowPlayingReactions } from "@/components/now-playing-reactions"; import { useMediaQuery } from "@/hooks/use-media-query"; interface NowPlayingBarProps { @@ -122,11 +122,7 @@ export function NowPlayingBar({ >
      {thumbnailUrl ? ( - + ) : (
      No art @@ -136,9 +132,7 @@ export function NowPlayingBar({

      {displayTitle}

      - {artist && ( -

      {artist}

      - )} + {artist &&

      {artist}

      }
      +
      {!reducedMotion && (
      {floating.map((reaction) => ( diff --git a/apps/web/src/components/participants-panel.tsx b/apps/web/src/components/participants-panel.tsx index fe88758..25a924e 100644 --- a/apps/web/src/components/participants-panel.tsx +++ b/apps/web/src/components/participants-panel.tsx @@ -1,7 +1,7 @@ "use client"; -import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@together/ui"; import type { Participant } from "@together/shared"; +import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@together/ui"; import { Crown, Shield, ShieldOff, UserX } from "lucide-react"; interface ParticipantsPanelProps { @@ -34,7 +34,7 @@ export function ParticipantsPanel({ ); return ( -
      +
      {isRoomOwner && (

      {transferTargets.length > 0 @@ -52,7 +52,9 @@ export function ParticipantsPanel({ {p.displayName} {p.id === currentId && " (you)"}

      -

      {p.role.replace("-", " ")}

      +

      + {p.role.replace("-", " ")} +

      {isHost && p.id !== currentId && p.role !== "host" && (
      diff --git a/apps/web/src/components/playback-embed-error-banner.tsx b/apps/web/src/components/playback-embed-error-banner.tsx index 899c151..66b6e40 100644 --- a/apps/web/src/components/playback-embed-error-banner.tsx +++ b/apps/web/src/components/playback-embed-error-banner.tsx @@ -26,12 +26,7 @@ export function PlaybackEmbedErrorBanner({

      {message}

      {canPickAlternate && onPickAlternate && ( - )} diff --git a/apps/web/src/components/playback-seek-bar.tsx b/apps/web/src/components/playback-seek-bar.tsx index 8f51452..661de6d 100644 --- a/apps/web/src/components/playback-seek-bar.tsx +++ b/apps/web/src/components/playback-seek-bar.tsx @@ -1,8 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { getEffectivePlaybackPosition } from "@together/shared"; import type { PlaybackState } from "@together/shared"; +import { getEffectivePlaybackPosition } from "@together/shared"; +import { useCallback, useEffect, useRef, useState } from "react"; function formatTime(ms: number): string { const totalSec = Math.max(0, Math.floor(ms / 1000)); diff --git a/apps/web/src/components/playback-volume-control.tsx b/apps/web/src/components/playback-volume-control.tsx index d25be8a..9536abc 100644 --- a/apps/web/src/components/playback-volume-control.tsx +++ b/apps/web/src/components/playback-volume-control.tsx @@ -1,8 +1,8 @@ "use client"; -import { useCallback, useRef, useState } from "react"; import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@together/ui"; import { Volume2, VolumeX } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; interface PlaybackVolumeControlProps { volume: number; diff --git a/apps/web/src/components/playlist-picker-dialog.tsx b/apps/web/src/components/playlist-picker-dialog.tsx index ec8c8f1..0f74d42 100644 --- a/apps/web/src/components/playlist-picker-dialog.tsx +++ b/apps/web/src/components/playlist-picker-dialog.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; import { Button } from "@together/ui"; +import { useEffect, useState } from "react"; interface PlaylistSummary { id: string; @@ -12,15 +12,17 @@ interface PlaylistSummary { interface PlaylistPickerDialogProps { open: boolean; onClose: () => void; - onLoad: (items: Array<{ - source: string; - videoId: string | null; - title: string; - artist?: string; - durationMs?: number; - confidence?: number; - alternates?: unknown; - }>) => void; + onLoad: ( + items: Array<{ + source: string; + videoId: string | null; + title: string; + artist?: string; + durationMs?: number; + confidence?: number; + alternates?: unknown; + }>, + ) => void; } export function PlaylistPickerDialog({ open, onClose, onLoad }: PlaylistPickerDialogProps) { diff --git a/apps/web/src/components/playlists-modal.tsx b/apps/web/src/components/playlists-modal.tsx index 0b549c8..45986e7 100644 --- a/apps/web/src/components/playlists-modal.tsx +++ b/apps/web/src/components/playlists-modal.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; import { Button } from "@together/ui"; -import { useSupabaseUser } from "@/hooks/use-supabase-user"; +import { useEffect, useState } from "react"; import type { ImportedTrack } from "@/components/import-playlist-dialog"; +import { useSupabaseUser } from "@/hooks/use-supabase-user"; interface PlaylistSummary { id: string; diff --git a/apps/web/src/components/queue-loop-button.tsx b/apps/web/src/components/queue-loop-button.tsx index 2bb3401..b50188f 100644 --- a/apps/web/src/components/queue-loop-button.tsx +++ b/apps/web/src/components/queue-loop-button.tsx @@ -8,10 +8,7 @@ type LoopMode = RoomSettings["loopMode"]; const LOOP_CYCLE: LoopMode[] = ["off", "queue", "track"]; -const LOOP_CONFIG: Record< - LoopMode, - { icon: typeof Repeat; label: string; tooltip: string } -> = { +const LOOP_CONFIG: Record = { off: { icon: ListOrdered, label: "Go through queue", diff --git a/apps/web/src/components/room-client.tsx b/apps/web/src/components/room-client.tsx index b51a8bb..8f1ca1a 100644 --- a/apps/web/src/components/room-client.tsx +++ b/apps/web/src/components/room-client.tsx @@ -1,76 +1,80 @@ "use client"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; +import type { HistoryItem, RequestItem, RoomActivity, RoomReaction } from "@together/shared"; +import { + getEffectivePlaybackPosition, + type RoomSettings, + roomSettingsSchema, +} from "@together/shared"; import { Button, + HistoryList, + Input, + Label, QueueList, RequestList, - HistoryList, Tabs, TabsContent, TabsList, TabsTrigger, - Input, - Label, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@together/ui"; import { - Settings, - Users, - Plus, - Music2, + FolderOpen, + History, ListMusic, LogIn, MessageSquare, - History, + Music2, + Plus, RefreshCw, Save, - FolderOpen, + Settings, User, + Users, } from "lucide-react"; -import { PlaybackEmbedErrorBanner } from "@/components/playback-embed-error-banner"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { AccountNav } from "@/components/account-nav"; +import { AccountSettingsModal } from "@/components/account-settings-modal"; +import { AlternatePicker } from "@/components/alternate-picker"; import { ConnectionStatus } from "@/components/connection-status"; -import { embedErrorMessage, isEmbedBlockedError } from "@/lib/playback-embed-error"; -import { useRoomSocket } from "@/hooks/use-room-socket"; -import { useYouTubePlayer } from "@/hooks/use-youtube-player"; +import { DiscordStatusButton, useDiscordStatus } from "@/components/discord-status-button"; import { ChatInput, ChatMessages } from "@/components/emoji-chat"; +import { KeyboardShortcutsHelp } from "@/components/keyboard-shortcuts-help"; import { NowPlayingBar } from "@/components/now-playing-bar"; +import { ParticipantsPanel } from "@/components/participants-panel"; +import { PlaybackEmbedErrorBanner } from "@/components/playback-embed-error-banner"; +import { PlaylistsModal } from "@/components/playlists-modal"; import { QueueLoopButton } from "@/components/queue-loop-button"; -import { KeyboardShortcutsHelp } from "@/components/keyboard-shortcuts-help"; +import { RoomMobileMoreMenu } from "@/components/room-mobile-header"; import { SettingsDrawer } from "@/components/room-settings"; -import { useUserPreferences } from "@/hooks/use-user-preferences"; +import { SavePlaylistDialog } from "@/components/save-playlist-dialog"; +import { ShareInviteButton, useShareInvite } from "@/components/share-invite-button"; +import { SignInModal } from "@/components/sign-in-modal"; +import { useToast } from "@/components/toast"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useOnClickOutside } from "@/hooks/use-on-click-outside"; +import { useRoomSocket } from "@/hooks/use-room-socket"; +import { useSupabaseUser } from "@/hooks/use-supabase-user"; +import { useUserPreferences } from "@/hooks/use-user-preferences"; import { useVisualViewportHeight } from "@/hooks/use-visual-viewport-height"; -import { AlternatePicker } from "@/components/alternate-picker"; -import { ParticipantsPanel } from "@/components/participants-panel"; -import { getDisplayName, setDisplayName } from "@/lib/utils"; -import { ShareInviteButton, useShareInvite } from "@/components/share-invite-button"; -import { DiscordStatusButton, useDiscordStatus } from "@/components/discord-status-button"; -import { SavePlaylistDialog } from "@/components/save-playlist-dialog"; -import { PlaylistsModal } from "@/components/playlists-modal"; -import { AccountSettingsModal } from "@/components/account-settings-modal"; -import { importRequestForQuery, isLikelyUrl } from "@/lib/import-url"; +import { useYouTubePlayer } from "@/hooks/use-youtube-player"; import { + type ImportResult, + type ImportTrackResult, isImportPlaylist, normalizeImportResponse, shouldShowImportPicker, - type ImportResult, - type ImportTrackResult, } from "@/lib/import-results"; -import { useOnClickOutside } from "@/hooks/use-on-click-outside"; -import { useToast } from "@/components/toast"; -import { useSupabaseUser } from "@/hooks/use-supabase-user"; +import { importRequestForQuery, isLikelyUrl } from "@/lib/import-url"; +import { embedErrorMessage, isEmbedBlockedError } from "@/lib/playback-embed-error"; import { recordRecentRoom } from "@/lib/recent-rooms"; -import { SignInModal } from "@/components/sign-in-modal"; -import { AccountNav } from "@/components/account-nav"; -import { RoomMobileMoreMenu } from "@/components/room-mobile-header"; -import type { HistoryItem, RequestItem, RoomActivity, RoomReaction } from "@together/shared"; -import { getEffectivePlaybackPosition, roomSettingsSchema, type RoomSettings } from "@together/shared"; import { shouldToastTrackSkipped } from "@/lib/skip-feedback"; +import { getDisplayName, setDisplayName } from "@/lib/utils"; function ImportResultRow({ item, @@ -203,7 +207,20 @@ export function RoomClient({ const { prefs: userPrefs, setPrefs: setUserPrefs } = useUserPreferences(signedIn); const viewportHeight = useVisualViewportHeight(); - const { connected, synced, roomState, send, participant, isHost, canControlPlayback, error, offline, clockOffsetMs, chatNotice, dismissChatNotice } = useRoomSocket({ + const { + connected, + synced, + roomState, + send, + participant, + isHost, + canControlPlayback, + error, + offline, + clockOffsetMs, + chatNotice, + dismissChatNotice, + } = useRoomSocket({ roomId, displayName, userId, @@ -275,7 +292,7 @@ export function RoomClient({ useEffect(() => { lastEndedReportRef.current = null; setEmbedError(null); - }, [playback?.queueItemId]); + }, []); const currentQueueItem = playback?.queueItemId != null @@ -291,23 +308,28 @@ export function RoomClient({ [toast], ); - const { ready, resyncView, needsUserGesture, unlockPlayback, durationMs: playerDurationMs } = - useYouTubePlayer({ - containerId: "youtube-player", - playback, - clockOffsetMs, - quality: userPrefs.quality, - audioOnly: userPrefs.audioOnly, - volume: userPrefs.volume, - muted: userPrefs.muted, - onEnded: handlePlaybackEnded, - onError: handleYouTubeError, - }); + const { + ready, + resyncView, + needsUserGesture, + unlockPlayback, + durationMs: playerDurationMs, + } = useYouTubePlayer({ + containerId: "youtube-player", + playback, + clockOffsetMs, + quality: userPrefs.quality, + audioOnly: userPrefs.audioOnly, + volume: userPrefs.volume, + muted: userPrefs.muted, + onEnded: handlePlaybackEnded, + onError: handleYouTubeError, + }); // Keep player aligned when toggling audio/video view without changing play/pause useEffect(() => { if (ready) resyncView(); - }, [userPrefs.audioOnly, ready, resyncView]); + }, [ready, resyncView]); const handleSyncPlayback = useCallback(() => { send({ type: "playback:sync", positionMs: 0 }); @@ -319,7 +341,7 @@ export function RoomClient({ useEffect(() => { didInitialPlaybackSyncRef.current = false; - }, [playback?.queueItemId]); + }, []); useEffect(() => { if (!ready || !connected || didInitialPlaybackSyncRef.current) return; @@ -700,10 +722,7 @@ export function RoomClient({ const queueTabToolbar = (
      {canEditLoop ? ( - + ) : ( )} @@ -749,8 +768,7 @@ export function RoomClient({ }; const canSkip = - !!roomState?.queue.some((i) => i.id === playback?.queueItemId) || - !!roomState?.skipVotes; + !!roomState?.queue.some((i) => i.id === playback?.queueItemId) || !!roomState?.skipVotes; const nowPlayingBar = ( 0 ? (
        {searchResults.map((item) => ( - + ))}
      ) : null; @@ -872,23 +892,18 @@ export function RoomClient({ onKeyDown={(e) => e.key === "Enter" && handleAddUrl()} className="min-w-0 flex-1" /> -
      - {addError && ( -

      {addError}

      - )} + {addError &&

      {addError}

      } {searchResults && searchResults.length > 0 && (
        {searchResults.map((item) => ( @@ -901,9 +916,7 @@ export function RoomClient({ variant="secondary" size="sm" className="shrink-0 whitespace-nowrap" - onClick={() => - signedIn ? setPlaylistsModalOpen(true) : openSignIn() - } + onClick={() => (signedIn ? setPlaylistsModalOpen(true) : openSignIn())} > Load Saved Playlists @@ -912,14 +925,16 @@ export function RoomClient({
      - + Requests Queue History - - Chat{tabBadge(unreadChat)} - + Chat{tabBadge(unreadChat)} @@ -929,18 +944,16 @@ export function RoomClient({ {queueTabToolbar}
      - send({ type: "queue:remove", itemId: id, lane: "queue" })} - hideClearAll - onPlay={(id) => send({ type: "queue:play", itemId: id })} - onReorder={(itemId, newIndex) => - send({ type: "queue:reorder", itemId, newIndex }) - } - /> + send({ type: "queue:remove", itemId: id, lane: "queue" })} + hideClearAll + onPlay={(id) => send({ type: "queue:play", itemId: id })} + onReorder={(itemId, newIndex) => send({ type: "queue:reorder", itemId, newIndex })} + />
      @@ -964,12 +977,8 @@ export function RoomClient({ isRoomOwner={isRoomHost} onKick={(id) => send({ type: "moderation:kick", participantId: id })} onBan={(id) => send({ type: "moderation:ban", participantId: id })} - onPromote={(id) => - send({ type: "moderation:promote", participantId: id, role: "co-host" }) - } - onDemote={(id) => - send({ type: "moderation:promote", participantId: id, role: "guest" }) - } + onPromote={(id) => send({ type: "moderation:promote", participantId: id, role: "co-host" })} + onDemote={(id) => send({ type: "moderation:promote", participantId: id, role: "guest" })} onTransferOwnership={handleTransferOwnership} />
      @@ -1020,308 +1029,309 @@ export function RoomClient({ return ( -
      -
      -
      -
      -
      -

      - {roomTitle} -

      - -
      -
      - - - - - Sync playback - -
      - - {participantsOpen && participantsPanel} -
      -
      - setSettingsOpen(true)} - menuExtras={mobileMenuExtras} - /> -
      -
      - +
      +
      +
      +
      +

      + {roomTitle} +

      + - - - signedIn ? setPlaylistsModalOpen(true) : openSignIn() - } - onAccountClick={() => setAccountModalOpen(true)} - /> +
      +
      - Settings + Sync playback +
      + + {participantsOpen && participantsPanel} +
      +
      + setSettingsOpen(true)} + menuExtras={mobileMenuExtras} + /> +
      +
      + + + (signedIn ? setPlaylistsModalOpen(true) : openSignIn())} + onAccountClick={() => setAccountModalOpen(true)} + /> + + + + + Settings + +
      -
      -
      +
      -
      -
      e.preventDefault()} - onClick={needsUserGesture ? unlockPlayback : undefined} - /> - {needsUserGesture && playback?.playing && ( -
      - -
      - )} - {embedError && ( - { - if (!currentQueueItem) return; - setPickRequest({ - ...currentQueueItem, - status: "needs_pick", - } as RequestItem); - }} - onDismiss={() => setEmbedError(null)} + className={`relative w-full shrink-0 bg-black ${ + userPrefs.audioOnly ? "hidden" : "aspect-video md:min-h-0 md:flex-1 md:aspect-auto" + }`} + > +
      +
      e.preventDefault()} + onClick={needsUserGesture ? unlockPlayback : undefined} /> + {needsUserGesture && playback?.playing && ( +
      + +
      + )} + {embedError && ( + { + if (!currentQueueItem) return; + setPickRequest({ + ...currentQueueItem, + status: "needs_pick", + } as RequestItem); + }} + onDismiss={() => setEmbedError(null)} + /> + )} +
      + + {userPrefs.audioOnly && ( +
      + +

      + {playback?.title ?? "Nothing playing"} +

      +
      )} -
      - {userPrefs.audioOnly && ( -
      - -

      {playback?.title ?? "Nothing playing"}

      +
      + {mobileTab === "chat" ? ( +
      {chatPanel}
      + ) : ( + <> +
      + {mobileTab === "requests" && } + {mobileTab === "queue" && ( + <> + {queueTabToolbar} + send({ type: "queue:remove", itemId: id, lane: "queue" })} + hideClearAll + onPlay={(id) => send({ type: "queue:play", itemId: id })} + onReorder={(itemId, newIndex) => + send({ type: "queue:reorder", itemId, newIndex }) + } + /> + + )} + {mobileTab === "history" && ( + + )} +
      + {(mobileTab === "requests" || mobileTab === "queue") && addTrackFooter} + + )}
      - )} -
      - {mobileTab === "chat" ? ( -
      {chatPanel}
      - ) : ( - <> -
      - {mobileTab === "requests" && ( - - )} - {mobileTab === "queue" && ( - <> - {queueTabToolbar} - send({ type: "queue:remove", itemId: id, lane: "queue" })} - hideClearAll - onPlay={(id) => send({ type: "queue:play", itemId: id })} - onReorder={(itemId, newIndex) => - send({ type: "queue:reorder", itemId, newIndex }) - } - /> - - )} - {mobileTab === "history" && ( - - )} -
      - {(mobileTab === "requests" || mobileTab === "queue") && addTrackFooter} - - )} -
      +
      + {playbackControls} +
      -
      - {playbackControls} +
      - -
      - -
      - {sidebar} -
      +
      + {sidebar} +
      - {shortcutsOpen && setShortcutsOpen(false)} />} + {shortcutsOpen && setShortcutsOpen(false)} />} + + {settingsOpen && ( + { + send({ type: "settings:update", settings: s }); + if (isHost) { + void fetch(`/api/rooms/${slug}/settings`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(s), + }); + } + }} + onRoomTitleUpdate={handleRoomTitleUpdate} + onUserPrefsUpdate={setUserPrefs} + onClose={closeSettings} + onSignIn={openSignIn} + onClaim={handleClaimRoom} + /> + )} - {settingsOpen && ( - { - send({ type: "settings:update", settings: s }); - if (isHost) { - void fetch(`/api/rooms/${slug}/settings`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(s), - }); - } - }} - onRoomTitleUpdate={handleRoomTitleUpdate} - onUserPrefsUpdate={setUserPrefs} - onClose={closeSettings} - onSignIn={openSignIn} - onClaim={handleClaimRoom} + setSignInOpen(false)} + returnTo={`/r/${slug}`} /> - )} - setSignInOpen(false)} - returnTo={`/r/${slug}`} - /> + {pickRequest && ( + { + if (playback && pickRequest.id === playback.queueItemId) { + onPlaybackChange({ videoId, title }); + setEmbedError(null); + } else { + send({ + type: "resolve:pick", + requestId: pickRequest.id, + videoId, + title, + }); + } + setPickRequest(null); + }} + onClose={() => setPickRequest(null)} + /> + )} - {pickRequest && ( - { - if (playback && pickRequest.id === playback.queueItemId) { - onPlaybackChange({ videoId, title }); - setEmbedError(null); - } else { - send({ - type: "resolve:pick", - requestId: pickRequest.id, - videoId, - title, - }); - } - setPickRequest(null); - }} - onClose={() => setPickRequest(null)} + setSavePlaylistOpen(false)} + onSaved={(name) => toast(`Saved "${name}"`, "success")} /> - )} - setSavePlaylistOpen(false)} - onSaved={(name) => toast(`Saved "${name}"`, "success")} - /> - - setPlaylistsModalOpen(false)} - onLoad={importPlaylistItems} - onSignIn={openSignIn} - /> + setPlaylistsModalOpen(false)} + onLoad={importPlaylistItems} + onSignIn={openSignIn} + /> - setAccountModalOpen(false)} - userPrefs={userPrefs} - onUserPrefsUpdate={setUserPrefs} - /> + setAccountModalOpen(false)} + userPrefs={userPrefs} + onUserPrefsUpdate={setUserPrefs} + /> - {error && ( -
      - {error} -
      - )} -
      + {error && ( +
      + {error} +
      + )} +
      ); } diff --git a/apps/web/src/components/room-mobile-header.tsx b/apps/web/src/components/room-mobile-header.tsx index d959957..bffaa19 100644 --- a/apps/web/src/components/room-mobile-header.tsx +++ b/apps/web/src/components/room-mobile-header.tsx @@ -1,14 +1,8 @@ "use client"; -import { useRef, useState, type ReactNode } from "react"; import { Button } from "@together/ui"; -import { - MessageSquareQuote, - MoreHorizontal, - RefreshCw, - Settings, - Share2, -} from "lucide-react"; +import { MessageSquareQuote, MoreHorizontal, Settings, Share2 } from "lucide-react"; +import { type ReactNode, useRef, useState } from "react"; import { useOnClickOutside } from "@/hooks/use-on-click-outside"; interface RoomMobileMoreMenuProps { diff --git a/apps/web/src/components/room-settings.tsx b/apps/web/src/components/room-settings.tsx index a3ff1a5..6613677 100644 --- a/apps/web/src/components/room-settings.tsx +++ b/apps/web/src/components/room-settings.tsx @@ -1,9 +1,11 @@ "use client"; -import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import type { RoomSettings } from "@together/shared"; +import { QUALITY_OPTIONS } from "@together/shared"; import { Button, + getThemeVars, + Input, Label, Select, SelectContent, @@ -11,16 +13,14 @@ import { SelectTrigger, SelectValue, Switch, - Input, - getThemeVars, } from "@together/ui"; -import type { RoomSettings } from "@together/shared"; -import { QUALITY_OPTIONS } from "@together/shared"; -import type { UserPreferences } from "@/hooks/use-user-preferences"; +import { X } from "lucide-react"; +import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; import { PlaybackVolumeControl } from "@/components/playback-volume-control"; import { ThemeSelector } from "@/components/theme-selector"; import { useFocusTrap } from "@/hooks/use-focus-trap"; -import { X } from "lucide-react"; +import type { UserPreferences } from "@/hooks/use-user-preferences"; interface SettingsDrawerProps { roomSettings: RoomSettings; @@ -59,9 +59,7 @@ function SettingRow({
      - {description && ( -

      {description}

      - )} + {description &&

      {description}

      }
      {children}
      @@ -109,6 +107,7 @@ export function SettingsDrawer({ >
      e.stopPropagation()} role="dialog" @@ -210,9 +209,7 @@ export function SettingsDrawer({ onRoomUpdate({ slowModeSeconds: parseInt(v) })} + onValueChange={(v) => onRoomUpdate({ slowModeSeconds: parseInt(v, 10) })} > @@ -355,9 +350,7 @@ export function SettingsDrawer({ onChange(v as NonNullable)}> +