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:
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';
|
Reference in New Issue
Block a user