Compare commits
33 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
410cc70259 | ||
![]() |
258a82f2ac | ||
![]() |
e2a335f98d | ||
![]() |
246f1155d5 | ||
![]() |
19f1700a55 | ||
![]() |
9fdd731136 | ||
![]() |
4dffbbc0ec | ||
![]() |
264442448f | ||
![]() |
2b00d5406a | ||
![]() |
97f1432ac5 | ||
![]() |
60fb23fc5b | ||
![]() |
12eaa741ab | ||
![]() |
40eb8a372b | ||
![]() |
e91c313f12 | ||
![]() |
5fa5e9e2c2 | ||
![]() |
27322a4c8c | ||
![]() |
21a1c83d95 | ||
![]() |
b07cb713d8 | ||
![]() |
5628d6b75d | ||
![]() |
38ed0c6217 | ||
![]() |
c610ef4d1b | ||
![]() |
736d680243 | ||
![]() |
0aea2f47da | ||
![]() |
23eeae3558 | ||
![]() |
bb6405cb05 | ||
![]() |
c53c88db9b | ||
![]() |
8599ae2c5a | ||
![]() |
8d9b6630a5 | ||
![]() |
be80244eb3 | ||
![]() |
a0f3ba095a | ||
![]() |
11aea1d489 | ||
![]() |
3ef41d9a6d | ||
![]() |
7dea9eac14 |
64 changed files with 2329 additions and 1142 deletions
|
@ -24,10 +24,15 @@ NEXT_TELEMETRY_DISABLED=1
|
|||
################################################################################
|
||||
### 3. REDIS CONFIG(optional if you don't need redis)
|
||||
################################################################################
|
||||
## if you want to use redis to speed up the media proxy, set this to true
|
||||
## enables caching of api routes as well as media
|
||||
# USE_REDIS=true
|
||||
## in case you don't want to cache media but only api routes
|
||||
# USE_REDIS_FOR_API_ONLY=true
|
||||
## ttl for media and api
|
||||
# REDIS_CACHE_TTL_API=3600
|
||||
# REDIS_CACHE_TTL_MEDIA=3600
|
||||
## for docker, just set the domain to the container name, default is 'libremdb_redis'
|
||||
REDIS_URL=localhost:6379
|
||||
# REDIS_URL=localhost:6379
|
||||
|
||||
################################################################################
|
||||
### 4. INSTANCE META FIELDS(not required but good to have)
|
||||
|
|
32
CHANGELOG.md
32
CHANGELOG.md
|
@ -2,6 +2,38 @@
|
|||
|
||||
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
||||
|
||||
## [3.2.0](https://github.com/zyachel/libremdb/compare/v3.1.1...v3.2.0) (2023-10-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **list:** add list route ([97f1432](https://github.com/zyachel/libremdb/commit/97f1432ac5d23206229d806b7cb3e04af6dec36f))
|
||||
|
||||
## [3.1.1](https://github.com/zyachel/libremdb/compare/v3.1.0...v3.1.1) (2023-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card:** fix long attributes in cards under 'Known For' section ([736d680](https://github.com/zyachel/libremdb/commit/736d6802430a3f4f364915f3df93fc548a51ebf1))
|
||||
* **error:** fix incorrect 'view on IMDb' link on error page ([0aea2f4](https://github.com/zyachel/libremdb/commit/0aea2f47dad6eb78e319ea1abd8c444f2cba4424))
|
||||
* **media proxy:** fix 304 response code with body error ([c610ef4](https://github.com/zyachel/libremdb/commit/c610ef4d1be39c122715a0eb200155537e7d6abf))
|
||||
* **name:** fix name route crash ([38ed0c6](https://github.com/zyachel/libremdb/commit/38ed0c62177532b93f61af4172ffa6e5b9995bdc))
|
||||
* **name:** fix route crash for some ids ([e91c313](https://github.com/zyachel/libremdb/commit/e91c313f127632f1bd44d190af71bc841bbe87b7))
|
||||
* **title:** fix a crash in title route ([21a1c83](https://github.com/zyachel/libremdb/commit/21a1c83d95b703fa08cdb96c206626f22d5366c9))
|
||||
|
||||
## [3.1.0](https://github.com/zyachel/libremdb/compare/v3.0.0...v3.1.0) (2023-05-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cache:** implement caching of routes ([c53c88d](https://github.com/zyachel/libremdb/commit/c53c88db9bf98258547e2ca512f864800821cb1f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **form:** fix hydration error ([8599ae2](https://github.com/zyachel/libremdb/commit/8599ae2c5ac11f2818f56c9f7de7666a38b4386c))
|
||||
* **name:** fix a couple of crashes in name and title route ([8d9b663](https://github.com/zyachel/libremdb/commit/8d9b6630a576b7e8331eb5431cd90d02733b4917))
|
||||
|
||||
## [3.0.0](https://github.com/zyachel/libremdb/compare/v2.4.0...v3.0.0) (2023-04-15)
|
||||
|
||||
|
||||
|
|
19
README.md
19
README.md
|
@ -38,19 +38,22 @@ Inspired by projects like [teddit](https://codeberg.org/teddit/teddit), [nitter]
|
|||
| 1. Clearnet | | |
|
||||
| [libremdb.iket.me](https://libremdb.iket.me) | Canada | Operated by me |
|
||||
| [libremdb.pussthecat.org](https://libremdb.pussthecat.org) | Germany | Operated by [PussTheCat.org](https://pussthecat.org/) |
|
||||
| [libremdbeu.herokuapp.com](https://libremdbeu.herokuapp.com) | Europe | Operated by [toyboatcash](https://github.com/toyboatcash) |
|
||||
| [lmdb.tokhmi.xyz](https://lmdb.tokhmi.xyz) | U.S. | Operated by [Tokhmi](https://tokhmi.xyz) |
|
||||
| [libremdb.esmailelbob.xyz](https://libremdb.esmailelbob.xyz) | Canada | Operated by [Esmail EL BoB](https://esmailelbob.xyz) |
|
||||
| [ld.vern.cc](https://ld.vern.cc) | US | Operated by [~vern](https://vern.cc) |
|
||||
| [binge.whatever.social](https://binge.whatever.social) | US & Germany | Operated by [Whatever Social](https://whatever.social) |
|
||||
| [libremdb.lunar.icu](https://libremdb.lunar.icu/) | Germany (Cloudflare) | Operated by [lunar.icu](https://lunar.icu/) |
|
||||
| [libremdb.lunar.icu](https://libremdb.lunar.icu) | Germany (Cloudflare) | Operated by [lunar.icu](https://lunar.icu/) |
|
||||
| [libremdb.jeikobu.net](https://libremdb.jeikobu.net) | Germany (Cloudflare) | Operated by [shindouj](https://github.com/shindouj) |
|
||||
| [lmdb.hostux.net](https://lmdb.hostux.net) | France | Operated by [Hostux.net](https://hostux.net) |
|
||||
| [binge.whateveritworks.org](https://binge.whateveritworks.org) | Germany (Cloudflare) | Operated by [WhateverItWorks](https://github.com/WhateverItWorks) |
|
||||
| [libremdb.nerdyfam.tech](https://libremdb.nerdyfam.tech) | US | Operated by [Nerdyfam.tech](https://nerdyfam.tech/) |
|
||||
| [libremdb.tux.pizza](https://libremdb.tux.pizza) | US | Operated by [tux.pizza](https://tux.pizza) |
|
||||
| [libremdb.frontendfriendly.xyz](https://libremdb.frontendfriendly.xyz) | — | Operated by [frontendfriendly.xyz](https://frontendfriendly.xyz) |
|
||||
[d.opnxng.com](https://d.opnxng.com) | Singapore | Operated by [Opnxng](https://about.opnxng.com/)
|
||||
[libremdb.catsarch.com](https://libremdb.catsarch.com) | US | Operated by [Butter Cat](https://catsarch.com/)
|
||||
[mdb.sudovanilla.com](https://mdb.sudovanilla.com) | US (Cloudflare) | Operated by [SudoVanilla](https://sudovanilla.com/)
|
||||
| 2. Onion | | |
|
||||
| [libremdb.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion](http://libremdb.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion) | Canada | Operated by [Esmail EL BoB](https://esmailelbob.xyz) |
|
||||
| [ld.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://ld.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) | US | Operated by [~vern](https://vern.cc) |
|
||||
| [ld.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://ld.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) | US | Operated by [~vern](https://vern.cc) |
|
||||
| 3. I2P | | |
|
||||
| [vernz3ubrntql4wrgyrssd6u3qzi36zrhz2agbo6vibzbs5olk2q.b32.i2p](http://vernz3ubrntql4wrgyrssd6u3qzi36zrhz2agbo6vibzbs5olk2q.b32.i2p) | US | Operated by [~vern](https://vern.cc) |
|
||||
| [vernz3ubrntql4wrgyrssd6u3qzi36zrhz2agbo6vibzbs5olk2q.b32.i2p](http://vernz3ubrntql4wrgyrssd6u3qzi36zrhz2agbo6vibzbs5olk2q.b32.i2p) | US | Operated by [~vern](https://vern.cc) |
|
||||
|
||||
---
|
||||
|
||||
|
@ -111,7 +114,7 @@ Inspired by projects like [teddit](https://codeberg.org/teddit/teddit), [nitter]
|
|||
- [ ] company info
|
||||
- [ ] user info
|
||||
|
||||
- [ ] use redis, or any other caching strategy
|
||||
- [X] use redis, or any other caching strategy
|
||||
- [x] implement a better installation method
|
||||
- [x] serve images and videos from libremdb itself
|
||||
|
||||
|
|
|
@ -2,21 +2,14 @@
|
|||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
async rewrites() {
|
||||
return {
|
||||
afterFiles: [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/find',
|
||||
},
|
||||
],
|
||||
fallback: [
|
||||
{
|
||||
source: '/:path*',
|
||||
destination: '/404',
|
||||
},
|
||||
],
|
||||
};
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/find',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
domains: ['m.media-amazon.com'],
|
||||
|
|
10
package.json
10
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "libremdb",
|
||||
"version": "3.0.0",
|
||||
"version": "3.2.0",
|
||||
"description": "a free & open source IMDb front-end",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
@ -19,11 +19,11 @@
|
|||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"ioredis": "^5.2.4",
|
||||
"ioredis": "^5.3.2",
|
||||
"next": "12.2.5",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"sharp": "^0.31.0"
|
||||
"sharp": "^0.31.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.7.3",
|
||||
|
@ -31,11 +31,11 @@
|
|||
"@types/react-dom": "18.0.6",
|
||||
"eslint": "8.22.0",
|
||||
"eslint-config-next": "12.2.5",
|
||||
"sass": "^1.54.4",
|
||||
"sass": "^1.62.1",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.5.0",
|
||||
"pnpm": ">=7.0.0"
|
||||
"pnpm": ">=8.0.0"
|
||||
}
|
||||
}
|
||||
|
|
1396
pnpm-lock.yaml
generated
1396
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -25,16 +25,14 @@ const CardResult = ({ link, name, image, showImage, children, ...rest }: Props)
|
|||
);
|
||||
|
||||
return (
|
||||
<Card hoverable {...rest}>
|
||||
<Link href={link}>
|
||||
<a className={`${styles.item} ${!showImage && styles.sansImage}`}>
|
||||
<div className={styles.imgContainer}>{ImageComponent}</div>
|
||||
<div className={styles.info}>
|
||||
<p className={`heading ${styles.heading}`}>{name}</p>
|
||||
{children}
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
<Card hoverable {...rest} className={`${styles.item} ${!showImage && styles.sansImage}`}>
|
||||
<div className={styles.imgContainer}>{ImageComponent}</div>
|
||||
<div className={styles.info}>
|
||||
<Link href={link}>
|
||||
<a className={`heading ${styles.heading}`}>{name}</a>
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -52,7 +52,7 @@ const CardTitle = ({ link, name, year, image, ratings, titleType, children, ...r
|
|||
<span> ({formatNumber(ratings.numVotes)} votes)</span>
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
<div className={styles.children}>{children}</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Layout from 'src/layouts/Layout';
|
||||
import Layout from 'src/components/layout';
|
||||
import Meta from 'src/components/meta/Meta';
|
||||
import styles from 'src/styles/modules/components/error/error-info.module.scss';
|
||||
|
||||
|
@ -11,7 +10,8 @@ import styles from 'src/styles/modules/components/error/error-info.module.scss';
|
|||
type Props = {
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
// props specific to error boundary.
|
||||
originalPath?: string;
|
||||
/** props specific to error boundary. */
|
||||
misc?: {
|
||||
subtext: string;
|
||||
buttonText: string;
|
||||
|
@ -19,12 +19,12 @@ type Props = {
|
|||
};
|
||||
};
|
||||
|
||||
const ErrorInfo = ({ message, statusCode, misc }: Props) => {
|
||||
const ErrorInfo = ({ message, statusCode, misc, originalPath }: Props) => {
|
||||
const title = statusCode ? `${message} (${statusCode})` : message;
|
||||
return (
|
||||
<>
|
||||
<Meta title={title} description='you encountered an error page!' />
|
||||
<Layout className={styles.error}>
|
||||
<Layout className={styles.error} originalPath={originalPath}>
|
||||
<svg
|
||||
className={styles.gnu}
|
||||
focusable='false'
|
||||
|
@ -52,6 +52,15 @@ const ErrorInfo = ({ message, statusCode, misc }: Props) => {
|
|||
<Link href='/'>
|
||||
<a className='link'>the homepage</a>
|
||||
</Link>
|
||||
, or view this route{' '}
|
||||
<a
|
||||
className='link'
|
||||
href={`https://www.imdb.com${originalPath ?? ''}`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
on IMDb
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
|
|
|
@ -5,41 +5,16 @@ import { QueryTypes } from 'src/interfaces/shared/search';
|
|||
import { resultTypes, resultTitleTypes } from 'src/utils/constants/find';
|
||||
import styles from 'src/styles/modules/components/form/find.module.scss';
|
||||
|
||||
/**
|
||||
* helper function to render similar radio btns. saves from boilerplate.
|
||||
* @param data radio btn obj
|
||||
* @param parentClass class under which radio input and label will be
|
||||
* @returns JSX array of radios
|
||||
*/
|
||||
const renderRadioBtns = (
|
||||
data: typeof resultTypes | typeof resultTitleTypes,
|
||||
parentClass: string
|
||||
) => {
|
||||
return data.types.map(({ name, val }) => (
|
||||
<p className={parentClass} key={val}>
|
||||
<input
|
||||
type='radio'
|
||||
name={data.key}
|
||||
id={`${data.key}:${val}`}
|
||||
value={val}
|
||||
className='visually-hidden'
|
||||
/>
|
||||
<label htmlFor={`${data.key}:${val}`}>{name}</label>
|
||||
</p>
|
||||
));
|
||||
};
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// MAIN FUNCTION
|
||||
const Form = ({ className }: Props) => {
|
||||
const router = useRouter();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
|
||||
// title types can't be selected unless type selected is 'title'. below is the logic for disabling/enabling titleTypes.
|
||||
// title types can't be selected unless type selected is 'title'
|
||||
const typesChangeHandler: ChangeEventHandler<HTMLFieldSetElement> = e => {
|
||||
const el = e.target as unknown as HTMLInputElement; // we have only radios that'll fire change event.
|
||||
const value = el.value as QueryTypes;
|
||||
|
@ -60,6 +35,7 @@ const Form = ({ className }: Props) => {
|
|||
const queryStr = cleanQueryStr(entries);
|
||||
|
||||
if (query) router.push(`/find?${queryStr}`);
|
||||
else setIsDisabled(false);
|
||||
formEl.reset();
|
||||
};
|
||||
|
||||
|
@ -87,22 +63,20 @@ const Form = ({ className }: Props) => {
|
|||
name='q'
|
||||
placeholder='movies, people...'
|
||||
className={styles.searchbar__input}
|
||||
required
|
||||
minLength={2}
|
||||
/>
|
||||
<label className='visually-hidden' htmlFor='searchbar'>
|
||||
Search for anything
|
||||
</label>
|
||||
</p>
|
||||
<fieldset className={styles.types} onChange={typesChangeHandler}>
|
||||
<legend className={`heading ${styles.types__heading}`}>
|
||||
Filter by Type
|
||||
</legend>
|
||||
{renderRadioBtns(resultTypes, styles.type)}
|
||||
<legend className={`heading ${styles.types__heading}`}>Filter by Type</legend>
|
||||
<RadioBtns data={resultTypes} className={styles.type} />
|
||||
</fieldset>
|
||||
<fieldset className={styles.titleTypes} disabled={isDisabled}>
|
||||
<legend className={`heading ${styles.titleTypes__heading}`}>
|
||||
Filter by Title Type
|
||||
</legend>
|
||||
{renderRadioBtns(resultTitleTypes, styles.titleType)}
|
||||
<legend className={`heading ${styles.titleTypes__heading}`}>Filter by Title Type</legend>
|
||||
<RadioBtns data={resultTitleTypes} className={styles.titleType} />
|
||||
</fieldset>
|
||||
<p className={styles.exact}>
|
||||
<label htmlFor='exact'>Exact Matches</label>
|
||||
|
@ -120,4 +94,28 @@ const Form = ({ className }: Props) => {
|
|||
);
|
||||
};
|
||||
|
||||
const RadioBtns = ({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: typeof resultTypes | typeof resultTitleTypes;
|
||||
className: string;
|
||||
}) => (
|
||||
<>
|
||||
{data.types.map(({ name, val }) => (
|
||||
<p className={className} key={val}>
|
||||
<input
|
||||
type='radio'
|
||||
name={data.key}
|
||||
id={`${data.key}:${val}`}
|
||||
value={val}
|
||||
className='visually-hidden'
|
||||
/>
|
||||
<label htmlFor={`${data.key}:${val}`}>{name}</label>
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
export default Form;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import styles from '../styles/modules/layout/footer.module.scss';
|
||||
import styles from 'src/styles/modules/layout/footer.module.scss';
|
||||
|
||||
const links = [
|
||||
{ path: '/about', text: 'About' },
|
|
@ -1,21 +1,14 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import ThemeToggler from 'src/components/buttons/ThemeToggler';
|
||||
import styles from 'src/styles/modules/layout/header.module.scss';
|
||||
|
||||
type Props = { full?: boolean; children?: ReactNode };
|
||||
|
||||
const Header = (props: Props) => {
|
||||
const { asPath: path } = useRouter();
|
||||
type Props = { full?: boolean; originalPath?: string };
|
||||
|
||||
const Header = ({ full, originalPath }: Props) => {
|
||||
return (
|
||||
<header
|
||||
id='header'
|
||||
className={`${styles.header} ${props.full ? styles.header__about : ''}`}
|
||||
>
|
||||
<header id='header' className={`${styles.header} ${full ? styles.header__about : ''}`}>
|
||||
<div className={styles.topbar}>
|
||||
<Link href='/'>
|
||||
<Link href='/find'>
|
||||
<a aria-label='go to homepage' className={styles.logo}>
|
||||
<svg className={styles.logo__icon} role='img' aria-hidden>
|
||||
<use href='/svg/sprite.svg#icon-logo'></use>
|
||||
|
@ -23,7 +16,7 @@ const Header = (props: Props) => {
|
|||
<span className={styles.logo__text}>libremdb</span>
|
||||
</a>
|
||||
</Link>
|
||||
{props.full && (
|
||||
{full && (
|
||||
<nav className={styles.nav}>
|
||||
<ul className={styles.nav__list}>
|
||||
<li className={styles.nav__item}>
|
||||
|
@ -45,14 +38,8 @@ const Header = (props: Props) => {
|
|||
</nav>
|
||||
)}
|
||||
<div className={styles.misc}>
|
||||
<a
|
||||
href={`https://www.imdb.com${path}`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<span className='visually-hidden'>
|
||||
View on IMDb (opens in new tab)
|
||||
</span>
|
||||
<a href={`https://www.imdb.com${originalPath ?? ''}`} target='_blank' rel='noreferrer'>
|
||||
<span className='visually-hidden'>View on IMDb (opens in new tab)</span>
|
||||
<svg className='icon' role='img' aria-hidden>
|
||||
<use href='/svg/sprite.svg#icon-external-link'></use>
|
||||
</svg>
|
||||
|
@ -68,7 +55,7 @@ const Header = (props: Props) => {
|
|||
<ThemeToggler className={styles.themeToggler} />
|
||||
</div>
|
||||
</div>
|
||||
{props.full && (
|
||||
{full && (
|
||||
<div className={styles.hero}>
|
||||
<h1 className={`heading heading__primary ${styles.hero__text}`}>
|
||||
A free & open source IMDb front-end
|
||||
|
@ -83,10 +70,7 @@ const Header = (props: Props) => {
|
|||
nitter
|
||||
</a>
|
||||
, and{' '}
|
||||
<a
|
||||
href='https://github.com/digitalblossom/alternative-frontends'
|
||||
className='link'
|
||||
>
|
||||
<a href='https://github.com/digitalblossom/alternative-frontends' className='link'>
|
||||
many others
|
||||
</a>
|
||||
.
|
|
@ -6,12 +6,13 @@ type Props = {
|
|||
full?: true;
|
||||
children: ReactNode;
|
||||
className: string;
|
||||
originalPath?: string;
|
||||
};
|
||||
|
||||
const Layout = ({ full, children, className }: Props) => {
|
||||
const Layout = ({ full, children, className, originalPath }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Header full={full} />
|
||||
<Header full={full} originalPath={originalPath} />
|
||||
<main id='main' className={`main ${className}`}>
|
||||
{children}
|
||||
</main>
|
23
src/components/list/Data.tsx
Normal file
23
src/components/list/Data.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import type { DataKind, Data as TData } from 'src/interfaces/shared/list';
|
||||
import type { ToArray } from 'src/interfaces/shared';
|
||||
import Images from './Images';
|
||||
import Names from './Names';
|
||||
import Titles from './Titles';
|
||||
|
||||
type Props = {
|
||||
data: ToArray<TData<DataKind>>;
|
||||
};
|
||||
|
||||
const Data = ({ data }: Props) => {
|
||||
if (isDataImages(data)) return <Images images={data} />;
|
||||
if (isDataNames(data)) return <Names names={data} />;
|
||||
|
||||
return <Titles titles={data} />;
|
||||
};
|
||||
export default Data;
|
||||
|
||||
const isDataImages = (data: unknown): data is TData<'images'>[] =>
|
||||
Array.isArray(data) && typeof data[0] === 'string';
|
||||
|
||||
const isDataNames = (data: unknown): data is TData<'names'>[] =>
|
||||
Array.isArray(data) && data[0] && typeof data[0] === 'object' && 'about' in data[0];
|
22
src/components/list/Images.tsx
Normal file
22
src/components/list/Images.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import Image from 'next/future/image';
|
||||
import { modifyIMDbImg } from 'src/utils/helpers';
|
||||
import type { Data } from 'src/interfaces/shared/list';
|
||||
import styles from 'src/styles/modules/components/list/images.module.scss';
|
||||
|
||||
type Props = {
|
||||
images: Data<'images'>[];
|
||||
};
|
||||
|
||||
const Images = ({ images }: Props) => {
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
{images.map(image => (
|
||||
<figure className={styles.imgContainer} key={image}>
|
||||
<Image src={modifyIMDbImg(image, 400)} alt='' fill className={styles.img} sizes='200px'/>
|
||||
</figure>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Images;
|
35
src/components/list/Meta.tsx
Normal file
35
src/components/list/Meta.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import Link from 'next/link';
|
||||
import { formatDate } from 'src/utils/helpers';
|
||||
import List from 'src/interfaces/shared/list';
|
||||
import styles from 'src/styles/modules/components/list/meta.module.scss';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
meta: List['meta'];
|
||||
description: List['description'];
|
||||
};
|
||||
const Meta = ({ title, meta, description }: Props) => {
|
||||
const by = meta.by.link ? (
|
||||
<Link href={meta.by.link}>
|
||||
<a className='link'>{meta.by.name}</a>
|
||||
</Link>
|
||||
) : (
|
||||
meta.by.name
|
||||
);
|
||||
|
||||
return (
|
||||
<header className={styles.container}>
|
||||
<h1 className='heading heading__secondary'>{title}</h1>
|
||||
<ul className={styles.list}>
|
||||
<li>by {by}</li>
|
||||
<li>{meta.created}</li>
|
||||
{meta.updated && <li>{meta.updated}</li>}
|
||||
<li>
|
||||
{meta.num} {meta.type}
|
||||
</li>
|
||||
</ul>
|
||||
{description && <p>{description}</p>}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
export default Meta;
|
57
src/components/list/Names.tsx
Normal file
57
src/components/list/Names.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import Image from 'next/future/image';
|
||||
import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
|
||||
import { Card } from 'src/components/card';
|
||||
import type { Data } from 'src/interfaces/shared/list';
|
||||
import styles from 'src/styles/modules/components/list/names.module.scss';
|
||||
import OptionalLink from './OptionalLink';
|
||||
|
||||
type Props = {
|
||||
names: Data<'names'>[];
|
||||
};
|
||||
|
||||
const Names = ({ names }: Props) => {
|
||||
return (
|
||||
<ul className={styles.names}>
|
||||
{names.map(name => (
|
||||
<Name {...name} key={name.name} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
export default Names;
|
||||
|
||||
const Name = ({ about, image, job, knownFor, knownForLink, name, url }: Props['names'][number]) => {
|
||||
// const style: CSSProperties = {
|
||||
// backgroundImage: image ? `url(${getProxiedIMDbImgUrl(modifyIMDbImg(image, 300))})` : undefined,
|
||||
// };
|
||||
|
||||
return (
|
||||
<Card hoverable className={styles.name}>
|
||||
<div className={styles.imgContainer}>
|
||||
{image ? (
|
||||
<Image src={modifyIMDbImg(image, 400)} alt='' fill className={styles.img} sizes='200px' />
|
||||
) : (
|
||||
<svg className={styles.imgNA}>
|
||||
<use href='/svg/sprite.svg#icon-image-slash' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<h2 className={`heading ${styles.heading}`}>
|
||||
<OptionalLink href={url} className={`heading ${styles.heading}`}>
|
||||
{name}
|
||||
</OptionalLink>
|
||||
</h2>
|
||||
<ul className={styles.basicInfo} aria-label='quick facts'>
|
||||
{job && <li>{job}</li>}
|
||||
{knownFor && (
|
||||
<li>
|
||||
<OptionalLink href={knownForLink}>{knownFor}</OptionalLink>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<p>{about}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
20
src/components/list/OptionalLink.tsx
Normal file
20
src/components/list/OptionalLink.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import type { ReactNode, ComponentPropsWithoutRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const OptionalLink = ({
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: { href?: string | null; children: ReactNode } & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
|
||||
<>
|
||||
{href ? (
|
||||
<Link href={href}>
|
||||
<a {...rest}>{children}</a>
|
||||
</Link>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
export default OptionalLink;
|
33
src/components/list/Pagination.tsx
Normal file
33
src/components/list/Pagination.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import OptionalLink from './OptionalLink';
|
||||
import type List from 'src/interfaces/shared/list';
|
||||
import styles from 'src/styles/modules/components/list/pagination.module.scss';
|
||||
|
||||
type Props = {
|
||||
pagination: List['pagination'];
|
||||
};
|
||||
const Pagination = ({ pagination }: Props) => {
|
||||
const prevLink = pagination.prev && pagination.prev !== '#' ? pagination.prev : null;
|
||||
const nextLink = pagination.next && pagination.next !== '#' ? pagination.next : null;
|
||||
|
||||
if (!prevLink && !nextLink) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label='pagination'>
|
||||
<ul className={styles.nav}>
|
||||
<li aria-hidden={!prevLink}>
|
||||
<OptionalLink href={prevLink} className='link'>
|
||||
Prev
|
||||
</OptionalLink>
|
||||
</li>
|
||||
<li>{pagination.range} shown</li>
|
||||
<li aria-hidden={!nextLink}>
|
||||
<OptionalLink href={nextLink} className='link'>
|
||||
Next
|
||||
</OptionalLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
79
src/components/list/Titles.tsx
Normal file
79
src/components/list/Titles.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import Image from 'next/future/image';
|
||||
import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
|
||||
import { Card } from 'src/components/card';
|
||||
import type { Data } from 'src/interfaces/shared/list';
|
||||
import styles from 'src/styles/modules/components/list/titles.module.scss';
|
||||
import { CSSProperties } from 'react';
|
||||
import OptionalLink from './OptionalLink';
|
||||
|
||||
type Props = {
|
||||
titles: Data<'titles'>[];
|
||||
};
|
||||
|
||||
const Titles = ({ titles }: Props) => {
|
||||
return (
|
||||
<ul className={styles.titles}>
|
||||
{titles.map(title => (
|
||||
<Title {...title} key={title.name} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
export default Titles;
|
||||
|
||||
const Title = (props: Props['titles'][number]) => {
|
||||
const style: CSSProperties = {
|
||||
backgroundImage: props.image
|
||||
? `url(${getProxiedIMDbImgUrl(modifyIMDbImg(props.image, 300))})`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card hoverable className={styles.title}>
|
||||
<div className={styles.imgContainer}>
|
||||
{props.image ? (
|
||||
<Image src={modifyIMDbImg(props.image, 400)} alt='' fill className={styles.img} />
|
||||
) : (
|
||||
<svg className={styles.imgNA}>
|
||||
<use href='/svg/sprite.svg#icon-image-slash' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<h2 className={`heading heading__tertiary ${styles.heading}`}>
|
||||
<OptionalLink href={props.url} className={`heading ${styles.heading}`}>
|
||||
{props.name} {props.year}
|
||||
</OptionalLink>
|
||||
</h2>
|
||||
<ul className={styles.basicInfo} aria-label='quick facts'>
|
||||
{props.certificate && <li>{props.certificate}</li>}
|
||||
{props.runtime && <li>{props.runtime}</li>}
|
||||
{props.genre && <li>{props.genre}</li>}
|
||||
</ul>
|
||||
<ul className={styles.ratings}>
|
||||
{Boolean(props.rating) && <li className={styles.rating}>
|
||||
<span className={styles.rating__num}>{props.rating}</span>
|
||||
<svg className={styles.rating__icon}>
|
||||
<use href='/svg/sprite.svg#icon-rating'></use>
|
||||
</svg>
|
||||
<span className={styles.rating__text}> Avg. rating</span>
|
||||
</li>}
|
||||
{Boolean(props.metascore) && <li className={styles.rating}>
|
||||
<span className={styles.rating__num}>{props.metascore}</span>
|
||||
<span className={styles.rating__text}>Metascore</span>
|
||||
</li>}
|
||||
</ul>
|
||||
<p className={styles.plot}>
|
||||
<span>Plot:</span> {props.plot}
|
||||
</p>
|
||||
<ul className={styles.otherInfo}>
|
||||
{props.otherInfo.map(([infoHeading, info]) => (
|
||||
<li key={infoHeading}>
|
||||
<span>{infoHeading}:</span> {info}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
3
src/components/list/index.ts
Normal file
3
src/components/list/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as Data } from './Data';
|
||||
export { default as Meta } from './Meta';
|
||||
export { default as Pagination } from './Pagination';
|
|
@ -10,7 +10,7 @@ type Props = {
|
|||
|
||||
const Basic = ({ data, className }: Props) => {
|
||||
return (
|
||||
<CardBasic className={className} image={data.poster.url} title={data.name}>
|
||||
<CardBasic className={className} image={data.poster?.url} title={data.name}>
|
||||
<div className={styles.ratings}>
|
||||
{data.ranking && (
|
||||
<p className={styles.rating}>
|
||||
|
@ -39,10 +39,12 @@ const Basic = ({ data, className }: Props) => {
|
|||
{data.bio.short}...
|
||||
</p>
|
||||
}
|
||||
<p className={styles.genres}>
|
||||
<span className={styles.heading}>Known for: </span>
|
||||
{data.knownFor.title} ({data.knownFor.role})
|
||||
</p>
|
||||
{data.knownFor.title && (
|
||||
<p className={styles.genres}>
|
||||
<span className={styles.heading}>Known for: </span>
|
||||
{data.knownFor.title} ({data.knownFor.role})
|
||||
</p>
|
||||
)}
|
||||
</CardBasic>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,59 +13,29 @@ const Credits = ({ className, data }: Props) => {
|
|||
return (
|
||||
<section className={`${className} ${styles.credits}`}>
|
||||
<h2 className='heading heading__secondary'>Credits</h2>
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Released</h3>
|
||||
{data.released.map(
|
||||
(item, i) =>
|
||||
!!item.total && (
|
||||
<details open={i === 0} key={item.category.id}>
|
||||
<summary>
|
||||
{item.category.text} ({item.total})
|
||||
</summary>
|
||||
<ul className={styles.container} key={item.category.id}>
|
||||
{item.titles.map(title => (
|
||||
<CardTitle
|
||||
key={title.id}
|
||||
link={`/title/${title.id}`}
|
||||
name={title.title}
|
||||
titleType={title.type.text}
|
||||
image={title.poster?.url}
|
||||
year={title.releaseYear}
|
||||
ratings={title.ratings}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Unreleased</h3>
|
||||
{data.unreleased.map(
|
||||
(item, i) =>
|
||||
!!item.total && (
|
||||
<details open={i === 0} key={item.category.id}>
|
||||
<summary>
|
||||
{item.category.text} ({item.total})
|
||||
</summary>
|
||||
<ul className={styles.container}>
|
||||
{item.titles.map(title => (
|
||||
<CardTitle
|
||||
key={title.id}
|
||||
link={`/title/${title.id}`}
|
||||
name={title.title}
|
||||
titleType={title.type.text}
|
||||
image={title.poster?.url}
|
||||
year={title.releaseYear}
|
||||
>
|
||||
<p>{title.productionStatus}</p>
|
||||
</CardTitle>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
{data.released.map(
|
||||
(item, i) =>
|
||||
!!item.total && (
|
||||
<details open={i === 0} key={item.category.id}>
|
||||
<summary>
|
||||
{item.category.text} ({item.total})
|
||||
</summary>
|
||||
<ul className={styles.container} key={item.category.id}>
|
||||
{item.titles.map(title => (
|
||||
<CardTitle
|
||||
key={title.id}
|
||||
link={`/title/${title.id}`}
|
||||
name={title.title}
|
||||
titleType={title.type.text}
|
||||
image={title.poster?.url}
|
||||
year={title.releaseYear}
|
||||
ratings={title.ratings}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,46 +7,44 @@ type Props = {
|
|||
};
|
||||
|
||||
const DidYouKnow = ({ data }: Props) => (
|
||||
<section className={styles.didYouKnow}>
|
||||
<section className={styles.container}>
|
||||
<h2 className='heading heading__secondary'>Did you know</h2>
|
||||
<div className={styles.container}>
|
||||
{!!data.trivia?.total && (
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Trivia</h3>
|
||||
<div dangerouslySetInnerHTML={{ __html: data.trivia.html }}></div>
|
||||
</section>
|
||||
)}
|
||||
{!!data.quotes?.total && (
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Quotes</h3>
|
||||
<div dangerouslySetInnerHTML={{ __html: data.quotes.html }}></div>
|
||||
</section>
|
||||
)}
|
||||
{!!data.trademark?.total && (
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Trademark</h3>
|
||||
<div dangerouslySetInnerHTML={{ __html: data.trademark.html }}></div>
|
||||
</section>
|
||||
)}
|
||||
{!!data.nicknames.length && (
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Nicknames</h3>
|
||||
<p>{data.nicknames.join(', ')}</p>
|
||||
</section>
|
||||
)}
|
||||
{!!data.salary?.total && (
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Salary</h3>
|
||||
<p>
|
||||
<span>{data.salary.value} in </span>
|
||||
<Link href={`/title/${data.salary.title.id}`}>
|
||||
<a className={'link'}>{data.salary.title.text}</a>
|
||||
</Link>
|
||||
<span> ({data.salary.title.year})</span>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{!!data.trivia?.total && (
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Trivia</h3>
|
||||
<div dangerouslySetInnerHTML={{ __html: data.trivia.html }}></div>
|
||||
</section>
|
||||
)}
|
||||
{!!data.quotes?.total && (
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Quotes</h3>
|
||||
<div dangerouslySetInnerHTML={{ __html: data.quotes.html }}></div>
|
||||
</section>
|
||||
)}
|
||||
{!!data.trademark?.total && (
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Trademark</h3>
|
||||
<div dangerouslySetInnerHTML={{ __html: data.trademark.html }}></div>
|
||||
</section>
|
||||
)}
|
||||
{!!data.nicknames.length && (
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Nicknames</h3>
|
||||
<p>{data.nicknames.join(', ')}</p>
|
||||
</section>
|
||||
)}
|
||||
{!!data.salary?.total && (
|
||||
<section>
|
||||
<h3 className='heading heading__tertiary'>Salary</h3>
|
||||
<p>
|
||||
<span>{data.salary.value} in </span>
|
||||
<Link href={`/title/${data.salary.title.id}`}>
|
||||
<a className={'link'}>{data.salary.title.text}</a>
|
||||
</Link>
|
||||
<span> ({data.salary.title.year})</span>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
|
|
|
@ -82,13 +82,15 @@ const PersonalDetails = ({ info, accolades }: Props) => {
|
|||
<p>
|
||||
<span>Died: </span>
|
||||
<span>{info.death.date}</span>
|
||||
<span>
|
||||
{' '}
|
||||
in{' '}
|
||||
<Link href={`/search/name?death_place=${info.death.location}`}>
|
||||
<a className='link'>{info.death.location}</a>
|
||||
</Link>
|
||||
</span>
|
||||
{info.death.location && (
|
||||
<span>
|
||||
{' '}
|
||||
in{' '}
|
||||
<Link href={`/search/name?death_place=${info.death.location}`}>
|
||||
<a className='link'>{info.death.location}</a>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{info.death.cause && (
|
||||
|
@ -102,11 +104,9 @@ const PersonalDetails = ({ info, accolades }: Props) => {
|
|||
<span>Spouses: </span>
|
||||
{info.spouses.map((spouse, i) => (
|
||||
<span key={spouse.name}>
|
||||
<>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(spouse)} {spouse.range} (
|
||||
{spouse.attributes.join(', ')})
|
||||
</>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(spouse)} {spouse.range}
|
||||
{spouse.attributes && ' (' + spouse.attributes.join(', ') + ')'}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
|
@ -116,10 +116,8 @@ const PersonalDetails = ({ info, accolades }: Props) => {
|
|||
<span>Children: </span>
|
||||
{info.children.map((child, i) => (
|
||||
<span key={child.name}>
|
||||
<>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(child)}
|
||||
</>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(child)}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
|
@ -129,10 +127,8 @@ const PersonalDetails = ({ info, accolades }: Props) => {
|
|||
<span>Parents: </span>
|
||||
{info.parents.map((parent, i) => (
|
||||
<span key={parent.name}>
|
||||
<>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(parent)}
|
||||
</>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(parent)}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
|
@ -142,10 +138,8 @@ const PersonalDetails = ({ info, accolades }: Props) => {
|
|||
<span>Relatives: </span>
|
||||
{info.relatives.map((relative, i) => (
|
||||
<span key={relative.name}>
|
||||
<>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(relative)} ({relative.relation})
|
||||
</>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(relative)} ({relative.relation})
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
|
@ -155,10 +149,8 @@ const PersonalDetails = ({ info, accolades }: Props) => {
|
|||
<span>Other Works: </span>
|
||||
{info.otherWorks.map((work, i) => (
|
||||
<span key={work.text}>
|
||||
<>
|
||||
{!!i && ', '}
|
||||
<span dangerouslySetInnerHTML={{ __html: work.text }} />
|
||||
</>
|
||||
{!!i && ', '}
|
||||
<span dangerouslySetInnerHTML={{ __html: work.text }} />
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
|
|
|
@ -6,13 +6,13 @@ export default interface Name {
|
|||
nameText: {
|
||||
text: string;
|
||||
};
|
||||
disambiguator?: {
|
||||
text: string;
|
||||
};
|
||||
/*
|
||||
searchIndexing: {
|
||||
disableIndexing: boolean
|
||||
}*/
|
||||
disambiguator?: {
|
||||
text: string;
|
||||
};
|
||||
knownFor: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
|
@ -34,7 +34,7 @@ export default interface Name {
|
|||
total: number;
|
||||
};*/
|
||||
|
||||
primaryImage: {
|
||||
primaryImage?: {
|
||||
id: string;
|
||||
url: string;
|
||||
height: number;
|
||||
|
@ -49,18 +49,17 @@ export default interface Name {
|
|||
publicationStatus: string
|
||||
}
|
||||
*/
|
||||
|
||||
primaryProfessions: Array<{
|
||||
category: {
|
||||
text: string;
|
||||
};
|
||||
}>;
|
||||
bio: {
|
||||
text: {
|
||||
plainText: string;
|
||||
plaidHtml: string;
|
||||
};
|
||||
};
|
||||
primaryProfessions: Array<{
|
||||
category: {
|
||||
text: string;
|
||||
};
|
||||
}>;
|
||||
birthDate: {
|
||||
displayableProperty: {
|
||||
value: {
|
||||
|
@ -105,6 +104,9 @@ export default interface Name {
|
|||
subNavAwardNominations: {
|
||||
total: number;
|
||||
};
|
||||
subNavFaqs: {
|
||||
total: number;
|
||||
};
|
||||
// videos: {
|
||||
// total: number;
|
||||
// };
|
||||
|
@ -222,185 +224,21 @@ export default interface Name {
|
|||
};
|
||||
}>;
|
||||
};
|
||||
// primaryImage: {
|
||||
// id: string;
|
||||
// caption: {
|
||||
// plainText: string;
|
||||
// };
|
||||
// height: number;
|
||||
// width: number;
|
||||
// url: string;
|
||||
// };
|
||||
// imageUploadLink: null;
|
||||
// nameText: {
|
||||
// text: string;
|
||||
// };
|
||||
knownFor: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
summary: {
|
||||
attributes?: Array<{
|
||||
text: string;
|
||||
}>;
|
||||
episodeCount?: number;
|
||||
principalCategory: {
|
||||
text: string;
|
||||
id: string;
|
||||
};
|
||||
principalCharacters?: Array<{
|
||||
name: string;
|
||||
}>;
|
||||
principalJobs?: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
}>;
|
||||
yearRange: {
|
||||
year: number;
|
||||
endYear?: number;
|
||||
};
|
||||
};
|
||||
credit: {
|
||||
attributes?: Array<{
|
||||
text: string;
|
||||
}>;
|
||||
category: {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
characters?: Array<{
|
||||
name: string;
|
||||
}>;
|
||||
episodeCredits: {
|
||||
total: number;
|
||||
yearRange?: {
|
||||
year: number;
|
||||
endYear: number;
|
||||
};
|
||||
displayableYears: {
|
||||
total: number;
|
||||
edges: Array<{
|
||||
node: {
|
||||
year: string;
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
displayableSeasons: {
|
||||
total: number;
|
||||
edges: Array<{
|
||||
node: {
|
||||
season: string;
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
jobs?: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
title: {
|
||||
id: string;
|
||||
canRate: {
|
||||
isRatable: boolean;
|
||||
};
|
||||
certificate?: {
|
||||
rating: string;
|
||||
};
|
||||
originalTitleText: {
|
||||
text: string;
|
||||
};
|
||||
titleText: {
|
||||
text: string;
|
||||
};
|
||||
titleType: {
|
||||
canHaveEpisodes: boolean;
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
text: string;
|
||||
id: 'movie' | 'tvSeries' | 'tvEpisode' | 'videoGame';
|
||||
};
|
||||
primaryImage: {
|
||||
id: string;
|
||||
url: string;
|
||||
height: number;
|
||||
width: number;
|
||||
caption: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
ratingsSummary: {
|
||||
aggregateRating?: number;
|
||||
voteCount: number;
|
||||
};
|
||||
latestTrailer?: {
|
||||
id: string;
|
||||
};
|
||||
releaseYear: {
|
||||
year: number;
|
||||
endYear?: number;
|
||||
};
|
||||
runtime?: {
|
||||
seconds: number;
|
||||
};
|
||||
series: null;
|
||||
episodes?: {
|
||||
displayableSeasons: {
|
||||
total: number;
|
||||
edges: Array<{
|
||||
node: {
|
||||
season: string;
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
displayableYears: {
|
||||
total: number;
|
||||
edges: Array<{
|
||||
node: {
|
||||
year: string;
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
titleGenres: {
|
||||
genres: Array<{
|
||||
genre: {
|
||||
text: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
productionStatus: {
|
||||
currentProductionStage: {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
/*
|
||||
primaryImage: {
|
||||
id: string;
|
||||
caption: {
|
||||
plainText: string;
|
||||
};
|
||||
height: number;
|
||||
width: number;
|
||||
url: string;
|
||||
};
|
||||
imageUploadLink: null;
|
||||
nameText: {
|
||||
text: string;
|
||||
};
|
||||
*/
|
||||
primaryProfessions: Array<{
|
||||
category: {
|
||||
text: string;
|
||||
|
@ -535,7 +373,7 @@ export default interface Name {
|
|||
}>;
|
||||
};
|
||||
};
|
||||
titleGenres: {
|
||||
titleGenres?: {
|
||||
genres: Array<{
|
||||
genre: {
|
||||
text: string;
|
||||
|
@ -687,13 +525,13 @@ export default interface Name {
|
|||
}>;
|
||||
};
|
||||
};
|
||||
titleGenres: {
|
||||
genres: Array<{
|
||||
genre: {
|
||||
text: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
titleGenres?: {
|
||||
genres: Array<{
|
||||
genre: {
|
||||
text: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
productionStatus: {
|
||||
currentProductionStage: {
|
||||
id: string;
|
||||
|
@ -753,6 +591,172 @@ export default interface Name {
|
|||
};
|
||||
}>;
|
||||
};
|
||||
knownForFeature: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
summary: {
|
||||
attributes?: Array<{
|
||||
text: string;
|
||||
}>;
|
||||
episodeCount?: number;
|
||||
principalCategory: {
|
||||
text: string;
|
||||
id: string;
|
||||
};
|
||||
principalCharacters?: Array<{
|
||||
name: string;
|
||||
}>;
|
||||
principalJobs?: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
}>;
|
||||
yearRange: {
|
||||
year: number;
|
||||
endYear?: number;
|
||||
};
|
||||
};
|
||||
credit: {
|
||||
attributes?: Array<{
|
||||
text: string;
|
||||
}>;
|
||||
category: {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
characters?: Array<{
|
||||
name: string;
|
||||
}>;
|
||||
episodeCredits: {
|
||||
total: number;
|
||||
yearRange?: {
|
||||
year: number;
|
||||
endYear: number;
|
||||
};
|
||||
displayableYears: {
|
||||
total: number;
|
||||
edges: Array<{
|
||||
node: {
|
||||
year: string;
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
displayableSeasons: {
|
||||
total: number;
|
||||
edges: Array<{
|
||||
node: {
|
||||
season: string;
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
jobs?: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
title: {
|
||||
id: string;
|
||||
canRate: {
|
||||
isRatable: boolean;
|
||||
};
|
||||
certificate?: {
|
||||
rating: string;
|
||||
};
|
||||
originalTitleText: {
|
||||
text: string;
|
||||
};
|
||||
titleText: {
|
||||
text: string;
|
||||
};
|
||||
titleType: {
|
||||
canHaveEpisodes: boolean;
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
text: string;
|
||||
id: 'movie' | 'tvSeries' | 'tvEpisode' | 'videoGame';
|
||||
};
|
||||
primaryImage: {
|
||||
id: string;
|
||||
url: string;
|
||||
height: number;
|
||||
width: number;
|
||||
caption: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
ratingsSummary: {
|
||||
aggregateRating?: number;
|
||||
voteCount: number;
|
||||
};
|
||||
latestTrailer?: {
|
||||
id: string;
|
||||
};
|
||||
releaseYear: {
|
||||
year: number;
|
||||
endYear?: number;
|
||||
};
|
||||
runtime?: {
|
||||
seconds: number;
|
||||
};
|
||||
series: null;
|
||||
episodes?: {
|
||||
displayableSeasons: {
|
||||
total: number;
|
||||
edges: Array<{
|
||||
node: {
|
||||
season: string;
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
displayableYears: {
|
||||
total: number;
|
||||
edges: Array<{
|
||||
node: {
|
||||
year: string;
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
titleGenres?: {
|
||||
genres: Array<{
|
||||
genre: {
|
||||
text: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
productionStatus: {
|
||||
currentProductionStage: {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
videos: {
|
||||
total: number;
|
||||
edges: Array<{
|
||||
|
@ -799,18 +803,20 @@ export default interface Name {
|
|||
};
|
||||
};
|
||||
};
|
||||
// birthDate: {
|
||||
// dateComponents: {
|
||||
// day?: number;
|
||||
// month?: number;
|
||||
// year: number;
|
||||
// };
|
||||
// displayableProperty: {
|
||||
// value: {
|
||||
// plainText: string;
|
||||
// };
|
||||
// };
|
||||
// };
|
||||
/*
|
||||
birthDate: {
|
||||
dateComponents: {
|
||||
day?: number;
|
||||
month?: number;
|
||||
year: number;
|
||||
};
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
*/
|
||||
birthLocation: {
|
||||
text: string;
|
||||
displayableProperty: {
|
||||
|
@ -819,18 +825,20 @@ export default interface Name {
|
|||
};
|
||||
};
|
||||
};
|
||||
// deathDate?: {
|
||||
// dateComponents: {
|
||||
// day?: number;
|
||||
// month?: number;
|
||||
// year: number;
|
||||
// };
|
||||
// displayableProperty: {
|
||||
// value: {
|
||||
// plainText: string;
|
||||
// };
|
||||
// };
|
||||
// };
|
||||
/*
|
||||
deathDate?: {
|
||||
dateComponents: {
|
||||
day?: number;
|
||||
month?: number;
|
||||
year: number;
|
||||
};
|
||||
displayableProperty: {
|
||||
value: {
|
||||
plainText: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
*/
|
||||
deathLocation?: {
|
||||
text: string;
|
||||
displayableProperty: {
|
||||
|
@ -882,7 +890,7 @@ export default interface Name {
|
|||
plainText: string;
|
||||
};
|
||||
};
|
||||
attributes: Array<{
|
||||
attributes?: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
}>;
|
||||
|
|
|
@ -125,7 +125,7 @@ export default interface RawTitle {
|
|||
runtime: {
|
||||
value: number;
|
||||
};
|
||||
description: {
|
||||
description?: {
|
||||
value: string;
|
||||
language: string;
|
||||
};
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
export type AppError = {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
stack?: any;
|
||||
};
|
||||
import { AppError as AppErrorClass } from 'src/utils/helpers';
|
||||
|
||||
export type AppError = Omit<InstanceType<typeof AppErrorClass>, 'name'>;
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import type Name from './name';
|
||||
|
||||
export type Media = Name['media']; // exactly the same in title and name
|
||||
|
||||
// forcefully makes array of individual elements of T, where t is any conditional type.
|
||||
export type ToArray<T> = T extends any ? T[] : never;
|
||||
|
|
39
src/interfaces/shared/list.ts
Normal file
39
src/interfaces/shared/list.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import list from 'src/utils/fetchers/list';
|
||||
|
||||
// for full title
|
||||
type List = Awaited<ReturnType<typeof list>>;
|
||||
export type { List as default };
|
||||
|
||||
type DataTitle = {
|
||||
image: string | null;
|
||||
name: string;
|
||||
url: string | null;
|
||||
year: string;
|
||||
certificate: string;
|
||||
runtime: string;
|
||||
genre: string;
|
||||
plot: string;
|
||||
rating: string;
|
||||
metascore: string;
|
||||
otherInfo: string[][];
|
||||
};
|
||||
|
||||
type DataName = {
|
||||
image: string | null;
|
||||
name: string;
|
||||
url: string | null;
|
||||
job: string | null;
|
||||
knownFor: string | null;
|
||||
knownForLink: string | null;
|
||||
about: string;
|
||||
};
|
||||
|
||||
type DataImage = string;
|
||||
|
||||
export type DataKind = 'images' | 'titles' | 'names';
|
||||
|
||||
export type Data<T extends DataKind> = T extends 'images'
|
||||
? DataImage
|
||||
: T extends 'names'
|
||||
? DataName
|
||||
: DataTitle;
|
25
src/pages/[...error]/index.tsx
Normal file
25
src/pages/[...error]/index.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import ErrorInfo from 'src/components/error/ErrorInfo';
|
||||
|
||||
const error = {
|
||||
statusCode: 404,
|
||||
message: 'Not found, sorry.',
|
||||
} as const;
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const Error404 = ({ originalPath }: Props) => {
|
||||
return <ErrorInfo {...error} originalPath={originalPath} />;
|
||||
};
|
||||
|
||||
export default Error404;
|
||||
|
||||
type Data = { originalPath: string };
|
||||
type Params = { error: string[] };
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Data, Params> = async ctx => {
|
||||
ctx.res.statusCode = error.statusCode;
|
||||
ctx.res.statusMessage = error.message;
|
||||
|
||||
return { props: { originalPath: ctx.resolvedUrl } };
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
import Link from 'next/link';
|
||||
import Meta from 'src/components/meta/Meta';
|
||||
import Layout from 'src/layouts/Layout';
|
||||
import Layout from 'src/components/layout';
|
||||
import styles from 'src/styles/modules/pages/about/about.module.scss';
|
||||
|
||||
const About = () => {
|
||||
|
|
7
src/pages/api/[...error].ts
Normal file
7
src/pages/api/[...error].ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
type ResponseData = { status: false; message: string };
|
||||
|
||||
export default async function handler(_: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
res.status(400).json({ status: false, message: 'Not found' });
|
||||
}
|
32
src/pages/api/find.ts
Normal file
32
src/pages/api/find.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import Find, { type FindQueryParams } from 'src/interfaces/shared/search';
|
||||
import basicSearch from 'src/utils/fetchers/basicSearch';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import { findKey } from 'src/utils/constants/keys';
|
||||
import { AppError, cleanQueryStr } from 'src/utils/helpers';
|
||||
|
||||
type ResponseData =
|
||||
| { status: true; data: { title: null | string; results: null | Find } }
|
||||
| { status: false; message: string };
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
try {
|
||||
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
|
||||
|
||||
const queryObj = req.query as FindQueryParams | Record<string, never>;
|
||||
const query = queryObj.q?.trim();
|
||||
|
||||
if (!query) {
|
||||
return res.status(200).json({ status: true, data: { title: null, results: null } });
|
||||
}
|
||||
|
||||
const entries = Object.entries(queryObj);
|
||||
const queryStr = cleanQueryStr(entries);
|
||||
const results = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr);
|
||||
|
||||
res.status(200).json({ status: true, data: { title: query, results } });
|
||||
} catch (error: any) {
|
||||
const { message = 'Not found', statusCode = 404 } = error;
|
||||
res.status(statusCode).json({ status: false, message });
|
||||
}
|
||||
}
|
23
src/pages/api/list/[listId].ts
Normal file
23
src/pages/api/list/[listId].ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type List from 'src/interfaces/shared/list';
|
||||
import list from 'src/utils/fetchers/list';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import { listKey } from 'src/utils/constants/keys';
|
||||
import { AppError } from 'src/utils/helpers';
|
||||
|
||||
type ResponseData = { status: true; data: List } | { status: false; message: string };
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
try {
|
||||
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
|
||||
|
||||
const listId = req.query.listId as string;
|
||||
const pageNum = req.query.page as string | undefined;
|
||||
|
||||
const data = await getOrSetApiCache(listKey(listId, pageNum), list, listId, pageNum);
|
||||
res.status(200).json({ status: true, data });
|
||||
} catch (error: any) {
|
||||
const { message = 'Not found', statusCode = 404 } = error;
|
||||
res.status(statusCode).json({ status: false, message });
|
||||
}
|
||||
}
|
|
@ -1,30 +1,32 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { AxiosRequestHeaders } from 'axios';
|
||||
import redis from 'src/utils/redis';
|
||||
import axiosInstance from 'src/utils/axiosInstance';
|
||||
import { mediaKey } from 'src/utils/constants/keys';
|
||||
|
||||
const getCleanReqHeaders = (headers: NextApiRequest['headers']) => ({
|
||||
...(headers.accept && { accept: headers.accept }),
|
||||
...(headers.range && { range: headers.range }),
|
||||
...(headers['accept-encoding'] && {
|
||||
'accept-encoding': headers['accept-encoding'] as string,
|
||||
}),
|
||||
});
|
||||
const dontCacheMedia =
|
||||
process.env.USE_REDIS_FOR_API_ONLY === 'true' || process.env.USE_REDIS !== 'true';
|
||||
|
||||
const resHeadersArr = [
|
||||
'content-range',
|
||||
'content-length',
|
||||
'content-type',
|
||||
'accept-ranges',
|
||||
];
|
||||
const ttl = process.env.REDIS_CACHE_TTL_MEDIA ?? 30 * 60;
|
||||
|
||||
const getCleanReqHeaders = (headers: NextApiRequest['headers']) => {
|
||||
const cleanHeaders: AxiosRequestHeaders = {};
|
||||
|
||||
if (headers.accept) cleanHeaders.accept = headers.accept;
|
||||
if (headers.range) cleanHeaders.range = headers.range;
|
||||
if (headers['accept-encoding'])
|
||||
cleanHeaders['accept-encoding'] = headers['accept-encoding'].toString();
|
||||
|
||||
return cleanHeaders;
|
||||
};
|
||||
|
||||
const resHeadersArr = ['content-range', 'content-length', 'content-type', 'accept-ranges'];
|
||||
|
||||
// checks if a url is pointing towards a video/image from imdb
|
||||
const regex =
|
||||
/^https:\/\/((m\.)?media-amazon\.com|imdb-video\.media-imdb\.com).*\.(jpg|jpeg|png|mp4|gif|webp).*$/;
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const mediaUrl = req.query.url as string | undefined;
|
||||
const requestHeaders = getCleanReqHeaders(req.headers);
|
||||
|
@ -36,8 +38,8 @@ export default async function handler(
|
|||
message: 'Invalid query',
|
||||
});
|
||||
|
||||
// 2. sending streamed response if redis isn't enabled
|
||||
if (redis === null) {
|
||||
// 2. sending streamed response if redis, or redis for media isn't enabled
|
||||
if (dontCacheMedia) {
|
||||
const mediaRes = await axiosInstance.get(mediaUrl, {
|
||||
responseType: 'stream',
|
||||
headers: requestHeaders,
|
||||
|
@ -54,23 +56,21 @@ export default async function handler(
|
|||
}
|
||||
|
||||
// 3. else if resourced is cached, sending it
|
||||
const cachedMedia = await redis!.getBuffer(mediaUrl);
|
||||
const cachedMedia = await redis.getBuffer(mediaKey(mediaUrl));
|
||||
|
||||
if (cachedMedia) {
|
||||
res.setHeader('x-cached', 'true');
|
||||
res.status(302).send(cachedMedia);
|
||||
res.send(cachedMedia);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. else getting, caching and sending response
|
||||
const mediaRes = await axiosInstance(mediaUrl, {
|
||||
const { data } = await axiosInstance(mediaUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
const { data } = mediaRes;
|
||||
|
||||
// saving in redis for 30 minutes
|
||||
await redis!.setex(mediaUrl, 30 * 60, Buffer.from(data));
|
||||
await redis.setex(mediaKey(mediaUrl), ttl, Buffer.from(data));
|
||||
|
||||
// sending media
|
||||
res.setHeader('x-cached', 'false');
|
||||
|
|
22
src/pages/api/name/[nameId].ts
Normal file
22
src/pages/api/name/[nameId].ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type Name from 'src/interfaces/shared/name';
|
||||
import name from 'src/utils/fetchers/name';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import { nameKey } from 'src/utils/constants/keys';
|
||||
import { AppError } from 'src/utils/helpers';
|
||||
|
||||
type ResponseData = { status: true; data: Name } | { status: false; message: string };
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
try {
|
||||
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
|
||||
|
||||
const nameId = req.query.nameId as string;
|
||||
|
||||
const data = await getOrSetApiCache(nameKey(nameId), name, nameId);
|
||||
res.status(200).json({ status: true, data });
|
||||
} catch (error: any) {
|
||||
const { message = 'Not found', statusCode = 404 } = error;
|
||||
res.status(statusCode).json({ status: false, message });
|
||||
}
|
||||
}
|
21
src/pages/api/title/[titleId].ts
Normal file
21
src/pages/api/title/[titleId].ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type Title from 'src/interfaces/shared/title';
|
||||
import title from 'src/utils/fetchers/title';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import { titleKey } from 'src/utils/constants/keys';
|
||||
import { AppError } from 'src/utils/helpers';
|
||||
|
||||
type ResponseData = { status: true; data: Title } | { status: false; message: string };
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
try {
|
||||
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
|
||||
|
||||
const titleId = req.query.titleId as string;
|
||||
const data = await getOrSetApiCache(titleKey(titleId), title, titleId);
|
||||
res.status(200).json({ status: true, data });
|
||||
} catch (error: any) {
|
||||
const { message = 'Not found', statusCode = 404 } = error;
|
||||
res.status(statusCode).json({ status: false, message });
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import Meta from 'src/components/meta/Meta';
|
||||
import Layout from 'src/layouts/Layout';
|
||||
import Layout from 'src/components/layout';
|
||||
import styles from 'src/styles/modules/pages/contact/contact.module.scss';
|
||||
|
||||
const Contact = () => {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { GetServerSideProps } from 'next';
|
||||
import Layout from 'src/layouts/Layout';
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import Layout from 'src/components/layout';
|
||||
import ErrorInfo from 'src/components/error/ErrorInfo';
|
||||
import Meta from 'src/components/meta/Meta';
|
||||
import Results from 'src/components/find';
|
||||
|
@ -7,13 +7,12 @@ import Form from 'src/components/forms/find';
|
|||
import Find, { FindQueryParams } from 'src/interfaces/shared/search';
|
||||
import { AppError } from 'src/interfaces/shared/error';
|
||||
import basicSearch from 'src/utils/fetchers/basicSearch';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import { cleanQueryStr } from 'src/utils/helpers';
|
||||
import { findKey } from 'src/utils/constants/keys';
|
||||
import styles from 'src/styles/modules/pages/find/find.module.scss';
|
||||
|
||||
type Props =
|
||||
| { data: { title: string; results: Find }; error: null }
|
||||
| { data: { title: null; results: null }; error: null }
|
||||
| { data: { title: string; results: null }; error: AppError };
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const getMetadata = (title: string | null) => ({
|
||||
title: title || 'Search',
|
||||
|
@ -22,14 +21,16 @@ const getMetadata = (title: string | null) => ({
|
|||
: 'Search for anything on libremdb, a free & open source IMDb front-end',
|
||||
});
|
||||
|
||||
const BasicSearch = ({ data: { title, results }, error }: Props) => {
|
||||
if (error)
|
||||
return <ErrorInfo message={error.message} statusCode={error.statusCode} />;
|
||||
const BasicSearch = ({ data: { title, results }, error, originalPath }: Props) => {
|
||||
if (error) return <ErrorInfo {...error} originalPath={originalPath} />;
|
||||
|
||||
let layoutClassName = styles.find;
|
||||
if (!title) layoutClassName += ' ' + styles.find__home;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta {...getMetadata(title)} />
|
||||
<Layout className={`${styles.find} ${!title && styles.find__home}`}>
|
||||
<Layout className={layoutClassName} originalPath={originalPath}>
|
||||
{title && ( // only showing when user has searched for something
|
||||
<Results results={results} title={title} className={styles.results} />
|
||||
)}
|
||||
|
@ -40,22 +41,30 @@ const BasicSearch = ({ data: { title, results }, error }: Props) => {
|
|||
};
|
||||
|
||||
// TODO: use generics for passing in queryParams(to components) for better type-checking.
|
||||
export const getServerSideProps: GetServerSideProps = async ctx => {
|
||||
type Data = (
|
||||
| { data: { title: string; results: Find }; error: null }
|
||||
| { data: { title: null; results: null }; error: null }
|
||||
| { data: { title: string; results: null }; error: AppError }
|
||||
) & {
|
||||
originalPath: string;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Data, FindQueryParams> = async ctx => {
|
||||
// sample query str: find/?q=babylon&s=tt&ttype=ft&exact=true
|
||||
const queryObj = ctx.query as FindQueryParams;
|
||||
const query = queryObj.q?.trim();
|
||||
const originalPath = ctx.resolvedUrl;
|
||||
|
||||
if (!query)
|
||||
return { props: { data: { title: null, results: null }, error: null } };
|
||||
if (!query) return { props: { data: { title: null, results: null }, error: null, originalPath } };
|
||||
|
||||
try {
|
||||
const entries = Object.entries(queryObj);
|
||||
const queryStr = cleanQueryStr(entries);
|
||||
|
||||
const res = await basicSearch(queryStr);
|
||||
const res = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr);
|
||||
|
||||
return {
|
||||
props: { data: { title: query, results: res }, error: null },
|
||||
props: { data: { title: query, results: res }, error: null, originalPath },
|
||||
};
|
||||
} catch (error: any) {
|
||||
const { message, statusCode } = error;
|
||||
|
@ -66,6 +75,7 @@ export const getServerSideProps: GetServerSideProps = async ctx => {
|
|||
props: {
|
||||
error: { message, statusCode },
|
||||
data: { title: query, results: null },
|
||||
originalPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
54
src/pages/list/[listId]/index.tsx
Normal file
54
src/pages/list/[listId]/index.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import Meta from 'src/components/meta/Meta';
|
||||
import Layout from 'src/components/layout';
|
||||
import ErrorInfo from 'src/components/error/ErrorInfo';
|
||||
import { Data, Meta as ListMeta, Pagination } from 'src/components/list';
|
||||
import { AppError } from 'src/interfaces/shared/error';
|
||||
import TList from 'src/interfaces/shared/list';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import list from 'src/utils/fetchers/list';
|
||||
import { listKey } from 'src/utils/constants/keys';
|
||||
import styles from 'src/styles/modules/pages/list/list.module.scss';
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const List = ({ data, error, originalPath }: Props) => {
|
||||
if (error) return <ErrorInfo {...error} originalPath={originalPath} />;
|
||||
|
||||
const description = data.description || `List created by ${data.meta.by.name} (${data.meta.num} ${data.meta.type}).`
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={data.title} description={description} />
|
||||
<Layout className={styles.list} originalPath={originalPath}>
|
||||
<ListMeta title={data.title} description={data.description} meta={data.meta} />
|
||||
{/* @ts-expect-error don't have time to fix it. just a type fluff. */}
|
||||
<Data data={data.data} />
|
||||
<Pagination pagination={data.pagination} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TData = ({ data: TList; error: null } | { error: AppError; data: null }) & {
|
||||
originalPath: string;
|
||||
};
|
||||
type Params = { listId: string };
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<TData, Params> = async ctx => {
|
||||
const listId = ctx.params!.listId;
|
||||
const pageNum = (ctx.query.page as string | undefined) ?? '1';
|
||||
const originalPath = ctx.resolvedUrl;
|
||||
try {
|
||||
const data = await getOrSetApiCache(listKey(listId, pageNum), list, listId, pageNum);
|
||||
|
||||
return { props: { data, error: null, originalPath } };
|
||||
} catch (error: any) {
|
||||
const { message = 'Internal server error', statusCode = 500 } = error;
|
||||
ctx.res.statusCode = statusCode;
|
||||
ctx.res.statusMessage = message;
|
||||
return { props: { error: { message, statusCode }, data: null, originalPath } };
|
||||
}
|
||||
};
|
||||
|
||||
export default List;
|
|
@ -1,19 +1,21 @@
|
|||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import Meta from 'src/components/meta/Meta';
|
||||
import Layout from 'src/layouts/Layout';
|
||||
import Layout from 'src/components/layout';
|
||||
import ErrorInfo from 'src/components/error/ErrorInfo';
|
||||
import Media from 'src/components/media/Media';
|
||||
import { Basic, Credits, DidYouKnow, Info, Bio, KnownFor } from 'src/components/name';
|
||||
import Name from 'src/interfaces/shared/name';
|
||||
import { AppError } from 'src/interfaces/shared/error';
|
||||
import name from 'src/utils/fetchers/name';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import { getProxiedIMDbImgUrl } from 'src/utils/helpers';
|
||||
import { nameKey } from 'src/utils/constants/keys';
|
||||
import styles from 'src/styles/modules/pages/name/name.module.scss';
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const NameInfo = ({ data, error }: Props) => {
|
||||
if (error) return <ErrorInfo message={error.message} statusCode={error.statusCode} />;
|
||||
const NameInfo = ({ data, error, originalPath }: Props) => {
|
||||
if (error) return <ErrorInfo {...error} originalPath={originalPath} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -22,7 +24,7 @@ const NameInfo = ({ data, error }: Props) => {
|
|||
description={data.basic.bio.short + '...'}
|
||||
imgUrl={data.basic.poster?.url && getProxiedIMDbImgUrl(data.basic.poster.url)}
|
||||
/>
|
||||
<Layout className={styles.name}>
|
||||
<Layout className={styles.name} originalPath={originalPath}>
|
||||
<Basic data={data.basic} className={styles.basic} />
|
||||
<Media className={styles.media} media={data.media} />
|
||||
<div className={styles.textarea}>
|
||||
|
@ -39,23 +41,26 @@ const NameInfo = ({ data, error }: Props) => {
|
|||
);
|
||||
};
|
||||
|
||||
type Data = { data: Name; error: null } | { error: AppError; data: null };
|
||||
type Data = ({ data: Name; error: null } | { error: AppError; data: null }) & {
|
||||
originalPath: string;
|
||||
};
|
||||
type Params = { nameId: string };
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Data, Params> = async ctx => {
|
||||
const nameId = ctx.params!.nameId;
|
||||
const originalPath = ctx.resolvedUrl;
|
||||
|
||||
try {
|
||||
const data = await name(nameId);
|
||||
const data = await getOrSetApiCache(nameKey(nameId), name, nameId);
|
||||
|
||||
return { props: { data, error: null } };
|
||||
return { props: { data, error: null, originalPath } };
|
||||
} catch (error: any) {
|
||||
const { message, statusCode } = error;
|
||||
|
||||
ctx.res.statusCode = statusCode;
|
||||
ctx.res.statusMessage = message;
|
||||
|
||||
return { props: { error: { message, statusCode }, data: null } };
|
||||
return { props: { error: { message, statusCode }, data: null, originalPath } };
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Meta from 'src/components/meta/Meta';
|
||||
import Layout from 'src/layouts/Layout';
|
||||
import Layout from 'src/components/layout';
|
||||
import packageInfo from 'src/../package.json';
|
||||
import styles from 'src/styles/modules/pages/privacy/privacy.module.scss';
|
||||
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import Meta from 'src/components/meta/Meta';
|
||||
import Layout from 'src/layouts/Layout';
|
||||
import Layout from 'src/components/layout';
|
||||
import ErrorInfo from 'src/components/error/ErrorInfo';
|
||||
import Media from 'src/components/media/Media';
|
||||
import { Basic, Cast, DidYouKnow, Info, MoreLikeThis, Reviews } from 'src/components/title';
|
||||
import Title from 'src/interfaces/shared/title';
|
||||
import { AppError } from 'src/interfaces/shared/error';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import title from 'src/utils/fetchers/title';
|
||||
import { getProxiedIMDbImgUrl } from 'src/utils/helpers';
|
||||
import { titleKey } from 'src/utils/constants/keys';
|
||||
import styles from 'src/styles/modules/pages/title/title.module.scss';
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
// TO-DO: make a wrapper page component to display errors, if present in props
|
||||
const TitleInfo = ({ data, error }: Props) => {
|
||||
if (error) return <ErrorInfo message={error.message} statusCode={error.statusCode} />;
|
||||
const TitleInfo = ({ data, error, originalPath }: Props) => {
|
||||
if (error) return <ErrorInfo {...error} originalPath={originalPath} />;
|
||||
|
||||
const info = {
|
||||
meta: data.meta,
|
||||
|
@ -32,7 +34,7 @@ const TitleInfo = ({ data, error }: Props) => {
|
|||
description={data.basic.plot ?? undefined}
|
||||
imgUrl={data.basic.poster?.url && getProxiedIMDbImgUrl(data.basic.poster.url)}
|
||||
/>
|
||||
<Layout className={styles.title}>
|
||||
<Layout className={styles.title} originalPath={originalPath}>
|
||||
<Basic data={data.basic} className={styles.basic} />
|
||||
<Media className={styles.media} media={data.media} />
|
||||
<Cast className={styles.cast} cast={data.cast} />
|
||||
|
@ -48,22 +50,25 @@ const TitleInfo = ({ data, error }: Props) => {
|
|||
};
|
||||
|
||||
// TO-DO: make a getServerSideProps wrapper for handling errors
|
||||
type Data = { data: Title; error: null } | { error: AppError; data: null };
|
||||
type Data = ({ data: Title; error: null } | { error: AppError; data: null }) & {
|
||||
originalPath: string;
|
||||
};
|
||||
type Params = { titleId: string };
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Data, Params> = async ctx => {
|
||||
const titleId = ctx.params!.titleId;
|
||||
const originalPath = ctx.resolvedUrl;
|
||||
|
||||
try {
|
||||
const data = await title(titleId);
|
||||
const data = await getOrSetApiCache(titleKey(titleId), title, titleId);
|
||||
|
||||
return { props: { data, error: null } };
|
||||
return { props: { data, error: null, originalPath } };
|
||||
} catch (error: any) {
|
||||
const { message, statusCode } = error;
|
||||
ctx.res.statusCode = statusCode;
|
||||
ctx.res.statusMessage = message;
|
||||
|
||||
return { props: { error: { message, statusCode }, data: null } };
|
||||
return { props: { error: { message, statusCode }, data: null, originalPath } };
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -7,9 +7,6 @@
|
|||
display: grid;
|
||||
grid-template-columns: var(--width) auto;
|
||||
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
--height: 15rem;
|
||||
grid-template-columns: auto;
|
||||
|
|
|
@ -35,6 +35,12 @@
|
|||
align-content: start;
|
||||
}
|
||||
|
||||
.children {
|
||||
max-height: 7em; // firefox doesn't support lh yet.
|
||||
max-height: 7lh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
|
31
src/styles/modules/components/list/images.module.scss
Normal file
31
src/styles/modules/components/list/images.module.scss
Normal file
|
@ -0,0 +1,31 @@
|
|||
@use '../../../abstracts' as helper;
|
||||
|
||||
.container {
|
||||
--min-width: 22rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--min-width), 1fr));
|
||||
|
||||
gap: var(--spacer-1);
|
||||
|
||||
@include helper.bp('bp-900') {
|
||||
--min-width: 18rem;
|
||||
}
|
||||
@include helper.bp('bp-700') {
|
||||
--min-width: 15rem;
|
||||
}
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
--min-width: 12rem;
|
||||
}
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
position: relative;
|
||||
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.img {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
18
src/styles/modules/components/list/meta.module.scss
Normal file
18
src/styles/modules/components/list/meta.module.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
.container {
|
||||
display: grid;
|
||||
gap: var(--spacer-1);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& * + *::before {
|
||||
content: '\00b7';
|
||||
padding-inline: var(--spacer-0);
|
||||
font-weight: 900;
|
||||
line-height: 0;
|
||||
font-size: var(--fs-5);
|
||||
}
|
||||
}
|
86
src/styles/modules/components/list/names.module.scss
Normal file
86
src/styles/modules/components/list/names.module.scss
Normal file
|
@ -0,0 +1,86 @@
|
|||
@use '../../../abstracts' as helper;
|
||||
|
||||
|
||||
.names {
|
||||
display: grid;
|
||||
gap: var(--spacer-6);
|
||||
--min-width: 55rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--min-width), 1fr));
|
||||
|
||||
@include helper.bp('bp-700') {
|
||||
grid-template-columns: auto;
|
||||
gap: var(--spacer-5);
|
||||
}
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
gap: var(--spacer-3);
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
--image-dimension: 18rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--image-dimension) auto;
|
||||
|
||||
@include helper.bp('bp-700') {
|
||||
--dimension: 15rem;
|
||||
grid-template-columns: auto;
|
||||
grid-template-rows: var(--image-dimension) auto;
|
||||
}
|
||||
}
|
||||
.imgContainer {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.img {
|
||||
object-fit: cover;
|
||||
object-position: center 25%; // most of the time, person's face is visible at 1/4 of height in a potrait image.
|
||||
}
|
||||
|
||||
.imgNA {
|
||||
width: 80%;
|
||||
fill: var(--clr-fill-muted);
|
||||
}
|
||||
|
||||
.info {
|
||||
display: grid;
|
||||
padding: var(--spacer-3);
|
||||
gap: var(--spacer-0);
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
padding: var(--spacer-1);
|
||||
}
|
||||
|
||||
& :empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: var(--fs-4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.basicInfo {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& * + ::before {
|
||||
content: '\00b7';
|
||||
padding-inline: var(--spacer-1);
|
||||
font-weight: 900;
|
||||
line-height: 0;
|
||||
font-size: var(--fs-5);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
18
src/styles/modules/components/list/pagination.module.scss
Normal file
18
src/styles/modules/components/list/pagination.module.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
.nav {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
gap: var(--spacer-3);
|
||||
|
||||
[aria-hidden="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
li {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
133
src/styles/modules/components/list/titles.module.scss
Normal file
133
src/styles/modules/components/list/titles.module.scss
Normal file
|
@ -0,0 +1,133 @@
|
|||
@use '../../../abstracts' as helper;
|
||||
|
||||
.titles {
|
||||
display: grid;
|
||||
gap: var(--spacer-6);
|
||||
--min-width: 55rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--min-width), 1fr));
|
||||
|
||||
@include helper.bp('bp-700') {
|
||||
grid-template-columns: auto;
|
||||
gap: var(--spacer-5);
|
||||
}
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
gap: var(--spacer-3);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
--image-dimension: 18rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--image-dimension) auto;
|
||||
|
||||
@include helper.bp('bp-700') {
|
||||
grid-template-columns: auto;
|
||||
grid-template-rows: var(--image-dimension) auto;
|
||||
}
|
||||
@include helper.bp('bp-450') {
|
||||
--image-dimension: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.img {
|
||||
object-fit: cover;
|
||||
object-position: center 20%;
|
||||
}
|
||||
|
||||
.imgNA {
|
||||
width: 80%;
|
||||
fill: var(--clr-fill-muted);
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
padding: var(--spacer-3);
|
||||
gap: var(--spacer-0);
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
padding: var(--spacer-2);
|
||||
}
|
||||
|
||||
& :empty:not(svg, use, img) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ratings {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacer-2);
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
// place-content: center;
|
||||
|
||||
&__icon {
|
||||
--dim: 1.3em;
|
||||
fill: var(--clr-fill);
|
||||
height: var(--dim);
|
||||
width: var(--dim);
|
||||
max-width: initial;
|
||||
}
|
||||
|
||||
&__num {
|
||||
font-size: var(--fs-4);
|
||||
font-weight: var(--fw-medium);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__text {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.9em;
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.basicInfo {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& * + ::before {
|
||||
content: '\00b7';
|
||||
padding-inline: var(--spacer-1);
|
||||
font-weight: 900;
|
||||
line-height: 0;
|
||||
font-size: var(--fs-5);
|
||||
}
|
||||
}
|
||||
|
||||
.plot {
|
||||
padding-block: var(--spacer-0);
|
||||
|
||||
span {
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.otherInfo {
|
||||
list-style: none;
|
||||
|
||||
span {
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
}
|
|
@ -4,12 +4,6 @@
|
|||
display: grid;
|
||||
gap: var(--comp-whitespace);
|
||||
|
||||
& > section {
|
||||
overflow-x: auto;
|
||||
display: grid;
|
||||
gap: var(--spacer-1);
|
||||
}
|
||||
|
||||
details {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.bio {
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--comp-whitespace);
|
||||
}
|
||||
|
|
19
src/styles/modules/pages/list/list.module.scss
Normal file
19
src/styles/modules/pages/list/list.module.scss
Normal file
|
@ -0,0 +1,19 @@
|
|||
@use '../../../abstracts' as helper;
|
||||
|
||||
.list {
|
||||
--doc-whitespace: var(--spacer-8);
|
||||
--comp-whitespace: var(--spacer-3);
|
||||
|
||||
display: grid;
|
||||
|
||||
gap: var(--doc-whitespace);
|
||||
padding: var(--doc-whitespace);
|
||||
|
||||
@include helper.bp('bp-700') {
|
||||
--doc-whitespace: var(--spacer-5);
|
||||
}
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
padding: var(--spacer-3);
|
||||
}
|
||||
}
|
|
@ -14,8 +14,8 @@ const cleanName = (rawData: RawName) => {
|
|||
name: main.nameText.text,
|
||||
nameSuffix: main.disambiguator?.text ?? null,
|
||||
knownFor: {
|
||||
title: main.knownFor.edges[0].node.title.titleText.text,
|
||||
role: main.knownFor.edges[0].node.summary.principalCategory.text,
|
||||
title: main.knownFor.edges[0]?.node.title.titleText.text ?? null,
|
||||
role: main.knownFor.edges[0]?.node.summary.principalCategory.text ?? null,
|
||||
},
|
||||
...(main.primaryImage && {
|
||||
poster: {
|
||||
|
@ -87,7 +87,7 @@ const cleanName = (rawData: RawName) => {
|
|||
},
|
||||
}),
|
||||
},
|
||||
knownFor: misc.knownFor.edges.map(item => ({
|
||||
knownFor: misc.knownForFeature.edges.map(item => ({
|
||||
id: item.node.title.id,
|
||||
title: item.node.title.titleText.text,
|
||||
...(item.node.title.primaryImage && {
|
||||
|
@ -113,7 +113,7 @@ const cleanName = (rawData: RawName) => {
|
|||
avg: item.node.title.ratingsSummary.aggregateRating ?? null,
|
||||
numVotes: item.node.title.ratingsSummary.voteCount,
|
||||
},
|
||||
genres: item.node.title.titleGenres.genres.map(genre => genre.genre.text),
|
||||
genres: item.node.title.titleGenres?.genres.map(genre => genre.genre.text) ?? [],
|
||||
|
||||
summary: {
|
||||
numEpisodes: item.node.summary.episodeCount ?? null,
|
||||
|
@ -133,13 +133,14 @@ const cleanName = (rawData: RawName) => {
|
|||
id: cat.titleTypeCategory.id,
|
||||
label: cat.titleTypeCategory.text,
|
||||
})),
|
||||
genres: misc.creditSummary.genres.map(genre => ({
|
||||
total: genre.total,
|
||||
name: genre.genre.displayableProperty.value.plainText,
|
||||
})),
|
||||
genres:
|
||||
misc.creditSummary.genres.map(genre => ({
|
||||
total: genre.total,
|
||||
name: genre.genre.displayableProperty.value.plainText,
|
||||
})) ?? [],
|
||||
},
|
||||
released: getCredits(misc.releasedPrimaryCredits),
|
||||
unreleased: getCredits<'unreleased'>(misc.unreleasedPrimaryCredits),
|
||||
// unreleased: getCredits<'unreleased'>(misc.unreleasedPrimaryCredits),
|
||||
},
|
||||
personalDetails: {
|
||||
officialSites: misc.personalDetailsExternalLinks.edges.map(item => ({
|
||||
|
@ -162,7 +163,7 @@ const cleanName = (rawData: RawName) => {
|
|||
name: spouse.spouse.asMarkdown.plainText,
|
||||
id: spouse.spouse.name?.id ?? null,
|
||||
range: spouse.timeRange.displayableProperty.value.plaidHtml,
|
||||
attributes: spouse.attributes.map(attr => attr.text),
|
||||
attributes: spouse.attributes?.map(attr => attr.text) ?? null,
|
||||
})) ?? null,
|
||||
children: misc.children.edges.map(child => ({
|
||||
name: child.node.relationName.displayableProperty.value.plainText,
|
||||
|
@ -263,7 +264,7 @@ const getCredits = <T extends 'released' | 'unreleased' = 'released'>(
|
|||
numVotes: item.node.title.ratingsSummary.voteCount,
|
||||
},
|
||||
test: JSON.stringify(item.node.title),
|
||||
genres: item.node.title.titleGenres.genres.map(genre => genre.genre.text),
|
||||
genres: item.node.title.titleGenres?.genres.map(genre => genre.genre.text) ?? [],
|
||||
productionStatus: item.node.title.productionStatus.currentProductionStage.text,
|
||||
|
||||
summary: {
|
||||
|
|
|
@ -81,7 +81,7 @@ const cleanTitle = (rawData: RawTitle) => {
|
|||
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,
|
||||
caption: main.primaryVideos.edges[0].node.description?.value ?? null,
|
||||
urls: main.primaryVideos.edges[0].node.playbackURLs.map(url => ({
|
||||
resolution: url.displayName.value,
|
||||
mimeType: url.mimeType,
|
||||
|
@ -192,7 +192,7 @@ const cleanTitle = (rawData: RawTitle) => {
|
|||
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,
|
||||
year: misc.connections.edges[0].node.associatedTitle.releaseYear?.year ?? null,
|
||||
text: misc.connections.edges[0].node.associatedTitle.titleText.text,
|
||||
},
|
||||
},
|
||||
|
|
5
src/utils/constants/keys.ts
Normal file
5
src/utils/constants/keys.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const titleKey = (titleId: string) => `title:${titleId}`;
|
||||
export const nameKey = (nameId: string) => `name:${nameId}`;
|
||||
export const listKey = (listId: string, pageNum = '1') => `list:${listId}?page=${pageNum}`;
|
||||
export const findKey = (query: string) => `find:${query}`;
|
||||
export const mediaKey = (url: string) => `media:${url}`;
|
141
src/utils/fetchers/list.ts
Normal file
141
src/utils/fetchers/list.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
import axios, { AxiosError } from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import type { Data, DataKind } from 'src/interfaces/shared/list';
|
||||
import axiosInstance from 'src/utils/axiosInstance';
|
||||
import { AppError, isIMDbImgPlaceholder } from 'src/utils/helpers';
|
||||
|
||||
const list = async (listId: string, pageNum = '1') => {
|
||||
try {
|
||||
const res = await axiosInstance(`/list/${listId}?page=${pageNum}`);
|
||||
const $ = cheerio.load(res.data);
|
||||
|
||||
const $main = $('#main > .article');
|
||||
const $meta = $main.children('#list-overview-summary');
|
||||
const $footer = $main.find('.footer .desc .list-pagination');
|
||||
|
||||
const title = clean($main.children('h1.list-name'));
|
||||
const numWithtype = clean($main.find('.sub-list .header .nav .desc')).split(' ');
|
||||
|
||||
if (numWithtype.length < 2) throw new AppError('invalid list', 400);
|
||||
|
||||
const [num, type] = numWithtype as [string, DataKind];
|
||||
|
||||
const meta = {
|
||||
by: {
|
||||
name: clean($meta.children('a')),
|
||||
link: $meta.children('a').attr('href') ?? null,
|
||||
},
|
||||
created: clean($meta.find('#list-overview-created')),
|
||||
updated: clean($meta.find('#list-overview-lastupdated')),
|
||||
num,
|
||||
type,
|
||||
};
|
||||
const description = clean($main.children('.list-description'));
|
||||
|
||||
const pagination = {
|
||||
prev: $footer.children('a.prev-page').attr('href') ?? null,
|
||||
range: clean($footer.children('.pagination-range')),
|
||||
next: $footer.children('a.next-page').attr('href') ?? null,
|
||||
};
|
||||
|
||||
const $imagesContainer = $main.find('.lister-list .media_index_thumb_list');
|
||||
const $listItems = $main.find('.lister-list').children();
|
||||
let data: Data<typeof type>[] = [];
|
||||
|
||||
// 1. images list
|
||||
if (type === 'images') {
|
||||
data = $imagesContainer
|
||||
.find('a > img')
|
||||
.map((_i, el) => $(el).attr('src'))
|
||||
.toArray();
|
||||
}
|
||||
|
||||
// 2. movies list
|
||||
else if (type === 'titles') {
|
||||
$listItems.each((_i, el) => {
|
||||
let image = $(el).find('.lister-item-image > a > img.loadlate').attr('loadlate') ?? null;
|
||||
if (image && isIMDbImgPlaceholder(image)) image = null;
|
||||
|
||||
const $content = $(el).children('.lister-item-content');
|
||||
const $heading = $content.find('h3.lister-item-header > a');
|
||||
const name = clean($heading);
|
||||
const url = $heading.attr('href') ?? null;
|
||||
const year = clean($heading.next('.lister-item-year'));
|
||||
const $itemMeta = $content.find('h3.lister-item-header + p');
|
||||
const certificate = clean($itemMeta.children('.certificate'));
|
||||
const runtime = clean($itemMeta.children('.runtime'));
|
||||
const genre = clean($itemMeta.children('.genre'));
|
||||
const rating = clean($content.find('.ipl-rating-star__rating').first());
|
||||
const metascore = clean($content.find('.metascore'));
|
||||
const plot = clean($content.children('p[class=""]'));
|
||||
|
||||
// eg: [["Director", "Nabwana I.G.G."], ["Stars", "Kakule William, Sserunya Ernest, G. Puffs"]]
|
||||
const otherInfo = $content
|
||||
.children('p.text-muted.text-small')
|
||||
.nextAll('p.text-muted.text-small')
|
||||
.map((__i, infoEl) => {
|
||||
const arr = clean($(infoEl)).replace(/\s+/g, ' ').split('|');
|
||||
|
||||
return arr.map(i => i.split(':'));
|
||||
})
|
||||
.toArray();
|
||||
|
||||
data.push({
|
||||
image,
|
||||
name,
|
||||
url,
|
||||
year,
|
||||
certificate,
|
||||
runtime,
|
||||
genre,
|
||||
plot,
|
||||
rating,
|
||||
metascore,
|
||||
otherInfo,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. actors list
|
||||
else if (type === 'names') {
|
||||
$listItems.each((_i, el) => {
|
||||
let image = $(el).find('.lister-item-image > a > img').attr('src') ?? null;
|
||||
if (image && isIMDbImgPlaceholder(image)) image = null;
|
||||
|
||||
const $content = $(el).children('.lister-item-content');
|
||||
const $heading = $content.find('h3.lister-item-header > a');
|
||||
const name = clean($heading);
|
||||
const url = $heading.attr('href') ?? null;
|
||||
const $itemMeta = $content.find('h3.lister-item-header + p');
|
||||
const jobNKnownForRaw = clean($itemMeta.first()).split('|');
|
||||
const job = jobNKnownForRaw.at(0) ?? null;
|
||||
const knownFor = jobNKnownForRaw.at(1) ?? null;
|
||||
const knownForLink = $itemMeta.children('a').attr('href') ?? null;
|
||||
const about = clean($content.children('p:not([class])'));
|
||||
|
||||
data.push({
|
||||
image,
|
||||
name,
|
||||
url,
|
||||
job,
|
||||
knownFor,
|
||||
knownForLink,
|
||||
about,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { title, meta, description, pagination, data };
|
||||
} catch (err: any) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404)
|
||||
throw new AppError('not found', 404, err.cause);
|
||||
|
||||
if (err instanceof AppError) throw err;
|
||||
|
||||
throw new AppError('something went wrong', 500, err.cause);
|
||||
}
|
||||
};
|
||||
|
||||
export default list;
|
||||
|
||||
const clean = <T extends cheerio.Cheerio<any>>(item: T) => item.text().trim();
|
|
@ -18,9 +18,6 @@ const name = async (nameId: string) => {
|
|||
} catch (err: any) {
|
||||
if (err.response?.status === 404) throw new AppError('not found', 404, err.cause);
|
||||
|
||||
console.warn(err);
|
||||
|
||||
|
||||
throw new AppError('something went wrong', 500, err.cause);
|
||||
}
|
||||
};
|
||||
|
|
24
src/utils/getOrSetApiCache.ts
Normal file
24
src/utils/getOrSetApiCache.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import redis from 'src/utils/redis';
|
||||
|
||||
const ttl = process.env.REDIS_CACHE_TTL_API ?? 30 * 60;
|
||||
const redisEnabled =
|
||||
process.env.USE_REDIS === 'true' || process.env.USE_REDIS_FOR_API_ONLY === 'true';
|
||||
|
||||
const getOrSetApiCache = async <T extends (...fetcherArgs: any[]) => Promise<any>>(
|
||||
key: string,
|
||||
fetcher: T,
|
||||
...fetcherArgs: Parameters<T>
|
||||
): Promise<ReturnType<T>> => {
|
||||
if (!redisEnabled) return await fetcher(...fetcherArgs);
|
||||
|
||||
const dataInCache = await redis.get(key);
|
||||
if (dataInCache) return JSON.parse(dataInCache);
|
||||
|
||||
const dataToCache = await fetcher(...fetcherArgs);
|
||||
|
||||
await redis.setex(key, ttl, JSON.stringify(dataToCache));
|
||||
|
||||
return dataToCache;
|
||||
};
|
||||
|
||||
export default getOrSetApiCache;
|
|
@ -66,6 +66,9 @@ export const modifyIMDbImg = (url: string, widthInPx = 600) => {
|
|||
return url;
|
||||
};
|
||||
|
||||
const placeholderImageRegex = /https:\/\/m\.media-amazon.com\/images\/.{1}\/sash\/.*/;
|
||||
export const isIMDbImgPlaceholder = (url?: string | null) => url ? placeholderImageRegex.test(url) : false;
|
||||
|
||||
export const getProxiedIMDbImgUrl = (url: string) => {
|
||||
return `/api/media_proxy?url=${encodeURIComponent(url)}`;
|
||||
};
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
import Redis from 'ioredis';
|
||||
|
||||
const redisUrl = process.env.REDIS_URL;
|
||||
const toUseRedis = process.env.USE_REDIS === 'true';
|
||||
const toUseRedis =
|
||||
process.env.USE_REDIS === 'true' || process.env.USE_REDIS_FOR_API_ONLY === 'true';
|
||||
|
||||
const stub: Pick<Redis, 'get' | 'setex' | 'getBuffer'> = {
|
||||
get: async key => Promise.resolve(null),
|
||||
|
|
Loading…
Add table
Reference in a new issue