feat: major rewrite

the application is now rewritten in next.js. this commit also adds the ability to see trailers, did you know, more like this, etc. on title page.

BREAKING CHANGE: the whole application is rewritten from scratch.
This commit is contained in:
zyachel
2022-09-11 19:37:24 +05:30
committed by zyachel
parent 620ddf348a
commit 9891204f5a
129 changed files with 6314 additions and 4671 deletions

View File

@ -0,0 +1,48 @@
import Link from 'next/link';
import Layout from '../../layouts/Layout';
import Meta from '../Meta/Meta';
import styles from '../../styles/modules/components/error/error-info.module.scss';
// for details regarding the svg, go to sadgnu.svg file
// description copied verbatim from https://www.gnu.org/graphics/sventsitsky-sadgnu.html
// 404 idea from ninamori.org 404 page.
const ErrorInfo = ({ message = 'Not found, sorry.', statusCode = 404 }) => {
return (
<>
<Meta
title={`${message} (${statusCode})`}
description='you encountered an error page!'
/>
<Layout className={styles.error}>
<svg
className={styles.gnu}
focusable='false'
role='img'
aria-labelledby='gnu-title gnu-desc'
>
<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.
</desc>
<use href='/svg/sadgnu.svg#sad-gnu'></use>
</svg>
<h1 className={`heading heading__primary ${styles.heading}`}>
<span>{message}</span>
<span> ({statusCode})</span>
</h1>
<p className={styles.back}>
Go back to{' '}
<Link href='/about'>
<a className='link'>the homepage</a>
</Link>
.
</p>
</Layout>
</>
);
};
export default ErrorInfo;

View File

@ -0,0 +1,40 @@
import Head from 'next/head';
type Props = {
title: string;
description?: string;
imgUrl?: string;
};
const Meta = ({
title,
description = 'libremdb, a free & open source IMDb front-end.',
imgUrl = 'icon.svg',
}: Props) => {
return (
<Head>
<meta charSet='UTF-8' />
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title key='title'>{`${title} | libremdb`}</title>
<meta key='desc' name='description' content={description} />
<link rel='icon' href='/favicon.ico' sizes='any' />
<link rel='icon' href='/icon.svg' type='image/svg+xml' />
<link rel='apple-touch-icon' href='/apple-touch-icon.png' />
<link rel='manifest' href='/site.webmanifest' />
<meta name='theme-color' content='#ffe5ef' />
<meta property='og:title' content={title} />
<meta property='og:description' content={description} />
<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}`}
/>
</Head>
);
};
export default Meta;

View File

@ -0,0 +1,35 @@
import { useContext } from 'react';
import { themeContext } from '../../context/theme-context';
import styles from '../../styles/modules/components/buttons/themeToggler.module.scss';
type Props = {
className: string;
};
const ThemeToggler = (props: Props) => {
const { theme, setTheme } = useContext(themeContext);
const clickHandler = () => {
const themeToSet = theme === 'light' ? 'dark' : 'light';
setTheme(themeToSet);
};
return (
<button
className={`${styles.button} ${props.className}`}
aria-label='Change theme'
onClick={clickHandler}
>
<svg
className={`icon ${styles.icon}`}
focusable='false'
aria-hidden='true'
role='img'
>
<use href='/svg/sprite.svg#icon-theme-switcher'></use>
</svg>
</button>
);
};
export default ThemeToggler;

View File

@ -0,0 +1,6 @@
import styles from '../../styles/modules/components/loaders/progress-bar.module.scss';
const ProgressBar = () => {
return <span className={styles.progress} role='progressbar'></span>;
};
export default ProgressBar;

View File

@ -0,0 +1,153 @@
import { Fragment } from 'react';
import Image from 'next/future/image';
import Link from 'next/link';
import { formatNumber, formatTime, modifyIMDbImg } from '../../utils/helpers';
import { Basic } from '../../interfaces/shared/title';
import styles from '../../styles/modules/components/title/basic.module.scss';
type Props = {
className: string;
data: Basic;
};
const Basic = ({ data, className }: Props) => {
const titleType = data.type.id;
const releaseTime =
titleType === 'tvSeries'
? `${data.releaseYear?.start}-${data.releaseYear?.end || 'present'}`
: 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})` }}
className={`${styles.container} ${className}`}
>
<div
className={styles.imageContainer}
style={{
backgroundImage:
data.poster && `url(${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>
)}
</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.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 && (
<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>
)}
</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>
)}
{
<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>
);
};
export default Basic;

View File

@ -0,0 +1,56 @@
import Image from 'next/future/image';
import Link from 'next/link';
import { Cast } from '../../interfaces/shared/title';
import { modifyIMDbImg } from '../../utils/helpers';
import styles from '../../styles/modules/components/title/cast.module.scss';
type Props = {
className: string;
cast: Cast;
};
const Cast = ({ className, cast }: Props) => {
if (!cast.length) return <></>;
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>
))}
</ul>
</section>
);
};
export default Cast;

View File

@ -0,0 +1,106 @@
import Link from 'next/link';
import { Fragment } from 'react';
import { DidYouKnow } from '../../interfaces/shared/title';
import styles from '../../styles/modules/components/title/did-you-know.module.scss';
type Props = {
data: DidYouKnow;
};
const DidYouKnow = ({ data }: Props) => {
if (!Object.keys(data).length) return <></>;
return (
<section className={styles.didYouKnow}>
<h2 className='heading heading__secondary'>Did you know</h2>
<div className={styles.container}>
{data.trivia && (
<div className={styles.item}>
<h3 className='heading heading__tertiary'>Trivia</h3>
<div
className={styles.item__desc}
dangerouslySetInnerHTML={{ __html: data.trivia.html }}
></div>
</div>
)}
{data.goofs && (
<div className={styles.item}>
<h3 className='heading heading__tertiary'>Goofs</h3>
<div
className={styles.item__desc}
dangerouslySetInnerHTML={{ __html: data.goofs.html }}
></div>
</div>
)}
{data.quotes?.lines.length && (
// html spec says not to use blockquote & cite for conversations, even though it seems a perfect choice here.
// see 'note' part https://html.spec.whatwg.org/multipage/grouping-content.html#the-blockquote-element
<div className={styles.item}>
<h3 className='heading heading__tertiary'>Quotes</h3>
{data.quotes.lines.map((line, i) => (
<div className={styles.quotes} key={i}>
<p className={styles.quote}>
{line.name && (
<Link href={`/name/${line.id}`}>
<a className={'link'}>{line.name}</a>
</Link>
)}
{line.stageDirection && <i> [{line.stageDirection}] </i>}
{line.text && <span>: {line.text}</span>}
</p>
</div>
))}
</div>
)}
{data.crazyCredits && (
<div className={styles.item}>
<h3 className='heading heading__tertiary'>Crazy credits</h3>
<div
className={styles.item__desc}
dangerouslySetInnerHTML={{ __html: data.crazyCredits.html }}
></div>
</div>
)}
{data.alternativeVersions && (
<div className={styles.item}>
<h3 className='heading heading__tertiary'>Alternate versions</h3>
<div
className={styles.item__desc}
dangerouslySetInnerHTML={{
__html: data.alternativeVersions.html,
}}
></div>
</div>
)}
{data.connections && (
<div className={styles.item}>
<h3 className='heading heading__tertiary'>Connections</h3>
<p className={styles.item__desc}>
<span>{data.connections.startText} </span>
<Link href={`/title/${data.connections.title.id}`}>
<a className={'link'}>{data.connections.title.text}</a>
</Link>
<span> ({data.connections.title.year})</span>
</p>
</div>
)}
{data.soundTrack && (
<div className={styles.item}>
<h3 className='heading heading__tertiary'>Soundtracks</h3>
<div className={styles.list}>
<p>{data.soundTrack.title}</p>
{data.soundTrack.htmls &&
data.soundTrack.htmls.map(html => (
<div
key={html}
className={styles.item__desc}
dangerouslySetInnerHTML={{ __html: html }}
></div>
))}
</div>
</div>
)}
</div>
</section>
);
};
export default DidYouKnow;

View File

@ -0,0 +1,333 @@
import Link from 'next/link';
import { NextRouter } from 'next/router';
import { Info } from '../../interfaces/shared/title';
import { formatMoney, formatTime } from '../../utils/helpers';
import styles from '../../styles/modules/components/title/info.module.scss';
type Props = {
info: Info;
className: string;
router: NextRouter;
};
const Info = ({ info, className, router }: Props) => {
const { titleId } = router.query;
const { boxOffice, details, meta, keywords, technicalSpecs, accolades } =
info;
return (
<div className={`${className} ${styles.info}`}>
{meta.infoEpisode && (
<section className={styles.episodeInfo}>
<h2 className='heading heading__secondary'>Episode info</h2>
<div className={styles.episodeInfo__container}>
{meta.infoEpisode.numSeason && (
<p className={styles.series}>
<span>Season: </span>
<span>{meta.infoEpisode.numSeason}</span>
</p>
)}
{meta.infoEpisode.numEpisode && (
<p>
<span>Episode: </span>
<span>{meta.infoEpisode.numEpisode}</span>
</p>
)}
<p>
<span>Series: </span>
<span>
<Link href={`/title/${meta.infoEpisode.series.id}`}>
<a className={'link'}>{meta.infoEpisode.series.title}</a>
</Link>
<span>
{' '}
({meta.infoEpisode.series.startYear}-
{meta.infoEpisode.series.endYear || 'present'})
</span>
</span>
</p>
{meta.infoEpisode.prevId && (
<p>
<Link href={`/title/${meta.infoEpisode.prevId}`}>
<a className='link'>Go to previous episode</a>
</Link>
</p>
)}
{meta.infoEpisode.nextId && (
<p>
<Link href={`/title/${meta.infoEpisode.nextId}`}>
<a className='link'>Go to next episode</a>
</Link>
</p>
)}
</div>
</section>
)}
{meta.infoSeries && (
<section className={styles.seriesInfo}>
<h2 className='heading heading__secondary'>Series info</h2>
<div className={styles.seriesInfo__container}>
<p>
<span>Total Seasons: </span>
<span>{meta.infoSeries.seasons.length}</span>
</p>
<p>
<span>Total Years: </span>
<span>{meta.infoSeries.years.length}</span>
</p>
<p>
<span>Total Episodes: </span>
<span>{meta.infoSeries.totalEpisodes}</span>
</p>
<p>
<Link href={`/title/${titleId}/episodes`}>
<a className='link'>See all Episodes</a>
</Link>
</p>
</div>
</section>
)}
<section className={styles.accolades}>
<h2 className='heading heading__secondary'>Accolades</h2>
<div className={styles.accolades__container}>
{accolades.topRating && (
<p>
<Link href={`/chart/top`}>
<a className='link'>Top rated (#{accolades.topRating})</a>
</Link>
</p>
)}
{accolades.awards && (
<p>
<span>
Won {accolades.awards.wins} {accolades.awards.name}
</span>
<span> (out of {accolades.awards.nominations} nominations)</span>
</p>
)}
<p>
{accolades.wins} wins and {accolades.nominations} nominations in
total
</p>
<p>
<Link href={`/title/${titleId}/awards`}>
<a className='link'>View all awards</a>
</Link>
</p>
</div>
</section>
{!!keywords.total && (
<section className={styles.keywords}>
<h2 className='heading heading__secondary'>Keywords</h2>
<ul className={styles.keywords__container}>
{keywords.list.map(word => (
<li className={styles.keywords__item} key={word}>
<Link
href={`/search/keyword/?keywords=${word.replaceAll(
' ',
'-'
)}`}
>
<a className='link'>{word}</a>
</Link>
</li>
))}
</ul>
</section>
)}
{!!Object.keys(details).length && (
<section className={styles.details}>
<h2 className='heading heading__secondary'>Details</h2>
<div className={styles.details__container}>
{details.releaseDate && (
<p>
<span>Release date: </span>
<time dateTime={details.releaseDate.date}>
{details.releaseDate.date}
</time>
<span> ({details.releaseDate.country.text})</span>
</p>
)}
{details.countriesOfOrigin && (
<p>
<span>Countries of origin: </span>
{details.countriesOfOrigin.map((country, i) => (
<span key={country.id}>
{!!i && ', '}
<Link
href={`/search/title/?country_of_origin=${country.id}`}
>
<a className='link'>{country.text}</a>
</Link>
</span>
))}
</p>
)}
{details.officialSites && (
<p>
<span>Official sites: </span>
{details.officialSites.sites.map((site, i) => (
<span key={site.url}>
{!!i && ', '}
<a href={site.url} className='link'>
{site.name}
</a>
</span>
))}
</p>
)}
{details.languages?.length && (
<p>
<span>Languages: </span>
{details.languages.map((lang, i) => (
<span key={lang.id}>
{!!i && ', '}
<Link href={`/search/title/?primary_language=${lang.id}`}>
<a className='link'>{lang.text}</a>
</Link>
</span>
))}
</p>
)}
{details.alsoKnownAs && (
<p>
<span>Also known as: </span>
<span>{details.alsoKnownAs}</span>
</p>
)}
{details.filmingLocations?.total && (
<p>
<span>Filming locations: </span>
{details.filmingLocations.locations.map((loc, i) => (
<span key={loc}>
{!!i && ', '}
<Link href={`/search/title/?locations=${loc}`}>
<a className='link'>{loc}</a>
</Link>
</span>
))}
</p>
)}
{!!details.production?.total && (
<p>
<span>Production companies: </span>
{details.production.companies.map((co, i) => (
<span key={co.id}>
{!!i && ', '}
<Link href={`/company/${co.id}`}>
<a className='link'>{co.name}</a>
</Link>
</span>
))}
</p>
)}
</div>
</section>
)}
{!!Object.keys(boxOffice).length && (
<section className={styles.boxoffice}>
<h2 className='heading heading__secondary'>Box office</h2>
<div className={styles.boxoffice__container}>
{boxOffice.budget && (
<p>
<span>Budget: </span>
<span>
{formatMoney(
boxOffice.budget.amount,
boxOffice.budget.currency
)}
</span>
</p>
)}
{boxOffice.grossUs && (
<p>
<span>Gross US & Canada: </span>
<span>
{formatMoney(
boxOffice.grossUs.amount,
boxOffice.grossUs.currency
)}
</span>
</p>
)}
{boxOffice.openingGrossUs && (
<p>
<span>Opening weekend US & Canada: </span>
<span>
{formatMoney(
boxOffice.openingGrossUs.amount,
boxOffice.openingGrossUs.currency
)}
<span> ({boxOffice.openingGrossUs.date})</span>
</span>
</p>
)}
{boxOffice.gross && (
<p>
<span>Gross worldwide: </span>
<span>
{formatMoney(
boxOffice.gross.amount,
boxOffice.gross.currency
)}
</span>
</p>
)}
</div>
</section>
)}
{!!Object.keys(technicalSpecs).length && (
<section className={styles.technical}>
<h2 className='heading heading__secondary'>Technical specs</h2>
<div className={styles.technical__container}>
{technicalSpecs.runtime && (
<p>
<span>Runtime: </span>
<span>{formatTime(technicalSpecs.runtime)}</span>
</p>
)}
{!!technicalSpecs.colorations?.length && (
<p>
<span> Color: </span>
<span>
{technicalSpecs.colorations.map((color, i) => (
<span key={color.id}>
{!!i && ', '}
<Link href={`/search/title/?colors=${color.id}`}>
<a className='link'>{color.name}</a>
</Link>
</span>
))}
</span>
</p>
)}
{!!technicalSpecs.soundMixes?.length && (
<p>
<span>Sound mix: </span>
<span>
{technicalSpecs.soundMixes?.map((sound, i) => (
<span key={sound.id}>
{!!i && ', '}
<Link href={`/search/title/?sound_mixes=${sound.id}`}>
<a className='link'>{sound.name}</a>
</Link>
</span>
))}
</span>
</p>
)}
{!!technicalSpecs.aspectRatios?.length && (
<p>
<span>Aspect ratio: </span>
<span>{technicalSpecs.aspectRatios.join(', ')}</span>
</p>
)}
</div>
</section>
)}
</div>
);
};
export default Info;

View File

@ -0,0 +1,90 @@
import Image from 'next/future/image';
import Link from 'next/link';
import { NextRouter } from 'next/router';
import { Media } from '../../interfaces/shared/title';
import { modifyIMDbImg } from '../../utils/helpers';
import styles from '../../styles/modules/components/title/media.module.scss';
type Props = {
className: string;
media: Media;
router: NextRouter;
};
const Media = ({ className, media, router }: Props) => {
return (
<div className={`${className} ${styles.media}`}>
{(media.trailer || !!media.videos.total) && (
<section className={styles.videos}>
<h2 className='heading heading__secondary'>Videos</h2>
<div className={styles.videos__container}>
{media.trailer && (
<div key={router.asPath} 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={modifyIMDbImg(media.trailer.thumbnail)}
className={styles.trailer__video}
>
{media.trailer.urls.map(source => (
<source
key={source.url}
type={source.mimeType}
src={source.url}
data-res={source.resolution}
/>
))}
</video>
</div>
)}
{!!media.videos.total &&
media.videos.videos.map(video => (
<Link href={`/video/${video.id}`} key={video.id}>
<a className={styles.video}>
<Image
className={styles.video__img}
src={modifyIMDbImg(video.thumbnail)}
alt=''
fill
sizes='400px'
/>
<p className={styles.video__caption}>
{video.caption} ({video.runtime}s)
</p>
</a>
</Link>
))}
</div>
</section>
)}
{!!media.images.total && (
<section className={styles.images}>
<h2 className='heading heading__secondary'>Images</h2>
<div className={styles.images__container}>
{media.images.images.map(image => (
<figure key={image.id} className={styles.image}>
<Image
className={styles.image__img}
src={modifyIMDbImg(image.url)}
alt=''
fill
sizes='400px'
/>
<figcaption className={styles.image__caption}>
{image.caption.plainText}
</figcaption>
</figure>
))}
</div>
</section>
)}
</div>
);
};
export default Media;

View File

@ -0,0 +1,64 @@
import Image from 'next/future/image';
import Link from 'next/link';
import { MoreLikeThis } from '../../interfaces/shared/title';
import { formatNumber, modifyIMDbImg } from '../../utils/helpers';
import styles from '../../styles/modules/components/title/more-like-this.module.scss';
type Props = {
className: string;
data: MoreLikeThis;
};
const MoreLikeThis = ({ className, data }: Props) => {
if (!data.length) return <></>;
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>
))}
</ul>
</section>
);
};
export default MoreLikeThis;

View File

@ -0,0 +1,82 @@
import { NextRouter } from 'next/router';
import Link from 'next/link';
import { Reviews } from '../../interfaces/shared/title';
import { formatNumber } from '../../utils/helpers';
import styles from '../../styles/modules/components/title/reviews.module.scss';
type Props = {
reviews: Reviews;
router: NextRouter;
};
const Reviews = ({ reviews, router }: Props) => {
const { titleId } = router.query;
return (
<section className={styles.reviews}>
<h2 className='heading heading__secondary'>Reviews</h2>
{reviews.featuredReview && (
<article className={styles.reviews__reviewContainer}>
<details className={styles.review}>
<summary className={styles.review__summary}>
<strong>{reviews.featuredReview.review.summary}</strong>
</summary>
<div
className={styles.review__text}
dangerouslySetInnerHTML={{
__html: reviews.featuredReview.review.html,
}}
></div>
</details>
<footer className={styles.review__metadata}>
<p>
{reviews.featuredReview.rating && (
<span>Rated {reviews.featuredReview.rating}/10</span>
)}
<span>
{' '}
by{' '}
<Link href={`/user/${reviews.featuredReview.reviewer.id}`}>
<a className='link'>{reviews.featuredReview.reviewer.name}</a>
</Link>
</span>
<span> on {reviews.featuredReview.date}.</span>
</p>
<p>
<span>
{formatNumber(reviews.featuredReview.votes.up)} upvotes
</span>
<span>
, {formatNumber(reviews.featuredReview.votes.down)} downvotes
</span>
</p>
</footer>
</article>
)}
<div className={styles.reviews__stats}>
<p>
<Link href={`/title/${titleId}/reviews`}>
<a className='link'>
{formatNumber(reviews.numUserReviews)} User reviews
</a>
</Link>
</p>
<p>
<Link href={`/title/${titleId}/externalreviews`}>
<a className='link'>
{formatNumber(reviews.numCriticReviews)} Critic reviews
</a>
</Link>
</p>
<p>
<Link href={`/title/${titleId}/criticreviews`}>
<a className='link'> {reviews.metacriticScore} Metascore</a>
</Link>
</p>
</div>
</section>
);
};
export default Reviews;

View File

@ -0,0 +1,55 @@
import React, { useState, createContext, ReactNode } from 'react';
const getInitialTheme = () => {
// for server-side rendering, as window isn't availabe there
if (typeof window === 'undefined') return 'light';
const userPrefersTheme = window.localStorage.getItem('theme') || null;
const browserPrefersDarkTheme = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
if (userPrefersTheme) return userPrefersTheme;
else if (browserPrefersDarkTheme) return 'dark';
else return 'light';
};
const updateMetaTheme = () => {
const meta = document.querySelector(
'meta[name="theme-color"]'
) as HTMLMetaElement;
const footerClr = window.getComputedStyle(document.body).backgroundColor;
meta.content = footerClr;
};
const initialContext = {
theme: '',
setTheme: (theme: string) => {},
};
export const themeContext = createContext(initialContext);
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [curTheme, setCurTheme] = useState(getInitialTheme);
const setTheme = (theme: string) => {
setCurTheme(theme);
window.localStorage.setItem('theme', theme);
document.documentElement.dataset.theme = theme;
updateMetaTheme();
};
const providerValue = {
theme: curTheme,
setTheme: setTheme,
};
return (
<themeContext.Provider value={providerValue}>
{children}
</themeContext.Provider>
);
};
export default ThemeProvider;

View File

@ -0,0 +1,832 @@
export default interface RawTitle {
props: {
pageProps: {
aboveTheFoldData: {
id: string;
productionStatus: {
currentProductionStage: {
id: string;
text: string;
};
productionStatusHistory?: Array<{
status: {
id: string;
text: string;
};
}>;
restriction?: {
restrictionReason: Array<string>;
unrestrictedTotal: number;
};
};
canHaveEpisodes: boolean;
series?: {
episodeNumber: {
episodeNumber: number;
seasonNumber: number;
};
nextEpisode: {
id: string;
};
previousEpisode:
| {
id: string;
}
| undefined;
series: {
id: string;
titleText: {
text: string;
};
originalTitleText: {
text: string;
};
titleType: {
id: string;
};
releaseYear: {
year: number;
endYear: any;
};
};
};
titleText: {
text: string;
};
titleType: {
text: string;
id: string;
isSeries: boolean;
isEpisode: boolean;
};
originalTitleText: {
text: string;
};
certificate?: {
rating: string;
};
releaseYear?: {
year: number;
endYear: any;
};
releaseDate?: {
day: number;
month: number;
year: number;
};
runtime?: {
seconds: number;
};
canRate: {
isRatable: boolean;
};
ratingsSummary: {
aggregateRating?: number;
voteCount: number;
};
meterRanking?: {
currentRank: number;
rankChange: {
changeDirection: string;
difference: number;
};
};
primaryImage?: {
id: string;
width: number;
height: number;
url: string;
caption: {
plainText: string;
};
};
images: {
total: number;
};
videos: {
total: number;
};
primaryVideos: {
edges: Array<{
node: {
id: string;
isMature: boolean;
contentType: {
id: string;
displayName: {
value: string;
};
};
thumbnail: {
url: string;
height: number;
width: number;
};
runtime: {
value: number;
};
description: {
value: string;
language: string;
};
name: {
value: string;
language: string;
};
playbackURLs: Array<{
displayName: {
value: string;
language: string;
};
mimeType: string;
url: string;
}>;
previewURLs: Array<{
displayName: {
value: string;
language: string;
};
mimeType: string;
url: string;
}>;
};
}>;
};
externalLinks: {
total: number;
};
metacritic?: {
metascore: {
score: number;
};
};
keywords: {
total: number;
edges: Array<{
node: {
text: string;
};
}>;
};
genres: {
genres: Array<{
text: string;
id: string;
}>;
};
plot?: {
plotText?: {
plainText: string;
};
language?: {
id: string;
};
};
plotContributionLink: {
url: string;
};
credits: {
total: number;
};
principalCredits: Array<{
totalCredits: number;
category: {
text: string;
id: string;
};
credits: Array<{
name: {
nameText: {
text: string;
};
id: string;
};
attributes?: Array<{
text: string;
}>;
}>;
}>;
reviews: {
total: number;
};
criticReviewsTotal: {
total: number;
};
triviaTotal: {
total: number;
};
meta: {
canonicalId: string;
publicationStatus: string;
};
castPageTitle: {
edges: Array<{
node: {
name: {
nameText: {
text: string;
};
};
};
}>;
};
creatorsPageTitle: Array<{
credits: Array<{
name: {
nameText: {
text: string;
};
};
}>;
}>;
directorsPageTitle: Array<{
credits: Array<{
name: {
nameText: {
text: string;
};
};
}>;
}>;
countriesOfOrigin?: {
countries: Array<{
id: string;
}>;
};
production: {
edges: Array<{
node: {
company: {
id: string;
companyText: {
text: string;
};
};
};
}>;
};
featuredReviews: {
edges: Array<{
node: {
author: {
nickName: string;
};
summary: {
originalText: string;
};
text: {
originalText: {
plainText: string;
};
};
authorRating: number;
submissionDate: string;
};
}>;
};
};
mainColumnData: {
id: string;
wins: {
total: number;
};
nominations: {
total: number;
};
prestigiousAwardSummary?: {
nominations: number;
wins: number;
award: {
text: string;
id: string;
event: {
id: string;
};
};
};
ratingsSummary: {
topRanking?: {
id: string;
text: {
value: string;
};
rank: number;
};
};
episodes?: {
episodes: {
total: number;
};
seasons: Array<{
number: number;
}>;
years: Array<{
year: number;
}>;
totalEpisodes: {
total: number;
};
topRated: {
edges: Array<{
node: {
ratingsSummary: {
aggregateRating: number;
};
};
}>;
};
};
videos: {
total: number;
};
videoStrip: {
edges: Array<{
node: {
id: string;
contentType: {
displayName: {
value: string;
};
};
name: {
value: string;
};
runtime: {
value: number;
};
thumbnail: {
height: number;
url: string;
width: number;
};
};
}>;
};
titleMainImages: {
total: number;
edges: Array<{
node: {
id: string;
url: string;
caption: {
plainText: string;
};
height: number;
width: number;
};
}>;
};
productionStatus: {
currentProductionStage: {
id: string;
text: string;
};
productionStatusHistory?: Array<{
status: {
id: string;
text: string;
};
}>;
restriction?: {
restrictionReason: Array<string>;
};
};
primaryImage?: {
id: string;
};
imageUploadLink?: {
url: string;
};
titleType: {
id: string;
canHaveEpisodes: boolean;
};
cast: {
edges: Array<{
node: {
name: {
id: string;
nameText: {
text: string;
};
primaryImage?: {
url: string;
width: number;
height: number;
};
};
attributes?: Array<{
text: string;
}>;
characters?: Array<{
name: string;
}>;
episodeCredits: {
total: number;
yearRange?: {
year: number;
endYear: number;
};
};
};
}>;
};
creators: Array<{
totalCredits: number;
category: {
text: string;
};
credits: Array<{
name: {
id: string;
nameText: {
text: string;
};
};
attributes: any;
}>;
}>;
directors: Array<{
totalCredits: number;
category: {
text: string;
};
credits: Array<{
name: {
id: string;
nameText: {
text: string;
};
};
attributes: any;
}>;
}>;
writers: Array<{
totalCredits: number;
category: {
text: string;
};
credits: Array<{
name: {
id: string;
nameText: {
text: string;
};
};
attributes?: Array<{
text: string;
}>;
}>;
}>;
isAdult: boolean;
moreLikeThisTitles: {
edges: Array<{
node: {
id: string;
titleText: {
text: string;
};
titleType: {
id: string;
text: string;
};
originalTitleText: {
text: string;
};
primaryImage?: {
id: string;
width: number;
height: number;
url: string;
};
releaseYear?: {
year: number;
endYear?: number;
};
ratingsSummary: {
aggregateRating?: number;
voteCount: number;
};
runtime?: {
seconds: number;
};
certificate?: {
rating: string;
};
canRate: {
isRatable: boolean;
};
titleCardGenres: {
genres: Array<{
text: string;
}>;
};
canHaveEpisodes: boolean;
};
}>;
};
triviaTotal: {
total: number;
};
trivia: {
edges: Array<{
node: {
text: {
plaidHtml: string;
};
trademark: any;
relatedNames: any;
};
}>;
};
goofsTotal: {
total: number;
};
goofs: {
edges: Array<{
node: {
text: {
plaidHtml: string;
};
};
}>;
};
quotesTotal: {
total: number;
};
quotes: {
edges: Array<{
node: {
lines: Array<{
characters?: Array<{
character: string;
name?: {
id: string;
};
}>;
text: string;
stageDirection?: string;
}>;
};
}>;
};
crazyCredits: {
edges: Array<{
node: {
text: {
plaidHtml: string;
};
};
}>;
};
alternateVersions: {
total: number;
edges: Array<{
node: {
text: {
plaidHtml: string;
};
};
}>;
};
connections: {
edges: Array<{
node: {
associatedTitle: {
id: string;
releaseYear: {
year: number;
};
titleText: {
text: string;
};
originalTitleText: {
text: string;
};
series: any;
};
category: {
text: string;
};
};
}>;
};
soundtrack: {
edges: Array<{
node: {
text: string;
comments?: Array<{
plaidHtml: string;
}>;
};
}>;
};
titleText: {
text: string;
};
originalTitleText: {
text: string;
};
releaseYear?: {
year: number;
};
reviews: {
total: number;
};
featuredReviews: {
edges: Array<{
node: {
id: string;
author: {
nickName: string;
userId: string;
};
summary: {
originalText: string;
};
text: {
originalText: {
plaidHtml: string;
};
};
authorRating: number;
submissionDate: string;
helpfulness: {
upVotes: number;
downVotes: number;
};
};
}>;
};
canRate: {
isRatable: boolean;
};
iframeAddReviewLink: {
url: string;
};
faqsTotal: {
total: number;
};
faqs: {
edges: Array<{
node: {
id: string;
question: {
plainText: string;
};
};
}>;
};
releaseDate?: {
day: number;
month: number;
year: number;
country: {
id: string;
text: string;
};
};
countriesOfOrigin?: {
countries: Array<{
id: string;
text: string;
}>;
};
detailsExternalLinks: {
edges: Array<{
node: {
url: string;
label: string;
externalLinkRegion?: {
text: string;
};
};
}>;
total: number;
};
spokenLanguages: {
spokenLanguages: Array<{
id: string;
text: string;
}>;
};
akas: {
edges: Array<{
node: {
text: string;
};
}>;
};
filmingLocations: {
edges: Array<{
node: {
text: string;
};
}>;
total: number;
};
production: {
edges: Array<{
node: {
company: {
id: string;
companyText: {
text: string;
};
};
};
}>;
};
companies: {
total: number;
};
productionBudget?: {
budget: {
amount: number;
currency: string;
};
};
lifetimeGross?: {
total: {
amount: number;
currency: string;
};
};
openingWeekendGross?: {
gross: {
total: {
amount: number;
currency: string;
};
};
weekendEndDate: string;
};
worldwideGross?: {
total: {
amount: number;
currency: string;
};
};
technicalSpecifications: {
soundMixes: {
items: Array<{
id: string;
text: string;
attributes: Array<any>;
}>;
};
aspectRatios: {
items: Array<{
aspectRatio: string;
attributes: Array<any>;
}>;
};
colorations: {
items: Array<{
conceptId: string;
text: string;
attributes: Array<any>;
}>;
};
};
runtime?: {
seconds: number;
};
series?: {
series: {
runtime?: {
seconds: number;
};
};
};
canHaveEpisodes: boolean;
contributionQuestions: {
contributionLink: {
url: string;
};
edges: Array<{
node: {
entity: {
primaryImage?: {
url: string;
width: number;
height: number;
caption: {
plainText: string;
};
};
};
questionId: string;
questionText: {
plainText: string;
};
contributionLink: {
url: string;
};
};
}>;
};
};
};
};
}

View File

@ -0,0 +1,5 @@
export type AppError = {
message: string;
statusCode: number;
stack?: any;
};

View File

@ -0,0 +1,25 @@
import cleanTitle from '../../utils/cleaners/title';
import title from '../../utils/fetchers/title';
export type AxiosTitleRes = Awaited<ReturnType<typeof title>>;
// for full title
type Title = ReturnType<typeof cleanTitle>;
export type { Title as default };
export type Basic = Title['basic'];
export type Media = Title['media'];
export type Cast = Title['cast'];
export type DidYouKnow = Title['didYouKnow'];
export type Info = Pick<
Title,
'meta' | 'accolades' | 'keywords' | 'details' | 'boxOffice' | 'technicalSpecs'
>;
export type Reviews = Title['reviews'];
export type MoreLikeThis = Title['moreLikeThis'];

52
src/layouts/Footer.tsx Normal file
View File

@ -0,0 +1,52 @@
import { FC } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import styles from '../styles/modules/layout/footer.module.scss';
const Footer: FC = () => {
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='/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>
<li className={styles.nav__item}>
<a href='#' className={styles.nav__link}>
Back to top
</a>
</li>
</ul>
</nav>
<p className={styles.licence}>
Licensed under&nbsp;
<a
className={styles.nav__link}
href='https://www.gnu.org/licenses/agpl-3.0-standalone.html'
>
GNU AGPLv3
</a>
.
</p>
</footer>
);
};
export default Footer;

86
src/layouts/Header.tsx Normal file
View File

@ -0,0 +1,86 @@
import { ReactNode } from 'react';
// import dynamic from 'next/dynamic';
import Link from 'next/link';
import styles from '../styles/modules/layout/header.module.scss';
import ThemeToggler from '../components/buttons/ThemeToggler';
// const ThemeToggler = dynamic(
// () => import('../components/buttons/ThemeToggler'),
// { ssr: false }
// );
type Props = { full?: boolean; children?: ReactNode };
const Header = (props: Props) => {
return (
<header
id='header'
className={`${styles.header} ${props.full ? styles.header__about : ''}`}
>
<div className={styles.topbar}>
<Link href='/about'>
<a aria-label='go to homepage' className={styles.logo}>
<svg
className={styles.logo__icon}
focusable='false'
role='img'
aria-hidden='true'
>
<use href='/svg/sprite.svg#icon-logo'></use>
</svg>
<span className={styles.logo__text}>libremdb</span>
</a>
</Link>
{props.full && (
<nav className={styles.nav}>
<ul className={styles.nav__list}>
<li className={styles.nav__item}>
<a href='#features' className='link'>
Features
</a>
</li>
<li className={styles.nav__item}>
<a href='#faq' className='link'>
FAQs
</a>
</li>
<li className={styles.nav__item}>
<a href='https://github.com/zyachel/libremdb' className='link'>
Source
</a>
</li>
</ul>
</nav>
)}
<ThemeToggler className={styles.themeToggler} />
</div>
{props.full && (
<div className={styles.hero}>
<h1 className={`heading heading__primary ${styles.hero__text}`}>
A free & open source IMDb front-end
</h1>
<p className={styles.hero__more}>
inspired by projects like&nbsp;
<a href='https://codeberg.org/teddit/teddit' className='link'>
teddit
</a>
,&nbsp;
<a href='https://github.com/zedeus/nitter' className='link'>
nitter
</a>
,&nbsp; and&nbsp;
<a
href='https://github.com/digitalblossom/alternative-frontends'
className='link'
>
many others
</a>
.
</p>
</div>
)}
</header>
);
};
export default Header;

23
src/layouts/Layout.tsx Normal file
View File

@ -0,0 +1,23 @@
import React from 'react';
import Footer from './Footer';
import Header from './Header';
type Props = {
full?: boolean;
children: React.ReactNode;
className: string;
};
const Layout = ({ full, children, className }: Props) => {
return (
<>
<Header full={full} />
<main id='main' className={`main ${className}`}>
{children}
</main>
<Footer />
</>
);
};
export default Layout;

7
src/pages/404.tsx Normal file
View File

@ -0,0 +1,7 @@
import ErrorInfo from '../components/Error/ErrorInfo';
const Error404 = () => {
return <ErrorInfo />;
};
export default Error404;

6
src/pages/500.tsx Normal file
View File

@ -0,0 +1,6 @@
import ErrorInfo from '../components/Error/ErrorInfo';
const Error500 = () => {
return <ErrorInfo message='server messed up, sorry.' statusCode={500} />;
};
export default Error500;

40
src/pages/_app.tsx Normal file
View File

@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from 'react';
import type { AppProps } from 'next/app';
import { useRouter } from 'next/router';
import ProgressBar from '../components/loaders/ProgressBar';
import ThemeProvider from '../context/theme-context';
import '../styles/main.scss';
const ModifiedApp = ({ Component, pageProps }: AppProps) => {
// for showing progress bar
// could've used nprogress package, but didn't feel like it
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const handleStart = useCallback(() => setIsLoading(true), []);
const handleEnd = useCallback(() => setIsLoading(false), []);
useEffect(() => {
router.events.on('routeChangeStart', handleStart);
router.events.on('routeChangeComplete', handleEnd);
router.events.on('routeChangeError', handleEnd);
return () => {
router.events.off('routeChangeStart', handleStart);
router.events.off('routeChangeComplete', handleEnd);
router.events.off('routeChangeError', handleEnd);
};
}, [router, handleStart, handleEnd]);
//
return (
<ThemeProvider>
{isLoading && <ProgressBar />}
<Component {...pageProps} />
</ThemeProvider>
);
};
export default ModifiedApp;

38
src/pages/_document.tsx Normal file
View File

@ -0,0 +1,38 @@
import Document, { Html, Head, Main, NextScript } from 'next/document';
// for preventing Flash of inAccurate coloR Theme(fart)
// chris coyier came up with that acronym(https://css-tricks.com/flash-of-inaccurate-color-theme-fart/)
const setInitialTheme = `
document.documentElement.dataset.js = true;
document.documentElement.dataset.theme = (() => {
const userPrefersTheme = window.localStorage.getItem('theme') || null;
const browserPrefersDarkTheme = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
if (userPrefersTheme) return userPrefersTheme;
else if (browserPrefersDarkTheme) return 'dark';
else return 'light';
})();
`;
const ModifiedDocument = class extends Document {
static async getInitialProps(ctx: any) {
const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps };
}
render() {
return (
<Html lang='en'>
<Head />
<body>
<script dangerouslySetInnerHTML={{ __html: setInitialTheme }} />
<Main />
<NextScript />
</body>
</Html>
);
}
};
export default ModifiedDocument;

176
src/pages/about/index.tsx Normal file
View File

@ -0,0 +1,176 @@
/* eslint-disable react/no-unescaped-entities */
import Link from 'next/link';
import Meta from '../../components/Meta/Meta';
import Layout from '../../layouts/Layout';
import styles from '../../styles/modules/pages/about/about.module.scss';
const About = () => {
return (
<>
<Meta
title='About'
description='libremdb is a free & open source IMDb front-end. It allows you to see information about movies, tv shows, video games without any ads or tracking.'
/>
<Layout full className={styles.about}>
<section id='features' className={styles.features}>
<h2
className={`heading heading__secondary ${styles.features__heading}`}
>
Some features
</h2>
<ul className={styles.features__list}>
<li className={styles.feature}>
<svg
aria-hidden='true'
focusable='false'
role='img'
className={styles.feature__icon}
>
<use href='/svg/sprite.svg#icon-eye-slash'></use>
</svg>
<h3
className={`heading heading__tertiary ${styles.feature__heading}`}
>
No ads or tracking
</h3>
<p className={styles.feature__text}>
Browse any movie info without being tracked or bombarded by
annoying ads.
</p>
</li>
<li className={styles.feature}>
<svg
aria-hidden='true'
focusable='false'
role='img'
className={styles.feature__icon}
>
<use href='/svg/sprite.svg#icon-palette'></use>
</svg>
<h3
className={`heading heading__tertiary ${styles.feature__heading}`}
>
Modern interface
</h3>
<p className={styles.feature__text}>
Modern interface with curated colors supporting both dark and
light themes.
</p>
</li>
<li className={styles.feature}>
<svg
aria-hidden='true'
focusable='false'
role='img'
className={styles.feature__icon}
>
<use href='/svg/sprite.svg#icon-responsive'></use>
</svg>
<h3
className={`heading heading__tertiary ${styles.feature__heading}`}
>
Responsive design
</h3>
<p className={styles.feature__text}>
Be it your small mobile or big computer screen, it's fully
responsive.
</p>
</li>
</ul>
</section>
<section id='faq' className={styles.faqs}>
<h2 className={`heading heading__secondary ${styles.faqs__heading}`}>
Questions you may have
</h2>
<div className={styles.faqs__list}>
<details className={styles.faq}>
<summary className={styles.faq__summary}>Why is it slow?</summary>
<p className={styles.faq__description}>
Whenever you request info about a movie/show on libremdb, 4
trips are made(2 between your browser and libremdb's server, and
2 between libremdb's server and IMDb's server) instead of the
usual 2 trips when you visit a website. For this reason there's
a noticable delay. This is a bit of inconvenience you'll have to
face should you wish to use this website.
</p>
</details>
<details className={styles.faq}>
<summary className={styles.faq__summary}>
It doesn't have all routes.
</summary>
<p className={styles.faq__description}>
I'll implement more with time :)
</p>
</details>
<details className={styles.faq}>
<summary className={styles.faq__summary}>
I see connection being made to some Amazon domains.
</summary>
<p className={styles.faq__description}>
For now, images and videos are directly served from Amazon. If I
have enough time in the future, I'll implement a way to serve
the images from libremdb instead.
</p>
</details>
<details className={styles.faq}>
<summary className={styles.faq__summary}>
Will Amazon track me then?
</summary>
<p className={styles.faq__description}>
They may log your IP address, useragent, and other such
identifiers. I'd recommend using a VPN, or accessing the website
through TOR for mitigating this risk.
</p>
</details>
<details className={styles.faq}>
<summary className={styles.faq__summary}>
Why not just use IMDb?
</summary>
<p className={styles.faq__description}>
Refer to the{' '}
<a className='link' href='#features'>
features section
</a>{' '}
above.
</p>
</details>
<details className={styles.faq}>
<summary className={styles.faq__summary}>
Why didn't you use other databases like TMDB or OMDb?
</summary>
<p className={styles.faq__description}>
IMDb simply has superior dataset compared to all other
alternatives. With that being said, I'd encourage you to check
out those alternatives too.
</p>
</details>
<details className={styles.faq}>
<summary className={styles.faq__summary}>
Your website name is quite, ehm, lame.
</summary>
<p className={styles.faq__description}>
Let's just say I'm not very good at naming things.
</p>
</details>
<details className={styles.faq}>
<summary className={styles.faq__summary}>
I have some ideas/features/suggestions.
</summary>
<p className={styles.faq__description}>
That's great! I've a couple of{' '}
<Link href='/contact'>
<a className='link'>contact methods</a>
</Link>
. Send your beautiful suggestions(or complaints), or just drop a
hi.
</p>
</details>
</div>
</section>
</Layout>
</>
);
};
export default About;

View File

@ -0,0 +1,49 @@
import Meta from '../../components/Meta/Meta';
import Layout from '../../layouts/Layout';
import styles from '../../styles/modules/pages/contact/contact.module.scss';
const Contact = () => {
return (
<>
<Meta
title='Contact'
description='Contact page of libremdb, a free & open source IMDb front-end.'
/>
<Layout className=''>
<section className={styles.contact}>
<h1 className={`heading heading__primary ${styles.contact__heading}`}>
Contact
</h1>
<div className={styles.list}>
<p className={styles.item}>
You can use{' '}
<a href='https://github.com/zyachel/libremdb' className='link'>
GitHub
</a>{' '}
or{' '}
<a href='https://codeberg.org/zyachel/libremdb' className='link'>
Codeberg
</a>{' '}
for general issues, questions, or requests.
</p>
<p className={styles.item}>
In case you wish to contact me personally, I'm reachable via{' '}
<a className='link' href='https://matrix.to/#/@ninal:matrix.org'>
[matrix]
</a>{' '}
and{' '}
<a className='link' href='mailto:aricla@protonmail.com'>
email
</a>
.
</p>
</div>
</section>
</Layout>
</>
);
};
export default Contact;

View File

@ -0,0 +1,74 @@
import Meta from '../../components/Meta/Meta';
import Layout from '../../layouts/Layout';
import styles from '../../styles/modules/pages/privacy/privacy.module.scss';
const Privacy = () => {
return (
<>
<Meta
title='Privacy'
description='Privacy policy of libremdb, a free & open source IMDb front-end.'
/>
<Layout className={styles.privacy}>
<section className={styles.policy}>
<h1 className={`heading heading__primary ${styles.policy__heading}`}>
Privacy Policy
</h1>
<div className={styles.list}>
<div className={styles.item}>
<h2
className={`heading heading__secondary ${styles.item__heading}`}
>
Information collected
</h2>
<p className={styles.item__text}>No information is collected.</p>
</div>
<div className={styles.item}>
<h2
className={`heading heading__secondary ${styles.item__heading}`}
>
Information stored in your browser
</h2>
<p className={styles.item__text}>
A key named 'theme' is stored in Local Storage provided by your
browser, if you ever override the default theme. To remove it,
go to site data settings, and clear the data for this website.
</p>
<p className={styles.item__text}>
To permamently disable libremdb from storing your theme
prefrences, either turn off JavaScript or disable access to
Local Storage for libremdb.
</p>
</div>
<div className={styles.item}>
<h2
className={`heading heading__secondary ${styles.item__heading}`}
>
Information collected by other services
</h2>
<p className={styles.item__text}>
libremdb connects to 'media-amazon.com' and 'media-imdb.com' for
fetching images and videos. So, Amazon might log your IP
address, and other information(such as http headers) sent by
your browser.
</p>
</div>
</div>
<footer className={styles.metadata}>
<p>
Last updated on <time>10 september, 2022.</time>
</p>
<p>
You can see the full revision history of this privacy policy on
GitHub, or Codeberg.
</p>
</footer>
</section>
</Layout>
</>
);
};
export default Privacy;

View File

@ -0,0 +1,108 @@
// external
import { GetServerSideProps, GetStaticProps, GetStaticPaths } from 'next';
import { useRouter } from 'next/router';
// local
import Meta from '../../../components/Meta/Meta';
import Layout from '../../../layouts/Layout';
import title from '../../../utils/fetchers/title';
// components
import ErrorInfo from '../../../components/Error/ErrorInfo';
import Basic from '../../../components/title/Basic';
import Media from '../../../components/title/Media';
import Cast from '../../../components/title/Cast';
import DidYouKnow from '../../../components/title/DidYouKnow';
import Info from '../../../components/title/Info';
import Reviews from '../../../components/title/Reviews';
import MoreLikeThis from '../../../components/title/MoreLikeThis';
// misc
import Title from '../../../interfaces/shared/title';
import { AppError } from '../../../interfaces/shared/error';
// styles
import styles from '../../../styles/modules/pages/title/title.module.scss';
type Props = { data: Title; error: null } | { error: AppError; data: null };
// TO-DO: make a wrapper page component to display errors, if present in props
const TitleInfo = ({ data, error }: Props) => {
const router = useRouter();
if (error)
return <ErrorInfo message={error.message} statusCode={error.statusCode} />;
const info = {
meta: data.meta,
keywords: data.keywords,
details: data.details,
boxOffice: data.boxOffice,
technicalSpecs: data.technicalSpecs,
accolades: data.accolades,
};
return (
<>
<Meta
title={`${data.basic.title} (${
data.basic.releaseYear?.start || data.basic.type.name
})`}
description={data.basic.plot || undefined}
/>
<Layout className={styles.title}>
<Basic data={data.basic} className={styles.basic} />
<Media className={styles.media} media={data.media} router={router} />
<Cast className={styles.cast} cast={data.cast} />
<div className={styles.textarea}>
<DidYouKnow data={data.didYouKnow} />
<Reviews reviews={data.reviews} router={router} />
</div>
<Info className={styles.infoarea} info={info} router={router} />
<MoreLikeThis className={styles.related} data={data.moreLikeThis} />
</Layout>
</>
);
};
// TO-DO: make a getServerSideProps wrapper for handling errors
export const getServerSideProps: GetServerSideProps = async ctx => {
const titleId = ctx.params!.titleId as string;
try {
const data = await title(titleId);
return { props: { data, error: null } };
} catch (error: any) {
const { message, statusCode } = error;
ctx.res.statusCode = statusCode;
ctx.res.statusMessage = message;
return { props: { error: { message, statusCode }, data: null } };
}
};
export default TitleInfo;
// could've used getStaticProps instead of getServerSideProps, but meh.
/*
export const getStaticProps: GetStaticProps = async ctx => {
const titleId = ctx.params!.titleId as string;
try {
const data = await title(titleId);
return {
props: { data, error: null },
revalidate: 60 * 60 * 24, // 1 day
};
} catch (error) {
// console.log(error);
return { notFound: true };
}
};
export const getStaticPaths: GetStaticPaths = () => {
return {
paths: [{ params: { titleId: 'tt0133093' } }],
fallback: 'blocking',
};
};
*/

View File

@ -0,0 +1 @@
@forward './mixins';

View File

@ -0,0 +1,80 @@
@use 'sass:map';
@use './variables' as v;
////////////////////////////////////////////////////////////////
// ROOT MIXINS
////////////////////////////////////////////////////////////////
// MIXINS TO TURN SCSS VARIABLES INTO CSS VARIABLES:
@mixin typescale($mode) {
// getting appropriate type scale
$type-scale: map.get(v.$font-sizes, $mode);
// making variables out of it
@each $key, $value in $type-scale {
--fs-#{$key}: #{$value};
}
}
@mixin typography {
$other-vars: v.$misc;
@each $key, $value in $other-vars {
--#{$key}: #{$value};
}
}
@mixin spacers {
@each $var, $value in v.$space {
--spacer-#{$var}: #{$value};
}
}
@mixin colors($color: 'light') {
$color-map: map-get(v.$themes, $color);
@each $prop, $val in $color-map {
--clr-#{$prop}: #{$val};
}
}
////////////////////////////////////////////////////////////////
// REUSABLE MIXINS
////////////////////////////////////////////////////////////////
// 1. mixin to handle known and unknown breakpoints:
@mixin bp($given-breakpoint, $min: false) {
// just assigning the given value to a new variable since we're going to change it conditionally;
$breakpoint: $given-breakpoint;
// if $breakpoints map contains the given variable then getting it's value
@if map.has-key(v.$breakpoints, $breakpoint) {
$breakpoint: map.get(v.$breakpoints, $breakpoint);
}
// and then using it for media query. This will also work for straight out values(50em or 800px, for example)
$expr: 'max-width: #{$breakpoint}';
@if ($min) {
$expr: 'min-width: #{$breakpoint}';
}
@media screen and ($expr) {
@content;
}
}
// 2. for prettifying links
@mixin prettify-link($clr: currentColor, $clr-line: $clr, $animate: true) {
$height: 0.1em;
text-decoration: none;
color: $clr;
background: linear-gradient(to left, $clr-line, $clr-line) no-repeat right
bottom;
@if ($animate) {
background-size: 0 $height;
transition: background-size 200ms ease;
&:where(:hover, :focus-visible) {
background-size: 100% $height;
background-position-x: left;
}
} @else {
background-size: 100% $height;
}
}

View File

@ -0,0 +1,3 @@
@forward './misc';
@forward './typography';
@forward './themes';

View File

@ -0,0 +1,28 @@
// 8 pt spacer
$space: (
0: 0.4rem,
1: 0.8rem,
2: 1.6rem,
3: 2.4rem,
4: 3.2rem,
5: 4rem,
6: 4.8rem,
7: 5.6rem,
8: 6.4rem,
9: 7.2rem,
10: 8rem,
);
$breakpoints: (
'bp-1200': 75em,
'bp-900': 56.25em,
'bp-700': 43.75em,
'bp-450': 28.125em,
);
// 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%);

View File

@ -0,0 +1,62 @@
$_light: (
// 1. text
// 1.1 for headings
text-accent: hsl(240, 31%, 25%),
// 1.2 for base text
text: hsl(0, 0%, 24%),
// 1.3 for subtle text like metadata
text-muted: hsl(204, 4%, 35%),
// 2. bg
// 2.1 for cards, headers, footers,
bg-accent: hsl(339, 100%, 97%),
// 2.2 for base bg
bg: hsl(0, 0%, 100%),
// 2.3 for hover state of cards
bg-muted: rgb(255, 229, 239),
// 3. links
// 3.1 the default one.
link: hsl(219, 100%, 20%),
link-muted: hsl(344, 79%, 40%),
// 4. for icons, borders
fill: hsl(339, 100%, 36%),
// 4.2 for borders, primarily
fill-muted: hsl(0, 0%, 80%),
// shadows on cards
shadow: 0 0 1rem 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 72% 55%, hsla(344, 79%, 40%, 0.2) 0px, transparent 50%)
)
);
$_dark: (
text-accent: hsl(0, 0%, 100%),
text: hsl(0, 0%, 96%),
text-muted: hsl(0, 0%, 80%),
bg-accent: hsl(221, 39%, 15%),
bg: hsl(221, 39%, 11%),
bg-muted: rgb(20, 28, 46),
link: hsl(339, 95%, 80%),
link-muted: hsl(344, 79%, 80%),
fill: hsl(339, 75%, 64%),
fill-muted: hsl(0, 0, 35%),
shadow: hsla(0, 0%, 0%, 1),
highlight: hsl(176, 43%, 46%),
gradient: (
radial-gradient(at 23% 32%, hsla(344, 79%, 40%, 0.04) 0px, transparent 70%),
radial-gradient(at 72% 55%, hsla(344, 79%, 40%, 0.05) 0px, transparent 50%),
),
);
$themes: (
light: $_light,
dark: $_dark,
);

View File

@ -0,0 +1,56 @@
////////////////////////////////////////////////////////////////
// RAW VARIABLES
////////////////////////////////////////////////////////////////
// 1. type scale
// see more at https://type-scale.com/
// 1.33
$_perfect-fourth: (
0: 6rem,
1: 5rem,
2: 3.8rem,
3: 2.8rem,
4: 2.1rem,
5: 1.6rem,
// 6: 1.2rem,
);
// 1.25
$_major-third: (
0: 4.9rem,
1: 3.9rem,
2: 3.1rem,
3: 2.5rem,
4: 2rem,
5: 1.6rem,
// 6: 1.3rem,
);
// 2 font families
$_ff-sans: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui,
helvetica neue, Cantarell, Ubuntu, roboto, noto, arial, sans-serif;
$_ff-serif: Iowan Old Style, Apple Garamond, Baskerville, Times New Roman,
Droid Serif, Times, Source Serif Pro, serif, Apple Color Emoji, Segoe UI Emoji,
Segoe UI Symbol;
$_ff-mono: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace;
////////////////////////////////////////////////////////////////
// TO EXPORT
////////////////////////////////////////////////////////////////
$font-sizes: (
desktop: $_perfect-fourth,
mobile: $_major-third,
);
$misc: (
ff-base: $_ff-sans,
ff-accent: (
'RedHat Display',
$_ff-sans,
),
fw-thin: 300,
fw-base: 400,
fw-medium: 500,
fw-bold: 700,
fw-black: 900,
);

View File

@ -0,0 +1,24 @@
@use '../abstracts' as helper;
#__next,
html,
body {
min-height: 100vh;
}
#__next {
display: grid;
grid-template-rows: min-content 1fr min-content;
}
body {
color: var(--clr-text);
background-color: var(--clr-bg);
}
// restricting to 1600px width
.main {
--max-width: 160rem;
width: min(100%, var(--max-width));
margin-inline: auto;
}

View File

@ -0,0 +1,5 @@
@font-face {
font-family: 'RedHat Display';
src: url('../../../public/fonts/RedHatDisplay-VariableFont_wght.ttf');
font-display: swap;
}

View File

@ -0,0 +1,27 @@
/**
* Hide text while making it readable for screen readers
* 1. Needed in WebKit-based browsers because of an implementation bug;
* See: https://code.google.com/p/chromium/issues/detail?id=457146
*/
.hide-text {
overflow: hidden;
padding: 0; /* 1 */
text-indent: 101%;
white-space: nowrap;
}
/**
* Hide element while making it readable for screen readers
* Shamelessly borrowed from HTML5Boilerplate:
* https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css#L119-L133
*/
.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}

View File

@ -0,0 +1,6 @@
@forward './reset';
// @forward './helpers';
@forward './root';
@forward './base';
@forward './fonts';
@forward './typography';

View File

@ -0,0 +1,54 @@
html {
scroll-behavior: smooth;
font-size: 62.5%;
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: inherit;
// font: inherit;
}
body {
box-sizing: border-box;
text-rendering: optimizeSpeed;
line-height: 1.5;
}
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
ul[role='list'],
ol[role='list'] {
list-style: none;
}
/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
}
/* Make images easier to work with */
img,
picture,
svg {
max-width: 100%;
display: block;
}
/* Remove all animations, transitions and smooth scroll for people that prefer not to see them */
@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@ -0,0 +1,27 @@
@use '../abstracts' as helper;
:root {
@include helper.typography;
@include helper.typescale('desktop');
@include helper.spacers;
@include helper.colors('light');
// dark themed vars when root has an attribute of theme set to 'dark'
&[data-theme='dark'] {
@include helper.colors('dark');
}
// styles to be applied when js is disabled
&:not([data-js]) {
// if the user prefers dark theme
@media (prefers-color-scheme: dark) {
// using dark theme instead of default one
@include helper.colors('dark');
}
}
// change typescale for small screens
@include helper.bp('bp-700') {
@include helper.typescale('mobile');
}
}

View File

@ -0,0 +1,22 @@
body {
font-family: var(--ff-base);
font-size: var(--fs-5);
}
.heading {
color: var(--clr-text-accent);
font-family: var(--ff-accent);
font-weight: var(--fw-medium);
&__primary {
font-size: var(--fs-1);
}
&__secondary {
font-size: var(--fs-2);
}
&__tertiary {
font-size: var(--fs-3);
}
}

View File

@ -0,0 +1 @@
@forward './links';

View File

@ -0,0 +1,6 @@
@use '../abstracts' as helper;
.link,
.ipc-md-link {
@include helper.prettify-link(var(--clr-link));
}

4
src/styles/main.scss Normal file
View File

@ -0,0 +1,4 @@
@charset "UTF-8";
@use './base';
@use './components';

View File

@ -0,0 +1,13 @@
.button {
border: none;
background: none;
cursor: pointer;
}
.icon {
// we'll get --dimension var from header.module.scss
height: var(--dimension, 4rem);
width: var(--dimension, 4rem);
fill: var(--clr-fill);
}

View File

@ -0,0 +1,33 @@
@use '../../../abstracts/' as helper;
.error {
--doc-whitespace: var(--spacer-8);
--comp-whitespace: var(--spacer-5);
padding: var(--doc-whitespace);
display: grid;
justify-content: center;
justify-items: center;
gap: var(--spacer-1);
@include helper.bp('bp-700') {
--doc-whitespace: var(--spacer-5);
--comp-whitespace: var(--spacer-3);
}
@include helper.bp('bp-450') {
padding: var(--spacer-3);
}
}
.gnu {
--dim: 30rem;
height: var(--dim);
width: var(--dim);
fill: var(--clr-fill);
}
.heading {
// justify-self: center;
text-align: center;
}

View File

@ -0,0 +1,24 @@
.progress {
position: fixed;
z-index: 1;
inset-inline: 0;
inset-block-start: 0;
height: 4px;
width: 100%;
background: var(--clr-fill);
transform: translateX(-100%);
box-shadow: 2px 0 5px var(--clr-fill);
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
animation: prograte 60s ease-out forwards;
}
@keyframes prograte {
5% {
transform: translateX(-40%);
}
100% {
transform: translateX(-3%);
}
}

View File

@ -0,0 +1,172 @@
@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;
}
.meta {
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);
}
@include helper.bp('bp-900') {
justify-content: center;
}
}
.ratings {
display: flex;
flex-wrap: wrap;
gap: var(--spacer-0) var(--spacer-3);
@include helper.bp('bp-900') {
justify-content: center;
}
}
.rating {
font-size: var(--fs-5);
display: grid;
grid-template-columns: repeat(2, max-content);
place-items: center;
gap: 0 var(--spacer-0);
&__num {
grid-column: 1 / 2;
font-size: 1.8em;
font-weight: var(--fw-medium);
// line-height: 1;
}
&__icon {
--dim: 1.8em;
grid-column: -2 / -1;
line-height: 1;
height: var(--dim);
width: var(--dim);
display: grid;
place-content: center;
fill: var(--clr-fill);
}
&__text {
grid-column: 1 / -1;
font-size: 0.9em;
line-height: 1;
color: var(--clr-text-muted);
}
}
.link {
@include helper.prettify-link(var(--clr-link));
}
.genres,
.overview,
.crewType {
&__heading {
font-weight: var(--fw-bold);
}
}

View File

@ -0,0 +1,76 @@
@use '../../../abstracts' as helper;
.container {
display: grid;
gap: var(--comp-whitespace);
margin-inline: auto; // for when cast members are so few that the container doesn't scroll
}
.cast {
--max-width: 15rem;
--min-height: 35rem;
list-style: none;
overflow-x: auto;
display: grid;
grid-auto-flow: column;
gap: var(--spacer-4);
padding: 0 var(--spacer-2) var(--spacer-3) var(--spacer-2);
grid-auto-columns: var(--max-width);
min-height: var(--min-height);
@include helper.bp('bp-700') {
--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

@ -0,0 +1,9 @@
.didYouKnow {
display: grid;
gap: var(--comp-whitespace);
}
.container {
display: grid;
gap: var(--comp-whitespace);
}

View File

@ -0,0 +1,35 @@
.info {
display: grid;
gap: var(--doc-whitespace);
}
.episodeInfo,
.seriesInfo,
.accolades,
.keywords,
.details,
.boxoffice,
.technical {
display: grid;
gap: var(--comp-whitespace);
&__container {
display: grid;
gap: var(--spacer-0);
// for span elements like these: 'release date:'
& > p > span:first-of-type {
font-weight: var(--fw-bold);
}
}
}
.keywords {
&__container {
display: flex;
list-style: none;
flex-wrap: wrap;
column-gap: var(--spacer-2);
}
}

View File

@ -0,0 +1,101 @@
@use '../../../abstracts' as helper;
// grid is better than flexbox, as in flexbox, you specifically have to specify height.
.media {
--min-height: 30rem;
--max-width: 50rem;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: var(--doc-whitespace); // declared in title.module.scss
@include helper.bp('bp-1200') {
grid-auto-flow: row;
grid-auto-columns: initial;
}
@include helper.bp('bp-700') {
--min-height: 20rem;
--max-width: 30rem;
}
@include helper.bp('bp-450') {
// --min-height: 15rem;
--max-width: 95%;
}
}
// section
.images,
.videos {
display: grid;
grid-template-rows: min-content;
gap: var(--comp-whitespace); // declared in title.module.scss
&__container {
overflow-x: auto;
display: grid;
grid-auto-flow: column;
gap: var(--spacer-2);
padding: 0 var(--spacer-2) var(--spacer-3) var(--spacer-2);
grid-auto-columns: var(--max-width);
min-height: var(--min-height);
}
}
%cardify {
overflow: hidden;
border-radius: 5px;
box-shadow: var(--clr-shadow);
}
// child of .videos
.trailer {
@extend %cardify;
&__video {
object-fit: cover;
height: 100%;
width: 100%;
}
}
// since it is wrapped in a tag
.video {
text-decoration: none;
}
.video,
.image {
@extend %cardify;
position: relative;
&__caption {
position: absolute;
inset-block-end: 0;
inset-inline-end: 0;
padding: var(--spacer-0);
font-size: 0.9em;
color: var(--clr-text);
background: var(--clr-bg);
// hovering effect only for desktop/stylus users
@media (any-hover: hover) and (any-pointer: fine) {
transform: translateY(100%);
transition: transform 500ms ease-in-out;
}
}
&__img {
object-position: top;
object-fit: cover;
}
&:hover &__caption {
transform: translateY(1%); // 0% is leaving some gap from bottom
}
}

View File

@ -0,0 +1,105 @@
@use '../../../abstracts' as helper;
.morelikethis {
display: grid;
gap: var(--comp-whitespace);
}
.container {
--max-width: 20rem;
--min-height: 50rem;
list-style: none;
overflow-x: auto;
display: grid;
grid-auto-flow: column;
gap: var(--spacer-4);
padding: 0 var(--spacer-2) var(--spacer-3) var(--spacer-2);
grid-auto-columns: 20rem;
min-height: 50rem;
> li {
list-style: none;
}
@include helper.bp('bp-700') {
grid-auto-columns: 17rem;
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

@ -0,0 +1,26 @@
.reviews {
display: grid;
gap: var(--comp-whitespace);
&__reviewContainer {
// background-color: antiquewhite;
}
&__stats {
display: flex;
flex-wrap: wrap;
gap: var(--spacer-2);
}
}
.review {
&__summary {
font-size: calc(var(--fs-5) * 1.1);
cursor: pointer;
}
&__text,
&__metadata {
padding-top: var(--spacer-2);
}
}

View File

@ -0,0 +1,37 @@
@use '../../abstracts' as helper;
.footer {
background: var(--clr-bg-muted);
padding: var(--spacer-4);
display: flex;
flex-direction: column;
gap: var(--space-small);
font-size: var(--fs-5);
}
.nav {
.list {
list-style: none;
display: flex;
gap: var(--spacer-2) var(--spacer-4);
justify-content: space-evenly;
flex-wrap: wrap;
}
&__item {
}
&__link {
@include helper.prettify-link(var(--clr-link));
}
&__linkActive {
@include helper.prettify-link(var(--clr-link), $animate: false);
}
}
.licence {
margin-top: var(--spacer-1);
align-self: center;
text-align: center;
}

View File

@ -0,0 +1,91 @@
@use '../../abstracts' as helper;
.header {
--dimension: 1.5em; // will be used for icons
font-size: 1.1em;
display: grid;
background: (var(--clr-bg-muted));
&__about {
min-height: 100vh;
background: var(--clr-gradient);
grid-template-rows: max-content 1fr 0.15fr; // .15fr for centering the hero
}
}
.topbar {
display: grid;
grid-auto-flow: column;
grid-template-columns:
minmax(max-content, 0.3fr)
1fr minmax(max-content, 0.3fr);
align-items: center;
gap: var(--spacer-4);
padding: var(--spacer-4);
@include helper.bp('bp-700') {
padding: var(--spacer-3);
}
}
.logo {
justify-self: start;
text-decoration: none;
display: flex;
align-items: center;
gap: var(--spacer-1);
&__icon {
height: var(--dimension);
width: var(--dimension);
fill: var(--clr-fill);
}
&__text {
line-height: 1;
font-size: var(--fs-4);
color: var(--clr-fill);
font-weight: var(--fw-bold);
font-family: var(--ff-accent);
}
}
.nav {
justify-self: center;
&__list {
display: flex;
list-style: none;
gap: var(--spacer-4);
}
@include helper.bp('bp-700') {
display: none;
}
}
.themeToggler {
justify-self: end;
grid-column: -2 / -1;
}
.hero {
display: grid;
text-align: center;
place-content: center;
gap: var(--spacer-0);
padding-inline: var(--spacer-1);
&__text {
font-size: var(--fs-0);
}
@include helper.bp('bp-700') {
&__text {
font-size: var(--fs-1);
}
}
}

View File

@ -0,0 +1,114 @@
@use '../../../abstracts' as helper;
.about {
display: grid;
--doc-whitespace: var(--spacer-8);
--comp-whitespace: var(--spacer-5);
padding: var(--doc-whitespace);
gap: var(--doc-whitespace);
@include helper.bp('bp-700') {
--doc-whitespace: var(--spacer-5);
// --comp-whitespace: var(--spacer-3);
}
@include helper.bp('bp-450') {
padding: var(--spacer-3);
}
}
.features {
display: grid;
gap: var(--comp-whitespace);
&__heading {
justify-self: center;
}
&__list {
list-style: none;
display: grid;
gap: var(--comp-whitespace);
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
@include helper.bp('bp-900') {
grid-template-columns: none;
}
}
}
.feature {
display: grid;
gap: var(--spacer-1);
justify-content: center;
justify-items: center;
text-align: center;
&__icon {
--dim: var(--fs-1);
max-width: unset;
height: var(--dim);
width: var(--dim);
fill: var(--clr-fill);
}
}
.faqs {
display: grid;
gap: var(--comp-whitespace);
justify-items: center;
&__heading {
text-align: center;
}
&__list {
display: grid;
gap: var(--spacer-3);
margin-inline: auto;
width: min(100%, 120rem);
}
padding-block: var(--doc-whitespace);
@include helper.bp('bp-900') {
padding-block: var(--spacer-3);
}
}
.faq {
border: 1px solid var(--clr-fill-muted);
&__summary {
list-style: none;
padding: var(--spacer-2);
font-size: 1.05em;
cursor: pointer;
// for icon
display: flex;
justify-content: space-between;
&::after {
content: '+';
font-weight: bold;
}
}
&__description {
padding: var(--spacer-2);
padding-top: 0;
}
&[open] {
border-color: var(--clr-fill);
}
&[open] &__summary::after {
content: '\2212'; // minus sign
color: var(--clr-fill);
}
}

View File

@ -0,0 +1,32 @@
@use '../../../abstracts' as helper;
.contact {
// to make text more readable for large screen users
margin: auto;
width: min(100%, 100rem);
display: grid;
--doc-whitespace: var(--spacer-8);
--comp-whitespace: var(--spacer-3);
padding: var(--doc-whitespace);
place-content: center;
&__heading {
text-align: center;
}
@include helper.bp('bp-700') {
--doc-whitespace: var(--spacer-5);
}
@include helper.bp('bp-450') {
--doc-whitespace: var(--spacer-3);
}
}
.list {
padding-block: var(--comp-whitespace);
display: grid;
gap: var(--comp-whitespace);
}

View File

@ -0,0 +1,36 @@
@use '../../../abstracts' as helper;
.policy {
// to make text more readable for large screen users
margin-inline: auto;
width: min(100%, 100rem);
display: grid;
--doc-whitespace: var(--spacer-8);
--comp-whitespace: var(--spacer-5);
padding: var(--doc-whitespace);
&__heading {
text-align: center;
}
@include helper.bp('bp-700') {
--doc-whitespace: var(--spacer-5);
--comp-whitespace: var(--spacer-3);
}
@include helper.bp('bp-450') {
padding: var(--spacer-3);
}
}
.list {
padding-block: var(--doc-whitespace);
display: grid;
gap: var(--comp-whitespace);
@include helper.bp('bp-900') {
padding-block: var(--spacer-3);
}
}

View File

@ -0,0 +1,71 @@
@use '../../../abstracts' as helper;
.title {
// major whitespace properties used on title page
--doc-whitespace: var(--spacer-8);
--comp-whitespace: var(--spacer-3);
display: grid;
gap: var(--doc-whitespace);
padding: var(--doc-whitespace);
align-items: start;
grid-template-columns: repeat(8, 1fr);
grid-template-areas:
'basic basic basic basic basic basic basic basic'
'media media media media media media media media'
'cast cast cast cast cast cast cast cast'
'text text text text text info info info'
'related related related related related related related related';
@include helper.bp('bp-1200') {
grid-template-columns: none;
grid-template-areas:
'basic'
'media'
'cast'
'text'
'info'
'related';
}
@include helper.bp('bp-700') {
--doc-whitespace: var(--spacer-5);
}
@include helper.bp('bp-450') {
padding: var(--spacer-3);
}
}
.basic {
grid-area: basic;
}
.media {
grid-area: media;
}
.cast {
grid-area: cast;
}
.textarea {
grid-area: text;
display: grid;
gap: var(--doc-whitespace);
}
.infoarea {
grid-area: info;
}
.related {
grid-area: related;
}
.morelikethis {
grid-area: related;
}

View File

@ -0,0 +1,14 @@
import axios from 'axios';
const axiosInstance = axios.create({
baseURL: 'https://www.imdb.com/',
timeout: 50000,
headers: {
...(process.env.AXIOS_USERAGENT && {
'User-Agent': process.env.AXIOS_USERAGENT,
}),
...(process.env.AXIOS_ACCEPT && { Accept: process.env.AXIOS_ACCEPT }),
},
});
export default axiosInstance;

377
src/utils/cleaners/title.ts Normal file
View File

@ -0,0 +1,377 @@
import RawTitle from '../../interfaces/misc/rawTitle';
import { formatDate } from '../helpers';
const cleanTitle = (rawData: RawTitle) => {
const {
props: {
pageProps: { aboveTheFoldData: main, mainColumnData: misc },
},
} = rawData;
const cleanData = {
titleId: main.id,
basic: {
id: main.id,
title: main.titleText.text,
// ...(main.originalTitleText.text.toLowerCase() !==
// main.titleText.text.toLowerCase() && {
// originalTitle: main.originalTitleText.text,
// }),
type: {
id: main.titleType.id as
| 'movie'
| 'tvSeries'
| 'tvEpisode'
| 'videoGame',
name: main.titleType.text,
},
status: {
id: main.productionStatus.currentProductionStage.id,
text: main.productionStatus.currentProductionStage.text,
},
ceritficate: main.certificate?.rating || null,
...(main.releaseYear && {
releaseYear: {
start: main.releaseYear.year,
end: main.releaseYear.endYear,
},
}),
runtime: main.runtime?.seconds || null,
ratings: {
avg: main.ratingsSummary.aggregateRating || null,
numVotes: main.ratingsSummary.voteCount,
},
...(main.meterRanking && {
ranking: {
position: main.meterRanking.currentRank,
change: main.meterRanking.rankChange.difference,
direction: main.meterRanking.rankChange.changeDirection as
| 'UP'
| 'DOWN'
| 'FLAT',
},
}),
genres: main.genres.genres.map(genre => ({
id: genre.id,
text: genre.text,
})),
plot: main.plot?.plotText?.plainText || null,
primaryCrew: main.principalCredits.map(type => ({
type: { category: type.category.text, id: type.category.id },
crew: type.credits.map(person => ({
attributes: person.attributes?.map(attr => attr.text) || null,
name: person.name.nameText.text,
id: person.name.id,
})),
})),
...(main.primaryImage && {
poster: {
url: main.primaryImage.url,
id: main.primaryImage.id,
caption: main.primaryImage.caption.plainText,
},
}),
},
cast: misc.cast.edges.map(cast => ({
name: cast.node.name.nameText.text,
id: cast.node.name.id,
image: cast.node.name.primaryImage?.url || null,
attributes: cast.node.attributes?.map(attr => attr.text) || null,
characters: cast.node.characters?.map(name => name.name) || null,
})),
media: {
...(main.primaryVideos.edges.length && {
trailer: {
id: main.primaryVideos.edges[0].node.id,
isMature: main.primaryVideos.edges[0].node.isMature,
thumbnail: main.primaryVideos.edges[0].node.thumbnail.url,
runtime: main.primaryVideos.edges[0].node.runtime.value,
caption: main.primaryVideos.edges[0].node.description.value,
urls: main.primaryVideos.edges[0].node.playbackURLs.map(url => ({
resolution: url.displayName.value,
mimeType: url.mimeType,
url: url.url,
})),
},
}),
images: {
total: misc.titleMainImages.total,
images: misc.titleMainImages.edges.map(image => ({
id: image.node.id,
url: image.node.url,
caption: image.node.caption,
})),
},
videos: {
total: misc.videos.total,
videos: misc.videoStrip.edges.map(video => ({
id: video.node.id,
type: video.node.contentType.displayName,
caption: video.node.name.value,
runtime: video.node.runtime.value,
thumbnail: video.node.thumbnail.url,
})),
},
},
accolades: {
wins: misc.wins.total,
nominations: misc.nominations.total,
...(misc.prestigiousAwardSummary && {
awards: {
name: misc.prestigiousAwardSummary.award.text,
id: misc.prestigiousAwardSummary.award.id,
event: misc.prestigiousAwardSummary.award.event,
nominations: misc.prestigiousAwardSummary.nominations,
wins: misc.prestigiousAwardSummary.wins,
},
}),
topRating: misc.ratingsSummary.topRanking?.rank || null,
},
meta: {
// for tv episode
...(main.series && {
infoEpisode: {
numSeason: main.series.episodeNumber?.seasonNumber || null,
numEpisode: main.series.episodeNumber?.episodeNumber || null,
prevId: main.series.previousEpisode?.id || null,
nextId: main.series.nextEpisode.id,
series: {
id: main.series.series.id,
title: main.series.series.titleText.text,
startYear: main.series.series.releaseYear.year,
endYear: main.series.series.releaseYear.endYear,
},
},
}),
// for tv series
...(misc.episodes && {
infoSeries: {
totalEpisodes: misc.episodes.episodes.total,
seasons: misc.episodes.seasons.map(season => season.number),
years: misc.episodes.years.map(year => year.year),
topRatedEpisode:
misc.episodes.topRated.edges[0].node.ratingsSummary.aggregateRating,
},
}),
},
keywords: {
total: main.keywords.total,
list: main.keywords.edges.map(word => word.node.text),
},
didYouKnow: {
...(misc.trivia.edges.length && {
trivia: {
total: misc.triviaTotal.total,
html: misc.trivia.edges[0].node.text.plaidHtml,
},
}),
...(misc.goofs.edges.length && {
goofs: {
total: misc.goofsTotal.total,
html: misc.goofs.edges[0].node.text.plaidHtml,
},
}),
...(misc.quotes.edges.length && {
quotes: {
total: misc.quotesTotal.total,
lines: misc.quotes.edges[0].node.lines.map(line => ({
name: line.characters?.[0].character || null,
id: line.characters?.[0].name?.id || null,
stageDirection: line.stageDirection || null,
text: line.text,
})),
},
}),
...(misc.crazyCredits.edges.length && {
crazyCredits: {
html: misc.crazyCredits.edges[0].node.text.plaidHtml,
},
}),
...(misc.alternateVersions.edges.length && {
alternativeVersions: {
total: misc.alternateVersions.total,
html: misc.alternateVersions.edges[0].node.text.plaidHtml,
},
}),
...(misc.connections.edges.length && {
connections: {
startText: misc.connections.edges[0].node.category.text,
title: {
id: misc.connections.edges[0].node.associatedTitle.id,
year: misc.connections.edges[0].node.associatedTitle.releaseYear
.year,
text: misc.connections.edges[0].node.associatedTitle.titleText.text,
},
},
}),
...(misc.soundtrack.edges.length && {
soundTrack: {
title: misc.soundtrack.edges[0].node.text,
htmls:
misc.soundtrack.edges[0].node.comments?.map(
html => html.plaidHtml
) || null,
},
}),
},
reviews: {
metacriticScore: main.metacritic?.metascore.score || null,
numCriticReviews: main.criticReviewsTotal.total,
numUserReviews: misc.reviews.total,
...(misc.featuredReviews.edges.length && {
featuredReview: {
id: misc.featuredReviews.edges[0].node.id,
reviewer: {
id: misc.featuredReviews.edges[0].node.author.userId,
name: misc.featuredReviews.edges[0].node.author.nickName,
},
rating: misc.featuredReviews.edges[0].node.authorRating,
date: formatDate(misc.featuredReviews.edges[0].node.submissionDate),
votes: {
up: misc.featuredReviews.edges[0].node.helpfulness.upVotes,
down: misc.featuredReviews.edges[0].node.helpfulness.downVotes,
},
review: {
summary: misc.featuredReviews.edges[0].node.summary.originalText,
html: misc.featuredReviews.edges[0].node.text.originalText
.plaidHtml,
},
},
}),
},
details: {
...(misc.releaseDate && {
releaseDate: {
date: formatDate(
misc.releaseDate.year,
misc.releaseDate.month - 1, // month starts from 0
misc.releaseDate.day
),
country: {
id: misc.releaseDate.country.id,
text: misc.releaseDate.country.text,
},
},
}),
...(misc.countriesOfOrigin && {
countriesOfOrigin: misc.countriesOfOrigin.countries.map(country => ({
id: country.id,
text: country.text,
})),
}),
...(misc.detailsExternalLinks.edges.length && {
officialSites: {
total: misc.detailsExternalLinks.total,
sites: misc.detailsExternalLinks.edges.map(site => ({
name: site.node.label,
url: site.node.url,
country: site.node.externalLinkRegion?.text || null,
})),
},
}),
...(misc.spokenLanguages && {
languages: misc.spokenLanguages.spokenLanguages.map(lang => ({
id: lang.id,
text: lang.text,
})),
}),
alsoKnownAs: misc.akas.edges[0]?.node.text || null,
...(misc.filmingLocations.edges.length && {
filmingLocations: {
total: misc.filmingLocations.total,
locations: misc.filmingLocations.edges.map(loc => loc.node.text),
},
}),
...(misc.production.edges.length && {
production: {
total: misc.companies.total,
companies: misc.production.edges.map(c => ({
id: c.node.company.id,
name: c.node.company.companyText.text,
})),
},
}),
},
boxOffice: {
...(misc.productionBudget && {
budget: {
amount: misc.productionBudget.budget.amount,
currency: misc.productionBudget.budget.currency,
},
}),
...(misc.worldwideGross && {
gross: {
amount: misc.worldwideGross.total.amount,
currency: misc.worldwideGross.total.currency,
},
}),
...(misc.lifetimeGross && {
grossUs: {
amount: misc.lifetimeGross.total.amount,
currency: misc.lifetimeGross.total.currency,
},
}),
...(misc.openingWeekendGross && {
openingGrossUs: {
amount: misc.openingWeekendGross.gross.total.amount,
currency: misc.openingWeekendGross.gross.total.currency,
date: formatDate(misc.openingWeekendGross.weekendEndDate),
},
}),
},
technicalSpecs: {
...(misc.technicalSpecifications.soundMixes.items.length && {
soundMixes: misc.technicalSpecifications.soundMixes.items.map(item => ({
id: item.id,
name: item.text,
})),
}),
...(misc.technicalSpecifications.aspectRatios.items.length && {
aspectRatios: misc.technicalSpecifications.aspectRatios.items.map(
item => item.aspectRatio
),
}),
...(misc.technicalSpecifications.colorations.items.length && {
colorations: misc.technicalSpecifications.colorations.items.map(
item => ({ id: item.conceptId, name: item.text })
),
}),
...(main.runtime && { runtime: main.runtime?.seconds }),
},
moreLikeThis: misc.moreLikeThisTitles.edges.map(title => ({
id: title.node.id,
title: title.node.titleText.text,
...(title.node.primaryImage && {
poster: {
id: title.node.primaryImage.id,
url: title.node.primaryImage.url,
},
}),
type: {
id: title.node.titleType.id as
| 'movie'
| 'tvSeries'
| 'tvEpisode'
| 'videoGame',
text: title.node.titleType.text,
},
certificate: title.node.certificate?.rating || null,
...(title.node.releaseYear && {
releaseYear: {
start: title.node.releaseYear.year,
end: title.node.releaseYear.endYear || null,
},
}),
runtime: title.node.runtime?.seconds || null,
ratings: {
avg: title.node.ratingsSummary.aggregateRating || null,
numVotes: title.node.ratingsSummary.voteCount,
},
genres: title.node.titleCardGenres.genres.map(genre => genre.text),
})),
};
return cleanData;
};
export default cleanTitle;

View File

@ -0,0 +1,29 @@
// external deps
import * as cheerio from 'cheerio';
// local files
import axiosInstance from '../axiosInstance';
import cleanTitle from '../cleaners/title';
import { AppError } from '../helpers';
// interfaces
import RawTitle from '../../interfaces/misc/rawTitle';
const title = async (titleId: string) => {
try {
// getting data
const res = await axiosInstance(`/title/${titleId}`);
const $ = cheerio.load(res.data);
const rawData = $('script#__NEXT_DATA__').text();
// cleaning it a bit
const parsedRawData: RawTitle = JSON.parse(rawData);
const cleanData = cleanTitle(parsedRawData);
// returning
return cleanData;
} catch (err: any) {
if (err.response?.status === 404)
throw new AppError('not found', 404, err.cause);
throw new AppError('something went wrong', 500, err.cause);
}
};
export default title;

62
src/utils/helpers.ts Normal file
View File

@ -0,0 +1,62 @@
export const formatTime = (timeInSecs: number) => {
if (!timeInSecs) return;
// year, month, date, hours, minutes, seconds
// (passing all except seconds zero because they don't matter. seconds will overflow to minutes and hours.)
const dateObj = new Date(0, 0, 0, 0, 0, timeInSecs);
const days = dateObj.getDate();
const hours = dateObj.getHours();
const minutes = dateObj.getMinutes();
const seconds = dateObj.getSeconds();
// example for movie runtime spanning days: /title/tt0284020
return `${days === 31 ? '' : days + 'd'} ${!hours ? '' : hours + 'h'} ${
!minutes ? '' : minutes + 'm'
} ${!seconds ? '' : seconds + 's'}`.trim();
};
export const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(num);
};
export function formatDate(dateStr: string): string;
export function formatDate(year: number, month: number, date: number): string;
export function formatDate(
dateStrOrYear: unknown,
month?: unknown,
date?: unknown
) {
const options = { dateStyle: 'medium' } as const;
if (
typeof dateStrOrYear === 'string' &&
typeof month === 'undefined' &&
typeof date === 'undefined'
)
return new Date(dateStrOrYear).toLocaleString('en-US', options);
return new Date(
dateStrOrYear as number,
month as number,
date as number
).toLocaleString('en-US', options);
}
export const formatMoney = (num: number, cur: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: cur,
maximumFractionDigits: 0,
}).format(num);
};
export const modifyIMDbImg = (url: string, widthInPx = 600) => {
return url.replaceAll('.jpg', `UX${widthInPx}.jpg`);
};
export const AppError = class extends Error {
constructor(message: string, public statusCode: number, cause?: any) {
super(message, cause);
Error.captureStackTrace(this, AppError);
}
};