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

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