refactor: make components more modular

would help in implementing name route

also did some stylistic changes
This commit is contained in:
zyachel 2023-04-15 20:56:15 +05:30
parent 8ce02d0236
commit 18ca98fd4a
43 changed files with 757 additions and 796 deletions

View File

@ -4,5 +4,6 @@
"arrowParens": "avoid",
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true
"jsxSingleQuote": true,
"printWidth": 100
}

View File

@ -0,0 +1,29 @@
import type { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react';
import styles from 'src/styles/modules/components/card/card.module.scss';
// ensuring that other attributes to <Card/> are correct based on the value of 'as' prop.
// a cheap implementation of as prop found in libraries like CharkaUI or MaterialUI.
type Props<T extends ElementType> = {
children: ReactNode;
as?: T | 'section';
hoverable?: true;
} & ComponentPropsWithoutRef<T>;
const Card = <T extends ElementType = 'li'>({
children,
as,
hoverable,
className,
...rest
}: Props<T>) => {
const Component = as ?? 'li';
const classNames = `${hoverable ? styles.hoverable : ''} ${styles.card} ${className}`;
return (
<Component className={classNames} {...rest}>
{children}
</Component>
);
};
export default Card;

View File

@ -0,0 +1,45 @@
import { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
import Image from 'next/future/image';
import Card from './Card';
import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
import styles from 'src/styles/modules/components/card/card-basic.module.scss';
type Props = {
children: ReactNode;
className?: string;
image?: string;
title: string;
} & ComponentPropsWithoutRef<'section'>;
const CardBasic = ({ image, children, className, title, ...rest }: Props) => {
const style: CSSProperties = {
backgroundImage: image && `url(${getProxiedIMDbImgUrl(modifyIMDbImg(image, 300))})`,
};
return (
<Card as='section' className={`${styles.container} ${className}`} {...rest}>
<div className={styles.imageContainer} style={style}>
{image ? (
<Image
className={styles.image}
src={modifyIMDbImg(image)}
alt=''
priority
fill
sizes='300px'
/>
) : (
<svg className={styles.imageNA}>
<use href='/svg/sprite.svg#icon-image-slash' />
</svg>
)}
</div>
<div className={styles.info}>
<h1 className={`${styles.title} heading heading__primary`}>{title}</h1>
{children}
</div>
</Card>
);
};
export default CardBasic;

View File

@ -0,0 +1,51 @@
import Card from './Card';
import styles from 'src/styles/modules/components/card/card-cast.module.scss';
import { ComponentPropsWithoutRef, ReactNode } from 'react';
import Link from 'next/link';
import Image from 'next/future/image';
import { modifyIMDbImg } from 'src/utils/helpers';
type Props = {
link: string;
name: string;
characters: string[] | null;
attributes: string[] | null;
image?: string | null;
children?: ReactNode;
} & ComponentPropsWithoutRef<'li'>;
const CardCast = ({ link, name, image, children, characters, attributes, ...rest }: Props) => {
return (
<Card hoverable {...rest}>
<Link href={link}>
<a className={styles.item}>
<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.textContainer}>
<p className={`heading ${styles.name}`}>{name}</p>
<p className={styles.role}>
{characters?.join(', ')}
{attributes && <span> ({attributes.join(', ')})</span>}
</p>
{children}
</div>
</a>
</Link>
</Card>
);
};
export default CardCast;

View File

@ -0,0 +1,42 @@
import { ComponentPropsWithoutRef, ReactNode } from 'react';
import Link from 'next/link';
import Image from 'next/future/image';
import Card from './Card';
import { modifyIMDbImg } from 'src/utils/helpers';
import styles from 'src/styles/modules/components/card/card-result.module.scss';
type Props = {
link: string;
name: string;
image?: string;
showImage?: true;
children?: ReactNode;
} & ComponentPropsWithoutRef<'li'>;
const CardResult = ({ link, name, image, showImage, children, ...rest }: Props) => {
let ImageComponent = null;
if (showImage)
ImageComponent = 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>
);
return (
<Card hoverable {...rest}>
<Link href={link}>
<a className={`${styles.item} ${!showImage && styles.sansImage}`}>
<div className={styles.imgContainer}>{ImageComponent}</div>
<div className={styles.info}>
<p className={`heading ${styles.heading}`}>{name}</p>
{children}
</div>
</a>
</Link>
</Card>
);
};
export default CardResult;

View File

@ -0,0 +1,63 @@
import Card from './Card';
import styles from 'src/styles/modules/components/card/card-title.module.scss';
import { ComponentPropsWithoutRef, ReactNode } from 'react';
import Link from 'next/link';
import Image from 'next/future/image';
import { formatNumber, modifyIMDbImg } from 'src/utils/helpers';
type Props = {
link: string;
name: string;
titleType: string;
year?: { start: number; end: number | null };
ratings?: { avg: number | null; numVotes: number };
image?: string;
children?: ReactNode;
} & ComponentPropsWithoutRef<'li'>;
const CardTitle = ({ link, name, year, image, ratings, titleType, children, ...rest }: Props) => {
const years = year?.end ? `${year.start}-${year.end}` : year?.start;
return (
<Card hoverable {...rest}>
<Link href={link}>
<a className={styles.item}>
<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.textContainer}>
<p className={`heading ${styles.name}`}>{name}</p>
<p>
<span>{titleType}</span>
<span>{years && ` (${years})`}</span>
</p>
{ratings?.avg && (
<p className={styles.rating}>
<span className={styles.ratingNum}>{ratings.avg}</span>
<svg className={styles.ratingIcon}>
<use href='/svg/sprite.svg#icon-rating'></use>
</svg>
<span> ({formatNumber(ratings.numVotes)} votes)</span>
</p>
)}
{children}
</div>
</a>
</Link>
</Card>
);
};
export default CardTitle;

View File

@ -0,0 +1,5 @@
export { default as Card } from './Card';
export { default as CardTitle } from './CardTitle';
export { default as CardBasic } from './CardBasic';
export { default as CardCast } from './CardCast';
export { default as CardResult } from './CardResult';

View File

@ -33,15 +33,12 @@ const ErrorInfo = ({ message, statusCode, misc }: Props) => {
>
<title id='gnu-title'>GNU and Tux</title>
<desc id='gnu-desc'>
A pencil drawing of a big gnu and a small penguin, both very sad.
GNU is despondently sitting on a bench, and Tux stands beside him,
looking down and patting him on the back.
A pencil drawing of a big gnu and a small penguin, both very sad. GNU is despondently
sitting on a bench, and Tux stands beside him, looking down and patting him on the back.
</desc>
<use href='/svg/sadgnu.svg#sad-gnu'></use>
</svg>
<h1 className={`heading heading__primary ${styles.heading}`}>
{title}
</h1>
<h1 className={`heading heading__primary ${styles.heading}`}>{title}</h1>
{misc ? (
<>
<p>{misc.subtext}</p>
@ -52,7 +49,7 @@ const ErrorInfo = ({ message, statusCode, misc }: Props) => {
) : (
<p>
Go back to{' '}
<Link href='/about'>
<Link href='/'>
<a className='link'>the homepage</a>
</Link>
.

View File

@ -1,22 +1,13 @@
import { CardResult } from 'src/components/card';
import { Companies } from 'src/interfaces/shared/search';
import Link from 'next/link';
import styles from 'src/styles/modules/components/find/company.module.scss';
type Props = { company: Companies[number] };
type Props = {
company: Companies[0];
};
const Company = ({ company }: Props) => {
return (
<li className={styles.company}>
<Link href={`name/${company.id}`}>
<a className={`heading ${styles.heading}`}>{company.name}</a>
</Link>
{company.country && <p>{company.country}</p>}
{!!company.type && <p>{company.type}</p>}
</li>
);
};
const Company = ({ company }: Props) => (
<CardResult name={company.name} link={`/search/title?companies=${company.id}`}>
{company.country && <p>{company.country}</p>}
{!!company.type && <p>{company.type}</p>}
</CardResult>
);
export default Company;

View File

@ -1,20 +1,12 @@
import Link from 'next/link';
import { CardResult } from 'src/components/card';
import { Keywords } from 'src/interfaces/shared/search';
import styles from 'src/styles/modules/components/find/keyword.module.scss';
type Props = {
keyword: Keywords[0];
};
type Props = { keyword: Keywords[number] };
const Keyword = ({ keyword }: Props) => {
return (
<li className={styles.keyword}>
<Link href={`name/${keyword.id}`}>
<a className={`heading ${styles.heading}`}>{keyword.text}</a>
</Link>
{keyword.numTitles && <p>{keyword.numTitles} titles</p>}
</li>
);
};
const Keyword = ({ keyword }: Props) => (
<CardResult link={`/search/keyword?keywords=${keyword.text}`} name={keyword.text}>
{keyword.numTitles && <p>{keyword.numTitles} titles</p>}
</CardResult>
);
export default Keyword;

View File

@ -1,44 +1,19 @@
import Image from 'next/future/image';
import Link from 'next/link';
import { CardResult } from 'src/components/card';
import { People } from 'src/interfaces/shared/search';
import { modifyIMDbImg } from 'src/utils/helpers';
import styles from 'src/styles/modules/components/find/person.module.scss';
type Props = {
person: People[0];
};
type Props = { person: People[number] };
const Person = ({ person }: Props) => {
return (
<li className={styles.person}>
<div className={styles.imgContainer} style={{ position: 'relative' }}>
{person.image ? (
<Image
src={modifyIMDbImg(person.image.url, 400)}
alt={person.image.caption}
fill
className={styles.img}
/>
) : (
<svg className={styles.imgNA}>
<use href='/svg/sprite.svg#icon-image-slash' />
</svg>
)}
</div>
<div className={styles.info}>
<Link href={`name/${person.id}`}>
<a className={`heading ${styles.heading}`}>{person.name}</a>
</Link>
{person.aka && <p>{person.aka}</p>}
{person.jobCateogry && <p>{person.jobCateogry}</p>}
{(person.knownForTitle || person.knownInYear) && (
<ul className={styles.basicInfo} aria-label='quick facts'>
{person.knownForTitle && <li>{person.knownForTitle}</li>}
{person.knownInYear && <li>{person.knownInYear}</li>}
</ul>
)}
</div>
</li>
<CardResult showImage name={person.name} link={`/name/${person.id}`} image={person.image?.url}>
<p>{person.aka}</p>
<p>{person.jobCateogry}</p>
<ul className={styles.basicInfo} aria-label='quick facts'>
{person.knownForTitle && <li>{person.knownForTitle}</li>}
{person.knownInYear && <li>{person.knownInYear}</li>}
</ul>
</CardResult>
);
};

View File

@ -1,58 +1,36 @@
import Image from 'next/future/image';
import Link from 'next/link';
import { CardResult } from 'src/components/card';
import { Titles } from 'src/interfaces/shared/search';
import { modifyIMDbImg } from 'src/utils/helpers';
import styles from 'src/styles/modules/components/find/title.module.scss';
type Props = {
title: Titles[0];
};
type Props = { title: Titles[number] };
const Title = ({ title }: Props) => {
return (
<li className={styles.title}>
<div className={styles.imgContainer}>
{title.image ? (
<Image
src={modifyIMDbImg(title.image.url, 400)}
alt={title.image.caption}
fill
className={styles.img}
/>
) : (
<svg className={styles.imgNA}>
<use href='/svg/sprite.svg#icon-image-slash' />
</svg>
)}
</div>
<div className={styles.info}>
<Link href={`/title/${title.id}`}>
<a className={`heading ${styles.heading}`}>{title.name}</a>
</Link>
<ul aria-label='quick facts' className={styles.basicInfo}>
{title.type && <li>{title.type}</li>}
{title.sAndE && <li>{title.sAndE}</li>}
{title.releaseYear && <li>{title.releaseYear}</li>}
<CardResult showImage name={title.name} link={`/title/${title.id}`} image={title.image?.url}>
<ul aria-label='quick facts' className={styles.basicInfo}>
<li>{title.type}</li>
<li>{title.sAndE}</li>
<li>{title.releaseYear}</li>
</ul>
{!!title.credits.length && (
<p className={styles.stars}>
<span>Stars: </span>
{title.credits.join(', ')}
</p>
)}
{title.seriesId && (
<ul aria-label='quick series facts' className={styles.seriesInfo}>
{title.seriesType && <li>{title.seriesType}</li>}
<li>
<Link href={`/title/${title.seriesId}`}>
<a className='link'>{title.seriesName}</a>
</Link>
</li>
{title.seriesReleaseYear && <li>{title.seriesReleaseYear}</li>}
</ul>
{!!title.credits.length && (
<p className={styles.stars}>
<span>Stars: </span>
{title.credits.join(', ')}
</p>
)}
{title.seriesId && (
<ul aria-label='quick series facts' className={styles.seriesInfo}>
{title.seriesType && <li>{title.seriesType}</li>}
<li>
<Link href={`/title/${title.seriesId}`}>
<a className='link'>{title.seriesName}</a>
</Link>
</li>
{title.seriesReleaseYear && <li>{title.seriesReleaseYear}</li>}
</ul>
)}
</div>
</li>
)}
</CardResult>
);
};

View File

@ -12,18 +12,16 @@ type Props = {
title: string;
};
const resultsExist = (results: Props['results']) => {
if (
!results ||
(!results.people.length &&
!results.keywords.length &&
!results.companies.length &&
!results.titles.length)
)
return false;
return true;
};
const resultsExist = (
results: Props['results']
): results is NonNullable<Props['results']> =>
Boolean(
results &&
(results.people.length ||
results.keywords.length ||
results.companies.length ||
results.titles.length)
);
// MAIN COMPONENT
const Results = ({ results, className, title }: Props) => {
@ -34,7 +32,7 @@ const Results = ({ results, className, title }: Props) => {
</h1>
);
const { titles, people, keywords, companies, meta } = results!;
const { titles, people, keywords, companies, meta } = results;
const titlesSectionHeading = getResTitleTypeHeading(
meta.type,
meta.titleType

View File

@ -1,14 +1,16 @@
import Image from 'next/future/image';
import Link from 'next/link';
import { Media } from 'src/interfaces/shared/title';
import { Media } from 'src/interfaces/shared';
import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
import styles from 'src/styles/modules/components/title/media.module.scss';
import styles from 'src/styles/modules/components/media/media.module.scss';
type Props = {
className: string;
media: Media;
};
// TODO: refactor this component.
const Media = ({ className, media }: Props) => {
return (
<div className={`${className} ${styles.media}`}>
@ -21,13 +23,9 @@ const Media = ({ className, media }: Props) => {
<div className={styles.trailer}>
<video
aria-label='trailer video'
// it's a relatively new tag. hence jsx-all1 complains
aria-description={media.trailer.caption}
controls
playsInline
poster={getProxiedIMDbImgUrl(
modifyIMDbImg(media.trailer.thumbnail)
)}
poster={getProxiedIMDbImgUrl(modifyIMDbImg(media.trailer.thumbnail))}
className={styles.trailer__video}
preload='none'
>
@ -76,9 +74,7 @@ const Media = ({ className, media }: Props) => {
fill
sizes='400px'
/>
<figcaption className={styles.image__caption}>
{image.caption.plainText}
</figcaption>
<figcaption className={styles.image__caption}>{image.caption.plainText}</figcaption>
</figure>
))}
</div>

View File

@ -1,4 +1,5 @@
import Head from 'next/head';
import { ReactNode } from 'react';
type Props = {
title: string;
@ -6,11 +7,15 @@ type Props = {
imgUrl?: string;
};
const BASE_URL = process.env.NEXT_PUBLIC_URL ?? 'https://iket.me';
const Meta = ({
title,
description = 'libremdb, a free & open source IMDb front-end.',
imgUrl = 'icon.svg',
}: Props) => {
const url = new URL(imgUrl, BASE_URL);
return (
<Head>
<meta charSet='UTF-8' />
@ -30,10 +35,7 @@ const Meta = ({
<meta property='og:site_name' content='libremdb' />
<meta property='og:locale' content='en_US' />
<meta property='og:type' content='video.movie' />
<meta
property='og:image'
content={`${process.env.NEXT_PUBLIC_URL}/${imgUrl}`}
/>
<meta property='og:image' content={url.toString()} />
</Head>
);
};

View File

@ -1,13 +1,8 @@
import { Fragment } from 'react';
import Image from 'next/future/image';
import Link from 'next/link';
import { CardBasic } from 'src/components/card';
import { Basic } from 'src/interfaces/shared/title';
import {
formatNumber,
formatTime,
getProxiedIMDbImgUrl,
modifyIMDbImg,
} from 'src/utils/helpers';
import { formatNumber, formatTime } from 'src/utils/helpers';
import styles from 'src/styles/modules/components/title/basic.module.scss';
type Props = {
@ -23,135 +18,92 @@ const Basic = ({ data, className }: Props) => {
: data.releaseYear?.start;
return (
<section
// role is valid but not known to jsx-a11y
// aria-description={`basic info for '${data.title}'`}
// style={{ backgroundImage: data.poster && `url(${data.poster?.url})` }}
<CardBasic
className={`${styles.container} ${className}`}
image={data.poster?.url}
title={data.title}
>
<div
className={styles.imageContainer}
style={{
backgroundImage:
data.poster &&
`url(${getProxiedIMDbImgUrl(modifyIMDbImg(data.poster.url, 300))})`,
}}
>
{data.poster ? (
<Image
className={styles.image}
src={modifyIMDbImg(data.poster.url)}
alt={data.poster.caption}
priority
fill
sizes='300px'
/>
) : (
<svg className={styles.image__NA}>
<use href='/svg/sprite.svg#icon-image-slash' />
</svg>
<ul className={styles.meta} aria-label='quick facts'>
{data.status && data.status.id !== 'released' && (
<li className={styles.meta__text}>{data.status.text}</li>
)}
</div>
<div className={styles.info}>
<h1 className={`${styles.title} heading heading__primary`}>
{data.title}
</h1>
<ul className={styles.meta} aria-label='quick facts'>
{data.status && data.status.id !== 'released' && (
<li className={styles.meta__text}>{data.status.text}</li>
)}
<li className={styles.meta__text}>{data.type.name}</li>
{data.releaseYear && (
<li className={styles.meta__text}>{releaseTime}</li>
)}
{data.ceritficate && (
<li className={styles.meta__text}>{data.ceritficate}</li>
)}
{data.runtime && (
<li className={styles.meta__text}>{formatTime(data.runtime)}</li>
)}
</ul>
<div className={styles.ratings}>
{data.ratings.avg && (
<>
<p className={styles.rating}>
<span className={styles.rating__num}>{data.ratings.avg}</span>
<svg className={styles.rating__icon}>
<use href='/svg/sprite.svg#icon-rating'></use>
</svg>
<span className={styles.rating__text}> Avg. rating</span>
</p>
<p className={styles.rating}>
<span className={styles.rating__num}>
{formatNumber(data.ratings.numVotes)}
</span>
<svg className={styles.rating__icon}>
<use href='/svg/sprite.svg#icon-like-dislike'></use>
</svg>
<span className={styles.rating__text}> No. of votes</span>
</p>
</>
)}
{data.ranking && (
<li className={styles.meta__text}>{data.type.name}</li>
{data.releaseYear && <li className={styles.meta__text}>{releaseTime}</li>}
{data.ceritficate && <li className={styles.meta__text}>{data.ceritficate}</li>}
{data.runtime && <li className={styles.meta__text}>{formatTime(data.runtime)}</li>}
</ul>
<div className={styles.ratings}>
{data.ratings.avg && (
<>
<p className={styles.rating}>
<span className={styles.rating__num}>
{formatNumber(data.ranking.position)}
</span>
<span className={styles.rating__num}>{data.ratings.avg}</span>
<svg className={styles.rating__icon}>
<use href='/svg/sprite.svg#icon-graph-rising'></use>
<use href='/svg/sprite.svg#icon-rating'></use>
</svg>
<span className={styles.rating__text}>
{' '}
Popularity (
<span className={styles.rating__sub}>
{data.ranking.direction === 'UP'
? `\u2191${formatNumber(data.ranking.change)}`
: data.ranking.direction === 'DOWN'
? `\u2193${formatNumber(data.ranking.change)}`
: ''}
</span>
)
</span>
<span className={styles.rating__text}> Avg. rating</span>
</p>
)}
</div>
{!!data.genres.length && (
<p className={styles.genres}>
<span className={styles.genres__heading}>Genres: </span>
{data.genres.map((genre, i) => (
<Fragment key={genre.id}>
{i > 0 && ', '}
<Link href={`/search/title?genres=${genre.id}`}>
<a className={styles.link}>{genre.text}</a>
</Link>
</Fragment>
))}
<p className={styles.rating}>
<span className={styles.rating__num}>{formatNumber(data.ratings.numVotes)}</span>
<svg className={styles.rating__icon}>
<use href='/svg/sprite.svg#icon-like-dislike'></use>
</svg>
<span className={styles.rating__text}> No. of votes</span>
</p>
</>
)}
{data.ranking && (
<p className={styles.rating}>
<span className={styles.rating__num}>{formatNumber(data.ranking.position)}</span>
<svg className={styles.rating__icon}>
<use href='/svg/sprite.svg#icon-graph-rising'></use>
</svg>
<span className={styles.rating__text}>
{' '}
Popularity (
<span className={styles.rating__sub}>
{data.ranking.direction === 'UP'
? `\u2191${formatNumber(data.ranking.change)}`
: data.ranking.direction === 'DOWN'
? `\u2193${formatNumber(data.ranking.change)}`
: ''}
</span>
)
</span>
</p>
)}
{
<p className={styles.overview}>
<span className={styles.overview__heading}>Plot: </span>
<span className={styles.overview__text}>{data.plot || '-'}</span>
</p>
}
{data.primaryCrew.map(crewType => (
<p className={styles.crewType} key={crewType.type.id}>
<span className={styles.crewType__heading}>
{`${crewType.type.category}: `}
</span>
{crewType.crew.map((crew, i) => (
<Fragment key={crew.id}>
{i > 0 && ', '}
<Link href={`/name/${crew.id}`}>
<a className={styles.link}>{crew.name}</a>
</Link>
</Fragment>
))}
</p>
))}
</div>
</section>
{!!data.genres.length && (
<p className={styles.genres}>
<span className={styles.genres__heading}>Genres: </span>
{data.genres.map((genre, i) => (
<Fragment key={genre.id}>
{i > 0 && ', '}
<Link href={`/search/title?genres=${genre.id}`}>
<a className={styles.link}>{genre.text}</a>
</Link>
</Fragment>
))}
</p>
)}
<p className={styles.overview}>
<span className={styles.overview__heading}>Plot: </span>
<span className={styles.overview__text}>{data.plot || '-'}</span>
</p>
{data.primaryCrew.map(crewType => (
<p className={styles.crewType} key={crewType.type.id}>
<span className={styles.crewType__heading}>{`${crewType.type.category}: `}</span>
{crewType.crew.map((crew, i) => (
<Fragment key={crew.id}>
{i > 0 && ', '}
<Link href={`/name/${crew.id}`}>
<a className={styles.link}>{crew.name}</a>
</Link>
</Fragment>
))}
</p>
))}
</CardBasic>
);
};

View File

@ -1,7 +1,5 @@
import Image from 'next/future/image';
import Link from 'next/link';
import { CardCast } from 'src/components/card';
import { Cast } from 'src/interfaces/shared/title';
import { modifyIMDbImg } from 'src/utils/helpers';
import styles from 'src/styles/modules/components/title/cast.module.scss';
type Props = {
@ -10,46 +8,25 @@ type Props = {
};
const Cast = ({ className, cast }: Props) => {
if (!cast.length) return <></>;
if (!cast.length) return null;
return (
<section className={`${className} ${styles.container}`}>
<h2 className='heading heading__secondary'>Cast</h2>
<ul className={styles.cast}>
{cast.map(member => (
<li key={member.id} className={styles.member}>
<div className={styles.member__imgContainer}>
{member.image ? (
<Image
src={modifyIMDbImg(member.image, 400)}
alt=''
fill
className={styles.member__img}
sizes='200px'
/>
) : (
<svg className={styles.member__imgNA}>
<use href='/svg/sprite.svg#icon-image-slash' />
</svg>
)}
</div>
<div className={styles.member__textContainer}>
<p>
<Link href={`/name/${member.id}`}>
<a className={styles.member__name}>{member.name}</a>
</Link>
</p>
<p className={styles.member__role}>
{member.characters?.join(', ')}
{member.attributes && (
<span> ({member.attributes.join(', ')})</span>
)}
</p>
</div>
</li>
<CardCast
key={member.id}
link={`/name/${member.id}`}
name={member.name}
image={member.image}
characters={member.characters}
attributes={member.attributes}
/>
))}
</ul>
</section>
);
};
export default Cast;

View File

@ -1,7 +1,5 @@
import Image from 'next/future/image';
import Link from 'next/link';
import { CardTitle } from 'src/components/card';
import { MoreLikeThis } from 'src/interfaces/shared/title';
import { formatNumber, modifyIMDbImg } from 'src/utils/helpers';
import styles from 'src/styles/modules/components/title/more-like-this.module.scss';
type Props = {
@ -10,52 +8,22 @@ type Props = {
};
const MoreLikeThis = ({ className, data }: Props) => {
if (!data.length) return <></>;
if (!data.length) return null;
return (
<section className={`${className} ${styles.morelikethis}`}>
<h2 className='heading heading__secondary'>More like this</h2>
<ul className={styles.container}>
{data.map(title => (
<li key={title.id}>
<Link href={`/title/${title.id}`}>
<a className={styles.item}>
<div className={styles.item__imgContainer}>
{title.poster ? (
<Image
src={modifyIMDbImg(title.poster.url, 400)}
alt=''
fill
className={styles.item__img}
sizes='200px'
/>
) : (
<svg className={styles.item__imgNA}>
<use href='/svg/sprite.svg#icon-image-slash' />
</svg>
)}
</div>
<div className={styles.item__textContainer}>
<h3 className={`heading ${styles.item__heading}`}>
{title.title}
</h3>
{title.ratings.avg && (
<p className={styles.item__rating}>
<span className={styles.item__ratingNum}>
{title.ratings.avg}
</span>
<svg className={styles.item__ratingIcon}>
<use href='/svg/sprite.svg#icon-rating'></use>
</svg>
<span>
({formatNumber(title.ratings.numVotes)} votes)
</span>
</p>
)}
</div>
</a>
</Link>
</li>
<CardTitle
key={title.id}
link={`/title/${title.id}`}
name={title.title}
titleType={title.type.text}
image={title.poster?.url}
year={title.releaseYear}
ratings={title.ratings}
/>
))}
</ul>
</section>

View File

@ -1,9 +1,6 @@
import Basic from './Basic';
import Cast from './Cast';
import DidYouKnow from './DidYouKnow';
import Info from './Info';
import Media from './Media';
import MoreLikeThis from './MoreLikeThis';
import Reviews from './Reviews';
export { Basic, Cast, DidYouKnow, Info, Media, MoreLikeThis, Reviews };
export { default as Basic } from './Basic';
export { default as Cast } from './Cast';
export { default as DidYouKnow } from './DidYouKnow';
export { default as Info } from './Info';
export { default as MoreLikeThis } from './MoreLikeThis';
export { default as Reviews } from './Reviews';

View File

@ -5,9 +5,9 @@ const getInitialTheme = () => {
// for server-side rendering, as window isn't availabe there
if (typeof window === 'undefined') return 'light';
const userPrefersTheme = isLocalStorageAvailable()
? window.localStorage.getItem('theme')
: null;
const userPrefersTheme = (
isLocalStorageAvailable() ? window.localStorage.getItem('theme') : null
) as 'light' | 'dark' | null;
const browserPrefersDarkTheme = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
@ -28,7 +28,7 @@ const updateMetaTheme = () => {
const initialContext = {
theme: '',
setTheme: (theme: string) => {},
setTheme: (theme: ReturnType<typeof getInitialTheme>) => { },
};
export const themeContext = createContext(initialContext);
@ -36,7 +36,7 @@ export const themeContext = createContext(initialContext);
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [curTheme, setCurTheme] = useState(getInitialTheme);
const setTheme = (theme: string) => {
const setTheme = (theme: typeof curTheme) => {
setCurTheme(theme);
if (isLocalStorageAvailable()) window.localStorage.setItem('theme', theme);
document.documentElement.dataset.theme = theme;

View File

@ -0,0 +1,3 @@
import type Name from './name';
export type Media = Name['media']; // exactly the same in title and name

View File

@ -1,8 +1,6 @@
import cleanTitle from 'src/utils/cleaners/title';
import title from 'src/utils/fetchers/title';
export type AxiosTitleRes = Awaited<ReturnType<typeof title>>;
// for full title
type Title = ReturnType<typeof cleanTitle>;
export type { Title as default };

View File

@ -2,35 +2,32 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import styles from '../styles/modules/layout/footer.module.scss';
const links = [
{ path: '/about', text: 'About' },
{ path: '/find', text: 'Find' },
{ path: '/privacy', text: 'Privacy' },
{ path: '/contact', text: 'Contact' },
] as const;
const Footer = () => {
const { pathname } = useRouter();
const className = (link: string) =>
pathname === link ? styles.nav__linkActive : styles.nav__link;
return (
<footer id='footer' className={styles.footer}>
<nav aria-label='primary navigation' className={styles.nav}>
<ul className={styles.list}>
<li className={styles.nav__item}>
<Link href='/about'>
<a className={className('/about')}>About</a>
</Link>
</li>
<li className={styles.nav__item}>
<Link href='/find'>
<a className={className('/find')}>Search</a>
</Link>
</li>
<li className={styles.nav__item}>
<Link href='/privacy'>
<a className={className('/privacy')}>Privacy</a>
</Link>
</li>
<li className={styles.nav__item}>
<Link href='/contact'>
<a className={className('/contact')}>Contact</a>
</Link>
</li>
{links.map(link => (
<li className={styles.nav__item} key={link.path}>
<Link href={link.path}>
<a
className={styles.nav__link}
aria-current={pathname === link.path ? 'page' : undefined}
>
{link.text}
</a>
</Link>
</li>
))}
<li className={styles.nav__item}>
<a href='#' className={styles.nav__link}>
Back to top
@ -39,7 +36,7 @@ const Footer = () => {
</ul>
</nav>
<p className={styles.licence}>
Licensed under&nbsp;
Licensed under{' '}
<a
className={styles.nav__link}
href='https://www.gnu.org/licenses/agpl-3.0-standalone.html'

View File

@ -1,10 +1,10 @@
import React from 'react';
import { ReactNode } from 'react';
import Footer from './Footer';
import Header from './Header';
type Props = {
full?: boolean;
children: React.ReactNode;
full?: true;
children: ReactNode;
className: string;
};

View File

@ -1,23 +1,20 @@
// external
import { GetServerSideProps, GetStaticProps, GetStaticPaths } from 'next';
import Head from 'next/head';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import Meta from 'src/components/meta/Meta';
import Layout from 'src/layouts/Layout';
import ErrorInfo from 'src/components/error/ErrorInfo';
// prettier-ignore
import { Basic, Cast, DidYouKnow, Info, Media, MoreLikeThis, Reviews } from 'src/components/title';
import Media from 'src/components/media/Media';
import { Basic, Cast, DidYouKnow, Info, MoreLikeThis, Reviews } from 'src/components/title';
import Title from 'src/interfaces/shared/title';
import { AppError } from 'src/interfaces/shared/error';
import title from 'src/utils/fetchers/title';
import { getProxiedIMDbImgUrl } from 'src/utils/helpers';
import styles from 'src/styles/modules/pages/title/title.module.scss';
type Props = { data: Title; error: null } | { error: AppError; data: null };
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
// TO-DO: make a wrapper page component to display errors, if present in props
const TitleInfo = ({ data, error }: Props) => {
if (error)
return <ErrorInfo message={error.message} statusCode={error.statusCode} />;
if (error) return <ErrorInfo message={error.message} statusCode={error.statusCode} />;
const info = {
meta: data.meta,
@ -31,21 +28,10 @@ const TitleInfo = ({ data, error }: Props) => {
return (
<>
<Meta
title={`${data.basic.title} (${
data.basic.releaseYear?.start || data.basic.type.name
})`}
description={data.basic.plot || undefined}
title={`${data.basic.title} (${data.basic.releaseYear?.start || data.basic.type.name})`}
description={data.basic.plot ?? undefined}
imgUrl={data.basic.poster?.url && getProxiedIMDbImgUrl(data.basic.poster.url)}
/>
<Head>
<meta
title='og:image'
content={
data.basic.poster?.url
? getProxiedIMDbImgUrl(data.basic.poster?.url)
: '/icon-512.png'
}
/>
</Head>
<Layout className={styles.title}>
<Basic data={data.basic} className={styles.basic} />
<Media className={styles.media} media={data.media} />
@ -62,8 +48,11 @@ const TitleInfo = ({ data, error }: Props) => {
};
// TO-DO: make a getServerSideProps wrapper for handling errors
export const getServerSideProps: GetServerSideProps = async ctx => {
const titleId = ctx.params!.titleId as string;
type Data = { data: Title; error: null } | { error: AppError; data: null };
type Params = { titleId: string };
export const getServerSideProps: GetServerSideProps<Data, Params> = async ctx => {
const titleId = ctx.params!.titleId;
try {
const data = await title(titleId);

View File

@ -21,8 +21,8 @@ $breakpoints: (
);
// 1. colors
$clr-primary: hsl(240, 31%, 25%);
$clr-secondary: hsl(344, 79%, 40%);
$clr-tertiary: hsl(176, 43%, 46%);
$clr-quatenary: hsl(204, 4%, 23%);
$clr-quintenary: hsl(0, 0%, 100%);
// $clr-primary: hsl(240, 31%, 25%);
// $clr-secondary: hsl(344, 79%, 40%);
// $clr-tertiary: hsl(176, 43%, 46%);
// $clr-quatenary: hsl(204, 4%, 23%);
// $clr-quintenary: hsl(0, 0%, 100%);

View File

@ -22,17 +22,13 @@ $_light: (
// 4.2 for borders, primarily
fill-muted: hsl(0, 0%, 80%),
// shadows on cards
shadow: 0 0 1rem hsla(0, 0%, 0%, 0.2),
shadow: 0 0 0.5em hsla(0, 0%, 0%, 0.2),
// keyboard, focus hightlight
highlight: hsl(176, 43%, 46%),
// for gradient behind hero text on about page.
gradient:
(
radial-gradient(
at 23% 32%,
hsla(344, 79%, 40%, 0.15) 0px,
transparent 70%
),
radial-gradient(at 23% 32%, hsla(344, 79%, 40%, 0.15) 0px, transparent 70%),
radial-gradient(at 72% 55%, hsla(344, 79%, 40%, 0.2) 0px, transparent 50%)
),
// changes color of native html elemnts, either 'light' or 'dark' must be set.

View File

@ -0,0 +1,97 @@
@use '../../../abstracts' as helper;
.container {
margin-inline: auto;
display: grid;
grid-template-columns: minmax(25rem, 30rem) 1fr;
@include helper.bp('bp-900') {
grid-template-columns: none;
grid-template-rows: 30rem min-content;
}
@include helper.bp('bp-700') {
grid-template-rows: 25rem min-content;
}
}
.imageContainer {
display: flex; // for bringing out image__NA out of blur
position: relative;
height: auto;
width: auto;
overflow: hidden;
background-size: cover;
background-position: top;
place-items: center;
@include helper.bp('bp-900') {
padding: var(--spacer-2);
isolation: isolate;
// for adding layer of color on top of background image
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to top,
var(--clr-bg-accent) 10%,
transparent
);
backdrop-filter: blur(8px);
}
}
}
.image {
object-fit: cover;
object-position: center;
@include helper.bp('bp-900') {
z-index: 1;
object-fit: contain;
outline: 3px solid var(--clr-fill);
outline-offset: 5px;
max-height: 100%;
margin: auto;
// overrriding nex/future/image defaults
height: initial !important;
width: initial !important;
position: relative !important;
}
}
.imageNA {
z-index: 1;
fill: var(--clr-fill-muted);
}
.info {
padding: var(--spacer-2) var(--spacer-4);
display: flex;
flex-direction: column;
gap: var(--spacer-2);
@include helper.bp('bp-900') {
// text-align: center;
// align-items: center;
}
@include helper.bp('bp-450') {
gap: var(--spacer-1);
}
}
.title {
line-height: 1;
@include helper.bp('bp-900') {
text-align: center;
}
}

View File

@ -0,0 +1,44 @@
.item {
height: 100%;
display: grid;
grid-template-rows: minmax(auto, 65%) auto;
text-decoration: none;
color: currentColor;
}
.imgContainer {
justify-self: stretch;
position: relative;
// for icon when image is unavailable
display: grid;
place-items: center;
}
.img {
height: 100%;
object-fit: cover;
}
.imgNA {
fill: var(--clr-fill-muted);
height: 40%;
}
.textContainer {
display: grid;
gap: var(--spacer-1);
padding: var(--spacer-1);
text-align: center;
justify-items: center;
align-content: start;
}
.name {
font-size: 1.2em;
}
.role {
font-size: .95em;
}

View File

@ -0,0 +1,62 @@
@use '../../../abstracts' as helper;
.item {
--width: 10rem;
--height: var(--width);
display: grid;
grid-template-columns: var(--width) auto;
text-decoration: none;
color: inherit;
@include helper.bp('bp-450') {
--height: 15rem;
grid-template-columns: auto;
}
}
.sansImage {
grid-template-columns: auto;
padding: var(--spacer-1);
.imgContainer {
display: none;
}
}
.imgContainer {
display: grid;
place-items: center;
min-height: var(--height);
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;
}

View File

@ -0,0 +1,56 @@
.item {
height: 100%;
display: grid;
grid-template-rows: minmax(auto, 65%) auto;
text-decoration: none;
color: currentColor;
}
.imgContainer {
justify-self: stretch;
position: relative;
// for icon when image is unavailable
display: grid;
place-items: center;
}
.img {
height: 100%;
object-fit: cover;
}
.imgNA {
fill: var(--clr-fill-muted);
height: 40%;
}
.textContainer {
display: grid;
gap: var(--spacer-1);
padding: var(--spacer-1);
text-align: center;
justify-items: center;
align-content: start;
}
.name {
font-size: 1.2em;
}
.rating {
display: flex;
align-items: center;
gap: var(--spacer-0);
line-height: 1;
flex-wrap: wrap;
justify-content: center;
}
.ratingIcon {
--dim: 1em;
height: var(--dim);
width: var(--dim);
fill: var(--clr-fill);
}

View File

@ -0,0 +1,11 @@
.card {
overflow: hidden;
border-radius: 5px;
background-color: var(--clr-bg-accent);
box-shadow: var(--clr-shadow);
}
.hoverable:hover,
.hoverable:focus-within {
background-color: var(--clr-bg-muted);
}

View File

@ -1,13 +0,0 @@
.company {
background: var(--clr-bg-accent);
box-shadow: var(--clr-shadow);
border-radius: 5px;
display: grid;
padding: var(--spacer-3);
gap: var(--spacer-0);
}
.heading {
font-size: var(--fs-4);
text-decoration: none;
}

View File

@ -1,13 +0,0 @@
.keyword {
background: var(--clr-bg-accent);
box-shadow: var(--clr-shadow);
border-radius: 5px;
display: grid;
padding: var(--spacer-3);
gap: var(--spacer-0);
}
.heading {
font-size: var(--fs-4);
text-decoration: none;
}

View File

@ -1,57 +1,4 @@
@use '../../../abstracts' as helper;
.person {
--width: 10rem;
--height: var(--width);
background: var(--clr-bg-accent);
box-shadow: var(--clr-shadow);
border-radius: 5px;
overflow: hidden; // for background image
display: grid;
grid-template-columns: var(--width) auto;
@include helper.bp('bp-450') {
--height: 15rem;
grid-template-columns: auto;
}
}
.imgContainer {
display: grid;
place-items: center;
min-height: var(--height);
}
.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);
}
}
.heading {
font-size: var(--fs-4);
text-decoration: none;
}
.basicInfo, .seriesInfo {
.basicInfo {
display: flex;
list-style: none;
flex-wrap: wrap;
@ -64,11 +11,3 @@
font-size: var(--fs-5);
}
}
.stars {
span {
font-weight: var(--fw-bold);
}
}

View File

@ -10,7 +10,10 @@
}
}
.titles, .people, .companies, .keywords {
.titles,
.people,
.companies,
.keywords {
display: grid;
gap: var(--spacer-2);
@ -18,8 +21,5 @@
padding: var(--spacer-2);
display: grid;
gap: var(--spacer-4);
// justify-self: start;
}
}

View File

@ -1,57 +1,5 @@
@use '../../../abstracts' as helper;
.title {
--width: 10rem;
--height: 10rem;
background: var(--clr-bg-accent);
box-shadow: var(--clr-shadow);
border-radius: 5px;
overflow: hidden; // for background image
display: grid;
grid-template-columns: var(--width) auto;
@include helper.bp('bp-450') {
--height: 15rem;
grid-template-columns: auto;
}
}
.imgContainer {
min-height: var(--height);
display: grid;
place-items: center;
position: relative;
}
.img {
object-fit: cover;
@include helper.bp('bp-450') {
object-position: center 25%;
}
}
.imgNA {
width: 80%;
fill: var(--clr-fill-muted);
}
.info {
display: grid;
gap: var(--spacer-0);
padding: var(--spacer-3);
@include helper.bp('bp-450') {
padding: var(--spacer-1);
}
}
.heading {
font-size: var(--fs-4);
text-decoration: none;
}
.basicInfo,
.seriesInfo {
@ -59,7 +7,7 @@
list-style: none;
flex-wrap: wrap;
& * + ::before {
& :not(:last-child)::after {
content: '\00b7';
padding-inline: var(--spacer-1);
font-weight: 900;

View File

@ -1,97 +1,5 @@
@use '../../../abstracts' as helper;
.container {
margin-inline: auto;
background: var(--clr-bg-accent);
box-shadow: var(--clr-shadow);
border-radius: 5px;
overflow: hidden; // for background image
display: grid;
grid-template-columns: minmax(25rem, 30rem) 1fr;
@include helper.bp('bp-900') {
grid-template-columns: none;
grid-template-rows: 30rem min-content;
}
@include helper.bp('bp-700') {
grid-template-rows: 25rem min-content;
}
}
.imageContainer {
display: flex; // for bringing out image__NA out of blur
position: relative;
height: auto;
width: auto;
overflow: hidden;
background-size: cover;
background-position: top;
place-items: center;
@include helper.bp('bp-900') {
padding: var(--spacer-2);
isolation: isolate;
// for adding layer of color on top of background image
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to top,
var(--clr-bg-accent) 10%,
transparent
);
backdrop-filter: blur(8px);
}
}
}
.image {
object-fit: cover;
object-position: center;
@include helper.bp('bp-900') {
z-index: 1;
object-fit: contain;
outline: 3px solid var(--clr-fill);
outline-offset: 5px;
max-height: 100%;
margin: auto;
// overrriding nex/future/image defaults
height: initial !important;
width: initial !important;
position: relative !important;
}
&__NA {
z-index: 1;
fill: var(--clr-fill-muted);
}
}
.info {
padding: var(--spacer-2) var(--spacer-4);
display: flex;
flex-direction: column;
gap: var(--spacer-2);
@include helper.bp('bp-900') {
text-align: center;
align-items: center;
}
@include helper.bp('bp-450') {
gap: var(--spacer-1);
}
}
.title {
line-height: 1;
}

View File

@ -24,53 +24,3 @@
--min-height: 30rem;
}
}
.member {
height: 100%;
display: grid;
grid-template-rows: minmax(auto, 70%) min-content auto;
justify-items: center;
text-align: center;
font-size: var(--fs-5);
overflow: hidden;
border-radius: 5px;
box-shadow: var(--clr-shadow);
background-color: var(--clr-bg-accent);
&__imgContainer {
justify-self: stretch;
position: relative;
// for icon when image is unavailable
display: grid;
place-items: center;
}
&__img {
height: 100%;
object-fit: cover;
}
&__imgNA {
fill: var(--clr-fill-muted);
height: 40%;
}
&__textContainer {
display: grid;
gap: var(--spacer-0);
padding: var(--spacer-0);
// place-content: center;
text-align: center;
justify-items: center;
align-content: start;
}
&__name {
@include helper.prettify-link(var(--clr-link));
}
&__role {
font-size: 0.9em;
}
}

View File

@ -29,77 +29,3 @@
min-height: 37rem;
}
}
.item {
overflow: hidden;
border-radius: 5px;
box-shadow: var(--clr-shadow);
height: 100%;
display: grid;
grid-template-rows: minmax(auto, 70%) auto;
background-color: var(--clr-bg-accent);
text-decoration: none;
color: currentColor;
&__imgContainer {
justify-self: stretch;
position: relative;
// for icon when image is unavailable
display: grid;
place-items: center;
}
&__textContainer {
display: grid;
gap: var(--spacer-1);
padding: var(--spacer-1);
// place-content: center;
text-align: center;
justify-items: center;
align-content: start;
}
&__img {
height: 100%;
object-fit: cover;
}
&__imgNA {
fill: var(--clr-fill-muted);
height: 40%;
// vertical-align: center;
}
&__heading {
}
&__genres {
}
&__rating {
// font-size: 0.9em;
display: flex;
align-items: center;
gap: var(--spacer-0);
line-height: 1;
flex-wrap: wrap;
justify-content: center;
}
&__ratingNum {
}
&__ratingIcon {
--dim: 1em;
height: var(--dim);
width: var(--dim);
fill: var(--clr-fill);
}
&:hover {
background-color: var(--clr-bg-muted);
}
}

View File

@ -23,10 +23,10 @@
&__link {
@include helper.prettify-link(var(--clr-link));
}
&__linkActive {
@include helper.prettify-link(var(--clr-link), $animate: false);
&[aria-current] {
@include helper.prettify-link(var(--clr-link), $animate: false);
}
}
}

View File

@ -1,11 +1,15 @@
/* eslint-disable no-unused-vars */
import Redis from 'ioredis';
const redisUrl = process.env.REDIS_URL;
const toUseRedis = process.env.USE_REDIS === 'true';
let redis: Redis | null;
const stub: Pick<Redis, 'get' | 'setex' | 'getBuffer'> = {
get: async key => Promise.resolve(null),
setex: async (key, seconds, value) => Promise.resolve('OK'),
getBuffer: (key, callback) => Promise.resolve(null),
};
if (toUseRedis && redisUrl) redis = new Redis(redisUrl);
else redis = null;
const redis = toUseRedis && redisUrl ? new Redis(redisUrl) : stub;
export default redis;