From b0e7b2b8dd97f57c993c3c44f5b5e218a08f1264 Mon Sep 17 00:00:00 2001 From: glpc-blaze Date: Sat, 7 Nov 2026 00:03:30 +0530 Subject: [PATCH] feat(detail): add collapsible Content Advisory section with IMDb criteria & ratings - Fetch parental guide criteria & votes directly from IMDb GraphQL endpoint - Support movie & series IDs, TMDB inferred IDs, and anime Kitsu/MAL IDs - Integrate collapsible Content Advisory panel right below Cast/Characters - Use in-player Lucide icons (Heart, Swords, MessageSquareWarning, Wine, Ghost) - Implement thin boxy 3-bar level meter matching the playback advisory toast - Adopt player severity color tokens (danger, accent, muted whites) - Add section collapse toggle beside Open on IMDb with smooth grid transition - Ensure section order customization respects content advisory positioning --- src/lib/detail-customization.ts | 14 + src/lib/providers/imdb-parents-guide.ts | 230 +++++++++++++++++ src/views/detail.tsx | 17 ++ src/views/detail/parents-guide-panel.tsx | 312 +++++++++++++++++++++++ 4 files changed, 573 insertions(+) create mode 100644 src/lib/providers/imdb-parents-guide.ts create mode 100644 src/views/detail/parents-guide-panel.tsx diff --git a/src/lib/detail-customization.ts b/src/lib/detail-customization.ts index 6a3e78d97..bdee147b1 100644 --- a/src/lib/detail-customization.ts +++ b/src/lib/detail-customization.ts @@ -38,6 +38,20 @@ export function orderedSectionKeys(available: string[], c: DetailCustomization): for (const k of available) { if (!seen.has(k)) out.push(k); } + const pgIdx = + out.indexOf("parentsGuide") >= 0 ? out.indexOf("parentsGuide") : out.indexOf("contentAdvisory"); + const castIdx = Math.max(out.indexOf("cast"), out.indexOf("animeCharacters")); + if ( + pgIdx >= 0 && + castIdx >= 0 && + pgIdx > castIdx + 1 && + (out.includes("moreLikeThis") || out.includes("collection") || out.includes("letterboxdPanel")) + ) { + const key = out[pgIdx]; + out.splice(pgIdx, 1); + const newCastIdx = Math.max(out.indexOf("cast"), out.indexOf("animeCharacters")); + out.splice(newCastIdx + 1, 0, key); + } return out; } diff --git a/src/lib/providers/imdb-parents-guide.ts b/src/lib/providers/imdb-parents-guide.ts new file mode 100644 index 000000000..b9e365bed --- /dev/null +++ b/src/lib/providers/imdb-parents-guide.ts @@ -0,0 +1,230 @@ +import { lruSet } from "@/lib/cache"; +import { registerEvictable } from "@/lib/maintenance"; +import { safeFetch } from "@/lib/safe-fetch"; + +export type ParentsGuideItem = { + text: string; + isSpoiler: boolean; +}; + +export type ParentsGuideCategory = { + id: string; + label: string; + severity: "None" | "Mild" | "Moderate" | "Severe" | string; + severityVotes: number; + totalVotes: number; + items: ParentsGuideItem[]; +}; + +export type ParentsGuideResult = { + categories: ParentsGuideCategory[]; +}; + +const guideCache = new Map(); +const guideInflight = new Map>(); + +registerEvictable("imdb-parents-guide", (aggressive) => { + if (aggressive) guideCache.clear(); +}); + +const GRAPHQL_ENDPOINT = "https://graphql.imdb.com/"; + +const PARENTS_GUIDE_QUERY = ` +query ParentsGuide($id: ID!) { + title(id: $id) { + parentsGuide { + categories { + category { id text } + severity { text voteType votedFor } + totalSeverityVotes + guideItems(first: 30) { + edges { + node { + isSpoiler + text { plainText } + } + } + } + } + } + } +} +`; + +export function severityLevel(severity: string): number { + switch (severity.toLowerCase()) { + case "none": + return 1; + case "mild": + return 2; + case "moderate": + return 3; + case "severe": + return 4; + default: + return 0; + } +} + +export type SeverityColorStyle = { + badgeClass: string; + barClass: string; + textClass: string; + borderClass: string; + glowClass: string; +}; + +export function severityColorStyle(severity: string): SeverityColorStyle { + switch (severity.toLowerCase()) { + case "none": + return { + badgeClass: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + barClass: "bg-emerald-400", + textClass: "text-emerald-400", + borderClass: "border-emerald-500/30", + glowClass: "shadow-[0_0_15px_-4px_rgba(74,222,128,0.20)]", + }; + case "mild": + return { + badgeClass: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30", + barClass: "bg-yellow-400", + textClass: "text-yellow-400", + borderClass: "border-yellow-500/30", + glowClass: "shadow-[0_0_15px_-4px_rgba(250,204,21,0.20)]", + }; + case "moderate": + return { + badgeClass: "bg-orange-500/15 text-orange-400 border-orange-500/30", + barClass: "bg-orange-400", + textClass: "text-orange-400", + borderClass: "border-orange-500/30", + glowClass: "shadow-[0_0_15px_-4px_rgba(251,146,60,0.20)]", + }; + case "severe": + return { + badgeClass: "bg-rose-500/15 text-rose-400 border-rose-500/30", + barClass: "bg-rose-400", + textClass: "text-rose-400", + borderClass: "border-rose-500/30", + glowClass: "shadow-[0_0_15px_-4px_rgba(244,63,94,0.20)]", + }; + default: + return { + badgeClass: "bg-white/[0.08] text-ink-muted border-white/[0.12]", + barClass: "bg-white/40", + textClass: "text-ink-muted", + borderClass: "border-white/10", + glowClass: "", + }; + } +} + +export async function fetchImdbParentsGuide(imdbId: string): Promise { + const cleanId = imdbId.trim(); + if (!/^tt\d+$/.test(cleanId)) return null; + + if (guideCache.has(cleanId)) { + return guideCache.get(cleanId) ?? null; + } + + const pending = guideInflight.get(cleanId); + if (pending) return pending; + + const request = (async (): Promise => { + try { + const res = await safeFetch(GRAPHQL_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + Referer: "https://www.imdb.com/", + }, + body: JSON.stringify({ + query: PARENTS_GUIDE_QUERY, + variables: { id: cleanId }, + }), + signal: AbortSignal.timeout(6000), + }); + + if (!res.ok) { + guideCache.set(cleanId, null); + return null; + } + + type RawEdge = { + node?: { + isSpoiler?: boolean; + text?: { plainText?: string }; + }; + }; + + type RawCategory = { + category?: { id?: string; text?: string }; + severity?: { text?: string; voteType?: string; votedFor?: number }; + totalSeverityVotes?: number; + guideItems?: { edges?: RawEdge[] }; + }; + + type ImdbResponse = { + data?: { + title?: { + parentsGuide?: { + categories?: RawCategory[]; + }; + }; + }; + }; + + const json = (await res.json()) as ImdbResponse; + const rawCategories = json.data?.title?.parentsGuide?.categories; + + if (!rawCategories || !Array.isArray(rawCategories) || rawCategories.length === 0) { + guideCache.set(cleanId, null); + return null; + } + + const categories: ParentsGuideCategory[] = []; + for (const raw of rawCategories) { + const cat = raw.category; + const sev = raw.severity; + const edges = raw.guideItems?.edges ?? []; + + const items: ParentsGuideItem[] = []; + for (const edge of edges) { + const text = edge.node?.text?.plainText?.trim(); + if (text) { + items.push({ + text, + isSpoiler: Boolean(edge.node?.isSpoiler), + }); + } + } + + categories.push({ + id: cat?.id ?? "", + label: cat?.text ?? "", + severity: (sev?.text ?? "None") as "None" | "Mild" | "Moderate" | "Severe", + severityVotes: Number(sev?.votedFor) || 0, + totalVotes: Number(raw.totalSeverityVotes) || 0, + items, + }); + } + + if (categories.length === 0) { + guideCache.set(cleanId, null); + return null; + } + + const result: ParentsGuideResult = { categories }; + lruSet(guideCache, cleanId, result, 150); + return result; + } catch { + return null; + } finally { + guideInflight.delete(cleanId); + } + })(); + + guideInflight.set(cleanId, request); + return request; +} diff --git a/src/views/detail.tsx b/src/views/detail.tsx index f6641eaec..ebbc14c2f 100644 --- a/src/views/detail.tsx +++ b/src/views/detail.tsx @@ -198,6 +198,8 @@ import { useAnimeAnilistDetails } from "./detail/use-anime-anilist-details"; import { useAnimeCharacters } from "./detail/use-anime-characters"; import { TraktComments } from "./detail/trakt-comments"; import { LetterboxdPanel } from "./detail/letterboxd-panel"; +import { ParentsGuidePanel } from "./detail/parents-guide-panel"; +import { useImdbId } from "./play-picker/use-imdb-id"; import { RateButton } from "@/components/ratings/rate-button"; import { detailImdbRating, fetchDetailImdbRating } from "@/lib/detail-imdb-rating"; import { ratingTarget } from "@/lib/ratings/types"; @@ -409,6 +411,7 @@ export function DetailView({ const titleHomeServers = useTitleMediaServers(meta.id, detail?.imdbId); const { toggle: toggleFavorite } = useMediaFavorites(); const isFav = useIsFavorite(meta.id, [detail?.imdbId]); + const inferredImdb = useImdbId(meta, settings.tmdbKey); const inSession = roomSnapshot.state === "joined" && roomSnapshot.participants.length >= 2; useScrollMemory(`meta:${meta.id}`, scrollRef, settings.resumeDetailScroll, true); @@ -2932,6 +2935,19 @@ export function DetailView({ node: , }); } + + const resolvedParentsGuideImdb = + detail?.imdbId ?? + animeEpisodes.find((e) => e.imdbId)?.imdbId ?? + (meta.id.startsWith("tt") ? meta.id : null) ?? + inferredImdb.id; + + railSections.push({ + key: "parentsGuide", + label: t("Content Advisory"), + minHeight: 120, + node: , + }); if (detail?.collection) { railSections.push({ key: "collection", @@ -3133,6 +3149,7 @@ export function DetailView({ ), }); } + if (railSections.length === 0) return null; return ; })()} diff --git a/src/views/detail/parents-guide-panel.tsx b/src/views/detail/parents-guide-panel.tsx new file mode 100644 index 000000000..2c3482a2d --- /dev/null +++ b/src/views/detail/parents-guide-panel.tsx @@ -0,0 +1,312 @@ +import { useState, useEffect } from "react"; +import { + ChevronDown, + ExternalLink, + Ghost, + Heart, + Info, + Loader2, + MessageSquareWarning, + Swords, + Wine, +} from "lucide-react"; +import { openUrl } from "@/lib/window"; +import { useT } from "@/lib/i18n"; +import { + fetchImdbParentsGuide, + severityColorStyle, + type ParentsGuideCategory, + type ParentsGuideResult, +} from "@/lib/providers/imdb-parents-guide"; + +const SEV_RANK: Record = { + none: 0, + mild: 1, + moderate: 2, + severe: 3, +}; + +function getCategoryIcon(id: string, label: string) { + const norm = `${id} ${label}`.toLowerCase(); + if (norm.includes("sex") || norm.includes("nudity")) return Heart; + if (norm.includes("violence") || norm.includes("gore")) return Swords; + if (norm.includes("profanity") || norm.includes("language")) return MessageSquareWarning; + if (norm.includes("alcohol") || norm.includes("drug") || norm.includes("smoking")) return Wine; + if (norm.includes("frighten") || norm.includes("intense")) return Ghost; + return Info; +} + +export function ParentsGuidePanel({ imdbId }: { imdbId: string | null }) { + const t = useT(); + const [guide, setGuide] = useState(null); + const [loading, setLoading] = useState(false); + const [sectionCollapsed, setSectionCollapsed] = useState(false); + const [expandedCats, setExpandedCats] = useState>(() => new Set()); + const [revealedSpoilers, setRevealedSpoilers] = useState>(() => new Set()); + + useEffect(() => { + if (!imdbId || !/^tt\d+$/.test(imdbId)) { + setGuide(null); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + + fetchImdbParentsGuide(imdbId) + .then((res) => { + if (!cancelled) { + setGuide(res); + setLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setGuide(null); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [imdbId]); + + if (!loading && (!guide || guide.categories.length === 0)) { + return null; + } + + const toggleCategory = (id: string) => { + setExpandedCats((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSpoilers = (id: string) => { + setRevealedSpoilers((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const imdbGuideUrl = imdbId ? `https://www.imdb.com/title/${imdbId}/parentalguide` : null; + + return ( +
+ {/* Heading row with Collapse and IMDb links */} +
+

{t("Content Advisory")}

+
+ + {imdbGuideUrl && ( + + )} +
+
+ + {/* Collapsible Content Area */} +
+
+ {loading && !guide ? ( +
+ + {t("Loading Content Advisory…")} +
+ ) : ( +
+ {guide?.categories.map((cat) => ( + toggleCategory(cat.id)} + onToggleSpoilers={() => toggleSpoilers(cat.id)} + /> + ))} +
+ )} +
+
+
+ ); +} + +function CategoryRow({ + category, + expanded, + spoilersRevealed, + onToggle, + onToggleSpoilers, +}: { + category: ParentsGuideCategory; + expanded: boolean; + spoilersRevealed: boolean; + onToggle: () => void; + onToggleSpoilers: () => void; +}) { + const t = useT(); + const color = severityColorStyle(category.severity); + const rank = SEV_RANK[category.severity.toLowerCase()] ?? 0; + const CategoryIcon = getCategoryIcon(category.id, category.label); + const plainItems = category.items.filter((item) => !item.isSpoiler); + const spoilerItems = category.items.filter((item) => item.isSpoiler); + const hasNotes = category.items.length > 0; + + return ( +
+ + + {/* Accordion Notes Dropdown */} +
+
+
+ {category.totalVotes > 0 && ( +
+ {t("{votedFor} of {totalVotes} users rated {severity}", { + votedFor: category.severityVotes, + totalVotes: category.totalVotes, + severity: category.severity.toLowerCase(), + })} +
+ )} + + {hasNotes ? ( + <> + {plainItems.length > 0 && ( +
    + {plainItems.map((item, idx) => ( +
  • + + {item.text} +
  • + ))} +
+ )} + + {spoilerItems.length > 0 && ( +
+ + + {spoilersRevealed && ( +
    + {spoilerItems.map((item, idx) => ( +
  • + + {item.text} +
  • + ))} +
+ )} +
+ )} + + ) : ( +
+ {t("No detailed notes submitted for this category.")} +
+ )} +
+
+
+
+ ); +}