Compare commits
24 Commits
2b13a79013
...
renovate/m
Author | SHA1 | Date | |
---|---|---|---|
72bdbaf947 | |||
f717a6b610
|
|||
648d5cde32
|
|||
288ec34fb4
|
|||
4e03757d69
|
|||
f92035ed8e
|
|||
0e2117e4c7
|
|||
ec6c0a9a13
|
|||
34bcdc3b05
|
|||
e98ab85034
|
|||
255fbb521f
|
|||
ac60bda3bd
|
|||
bde980536d
|
|||
e6ebf6ca78
|
|||
9a16fa65c9
|
|||
8ba89d1885
|
|||
10772a8d6f
|
|||
22bba72cf9
|
|||
d9c37b7f8f
|
|||
19a74ec438
|
|||
1132ba4a98
|
|||
ff8ca7db4a
|
|||
e1d46adb1e
|
|||
721d83eff1
|
41
.env.example
41
.env.example
@ -1,43 +1,12 @@
|
||||
################################################################################
|
||||
### PLEASE FILL/ENABLE REQUIRED VARS AT LEAST BEFORE RUNNING THE APPLICATION ###
|
||||
################################################################################
|
||||
NEXT_PUBLIC_URL=https://example.com
|
||||
NEXT_PUBLIC_TITLE='my libremdb'
|
||||
|
||||
################################################################################
|
||||
### 1. REQUIRED VARS(site may not work as expected without these).
|
||||
################################################################################
|
||||
## used for meta tags. e.g: 'https://libremdb.iket.me'. don't add end slash.
|
||||
NEXT_PUBLIC_URL=
|
||||
## used when fetching data from IMDb. not adding these could result in not getting any response.
|
||||
## example useragent header: 'Mozilla/5.0 (X11; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0'
|
||||
AXIOS_USERAGENT=
|
||||
## example accept header: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'
|
||||
AXIOS_ACCEPT=
|
||||
AXIOS_USERAGENT='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3'
|
||||
AXIOS_LANGUAGE='en-US,en;q=0.5'
|
||||
AXIOS_ACCEPT='text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'
|
||||
|
||||
################################################################################
|
||||
### 2. OPTIONAL VARS(enabling these is encouraged)
|
||||
################################################################################
|
||||
## for forcing a certain language for data we get from imdb. Useful when you don't want your IP to determine the preferred language.
|
||||
# AXIOS_LANGUAGE='en-US,en;q=0.5'
|
||||
## comment it out if you wish to enable nextjs stats collection. more at https://nextjs.org/telemetry
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
################################################################################
|
||||
### 3. REDIS CONFIG(optional if you don't need redis)
|
||||
################################################################################
|
||||
## 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
|
||||
|
||||
################################################################################
|
||||
### 4. INSTANCE META FIELDS(not required but good to have)
|
||||
################################################################################
|
||||
## example: 'https://iket.me'.
|
||||
NEXT_PUBLIC_INSTANCE_MAIN_URL=
|
||||
## eg: 'zyachel'
|
||||
NEXT_PUBLIC_INSTANCE_NAME=
|
@ -1,28 +1,34 @@
|
||||
name: Build and publish the docker image
|
||||
name: docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["custom"]
|
||||
branches:
|
||||
- "main"
|
||||
paths-ignore:
|
||||
- "README.md"
|
||||
- "LICENSE.txt"
|
||||
- "docker-compose.example.yml"
|
||||
- "ups.json"
|
||||
|
||||
env:
|
||||
REGISTRY: git.ngn.tf
|
||||
IMAGE: ${{gitea.repository}}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: "https://github.com/actions/checkout@v4"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to container repo
|
||||
uses: "https://github.com/docker/login-action@v1"
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ${{env.REGISTRY}}
|
||||
username: ${{gitea.actor}}
|
||||
password: ${{secrets.PACKAGES_TOKEN}}
|
||||
|
||||
- name: Build image
|
||||
- name: Build docker image
|
||||
run: |
|
||||
docker build . --tag ${{env.REGISTRY}}/${{env.IMAGE}}:latest
|
||||
docker push ${{env.REGISTRY}}/${{env.IMAGE}}:latest
|
25
.gitea/workflows/ups.yml
Normal file
25
.gitea/workflows/ups.yml
Normal file
@ -0,0 +1,25 @@
|
||||
name: ups
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "@weekly"
|
||||
|
||||
jobs:
|
||||
ups:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt update -y
|
||||
sudo apt install -y python3 python3-build python3-requests make
|
||||
|
||||
- name: Install ups
|
||||
run: |
|
||||
git clone https://git.ngn.tf/ngn/ups && cd ups
|
||||
make && make install
|
||||
|
||||
- name: Run ups
|
||||
run: PATH=~/.local/bin:$PATH ups-check
|
10
Dockerfile
10
Dockerfile
@ -1,20 +1,12 @@
|
||||
FROM node:lts-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM node:lts-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:lts-alpine AS runner
|
||||
|
@ -1,5 +1,7 @@
|
||||
# [ngn.tf] | libremdb
|
||||
# libremdb - IMDb frontend
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
A fork of the [libremdb](https://github.com/zyachel/libremdb) project, with my personal changes.
|
||||
A fork of the [libremdb](https://github.com/zyachel/libremdb) project, with my
|
||||
personal changes.
|
||||
|
@ -3,16 +3,16 @@ services:
|
||||
container_name: libremdb
|
||||
image: git.ngn.tf/ngn/libremdb
|
||||
ports:
|
||||
- 80:3000
|
||||
- 80:3000
|
||||
env_file: .env.example
|
||||
depends_on:
|
||||
- libremdb-redis
|
||||
- libremdb_redis
|
||||
tmpfs:
|
||||
- /opt/app/.next/cache/:size=10M,mode=0770,uid=65534,gid=65534,noexec,nosuid,nodev
|
||||
- /app/.next/cache/:size=10M,mode=0770,uid=65534,gid=65534,noexec,nosuid,nodev
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
- ALL
|
||||
user: 65534:65534 # equivalent to the nobody user
|
||||
read_only: true
|
||||
restart: unless-stopped
|
||||
@ -22,10 +22,10 @@ services:
|
||||
image: redis
|
||||
user: nobody
|
||||
tmpfs:
|
||||
- /data:size=10M,mode=0770,uid=65534,gid=65534,noexec,nosuid,nodev
|
||||
- /data:size=10M,mode=0770,uid=65534,gid=65534,noexec,nosuid,nodev
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
- ALL
|
||||
read_only: true
|
||||
restart: unless-stopped
|
||||
|
@ -9,6 +9,11 @@ const nextConfig = {
|
||||
destination: '/find',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/:langcode(\\w{2})/:slug*',
|
||||
destination: '/:slug*',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
|
10
package.json
10
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "libremdb",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.0",
|
||||
"description": "a free & open source IMDb front-end",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@ -21,14 +21,14 @@
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"ioredis": "^5.3.2",
|
||||
"next": "12.2.5",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"sharp": "^0.33.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.7.3",
|
||||
"@types/react": "18.0.17",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"eslint": "8.22.0",
|
||||
"eslint-config-next": "12.2.5",
|
||||
"sass": "^1.62.1",
|
||||
|
3200
pnpm-lock.yaml
generated
3200
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
7
public/opensearch.xml
Normal file
7
public/opensearch.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<ShortName>libremdb</ShortName>
|
||||
<Description>Search libremdb</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<Image width="16" height="16" type="image/x-icon">https://libremdb.iket.me/favicon.ico</Image>
|
||||
<Url type="text/html" method="get" template="https://libremdb.iket.me/find?q={searchTerms}"/>
|
||||
</OpenSearchDescription>
|
4
renovate.json
Normal file
4
renovate.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["config:recommended"],
|
||||
"prHourlyLimit": 20
|
||||
}
|
@ -11,6 +11,7 @@ type Props = {
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
originalPath?: string;
|
||||
stack?: string;
|
||||
/** props specific to error boundary. */
|
||||
misc?: {
|
||||
subtext: string;
|
||||
@ -19,7 +20,9 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
const ErrorInfo = ({ message, statusCode, misc, originalPath }: Props) => {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
const ErrorInfo = ({ message, statusCode, misc, originalPath, stack }: Props) => {
|
||||
const title = statusCode ? `${message} (${statusCode})` : message;
|
||||
return (
|
||||
<>
|
||||
@ -39,6 +42,11 @@ const ErrorInfo = ({ message, statusCode, misc, originalPath }: Props) => {
|
||||
<use href='/svg/sadgnu.svg#sad-gnu'></use>
|
||||
</svg>
|
||||
<h1 className={`heading heading__primary ${styles.heading}`}>{title}</h1>
|
||||
{Boolean(stack && isDev) && (
|
||||
<pre className={styles.stack}>
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
{misc ? (
|
||||
<>
|
||||
<p>{misc.subtext}</p>
|
||||
@ -64,6 +72,13 @@ const ErrorInfo = ({ message, statusCode, misc, originalPath }: Props) => {
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
If you think this shouldn't happen,{' '}
|
||||
<Link href='/contact'>
|
||||
<a className='link'>let it be known</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
@ -22,9 +22,6 @@ const Footer = () => {
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<span> | </span>
|
||||
</li>
|
||||
))}
|
||||
<li className={styles.nav__item}>
|
||||
<a href='#' className={styles.nav__link}>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import Footer from './Footer';
|
||||
import Header from './Header';
|
||||
|
||||
type Props = {
|
||||
@ -15,6 +16,7 @@ const Layout = ({ full, children, className, originalPath }: Props) => {
|
||||
<main id='main' className={`main ${className}`}>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -14,32 +14,41 @@ type Props = {
|
||||
const Media = ({ className, media }: Props) => {
|
||||
return (
|
||||
<div className={`${className} ${styles.media}`}>
|
||||
{(media.trailer || !!media.videos.total) && (
|
||||
{(media.trailers?.length || !!media.videos.total) && (
|
||||
<section className={styles.videos}>
|
||||
<h2 className='heading heading__secondary'>Videos</h2>
|
||||
|
||||
<div className={styles.videos__container}>
|
||||
{media.trailer && (
|
||||
<div className={styles.trailer}>
|
||||
{media.trailers?.map(trailer => (
|
||||
<div className={styles.trailer} key={trailer.id}>
|
||||
<video
|
||||
aria-label='trailer video'
|
||||
aria-label={trailer.caption ?? 'trailer video'}
|
||||
controls
|
||||
playsInline
|
||||
poster={getProxiedIMDbImgUrl(modifyIMDbImg(media.trailer.thumbnail))}
|
||||
poster={getProxiedIMDbImgUrl(modifyIMDbImg(trailer.thumbnail))}
|
||||
className={styles.trailer__video}
|
||||
preload='none'
|
||||
muted
|
||||
>
|
||||
{media.trailer.urls.map(source => (
|
||||
{trailer.urls.map(source => (
|
||||
<source
|
||||
key={source.url}
|
||||
type={source.mimeType ?? undefined}
|
||||
type='video/mp4'
|
||||
src={getProxiedIMDbImgUrl(source.url)}
|
||||
media={source.resolution !== 'SD' ? '(min-width: 450px)' : undefined}
|
||||
data-res={source.resolution}
|
||||
/>
|
||||
))}
|
||||
|
||||
<p>
|
||||
{trailer.caption}:{' '}
|
||||
<Link href={getProxiedIMDbImgUrl(trailer.urls[0]?.url)}>
|
||||
<a className='link'>link</a>
|
||||
</Link>
|
||||
</p>
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
|
||||
{!!media.videos.total &&
|
||||
media.videos.videos.map(video => (
|
||||
|
@ -1,5 +1,4 @@
|
||||
import Head from 'next/head';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@ -8,6 +7,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_URL ?? 'https://iket.me';
|
||||
const BASE_TITLE = process.env.NEXT_PUBLIC_TITLE ?? 'libremdb';
|
||||
|
||||
const Meta = ({
|
||||
title,
|
||||
@ -22,7 +22,7 @@ const Meta = ({
|
||||
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
|
||||
<title key='title'>{`${title} | libremdb`}</title>
|
||||
<title key='title'>{`${title} - ${BASE_TITLE}`}</title>
|
||||
<meta key='desc' name='description' content={description} />
|
||||
<link rel='icon' href='/favicon.ico' sizes='any' />
|
||||
<link rel='icon' href='/icon.svg' type='image/svg+xml' />
|
||||
@ -36,6 +36,13 @@ const Meta = ({
|
||||
<meta property='og:locale' content='en_US' />
|
||||
<meta property='og:type' content='video.movie' />
|
||||
<meta property='og:image' content={url.toString()} />
|
||||
|
||||
<link
|
||||
rel='search'
|
||||
type='application/opensearchdescription+xml'
|
||||
href='/opensearch.xml'
|
||||
title='libremdb'
|
||||
></link>
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
@ -9,43 +9,54 @@ type Props = {
|
||||
const DidYouKnow = ({ data }: Props) => (
|
||||
<section className={styles.container}>
|
||||
<h2 className='heading heading__secondary'>Did you know</h2>
|
||||
{!!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>
|
||||
{isEmpty(data) ? (
|
||||
<p>Nothing interesting to show.</p>
|
||||
) : (
|
||||
<>
|
||||
{!!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>
|
||||
);
|
||||
|
||||
export default DidYouKnow;
|
||||
|
||||
const isEmpty = (data: Props['data']) =>
|
||||
Boolean(
|
||||
!data.nicknames.length && !data.quotes && !data.salary && !data.trademark && !data.trivia
|
||||
);
|
@ -86,6 +86,21 @@ const Basic = ({ data, className }: Props) => {
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!!data.interests.length && (
|
||||
<p className={styles.genres}>
|
||||
<span className={styles.genres__heading}>Interests: </span>
|
||||
{data.interests.map((interest, i) => (
|
||||
<Fragment key={interest.id}>
|
||||
{i > 0 && ', '}
|
||||
<Link href={`/interest/${interest.id}`}>
|
||||
<a className={styles.link}>{interest.text}</a>
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className={styles.overview}>
|
||||
<span className={styles.overview__heading}>Plot: </span>
|
||||
<span className={styles.overview__text}>{data.plot || '-'}</span>
|
||||
|
@ -7,7 +7,13 @@ type Props = {
|
||||
};
|
||||
|
||||
const DidYouKnow = ({ data }: Props) => {
|
||||
if (!Object.keys(data).length) return <></>;
|
||||
if (!Object.keys(data).length)
|
||||
return (
|
||||
<section className={styles.didYouKnow}>
|
||||
<h2 className='heading heading__secondary'>Did you know</h2>
|
||||
<p>Nothing interesting to show.</p>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section className={styles.didYouKnow}>
|
||||
<h2 className='heading heading__secondary'>Did you know</h2>
|
||||
|
@ -1,82 +1,127 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Reviews } from 'src/interfaces/shared/title';
|
||||
import type { Reviews as TReviews } from 'src/interfaces/shared/title';
|
||||
import { formatNumber } from 'src/utils/helpers';
|
||||
import styles from 'src/styles/modules/components/title/reviews.module.scss';
|
||||
|
||||
type Props = {
|
||||
reviews: Reviews;
|
||||
reviews: TReviews;
|
||||
};
|
||||
|
||||
const Reviews = ({ reviews }: Props) => {
|
||||
return (
|
||||
<section className={styles.reviews}>
|
||||
<h2 className='heading heading__secondary'>Reviews</h2>
|
||||
|
||||
<RatingsDistribution ratings={reviews.ratingsDistribution} />
|
||||
|
||||
<section className={styles.userReviews}>
|
||||
<h3 className='heading heading__tertiary'>User Reviews</h3>
|
||||
{reviews.featuredReviews ? (
|
||||
<ul className={styles.userReviews__list} role='list'>
|
||||
{reviews.featuredReviews.map(featuredReview => (
|
||||
<li key={featuredReview.id}>
|
||||
<details className={styles.review}>
|
||||
<summary className={styles.review__summary}>
|
||||
<strong>{featuredReview.review.summary}</strong>
|
||||
</summary>
|
||||
<div
|
||||
className={styles.review__text}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: featuredReview.review.html,
|
||||
}}
|
||||
></div>
|
||||
<footer className={styles.review__metadata}>
|
||||
<p>
|
||||
{featuredReview.rating && <span>Rated {featuredReview.rating}/10</span>}
|
||||
<span>
|
||||
{' '}
|
||||
by{' '}
|
||||
<Link href={`/user/${featuredReview.reviewer.id}`}>
|
||||
<a className='link'>{featuredReview.reviewer.name}</a>
|
||||
</Link>
|
||||
</span>
|
||||
</p>
|
||||
</footer>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No reviews yet.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{reviews.ai?.summary && (
|
||||
<details className={styles.reviewAi}>
|
||||
<summary className='heading heading__tertiary'>AI Summary</summary>
|
||||
<p dangerouslySetInnerHTML={{ __html: reviews.ai.summary }} />
|
||||
<ul>
|
||||
{reviews.ai.themes.map(theme => (
|
||||
<li key={theme.id}>{theme.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<ReviewStats reviews={reviews} />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reviews;
|
||||
|
||||
const RatingsDistribution = ({ ratings }: { ratings: Props['reviews']['ratingsDistribution'] }) => {
|
||||
const maxRating = Math.max(...ratings.map(r => r.votes));
|
||||
|
||||
return (
|
||||
<div className={styles.ratingsDistribution}>
|
||||
<h3 className='heading heading__tertiary'>Ratings Distribution</h3>
|
||||
{ratings.length ? (
|
||||
<ul>
|
||||
{ratings.map(rating => (
|
||||
<li
|
||||
key={rating.rating}
|
||||
style={
|
||||
{
|
||||
'--bar-height': `${((rating.votes / maxRating) * 100).toFixed(2)}%`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{rating.rating} <span>({formatNumber(rating.votes)})</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No ratings yet.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReviewStats = ({ reviews }: { reviews: Props['reviews'] }) => {
|
||||
const router = useRouter();
|
||||
const { titleId } = router.query;
|
||||
|
||||
return (
|
||||
<section className={styles.reviews}>
|
||||
<h2 className="heading heading__secondary">Reviews</h2>
|
||||
|
||||
{reviews.featuredReview && (
|
||||
<article className={styles.reviews__reviewContainer}>
|
||||
<details className={styles.review}>
|
||||
<summary className={styles.review__summary}>
|
||||
<strong>{reviews.featuredReview.review.summary}</strong>
|
||||
</summary>
|
||||
<div
|
||||
className={styles.review__text}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: reviews.featuredReview.review.html,
|
||||
}}
|
||||
></div>
|
||||
</details>
|
||||
<footer className={styles.review__metadata}>
|
||||
<p>
|
||||
{reviews.featuredReview.rating && (
|
||||
<span>Rated {reviews.featuredReview.rating}/10</span>
|
||||
)}
|
||||
<span>
|
||||
{' '}
|
||||
by{' '}
|
||||
<Link href={`/user/${reviews.featuredReview.reviewer.id}`}>
|
||||
<a className="link">{reviews.featuredReview.reviewer.name}</a>
|
||||
</Link>
|
||||
</span>
|
||||
<span> on {reviews.featuredReview.date}.</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
{formatNumber(reviews.featuredReview.votes.up)} upvotes
|
||||
</span>
|
||||
<span>
|
||||
, {formatNumber(reviews.featuredReview.votes.down)} downvotes
|
||||
</span>
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
)}
|
||||
|
||||
<div className={styles.reviews__stats}>
|
||||
<p>
|
||||
<Link href={`/title/${titleId}/reviews`}>
|
||||
<a className="link">
|
||||
{formatNumber(reviews.numUserReviews)} User reviews
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link href={`/title/${titleId}/externalreviews`}>
|
||||
<a className="link">
|
||||
{formatNumber(reviews.numCriticReviews)} Critic reviews
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link href={`/title/${titleId}/criticreviews`}>
|
||||
<a className="link"> {reviews.metacriticScore} Metascore</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<div className={styles.reviewStats}>
|
||||
<p>
|
||||
<Link href={`/title/${titleId}/reviews`}>
|
||||
<a className='link'>{formatNumber(reviews.numUserReviews)} User reviews</a>
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link href={`/title/${titleId}/externalreviews`}>
|
||||
<a className='link'>{formatNumber(reviews.numCriticReviews)} Critic reviews</a>
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link href={`/title/${titleId}/criticreviews`}>
|
||||
<a className='link'> {reviews.metacriticScore} Metascore</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Reviews;
|
||||
|
@ -143,7 +143,7 @@ export default interface Name {
|
||||
value: string;
|
||||
language: string;
|
||||
};
|
||||
mimeType?: string;
|
||||
videoMimeType?: string;
|
||||
url: string;
|
||||
}>;
|
||||
recommendedTimedTextTrack?: {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,3 @@
|
||||
import { AppError as AppErrorClass } from 'src/utils/helpers';
|
||||
|
||||
export type AppError = Omit<InstanceType<typeof AppErrorClass>, 'name'>;
|
||||
export type AppError = Pick<InstanceType<typeof AppErrorClass>, 'message' | 'statusCode' | 'stack'>;
|
||||
|
@ -67,13 +67,13 @@ export const getServerSideProps: GetServerSideProps<Data, FindQueryParams> = asy
|
||||
props: { data: { title: query, results: res }, error: null, originalPath },
|
||||
};
|
||||
} catch (error) {
|
||||
const { message, statusCode } = getErrorProperties(error);
|
||||
ctx.res.statusCode = statusCode;
|
||||
ctx.res.statusMessage = message;
|
||||
const err = getErrorProperties(error);
|
||||
ctx.res.statusCode = err.statusCode;
|
||||
ctx.res.statusMessage = err.message;
|
||||
|
||||
return {
|
||||
props: {
|
||||
error: { message, statusCode },
|
||||
error: { message: err.message, statusCode: err.statusCode, stack: err.format() },
|
||||
data: { title: query, results: null },
|
||||
originalPath,
|
||||
},
|
||||
|
@ -55,11 +55,17 @@ export const getServerSideProps: GetServerSideProps<Data, Params> = async ctx =>
|
||||
|
||||
return { props: { data, error: null, originalPath } };
|
||||
} catch (error) {
|
||||
const { message, statusCode } = getErrorProperties(error);
|
||||
ctx.res.statusCode = statusCode;
|
||||
ctx.res.statusMessage = message;
|
||||
const err = getErrorProperties(error);
|
||||
ctx.res.statusCode = err.statusCode;
|
||||
ctx.res.statusMessage = err.message;
|
||||
|
||||
return { props: { error: { message, statusCode }, data: null, originalPath } };
|
||||
return {
|
||||
props: {
|
||||
error: { message: err.message, statusCode: err.statusCode, stack: err.format() },
|
||||
data: null,
|
||||
originalPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -5,7 +5,7 @@ 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 type { AppError } from 'src/interfaces/shared/error';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import title from 'src/utils/fetchers/title';
|
||||
import { getErrorProperties, getProxiedIMDbImgUrl } from 'src/utils/helpers';
|
||||
@ -63,12 +63,18 @@ export const getServerSideProps: GetServerSideProps<Data, Params> = async ctx =>
|
||||
const data = await getOrSetApiCache(titleKey(titleId), title, titleId);
|
||||
|
||||
return { props: { data, error: null, originalPath } };
|
||||
} catch (error) {
|
||||
const { message, statusCode } = getErrorProperties(error);
|
||||
ctx.res.statusCode = statusCode;
|
||||
ctx.res.statusMessage = message;
|
||||
} catch (e) {
|
||||
const err = getErrorProperties(e);
|
||||
ctx.res.statusCode = err.statusCode;
|
||||
ctx.res.statusMessage = err.message;
|
||||
|
||||
return { props: { error: { message, statusCode }, data: null, originalPath } };
|
||||
const error = {
|
||||
message: err.message,
|
||||
statusCode: err.statusCode,
|
||||
stack: err.format(),
|
||||
};
|
||||
console.error(err);
|
||||
return { props: { error, data: null, originalPath } };
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -2,9 +2,9 @@ $_dark: (
|
||||
text-accent: hsl(0, 0%, 100%),
|
||||
text: hsl(0, 0%, 96%),
|
||||
text-muted: hsl(0, 0%, 80%),
|
||||
bg-accent: hsl(221, 39%, 15%),
|
||||
bg-accent: #232323,
|
||||
bg: #000,
|
||||
bg-muted: rgb(20, 28, 46),
|
||||
bg-muted: #141414,
|
||||
link: hsl(339, 95%, 80%),
|
||||
link-muted: hsl(344, 79%, 80%),
|
||||
fill: hsl(339, 75%, 64%),
|
||||
@ -20,4 +20,5 @@ $_dark: (
|
||||
|
||||
$themes: (
|
||||
light: $_dark, // yes
|
||||
dark: $_dark,
|
||||
);
|
||||
|
@ -8,7 +8,7 @@
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--spacer-1);
|
||||
gap: var(--comp-whitespace);
|
||||
|
||||
@include helper.bp('bp-700') {
|
||||
--doc-whitespace: var(--spacer-5);
|
||||
@ -31,6 +31,19 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stack {
|
||||
max-width: 90%;
|
||||
max-height: 20rem;
|
||||
padding: var(--spacer-3);
|
||||
white-space: pre-wrap;
|
||||
overflow: scroll;
|
||||
|
||||
user-select: all;
|
||||
|
||||
border-radius: var(--spacer-1);
|
||||
background-color: var(--clr-bg-muted);
|
||||
}
|
||||
|
||||
.button {
|
||||
align-self: end;
|
||||
|
||||
|
@ -1,26 +1,94 @@
|
||||
.reviews {
|
||||
display: grid;
|
||||
gap: var(--comp-whitespace);
|
||||
}
|
||||
|
||||
&__reviewContainer {
|
||||
// background-color: antiquewhite;
|
||||
.ratingsDistribution {
|
||||
overflow: hidden;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: var(--spacer-2);
|
||||
overflow-x: auto;
|
||||
padding-block: var(--spacer-1);
|
||||
}
|
||||
|
||||
&__stats {
|
||||
li {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 5em;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before {
|
||||
display: block;
|
||||
content: '';
|
||||
|
||||
height: var(--bar-height);
|
||||
margin-top: auto;
|
||||
background-color: var(--clr-fill);
|
||||
width: var(--spacer-6);
|
||||
border-radius: var(--spacer-0);
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: var(--fw-bold);
|
||||
|
||||
> span {
|
||||
font-weight: initial;
|
||||
font-size: 0.9em;
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reviewAi {
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
p {
|
||||
padding-block: var(--spacer-2);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: var(--spacer-1);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacer-2);
|
||||
|
||||
li {
|
||||
padding: var(--spacer-1);
|
||||
background-color: var(--clr-bg-muted);
|
||||
border-radius: var(--spacer-0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reviewStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacer-2);
|
||||
}
|
||||
|
||||
.userReviews {
|
||||
|
||||
&__list {
|
||||
padding-block-start: var(--spacer-1);
|
||||
display: grid;
|
||||
gap: var(--spacer-1);
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.review {
|
||||
&__summary {
|
||||
font-size: calc(var(--fs-5) * 1.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__text,
|
||||
&__metadata {
|
||||
padding-top: var(--spacer-2);
|
||||
padding-top: var(--spacer-1);
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
.footer {
|
||||
background: var(--clr-bg-muted);
|
||||
padding: var(--spacer-4);
|
||||
padding: var(--spacer-3);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -17,10 +17,6 @@
|
||||
gap: var(--spacer-2) var(--spacer-4);
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
span {
|
||||
font-weight: 900;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
|
@ -6,7 +6,8 @@
|
||||
font-size: 1.1em;
|
||||
|
||||
display: grid;
|
||||
background: (var(--clr-bg-muted));
|
||||
background: none;
|
||||
border-bottom: solid 1px (var(--clr-text-accent));
|
||||
|
||||
&__about {
|
||||
min-height: 100vh;
|
||||
@ -24,7 +25,7 @@
|
||||
|
||||
align-items: center;
|
||||
gap: var(--spacer-4);
|
||||
padding: var(--spacer-4);
|
||||
padding: var(--spacer-3);
|
||||
|
||||
@include helper.bp('bp-700') {
|
||||
padding: var(--spacer-3);
|
||||
|
@ -42,18 +42,18 @@ const cleanName = (rawData: RawName) => {
|
||||
},
|
||||
media: {
|
||||
...(main.primaryVideos.edges.length && {
|
||||
trailer: {
|
||||
id: main.primaryVideos.edges[0].node.id,
|
||||
isMature: main.primaryVideos.edges[0].node.isMature,
|
||||
thumbnail: main.primaryVideos.edges[0].node.thumbnail.url,
|
||||
runtime: main.primaryVideos.edges[0].node.runtime.value,
|
||||
caption: main.primaryVideos.edges[0].node.description?.value ?? null,
|
||||
urls: main.primaryVideos.edges[0].node.playbackURLs.map(url => ({
|
||||
resolution: url.displayName.value,
|
||||
mimeType: url.mimeType ?? null,
|
||||
trailers: main.primaryVideos.edges.map(trailer => ({
|
||||
id: trailer.node.id,
|
||||
isMature: trailer.node.isMature,
|
||||
thumbnail: trailer.node.thumbnail.url,
|
||||
runtime: trailer.node.runtime.value,
|
||||
caption: trailer.node.description?.value ?? null,
|
||||
urls: trailer.node.playbackURLs.map(url => ({
|
||||
resolution: url.displayName.value as 'SD' | '480p',
|
||||
mimeType: url.videoMimeType ?? null,
|
||||
url: url.url,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
}),
|
||||
images: {
|
||||
total: misc.images.total,
|
||||
|
@ -12,6 +12,7 @@ const cleanTitle = (rawData: RawTitle) => {
|
||||
titleId: main.id,
|
||||
basic: {
|
||||
id: main.id,
|
||||
isAdult: main.isAdult,
|
||||
title: main.titleText.text,
|
||||
// ...(main.originalTitleText.text.toLowerCase() !==
|
||||
// main.titleText.text.toLowerCase() && {
|
||||
@ -50,6 +51,10 @@ const cleanTitle = (rawData: RawTitle) => {
|
||||
id: genre.id,
|
||||
text: genre.text,
|
||||
})),
|
||||
interests: main.interests.edges.map(interest => ({
|
||||
id: interest.node.id,
|
||||
text: interest.node.primaryText.text,
|
||||
})),
|
||||
plot: main.plot?.plotText?.plainText || null,
|
||||
primaryCrew: main.principalCredits.map(type => ({
|
||||
type: { category: type.category.text, id: type.category.id },
|
||||
@ -76,18 +81,18 @@ const cleanTitle = (rawData: RawTitle) => {
|
||||
})),
|
||||
media: {
|
||||
...(main.primaryVideos.edges.length && {
|
||||
trailer: {
|
||||
id: main.primaryVideos.edges[0].node.id,
|
||||
isMature: main.primaryVideos.edges[0].node.isMature,
|
||||
thumbnail: main.primaryVideos.edges[0].node.thumbnail.url,
|
||||
runtime: main.primaryVideos.edges[0].node.runtime.value,
|
||||
caption: main.primaryVideos.edges[0].node.description?.value ?? null,
|
||||
urls: main.primaryVideos.edges[0].node.playbackURLs.map(url => ({
|
||||
resolution: url.displayName.value,
|
||||
mimeType: url.mimeType ?? null,
|
||||
trailers: main.primaryVideos.edges.map(trailer => ({
|
||||
id: trailer.node.id,
|
||||
isMature: trailer.node.isMature,
|
||||
thumbnail: trailer.node.thumbnail.url,
|
||||
runtime: trailer.node.runtime.value,
|
||||
caption: trailer.node.description?.value ?? null,
|
||||
urls: trailer.node.playbackURLs.map(url => ({
|
||||
resolution: url.displayName.value as 'SD' | '480p',
|
||||
mimeType: url.videoMimeType ?? null,
|
||||
url: url.url,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
}),
|
||||
images: {
|
||||
total: misc.titleMainImages.total,
|
||||
@ -122,6 +127,9 @@ const cleanTitle = (rawData: RawTitle) => {
|
||||
}),
|
||||
topRating: misc.ratingsSummary.topRanking?.rank || null,
|
||||
},
|
||||
watchlist: {
|
||||
text: main.engagementStatistics?.watchlistStatistics.displayableCount.text || null,
|
||||
},
|
||||
meta: {
|
||||
// for tv episode
|
||||
...(main.series && {
|
||||
@ -208,25 +216,35 @@ const cleanTitle = (rawData: RawTitle) => {
|
||||
metacriticScore: main.metacritic?.metascore.score || null,
|
||||
numCriticReviews: main.criticReviewsTotal.total,
|
||||
numUserReviews: misc.reviews.total,
|
||||
...(misc.featuredReviews.edges.length && {
|
||||
featuredReview: {
|
||||
id: misc.featuredReviews.edges[0].node.id,
|
||||
reviewer: {
|
||||
id: misc.featuredReviews.edges[0].node.author.userId,
|
||||
name: misc.featuredReviews.edges[0].node.author.nickName,
|
||||
},
|
||||
rating: misc.featuredReviews.edges[0].node.authorRating,
|
||||
date: formatDate(misc.featuredReviews.edges[0].node.submissionDate),
|
||||
votes: {
|
||||
up: misc.featuredReviews.edges[0].node.helpfulness.upVotes,
|
||||
down: misc.featuredReviews.edges[0].node.helpfulness.downVotes,
|
||||
},
|
||||
review: {
|
||||
summary: misc.featuredReviews.edges[0].node.summary.originalText,
|
||||
html: misc.featuredReviews.edges[0].node.text.originalText.plaidHtml,
|
||||
},
|
||||
ratingsDistribution:
|
||||
misc.aggregateRatingsBreakdown.histogram?.histogramValues.map(v => ({
|
||||
rating: v.rating,
|
||||
votes: v.voteCount,
|
||||
})) || [],
|
||||
...(misc.reviewSummary && {
|
||||
ai: {
|
||||
summary: misc.reviewSummary.overall.medium.value.plaidHtml,
|
||||
themes: misc.reviewSummary.themes.map(t => ({
|
||||
text: t.label.value,
|
||||
id: t.themeId,
|
||||
sentiment: t.sentiment as 'POSITIVE' | 'NEGATIVE',
|
||||
})),
|
||||
},
|
||||
}),
|
||||
...(misc.featuredReviews.edges.length && {
|
||||
featuredReviews: misc.featuredReviews.edges.map(featuredReview => ({
|
||||
id: featuredReview.node.id,
|
||||
reviewer: {
|
||||
id: featuredReview.node.author.userId,
|
||||
name: featuredReview.node.author.username.text,
|
||||
},
|
||||
rating: featuredReview.node.authorRating,
|
||||
review: {
|
||||
summary: featuredReview.node.summary.originalText,
|
||||
html: featuredReview.node.text.originalText.plaidHtml,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
},
|
||||
details: {
|
||||
...(misc.releaseDate && {
|
||||
@ -242,8 +260,8 @@ const cleanTitle = (rawData: RawTitle) => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(misc.countriesOfOrigin && {
|
||||
countriesOfOrigin: misc.countriesOfOrigin.countries.map(country => ({
|
||||
...(misc.countriesDetails && {
|
||||
countriesOfOrigin: misc.countriesDetails.countries.map(country => ({
|
||||
id: country.id,
|
||||
text: country.text,
|
||||
})),
|
||||
@ -353,6 +371,10 @@ const cleanTitle = (rawData: RawTitle) => {
|
||||
},
|
||||
genres: title.node.titleGenres?.genres.map(genre => genre.genre.text) ?? null,
|
||||
})),
|
||||
faqs: {
|
||||
questions: misc.faqs.edges.map(q => ({ question: q.node.question, id: q.node.id })),
|
||||
total: misc.faqs.total,
|
||||
},
|
||||
};
|
||||
|
||||
return cleanData;
|
||||
|
@ -74,12 +74,33 @@ export const getProxiedIMDbImgUrl = (url: string) => {
|
||||
};
|
||||
|
||||
export const AppError = class extends Error {
|
||||
constructor(message: string, public statusCode: number, errorOptions?: unknown) {
|
||||
const saneErrorOptions = getErrorOptions(errorOptions);
|
||||
super(message, saneErrorOptions);
|
||||
constructor(message: string, public statusCode: number, cause?: unknown) {
|
||||
const _cause = cause ? AppError.toError(cause) : undefined;
|
||||
super(message, { cause: _cause });
|
||||
|
||||
Error.captureStackTrace(this, AppError);
|
||||
if (process.env.NODE_ENV === 'development') console.error(this);
|
||||
}
|
||||
|
||||
static toError(err: unknown) {
|
||||
if (err instanceof Error) return err;
|
||||
return new Error(`Unexpected: ${JSON.stringify(err)}`);
|
||||
}
|
||||
|
||||
format() {
|
||||
let str = '';
|
||||
let cur: Error | null = this;
|
||||
let depth = 0;
|
||||
|
||||
while (cur && depth <= 4) {
|
||||
if (cur.stack) str += `${cur.stack}\n`;
|
||||
else str += `${cur.name}: ${cur.message}\n`;
|
||||
|
||||
cur = cur.cause instanceof Error ? cur.cause : null;
|
||||
if (cur) str += 'Caused by:\n';
|
||||
depth++;
|
||||
}
|
||||
|
||||
return str.trimEnd();
|
||||
}
|
||||
};
|
||||
|
||||
@ -110,19 +131,6 @@ export const isLocalStorageAvailable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getErrorOptions = (error: unknown): ErrorOptions | undefined => {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
|
||||
let cause: unknown;
|
||||
// @ts-expect-error it's not an error! just that project's ts version is old, which can't be upgraded
|
||||
if ('cause' in error) cause = error.cause;
|
||||
// @ts-expect-error it's not an error! just that project's ts version is old, which can't be upgraded
|
||||
else if ('stack' in error) cause = error.stack;
|
||||
|
||||
// @ts-expect-error it's not an error! just that project's ts version is old, which can't be upgraded
|
||||
return { cause };
|
||||
};
|
||||
|
||||
export const getErrorProperties = (
|
||||
error: unknown,
|
||||
message = 'Something went very wrong',
|
||||
|
Reference in New Issue
Block a user