diff --git a/public/app.js b/public/app.js index 80f9f72..a9589ca 100644 --- a/public/app.js +++ b/public/app.js @@ -42,6 +42,7 @@ const state = { seeds: [], // [{ tmdb_id, media_type, title }] editMode: false, // editing an existing profile (vs. first-time onboarding) mediaType: "movie", + platforms: new Set(), // selected streaming platforms (empty = any); creator-only room: null, soloMode: false, deck: [], @@ -321,6 +322,26 @@ function setupMediaToggle() { }); } +// Multi-select streaming platforms; empty set = "Any". Results include titles on ANY +// picked platform. The "Any" chip (data-platform="") clears the selection. +function syncPlatformChips() { + document.querySelectorAll("#platform-chips .chip").forEach((b) => { + const p = b.dataset.platform; + b.classList.toggle("on", p === "" ? state.platforms.size === 0 : state.platforms.has(p)); + }); +} +function setupPlatformChips() { + document.querySelectorAll("#platform-chips .chip").forEach((btn) => { + btn.onclick = () => { + const p = btn.dataset.platform; + if (p === "") state.platforms.clear(); // "Any" resets to no filter + else if (state.platforms.has(p)) state.platforms.delete(p); + else state.platforms.add(p); + syncPlatformChips(); + }; + }); +} + async function handleCreateRoom() { const err = $("lobby-error"); err.textContent = ""; @@ -330,7 +351,7 @@ async function handleCreateRoom() { body: JSON.stringify({ user_id: state.userId, media_type: state.mediaType, - platform_filter: $("platform-select").value || null, + platform_filter: [...state.platforms].join(",") || null, // CSV; empty = any solo: $("solo-check").checked, }), }); @@ -444,10 +465,17 @@ async function maybeTopUp() { } } +// The creator (user_a) owns the shared movie/tv choice; solo user is their own creator. +function isRoomCreator() { + return !!state.room && state.userId === state.room.user_a_id; +} + function syncMediaToggle() { document.querySelectorAll("#swipe-media-toggle .toggle-opt").forEach((b) => { b.classList.toggle("active", b.dataset.media === state.mediaType); }); + // Only the creator can switch type (shared pool); hide the control for the joiner. + $("swipe-media-toggle").classList.toggle("hidden", !isRoomCreator()); } async function toggleMedia(media) { @@ -1022,6 +1050,7 @@ function init() { renderAvoidChips(); setupSeedSearch(); setupMediaToggle(); + setupPlatformChips(); setupGestures(); $("onboarding-continue").onclick = handleOnboarding; diff --git a/public/index.html b/public/index.html index 276039c..197caea 100644 --- a/public/index.html +++ b/public/index.html @@ -65,15 +65,15 @@

Create a room

- - + +
+ + + + + + +
diff --git a/public/style.css b/public/style.css index ee8f11a..e7c5a09 100644 --- a/public/style.css +++ b/public/style.css @@ -174,6 +174,11 @@ select.input { border-color: var(--nope); color: #fff; } +.chip.on { + background: linear-gradient(135deg, var(--accent), var(--accent-dim)); + border-color: transparent; + color: #fff; +} .swipe-links { display: flex; justify-content: center; diff --git a/src/routes/rooms.ts b/src/routes/rooms.ts index 9c2a4be..5bebd9a 100644 --- a/src/routes/rooms.ts +++ b/src/routes/rooms.ts @@ -310,6 +310,8 @@ rooms.get("/:id/ws", async (c) => { }); // PATCH /api/rooms/:id — live movie/tv toggle: change media_type + reset the pool. +// Creator-only: media_type is a shared, room-level setting (both users draw from ONE +// pool), so only the room creator (user_a) decides it; the joiner inherits it. // Body: { user_id, media_type } rooms.patch("/:id", async (c) => { const id = c.req.param("id"); @@ -327,8 +329,8 @@ rooms.patch("/:id", async (c) => { if (!row) return c.json({ error: "room_not_found" }, 404); const room = mapRoom(row); - // AUTH GATING: only room members can change the type. - if (userId !== room.user_a_id && userId !== room.user_b_id) { + // AUTH GATING: only the room creator (user_a) may change the shared media_type. + if (userId !== room.user_a_id) { return c.json({ error: "forbidden" }, 403); } diff --git a/src/services/tmdb.ts b/src/services/tmdb.ts index e58b191..7f4f3c1 100644 --- a/src/services/tmdb.ts +++ b/src/services/tmdb.ts @@ -101,6 +101,20 @@ const PROVIDER_IDS: Record = { "apple tv": 350, }; +/** + * Map a platform filter (single key or CSV like "netflix,prime") to TMDb provider ids, + * joined with "|" (OR) for with_watch_providers. Unknown/empty → null. Pure → unit-tested. + */ +export function platformProviderParam(platform?: string | null): string | null { + if (!platform) return null; + const ids = new Set(); + for (const raw of platform.split(",")) { + const id = PROVIDER_IDS[raw.trim().toLowerCase()]; + if (id) ids.add(id); + } + return ids.size > 0 ? [...ids].join("|") : null; +} + function dateToYear(date?: string): number | null { if (!date || date.length < 4) return null; const y = Number(date.slice(0, 4)); @@ -156,9 +170,9 @@ export async function discoverTitles(env: Env, opts: DiscoverOptions): Promise { it("genJoinCode is 6 chars from the safe alphabet", () => { @@ -77,3 +78,21 @@ describe("rejectAvoidGenres (adaptive top-up filter)", () => { expect(rejectAvoidGenres(cards, [])).toHaveLength(2); }); }); + +describe("platformProviderParam (multi-select platforms → TMDb providers)", () => { + it("maps a single platform to its provider id", () => { + expect(platformProviderParam("netflix")).toBe("8"); + }); + it("maps a CSV of platforms to OR-joined ids (netflix + prime)", () => { + expect(platformProviderParam("netflix,prime")).toBe("8|119"); + }); + it("treats empty / null as 'any' (no filter)", () => { + expect(platformProviderParam("")).toBeNull(); + expect(platformProviderParam(null)).toBeNull(); + expect(platformProviderParam(undefined)).toBeNull(); + }); + it("ignores unknown keys and dedupes, trimming/casing", () => { + expect(platformProviderParam(" Netflix , bogus ")).toBe("8"); + expect(platformProviderParam("prime,amazon")).toBe("119"); // both → 119, deduped + }); +});