diff --git a/src/components/list/Data.tsx b/src/components/list/Data.tsx new file mode 100644 index 0000000..88f11b6 --- /dev/null +++ b/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>; +}; + +const Data = ({ data }: Props) => { + if (isDataImages(data)) return ; + if (isDataNames(data)) return ; + + return ; +}; +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]; diff --git a/src/components/list/Images.tsx b/src/components/list/Images.tsx new file mode 100644 index 0000000..653dbc4 --- /dev/null +++ b/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 ( +
+ {images.map(image => ( +
+ +
+ ))} +
+ ); +}; + +export default Images; diff --git a/src/components/list/Meta.tsx b/src/components/list/Meta.tsx new file mode 100644 index 0000000..b1bb160 --- /dev/null +++ b/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 ? ( + + {meta.by.name} + + ) : ( + meta.by.name + ); + + return ( +
+

{title}

+
    +
  • by {by}
  • +
  • {meta.created}
  • + {meta.updated &&
  • {meta.updated}
  • } +
  • + {meta.num} {meta.type} +
  • +
+ {description &&

{description}

} +
+ ); +}; +export default Meta; diff --git a/src/components/list/Names.tsx b/src/components/list/Names.tsx new file mode 100644 index 0000000..e46482d --- /dev/null +++ b/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 ( +
    + {names.map(name => ( + + ))} +
+ ); +}; +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 ( + +
+ {image ? ( + + ) : ( + + + + )} +
+
+

+ + {name} + +

+
    + {job &&
  • {job}
  • } + {knownFor && ( +
  • + {knownFor} +
  • + )} +
+

{about}

+
+
+ ); +}; diff --git a/src/components/list/OptionalLink.tsx b/src/components/list/OptionalLink.tsx new file mode 100644 index 0000000..a6ec02f --- /dev/null +++ b/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, 'href'>) => ( + <> + {href ? ( + + {children} + + ) : ( + children + )} + +); + +export default OptionalLink; diff --git a/src/components/list/Pagination.tsx b/src/components/list/Pagination.tsx new file mode 100644 index 0000000..ad3275f --- /dev/null +++ b/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 ( + + ); +}; + +export default Pagination; diff --git a/src/components/list/Titles.tsx b/src/components/list/Titles.tsx new file mode 100644 index 0000000..477c6a3 --- /dev/null +++ b/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 ( +
    + {titles.map(title => ( + + ))} + </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> + ); +}; diff --git a/src/components/list/index.ts b/src/components/list/index.ts new file mode 100644 index 0000000..5f14749 --- /dev/null +++ b/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'; diff --git a/src/interfaces/shared/index.ts b/src/interfaces/shared/index.ts index 779c9b0..922383c 100644 --- a/src/interfaces/shared/index.ts +++ b/src/interfaces/shared/index.ts @@ -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; diff --git a/src/interfaces/shared/list.ts b/src/interfaces/shared/list.ts new file mode 100644 index 0000000..f8c7d53 --- /dev/null +++ b/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; diff --git a/src/pages/list/[listId]/index.tsx b/src/pages/list/[listId]/index.tsx new file mode 100644 index 0000000..8da548e --- /dev/null +++ b/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; diff --git a/src/styles/modules/components/list/images.module.scss b/src/styles/modules/components/list/images.module.scss new file mode 100644 index 0000000..f9b34af --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/src/styles/modules/components/list/meta.module.scss b/src/styles/modules/components/list/meta.module.scss new file mode 100644 index 0000000..af92397 --- /dev/null +++ b/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); + } +} diff --git a/src/styles/modules/components/list/names.module.scss b/src/styles/modules/components/list/names.module.scss new file mode 100644 index 0000000..ef15804 --- /dev/null +++ b/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; + } +} + diff --git a/src/styles/modules/components/list/pagination.module.scss b/src/styles/modules/components/list/pagination.module.scss new file mode 100644 index 0000000..c5fdb9a --- /dev/null +++ b/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; + } +} + diff --git a/src/styles/modules/components/list/titles.module.scss b/src/styles/modules/components/list/titles.module.scss new file mode 100644 index 0000000..81b3123 --- /dev/null +++ b/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); + } +} \ No newline at end of file diff --git a/src/styles/modules/pages/list/list.module.scss b/src/styles/modules/pages/list/list.module.scss new file mode 100644 index 0000000..4585c41 --- /dev/null +++ b/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); + } +} diff --git a/src/utils/constants/keys.ts b/src/utils/constants/keys.ts index aceaefd..3e1bcf3 100644 --- a/src/utils/constants/keys.ts +++ b/src/utils/constants/keys.ts @@ -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}`; diff --git a/src/utils/fetchers/list.ts b/src/utils/fetchers/list.ts new file mode 100644 index 0000000..8b36902 --- /dev/null +++ b/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(); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 0a97929..c31260e 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -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)}`; };