diff --git a/public/app.js b/public/app.js
index b235216..3a1abaa 100644
--- a/public/app.js
+++ b/public/app.js
@@ -482,6 +482,8 @@ function renderCard() {
$("card-overview").textContent = card.overview || "";
$("trailer-btn").classList.remove("hidden");
$("trailer-btn").onclick = () => openTrailer(card.tmdb_id);
+ $("details-btn").classList.remove("hidden");
+ $("details-btn").onclick = () => openDetails(card);
renderRatings(card.tmdb_id, "card-rating");
}
@@ -541,8 +543,8 @@ function setupGestures() {
const el = $("card");
let drag = null;
el.addEventListener("pointerdown", (e) => {
- if (el.classList.contains("hidden") || e.target.closest("#trailer-btn")) return;
- drag = { x: e.clientX, y: e.clientY, dx: 0 };
+ if (el.classList.contains("hidden") || e.target.closest("button")) return;
+ drag = { x: e.clientX, y: e.clientY, dx: 0, moved: false };
el.setPointerCapture(e.pointerId);
el.style.transition = "none";
});
@@ -550,6 +552,7 @@ function setupGestures() {
if (!drag) return;
drag.dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
+ if (!drag.moved && Math.hypot(drag.dx, dy) > 8) drag.moved = true;
const rot = Math.max(-15, Math.min(15, drag.dx / 12));
el.style.transform = `translate(${drag.dx}px, ${dy}px) rotate(${rot}deg)`;
const p = Math.min(1, Math.abs(drag.dx) / SWIPE_THRESHOLD);
@@ -558,8 +561,18 @@ function setupGestures() {
});
const end = () => {
if (!drag) return;
- const dx = drag.dx;
+ const { dx, moved } = drag;
drag = null;
+ if (!moved) {
+ // A tap (no real drag) → open the details sheet for the current card.
+ el.style.transition = "transform 0.15s ease";
+ el.style.transform = "";
+ $("stamp-like").style.opacity = 0;
+ $("stamp-nope").style.opacity = 0;
+ const card = state.deck[state.deckIndex];
+ if (card) openDetails(card);
+ return;
+ }
if (Math.abs(dx) > SWIPE_THRESHOLD) {
flingAndSwipe(dx > 0 ? "like" : "dislike");
} else {
@@ -593,6 +606,70 @@ function closeTrailer() {
$("trailer-overlay").classList.add("hidden");
}
+// ── Details sheet ───────────────────────────────────────────────────────────
+async function openDetails(card) {
+ // Prefill instantly from the card we already have, then enrich from the API.
+ $("details-poster").style.backgroundImage = card.poster_path
+ ? `url(https://image.tmdb.org/t/p/w780${card.poster_path})`
+ : "none";
+ const sheet = document.querySelector(".details-sheet");
+ if (sheet) sheet.scrollTop = 0; // always open scrolled to the top
+ $("details-title").textContent = card.title || "";
+ $("details-tagline").classList.add("hidden");
+ $("details-meta").textContent = card.release_year ? String(card.release_year) : "";
+ $("details-overview").textContent = card.overview || "Loading…";
+ $("details-crew").classList.add("hidden");
+ $("details-cast").innerHTML = "";
+ $("details-trailer").onclick = () => openTrailer(card.tmdb_id);
+ renderRatings(card.tmdb_id, "details-rating");
+ $("details-overlay").classList.remove("hidden");
+
+ try {
+ const d = await api(`/api/rooms/${state.room.id}/details/${card.tmdb_id}`);
+ $("details-title").textContent = d.title || card.title || "";
+ if (d.tagline) {
+ $("details-tagline").textContent = d.tagline;
+ $("details-tagline").classList.remove("hidden");
+ }
+ const meta = [];
+ if (d.release_year) meta.push(d.release_year);
+ if (d.runtime) meta.push(d.media_type === "tv" ? `~${d.runtime} min/ep` : `${d.runtime} min`);
+ if (d.seasons) meta.push(d.seasons + (d.seasons === 1 ? " season" : " seasons"));
+ if (d.genres && d.genres.length) meta.push(d.genres.slice(0, 3).join(", "));
+ $("details-meta").textContent = meta.join(" · ");
+ $("details-overview").textContent = d.overview || "No description available.";
+ if (d.directors && d.directors.length) {
+ const label = (d.media_type === "tv" ? "Creator" : "Director") + (d.directors.length > 1 ? "s" : "");
+ $("details-crew").textContent = `${label}: ${d.directors.join(", ")}`;
+ $("details-crew").classList.remove("hidden");
+ }
+ const box = $("details-cast");
+ box.innerHTML = "";
+ (d.cast || []).forEach((p) => {
+ const chip = document.createElement("div");
+ chip.className = "cast-chip";
+ const photo = document.createElement("div");
+ photo.className = "cast-photo";
+ if (p.profile_path) photo.style.backgroundImage = `url(https://image.tmdb.org/t/p/w185${p.profile_path})`;
+ const name = document.createElement("div");
+ name.className = "cast-name";
+ name.textContent = p.name;
+ const role = document.createElement("div");
+ role.className = "cast-role";
+ role.textContent = p.character || "";
+ chip.appendChild(photo);
+ chip.appendChild(name);
+ chip.appendChild(role);
+ box.appendChild(chip);
+ });
+ } catch {
+ $("details-overview").textContent = card.overview || "Couldn't load details.";
+ }
+}
+function closeDetails() {
+ $("details-overlay").classList.add("hidden");
+}
+
// ── WebSocket (live) ────────────────────────────────────────────────────────
function connectWs() {
if (state.soloMode) return;
@@ -935,6 +1012,10 @@ function init() {
$("trailer-overlay").onclick = (e) => {
if (e.target === $("trailer-overlay")) closeTrailer();
};
+ $("details-close").onclick = closeDetails;
+ $("details-overlay").onclick = (e) => {
+ if (e.target === $("details-overlay")) closeDetails();
+ };
const pendingCode = (new URLSearchParams(location.search).get("code") || "").trim().toUpperCase();
if (state.userId && state.username) {
diff --git a/public/index.html b/public/index.html
index f5d6621..80edb91 100644
--- a/public/index.html
+++ b/public/index.html
@@ -95,7 +95,10 @@
-
+
+
+
+
That's the whole deck! 🎉 Check your matches.
@@ -174,6 +177,24 @@ Enter request PIN
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/style.css b/public/style.css
index bb0801f..78e03e7 100644
--- a/public/style.css
+++ b/public/style.css
@@ -577,6 +577,80 @@ select.input {
border-radius: 12px;
}
+/* ── Details sheet ── */
+.card-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+.details-sheet {
+ background: var(--surface);
+ border-radius: var(--radius);
+ width: 100%;
+ max-width: 460px;
+ max-height: 92vh;
+ overflow-y: auto;
+ overflow-x: hidden;
+ box-shadow: var(--shadow);
+ -webkit-overflow-scrolling: touch;
+}
+.details-hero {
+ width: 100%;
+ height: 48vh;
+ background-size: cover;
+ background-position: center 15%;
+ background-color: var(--surface-2);
+}
+.details-body {
+ padding: 1.1rem;
+}
+.details-body h3 {
+ margin: 0 0 0.3rem;
+}
+.details-tagline {
+ font-style: italic;
+ color: var(--muted);
+ margin: 0 0 0.4rem;
+ font-size: 0.88rem;
+}
+.details-crew {
+ margin: 0.9rem 0 0;
+ font-size: 0.9rem;
+ color: var(--muted);
+}
+.details-overview {
+ margin: 0.7rem 0 1rem;
+ line-height: 1.55;
+ font-size: 0.95rem;
+}
+.details-cast {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.8rem;
+ margin-bottom: 1rem;
+}
+.cast-chip {
+ text-align: center;
+}
+.cast-photo {
+ width: 100%;
+ aspect-ratio: 1;
+ border-radius: 50%;
+ background-size: cover;
+ background-position: center top;
+ background-color: var(--surface-2);
+ margin-bottom: 0.35rem;
+}
+.cast-name {
+ font-size: 0.78rem;
+ font-weight: 600;
+ line-height: 1.2;
+}
+.cast-role {
+ font-size: 0.72rem;
+ line-height: 1.2;
+ color: var(--muted);
+}
+
.attribution {
padding: 0.75rem 0;
font-size: 0.7rem;
diff --git a/src/routes/rooms.ts b/src/routes/rooms.ts
index 401f756..d33fc56 100644
--- a/src/routes/rooms.ts
+++ b/src/routes/rooms.ts
@@ -4,7 +4,7 @@ import { genId } from "../lib/ids";
import { uniqueJoinCode, userExists } from "../lib/db";
import { mapRoom } from "../lib/mappers";
import { getDeckForUser, getCardFromDeck, resetDeck, buildMatchReason } from "../lib/deck";
-import { getWatchProviders, getImdbId, getTrailerKey } from "../services/tmdb";
+import { getWatchProviders, getImdbId, getTrailerKey, getTitleDetails } from "../services/tmdb";
import { getOmdbRatings } from "../services/omdb";
import { createRequest } from "../services/overseerr";
import { rateLimit, clientIp } from "../lib/ratelimit";
@@ -332,6 +332,24 @@ rooms.patch("/:id", async (c) => {
return c.json({ ...room, media_type: mediaType, deck: [] }, 200);
});
+// GET /api/rooms/:id/details/:tmdbId — full metadata for the details view
+// (cast, director/creator, tagline, runtime, genres, full overview).
+rooms.get("/:id/details/:tmdbId", async (c) => {
+ const id = c.req.param("id");
+ const tmdbId = Number(c.req.param("tmdbId"));
+ if (!Number.isInteger(tmdbId) || tmdbId <= 0) return c.json({ error: "tmdb_id_invalid" }, 400);
+
+ const row = await c.env.DB.prepare("SELECT media_type FROM rooms WHERE id = ?")
+ .bind(id)
+ .first>();
+ if (!row) return c.json({ error: "room_not_found" }, 404);
+ const mediaType = String(row.media_type) === "tv" ? "tv" : "movie";
+
+ const details = await getTitleDetails(c.env, mediaType, tmdbId);
+ if (!details) return c.json({ error: "details_fetch_failed" }, 502);
+ return c.json(details, 200);
+});
+
// GET /api/rooms/:id/providers/:tmdbId — where a title can be watched (RO).
rooms.get("/:id/providers/:tmdbId", async (c) => {
const id = c.req.param("id");
diff --git a/src/services/tmdb.ts b/src/services/tmdb.ts
index 23006c1..ae093d8 100644
--- a/src/services/tmdb.ts
+++ b/src/services/tmdb.ts
@@ -193,6 +193,88 @@ export async function getTitleCard(
}
}
+interface TmdbCredits {
+ cast?: { name: string; character?: string; profile_path?: string | null }[];
+ crew?: { name: string; job?: string; department?: string }[];
+}
+interface TmdbDetailFull extends TmdbDetail {
+ tagline?: string;
+ runtime?: number; // movie
+ episode_run_time?: number[]; // tv
+ number_of_seasons?: number; // tv
+ created_by?: { name: string }[]; // tv
+ credits?: TmdbCredits;
+}
+
+export interface CastMember {
+ name: string;
+ character: string | null;
+ profile_path: string | null;
+}
+export interface TitleDetails {
+ tmdb_id: number;
+ media_type: MediaType;
+ title: string;
+ tagline: string | null;
+ overview: string;
+ poster_path: string | null;
+ release_year: number | null;
+ genres: string[];
+ runtime: number | null; // minutes (movie) or per-episode (tv)
+ seasons: number | null; // tv only
+ vote_average: number | null;
+ directors: string[]; // Director(s) for movies, Creator(s) for tv
+ cast: CastMember[];
+}
+
+/** Full metadata for the details view: cast, director/creator, runtime, genres. One cached call. */
+export async function getTitleDetails(
+ env: Env,
+ mediaType: MediaType,
+ id: number,
+): Promise {
+ try {
+ const d = await tmdbFetch(env, `/${mediaType}/${id}`, {
+ language: TMDB_LANG,
+ append_to_response: "credits",
+ });
+ const directors =
+ mediaType === "movie"
+ ? (d.credits?.crew ?? []).filter((c) => c.job === "Director").map((c) => c.name)
+ : (d.created_by ?? []).map((c) => c.name);
+ const runtime =
+ mediaType === "movie"
+ ? typeof d.runtime === "number"
+ ? d.runtime
+ : null
+ : Array.isArray(d.episode_run_time) && d.episode_run_time.length
+ ? d.episode_run_time[0]
+ : null;
+ return {
+ tmdb_id: d.id,
+ media_type: mediaType,
+ title: (mediaType === "movie" ? d.title : d.name) ?? "",
+ tagline: d.tagline?.trim() || null,
+ overview: d.overview ?? "",
+ poster_path: d.poster_path ?? null,
+ release_year: dateToYear(mediaType === "movie" ? d.release_date : d.first_air_date),
+ genres: (d.genres ?? []).map((g) => g.name),
+ runtime,
+ seasons:
+ mediaType === "tv" && typeof d.number_of_seasons === "number" ? d.number_of_seasons : null,
+ vote_average: typeof d.vote_average === "number" ? d.vote_average : null,
+ directors: [...new Set(directors)].slice(0, 3),
+ cast: (d.credits?.cast ?? []).slice(0, 12).map((c) => ({
+ name: c.name,
+ character: c.character?.trim() || null,
+ profile_path: c.profile_path ?? null,
+ })),
+ };
+ } catch {
+ return null;
+ }
+}
+
interface TmdbProviderEntry {
provider_id: number;
provider_name: string;