Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 72 additions & 18 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
Expand All @@ -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) => {
Expand Down Expand Up @@ -690,6 +742,7 @@ function resetData() {
closeWs();
state.userId = null;
state.username = null;
exitEditMode();
state.genreLevels = {};
state.avoidGenres = new Set();
state.seeds = [];
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 8 additions & 5 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ <h1>Cinemate</h1>
<section id="content">
<!-- ── Onboarding ─────────────────────────────────────────── -->
<div class="screen" id="screen-onboarding">
<h2>Welcome 👋</h2>
<p class="muted">What's your name?</p>
<input id="username-input" class="input" type="text" placeholder="your name" maxlength="40" />
<h2 id="onboarding-title">Welcome 👋</h2>
<div id="name-field">
<p class="muted">What's your name?</p>
<input id="username-input" class="input" type="text" placeholder="your name" maxlength="40" />
</div>

<p class="muted mt">Which genres do you like? <span class="hint">(tap once = like, twice = love)</span></p>
<div id="genre-chips" class="chips"></div>
Expand Down Expand Up @@ -143,8 +145,9 @@ <h2>My watchlist</h2>
<h2>Profile</h2>
<div class="card-box">
<p id="profile-name" class="muted"></p>
<p class="muted">Starting over clears your data on this device and re-runs the quiz.</p>
<button id="reset-btn" class="btn btn-primary mt">Start over (clear my data)</button>
<button id="edit-prefs-btn" class="btn btn-primary">Edit preferences</button>
<p class="muted mt">Starting over clears your data on this device and re-runs the quiz.</p>
<button id="reset-btn" class="btn mt">Start over (clear my data)</button>
</div>
<button id="profile-back-btn" class="btn-link mt">Back</button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions src/lib/mappers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Map D1 rows (Record<string, unknown>) → 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<string, number> {
if (typeof value !== "string") return {};
Expand Down Expand Up @@ -53,9 +53,10 @@ function parsePrefs(value: unknown): ProfilePrefs {
.filter((s): s is Record<string, unknown> => !!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 {
Expand Down
5 changes: 4 additions & 1 deletion src/routes/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> => !!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);
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
Loading