diff --git a/public/app.js b/public/app.js index 7981143..afb8c86 100644 --- a/public/app.js +++ b/public/app.js @@ -40,6 +40,7 @@ const state = { genreLevels: {}, avoidGenres: new Set(), seeds: [], // [{ tmdb_id, media_type, title }] + editMode: false, // editing an existing profile (vs. first-time onboarding) mediaType: "movie", room: null, soloMode: false, @@ -90,18 +91,20 @@ function renderGenreChips() { GENRES.forEach((g) => { const chip = document.createElement("button"); chip.className = "chip"; - chip.textContent = g.name; + const paint = (level) => { + chip.classList.toggle("like", level === 1); + chip.classList.toggle("love", level === 2); + chip.textContent = g.name + (level === 2 ? " ♥♥" : level === 1 ? " ♥" : ""); + }; + paint(state.genreLevels[g.id] || 0); // reflect current state (for edit prefill) chip.onclick = () => { const level = (state.genreLevels[g.id] || 0) + 1; if (level > 2) { delete state.genreLevels[g.id]; - chip.classList.remove("like", "love"); - chip.textContent = g.name; + paint(0); } else { state.genreLevels[g.id] = level; - chip.classList.toggle("like", level === 1); - chip.classList.toggle("love", level === 2); - chip.textContent = g.name + (level === 2 ? " ♥♥" : " ♥"); + paint(level); } }; box.appendChild(chip); @@ -116,6 +119,7 @@ function renderAvoidChips() { const chip = document.createElement("button"); chip.className = "chip"; chip.textContent = g.name; + chip.classList.toggle("avoid", state.avoidGenres.has(g.id)); // reflect state (edit prefill) chip.onclick = () => { if (state.avoidGenres.has(g.id)) { state.avoidGenres.delete(g.id); @@ -217,17 +221,20 @@ function renderSeedChips() { async function handleOnboarding() { const err = $("onboarding-error"); err.textContent = ""; - const username = $("username-input").value.trim(); - if (!username) { - err.textContent = "Please enter a name."; - return; - } try { - const user = await api("/api/users", { method: "POST", body: JSON.stringify({ username }) }); - state.userId = user.id; - state.username = user.username; - localStorage.setItem("cinemate_user_id", user.id); - localStorage.setItem("cinemate_username", user.username); + // First-time onboarding creates the user; edit mode reuses the current one. + if (!state.editMode) { + const username = $("username-input").value.trim(); + if (!username) { + err.textContent = "Please enter a name."; + return; + } + const user = await api("/api/users", { method: "POST", body: JSON.stringify({ username }) }); + state.userId = user.id; + state.username = user.username; + localStorage.setItem("cinemate_user_id", user.id); + localStorage.setItem("cinemate_username", user.username); + } const genreScores = {}; for (const [id, level] of Object.entries(state.genreLevels)) { @@ -236,13 +243,20 @@ async function handleOnboarding() { await api("/api/profile/quiz", { method: "POST", body: JSON.stringify({ - user_id: user.id, + user_id: state.userId, genre_scores: genreScores, avoid_genres: [...state.avoidGenres], - seeds: state.seeds.map((s) => ({ tmdb_id: s.tmdb_id, media_type: s.media_type })), + seeds: state.seeds.map((s) => ({ tmdb_id: s.tmdb_id, media_type: s.media_type, title: s.title })), }), }); + if (state.editMode) { + exitEditMode(); + toast("Preferences saved"); + showScreen("screen-lobby"); + return; + } + updateUserBadge(); if (state.pendingCode) { $("join-code-input").value = state.pendingCode; @@ -256,6 +270,44 @@ async function handleOnboarding() { } } +// Open the quiz pre-filled with the current profile, to edit without a full reset. +async function openEditPrefs() { + try { + const p = await api(`/api/profile/${state.userId}`); + state.genreLevels = {}; + for (const [id, score] of Object.entries(p.genre_scores || {})) { + state.genreLevels[id] = Number(score) >= 0.9 ? 2 : 1; + } + state.avoidGenres = new Set((p.prefs?.avoid_genres || []).map(Number)); + state.seeds = (p.prefs?.seeds || []).map((s) => ({ + tmdb_id: s.tmdb_id, + media_type: s.media_type, + title: s.title || `#${s.tmdb_id}`, + })); + } catch { + state.genreLevels = {}; + state.avoidGenres = new Set(); + state.seeds = []; + } + state.editMode = true; + $("name-field").classList.add("hidden"); + $("onboarding-title").textContent = "Edit preferences"; + $("onboarding-continue").textContent = "Save"; + renderGenreChips(); + renderAvoidChips(); + renderSeedChips(); + hideSuggestions(); + $("onboarding-error").textContent = ""; + showScreen("screen-onboarding"); +} + +function exitEditMode() { + state.editMode = false; + $("name-field").classList.remove("hidden"); + $("onboarding-title").textContent = "Welcome 👋"; + $("onboarding-continue").textContent = "Continue"; +} + // ── Lobby ──────────────────────────────────────────────────────────────────── function setupMediaToggle() { document.querySelectorAll("#media-toggle .toggle-opt").forEach((btn) => { @@ -690,6 +742,7 @@ function resetData() { closeWs(); state.userId = null; state.username = null; + exitEditMode(); state.genreLevels = {}; state.avoidGenres = new Set(); state.seeds = []; @@ -747,6 +800,7 @@ function init() { $("match-continue-btn").onclick = () => showScreen("screen-swipe"); $("copy-link-btn").onclick = () => copyText(inviteLink(), "Invite link"); $("user-badge").onclick = showProfile; + $("edit-prefs-btn").onclick = openEditPrefs; $("reset-btn").onclick = resetData; $("profile-back-btn").onclick = () => showScreen("screen-lobby"); $("trailer-close").onclick = closeTrailer; diff --git a/public/index.html b/public/index.html index e95b173..a9b9dd2 100644 --- a/public/index.html +++ b/public/index.html @@ -16,9 +16,11 @@

Cinemate

-

Welcome 👋

-

What's your name?

- +

Welcome 👋

+
+

What's your name?

+ +

Which genres do you like? (tap once = like, twice = love)

@@ -143,8 +145,9 @@

My watchlist

Profile

-

Starting over clears your data on this device and re-runs the quiz.

- + +

Starting over clears your data on this device and re-runs the quiz.

+
diff --git a/src/index.ts b/src/index.ts index 042c1f3..dc11a99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ export { Room } from "./durable-objects/room"; const app = new Hono<{ Bindings: Env }>(); -app.get("/api/health", (c) => c.json({ ok: true, service: "cinemate", version: "v2.8" })); +app.get("/api/health", (c) => c.json({ ok: true, service: "cinemate", version: "v2.9" })); app.route("/api/users", users); app.route("/api/profile", profile); diff --git a/src/lib/mappers.ts b/src/lib/mappers.ts index 4000ad6..720e7db 100644 --- a/src/lib/mappers.ts +++ b/src/lib/mappers.ts @@ -1,6 +1,6 @@ // Map D1 rows (Record) → application types. -import type { MediaType, Profile, ProfilePrefs, Room, RoomStatus, SeedTitle } from "../types"; +import type { MediaType, Profile, ProfilePrefs, Room, RoomStatus } from "../types"; function parseJsonObject(value: unknown): Record { if (typeof value !== "string") return {}; @@ -53,9 +53,10 @@ function parsePrefs(value: unknown): ProfilePrefs { .filter((s): s is Record => !!s && typeof s === "object") .map((s) => ({ tmdb_id: Number(s.tmdb_id), - media_type: s.media_type === "tv" ? "tv" : "movie", + media_type: s.media_type === "tv" ? ("tv" as const) : ("movie" as const), + title: typeof s.title === "string" ? s.title : undefined, })) - .filter((s): s is SeedTitle => Number.isFinite(s.tmdb_id) && s.tmdb_id > 0); + .filter((s) => Number.isFinite(s.tmdb_id) && s.tmdb_id > 0); } return out; } catch { diff --git a/src/routes/profile.ts b/src/routes/profile.ts index ec5af0b..624ea1e 100644 --- a/src/routes/profile.ts +++ b/src/routes/profile.ts @@ -10,13 +10,16 @@ function toNumberArray(value: unknown): number[] { return value.map(Number).filter((n) => Number.isFinite(n)); } -function toSeeds(value: unknown): { tmdb_id: number; media_type: "movie" | "tv" }[] { +function toSeeds( + value: unknown, +): { tmdb_id: number; media_type: "movie" | "tv"; title?: string }[] { if (!Array.isArray(value)) return []; return value .filter((s): s is Record => !!s && typeof s === "object") .map((s) => ({ tmdb_id: Number(s.tmdb_id), media_type: s.media_type === "tv" ? ("tv" as const) : ("movie" as const), + ...(typeof s.title === "string" ? { title: s.title.slice(0, 120) } : {}), })) .filter((s) => Number.isInteger(s.tmdb_id) && s.tmdb_id > 0) .slice(0, 6); diff --git a/src/types.ts b/src/types.ts index 2dd63d7..75be54d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,7 @@ export interface User { export interface SeedTitle { tmdb_id: number; media_type: MediaType; + title?: string; // kept for display when editing preferences } /** Quiz preferences stored as JSON in profiles.prefs. Each is optional (empty = no filter). */