From dc42b3204caf843d0f07fa28572c5ed275bb601d Mon Sep 17 00:00:00 2001 From: zyachel Date: Sun, 31 Mar 2024 15:37:44 +0530 Subject: [PATCH] feat(reviews): add reviews route allows seeing all reviews with optional filters. works sans JS as well closes https://github.com/zyachel/libremdb/issues/25 closes https://codeberg.org/zyachel/libremdb/issues/19 --- README.md | 4 +- public/svg/sprite.svg | 6 + src/components/card/CardResult.tsx | 23 +++- src/components/forms/find/index.tsx | 15 ++- src/components/titleReviews/Card.tsx | 46 +++++++ src/components/titleReviews/Filters.tsx | 86 +++++++++++++ src/components/titleReviews/Pagination.tsx | 34 +++++ src/components/titleReviews/Reviews.tsx | 82 ++++++++++++ src/components/titleReviews/index.ts | 5 + src/interfaces/misc/rawName.ts | 2 +- src/interfaces/shared/titleReviews.ts | 6 + src/pages/api/find.ts | 6 +- .../{[titleId].ts => [titleId]/index.ts} | 0 .../[titleId]/reviews/[paginationKey].ts | 33 +++++ .../api/title/[titleId]/reviews/index.ts | 21 +++ src/pages/find/index.tsx | 4 +- .../reviews/[paginationKey]/index.tsx | 72 +++++++++++ src/pages/title/[titleId]/reviews/index.tsx | 92 ++++++++++++++ .../card/card-title-reviews.module.scss | 80 ++++++++++++ .../components/titleReviews/form.module.scss | 111 ++++++++++++++++ .../titleReviews/pagination.module.scss | 22 ++++ .../titleReviews/reviews.module.scss | 120 ++++++++++++++++++ .../titleReviews/titleReviews.module.scss | 43 +++++++ src/utils/cleaners/name.ts | 2 +- src/utils/constants/find.ts | 2 + src/utils/constants/keys.ts | 3 + src/utils/constants/titleReviewsFilters.ts | 36 ++++++ src/utils/fetchers/titleReviews.ts | 113 +++++++++++++++++ src/utils/helpers.ts | 15 +-- 29 files changed, 1053 insertions(+), 31 deletions(-) create mode 100644 src/components/titleReviews/Card.tsx create mode 100644 src/components/titleReviews/Filters.tsx create mode 100644 src/components/titleReviews/Pagination.tsx create mode 100644 src/components/titleReviews/Reviews.tsx create mode 100644 src/components/titleReviews/index.ts create mode 100644 src/interfaces/shared/titleReviews.ts rename src/pages/api/title/{[titleId].ts => [titleId]/index.ts} (100%) create mode 100644 src/pages/api/title/[titleId]/reviews/[paginationKey].ts create mode 100644 src/pages/api/title/[titleId]/reviews/index.ts create mode 100644 src/pages/title/[titleId]/reviews/[paginationKey]/index.tsx create mode 100644 src/pages/title/[titleId]/reviews/index.tsx create mode 100644 src/styles/modules/components/card/card-title-reviews.module.scss create mode 100644 src/styles/modules/components/titleReviews/form.module.scss create mode 100644 src/styles/modules/components/titleReviews/pagination.module.scss create mode 100644 src/styles/modules/components/titleReviews/reviews.module.scss create mode 100644 src/styles/modules/pages/titleReviews/titleReviews.module.scss create mode 100644 src/utils/constants/titleReviewsFilters.ts create mode 100644 src/utils/fetchers/titleReviews.ts diff --git a/README.md b/README.md index 63a1dc3..bc4c5c9 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Instances list in JSON format can be found in [instances.json](instances.json) f - [x] add a way to see trailer and other videos - [ ] implement movie specific routes like: - - [ ] reviews(including critic reviews) + - [x] reviews(including critic reviews) - [ ] video & image gallery - [ ] sections under 'did you know' - [ ] release info @@ -109,7 +109,7 @@ Instances list in JSON format can be found in [instances.json](instances.json) f - [ ] implement other routes like: - - [ ] lists + - [x] lists - [ ] moviemeter - [x] person info(includes directors and actors) - [ ] company info diff --git a/public/svg/sprite.svg b/public/svg/sprite.svg index d46a088..378a62d 100644 --- a/public/svg/sprite.svg +++ b/public/svg/sprite.svg @@ -43,4 +43,10 @@ + + + + + + \ No newline at end of file diff --git a/src/components/card/CardResult.tsx b/src/components/card/CardResult.tsx index aab4a66..82f7ac8 100644 --- a/src/components/card/CardResult.tsx +++ b/src/components/card/CardResult.tsx @@ -1,19 +1,28 @@ -import { ComponentPropsWithoutRef, ReactNode } from 'react'; +import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react'; import Link from 'next/link'; import Image from 'next/future/image'; import Card from './Card'; import { modifyIMDbImg } from 'src/utils/helpers'; import styles from 'src/styles/modules/components/card/card-result.module.scss'; -type Props = { +type Props = { link: string; name: string; image?: string; showImage?: true; children?: ReactNode; -} & ComponentPropsWithoutRef<'li'>; + as?: T; +} & ComponentPropsWithoutRef; -const CardResult = ({ link, name, image, showImage, children, ...rest }: Props) => { +const CardResult = ({ + link, + name, + image, + showImage, + className, + children, + ...rest +}: Props) => { let ImageComponent = null; if (showImage) ImageComponent = image ? ( @@ -25,7 +34,11 @@ const CardResult = ({ link, name, image, showImage, children, ...rest }: Props) ); return ( - +
{ImageComponent}
diff --git a/src/components/forms/find/index.tsx b/src/components/forms/find/index.tsx index 1628249..2522f45 100644 --- a/src/components/forms/find/index.tsx +++ b/src/components/forms/find/index.tsx @@ -2,7 +2,7 @@ import { ChangeEventHandler, FormEventHandler, useRef, useState } from 'react'; import { useRouter } from 'next/router'; import { cleanQueryStr } from 'src/utils/helpers'; import { QueryTypes } from 'src/interfaces/shared/search'; -import { resultTypes, resultTitleTypes } from 'src/utils/constants/find'; +import { resultTypes, resultTitleTypes, findFilterable } from 'src/utils/constants/find'; import styles from 'src/styles/modules/components/form/find.module.scss'; type Props = { @@ -29,13 +29,15 @@ const Form = ({ className }: Props) => { const formEl = formRef.current!; const formData = new FormData(formEl); - const query = (formData.get('q') as string).trim(); + const query = formData.get('q'); + if (typeof query !== 'string' || !query.trim()) return setIsDisabled(false); - const entries = [...formData.entries()] as [string, string][]; - const queryStr = cleanQueryStr(entries); + const queryParams = Object.fromEntries( + formData.entries() as IterableIterator<[string, string]> + ); + const queryStr = cleanQueryStr(queryParams, findFilterable); - if (query) router.push(`/find?${queryStr}`); - else setIsDisabled(false); + router.push(`/find?${queryStr}`); formEl.reset(); }; @@ -117,5 +119,4 @@ const RadioBtns = ({ ); - export default Form; diff --git a/src/components/titleReviews/Card.tsx b/src/components/titleReviews/Card.tsx new file mode 100644 index 0000000..1f3315a --- /dev/null +++ b/src/components/titleReviews/Card.tsx @@ -0,0 +1,46 @@ +import { useRouter } from 'next/router'; +import { CardResult } from 'src/components/card'; +import TitleReviews, { TitleReviewsCursored } from 'src/interfaces/shared/titleReviews'; + +type Props = { + meta: TitleReviews['meta']; + className?: string; +}; + +const Card = ({ meta, className }: Props) => { + return ( + +

User Reviews

+

{meta.numReviews}

+
+ ); +}; + +type BasicCardProps = { + meta: TitleReviewsCursored['meta']; + className?: string; +}; +export const BasicCard = ({ meta, className }: BasicCardProps) => { + const { titleId } = useRouter().query; + + return ( + +

User Reviews

+
+ ); +}; + +export default Card; diff --git a/src/components/titleReviews/Filters.tsx b/src/components/titleReviews/Filters.tsx new file mode 100644 index 0000000..be4b8a1 --- /dev/null +++ b/src/components/titleReviews/Filters.tsx @@ -0,0 +1,86 @@ +import { FormEventHandler, useRef } from 'react'; +import { useRouter } from 'next/router'; +import { cleanQueryStr } from 'src/utils/helpers'; +import { direction, keys, ratings, sortBy } from 'src/utils/constants/titleReviewsFilters'; +import styles from 'src/styles/modules/components/titleReviews/form.module.scss'; + +type Props = { + className?: string; + titleId: string; +}; + +const Filters = ({ className, titleId }: Props) => { + const router = useRouter(); + const formRef = useRef(null); + + const submitHandler: FormEventHandler = e => { + e.preventDefault(); + + const formEl = formRef.current!; + const formData = new FormData(formEl); + + const entries = Object.fromEntries(formData.entries()) as Record; + const queryStr = cleanQueryStr(entries, keys); + + router.push(`/title/${titleId}/reviews?${queryStr}`); + }; + + return ( +
+
+ Filter by Rating + +
+
+ Sort by + +
+
+ Direction + +
+

+ + +

+
+ + +
+
+ ); +}; + +const RadioBtns = ({ + data, + className, +}: { + data: typeof ratings | typeof sortBy | typeof direction; + className: string; +}) => ( + <> + {data.types.map(({ name, val }) => ( +

+ + +

+ ))} + +); + +export default Filters; diff --git a/src/components/titleReviews/Pagination.tsx b/src/components/titleReviews/Pagination.tsx new file mode 100644 index 0000000..5a4f361 --- /dev/null +++ b/src/components/titleReviews/Pagination.tsx @@ -0,0 +1,34 @@ +import TitleReviews, { TitleReviewsCursored } from 'src/interfaces/shared/titleReviews'; +import styles from 'src/styles/modules/components/titleReviews/pagination.module.scss'; +import Link from 'next/link'; +import { ReactNode } from 'react'; +import { useRouter } from 'next/router'; +import { cleanQueryStr } from 'src/utils/helpers'; +import { direction, keys, ratings, sortBy } from 'src/utils/constants/titleReviewsFilters'; + +type Props = { + meta: TitleReviewsCursored['meta']; + cursor: string | null; + onClick?: (queryStr: string) => void; +}; + +const Pagination = ({ cursor, onClick = () => {}, meta }: Props) => { + const router = useRouter(); + + if (!cursor || !meta.titleId) return null; + const queryParams = router.query as Record; + const queryStr = cleanQueryStr(queryParams, keys); + + return ( + <> + + + Load More + + + ); +}; + +export default Pagination; diff --git a/src/components/titleReviews/Reviews.tsx b/src/components/titleReviews/Reviews.tsx new file mode 100644 index 0000000..51ee93e --- /dev/null +++ b/src/components/titleReviews/Reviews.tsx @@ -0,0 +1,82 @@ +import TitleReviews from 'src/interfaces/shared/titleReviews'; +import styles from 'src/styles/modules/components/titleReviews/reviews.module.scss'; +import Link from 'next/link'; +import { ReactNode } from 'react'; + +type Props = { + list: TitleReviews['list']; + className?: string; + children?: ReactNode; +}; + +const Results = ({ list, className, children }: Props) => { + return ( +
+
+ {list.map(review => ( + + ))} +
+ {children} +
+ ); +}; + +const Review = ({ + by, + date, + isSpoiler, + rating, + responses, + reviewHtml, + reviewId, + summary, +}: TitleReviews['list'][number]) => { + return ( +
+
+ + + {by.name} + + +

+ {isSpoiler && ( + + + + + Spoilers + + )} + {rating && ( + + + + + {rating} + + )} + + + +

+ {summary} +
+ {reviewHtml && ( +
+ )} +
+
+

{responses}

+
+
+ ); +}; + +export default Results; diff --git a/src/components/titleReviews/index.ts b/src/components/titleReviews/index.ts new file mode 100644 index 0000000..8f706d2 --- /dev/null +++ b/src/components/titleReviews/index.ts @@ -0,0 +1,5 @@ +export { default as Reviews } from './Reviews'; +export { default as TitleCard } from './Card'; +export * from './Card'; +export { default as Filters } from './Filters'; +export { default as Pagination } from './Pagination'; diff --git a/src/interfaces/misc/rawName.ts b/src/interfaces/misc/rawName.ts index f180423..fb75506 100644 --- a/src/interfaces/misc/rawName.ts +++ b/src/interfaces/misc/rawName.ts @@ -562,7 +562,7 @@ export default interface Name { }; }>; totalCredits: { - total: number; + total?: number; // restriction?: { // unrestrictedTotal: number; // explanations: Array<{ diff --git a/src/interfaces/shared/titleReviews.ts b/src/interfaces/shared/titleReviews.ts new file mode 100644 index 0000000..104d53c --- /dev/null +++ b/src/interfaces/shared/titleReviews.ts @@ -0,0 +1,6 @@ +import reviews, { cursoredReviews } from 'src/utils/fetchers/titleReviews'; + +type TitleReviews = Awaited>; +export type { TitleReviews as default }; + +export type TitleReviewsCursored = Awaited>; diff --git a/src/pages/api/find.ts b/src/pages/api/find.ts index 2fcee7f..8190387 100644 --- a/src/pages/api/find.ts +++ b/src/pages/api/find.ts @@ -4,6 +4,7 @@ import basicSearch from 'src/utils/fetchers/basicSearch'; import getOrSetApiCache from 'src/utils/getOrSetApiCache'; import { findKey } from 'src/utils/constants/keys'; import { AppError, cleanQueryStr } from 'src/utils/helpers'; +import { findFilterable } from 'src/utils/constants/find'; type ResponseData = | { status: true; data: { title: null | string; results: null | Find } } @@ -13,15 +14,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< try { if (req.method !== 'GET') throw new AppError('Invalid method', 400); - const queryObj = req.query as FindQueryParams | Record; + const queryObj = req.query as FindQueryParams; const query = queryObj.q?.trim(); if (!query) { return res.status(200).json({ status: true, data: { title: null, results: null } }); } - const entries = Object.entries(queryObj); - const queryStr = cleanQueryStr(entries); + const queryStr = cleanQueryStr(queryObj, findFilterable); const results = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr); res.status(200).json({ status: true, data: { title: query, results } }); diff --git a/src/pages/api/title/[titleId].ts b/src/pages/api/title/[titleId]/index.ts similarity index 100% rename from src/pages/api/title/[titleId].ts rename to src/pages/api/title/[titleId]/index.ts diff --git a/src/pages/api/title/[titleId]/reviews/[paginationKey].ts b/src/pages/api/title/[titleId]/reviews/[paginationKey].ts new file mode 100644 index 0000000..60c695a --- /dev/null +++ b/src/pages/api/title/[titleId]/reviews/[paginationKey].ts @@ -0,0 +1,33 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import type { TitleReviewsCursored } from 'src/interfaces/shared/titleReviews'; +import { cursoredReviews } from 'src/utils/fetchers/titleReviews'; +import getOrSetApiCache from 'src/utils/getOrSetApiCache'; +import { titleReviewsCursoredKey } from 'src/utils/constants/keys'; +import { AppError, cleanQueryStr } from 'src/utils/helpers'; +import { keys as titleReviewsQueryKeys } from 'src/utils/constants/titleReviewsFilters'; + +type ResponseData = + | { status: true; data: TitleReviewsCursored } + | { status: false; message: string }; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + if (req.method !== 'GET') throw new AppError('Invalid method', 400); + + const titleId = req.query.titleId as string; + const paginationKey = req.query.paginationKey as string; + const queryObj = req.query as Record; + const queryStr = cleanQueryStr(queryObj, titleReviewsQueryKeys); + const data = await getOrSetApiCache( + titleReviewsCursoredKey(titleId, paginationKey), + cursoredReviews, + titleId, + paginationKey, + queryStr + ); + res.status(200).json({ status: true, data }); + } catch (error: any) { + const { message = 'Not found', statusCode = 404 } = error; + res.status(statusCode).json({ status: false, message }); + } +} diff --git a/src/pages/api/title/[titleId]/reviews/index.ts b/src/pages/api/title/[titleId]/reviews/index.ts new file mode 100644 index 0000000..9965a27 --- /dev/null +++ b/src/pages/api/title/[titleId]/reviews/index.ts @@ -0,0 +1,21 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import type Title from 'src/interfaces/shared/title'; +import title from 'src/utils/fetchers/title'; +import getOrSetApiCache from 'src/utils/getOrSetApiCache'; +import { titleKey } from 'src/utils/constants/keys'; +import { AppError } from 'src/utils/helpers'; + +type ResponseData = { status: true; data: Title } | { status: false; message: string }; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + if (req.method !== 'GET') throw new AppError('Invalid method', 400); + + const titleId = req.query.titleId as string; + const data = await getOrSetApiCache(titleKey(titleId), title, titleId); + res.status(200).json({ status: true, data }); + } catch (error: any) { + const { message = 'Not found', statusCode = 404 } = error; + res.status(statusCode).json({ status: false, message }); + } +} diff --git a/src/pages/find/index.tsx b/src/pages/find/index.tsx index e406b92..0a7e203 100644 --- a/src/pages/find/index.tsx +++ b/src/pages/find/index.tsx @@ -11,6 +11,7 @@ import getOrSetApiCache from 'src/utils/getOrSetApiCache'; import { cleanQueryStr } from 'src/utils/helpers'; import { findKey } from 'src/utils/constants/keys'; import styles from 'src/styles/modules/pages/find/find.module.scss'; +import { findFilterable } from 'src/utils/constants/find'; type Props = InferGetServerSidePropsType; @@ -58,8 +59,7 @@ export const getServerSideProps: GetServerSideProps = asy if (!query) return { props: { data: { title: null, results: null }, error: null, originalPath } }; try { - const entries = Object.entries(queryObj); - const queryStr = cleanQueryStr(entries); + const queryStr = cleanQueryStr(queryObj, findFilterable); const res = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr); diff --git a/src/pages/title/[titleId]/reviews/[paginationKey]/index.tsx b/src/pages/title/[titleId]/reviews/[paginationKey]/index.tsx new file mode 100644 index 0000000..180610d --- /dev/null +++ b/src/pages/title/[titleId]/reviews/[paginationKey]/index.tsx @@ -0,0 +1,72 @@ +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import Meta from 'src/components/meta/Meta'; +import Layout from 'src/components/layout'; +import ErrorInfo from 'src/components/error/ErrorInfo'; +import { BasicCard, Filters, Pagination, Reviews, TitleCard } from 'src/components/titleReviews'; +import { AppError } from 'src/interfaces/shared/error'; +import getOrSetApiCache from 'src/utils/getOrSetApiCache'; +import titleReviews, { cursoredReviews } from 'src/utils/fetchers/titleReviews'; +import { cleanQueryStr, getProxiedIMDbImgUrl } from 'src/utils/helpers'; +import { titleKey, titleReviewsKey } from 'src/utils/constants/keys'; +import styles from 'src/styles/modules/pages/titleReviews/titleReviews.module.scss'; +import TitleReviews, { TitleReviewsCursored } from 'src/interfaces/shared/titleReviews'; +import { keys as titleReviewsQueryKeys } from 'src/utils/constants/titleReviewsFilters'; + +type Props = InferGetServerSidePropsType; + +const CursoredReviewsPage = ({ data, error, originalPath }: Props) => { + if (error) return ; + + return ( + <> + + + + + + + + + + ); +}; + +// TO-DO: make a getServerSideProps wrapper for handling errors +type Data = ({ data: TitleReviewsCursored; error: null } | { error: AppError; data: null }) & { + originalPath: string; +}; +type Params = { titleId: string; paginationKey: string }; + +export const getServerSideProps: GetServerSideProps = async ctx => { + const titleId = ctx.params!.titleId; + const paginationKey = ctx.params!.paginationKey; + const title = ctx.query.title as string | null; + + const originalPath = `/title/${titleId}/reviews/_ajax?paginationKey=${paginationKey}`; + const queryObj = ctx.query as Record; + const queryStr = cleanQueryStr(queryObj, titleReviewsQueryKeys); + + try { + const data = await getOrSetApiCache( + titleKey(titleId), + cursoredReviews, + titleId, + paginationKey, + queryStr, + title + ); + + return { props: { data, error: null, originalPath } }; + } catch (error: any) { + const { message, statusCode } = error; + ctx.res.statusCode = statusCode; + ctx.res.statusMessage = message; + + return { props: { error: { message, statusCode }, data: null, originalPath } }; + } +}; + +export default CursoredReviewsPage; diff --git a/src/pages/title/[titleId]/reviews/index.tsx b/src/pages/title/[titleId]/reviews/index.tsx new file mode 100644 index 0000000..1944118 --- /dev/null +++ b/src/pages/title/[titleId]/reviews/index.tsx @@ -0,0 +1,92 @@ +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import Meta from 'src/components/meta/Meta'; +import Layout from 'src/components/layout'; +import ErrorInfo from 'src/components/error/ErrorInfo'; +import { Filters, Pagination, Reviews, TitleCard } from 'src/components/titleReviews'; +import { AppError } from 'src/interfaces/shared/error'; +import getOrSetApiCache from 'src/utils/getOrSetApiCache'; +import titleReviews, { cursoredReviews } from 'src/utils/fetchers/titleReviews'; +import { cleanQueryStr, getProxiedIMDbImgUrl } from 'src/utils/helpers'; +import { titleKey, titleReviewsKey } from 'src/utils/constants/keys'; +import styles from 'src/styles/modules/pages/titleReviews/titleReviews.module.scss'; +import TitleReviews from 'src/interfaces/shared/titleReviews'; +import { keys as titleReviewFiltersQueryKeys } from 'src/utils/constants/titleReviewsFilters'; +import { useState } from 'react'; +import ProgressBar from 'src/components/loaders/ProgressBar'; + +type Props = InferGetServerSidePropsType; + +// TO-DO: make a wrapper page component to display errors, if present in props +const ReviewsPage = ({ data, error, originalPath }: Props) => { + const [allData, setAllData] = useState({ list: data?.list ?? [], cursor: data?.cursor ?? null }); + const [isFetching, setIsFetching] = useState(false); + + if (error) return ; + + const handleOnClickLoadMore = (queryStr = '') => { + if (!data.cursor) return; + setIsFetching(true); + fetch(`/api/title/${data.meta.titleId}/reviews/${allData.cursor}?${queryStr}`) + .then(res => { + if (!res.ok) throw new Error('something went wrong'); + return res.json(); + }) + .then(newData => + setAllData(prev => ({ + list: prev.list.concat(newData.data.list), + cursor: newData.data.cursor ?? null, + })) + ) + .catch(console.log) + .finally(() => setIsFetching(false)); + }; + + return ( + <> + {isFetching && } + + + + + + + + + + ); +}; + +// TO-DO: make a getServerSideProps wrapper for handling errors +type Data = ({ data: TitleReviews; error: null } | { error: AppError; data: null }) & { + originalPath: string; +}; +type Params = { titleId: string }; + +export const getServerSideProps: GetServerSideProps = async ctx => { + const titleId = ctx.params!.titleId; + const originalPath = ctx.resolvedUrl; + const queryParams = ctx.query as Record; + const queryStr = cleanQueryStr(queryParams, titleReviewFiltersQueryKeys); + try { + const data = await getOrSetApiCache( + titleReviewsKey(titleId, queryStr), + titleReviews, + titleId, + queryStr + ); + + return { props: { data, error: null, originalPath } }; + } catch (error: any) { + const { message, statusCode } = error; + ctx.res.statusCode = statusCode; + ctx.res.statusMessage = message; + + return { props: { error: { message, statusCode }, data: null, originalPath } }; + } +}; + +export default ReviewsPage; diff --git a/src/styles/modules/components/card/card-title-reviews.module.scss b/src/styles/modules/components/card/card-title-reviews.module.scss new file mode 100644 index 0000000..648802f --- /dev/null +++ b/src/styles/modules/components/card/card-title-reviews.module.scss @@ -0,0 +1,80 @@ +@use '../../../abstracts' as helper; + +.title { + line-height: 1; +} + +.meta { + list-style: none; + display: flex; + flex-wrap: wrap; + + & * + *::before { + content: '\00b7'; + padding-inline: var(--spacer-0); + font-weight: 900; + line-height: 0; + font-size: var(--fs-5); + } + + @include helper.bp('bp-900') { + justify-content: center; + } +} + +.ratings { + display: flex; + flex-wrap: wrap; + gap: var(--spacer-0) var(--spacer-3); + + @include helper.bp('bp-900') { + justify-content: center; + } +} + +.rating { + font-size: var(--fs-5); + + display: grid; + grid-template-columns: repeat(2, max-content); + place-items: center; + gap: 0 var(--spacer-0); + + &__num { + grid-column: 1 / 2; + font-size: 1.8em; + font-weight: var(--fw-medium); + // line-height: 1; + } + + &__icon { + --dim: 1.8em; + grid-column: -2 / -1; + line-height: 1; + height: var(--dim); + width: var(--dim); + display: grid; + place-content: center; + fill: var(--clr-fill); + } + + &__text { + grid-column: 1 / -1; + font-size: 0.9em; + line-height: 1; + + color: var(--clr-text-muted); + } +} + +.link { + @include helper.prettify-link(var(--clr-link)); +} + +.genres, +.overview, +.crewType { + &__heading { + font-weight: var(--fw-bold); + } +} diff --git a/src/styles/modules/components/titleReviews/form.module.scss b/src/styles/modules/components/titleReviews/form.module.scss new file mode 100644 index 0000000..b5b09b2 --- /dev/null +++ b/src/styles/modules/components/titleReviews/form.module.scss @@ -0,0 +1,111 @@ +@use '../../../abstracts' as helper; + +.form { + display: grid; + gap: var(--spacer-2); + + position: sticky; + top: var(--spacer-2); + + @include helper.bp('bp-1200') { + position: initial; + } +} + +%border-styles { + border-radius: var(--spacer-1); + border: 2px solid var(--clr-fill-muted); +} + +.fieldset { + display: flex; + flex-wrap: wrap; + gap: var(--spacer-2); + padding: var(--spacer-2); + + @extend %border-styles; + + &__heading { + font-size: var(--fs-4); + padding-inline: var(--spacer-1); + flex: 100%; + line-height: 1; + color: var(--clr-text-muted); + } + + &:disabled { + &, * { + cursor: not-allowed; + filter: brightness(.95); + } + } +} + +.radio { + --border-color: transparent; + position: relative; + display: inline-flex; + + label { + cursor: pointer; + padding: var(--spacer-1) var(--spacer-2); + border-radius: 5px; + color: var(--clr-text-accent); + background-color: var(--clr-bg-accent); + border: 2px solid var(--border-color); + } + + input:checked + label { + --border-color: var(--clr-text-accent); + } + + // for keyboard navigation + input:focus + label { + @include helper.focus-rules; + } + + @supports selector(:focus-visible) { + input:focus + label { + outline: none; + } + + input:focus-visible + label { + @include helper.focus-rules; + } + } +} + +.exact { + display: flex; + gap: var(--spacer-1); + justify-self: start; + align-items: center; + + label, input { + cursor: pointer; + } +} + +.buttons { + display: flex; + gap: var(--spacer-2); +} + +.button { + + --text: var(--clr-link); + + padding: var(--spacer-1) var(--spacer-2); + font: inherit; + + background: transparent; + color: var(--text); + border: 2px solid currentColor; + border-radius: 5px; + cursor: pointer; + + &[type='reset'] { + --text: var(--clr-text-muted) + + } +} \ No newline at end of file diff --git a/src/styles/modules/components/titleReviews/pagination.module.scss b/src/styles/modules/components/titleReviews/pagination.module.scss new file mode 100644 index 0000000..2b300c8 --- /dev/null +++ b/src/styles/modules/components/titleReviews/pagination.module.scss @@ -0,0 +1,22 @@ +@use '../../../abstracts' as helper; + +.button, .link { + --text: var(--clr-link); + + padding: var(--spacer-1) var(--spacer-2); + font: inherit; + text-decoration: none; + + background: transparent; + color: var(--text); + border: 2px solid currentColor; + border-radius: 5px; + cursor: pointer; + +} + +:root { + &:not([data-js]) .button, &[data-js] .link { + display: none; + } +} diff --git a/src/styles/modules/components/titleReviews/reviews.module.scss b/src/styles/modules/components/titleReviews/reviews.module.scss new file mode 100644 index 0000000..4f5b429 --- /dev/null +++ b/src/styles/modules/components/titleReviews/reviews.module.scss @@ -0,0 +1,120 @@ +@use '../../../abstracts' as helper; + +.reviews { + display: grid; + // gap: var(--spacer-5); + + > * + * { + border-top: .5px solid var(--clr-fill-muted); + } + > * { + --padding: var(--spacer-5); + padding-block: var(--padding); + + @include helper.bp('bp-700') { + --padding: var(--spacer-3); + } + + } + + :first-child { + padding-top: 0; + } +} + +.review { + &__summary { + font-size: calc(var(--fs-5) * 1.1); + cursor: pointer; + + display: grid; + grid-template-areas: 'name rating' 'date rating' 'summary summary'; + align-items: stretch; + justify-items: start; + + + + a { + grid-area: name; + } + time { + grid-area: date; + font-size: .8em; + color: var(--clr-text-muted); + } + + strong { + grid-area: summary; + margin-top: var(--spacer-21); + } + + &_chevron { + --dim: 1.2em; + } + + // @include helper.bp('bp-700') { + // grid-template-areas: 'name' 'date' 'rating' 'summary'; + // } + } + + &__misc { + grid-area: rating; + align-self: center; + justify-self: self-end; + // background-color: red; + + display: grid; + grid-auto-flow: column; + gap: var(--spacer-2); + + span { + display: flex; + gap: var(--spacer-0); + align-items: center; + @include helper.bp('bp-700') { + font-size: .9em; + + } + } + + &_spoilers { + color: var(--clr-link-muted); + @include helper.bp('bp-700') { + + } + } + + @include helper.bp('bp-700') { + // justify-self: stretch; + // padding-block: var(--spacer-0); + grid-auto-flow: row; + grid-template-columns: repeat(2, max-content); + gap: 0; + } + } + + &__text, + &__metadata { + padding-top: var(--spacer-2); + } + + &__summary_chevron { + @include helper.bp('bp-700') { + grid-column: -2 / -1; + grid-row: 1 / 3; + place-self: center; + } + } + + &[open] &__summary_chevron { + transform: rotate(180deg); + + } +} + +:where(.icon) { + --dim: 1em; + height: var(--dim); + width: var(--dim); + fill: var(--clr-fill); +} diff --git a/src/styles/modules/pages/titleReviews/titleReviews.module.scss b/src/styles/modules/pages/titleReviews/titleReviews.module.scss new file mode 100644 index 0000000..ac8f63f --- /dev/null +++ b/src/styles/modules/pages/titleReviews/titleReviews.module.scss @@ -0,0 +1,43 @@ +@use '../../../abstracts' as helper; + +.container { + // major whitespace properties used on title page + --doc-whitespace: var(--spacer-8); + --comp-whitespace: var(--spacer-3); + + display: grid; + + gap: var(--doc-whitespace); + padding: var(--doc-whitespace); + align-items: start; + + grid-template-columns: repeat(5, 1fr); + grid-template-areas: + 'card card card form form' + 'results results results form form'; + + @include helper.bp('bp-900') { + grid-template-columns: none; + grid-template-areas: 'card' 'results' 'form'; + } + + @include helper.bp('bp-700') { + --doc-whitespace: var(--spacer-5); + } + + @include helper.bp('bp-450') { + --doc-whitespace: var(--spacer-3); + } +} + +.card { + grid-area: card; +} + +.results { + grid-area: results; +} + +.form { + grid-area: form; +} diff --git a/src/utils/cleaners/name.ts b/src/utils/cleaners/name.ts index 72fbef9..1482e46 100644 --- a/src/utils/cleaners/name.ts +++ b/src/utils/cleaners/name.ts @@ -126,7 +126,7 @@ const cleanName = (rawData: RawName) => { }, })), credits: { - total: misc.totalCredits.total, + total: misc.totalCredits?.total ?? null, summary: { titleType: misc.creditSummary.titleTypeCategories.map(cat => ({ total: cat.total, diff --git a/src/utils/constants/find.ts b/src/utils/constants/find.ts index e3e1f7b..b4d8cd8 100644 --- a/src/utils/constants/find.ts +++ b/src/utils/constants/find.ts @@ -34,3 +34,5 @@ export const resultTitleTypes = { ], key: 'ttype', } as const; + +export const findFilterable = ['q', 'exact', resultTitleTypes.key, resultTypes.key]; diff --git a/src/utils/constants/keys.ts b/src/utils/constants/keys.ts index 3e1bcf3..9cff9cd 100644 --- a/src/utils/constants/keys.ts +++ b/src/utils/constants/keys.ts @@ -1,4 +1,7 @@ export const titleKey = (titleId: string) => `title:${titleId}`; +export const titleReviewsKey = (titleId: string, query = '') => `title:${titleId}|${query}`; +export const titleReviewsCursoredKey = (titleId: string, paginationKey: string) => + `title:${titleId}|${paginationKey}`; 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/constants/titleReviewsFilters.ts b/src/utils/constants/titleReviewsFilters.ts new file mode 100644 index 0000000..07246dd --- /dev/null +++ b/src/utils/constants/titleReviewsFilters.ts @@ -0,0 +1,36 @@ +export const ratings = { + types: [ + { name: 'All', val: '0' }, + { name: '1', val: '1' }, + { name: '2', val: '2' }, + { name: '3', val: '3' }, + { name: '4', val: '4' }, + { name: '5', val: '5' }, + { name: '6', val: '6' }, + { name: '7', val: '7' }, + { name: '8', val: '8' }, + { name: '9', val: '9' }, + { name: '10', val: '10' }, + ], + key: 'ratingFilter', +} as const; + +export const sortBy = { + types: [ + { name: 'Featured', val: 'curated' }, + { name: 'Review Date', val: 'submissionDate' }, + { name: 'Total Votes', val: 'totalVotes' }, + { name: 'Prolific Reviewer', val: 'reviewVolume' }, + { name: 'Review Rating', val: 'userRating' }, + ], + key: 'sort', +} as const; + +export const direction = { + types: [ + { name: 'Ascending', val: 'asc' }, + { name: 'Descending', val: 'desc' }, + ], + key: 'dir', +} as const; +export const keys = ['spoiler', direction.key, sortBy.key, ratings.key]; \ No newline at end of file diff --git a/src/utils/fetchers/titleReviews.ts b/src/utils/fetchers/titleReviews.ts new file mode 100644 index 0000000..e28c4c3 --- /dev/null +++ b/src/utils/fetchers/titleReviews.ts @@ -0,0 +1,113 @@ +import { AxiosError } from 'axios'; +import * as cheerio from 'cheerio'; +import axiosInstance from 'src/utils/axiosInstance'; +import { AppError } from 'src/utils/helpers'; + +const reviews = async (titleId: string, queryStr = '') => { + try { + // https://www.imdb.com/title/tt0364343/reviews?spoiler=hide&sort=curated&dir=desc&ratingFilter=0 + const res = await axiosInstance(`/title/${titleId}/reviews?${queryStr}`); + const $ = cheerio.load(res.data); + + const $main = $('#main > .article'); + const $meta = $main.children('.subpage_title_block'); + + const meta = { + title: clean($meta.find('[itemprop="name"] > a')), + year: clean($meta.find('[itemprop="name"] > .nobr')), + image: $meta.find('img[itemprop="image"]').attr('src') ?? null, + numReviews: clean($main.find('.lister > .header > div')), + titleId, + }; + + const $listItems = $main.find('.lister-list').children(); + + const list = getReviewsList($listItems, $); + const cursor = $main.find('.lister > .load-more-data').attr('data-key') ?? null; + + return { meta, list, cursor }; + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) + throw new AppError('not found', 404, err.cause); + + if (err instanceof AppError) throw err; + + throw new AppError('something went wrong', 500, err instanceof Error ? err.cause : undefined); + } +}; + +export default reviews; + +const clean = >(item: T) => item.text().trim(); + +export const cursoredReviews = async ( + titleId: string, + paginationKey: string, + queryStr = '', + title: string | null = null +) => { + try { + // https://www.imdb.com/title/tt0364343/reviews/_ajax?paginationKey=g4w6ddbmqyzdo6ic4oxwjnjqrtt4yaz53iptz6pna7cpyv35pjt6udc2oiyfzmrkb4drv33tz5tbvxqxw25ns6mwx3qym&sort=desc + const res = await axiosInstance( + `/title/${titleId}/reviews/_ajax?paginationKey=${paginationKey}&${queryStr}` + ); + + const $ = cheerio.load(res.data); + const $main = $('body > div'); + const $listItems = $main.children('.lister-list').children(); + + const list = getReviewsList($listItems, $); + + const cursor = $main.children('.load-more-data').attr('data-key') ?? null; + + return { meta: { title, titleId }, list, cursor }; + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) + throw new AppError('not found', 404, err.cause); + + if (err instanceof AppError) throw err; + + throw new AppError('something went wrong', 500, err instanceof Error ? err.cause : undefined); + } +}; + +const getReviewsList = ($listItems: cheerio.Cheerio, $: cheerio.CheerioAPI) => { + return $listItems + .map((_i, el) => { + const reviewId = $(el).attr('data-review-id') ?? null; + + const $reviewContainer = $(el).find('.lister-item-content'); + + const summary = clean($reviewContainer.children('a.title')); + const rating = clean($reviewContainer.children('.ipl-ratings-bar')); + const $by = $reviewContainer.find('.display-name-date .display-name-link a'); + const by = { + name: clean($by), + link: $by.attr('href') ?? null, + }; + const date = clean($reviewContainer.find('.display-name-date .review-date')); + const isSpoiler = $reviewContainer.children('.spoiler-warning').length > 0; + const reviewHtml = $reviewContainer.find('.content > .text').html(); + const responses = clean($reviewContainer.find('.content > .actions').contents().first()); + // .contents() + // .filter(function () { + // return this.nodeType === 3; + // }) + // .map(function () { + // return $(this).text(); + // }) + // .get(); + + return { + summary, + reviewId, + rating, + by, + date, + isSpoiler, + reviewHtml, + responses, + }; + }) + .toArray(); +}; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index c31260e..c74bd8f 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -81,18 +81,13 @@ export const AppError = class extends Error { } }; -export const cleanQueryStr = ( - entries: [string, string][], - filterable = ['q', 's', 'exact', 'ttype'] -) => { - let queryStr = ''; +export const cleanQueryStr = (record: Record, filterable: string[]) => { + const urlSearchParams = new URLSearchParams(); - entries.forEach(([key, val], i) => { - if (!val || !filterable.includes(key)) return; - queryStr += `${i > 0 ? '&' : ''}${key}=${val.trim()}`; + filterable.forEach(key => { + if (record[key]) urlSearchParams.append(key, record[key].trim()); }); - - return queryStr; + return urlSearchParams.toString(); }; export const getResTitleTypeHeading = (