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:
zyachel 2024-03-31 15:37:44 +05:30
parent cf71cd39e1
commit dc42b3204c
29 changed files with 1053 additions and 31 deletions

View File

@ -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

View File

@ -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

View File

@ -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}>

View File

@ -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;

View 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;

View 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;

View 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;

View 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;

View 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';

View File

@ -562,7 +562,7 @@ export default interface Name {
};
}>;
totalCredits: {
total: number;
total?: number;
// restriction?: {
// unrestrictedTotal: number;
// explanations: Array<{

View 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>>;

View File

@ -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 } });

View 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 });
}
}

View 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 });
}
}

View File

@ -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);

View 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;

View 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;

View File

@ -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);
}
}

View 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)
}
}

View File

@ -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;
}
}

View 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);
}

View File

@ -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;
}

View File

@ -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,

View File

@ -34,3 +34,5 @@ export const resultTitleTypes = {
],
key: 'ttype',
} as const;
export const findFilterable = ['q', 'exact', resultTitleTypes.key, resultTypes.key];

View File

@ -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}`;

View 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];

View 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();
};

View File

@ -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 = (