24 Commits

Author SHA1 Message Date
72bdbaf947 fix(deps): update react monorepo to v19
Some checks failed
renovate/artifacts Artifact file update failure
2025-06-11 17:01:47 +00:00
ngn
f717a6b610 ups: update to dded1e5
Some checks failed
docker / docker (push) Successful in 1m33s
ups / ups (push) Failing after 39s
2025-06-10 09:48:19 +03:00
648d5cde32 fix(media): fix videos not playing
fixes mimetype
also adds other playable trailers, if they exist
2025-06-10 09:48:05 +03:00
288ec34fb4 feat: redirect paths from specific languages 2025-06-10 09:48:00 +03:00
4e03757d69 chore(build): add sharp to trusted dep 2025-06-10 09:47:54 +03:00
f92035ed8e feat(opensearch): add abilility to add libremdb as search engine
thanks to @user451421541757324

re https://github.com/zyachel/libremdb/issues/95
2025-06-10 09:47:49 +03:00
0e2117e4c7 fix(lockfile): update lockfile 2025-06-10 09:47:42 +03:00
ec6c0a9a13 chore(release): 4.2.0 2025-06-10 09:47:33 +03:00
34bcdc3b05 fix(name,title): handle empty states in "did you know" section 2025-06-10 09:47:11 +03:00
e98ab85034 feat(title): add "interests" in basic info 2025-06-10 09:47:05 +03:00
255fbb521f fix(title): fix title route crash
was due to an upstream change.
also show ai summary, ratings distrubution, and multiple user
reviews

re https://github.com/zyachel/libremdb/issues/98
2025-06-10 09:46:57 +03:00
ac60bda3bd fix(error): add trace to browser in dev mode
also make AppError a bit easier to use
2025-06-10 09:46:37 +03:00
ngn
bde980536d fix the example docker compose file
Some checks failed
ups / ups (push) Failing after 39s
Signed-off-by: ngn <ngn@ngn.tf>
2025-05-13 01:51:13 +03:00
ngn
e6ebf6ca78 remove the depend docker command
All checks were successful
docker / docker (push) Successful in 1m39s
Signed-off-by: ngn <ngn@ngn.tf>
2025-05-13 01:43:26 +03:00
ngn
9a16fa65c9 attempt to fix the broken pnpm docker command
All checks were successful
docker / docker (push) Successful in 1m46s
Signed-off-by: ngn <ngn@ngn.tf>
2025-05-13 01:35:05 +03:00
ngn
8ba89d1885 fix the pnpm-lock issue
Some checks failed
docker / docker (push) Failing after 12s
Signed-off-by: ngn <ngn@ngn.tf>
2025-05-13 01:29:21 +03:00
ngn
10772a8d6f add the ups workflow
Some checks failed
docker / docker (push) Failing after 20s
Signed-off-by: ngn <ngn@ngn.tf>
2025-05-13 01:09:41 +03:00
ngn
22bba72cf9 add renovate config
All checks were successful
Build and publish the docker image / build (push) Successful in 1m11s
Signed-off-by: ngn <ngn@ngn.tf>
2025-01-20 00:41:58 +03:00
ngn
d9c37b7f8f add footer to the layout
All checks were successful
Build and publish the docker image / build (push) Successful in 1m20s
Signed-off-by: ngn <ngn@ngn.tf>
2025-01-20 00:31:02 +03:00
ngn
19a74ec438 fix missing border-bottom semicolon for header
All checks were successful
Build and publish the docker image / build (push) Successful in 1m19s
Signed-off-by: ngn <ngn@ngn.tf>
2025-01-20 00:25:03 +03:00
ngn
1132ba4a98 add PUBLIC_TITLE env and cleanup example env file
All checks were successful
Build and publish the docker image / build (push) Successful in 1m19s
Signed-off-by: ngn <ngn@ngn.tf>
2025-01-19 23:48:30 +03:00
ngn
ff8ca7db4a [skip ci] fix example docker compose
Signed-off-by: ngn <ngn@ngn.tf>
2025-01-19 23:37:48 +03:00
ngn
e1d46adb1e fix theme selection
All checks were successful
Build and publish the docker image / build (push) Successful in 1m22s
Signed-off-by: ngn <ngn@ngn.tf>
2025-01-19 23:34:29 +03:00
ngn
721d83eff1 fix footer link loop and update theme colors
Signed-off-by: ngn <ngn@ngn.tf>
2025-01-19 23:32:01 +03:00
36 changed files with 3106 additions and 2400 deletions

View File

@ -1,43 +1,12 @@
################################################################################ NEXT_PUBLIC_URL=https://example.com
### PLEASE FILL/ENABLE REQUIRED VARS AT LEAST BEFORE RUNNING THE APPLICATION ### NEXT_PUBLIC_TITLE='my libremdb'
################################################################################
################################################################################ 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'
### 1. REQUIRED VARS(site may not work as expected without these). 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'
## 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=
################################################################################
### 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 # USE_REDIS=true
## in case you don't want to cache media but only api routes
# USE_REDIS_FOR_API_ONLY=true # USE_REDIS_FOR_API_ONLY=true
## ttl for media and api
# REDIS_CACHE_TTL_API=3600 # REDIS_CACHE_TTL_API=3600
# REDIS_CACHE_TTL_MEDIA=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)
################################################################################
## example: 'https://iket.me'.
NEXT_PUBLIC_INSTANCE_MAIN_URL=
## eg: 'zyachel'
NEXT_PUBLIC_INSTANCE_NAME=

View File

@ -1,28 +1,34 @@
name: Build and publish the docker image name: docker
on: on:
push: push:
branches: ["custom"] branches:
- "main"
paths-ignore:
- "README.md"
- "LICENSE.txt"
- "docker-compose.example.yml"
- "ups.json"
env: env:
REGISTRY: git.ngn.tf REGISTRY: git.ngn.tf
IMAGE: ${{gitea.repository}} IMAGE: ${{gitea.repository}}
jobs: jobs:
build: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: "https://github.com/actions/checkout@v4" uses: actions/checkout@v4
- name: Login to container repo - name: Login to container repo
uses: "https://github.com/docker/login-action@v1" uses: docker/login-action@v1
with: with:
registry: ${{env.REGISTRY}} registry: ${{env.REGISTRY}}
username: ${{gitea.actor}} username: ${{gitea.actor}}
password: ${{secrets.PACKAGES_TOKEN}} password: ${{secrets.PACKAGES_TOKEN}}
- name: Build image - name: Build docker image
run: | run: |
docker build . --tag ${{env.REGISTRY}}/${{env.IMAGE}}:latest docker build . --tag ${{env.REGISTRY}}/${{env.IMAGE}}:latest
docker push ${{env.REGISTRY}}/${{env.IMAGE}}:latest docker push ${{env.REGISTRY}}/${{env.IMAGE}}:latest

25
.gitea/workflows/ups.yml Normal file
View 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

View File

@ -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 FROM node:lts-alpine AS builder
WORKDIR /app WORKDIR /app
COPY . . COPY . .
COPY --from=deps /app/node_modules ./node_modules
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm install -g pnpm RUN npm install -g pnpm
RUN pnpm install
RUN pnpm build RUN pnpm build
FROM node:lts-alpine AS runner FROM node:lts-alpine AS runner

View File

@ -1,5 +1,7 @@
# [ngn.tf] | libremdb # libremdb - IMDb frontend
![](https://git.ngn.tf/ngn/libremdb/actions/workflows/build.yml/badge.svg) ![](https://git.ngn.tf/ngn/libremdb/actions/workflows/docker.yml/badge.svg)
![](https://git.ngn.tf/ngn/libremdb/actions/workflows/ups.yml/badge.svg)
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.

View File

@ -3,16 +3,16 @@ services:
container_name: libremdb container_name: libremdb
image: git.ngn.tf/ngn/libremdb image: git.ngn.tf/ngn/libremdb
ports: ports:
- 80:3000 - 80:3000
env_file: .env.example env_file: .env.example
depends_on: depends_on:
- libremdb-redis - libremdb_redis
tmpfs: 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: security_opt:
- no-new-privileges:true - no-new-privileges:true
cap_drop: cap_drop:
- ALL - ALL
user: 65534:65534 # equivalent to the nobody user user: 65534:65534 # equivalent to the nobody user
read_only: true read_only: true
restart: unless-stopped restart: unless-stopped
@ -22,10 +22,10 @@ services:
image: redis image: redis
user: nobody user: nobody
tmpfs: 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: security_opt:
- no-new-privileges:true - no-new-privileges:true
cap_drop: cap_drop:
- ALL - ALL
read_only: true read_only: true
restart: unless-stopped restart: unless-stopped

View File

@ -9,6 +9,11 @@ const nextConfig = {
destination: '/find', destination: '/find',
permanent: true, permanent: true,
}, },
{
source: '/:langcode(\\w{2})/:slug*',
destination: '/:slug*',
permanent: true,
},
]; ];
}, },
images: { images: {

View File

@ -1,6 +1,6 @@
{ {
"name": "libremdb", "name": "libremdb",
"version": "4.1.0", "version": "4.2.0",
"description": "a free & open source IMDb front-end", "description": "a free & open source IMDb front-end",
"private": true, "private": true,
"type": "module", "type": "module",
@ -21,14 +21,14 @@
"cheerio": "1.0.0-rc.12", "cheerio": "1.0.0-rc.12",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"next": "12.2.5", "next": "12.2.5",
"react": "18.2.0", "react": "19.1.0",
"react-dom": "18.2.0", "react-dom": "19.1.0",
"sharp": "^0.33.1" "sharp": "^0.33.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "18.7.3", "@types/node": "18.7.3",
"@types/react": "18.0.17", "@types/react": "19.1.8",
"@types/react-dom": "18.0.6", "@types/react-dom": "19.1.6",
"eslint": "8.22.0", "eslint": "8.22.0",
"eslint-config-next": "12.2.5", "eslint-config-next": "12.2.5",
"sass": "^1.62.1", "sass": "^1.62.1",

3200
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- sharp

7
public/opensearch.xml Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"extends": ["config:recommended"],
"prHourlyLimit": 20
}

View File

@ -11,6 +11,7 @@ type Props = {
message: string; message: string;
statusCode?: number; statusCode?: number;
originalPath?: string; originalPath?: string;
stack?: string;
/** props specific to error boundary. */ /** props specific to error boundary. */
misc?: { misc?: {
subtext: string; 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; const title = statusCode ? `${message} (${statusCode})` : message;
return ( return (
<> <>
@ -39,6 +42,11 @@ const ErrorInfo = ({ message, statusCode, misc, originalPath }: Props) => {
<use href='/svg/sadgnu.svg#sad-gnu'></use> <use href='/svg/sadgnu.svg#sad-gnu'></use>
</svg> </svg>
<h1 className={`heading heading__primary ${styles.heading}`}>{title}</h1> <h1 className={`heading heading__primary ${styles.heading}`}>{title}</h1>
{Boolean(stack && isDev) && (
<pre className={styles.stack}>
<code>{stack}</code>
</pre>
)}
{misc ? ( {misc ? (
<> <>
<p>{misc.subtext}</p> <p>{misc.subtext}</p>
@ -64,6 +72,13 @@ const ErrorInfo = ({ message, statusCode, misc, originalPath }: Props) => {
. .
</p> </p>
)} )}
<p>
If you think this shouldn't happen,{' '}
<Link href='/contact'>
<a className='link'>let it be known</a>
</Link>
.
</p>
</Layout> </Layout>
</> </>
); );

View File

@ -22,9 +22,6 @@ const Footer = () => {
</a> </a>
</Link> </Link>
</li> </li>
<li>
<span> | </span>
</li>
))} ))}
<li className={styles.nav__item}> <li className={styles.nav__item}>
<a href='#' className={styles.nav__link}> <a href='#' className={styles.nav__link}>

View File

@ -1,4 +1,5 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import Footer from './Footer';
import Header from './Header'; import Header from './Header';
type Props = { type Props = {
@ -15,6 +16,7 @@ const Layout = ({ full, children, className, originalPath }: Props) => {
<main id='main' className={`main ${className}`}> <main id='main' className={`main ${className}`}>
{children} {children}
</main> </main>
<Footer />
</> </>
); );
}; };

View File

@ -14,32 +14,41 @@ type Props = {
const Media = ({ className, media }: Props) => { const Media = ({ className, media }: Props) => {
return ( return (
<div className={`${className} ${styles.media}`}> <div className={`${className} ${styles.media}`}>
{(media.trailer || !!media.videos.total) && ( {(media.trailers?.length || !!media.videos.total) && (
<section className={styles.videos}> <section className={styles.videos}>
<h2 className='heading heading__secondary'>Videos</h2> <h2 className='heading heading__secondary'>Videos</h2>
<div className={styles.videos__container}> <div className={styles.videos__container}>
{media.trailer && ( {media.trailers?.map(trailer => (
<div className={styles.trailer}> <div className={styles.trailer} key={trailer.id}>
<video <video
aria-label='trailer video' aria-label={trailer.caption ?? 'trailer video'}
controls controls
playsInline playsInline
poster={getProxiedIMDbImgUrl(modifyIMDbImg(media.trailer.thumbnail))} poster={getProxiedIMDbImgUrl(modifyIMDbImg(trailer.thumbnail))}
className={styles.trailer__video} className={styles.trailer__video}
preload='none' preload='none'
muted
> >
{media.trailer.urls.map(source => ( {trailer.urls.map(source => (
<source <source
key={source.url} key={source.url}
type={source.mimeType ?? undefined} type='video/mp4'
src={getProxiedIMDbImgUrl(source.url)} src={getProxiedIMDbImgUrl(source.url)}
media={source.resolution !== 'SD' ? '(min-width: 450px)' : undefined}
data-res={source.resolution} data-res={source.resolution}
/> />
))} ))}
<p>
{trailer.caption}:{' '}
<Link href={getProxiedIMDbImgUrl(trailer.urls[0]?.url)}>
<a className='link'>link</a>
</Link>
</p>
</video> </video>
</div> </div>
)} ))}
{!!media.videos.total && {!!media.videos.total &&
media.videos.videos.map(video => ( media.videos.videos.map(video => (

View File

@ -1,5 +1,4 @@
import Head from 'next/head'; import Head from 'next/head';
import { ReactNode } from 'react';
type Props = { type Props = {
title: string; title: string;
@ -8,6 +7,7 @@ type Props = {
}; };
const BASE_URL = process.env.NEXT_PUBLIC_URL ?? 'https://iket.me'; const BASE_URL = process.env.NEXT_PUBLIC_URL ?? 'https://iket.me';
const BASE_TITLE = process.env.NEXT_PUBLIC_TITLE ?? 'libremdb';
const Meta = ({ const Meta = ({
title, title,
@ -22,7 +22,7 @@ const Meta = ({
<meta httpEquiv='X-UA-Compatible' content='IE=edge' /> <meta httpEquiv='X-UA-Compatible' content='IE=edge' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' /> <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} /> <meta key='desc' name='description' content={description} />
<link rel='icon' href='/favicon.ico' sizes='any' /> <link rel='icon' href='/favicon.ico' sizes='any' />
<link rel='icon' href='/icon.svg' type='image/svg+xml' /> <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:locale' content='en_US' />
<meta property='og:type' content='video.movie' /> <meta property='og:type' content='video.movie' />
<meta property='og:image' content={url.toString()} /> <meta property='og:image' content={url.toString()} />
<link
rel='search'
type='application/opensearchdescription+xml'
href='/opensearch.xml'
title='libremdb'
></link>
</Head> </Head>
); );
}; };

View File

@ -9,43 +9,54 @@ type Props = {
const DidYouKnow = ({ data }: Props) => ( const DidYouKnow = ({ data }: Props) => (
<section className={styles.container}> <section className={styles.container}>
<h2 className='heading heading__secondary'>Did you know</h2> <h2 className='heading heading__secondary'>Did you know</h2>
{!!data.trivia?.total && ( {isEmpty(data) ? (
<section> <p>Nothing interesting to show.</p>
<h3 className='heading heading__tertiary'>Trivia</h3> ) : (
<div dangerouslySetInnerHTML={{ __html: data.trivia.html }}></div> <>
</section> {!!data.trivia?.total && (
)} <section>
{!!data.quotes?.total && ( <h3 className='heading heading__tertiary'>Trivia</h3>
<section> <div dangerouslySetInnerHTML={{ __html: data.trivia.html }}></div>
<h3 className='heading heading__tertiary'>Quotes</h3> </section>
<div dangerouslySetInnerHTML={{ __html: data.quotes.html }}></div> )}
</section> {!!data.quotes?.total && (
)} <section>
{!!data.trademark?.total && ( <h3 className='heading heading__tertiary'>Quotes</h3>
<section> <div dangerouslySetInnerHTML={{ __html: data.quotes.html }}></div>
<h3 className='heading heading__tertiary'>Trademark</h3> </section>
<div dangerouslySetInnerHTML={{ __html: data.trademark.html }}></div> )}
</section> {!!data.trademark?.total && (
)} <section>
{!!data.nicknames.length && ( <h3 className='heading heading__tertiary'>Trademark</h3>
<section> <div dangerouslySetInnerHTML={{ __html: data.trademark.html }}></div>
<h3 className='heading heading__tertiary'>Nicknames</h3> </section>
<p>{data.nicknames.join(', ')}</p> )}
</section> {!!data.nicknames.length && (
)} <section>
{!!data.salary?.total && ( <h3 className='heading heading__tertiary'>Nicknames</h3>
<section> <p>{data.nicknames.join(', ')}</p>
<h3 className='heading heading__tertiary'>Salary</h3> </section>
<p> )}
<span>{data.salary.value} in </span> {!!data.salary?.total && (
<Link href={`/title/${data.salary.title.id}`}> <section>
<a className={'link'}>{data.salary.title.text}</a> <h3 className='heading heading__tertiary'>Salary</h3>
</Link> <p>
<span> ({data.salary.title.year})</span> <span>{data.salary.value} in </span>
</p> <Link href={`/title/${data.salary.title.id}`}>
</section> <a className={'link'}>{data.salary.title.text}</a>
</Link>
<span> ({data.salary.title.year})</span>
</p>
</section>
)}
</>
)} )}
</section> </section>
); );
export default DidYouKnow; export default DidYouKnow;
const isEmpty = (data: Props['data']) =>
Boolean(
!data.nicknames.length && !data.quotes && !data.salary && !data.trademark && !data.trivia
);

View File

@ -86,6 +86,21 @@ const Basic = ({ data, className }: Props) => {
))} ))}
</p> </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}> <p className={styles.overview}>
<span className={styles.overview__heading}>Plot: </span> <span className={styles.overview__heading}>Plot: </span>
<span className={styles.overview__text}>{data.plot || '-'}</span> <span className={styles.overview__text}>{data.plot || '-'}</span>

View File

@ -7,7 +7,13 @@ type Props = {
}; };
const DidYouKnow = ({ data }: 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 ( return (
<section className={styles.didYouKnow}> <section className={styles.didYouKnow}>
<h2 className='heading heading__secondary'>Did you know</h2> <h2 className='heading heading__secondary'>Did you know</h2>

View File

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

View File

@ -143,7 +143,7 @@ export default interface Name {
value: string; value: string;
language: string; language: string;
}; };
mimeType?: string; videoMimeType?: string;
url: string; url: string;
}>; }>;
recommendedTimedTextTrack?: { recommendedTimedTextTrack?: {

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,3 @@
import { AppError as AppErrorClass } from 'src/utils/helpers'; 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'>;

View File

@ -67,13 +67,13 @@ export const getServerSideProps: GetServerSideProps<Data, FindQueryParams> = asy
props: { data: { title: query, results: res }, error: null, originalPath }, props: { data: { title: query, results: res }, error: null, originalPath },
}; };
} catch (error) { } catch (error) {
const { message, statusCode } = getErrorProperties(error); const err = getErrorProperties(error);
ctx.res.statusCode = statusCode; ctx.res.statusCode = err.statusCode;
ctx.res.statusMessage = message; ctx.res.statusMessage = err.message;
return { return {
props: { props: {
error: { message, statusCode }, error: { message: err.message, statusCode: err.statusCode, stack: err.format() },
data: { title: query, results: null }, data: { title: query, results: null },
originalPath, originalPath,
}, },

View File

@ -55,11 +55,17 @@ export const getServerSideProps: GetServerSideProps<Data, Params> = async ctx =>
return { props: { data, error: null, originalPath } }; return { props: { data, error: null, originalPath } };
} catch (error) { } catch (error) {
const { message, statusCode } = getErrorProperties(error); const err = getErrorProperties(error);
ctx.res.statusCode = statusCode; ctx.res.statusCode = err.statusCode;
ctx.res.statusMessage = message; 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,
},
};
} }
}; };

View File

@ -5,7 +5,7 @@ import ErrorInfo from 'src/components/error/ErrorInfo';
import Media from 'src/components/media/Media'; import Media from 'src/components/media/Media';
import { Basic, Cast, DidYouKnow, Info, MoreLikeThis, Reviews } from 'src/components/title'; import { Basic, Cast, DidYouKnow, Info, MoreLikeThis, Reviews } from 'src/components/title';
import Title from 'src/interfaces/shared/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 getOrSetApiCache from 'src/utils/getOrSetApiCache';
import title from 'src/utils/fetchers/title'; import title from 'src/utils/fetchers/title';
import { getErrorProperties, getProxiedIMDbImgUrl } from 'src/utils/helpers'; 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); const data = await getOrSetApiCache(titleKey(titleId), title, titleId);
return { props: { data, error: null, originalPath } }; return { props: { data, error: null, originalPath } };
} catch (error) { } catch (e) {
const { message, statusCode } = getErrorProperties(error); const err = getErrorProperties(e);
ctx.res.statusCode = statusCode; ctx.res.statusCode = err.statusCode;
ctx.res.statusMessage = message; 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 } };
} }
}; };

View File

@ -2,9 +2,9 @@ $_dark: (
text-accent: hsl(0, 0%, 100%), text-accent: hsl(0, 0%, 100%),
text: hsl(0, 0%, 96%), text: hsl(0, 0%, 96%),
text-muted: hsl(0, 0%, 80%), text-muted: hsl(0, 0%, 80%),
bg-accent: hsl(221, 39%, 15%), bg-accent: #232323,
bg: #000, bg: #000,
bg-muted: rgb(20, 28, 46), bg-muted: #141414,
link: hsl(339, 95%, 80%), link: hsl(339, 95%, 80%),
link-muted: hsl(344, 79%, 80%), link-muted: hsl(344, 79%, 80%),
fill: hsl(339, 75%, 64%), fill: hsl(339, 75%, 64%),
@ -20,4 +20,5 @@ $_dark: (
$themes: ( $themes: (
light: $_dark, // yes light: $_dark, // yes
dark: $_dark,
); );

View File

@ -8,7 +8,7 @@
display: grid; display: grid;
justify-content: center; justify-content: center;
justify-items: center; justify-items: center;
gap: var(--spacer-1); gap: var(--comp-whitespace);
@include helper.bp('bp-700') { @include helper.bp('bp-700') {
--doc-whitespace: var(--spacer-5); --doc-whitespace: var(--spacer-5);
@ -31,6 +31,19 @@
text-align: center; 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 { .button {
align-self: end; align-self: end;

View File

@ -1,26 +1,94 @@
.reviews { .reviews {
display: grid; display: grid;
gap: var(--comp-whitespace); gap: var(--comp-whitespace);
}
&__reviewContainer { .ratingsDistribution {
// background-color: antiquewhite; 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; 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; 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 { .review {
&__summary { &__summary {
font-size: calc(var(--fs-5) * 1.1);
cursor: pointer; cursor: pointer;
} }
&__text, &__text,
&__metadata { &__metadata {
padding-top: var(--spacer-2); padding-top: var(--spacer-1);
} }
} }

View File

@ -2,7 +2,7 @@
.footer { .footer {
background: var(--clr-bg-muted); background: var(--clr-bg-muted);
padding: var(--spacer-4); padding: var(--spacer-3);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -17,10 +17,6 @@
gap: var(--spacer-2) var(--spacer-4); gap: var(--spacer-2) var(--spacer-4);
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
span {
font-weight: 900;
}
} }
&__item { &__item {

View File

@ -6,7 +6,8 @@
font-size: 1.1em; font-size: 1.1em;
display: grid; display: grid;
background: (var(--clr-bg-muted)); background: none;
border-bottom: solid 1px (var(--clr-text-accent));
&__about { &__about {
min-height: 100vh; min-height: 100vh;
@ -24,7 +25,7 @@
align-items: center; align-items: center;
gap: var(--spacer-4); gap: var(--spacer-4);
padding: var(--spacer-4); padding: var(--spacer-3);
@include helper.bp('bp-700') { @include helper.bp('bp-700') {
padding: var(--spacer-3); padding: var(--spacer-3);

View File

@ -42,18 +42,18 @@ const cleanName = (rawData: RawName) => {
}, },
media: { media: {
...(main.primaryVideos.edges.length && { ...(main.primaryVideos.edges.length && {
trailer: { trailers: main.primaryVideos.edges.map(trailer => ({
id: main.primaryVideos.edges[0].node.id, id: trailer.node.id,
isMature: main.primaryVideos.edges[0].node.isMature, isMature: trailer.node.isMature,
thumbnail: main.primaryVideos.edges[0].node.thumbnail.url, thumbnail: trailer.node.thumbnail.url,
runtime: main.primaryVideos.edges[0].node.runtime.value, runtime: trailer.node.runtime.value,
caption: main.primaryVideos.edges[0].node.description?.value ?? null, caption: trailer.node.description?.value ?? null,
urls: main.primaryVideos.edges[0].node.playbackURLs.map(url => ({ urls: trailer.node.playbackURLs.map(url => ({
resolution: url.displayName.value, resolution: url.displayName.value as 'SD' | '480p',
mimeType: url.mimeType ?? null, mimeType: url.videoMimeType ?? null,
url: url.url, url: url.url,
})), })),
}, })),
}), }),
images: { images: {
total: misc.images.total, total: misc.images.total,

View File

@ -12,6 +12,7 @@ const cleanTitle = (rawData: RawTitle) => {
titleId: main.id, titleId: main.id,
basic: { basic: {
id: main.id, id: main.id,
isAdult: main.isAdult,
title: main.titleText.text, title: main.titleText.text,
// ...(main.originalTitleText.text.toLowerCase() !== // ...(main.originalTitleText.text.toLowerCase() !==
// main.titleText.text.toLowerCase() && { // main.titleText.text.toLowerCase() && {
@ -50,6 +51,10 @@ const cleanTitle = (rawData: RawTitle) => {
id: genre.id, id: genre.id,
text: genre.text, text: genre.text,
})), })),
interests: main.interests.edges.map(interest => ({
id: interest.node.id,
text: interest.node.primaryText.text,
})),
plot: main.plot?.plotText?.plainText || null, plot: main.plot?.plotText?.plainText || null,
primaryCrew: main.principalCredits.map(type => ({ primaryCrew: main.principalCredits.map(type => ({
type: { category: type.category.text, id: type.category.id }, type: { category: type.category.text, id: type.category.id },
@ -76,18 +81,18 @@ const cleanTitle = (rawData: RawTitle) => {
})), })),
media: { media: {
...(main.primaryVideos.edges.length && { ...(main.primaryVideos.edges.length && {
trailer: { trailers: main.primaryVideos.edges.map(trailer => ({
id: main.primaryVideos.edges[0].node.id, id: trailer.node.id,
isMature: main.primaryVideos.edges[0].node.isMature, isMature: trailer.node.isMature,
thumbnail: main.primaryVideos.edges[0].node.thumbnail.url, thumbnail: trailer.node.thumbnail.url,
runtime: main.primaryVideos.edges[0].node.runtime.value, runtime: trailer.node.runtime.value,
caption: main.primaryVideos.edges[0].node.description?.value ?? null, caption: trailer.node.description?.value ?? null,
urls: main.primaryVideos.edges[0].node.playbackURLs.map(url => ({ urls: trailer.node.playbackURLs.map(url => ({
resolution: url.displayName.value, resolution: url.displayName.value as 'SD' | '480p',
mimeType: url.mimeType ?? null, mimeType: url.videoMimeType ?? null,
url: url.url, url: url.url,
})), })),
}, })),
}), }),
images: { images: {
total: misc.titleMainImages.total, total: misc.titleMainImages.total,
@ -122,6 +127,9 @@ const cleanTitle = (rawData: RawTitle) => {
}), }),
topRating: misc.ratingsSummary.topRanking?.rank || null, topRating: misc.ratingsSummary.topRanking?.rank || null,
}, },
watchlist: {
text: main.engagementStatistics?.watchlistStatistics.displayableCount.text || null,
},
meta: { meta: {
// for tv episode // for tv episode
...(main.series && { ...(main.series && {
@ -208,25 +216,35 @@ const cleanTitle = (rawData: RawTitle) => {
metacriticScore: main.metacritic?.metascore.score || null, metacriticScore: main.metacritic?.metascore.score || null,
numCriticReviews: main.criticReviewsTotal.total, numCriticReviews: main.criticReviewsTotal.total,
numUserReviews: misc.reviews.total, numUserReviews: misc.reviews.total,
...(misc.featuredReviews.edges.length && { ratingsDistribution:
featuredReview: { misc.aggregateRatingsBreakdown.histogram?.histogramValues.map(v => ({
id: misc.featuredReviews.edges[0].node.id, rating: v.rating,
reviewer: { votes: v.voteCount,
id: misc.featuredReviews.edges[0].node.author.userId, })) || [],
name: misc.featuredReviews.edges[0].node.author.nickName, ...(misc.reviewSummary && {
}, ai: {
rating: misc.featuredReviews.edges[0].node.authorRating, summary: misc.reviewSummary.overall.medium.value.plaidHtml,
date: formatDate(misc.featuredReviews.edges[0].node.submissionDate), themes: misc.reviewSummary.themes.map(t => ({
votes: { text: t.label.value,
up: misc.featuredReviews.edges[0].node.helpfulness.upVotes, id: t.themeId,
down: misc.featuredReviews.edges[0].node.helpfulness.downVotes, sentiment: t.sentiment as 'POSITIVE' | 'NEGATIVE',
}, })),
review: {
summary: misc.featuredReviews.edges[0].node.summary.originalText,
html: misc.featuredReviews.edges[0].node.text.originalText.plaidHtml,
},
}, },
}), }),
...(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: { details: {
...(misc.releaseDate && { ...(misc.releaseDate && {
@ -242,8 +260,8 @@ const cleanTitle = (rawData: RawTitle) => {
}, },
}, },
}), }),
...(misc.countriesOfOrigin && { ...(misc.countriesDetails && {
countriesOfOrigin: misc.countriesOfOrigin.countries.map(country => ({ countriesOfOrigin: misc.countriesDetails.countries.map(country => ({
id: country.id, id: country.id,
text: country.text, text: country.text,
})), })),
@ -353,6 +371,10 @@ const cleanTitle = (rawData: RawTitle) => {
}, },
genres: title.node.titleGenres?.genres.map(genre => genre.genre.text) ?? null, 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; return cleanData;

View File

@ -74,12 +74,33 @@ export const getProxiedIMDbImgUrl = (url: string) => {
}; };
export const AppError = class extends Error { export const AppError = class extends Error {
constructor(message: string, public statusCode: number, errorOptions?: unknown) { constructor(message: string, public statusCode: number, cause?: unknown) {
const saneErrorOptions = getErrorOptions(errorOptions); const _cause = cause ? AppError.toError(cause) : undefined;
super(message, saneErrorOptions); super(message, { cause: _cause });
Error.captureStackTrace(this, AppError); 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 = ( export const getErrorProperties = (
error: unknown, error: unknown,
message = 'Something went very wrong', message = 'Something went very wrong',
@ -130,4 +138,4 @@ export const getErrorProperties = (
) => { ) => {
if (error instanceof AppError) return error; if (error instanceof AppError) return error;
return new AppError(message, statusCode, error); return new AppError(message, statusCode, error);
}; };

5
ups.json Normal file
View File

@ -0,0 +1,5 @@
{
"upstream": "https://github.com/zyachel/libremdb",
"provider": "github",
"commit": "dded1e59ca6df40ccb1b3f2c70de14144afbe922"
}