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
|
||||
- [ ] 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
|
||||
|
@ -43,4 +43,10 @@
|
||||
<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>
|
||||
</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>
|
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 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<T extends ElementType> = {
|
||||
link: string;
|
||||
name: string;
|
||||
image?: string;
|
||||
showImage?: true;
|
||||
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;
|
||||
if (showImage)
|
||||
ImageComponent = image ? (
|
||||
@ -25,7 +34,11 @@ const CardResult = ({ link, name, image, showImage, children, ...rest }: Props)
|
||||
);
|
||||
|
||||
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.info}>
|
||||
<Link href={link}>
|
||||
|
@ -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;
|
||||
|
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: {
|
||||
total: number;
|
||||
total?: number;
|
||||
// restriction?: {
|
||||
// unrestrictedTotal: number;
|
||||
// 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 { 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<string, never>;
|
||||
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 } });
|
||||
|
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 { 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<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 } };
|
||||
|
||||
try {
|
||||
const entries = Object.entries(queryObj);
|
||||
const queryStr = cleanQueryStr(entries);
|
||||
const queryStr = cleanQueryStr(queryObj, findFilterable);
|
||||
|
||||
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: {
|
||||
total: misc.totalCredits.total,
|
||||
total: misc.totalCredits?.total ?? null,
|
||||
summary: {
|
||||
titleType: misc.creditSummary.titleTypeCategories.map(cat => ({
|
||||
total: cat.total,
|
||||
|
@ -34,3 +34,5 @@ export const resultTitleTypes = {
|
||||
],
|
||||
key: 'ttype',
|
||||
} as const;
|
||||
|
||||
export const findFilterable = ['q', 'exact', resultTitleTypes.key, resultTypes.key];
|
||||
|
@ -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}`;
|
||||
|
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 = (
|
||||
entries: [string, string][],
|
||||
filterable = ['q', 's', 'exact', 'ttype']
|
||||
) => {
|
||||
let queryStr = '';
|
||||
export const cleanQueryStr = (record: Record<string, string>, 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 = (
|
||||
|
Loading…
x
Reference in New Issue
Block a user