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
This commit is contained in:
parent
cf71cd39e1
commit
dc42b3204c
@ -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
|
- [x] add a way to see trailer and other videos
|
||||||
- [ ] implement movie specific routes like:
|
- [ ] implement movie specific routes like:
|
||||||
|
|
||||||
- [ ] reviews(including critic reviews)
|
- [x] reviews(including critic reviews)
|
||||||
- [ ] video & image gallery
|
- [ ] video & image gallery
|
||||||
- [ ] sections under 'did you know'
|
- [ ] sections under 'did you know'
|
||||||
- [ ] release info
|
- [ ] release info
|
||||||
@ -109,7 +109,7 @@ Instances list in JSON format can be found in [instances.json](instances.json) f
|
|||||||
|
|
||||||
- [ ] implement other routes like:
|
- [ ] implement other routes like:
|
||||||
|
|
||||||
- [ ] lists
|
- [x] lists
|
||||||
- [ ] moviemeter
|
- [ ] moviemeter
|
||||||
- [x] person info(includes directors and actors)
|
- [x] person info(includes directors and actors)
|
||||||
- [ ] company info
|
- [ ] company info
|
||||||
|
@ -43,4 +43,10 @@
|
|||||||
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-external-link">
|
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-external-link">
|
||||||
<path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"></path>
|
<path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"></path>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-alert">
|
||||||
|
<path d="M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-chevron">
|
||||||
|
<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z"></path>
|
||||||
|
</symbol>
|
||||||
</svg>
|
</svg>
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.8 KiB |
@ -1,19 +1,28 @@
|
|||||||
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/future/image';
|
import Image from 'next/future/image';
|
||||||
import Card from './Card';
|
import Card from './Card';
|
||||||
import { modifyIMDbImg } from 'src/utils/helpers';
|
import { modifyIMDbImg } from 'src/utils/helpers';
|
||||||
import styles from 'src/styles/modules/components/card/card-result.module.scss';
|
import styles from 'src/styles/modules/components/card/card-result.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props<T extends ElementType> = {
|
||||||
link: string;
|
link: string;
|
||||||
name: string;
|
name: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
showImage?: true;
|
showImage?: true;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
} & ComponentPropsWithoutRef<'li'>;
|
as?: T;
|
||||||
|
} & ComponentPropsWithoutRef<T>;
|
||||||
|
|
||||||
const CardResult = ({ link, name, image, showImage, children, ...rest }: Props) => {
|
const CardResult = <T extends 'li' | 'section' | 'div' = 'li'>({
|
||||||
|
link,
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
showImage,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: Props<T>) => {
|
||||||
let ImageComponent = null;
|
let ImageComponent = null;
|
||||||
if (showImage)
|
if (showImage)
|
||||||
ImageComponent = image ? (
|
ImageComponent = image ? (
|
||||||
@ -25,7 +34,11 @@ const CardResult = ({ link, name, image, showImage, children, ...rest }: Props)
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card hoverable {...rest} className={`${styles.item} ${!showImage && styles.sansImage}`}>
|
<Card
|
||||||
|
hoverable
|
||||||
|
{...rest}
|
||||||
|
className={`${styles.item} ${!showImage && styles.sansImage} ${className}`}
|
||||||
|
>
|
||||||
<div className={styles.imgContainer}>{ImageComponent}</div>
|
<div className={styles.imgContainer}>{ImageComponent}</div>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<Link href={link}>
|
<Link href={link}>
|
||||||
|
@ -2,7 +2,7 @@ import { ChangeEventHandler, FormEventHandler, useRef, useState } from 'react';
|
|||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { cleanQueryStr } from 'src/utils/helpers';
|
import { cleanQueryStr } from 'src/utils/helpers';
|
||||||
import { QueryTypes } from 'src/interfaces/shared/search';
|
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';
|
import styles from 'src/styles/modules/components/form/find.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -29,13 +29,15 @@ const Form = ({ className }: Props) => {
|
|||||||
|
|
||||||
const formEl = formRef.current!;
|
const formEl = formRef.current!;
|
||||||
const formData = new FormData(formEl);
|
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 queryParams = Object.fromEntries(
|
||||||
const queryStr = cleanQueryStr(entries);
|
formData.entries() as IterableIterator<[string, string]>
|
||||||
|
);
|
||||||
|
const queryStr = cleanQueryStr(queryParams, findFilterable);
|
||||||
|
|
||||||
if (query) router.push(`/find?${queryStr}`);
|
router.push(`/find?${queryStr}`);
|
||||||
else setIsDisabled(false);
|
|
||||||
formEl.reset();
|
formEl.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -117,5 +119,4 @@ const RadioBtns = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
export default Form;
|
export default Form;
|
||||||
|
46
src/components/titleReviews/Card.tsx
Normal file
46
src/components/titleReviews/Card.tsx
Normal file
@ -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 (
|
||||||
|
<CardResult
|
||||||
|
as='div'
|
||||||
|
showImage
|
||||||
|
name={`${meta.title} ${meta.year}`}
|
||||||
|
link={`/title/${meta.titleId}`}
|
||||||
|
image={meta.image ?? undefined}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<h1 className='heading heading__primary'>User Reviews</h1>
|
||||||
|
<p>{meta.numReviews}</p>
|
||||||
|
</CardResult>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BasicCardProps = {
|
||||||
|
meta: TitleReviewsCursored['meta'];
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
export const BasicCard = ({ meta, className }: BasicCardProps) => {
|
||||||
|
const { titleId } = useRouter().query;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardResult
|
||||||
|
as='div'
|
||||||
|
showImage
|
||||||
|
name={meta.title ?? ''}
|
||||||
|
link={`/title/${titleId}`}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<h1 className='heading heading__primary'>User Reviews</h1>
|
||||||
|
</CardResult>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Card;
|
86
src/components/titleReviews/Filters.tsx
Normal file
86
src/components/titleReviews/Filters.tsx
Normal file
@ -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<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
const submitHandler: FormEventHandler<HTMLFormElement> = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formEl = formRef.current!;
|
||||||
|
const formData = new FormData(formEl);
|
||||||
|
|
||||||
|
const entries = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||||
|
const queryStr = cleanQueryStr(entries, keys);
|
||||||
|
|
||||||
|
router.push(`/title/${titleId}/reviews?${queryStr}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={`/title/${titleId}/reviews`}
|
||||||
|
onSubmit={submitHandler}
|
||||||
|
ref={formRef}
|
||||||
|
className={`${className} ${styles.form}`}
|
||||||
|
>
|
||||||
|
<fieldset className={styles.fieldset}>
|
||||||
|
<legend className={`heading ${styles.fieldset__heading}`}>Filter by Rating</legend>
|
||||||
|
<RadioBtns data={ratings} className={styles.radio} />
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className={styles.fieldset}>
|
||||||
|
<legend className={`heading ${styles.fieldset__heading}`}>Sort by</legend>
|
||||||
|
<RadioBtns data={sortBy} className={styles.radio} />
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className={styles.fieldset}>
|
||||||
|
<legend className={`heading ${styles.fieldset__heading}`}>Direction</legend>
|
||||||
|
<RadioBtns data={direction} className={styles.radio} />
|
||||||
|
</fieldset>
|
||||||
|
<p className={styles.exact}>
|
||||||
|
<label htmlFor='spoiler'>Hide Spoilers</label>
|
||||||
|
<input type='checkbox' name='spoiler' id='spoiler' value='hide' />
|
||||||
|
</p>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<button type='reset' className={styles.button}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button type='submit' className={styles.button}>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RadioBtns = ({
|
||||||
|
data,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
data: typeof ratings | typeof sortBy | typeof direction;
|
||||||
|
className: string;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{data.types.map(({ name, val }) => (
|
||||||
|
<p className={className} key={val}>
|
||||||
|
<input
|
||||||
|
type='radio'
|
||||||
|
name={data.key}
|
||||||
|
id={`${data.key}:${val}`}
|
||||||
|
value={val}
|
||||||
|
className='visually-hidden'
|
||||||
|
/>
|
||||||
|
<label htmlFor={`${data.key}:${val}`}>{name}</label>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Filters;
|
34
src/components/titleReviews/Pagination.tsx
Normal file
34
src/components/titleReviews/Pagination.tsx
Normal file
@ -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<string, string>;
|
||||||
|
const queryStr = cleanQueryStr(queryParams, keys);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className={styles.button} onClick={() => onClick(queryStr)}>
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
<Link href={`/title/${meta.titleId}/reviews/${cursor}?${queryStr}&title=${meta.title ?? ''}`}>
|
||||||
|
<a className={styles.link}>Load More</a>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Pagination;
|
82
src/components/titleReviews/Reviews.tsx
Normal file
82
src/components/titleReviews/Reviews.tsx
Normal file
@ -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 (
|
||||||
|
<section className={className}>
|
||||||
|
<section className={styles.reviews}>
|
||||||
|
{list.map(review => (
|
||||||
|
<Review {...review} key={review.reviewId} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Review = ({
|
||||||
|
by,
|
||||||
|
date,
|
||||||
|
isSpoiler,
|
||||||
|
rating,
|
||||||
|
responses,
|
||||||
|
reviewHtml,
|
||||||
|
reviewId,
|
||||||
|
summary,
|
||||||
|
}: TitleReviews['list'][number]) => {
|
||||||
|
return (
|
||||||
|
<article className={styles.reviews__reviewContainer}>
|
||||||
|
<details className={styles.review}>
|
||||||
|
<summary className={styles.review__summary}>
|
||||||
|
<Link href={by.link ?? '#'}>
|
||||||
|
<a className='link'>{by.name}</a>
|
||||||
|
</Link>
|
||||||
|
<time>{date}</time>
|
||||||
|
<p className={styles.review__misc}>
|
||||||
|
{isSpoiler && (
|
||||||
|
<span className={styles.review__misc_spoilers}>
|
||||||
|
<svg className={styles.icon}>
|
||||||
|
<use href='/svg/sprite.svg#icon-alert'></use>
|
||||||
|
</svg>
|
||||||
|
Spoilers
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rating && (
|
||||||
|
<span>
|
||||||
|
<svg className={styles.icon}>
|
||||||
|
<use href='/svg/sprite.svg#icon-rating'></use>
|
||||||
|
</svg>
|
||||||
|
{rating}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<svg className={`${styles.icon} ${styles.review__summary_chevron}`}>
|
||||||
|
<use href='/svg/sprite.svg#icon-chevron'></use>
|
||||||
|
</svg>
|
||||||
|
</p>
|
||||||
|
<strong>{summary}</strong>
|
||||||
|
</summary>
|
||||||
|
{reviewHtml && (
|
||||||
|
<div
|
||||||
|
className={styles.review__text}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: reviewHtml,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
<footer className={styles.review__metadata}>
|
||||||
|
<p>{responses}</p>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Results;
|
5
src/components/titleReviews/index.ts
Normal file
5
src/components/titleReviews/index.ts
Normal file
@ -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';
|
@ -562,7 +562,7 @@ export default interface Name {
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
totalCredits: {
|
totalCredits: {
|
||||||
total: number;
|
total?: number;
|
||||||
// restriction?: {
|
// restriction?: {
|
||||||
// unrestrictedTotal: number;
|
// unrestrictedTotal: number;
|
||||||
// explanations: Array<{
|
// explanations: Array<{
|
||||||
|
6
src/interfaces/shared/titleReviews.ts
Normal file
6
src/interfaces/shared/titleReviews.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import reviews, { cursoredReviews } from 'src/utils/fetchers/titleReviews';
|
||||||
|
|
||||||
|
type TitleReviews = Awaited<ReturnType<typeof reviews>>;
|
||||||
|
export type { TitleReviews as default };
|
||||||
|
|
||||||
|
export type TitleReviewsCursored = Awaited<ReturnType<typeof cursoredReviews>>;
|
@ -4,6 +4,7 @@ import basicSearch from 'src/utils/fetchers/basicSearch';
|
|||||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||||
import { findKey } from 'src/utils/constants/keys';
|
import { findKey } from 'src/utils/constants/keys';
|
||||||
import { AppError, cleanQueryStr } from 'src/utils/helpers';
|
import { AppError, cleanQueryStr } from 'src/utils/helpers';
|
||||||
|
import { findFilterable } from 'src/utils/constants/find';
|
||||||
|
|
||||||
type ResponseData =
|
type ResponseData =
|
||||||
| { status: true; data: { title: null | string; results: null | Find } }
|
| { status: true; data: { title: null | string; results: null | Find } }
|
||||||
@ -13,15 +14,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
|
|||||||
try {
|
try {
|
||||||
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
|
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
|
||||||
|
|
||||||
const queryObj = req.query as FindQueryParams | Record<string, never>;
|
const queryObj = req.query as FindQueryParams;
|
||||||
const query = queryObj.q?.trim();
|
const query = queryObj.q?.trim();
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return res.status(200).json({ status: true, data: { title: null, results: null } });
|
return res.status(200).json({ status: true, data: { title: null, results: null } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = Object.entries(queryObj);
|
const queryStr = cleanQueryStr(queryObj, findFilterable);
|
||||||
const queryStr = cleanQueryStr(entries);
|
|
||||||
const results = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr);
|
const results = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr);
|
||||||
|
|
||||||
res.status(200).json({ status: true, data: { title: query, results } });
|
res.status(200).json({ status: true, data: { title: query, results } });
|
||||||
|
33
src/pages/api/title/[titleId]/reviews/[paginationKey].ts
Normal file
33
src/pages/api/title/[titleId]/reviews/[paginationKey].ts
Normal file
@ -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<ResponseData>) {
|
||||||
|
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<string, string>;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
21
src/pages/api/title/[titleId]/reviews/index.ts
Normal file
21
src/pages/api/title/[titleId]/reviews/index.ts
Normal file
@ -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<ResponseData>) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,7 @@ import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
|||||||
import { cleanQueryStr } from 'src/utils/helpers';
|
import { cleanQueryStr } from 'src/utils/helpers';
|
||||||
import { findKey } from 'src/utils/constants/keys';
|
import { findKey } from 'src/utils/constants/keys';
|
||||||
import styles from 'src/styles/modules/pages/find/find.module.scss';
|
import styles from 'src/styles/modules/pages/find/find.module.scss';
|
||||||
|
import { findFilterable } from 'src/utils/constants/find';
|
||||||
|
|
||||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||||
|
|
||||||
@ -58,8 +59,7 @@ export const getServerSideProps: GetServerSideProps<Data, FindQueryParams> = asy
|
|||||||
if (!query) return { props: { data: { title: null, results: null }, error: null, originalPath } };
|
if (!query) return { props: { data: { title: null, results: null }, error: null, originalPath } };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = Object.entries(queryObj);
|
const queryStr = cleanQueryStr(queryObj, findFilterable);
|
||||||
const queryStr = cleanQueryStr(entries);
|
|
||||||
|
|
||||||
const res = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr);
|
const res = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr);
|
||||||
|
|
||||||
|
72
src/pages/title/[titleId]/reviews/[paginationKey]/index.tsx
Normal file
72
src/pages/title/[titleId]/reviews/[paginationKey]/index.tsx
Normal file
@ -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<typeof getServerSideProps>;
|
||||||
|
|
||||||
|
const CursoredReviewsPage = ({ data, error, originalPath }: Props) => {
|
||||||
|
if (error) return <ErrorInfo {...error} originalPath={originalPath} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Meta
|
||||||
|
title={data.meta.title ?? 'User Reviews'}
|
||||||
|
description={data.meta.title ?? 'User Reviews'}
|
||||||
|
/>
|
||||||
|
<Layout className={styles.container} originalPath={originalPath}>
|
||||||
|
<BasicCard meta={data.meta} className={styles.card} />
|
||||||
|
<Reviews list={data.list} className={styles.results}>
|
||||||
|
<Pagination meta={data.meta} cursor={data.cursor} />
|
||||||
|
</Reviews>
|
||||||
|
<Filters titleId={data.meta.titleId} className={styles.form} />
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<Data, Params> = 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<string, string>;
|
||||||
|
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;
|
92
src/pages/title/[titleId]/reviews/index.tsx
Normal file
92
src/pages/title/[titleId]/reviews/index.tsx
Normal file
@ -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<typeof getServerSideProps>;
|
||||||
|
|
||||||
|
// 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 <ErrorInfo {...error} originalPath={originalPath} />;
|
||||||
|
|
||||||
|
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 && <ProgressBar />}
|
||||||
|
<Meta
|
||||||
|
title={data.meta.title}
|
||||||
|
description={data.meta.title}
|
||||||
|
imgUrl={data.meta?.image ? getProxiedIMDbImgUrl(data.meta.image) : undefined}
|
||||||
|
/>
|
||||||
|
<Layout className={styles.container} originalPath={originalPath}>
|
||||||
|
<TitleCard meta={data.meta} className={styles.card} />
|
||||||
|
<Reviews list={allData.list} className={styles.results}>
|
||||||
|
<Pagination meta={data.meta} cursor={allData.cursor} onClick={handleOnClickLoadMore} />
|
||||||
|
</Reviews>
|
||||||
|
<Filters titleId={data.meta.titleId} className={styles.form} />
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<Data, Params> = async ctx => {
|
||||||
|
const titleId = ctx.params!.titleId;
|
||||||
|
const originalPath = ctx.resolvedUrl;
|
||||||
|
const queryParams = ctx.query as Record<string, string>;
|
||||||
|
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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
111
src/styles/modules/components/titleReviews/form.module.scss
Normal file
111
src/styles/modules/components/titleReviews/form.module.scss
Normal file
@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
120
src/styles/modules/components/titleReviews/reviews.module.scss
Normal file
120
src/styles/modules/components/titleReviews/reviews.module.scss
Normal file
@ -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);
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -126,7 +126,7 @@ const cleanName = (rawData: RawName) => {
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
credits: {
|
credits: {
|
||||||
total: misc.totalCredits.total,
|
total: misc.totalCredits?.total ?? null,
|
||||||
summary: {
|
summary: {
|
||||||
titleType: misc.creditSummary.titleTypeCategories.map(cat => ({
|
titleType: misc.creditSummary.titleTypeCategories.map(cat => ({
|
||||||
total: cat.total,
|
total: cat.total,
|
||||||
|
@ -34,3 +34,5 @@ export const resultTitleTypes = {
|
|||||||
],
|
],
|
||||||
key: 'ttype',
|
key: 'ttype',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const findFilterable = ['q', 'exact', resultTitleTypes.key, resultTypes.key];
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
export const titleKey = (titleId: string) => `title:${titleId}`;
|
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 nameKey = (nameId: string) => `name:${nameId}`;
|
||||||
export const listKey = (listId: string, pageNum = '1') => `list:${listId}?page=${pageNum}`;
|
export const listKey = (listId: string, pageNum = '1') => `list:${listId}?page=${pageNum}`;
|
||||||
export const findKey = (query: string) => `find:${query}`;
|
export const findKey = (query: string) => `find:${query}`;
|
||||||
|
36
src/utils/constants/titleReviewsFilters.ts
Normal file
36
src/utils/constants/titleReviewsFilters.ts
Normal file
@ -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];
|
113
src/utils/fetchers/titleReviews.ts
Normal file
113
src/utils/fetchers/titleReviews.ts
Normal file
@ -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 = <T extends cheerio.Cheerio<any>>(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.Element>, $: 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();
|
||||||
|
};
|
@ -81,18 +81,13 @@ export const AppError = class extends Error {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cleanQueryStr = (
|
export const cleanQueryStr = (record: Record<string, string>, filterable: string[]) => {
|
||||||
entries: [string, string][],
|
const urlSearchParams = new URLSearchParams();
|
||||||
filterable = ['q', 's', 'exact', 'ttype']
|
|
||||||
) => {
|
|
||||||
let queryStr = '';
|
|
||||||
|
|
||||||
entries.forEach(([key, val], i) => {
|
filterable.forEach(key => {
|
||||||
if (!val || !filterable.includes(key)) return;
|
if (record[key]) urlSearchParams.append(key, record[key].trim());
|
||||||
queryStr += `${i > 0 ? '&' : ''}${key}=${val.trim()}`;
|
|
||||||
});
|
});
|
||||||
|
return urlSearchParams.toString();
|
||||||
return queryStr;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getResTitleTypeHeading = (
|
export const getResTitleTypeHeading = (
|
||||||
|
Loading…
x
Reference in New Issue
Block a user