Compare commits

...

33 commits
v3.0.0 ... main

Author SHA1 Message Date
zyachel
410cc70259
Merge pull request #60 from SudoVanilla/patch-1
Add SudoVanilla's Instance
2023-11-25 21:55:53 +00:00
Korbs
258a82f2ac
Add that SudoVanilla's Instance uses Cloudflare 2023-11-25 12:26:47 -05:00
Korbs
e2a335f98d
Add SudoVanilla's Instance 2023-11-25 12:26:16 -05:00
zyachel
246f1155d5 Merge branch 'main' of codeberg.org:zyachel/libremdb into nipos-main 2023-10-30 01:37:55 +05:30
zyachel
19f1700a55 feat(api): add api endpoints for dynamic routes
Squashed commit of the following:

commit 9fdd731136
Author: zyachel <aricla@protonmail.com>
Date:   Mon Oct 30 01:25:32 2023 +0530

    feat(api): add a catch-all route

commit 4dffbbc0ec
Author: zyachel <aricla@protonmail.com>
Date:   Mon Oct 30 01:24:10 2023 +0530

    fix(api): refactor all endpoints a bit

    disallow methods other that GET
    properly type return types
    add type guards where needed
    make all endpoints
    return same response format for consistency

commit 264442448f
Author: Niklas Poslovski <niklas.poslovski@nikisoft.one>
Date:   Sun Oct 29 19:00:44 2023 +0100

    Add API endpoints for all routes that contain IMDB data
2023-10-30 01:34:28 +05:30
zyachel
9fdd731136 feat(api): add a catch-all route 2023-10-30 01:25:32 +05:30
zyachel
4dffbbc0ec fix(api): refactor all endpoints a bit
disallow methods other that GET
properly type return types
add type guards where needed
make all endpoints
return same response format for consistency
2023-10-30 01:24:32 +05:30
Niklas Poslovski
264442448f Add API endpoints for all routes that contain IMDB data 2023-10-29 19:00:44 +01:00
zyachel
2b00d5406a chore(release): 3.2.0 2023-10-29 00:50:34 +05:30
zyachel
97f1432ac5 feat(list): add list route
adds ability to see titles, names, and images lists

closes https://github.com/zyachel/libremdb/issues/6
2023-10-29 00:49:55 +05:30
zyachel
60fb23fc5b refactor(name): remove console statement 2023-10-29 00:49:55 +05:30
zyachel
12eaa741ab refactor: general refactor
make barrel files .ts instead of .tsx
move layouts to components directory
2023-10-29 00:49:51 +05:30
zyachel
40eb8a372b chore(release): 3.1.1 2023-10-14 15:32:18 +05:30
zyachel
e91c313f12 fix(name): fix route crash for some ids
sometimes we don't get genres, causing the crash.

fix https://codeberg.org/zyachel/libremdb/issues/20
2023-10-14 15:22:59 +05:30
zyachel
5fa5e9e2c2 docs(readme): update instances list
add a new instance from @ButteredCats

closes https://github.com/zyachel/libremdb/issues/58
2023-10-03 00:28:53 +05:30
zyachel
27322a4c8c docs(readme): update instances list
remove instance from fascinated.cc as cert is broken
add a new instance by openxng.com

closes https://github.com/zyachel/libremdb/issues/54
closes https://github.com/zyachel/libremdb/issues/56
2023-09-02 21:51:15 +05:30
zyachel
21a1c83d95 fix(title): fix a crash in title route 2023-07-09 19:14:59 +05:30
zyachel
b07cb713d8 docs(instances): update instances list
add a new instance by @toyboatcash, and remove esmail's instance

closes https://github.com/zyachel/libremdb/issues/53, closes
https://github.com/zyachel/libremdb/issues/47
2023-07-09 19:11:26 +05:30
tuxsudo
5628d6b75d
Add libremdb.tux.pizza instance 2023-06-25 19:26:35 +00:00
zyachel
38ed0c6217 fix(name): fix name route crash
this commit fixes a crash in name route caused by upstream

closes https://github.com/zyachel/libremdb/issues/51
2023-06-18 14:31:40 +05:30
zyachel
c610ef4d1b fix(media proxy): fix 304 response code with body error
was accidently sending a 304 with body. introduced in c53c88d
2023-06-03 22:20:56 +05:30
zyachel
736d680243 fix(card): fix long attributes in cards under 'Known For' section
makes the attributes scrollable instead
2023-06-03 22:18:36 +05:30
zyachel
0aea2f47da fix(error): fix incorrect 'view on IMDb' link on error page
the error was due to a faulty logic. 'useRouter' was being used to detect pathname, which doesn't
keep original url on 404 page.
this commit fixes that.
this commit also makes it easy to go to
IMDb by adding a clear link on error page.

closes https://github.com/zyachel/libremdb/issues/50
2023-06-03 22:12:54 +05:30
NoPlagiarism
23eeae3558 Add WhateverItWorks instance 2023-05-26 00:33:25 +05:00
zyachel
bb6405cb05 chore(release): 3.1.0 2023-05-21 18:30:11 +05:30
zyachel
c53c88db9b feat(cache): implement caching of routes 2023-05-21 18:15:03 +05:30
zyachel
8599ae2c5a fix(form): fix hydration error
was due to nested anchor tags
2023-05-21 18:13:44 +05:30
zyachel
8d9b6630a5 fix(name): fix a couple of crashes in name and title route 2023-05-21 18:12:23 +05:30
zyachel
be80244eb3 docs(instances): remove dead instances
this commit removes instances that are either unreachable, or haven't been updated in a long time.

closes https://github.com/zyachel/libremdb/issues/46
2023-05-21 14:40:54 +05:30
zyachel
a0f3ba095a docs(instances): update instances list
add a new instance by @RealFascinated

closes https://github.com/zyachel/libremdb/issues/44
2023-05-07 08:42:25 +05:30
zyachel
11aea1d489 docs(instances): update instances list
add a new instance by nerdyfam.tech

resolves https://github.com/zyachel/libremdb/issues/42#issuecomment-1524052255
2023-04-29 11:30:21 +05:30
zyachel
3ef41d9a6d docs(instances): update instances list
add a new instance by @xbdmHQ

close https://github.com/zyachel/libremdb/issues/43
2023-04-26 22:18:59 +05:30
zyachel
7dea9eac14 build(dependencies): update dependencies and use pnpm v8
this commit also fixes an accidental lockfile mismatch

close https://github.com/zyachel/libremdb/issues/42
2023-04-26 22:12:22 +05:30
64 changed files with 2329 additions and 1142 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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) | &mdash; | 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

View file

@ -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'],

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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>
)}

View file

@ -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;

View file

@ -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' },

View file

@ -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>
.

View file

@ -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>

View 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];

View 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;

View 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;

View 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>
);
};

View 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;

View 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;

View 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>
);
};

View file

@ -0,0 +1,3 @@
export { default as Data } from './Data';
export { default as Meta } from './Meta';
export { default as Pagination } from './Pagination';

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);

View file

@ -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>

View file

@ -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;
}>;

View file

@ -125,7 +125,7 @@ export default interface RawTitle {
runtime: {
value: number;
};
description: {
description?: {
value: string;
language: string;
};

View file

@ -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'>;

View file

@ -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;

View 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;

View 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 } };
};

View file

@ -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 = () => {

View 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
View 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 });
}
}

View 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 });
}
}

View file

@ -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');

View 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 });
}
}

View 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 });
}
}

View file

@ -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 = () => {

View file

@ -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,
},
};
}

View 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;

View file

@ -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 } };
}
};

View file

@ -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';

View file

@ -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 } };
}
};

View file

@ -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;

View file

@ -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;
}

View 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;
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View file

@ -4,12 +4,6 @@
display: grid;
gap: var(--comp-whitespace);
& > section {
overflow-x: auto;
display: grid;
gap: var(--spacer-1);
}
details {
overflow-x: auto;
}

View file

@ -1,4 +1,4 @@
.bio {
.container {
display: grid;
gap: var(--comp-whitespace);
}

View 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);
}
}

View file

@ -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: {

View file

@ -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,
},
},

View 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
View 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();

View file

@ -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);
}
};

View 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;

View file

@ -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)}`;
};

View file

@ -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),