Compare commits

..

12 commits
v3.1.1 ... main

Author SHA1 Message Date
zyachel
410cc70259
Merge pull request #60 from SudoVanilla/patch-1
Add SudoVanilla's Instance
2023-11-25 21:55:53 +00:00
Korbs
258a82f2ac
Add that SudoVanilla's Instance uses Cloudflare 2023-11-25 12:26:47 -05:00
Korbs
e2a335f98d
Add SudoVanilla's Instance 2023-11-25 12:26:16 -05:00
zyachel
246f1155d5 Merge branch 'main' of codeberg.org:zyachel/libremdb into nipos-main 2023-10-30 01:37:55 +05:30
zyachel
19f1700a55 feat(api): add api endpoints for dynamic routes
Squashed commit of the following:

commit 9fdd731136
Author: zyachel <aricla@protonmail.com>
Date:   Mon Oct 30 01:25:32 2023 +0530

    feat(api): add a catch-all route

commit 4dffbbc0ec
Author: zyachel <aricla@protonmail.com>
Date:   Mon Oct 30 01:24:10 2023 +0530

    fix(api): refactor all endpoints a bit

    disallow methods other that GET
    properly type return types
    add type guards where needed
    make all endpoints
    return same response format for consistency

commit 264442448f
Author: Niklas Poslovski <niklas.poslovski@nikisoft.one>
Date:   Sun Oct 29 19:00:44 2023 +0100

    Add API endpoints for all routes that contain IMDB data
2023-10-30 01:34:28 +05:30
zyachel
9fdd731136 feat(api): add a catch-all route 2023-10-30 01:25:32 +05:30
zyachel
4dffbbc0ec fix(api): refactor all endpoints a bit
disallow methods other that GET
properly type return types
add type guards where needed
make all endpoints
return same response format for consistency
2023-10-30 01:24:32 +05:30
Niklas Poslovski
264442448f Add API endpoints for all routes that contain IMDB data 2023-10-29 19:00:44 +01:00
zyachel
2b00d5406a chore(release): 3.2.0 2023-10-29 00:50:34 +05:30
zyachel
97f1432ac5 feat(list): add list route
adds ability to see titles, names, and images lists

closes https://github.com/zyachel/libremdb/issues/6
2023-10-29 00:49:55 +05:30
zyachel
60fb23fc5b refactor(name): remove console statement 2023-10-29 00:49:55 +05:30
zyachel
12eaa741ab refactor: general refactor
make barrel files .ts instead of .tsx
move layouts to components directory
2023-10-29 00:49:51 +05:30
41 changed files with 940 additions and 13 deletions

View file

@ -2,6 +2,13 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
## [3.2.0](https://github.com/zyachel/libremdb/compare/v3.1.1...v3.2.0) (2023-10-28)
### Features
* **list:** add list route ([97f1432](https://github.com/zyachel/libremdb/commit/97f1432ac5d23206229d806b7cb3e04af6dec36f))
## [3.1.1](https://github.com/zyachel/libremdb/compare/v3.1.0...v3.1.1) (2023-10-14)

View file

@ -49,6 +49,7 @@ Inspired by projects like [teddit](https://codeberg.org/teddit/teddit), [nitter]
| [libremdb.frontendfriendly.xyz](https://libremdb.frontendfriendly.xyz) | &mdash; | Operated by [frontendfriendly.xyz](https://frontendfriendly.xyz) |
[d.opnxng.com](https://d.opnxng.com) | Singapore | Operated by [Opnxng](https://about.opnxng.com/)
[libremdb.catsarch.com](https://libremdb.catsarch.com) | US | Operated by [Butter Cat](https://catsarch.com/)
[mdb.sudovanilla.com](https://mdb.sudovanilla.com) | US (Cloudflare) | Operated by [SudoVanilla](https://sudovanilla.com/)
| 2. Onion | | |
| [ld.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://ld.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) | US | Operated by [~vern](https://vern.cc) |
| 3. I2P | | |

View file

@ -1,6 +1,6 @@
{
"name": "libremdb",
"version": "3.1.1",
"version": "3.2.0",
"description": "a free & open source IMDb front-end",
"private": true,
"type": "module",

View file

@ -1,6 +1,5 @@
import { ReactNode } from 'react';
import Link from 'next/link';
import Layout from 'src/layouts/Layout';
import Layout from 'src/components/layout';
import Meta from 'src/components/meta/Meta';
import styles from 'src/styles/modules/components/error/error-info.module.scss';

View file

@ -1,6 +1,6 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import styles from '../styles/modules/layout/footer.module.scss';
import styles from 'src/styles/modules/layout/footer.module.scss';
const links = [
{ path: '/about', text: 'About' },

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

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

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

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

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

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

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

View file

@ -0,0 +1,3 @@
export { default as Data } from './Data';
export { default as Meta } from './Meta';
export { default as Pagination } from './Pagination';

View file

@ -1,3 +1,6 @@
import type Name from './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;

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

View file

@ -1,6 +1,6 @@
import Link from 'next/link';
import Meta from 'src/components/meta/Meta';
import Layout from 'src/layouts/Layout';
import Layout from 'src/components/layout';
import styles from 'src/styles/modules/pages/about/about.module.scss';
const About = () => {

View file

@ -0,0 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
type ResponseData = { status: false; message: string };
export default async function handler(_: NextApiRequest, res: NextApiResponse<ResponseData>) {
res.status(400).json({ status: false, message: 'Not found' });
}

32
src/pages/api/find.ts Normal file
View file

@ -0,0 +1,32 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import Find, { type FindQueryParams } from 'src/interfaces/shared/search';
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';
type ResponseData =
| { status: true; data: { title: null | string; results: null | Find } }
| { 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 queryObj = req.query as FindQueryParams | Record<string, never>;
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 results = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr);
res.status(200).json({ status: true, data: { title: query, results } });
} catch (error: any) {
const { message = 'Not found', statusCode = 404 } = error;
res.status(statusCode).json({ status: false, message });
}
}

View file

@ -0,0 +1,23 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type List from 'src/interfaces/shared/list';
import list from 'src/utils/fetchers/list';
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
import { listKey } from 'src/utils/constants/keys';
import { AppError } from 'src/utils/helpers';
type ResponseData = { status: true; data: List } | { 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 listId = req.query.listId as string;
const pageNum = req.query.page as string | undefined;
const data = await getOrSetApiCache(listKey(listId, pageNum), list, listId, pageNum);
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,22 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type Name from 'src/interfaces/shared/name';
import name from 'src/utils/fetchers/name';
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
import { nameKey } from 'src/utils/constants/keys';
import { AppError } from 'src/utils/helpers';
type ResponseData = { status: true; data: Name } | { 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 nameId = req.query.nameId as string;
const data = await getOrSetApiCache(nameKey(nameId), name, nameId);
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

@ -1,5 +1,5 @@
import Meta from 'src/components/meta/Meta';
import Layout from 'src/layouts/Layout';
import Layout from 'src/components/layout';
import styles from 'src/styles/modules/pages/contact/contact.module.scss';
const Contact = () => {

View file

@ -1,5 +1,5 @@
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import Layout from 'src/layouts/Layout';
import Layout from 'src/components/layout';
import ErrorInfo from 'src/components/error/ErrorInfo';
import Meta from 'src/components/meta/Meta';
import Results from 'src/components/find';

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

View file

@ -1,6 +1,6 @@
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import Meta from 'src/components/meta/Meta';
import Layout from 'src/layouts/Layout';
import Layout from 'src/components/layout';
import ErrorInfo from 'src/components/error/ErrorInfo';
import Media from 'src/components/media/Media';
import { Basic, Credits, DidYouKnow, Info, Bio, KnownFor } from 'src/components/name';

View file

@ -1,5 +1,5 @@
import Meta from 'src/components/meta/Meta';
import Layout from 'src/layouts/Layout';
import Layout from 'src/components/layout';
import packageInfo from 'src/../package.json';
import styles from 'src/styles/modules/pages/privacy/privacy.module.scss';

View file

@ -1,6 +1,6 @@
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import Meta from 'src/components/meta/Meta';
import Layout from 'src/layouts/Layout';
import Layout from 'src/components/layout';
import ErrorInfo from 'src/components/error/ErrorInfo';
import Media from 'src/components/media/Media';
import { Basic, Cast, DidYouKnow, Info, MoreLikeThis, Reviews } from 'src/components/title';

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

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

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

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

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

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

View file

@ -1,4 +1,5 @@
export const titleKey = (titleId: string) => `title:${titleId}`;
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 mediaKey = (url: string) => `media:${url}`;

141
src/utils/fetchers/list.ts Normal file
View 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();

View file

@ -18,9 +18,6 @@ const name = async (nameId: string) => {
} catch (err: any) {
if (err.response?.status === 404) throw new AppError('not found', 404, err.cause);
console.warn(err);
throw new AppError('something went wrong', 500, err.cause);
}
};

View file

@ -66,6 +66,9 @@ export const modifyIMDbImg = (url: string, widthInPx = 600) => {
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) => {
return `/api/media_proxy?url=${encodeURIComponent(url)}`;
};