Jelajahi Sumber

feat(list): add list route

adds ability to see titles, names, and images lists

closes https://github.com/zyachel/libremdb/issues/6
zyachel 1 tahun lalu
induk
melakukan
97f1432ac5

+ 23 - 0
src/components/list/Data.tsx

@@ -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 - 0
src/components/list/Images.tsx

@@ -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 - 0
src/components/list/Meta.tsx

@@ -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 - 0
src/components/list/Names.tsx

@@ -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 - 0
src/components/list/OptionalLink.tsx

@@ -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 - 0
src/components/list/Pagination.tsx

@@ -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 - 0
src/components/list/Titles.tsx

@@ -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 - 0
src/components/list/index.ts

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

+ 3 - 0
src/interfaces/shared/index.ts

@@ -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 - 0
src/interfaces/shared/list.ts

@@ -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 - 0
src/pages/list/[listId]/index.tsx

@@ -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 - 0
src/styles/modules/components/list/images.module.scss

@@ -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 - 0
src/styles/modules/components/list/meta.module.scss

@@ -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 - 0
src/styles/modules/components/list/names.module.scss

@@ -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 - 0
src/styles/modules/components/list/pagination.module.scss

@@ -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 - 0
src/styles/modules/components/list/titles.module.scss

@@ -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 - 0
src/styles/modules/pages/list/list.module.scss

@@ -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 - 0
src/utils/constants/keys.ts

@@ -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 - 0
src/utils/fetchers/list.ts

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

+ 3 - 0
src/utils/helpers.ts

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