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:
48
src/components/Error/ErrorInfo.tsx
Normal file
48
src/components/Error/ErrorInfo.tsx
Normal 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;
|
40
src/components/Meta/Meta.tsx
Normal file
40
src/components/Meta/Meta.tsx
Normal 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;
|
35
src/components/buttons/ThemeToggler.tsx
Normal file
35
src/components/buttons/ThemeToggler.tsx
Normal 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;
|
6
src/components/loaders/ProgressBar.tsx
Normal file
6
src/components/loaders/ProgressBar.tsx
Normal 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;
|
153
src/components/title/Basic.tsx
Normal file
153
src/components/title/Basic.tsx
Normal 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;
|
56
src/components/title/Cast.tsx
Normal file
56
src/components/title/Cast.tsx
Normal 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;
|
106
src/components/title/DidYouKnow.tsx
Normal file
106
src/components/title/DidYouKnow.tsx
Normal 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;
|
333
src/components/title/Info.tsx
Normal file
333
src/components/title/Info.tsx
Normal 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;
|
90
src/components/title/Media.tsx
Normal file
90
src/components/title/Media.tsx
Normal 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;
|
64
src/components/title/MoreLikeThis.tsx
Normal file
64
src/components/title/MoreLikeThis.tsx
Normal 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;
|
82
src/components/title/Reviews.tsx
Normal file
82
src/components/title/Reviews.tsx
Normal 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;
|
55
src/context/theme-context.tsx
Normal file
55
src/context/theme-context.tsx
Normal 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;
|
832
src/interfaces/misc/rawTitle.ts
Normal file
832
src/interfaces/misc/rawTitle.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
5
src/interfaces/shared/error.ts
Normal file
5
src/interfaces/shared/error.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type AppError = {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
stack?: any;
|
||||
};
|
25
src/interfaces/shared/title.ts
Normal file
25
src/interfaces/shared/title.ts
Normal 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
52
src/layouts/Footer.tsx
Normal 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
|
||||
<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
86
src/layouts/Header.tsx
Normal 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
|
||||
<a href='https://codeberg.org/teddit/teddit' className='link'>
|
||||
teddit
|
||||
</a>
|
||||
,
|
||||
<a href='https://github.com/zedeus/nitter' className='link'>
|
||||
nitter
|
||||
</a>
|
||||
, and
|
||||
<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
23
src/layouts/Layout.tsx
Normal 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
7
src/pages/404.tsx
Normal 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
6
src/pages/500.tsx
Normal 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
40
src/pages/_app.tsx
Normal 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
38
src/pages/_document.tsx
Normal 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
176
src/pages/about/index.tsx
Normal 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;
|
49
src/pages/contact/index.tsx
Normal file
49
src/pages/contact/index.tsx
Normal 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;
|
74
src/pages/privacy/index.tsx
Normal file
74
src/pages/privacy/index.tsx
Normal 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;
|
108
src/pages/title/[titleId]/index.tsx
Normal file
108
src/pages/title/[titleId]/index.tsx
Normal 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',
|
||||
};
|
||||
};
|
||||
*/
|
1
src/styles/abstracts/_index.scss
Normal file
1
src/styles/abstracts/_index.scss
Normal file
@ -0,0 +1 @@
|
||||
@forward './mixins';
|
80
src/styles/abstracts/_mixins.scss
Normal file
80
src/styles/abstracts/_mixins.scss
Normal 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;
|
||||
}
|
||||
}
|
3
src/styles/abstracts/variables/_index.scss
Normal file
3
src/styles/abstracts/variables/_index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
@forward './misc';
|
||||
@forward './typography';
|
||||
@forward './themes';
|
28
src/styles/abstracts/variables/_misc.scss
Normal file
28
src/styles/abstracts/variables/_misc.scss
Normal 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%);
|
62
src/styles/abstracts/variables/_themes.scss
Normal file
62
src/styles/abstracts/variables/_themes.scss
Normal 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,
|
||||
);
|
56
src/styles/abstracts/variables/_typography.scss
Normal file
56
src/styles/abstracts/variables/_typography.scss
Normal 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,
|
||||
);
|
24
src/styles/base/_base.scss
Normal file
24
src/styles/base/_base.scss
Normal 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;
|
||||
}
|
5
src/styles/base/_fonts.scss
Normal file
5
src/styles/base/_fonts.scss
Normal file
@ -0,0 +1,5 @@
|
||||
@font-face {
|
||||
font-family: 'RedHat Display';
|
||||
src: url('../../../public/fonts/RedHatDisplay-VariableFont_wght.ttf');
|
||||
font-display: swap;
|
||||
}
|
27
src/styles/base/_helpers.scss
Normal file
27
src/styles/base/_helpers.scss
Normal 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;
|
||||
}
|
6
src/styles/base/_index.scss
Normal file
6
src/styles/base/_index.scss
Normal file
@ -0,0 +1,6 @@
|
||||
@forward './reset';
|
||||
// @forward './helpers';
|
||||
@forward './root';
|
||||
@forward './base';
|
||||
@forward './fonts';
|
||||
@forward './typography';
|
54
src/styles/base/_reset.scss
Normal file
54
src/styles/base/_reset.scss
Normal 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;
|
||||
}
|
||||
}
|
27
src/styles/base/_root.scss
Normal file
27
src/styles/base/_root.scss
Normal 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');
|
||||
}
|
||||
}
|
22
src/styles/base/_typography.scss
Normal file
22
src/styles/base/_typography.scss
Normal 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);
|
||||
}
|
||||
}
|
1
src/styles/components/_index.scss
Normal file
1
src/styles/components/_index.scss
Normal file
@ -0,0 +1 @@
|
||||
@forward './links';
|
6
src/styles/components/_links.scss
Normal file
6
src/styles/components/_links.scss
Normal 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
4
src/styles/main.scss
Normal file
@ -0,0 +1,4 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
@use './base';
|
||||
@use './components';
|
@ -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);
|
||||
}
|
33
src/styles/modules/components/error/error-info.module.scss
Normal file
33
src/styles/modules/components/error/error-info.module.scss
Normal 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;
|
||||
}
|
@ -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%);
|
||||
}
|
||||
}
|
172
src/styles/modules/components/title/basic.module.scss
Normal file
172
src/styles/modules/components/title/basic.module.scss
Normal 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);
|
||||
}
|
||||
}
|
76
src/styles/modules/components/title/cast.module.scss
Normal file
76
src/styles/modules/components/title/cast.module.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
.didYouKnow {
|
||||
display: grid;
|
||||
gap: var(--comp-whitespace);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--comp-whitespace);
|
||||
}
|
35
src/styles/modules/components/title/info.module.scss
Normal file
35
src/styles/modules/components/title/info.module.scss
Normal 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);
|
||||
}
|
||||
}
|
101
src/styles/modules/components/title/media.module.scss
Normal file
101
src/styles/modules/components/title/media.module.scss
Normal 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
|
||||
}
|
||||
}
|
105
src/styles/modules/components/title/more-like-this.module.scss
Normal file
105
src/styles/modules/components/title/more-like-this.module.scss
Normal 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);
|
||||
}
|
||||
}
|
26
src/styles/modules/components/title/reviews.module.scss
Normal file
26
src/styles/modules/components/title/reviews.module.scss
Normal 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);
|
||||
}
|
||||
}
|
37
src/styles/modules/layout/footer.module.scss
Normal file
37
src/styles/modules/layout/footer.module.scss
Normal 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;
|
||||
}
|
91
src/styles/modules/layout/header.module.scss
Normal file
91
src/styles/modules/layout/header.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
114
src/styles/modules/pages/about/about.module.scss
Normal file
114
src/styles/modules/pages/about/about.module.scss
Normal 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);
|
||||
}
|
||||
}
|
32
src/styles/modules/pages/contact/contact.module.scss
Normal file
32
src/styles/modules/pages/contact/contact.module.scss
Normal 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);
|
||||
}
|
36
src/styles/modules/pages/privacy/privacy.module.scss
Normal file
36
src/styles/modules/pages/privacy/privacy.module.scss
Normal 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);
|
||||
}
|
||||
}
|
71
src/styles/modules/pages/title/title.module.scss
Normal file
71
src/styles/modules/pages/title/title.module.scss
Normal 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;
|
||||
}
|
14
src/utils/axiosInstance.ts
Normal file
14
src/utils/axiosInstance.ts
Normal 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
377
src/utils/cleaners/title.ts
Normal 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;
|
29
src/utils/fetchers/title.ts
Normal file
29
src/utils/fetchers/title.ts
Normal 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
62
src/utils/helpers.ts
Normal 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);
|
||||
}
|
||||
};
|
Reference in New Issue
Block a user