diff --git a/src/components/title/DidYouKnow.tsx b/src/components/title/DidYouKnow.tsx index 66c5ab2..5f28f18 100644 --- a/src/components/title/DidYouKnow.tsx +++ b/src/components/title/DidYouKnow.tsx @@ -1,12 +1,13 @@ import Link from 'next/link'; -import { DidYouKnow } from 'src/interfaces/shared/title'; +import type { DidYouKnow as DidYouKnowType } from 'src/interfaces/shared/title'; import styles from 'src/styles/modules/components/title/did-you-know.module.scss'; type Props = { - data: DidYouKnow; + data: DidYouKnowType; + titleId: string; }; -const DidYouKnow = ({ data }: Props) => { +const DidYouKnow = ({ data, titleId }: Props) => { if (!Object.keys(data).length) return (
@@ -20,7 +21,16 @@ const DidYouKnow = ({ data }: Props) => {
{data.trivia && (
-

Trivia

+

+ Trivia + {data.trivia.total > 1 && ( + + + See all {data.trivia.total} trivia ยป + + + )} +

>; +export type { Trivia as default }; + +export type TriviaItem = Trivia['items'][0]; +export type TriviaMeta = Trivia['meta']; diff --git a/src/pages/api/title/[titleId]/trivia.ts b/src/pages/api/title/[titleId]/trivia.ts new file mode 100644 index 0000000..6daea2a --- /dev/null +++ b/src/pages/api/title/[titleId]/trivia.ts @@ -0,0 +1,34 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import trivia from 'src/utils/fetchers/titleTrivia'; +import { getErrorProperties } from 'src/utils/helpers'; +import getOrSetApiCache from 'src/utils/getOrSetApiCache'; +import { titleTriviaKey } from 'src/utils/constants/keys'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { titleId, page } = req.query; + + if (typeof titleId !== 'string') { + return res.status(400).json({ error: 'Invalid title ID' }); + } + + const pageNum = typeof page === 'string' ? parseInt(page, 10) : 1; + + if (isNaN(pageNum) || pageNum < 1) { + return res.status(400).json({ error: 'Invalid page number' }); + } + + try { + // Cache each page separately + const cacheKey = `${titleTriviaKey(titleId)}:page:${pageNum}`; + const data = await getOrSetApiCache(cacheKey, trivia, titleId, pageNum); + + return res.status(200).json(data); + } catch (e) { + const err = getErrorProperties(e); + console.error('Error fetching trivia:', err); + return res.status(err.statusCode).json({ + error: err.message, + statusCode: err.statusCode, + }); + } +} diff --git a/src/pages/title/[titleId]/index.tsx b/src/pages/title/[titleId]/index.tsx index a6ed616..f75804e 100644 --- a/src/pages/title/[titleId]/index.tsx +++ b/src/pages/title/[titleId]/index.tsx @@ -39,7 +39,7 @@ const TitleInfo = ({ data, error, originalPath }: Props) => {
- +
diff --git a/src/pages/title/[titleId]/trivia/index.tsx b/src/pages/title/[titleId]/trivia/index.tsx new file mode 100644 index 0000000..17226b9 --- /dev/null +++ b/src/pages/title/[titleId]/trivia/index.tsx @@ -0,0 +1,379 @@ +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import Link from 'next/link'; +import { useState, useEffect } from 'react'; +import Meta from 'src/components/meta/Meta'; +import Layout from 'src/components/layout'; +import ErrorInfo from 'src/components/error/ErrorInfo'; +import Trivia from 'src/interfaces/shared/trivia'; +import type { AppError } from 'src/interfaces/shared/error'; +import getOrSetApiCache from 'src/utils/getOrSetApiCache'; +import trivia from 'src/utils/fetchers/titleTrivia'; +import { getErrorProperties, getProxiedIMDbImgUrl } from 'src/utils/helpers'; +import { titleTriviaKey } from 'src/utils/constants/keys'; +import styles from 'src/styles/modules/pages/title/trivia.module.scss'; + +type Props = InferGetServerSidePropsType; + +const TriviaPage = ({ data, error, originalPath }: Props) => { + const [regularItems, setRegularItems] = useState( + data?.items?.filter(item => !item.isSpoiler) || [] + ); + const [spoilerItems, setSpoilerItems] = useState( + data?.items?.filter(item => item.isSpoiler) || [] + ); + const [regularPage, setRegularPage] = useState(1); + const [spoilerPage, setSpoilerPage] = useState(1); + const [isLoadingRegular, setIsLoadingRegular] = useState(false); + const [isLoadingSpoiler, setIsLoadingSpoiler] = useState(false); + const [isLoadingAllRegular, setIsLoadingAllRegular] = useState(false); + const [isLoadingAllSpoiler, setIsLoadingAllSpoiler] = useState(false); + const [hasMoreRegular, setHasMoreRegular] = useState(data?.hasMore || false); + const [hasMoreSpoiler, setHasMoreSpoiler] = useState(data?.hasMore || false); + const [totalRegular, setTotalRegular] = useState(0); + const [totalSpoiler, setTotalSpoiler] = useState(0); + const [showCopiedToast, setShowCopiedToast] = useState(false); + + // Calculate totals for each category on mount + useEffect(() => { + if (data?.items) { + const regularCount = data.items.filter(item => !item.isSpoiler).length; + const spoilerCount = data.items.filter(item => item.isSpoiler).length; + + // Estimate totals based on ratio if we don't have exact counts + if (data.total > 0 && regularCount + spoilerCount > 0) { + const ratio = data.total / (regularCount + spoilerCount); + setTotalRegular(Math.ceil(regularCount * ratio)); + setTotalSpoiler(Math.ceil(spoilerCount * ratio)); + } + } + }, [data]); + + if (error) return ; + + const { meta, total } = data; + const totalItems = regularItems.length + spoilerItems.length; + + const loadMoreRegular = async () => { + if (isLoadingRegular || !hasMoreRegular) return; + + setIsLoadingRegular(true); + try { + const response = await fetch(`/api/title/${meta.titleId}/trivia?page=${regularPage + 1}`); + const newData = await response.json(); + + if (newData.items && newData.items.length > 0) { + const regular = newData.items.filter((item: any) => !item.isSpoiler); + + if (regular.length > 0) { + setRegularItems(prev => [...prev, ...regular]); + setRegularPage(prev => prev + 1); + } + + setHasMoreRegular(newData.hasMore); + } else { + setHasMoreRegular(false); + } + } catch (err) { + console.error('Error loading more regular trivia:', err); + setHasMoreRegular(false); + } finally { + setIsLoadingRegular(false); + } + }; + + const loadMoreSpoiler = async () => { + if (isLoadingSpoiler || !hasMoreSpoiler) return; + + setIsLoadingSpoiler(true); + try { + const response = await fetch(`/api/title/${meta.titleId}/trivia?page=${spoilerPage + 1}`); + const newData = await response.json(); + + if (newData.items && newData.items.length > 0) { + const spoilers = newData.items.filter((item: any) => item.isSpoiler); + + if (spoilers.length > 0) { + setSpoilerItems(prev => [...prev, ...spoilers]); + setSpoilerPage(prev => prev + 1); + } + + setHasMoreSpoiler(newData.hasMore); + } else { + setHasMoreSpoiler(false); + } + } catch (err) { + console.error('Error loading more spoiler trivia:', err); + setHasMoreSpoiler(false); + } finally { + setIsLoadingSpoiler(false); + } + }; + + const loadAllRegular = async () => { + if (isLoadingAllRegular || !hasMoreRegular) return; + + setIsLoadingAllRegular(true); + try { + let currentPage = regularPage + 1; + let hasMore: boolean = hasMoreRegular; + + while (hasMore) { + const response = await fetch(`/api/title/${meta.titleId}/trivia?page=${currentPage}`); + const newData = await response.json(); + + if (newData.items && newData.items.length > 0) { + const regular = newData.items.filter((item: any) => !item.isSpoiler); + + if (regular.length > 0) { + setRegularItems(prev => [...prev, ...regular]); + } + + hasMore = newData.hasMore; + currentPage++; + } else { + hasMore = false; + } + } + + setRegularPage(currentPage - 1); + setHasMoreRegular(false); + } catch (err) { + console.error('Error loading all regular trivia:', err); + setHasMoreRegular(false); + } finally { + setIsLoadingAllRegular(false); + } + }; + + const loadAllSpoiler = async () => { + if (isLoadingAllSpoiler || !hasMoreSpoiler) return; + + setIsLoadingAllSpoiler(true); + try { + let currentPage = spoilerPage + 1; + let hasMore: boolean = hasMoreSpoiler; + + while (hasMore) { + const response = await fetch(`/api/title/${meta.titleId}/trivia?page=${currentPage}`); + const newData = await response.json(); + + if (newData.items && newData.items.length > 0) { + const spoilers = newData.items.filter((item: any) => item.isSpoiler); + + if (spoilers.length > 0) { + setSpoilerItems(prev => [...prev, ...spoilers]); + } + + hasMore = newData.hasMore; + currentPage++; + } else { + hasMore = false; + } + } + + setSpoilerPage(currentPage - 1); + setHasMoreSpoiler(false); + } catch (err) { + console.error('Error loading all spoiler trivia:', err); + setHasMoreSpoiler(false); + } finally { + setIsLoadingAllSpoiler(false); + } + }; + + const copyLinkToClipboard = (index: number, isSpoiler: boolean) => { + const anchor = isSpoiler ? `spoiler-${index}` : `regular-${index}`; + const url = `${window.location.origin}${window.location.pathname}#${anchor}`; + navigator.clipboard.writeText(url).then(() => { + setShowCopiedToast(true); + setTimeout(() => setShowCopiedToast(false), 2000); + }); + }; + + return ( + <> + + + <> + {showCopiedToast &&
โœ“ Link copied to clipboard!
} +
+ + +
+

+ {meta.title} {meta.year} +

+
+ +
+

+ Trivia + {total > 0 && ( + + ({totalItems} of {total} {total === 1 ? 'item' : 'items'}) + + )} +

+ + {totalItems > 0 ? ( + <> + {regularItems.length > 0 && ( + <> +

+ Regular Trivia ({regularItems.length} + {totalRegular > 0 && ` of ${totalRegular}`}) +

+
+ {regularItems.map((item, index) => ( +
+
+ #{index + 1} + +
+
+
+ ))} +
+ + {hasMoreRegular && ( +
+ + +
+ )} + + )} + + {spoilerItems.length > 0 && ( + <> +

+ Spoiler Trivia ({spoilerItems.length} + {totalSpoiler > 0 && ` of ${totalSpoiler}`}) +

+
+ {spoilerItems.map((item, index) => ( +
+
โš  Spoiler
+
+ #{index + 1} + +
+
+
+ ))} +
+ + {hasMoreSpoiler && ( +
+ + +
+ )} + + )} + + ) : ( +
+

No trivia available for this title.

+
+ )} +
+
+ +
+ + ); +}; + +export default TriviaPage; + +type Data = { + meta: Trivia['meta']; + items: Trivia['items']; + total: number; + hasMore: boolean; +}; +type Params = { titleId: string }; + +const getServerSideProps: GetServerSideProps< + | { data: Data; error: null; originalPath: string } + | { data: null; error: AppError; originalPath: string } +> = async context => { + const { titleId } = context.params as Params; + const key = titleTriviaKey(titleId); + const originalPath = context.resolvedUrl; + + try { + const data = await getOrSetApiCache(key, trivia, titleId, 1); + return { + props: { + data, + error: null, + originalPath, + }, + }; + } catch (error) { + const errorProperties = getErrorProperties(error); + return { props: { data: null, error: errorProperties, originalPath } }; + } +}; + +export { getServerSideProps }; diff --git a/src/styles/modules/pages/title/trivia.module.scss b/src/styles/modules/pages/title/trivia.module.scss new file mode 100644 index 0000000..60eb71e --- /dev/null +++ b/src/styles/modules/pages/title/trivia.module.scss @@ -0,0 +1,227 @@ +@use '../../../abstracts' as helper; + +.trivia { + --doc-whitespace: var(--spacer-8); + --comp-whitespace: var(--spacer-3); + + display: grid; + gap: var(--doc-whitespace); + padding: var(--doc-whitespace); + + @include helper.bp('bp-700') { + --doc-whitespace: var(--spacer-5); + } + + @include helper.bp('bp-450') { + padding: var(--spacer-3); + } +} + +.backLink { + margin-bottom: var(--comp-whitespace); + font-size: 0.9em; + + a { + @include helper.prettify-link(var(--clr-link)); + } +} + +.header { + margin-bottom: var(--comp-whitespace); +} + +.section { + display: grid; + gap: var(--comp-whitespace); +} + +.sectionHeader { + margin-bottom: var(--spacer-2); +} + +.count { + color: var(--clr-text-muted); + font-size: 0.9em; + margin-left: var(--spacer-2); +} + +.triviaList { + display: grid; + gap: var(--comp-whitespace); +} + +.triviaItem { + padding: var(--spacer-3); + background-color: var(--clr-bg-accent); + border-radius: 0.5rem; + border-left: 4px solid var(--clr-fill); + transition: background-color 200ms ease; + + &:hover { + background-color: var(--clr-bg-muted); + } +} + +.triviaItemSpoiler { + composes: triviaItem; + border-left-color: #dc2626; +} + +.spoilerBadge { + display: inline-block; + padding: var(--spacer-0) var(--spacer-1); + margin-bottom: var(--spacer-1); + background-color: #dc2626; + color: #fff; + font-size: 0.75rem; + font-weight: bold; + border-radius: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.triviaContent { + line-height: var(--line-height); + color: var(--clr-text); + + p { + margin-bottom: var(--spacer-1); + + &:last-child { + margin-bottom: 0; + } + } + + a { + @include helper.prettify-link(var(--clr-link)); + } +} + +.triviaHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--spacer-1); +} + +.triviaNumber { + color: var(--clr-text-muted); + font-size: 0.85em; +} + +.shareButton { + background: none; + border: none; + color: var(--clr-text-muted); + cursor: pointer; + font-size: 0.85em; + padding: var(--spacer-0) var(--spacer-1); + transition: color 200ms ease; + + &:hover { + color: var(--clr-link); + } + + &:focus-visible { + @include helper.focus-rules; + } +} + +.emptyState { + padding: var(--spacer-5) var(--spacer-3); + text-align: center; + background-color: var(--clr-bg-accent); + border-radius: 0.5rem; +} + +.emptyText { + color: var(--clr-text-muted); + font-size: 1.1em; +} + +.loadMoreButton { + padding: var(--spacer-1) var(--spacer-3); + font-size: 1rem; + background-color: var(--clr-fill); + color: var(--clr-bg); + border: none; + border-radius: 0.25rem; + cursor: pointer; + font-weight: 600; + transition: all 200ms ease; + + &:hover:not(:disabled) { + opacity: 0.85; + transform: translateY(-1px); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:focus-visible { + @include helper.focus-rules; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.loadMoreButtonSecondary { + composes: loadMoreButton; + background-color: var(--clr-link); + color: var(--clr-bg); +} + +.loadMoreContainer { + display: flex; + gap: var(--spacer-2); + justify-content: center; + margin-top: var(--comp-whitespace); + + @include helper.bp('bp-450') { + flex-direction: column; + } +} + +.categoryHeader { + margin-top: var(--doc-whitespace); + margin-bottom: var(--spacer-2); + + &:first-of-type { + margin-top: 0; + } +} + +.toast { + position: fixed; + top: var(--spacer-3); + right: var(--spacer-3); + background-color: #4caf50; + color: #fff; + padding: var(--spacer-2) var(--spacer-3); + border-radius: 0.25rem; + box-shadow: var(--clr-shadow); + z-index: 1000; + animation: fadeIn 300ms ease-in; + + @include helper.bp('bp-450') { + top: var(--spacer-2); + right: var(--spacer-2); + left: var(--spacer-2); + text-align: center; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-1rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/utils/constants/keys.ts b/src/utils/constants/keys.ts index 25ebafb..cee11f3 100644 --- a/src/utils/constants/keys.ts +++ b/src/utils/constants/keys.ts @@ -1,6 +1,7 @@ export const titleKey = (titleId: string) => `title:${titleId}`; export const titleReviewsKey = (titleId: string, queryStr: string, paginationKey: string | null) => `title/reviews:${titleId}?${queryStr}&paginationKey=${paginationKey}`; +export const titleTriviaKey = (titleId: string) => `title/trivia:${titleId}`; export const nameKey = (nameId: string) => `name:${nameId}`; export const listKey = (listId: string, pageNum = '1') => `list:${listId}?page=${pageNum}`; export const findKey = (query: string) => `find:${query}`; diff --git a/src/utils/fetchers/titleTrivia.ts b/src/utils/fetchers/titleTrivia.ts new file mode 100644 index 0000000..8022e76 --- /dev/null +++ b/src/utils/fetchers/titleTrivia.ts @@ -0,0 +1,99 @@ +import * as cheerio from 'cheerio'; +import axiosInstance, { isSaneError } from 'src/utils/axiosInstance'; +import { AppError } from 'src/utils/helpers'; + +const trivia = async (titleId: string, page: number = 1) => { + try { + // Fetch the trivia page (with optional page parameter) + const url = page > 1 ? `/title/${titleId}/trivia?page=${page}` : `/title/${titleId}/trivia`; + + const res = await axiosInstance(url); + const $ = cheerio.load(res.data); + const rawData = $('script#__NEXT_DATA__').text(); + + if (!rawData) { + throw new AppError('Could not find trivia data', 404); + } + + let parsedData; + try { + parsedData = JSON.parse(rawData); + } catch (e) { + throw new AppError('Invalid JSON data', 500); + } + + const pageProps = parsedData?.props?.pageProps; + + if (!pageProps) { + throw new AppError('Invalid data structure - no pageProps', 500); + } + + // Extract metadata + const mainData = pageProps.contentData?.entityMetadata; + const triviaCategories = pageProps.contentData?.data?.title?.triviaCategories; + const titleData = pageProps.contentData?.data?.title; + + const meta = { + title: mainData?.titleText?.text || mainData?.originalTitleText?.text || '', + year: mainData?.releaseYear?.year ? `(${mainData.releaseYear.year})` : '', + image: mainData?.primaryImage?.url || null, + titleId: mainData?.id || titleId, + }; + + // Extract trivia items from triviaCategories array + const triviaList: Array<{ html: string; text: string; isSpoiler?: boolean }> = []; + + if (triviaCategories && typeof triviaCategories === 'object') { + const categoriesArray = Object.values(triviaCategories); + + categoriesArray.forEach((category: any) => { + // Extract regular trivia + if (category?.trivia?.edges && Array.isArray(category.trivia.edges)) { + category.trivia.edges.forEach((edge: any) => { + const htmlContent = edge?.node?.displayableArticle?.body?.plaidHtml; + if (htmlContent) { + triviaList.push({ + html: htmlContent, + text: htmlContent.replace(/<[^>]*>/g, '').trim(), + }); + } + }); + } + + // Extract spoiler trivia + if (category?.spoilerTrivia?.edges && Array.isArray(category.spoilerTrivia.edges)) { + category.spoilerTrivia.edges.forEach((edge: any) => { + const htmlContent = edge?.node?.displayableArticle?.body?.plaidHtml; + if (htmlContent) { + triviaList.push({ + html: htmlContent, + text: htmlContent.replace(/<[^>]*>/g, '').trim(), + isSpoiler: true, + }); + } + }); + } + }); + } + + // Get total count from subNavTrivia + const total = titleData?.subNavTrivia?.total || triviaList.length; + + return { + meta, + total, + items: triviaList, + hasMore: triviaList.length > 0 && triviaList.length < total, + }; + } catch (err) { + if (isSaneError(err) && err.response?.status === 404) { + throw new AppError('not found', 404, err); + } + + if (err instanceof AppError) throw err; + + throw new AppError('something went wrong', 500, err); + } +}; + +export default trivia;