feat(list): add list route
adds ability to see titles, names, and images lists closes https://github.com/zyachel/libremdb/issues/6
This commit is contained in:
parent
60fb23fc5b
commit
97f1432ac5
23
src/components/list/Data.tsx
Normal file
23
src/components/list/Data.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import type { DataKind, Data as TData } from 'src/interfaces/shared/list';
|
||||||
|
import type { ToArray } from 'src/interfaces/shared';
|
||||||
|
import Images from './Images';
|
||||||
|
import Names from './Names';
|
||||||
|
import Titles from './Titles';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: ToArray<TData<DataKind>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Data = ({ data }: Props) => {
|
||||||
|
if (isDataImages(data)) return <Images images={data} />;
|
||||||
|
if (isDataNames(data)) return <Names names={data} />;
|
||||||
|
|
||||||
|
return <Titles titles={data} />;
|
||||||
|
};
|
||||||
|
export default Data;
|
||||||
|
|
||||||
|
const isDataImages = (data: unknown): data is TData<'images'>[] =>
|
||||||
|
Array.isArray(data) && typeof data[0] === 'string';
|
||||||
|
|
||||||
|
const isDataNames = (data: unknown): data is TData<'names'>[] =>
|
||||||
|
Array.isArray(data) && data[0] && typeof data[0] === 'object' && 'about' in data[0];
|
22
src/components/list/Images.tsx
Normal file
22
src/components/list/Images.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import Image from 'next/future/image';
|
||||||
|
import { modifyIMDbImg } from 'src/utils/helpers';
|
||||||
|
import type { Data } from 'src/interfaces/shared/list';
|
||||||
|
import styles from 'src/styles/modules/components/list/images.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
images: Data<'images'>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const Images = ({ images }: Props) => {
|
||||||
|
return (
|
||||||
|
<section className={styles.container}>
|
||||||
|
{images.map(image => (
|
||||||
|
<figure className={styles.imgContainer} key={image}>
|
||||||
|
<Image src={modifyIMDbImg(image, 400)} alt='' fill className={styles.img} sizes='200px'/>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Images;
|
35
src/components/list/Meta.tsx
Normal file
35
src/components/list/Meta.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { formatDate } from 'src/utils/helpers';
|
||||||
|
import List from 'src/interfaces/shared/list';
|
||||||
|
import styles from 'src/styles/modules/components/list/meta.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
meta: List['meta'];
|
||||||
|
description: List['description'];
|
||||||
|
};
|
||||||
|
const Meta = ({ title, meta, description }: Props) => {
|
||||||
|
const by = meta.by.link ? (
|
||||||
|
<Link href={meta.by.link}>
|
||||||
|
<a className='link'>{meta.by.name}</a>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
meta.by.name
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.container}>
|
||||||
|
<h1 className='heading heading__secondary'>{title}</h1>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>by {by}</li>
|
||||||
|
<li>{meta.created}</li>
|
||||||
|
{meta.updated && <li>{meta.updated}</li>}
|
||||||
|
<li>
|
||||||
|
{meta.num} {meta.type}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{description && <p>{description}</p>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Meta;
|
57
src/components/list/Names.tsx
Normal file
57
src/components/list/Names.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import Image from 'next/future/image';
|
||||||
|
import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
|
||||||
|
import { Card } from 'src/components/card';
|
||||||
|
import type { Data } from 'src/interfaces/shared/list';
|
||||||
|
import styles from 'src/styles/modules/components/list/names.module.scss';
|
||||||
|
import OptionalLink from './OptionalLink';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
names: Data<'names'>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const Names = ({ names }: Props) => {
|
||||||
|
return (
|
||||||
|
<ul className={styles.names}>
|
||||||
|
{names.map(name => (
|
||||||
|
<Name {...name} key={name.name} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Names;
|
||||||
|
|
||||||
|
const Name = ({ about, image, job, knownFor, knownForLink, name, url }: Props['names'][number]) => {
|
||||||
|
// const style: CSSProperties = {
|
||||||
|
// backgroundImage: image ? `url(${getProxiedIMDbImgUrl(modifyIMDbImg(image, 300))})` : undefined,
|
||||||
|
// };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card hoverable className={styles.name}>
|
||||||
|
<div className={styles.imgContainer}>
|
||||||
|
{image ? (
|
||||||
|
<Image src={modifyIMDbImg(image, 400)} alt='' fill className={styles.img} sizes='200px' />
|
||||||
|
) : (
|
||||||
|
<svg className={styles.imgNA}>
|
||||||
|
<use href='/svg/sprite.svg#icon-image-slash' />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<h2 className={`heading ${styles.heading}`}>
|
||||||
|
<OptionalLink href={url} className={`heading ${styles.heading}`}>
|
||||||
|
{name}
|
||||||
|
</OptionalLink>
|
||||||
|
</h2>
|
||||||
|
<ul className={styles.basicInfo} aria-label='quick facts'>
|
||||||
|
{job && <li>{job}</li>}
|
||||||
|
{knownFor && (
|
||||||
|
<li>
|
||||||
|
<OptionalLink href={knownForLink}>{knownFor}</OptionalLink>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<p>{about}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
20
src/components/list/OptionalLink.tsx
Normal file
20
src/components/list/OptionalLink.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactNode, ComponentPropsWithoutRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const OptionalLink = ({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: { href?: string | null; children: ReactNode } & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
|
||||||
|
<>
|
||||||
|
{href ? (
|
||||||
|
<Link href={href}>
|
||||||
|
<a {...rest}>{children}</a>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default OptionalLink;
|
33
src/components/list/Pagination.tsx
Normal file
33
src/components/list/Pagination.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import OptionalLink from './OptionalLink';
|
||||||
|
import type List from 'src/interfaces/shared/list';
|
||||||
|
import styles from 'src/styles/modules/components/list/pagination.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pagination: List['pagination'];
|
||||||
|
};
|
||||||
|
const Pagination = ({ pagination }: Props) => {
|
||||||
|
const prevLink = pagination.prev && pagination.prev !== '#' ? pagination.prev : null;
|
||||||
|
const nextLink = pagination.next && pagination.next !== '#' ? pagination.next : null;
|
||||||
|
|
||||||
|
if (!prevLink && !nextLink) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label='pagination'>
|
||||||
|
<ul className={styles.nav}>
|
||||||
|
<li aria-hidden={!prevLink}>
|
||||||
|
<OptionalLink href={prevLink} className='link'>
|
||||||
|
Prev
|
||||||
|
</OptionalLink>
|
||||||
|
</li>
|
||||||
|
<li>{pagination.range} shown</li>
|
||||||
|
<li aria-hidden={!nextLink}>
|
||||||
|
<OptionalLink href={nextLink} className='link'>
|
||||||
|
Next
|
||||||
|
</OptionalLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Pagination;
|
79
src/components/list/Titles.tsx
Normal file
79
src/components/list/Titles.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import Image from 'next/future/image';
|
||||||
|
import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
|
||||||
|
import { Card } from 'src/components/card';
|
||||||
|
import type { Data } from 'src/interfaces/shared/list';
|
||||||
|
import styles from 'src/styles/modules/components/list/titles.module.scss';
|
||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import OptionalLink from './OptionalLink';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
titles: Data<'titles'>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const Titles = ({ titles }: Props) => {
|
||||||
|
return (
|
||||||
|
<ul className={styles.titles}>
|
||||||
|
{titles.map(title => (
|
||||||
|
<Title {...title} key={title.name} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Titles;
|
||||||
|
|
||||||
|
const Title = (props: Props['titles'][number]) => {
|
||||||
|
const style: CSSProperties = {
|
||||||
|
backgroundImage: props.image
|
||||||
|
? `url(${getProxiedIMDbImgUrl(modifyIMDbImg(props.image, 300))})`
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card hoverable className={styles.title}>
|
||||||
|
<div className={styles.imgContainer}>
|
||||||
|
{props.image ? (
|
||||||
|
<Image src={modifyIMDbImg(props.image, 400)} alt='' fill className={styles.img} />
|
||||||
|
) : (
|
||||||
|
<svg className={styles.imgNA}>
|
||||||
|
<use href='/svg/sprite.svg#icon-image-slash' />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<h2 className={`heading heading__tertiary ${styles.heading}`}>
|
||||||
|
<OptionalLink href={props.url} className={`heading ${styles.heading}`}>
|
||||||
|
{props.name} {props.year}
|
||||||
|
</OptionalLink>
|
||||||
|
</h2>
|
||||||
|
<ul className={styles.basicInfo} aria-label='quick facts'>
|
||||||
|
{props.certificate && <li>{props.certificate}</li>}
|
||||||
|
{props.runtime && <li>{props.runtime}</li>}
|
||||||
|
{props.genre && <li>{props.genre}</li>}
|
||||||
|
</ul>
|
||||||
|
<ul className={styles.ratings}>
|
||||||
|
{Boolean(props.rating) && <li className={styles.rating}>
|
||||||
|
<span className={styles.rating__num}>{props.rating}</span>
|
||||||
|
<svg className={styles.rating__icon}>
|
||||||
|
<use href='/svg/sprite.svg#icon-rating'></use>
|
||||||
|
</svg>
|
||||||
|
<span className={styles.rating__text}> Avg. rating</span>
|
||||||
|
</li>}
|
||||||
|
{Boolean(props.metascore) && <li className={styles.rating}>
|
||||||
|
<span className={styles.rating__num}>{props.metascore}</span>
|
||||||
|
<span className={styles.rating__text}>Metascore</span>
|
||||||
|
</li>}
|
||||||
|
</ul>
|
||||||
|
<p className={styles.plot}>
|
||||||
|
<span>Plot:</span> {props.plot}
|
||||||
|
</p>
|
||||||
|
<ul className={styles.otherInfo}>
|
||||||
|
{props.otherInfo.map(([infoHeading, info]) => (
|
||||||
|
<li key={infoHeading}>
|
||||||
|
<span>{infoHeading}:</span> {info}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
3
src/components/list/index.ts
Normal file
3
src/components/list/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { default as Data } from './Data';
|
||||||
|
export { default as Meta } from './Meta';
|
||||||
|
export { default as Pagination } from './Pagination';
|
@ -1,3 +1,6 @@
|
|||||||
import type Name from './name';
|
import type Name from './name';
|
||||||
|
|
||||||
export type Media = Name['media']; // exactly the same in title and name
|
export type Media = Name['media']; // exactly the same in title and name
|
||||||
|
|
||||||
|
// forcefully makes array of individual elements of T, where t is any conditional type.
|
||||||
|
export type ToArray<T> = T extends any ? T[] : never;
|
||||||
|
39
src/interfaces/shared/list.ts
Normal file
39
src/interfaces/shared/list.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import list from 'src/utils/fetchers/list';
|
||||||
|
|
||||||
|
// for full title
|
||||||
|
type List = Awaited<ReturnType<typeof list>>;
|
||||||
|
export type { List as default };
|
||||||
|
|
||||||
|
type DataTitle = {
|
||||||
|
image: string | null;
|
||||||
|
name: string;
|
||||||
|
url: string | null;
|
||||||
|
year: string;
|
||||||
|
certificate: string;
|
||||||
|
runtime: string;
|
||||||
|
genre: string;
|
||||||
|
plot: string;
|
||||||
|
rating: string;
|
||||||
|
metascore: string;
|
||||||
|
otherInfo: string[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DataName = {
|
||||||
|
image: string | null;
|
||||||
|
name: string;
|
||||||
|
url: string | null;
|
||||||
|
job: string | null;
|
||||||
|
knownFor: string | null;
|
||||||
|
knownForLink: string | null;
|
||||||
|
about: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DataImage = string;
|
||||||
|
|
||||||
|
export type DataKind = 'images' | 'titles' | 'names';
|
||||||
|
|
||||||
|
export type Data<T extends DataKind> = T extends 'images'
|
||||||
|
? DataImage
|
||||||
|
: T extends 'names'
|
||||||
|
? DataName
|
||||||
|
: DataTitle;
|
54
src/pages/list/[listId]/index.tsx
Normal file
54
src/pages/list/[listId]/index.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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 { Data, Meta as ListMeta, Pagination } from 'src/components/list';
|
||||||
|
import { AppError } from 'src/interfaces/shared/error';
|
||||||
|
import TList from 'src/interfaces/shared/list';
|
||||||
|
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||||
|
import list from 'src/utils/fetchers/list';
|
||||||
|
import { listKey } from 'src/utils/constants/keys';
|
||||||
|
import styles from 'src/styles/modules/pages/list/list.module.scss';
|
||||||
|
|
||||||
|
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||||
|
|
||||||
|
const List = ({ data, error, originalPath }: Props) => {
|
||||||
|
if (error) return <ErrorInfo {...error} originalPath={originalPath} />;
|
||||||
|
|
||||||
|
const description = data.description || `List created by ${data.meta.by.name} (${data.meta.num} ${data.meta.type}).`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Meta title={data.title} description={description} />
|
||||||
|
<Layout className={styles.list} originalPath={originalPath}>
|
||||||
|
<ListMeta title={data.title} description={data.description} meta={data.meta} />
|
||||||
|
{/* @ts-expect-error don't have time to fix it. just a type fluff. */}
|
||||||
|
<Data data={data.data} />
|
||||||
|
<Pagination pagination={data.pagination} />
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type TData = ({ data: TList; error: null } | { error: AppError; data: null }) & {
|
||||||
|
originalPath: string;
|
||||||
|
};
|
||||||
|
type Params = { listId: string };
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps<TData, Params> = async ctx => {
|
||||||
|
const listId = ctx.params!.listId;
|
||||||
|
const pageNum = (ctx.query.page as string | undefined) ?? '1';
|
||||||
|
const originalPath = ctx.resolvedUrl;
|
||||||
|
try {
|
||||||
|
const data = await getOrSetApiCache(listKey(listId, pageNum), list, listId, pageNum);
|
||||||
|
|
||||||
|
return { props: { data, error: null, originalPath } };
|
||||||
|
} catch (error: any) {
|
||||||
|
const { message = 'Internal server error', statusCode = 500 } = error;
|
||||||
|
ctx.res.statusCode = statusCode;
|
||||||
|
ctx.res.statusMessage = message;
|
||||||
|
return { props: { error: { message, statusCode }, data: null, originalPath } };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default List;
|
31
src/styles/modules/components/list/images.module.scss
Normal file
31
src/styles/modules/components/list/images.module.scss
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
@use '../../../abstracts' as helper;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
--min-width: 22rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(var(--min-width), 1fr));
|
||||||
|
|
||||||
|
gap: var(--spacer-1);
|
||||||
|
|
||||||
|
@include helper.bp('bp-900') {
|
||||||
|
--min-width: 18rem;
|
||||||
|
}
|
||||||
|
@include helper.bp('bp-700') {
|
||||||
|
--min-width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include helper.bp('bp-450') {
|
||||||
|
--min-width: 12rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.imgContainer {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img {
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
18
src/styles/modules/components/list/meta.module.scss
Normal file
18
src/styles/modules/components/list/meta.module.scss
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacer-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
86
src/styles/modules/components/list/names.module.scss
Normal file
86
src/styles/modules/components/list/names.module.scss
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
@use '../../../abstracts' as helper;
|
||||||
|
|
||||||
|
|
||||||
|
.names {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacer-6);
|
||||||
|
--min-width: 55rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(var(--min-width), 1fr));
|
||||||
|
|
||||||
|
@include helper.bp('bp-700') {
|
||||||
|
grid-template-columns: auto;
|
||||||
|
gap: var(--spacer-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include helper.bp('bp-450') {
|
||||||
|
gap: var(--spacer-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
--image-dimension: 18rem;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--image-dimension) auto;
|
||||||
|
|
||||||
|
@include helper.bp('bp-700') {
|
||||||
|
--dimension: 15rem;
|
||||||
|
grid-template-columns: auto;
|
||||||
|
grid-template-rows: var(--image-dimension) auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.imgContainer {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img {
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center 25%; // most of the time, person's face is visible at 1/4 of height in a potrait image.
|
||||||
|
}
|
||||||
|
|
||||||
|
.imgNA {
|
||||||
|
width: 80%;
|
||||||
|
fill: var(--clr-fill-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: grid;
|
||||||
|
padding: var(--spacer-3);
|
||||||
|
gap: var(--spacer-0);
|
||||||
|
|
||||||
|
@include helper.bp('bp-450') {
|
||||||
|
padding: var(--spacer-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
& :empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-size: var(--fs-4);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.basicInfo {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
& * + ::before {
|
||||||
|
content: '\00b7';
|
||||||
|
padding-inline: var(--spacer-1);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 0;
|
||||||
|
font-size: var(--fs-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
18
src/styles/modules/components/list/pagination.module.scss
Normal file
18
src/styles/modules/components/list/pagination.module.scss
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: var(--spacer-3);
|
||||||
|
|
||||||
|
[aria-hidden="true"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
133
src/styles/modules/components/list/titles.module.scss
Normal file
133
src/styles/modules/components/list/titles.module.scss
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
@use '../../../abstracts' as helper;
|
||||||
|
|
||||||
|
.titles {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacer-6);
|
||||||
|
--min-width: 55rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(var(--min-width), 1fr));
|
||||||
|
|
||||||
|
@include helper.bp('bp-700') {
|
||||||
|
grid-template-columns: auto;
|
||||||
|
gap: var(--spacer-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include helper.bp('bp-450') {
|
||||||
|
gap: var(--spacer-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
--image-dimension: 18rem;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--image-dimension) auto;
|
||||||
|
|
||||||
|
@include helper.bp('bp-700') {
|
||||||
|
grid-template-columns: auto;
|
||||||
|
grid-template-rows: var(--image-dimension) auto;
|
||||||
|
}
|
||||||
|
@include helper.bp('bp-450') {
|
||||||
|
--image-dimension: 15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.imgContainer {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img {
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imgNA {
|
||||||
|
width: 80%;
|
||||||
|
fill: var(--clr-fill-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: var(--spacer-3);
|
||||||
|
gap: var(--spacer-0);
|
||||||
|
|
||||||
|
@include helper.bp('bp-450') {
|
||||||
|
padding: var(--spacer-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& :empty:not(svg, use, img) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratings {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacer-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
justify-items: center;
|
||||||
|
align-items: center;
|
||||||
|
// place-content: center;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
--dim: 1.3em;
|
||||||
|
fill: var(--clr-fill);
|
||||||
|
height: var(--dim);
|
||||||
|
width: var(--dim);
|
||||||
|
max-width: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__num {
|
||||||
|
font-size: var(--fs-4);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--clr-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.basicInfo {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
& * + ::before {
|
||||||
|
content: '\00b7';
|
||||||
|
padding-inline: var(--spacer-1);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 0;
|
||||||
|
font-size: var(--fs-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot {
|
||||||
|
padding-block: var(--spacer-0);
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.otherInfo {
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
}
|
||||||
|
}
|
19
src/styles/modules/pages/list/list.module.scss
Normal file
19
src/styles/modules/pages/list/list.module.scss
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
@use '../../../abstracts' as helper;
|
||||||
|
|
||||||
|
.list {
|
||||||
|
--doc-whitespace: var(--spacer-8);
|
||||||
|
--comp-whitespace: var(--spacer-3);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
gap: var(--doc-whitespace);
|
||||||
|
padding: var(--doc-whitespace);
|
||||||
|
|
||||||
|
@include helper.bp('bp-700') {
|
||||||
|
--doc-whitespace: var(--spacer-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include helper.bp('bp-450') {
|
||||||
|
padding: var(--spacer-3);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
export const titleKey = (titleId: string) => `title:${titleId}`;
|
export const titleKey = (titleId: string) => `title:${titleId}`;
|
||||||
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 findKey = (query: string) => `find:${query}`;
|
export const findKey = (query: string) => `find:${query}`;
|
||||||
export const mediaKey = (url: string) => `media:${url}`;
|
export const mediaKey = (url: string) => `media:${url}`;
|
||||||
|
141
src/utils/fetchers/list.ts
Normal file
141
src/utils/fetchers/list.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import type { Data, DataKind } from 'src/interfaces/shared/list';
|
||||||
|
import axiosInstance from 'src/utils/axiosInstance';
|
||||||
|
import { AppError, isIMDbImgPlaceholder } from 'src/utils/helpers';
|
||||||
|
|
||||||
|
const list = async (listId: string, pageNum = '1') => {
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance(`/list/${listId}?page=${pageNum}`);
|
||||||
|
const $ = cheerio.load(res.data);
|
||||||
|
|
||||||
|
const $main = $('#main > .article');
|
||||||
|
const $meta = $main.children('#list-overview-summary');
|
||||||
|
const $footer = $main.find('.footer .desc .list-pagination');
|
||||||
|
|
||||||
|
const title = clean($main.children('h1.list-name'));
|
||||||
|
const numWithtype = clean($main.find('.sub-list .header .nav .desc')).split(' ');
|
||||||
|
|
||||||
|
if (numWithtype.length < 2) throw new AppError('invalid list', 400);
|
||||||
|
|
||||||
|
const [num, type] = numWithtype as [string, DataKind];
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
by: {
|
||||||
|
name: clean($meta.children('a')),
|
||||||
|
link: $meta.children('a').attr('href') ?? null,
|
||||||
|
},
|
||||||
|
created: clean($meta.find('#list-overview-created')),
|
||||||
|
updated: clean($meta.find('#list-overview-lastupdated')),
|
||||||
|
num,
|
||||||
|
type,
|
||||||
|
};
|
||||||
|
const description = clean($main.children('.list-description'));
|
||||||
|
|
||||||
|
const pagination = {
|
||||||
|
prev: $footer.children('a.prev-page').attr('href') ?? null,
|
||||||
|
range: clean($footer.children('.pagination-range')),
|
||||||
|
next: $footer.children('a.next-page').attr('href') ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const $imagesContainer = $main.find('.lister-list .media_index_thumb_list');
|
||||||
|
const $listItems = $main.find('.lister-list').children();
|
||||||
|
let data: Data<typeof type>[] = [];
|
||||||
|
|
||||||
|
// 1. images list
|
||||||
|
if (type === 'images') {
|
||||||
|
data = $imagesContainer
|
||||||
|
.find('a > img')
|
||||||
|
.map((_i, el) => $(el).attr('src'))
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. movies list
|
||||||
|
else if (type === 'titles') {
|
||||||
|
$listItems.each((_i, el) => {
|
||||||
|
let image = $(el).find('.lister-item-image > a > img.loadlate').attr('loadlate') ?? null;
|
||||||
|
if (image && isIMDbImgPlaceholder(image)) image = null;
|
||||||
|
|
||||||
|
const $content = $(el).children('.lister-item-content');
|
||||||
|
const $heading = $content.find('h3.lister-item-header > a');
|
||||||
|
const name = clean($heading);
|
||||||
|
const url = $heading.attr('href') ?? null;
|
||||||
|
const year = clean($heading.next('.lister-item-year'));
|
||||||
|
const $itemMeta = $content.find('h3.lister-item-header + p');
|
||||||
|
const certificate = clean($itemMeta.children('.certificate'));
|
||||||
|
const runtime = clean($itemMeta.children('.runtime'));
|
||||||
|
const genre = clean($itemMeta.children('.genre'));
|
||||||
|
const rating = clean($content.find('.ipl-rating-star__rating').first());
|
||||||
|
const metascore = clean($content.find('.metascore'));
|
||||||
|
const plot = clean($content.children('p[class=""]'));
|
||||||
|
|
||||||
|
// eg: [["Director", "Nabwana I.G.G."], ["Stars", "Kakule William, Sserunya Ernest, G. Puffs"]]
|
||||||
|
const otherInfo = $content
|
||||||
|
.children('p.text-muted.text-small')
|
||||||
|
.nextAll('p.text-muted.text-small')
|
||||||
|
.map((__i, infoEl) => {
|
||||||
|
const arr = clean($(infoEl)).replace(/\s+/g, ' ').split('|');
|
||||||
|
|
||||||
|
return arr.map(i => i.split(':'));
|
||||||
|
})
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
image,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
year,
|
||||||
|
certificate,
|
||||||
|
runtime,
|
||||||
|
genre,
|
||||||
|
plot,
|
||||||
|
rating,
|
||||||
|
metascore,
|
||||||
|
otherInfo,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. actors list
|
||||||
|
else if (type === 'names') {
|
||||||
|
$listItems.each((_i, el) => {
|
||||||
|
let image = $(el).find('.lister-item-image > a > img').attr('src') ?? null;
|
||||||
|
if (image && isIMDbImgPlaceholder(image)) image = null;
|
||||||
|
|
||||||
|
const $content = $(el).children('.lister-item-content');
|
||||||
|
const $heading = $content.find('h3.lister-item-header > a');
|
||||||
|
const name = clean($heading);
|
||||||
|
const url = $heading.attr('href') ?? null;
|
||||||
|
const $itemMeta = $content.find('h3.lister-item-header + p');
|
||||||
|
const jobNKnownForRaw = clean($itemMeta.first()).split('|');
|
||||||
|
const job = jobNKnownForRaw.at(0) ?? null;
|
||||||
|
const knownFor = jobNKnownForRaw.at(1) ?? null;
|
||||||
|
const knownForLink = $itemMeta.children('a').attr('href') ?? null;
|
||||||
|
const about = clean($content.children('p:not([class])'));
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
image,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
job,
|
||||||
|
knownFor,
|
||||||
|
knownForLink,
|
||||||
|
about,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, meta, description, pagination, data };
|
||||||
|
} catch (err: any) {
|
||||||
|
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.cause);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default list;
|
||||||
|
|
||||||
|
const clean = <T extends cheerio.Cheerio<any>>(item: T) => item.text().trim();
|
@ -66,6 +66,9 @@ export const modifyIMDbImg = (url: string, widthInPx = 600) => {
|
|||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const placeholderImageRegex = /https:\/\/m\.media-amazon.com\/images\/.{1}\/sash\/.*/;
|
||||||
|
export const isIMDbImgPlaceholder = (url?: string | null) => url ? placeholderImageRegex.test(url) : false;
|
||||||
|
|
||||||
export const getProxiedIMDbImgUrl = (url: string) => {
|
export const getProxiedIMDbImgUrl = (url: string) => {
|
||||||
return `/api/media_proxy?url=${encodeURIComponent(url)}`;
|
return `/api/media_proxy?url=${encodeURIComponent(url)}`;
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user