From 97a2a6fdd3ac1ac0fe2415ad1c091b230ad01038 Mon Sep 17 00:00:00 2001 From: chtnnh <59027776+chtnnh@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:12:02 +0400 Subject: [PATCH 1/8] chore(biome): replace ESLint with Biome and format codebase Adopt Biome 2 for lint/format, remove apps/web ESLint config, and apply formatter across the monorepo. Lint scripts fail on warnings via --error-on-warnings. Know-Code-Verified: ae969e00cd2b7bbbd629d7b9a8a0337228c546cb0d52dc0cdfd24f8080c3ac1f --- apps/web/e2e/account-modal.spec.ts | 2 +- apps/web/e2e/admin.spec.ts | 2 +- apps/web/e2e/app.spec.ts | 6 +- apps/web/e2e/audio-only.spec.ts | 2 +- apps/web/e2e/background-playback.spec.ts | 2 +- apps/web/e2e/chat-ephemeral.spec.ts | 13 +- apps/web/e2e/connection-status.spec.ts | 16 +- apps/web/e2e/embed-error.spec.ts | 7 +- apps/web/e2e/empty-states.spec.ts | 2 +- apps/web/e2e/import-public.spec.ts | 2 +- apps/web/e2e/internal-snapshot.spec.ts | 6 +- apps/web/e2e/keyboard-shortcuts.spec.ts | 2 +- apps/web/e2e/mobile-room.spec.ts | 12 +- apps/web/e2e/now-playing-bar.spec.ts | 2 +- apps/web/e2e/og-image.spec.ts | 2 +- apps/web/e2e/presence.spec.ts | 2 +- apps/web/e2e/queue-toasts.spec.ts | 8 +- apps/web/e2e/queue-touch-dnd.spec.ts | 2 +- apps/web/e2e/rate-limit.spec.ts | 73 +- apps/web/e2e/reactions.spec.ts | 2 +- apps/web/e2e/room.spec.ts | 2 +- apps/web/e2e/settings-sync.spec.ts | 2 +- apps/web/e2e/soundcloud-import.spec.ts | 2 +- apps/web/e2e/spotify-import.spec.ts | 2 +- apps/web/e2e/sync-button.spec.ts | 2 +- apps/web/eslint.config.mjs | 14 - apps/web/package.json | 7 +- apps/web/public/sw.js | 16 +- apps/web/src/app/admin/page.tsx | 2 +- apps/web/src/app/api/admin/abuse/route.ts | 2 +- apps/web/src/app/api/admin/audit/route.ts | 2 +- .../app/api/admin/rooms/[slug]/purge/route.ts | 10 +- .../src/app/api/admin/rooms/[slug]/route.ts | 4 +- apps/web/src/app/api/admin/rooms/route.ts | 2 +- apps/web/src/app/api/admin/stats/route.ts | 2 +- .../src/app/api/admin/users/[id]/ban/route.ts | 4 +- apps/web/src/app/api/admin/users/route.ts | 2 +- apps/web/src/app/api/auth/apple/route.ts | 2 +- apps/web/src/app/api/auth/spotify/route.ts | 3 +- apps/web/src/app/api/import/apple/route.ts | 6 +- .../src/app/api/import/soundcloud/route.ts | 9 +- apps/web/src/app/api/import/spotify/route.ts | 12 +- apps/web/src/app/api/import/youtube/route.ts | 6 +- .../api/internal/rooms/[slug]/bans/route.ts | 6 +- .../internal/rooms/[slug]/settings/route.ts | 4 +- .../internal/rooms/[slug]/snapshot/route.ts | 4 +- .../api/internal/users/[id]/banned/route.ts | 4 +- apps/web/src/app/api/playlists/[id]/route.ts | 27 +- apps/web/src/app/api/playlists/route.ts | 7 +- apps/web/src/app/api/resolve/route.ts | 2 +- .../src/app/api/rooms/[slug]/access/route.ts | 14 +- .../src/app/api/rooms/[slug]/invite/route.ts | 2 +- apps/web/src/app/api/rooms/[slug]/route.ts | 35 +- .../app/api/rooms/[slug]/settings/route.ts | 22 +- .../app/api/rooms/[slug]/transfer/route.ts | 9 +- .../src/app/api/rooms/[slug]/verify/route.ts | 9 +- apps/web/src/app/api/rooms/public/route.ts | 10 +- apps/web/src/app/api/rooms/route.ts | 16 +- .../web/src/app/api/user/preferences/route.ts | 6 +- apps/web/src/app/auth/callback/route.ts | 6 +- .../src/app/auth/callback/spotify/route.ts | 3 +- apps/web/src/app/home-client.tsx | 40 +- apps/web/src/app/import/soundcloud/page.tsx | 4 +- .../soundcloud/soundcloud-import-client.tsx | 4 +- apps/web/src/app/import/spotify/page.tsx | 4 +- .../import/spotify/spotify-import-client.tsx | 14 +- apps/web/src/app/offline/page.tsx | 5 +- apps/web/src/app/opengraph-image.tsx | 3 +- apps/web/src/app/page.tsx | 6 +- apps/web/src/app/playlists/page.tsx | 12 +- apps/web/src/app/privacy/page.tsx | 134 +- apps/web/src/app/r/[slug]/invite/route.ts | 5 +- apps/web/src/app/r/[slug]/join/page.tsx | 6 +- apps/web/src/app/r/[slug]/opengraph-image.tsx | 3 +- apps/web/src/app/r/[slug]/page.tsx | 8 +- apps/web/src/app/settings/page.tsx | 37 +- apps/web/src/app/tos/page.tsx | 103 +- apps/web/src/components/account-nav.tsx | 16 +- .../src/components/account-settings-modal.tsx | 7 +- apps/web/src/components/connection-status.tsx | 2 +- .../src/components/discord-status-button.tsx | 4 +- .../src/components/import-playlist-dialog.tsx | 4 +- .../components/keyboard-shortcuts-help.tsx | 2 +- apps/web/src/components/legal-page.tsx | 19 +- apps/web/src/components/now-playing-bar.tsx | 14 +- .../src/components/now-playing-reactions.tsx | 7 +- .../playback-embed-error-banner.tsx | 7 +- apps/web/src/components/playback-seek-bar.tsx | 4 +- .../components/playback-volume-control.tsx | 2 +- .../src/components/playlist-picker-dialog.tsx | 22 +- apps/web/src/components/playlists-modal.tsx | 4 +- apps/web/src/components/queue-loop-button.tsx | 5 +- .../web/src/components/room-mobile-header.tsx | 10 +- apps/web/src/components/room-settings.tsx | 35 +- .../src/components/save-playlist-dialog.tsx | 11 +- .../src/components/share-invite-button.tsx | 2 +- apps/web/src/components/sign-in-modal.tsx | 15 +- apps/web/src/components/theme-bootstrap.tsx | 4 +- apps/web/src/components/theme-selector.tsx | 7 +- apps/web/src/components/toast.tsx | 2 +- apps/web/src/hooks/use-keyboard-shortcuts.ts | 7 +- apps/web/src/hooks/use-on-click-outside.ts | 2 +- apps/web/src/hooks/use-room-socket.ts | 29 +- apps/web/src/hooks/use-supabase-user.ts | 2 +- apps/web/src/hooks/use-user-preferences.ts | 2 +- apps/web/src/hooks/use-youtube-player.ts | 58 +- apps/web/src/lib/admin-auth.ts | 2 +- apps/web/src/lib/admin-data.ts | 17 +- apps/web/src/lib/api-log.ts | 12 +- apps/web/src/lib/apple-music.ts | 25 +- apps/web/src/lib/import-results.ts | 13 +- apps/web/src/lib/import-tracks.ts | 2 +- apps/web/src/lib/import-url.ts | 7 +- apps/web/src/lib/og-image.tsx | 54 +- apps/web/src/lib/realtime-server.ts | 4 +- apps/web/src/lib/rooms.ts | 16 +- apps/web/src/lib/skip-feedback.ts | 5 +- apps/web/src/lib/supabase-config.ts | 2 +- apps/web/src/lib/supabase-server.ts | 6 +- apps/web/src/lib/supabase/load-root-env.ts | 2 +- apps/web/src/lib/supabase/middleware.ts | 4 +- apps/web/src/lib/supabase/public-config.ts | 2 +- apps/web/src/lib/utils.ts | 6 +- apps/web/src/lib/youtube.ts | 36 +- apps/web/src/middleware.ts | 2 +- biome.json | 66 + package.json | 27 +- packages/db/drizzle.config.ts | 5 +- packages/db/package.json | 3 +- packages/db/scripts/migrate.mjs | 3 +- packages/db/src/client.ts | 1 + packages/db/src/index.ts | 2 +- packages/db/src/schema.ts | 10 +- packages/shared/package.json | 3 +- packages/shared/src/constants.ts | 16 +- packages/shared/src/index.ts | 2 +- packages/shared/src/schemas.ts | 13 +- packages/track-resolver/package.json | 3 +- packages/track-resolver/src/index.ts | 18 +- packages/ui/package.json | 3 +- packages/ui/src/components/button.tsx | 4 +- packages/ui/src/components/chat-panel.tsx | 19 +- packages/ui/src/components/queue-list.tsx | 54 +- packages/ui/src/components/select.tsx | 2 +- packages/ui/src/components/switch.tsx | 10 +- packages/ui/src/components/tabs.tsx | 10 +- packages/ui/src/components/tooltip.tsx | 4 +- packages/ui/src/globals.css | 4 +- packages/ui/src/index.ts | 10 +- packages/ui/src/lib/utils.ts | 9 +- pnpm-lock.yaml | 3795 ++++++----------- services/realtime/package.json | 3 +- services/realtime/src/index.ts | 2 +- 153 files changed, 2039 insertions(+), 3438 deletions(-) delete mode 100644 apps/web/eslint.config.mjs create mode 100644 biome.json 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.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-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/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/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/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/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/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-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/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/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.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-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/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/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/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/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/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/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/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

@@ -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:

    - 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() {