Compare commits
45 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
410cc70259 | ||
![]() |
258a82f2ac | ||
![]() |
e2a335f98d | ||
![]() |
246f1155d5 | ||
![]() |
19f1700a55 | ||
![]() |
9fdd731136 | ||
![]() |
4dffbbc0ec | ||
![]() |
264442448f | ||
![]() |
2b00d5406a | ||
![]() |
97f1432ac5 | ||
![]() |
60fb23fc5b | ||
![]() |
12eaa741ab | ||
![]() |
40eb8a372b | ||
![]() |
e91c313f12 | ||
![]() |
5fa5e9e2c2 | ||
![]() |
27322a4c8c | ||
![]() |
21a1c83d95 | ||
![]() |
b07cb713d8 | ||
![]() |
5628d6b75d | ||
![]() |
38ed0c6217 | ||
![]() |
c610ef4d1b | ||
![]() |
736d680243 | ||
![]() |
0aea2f47da | ||
![]() |
23eeae3558 | ||
![]() |
bb6405cb05 | ||
![]() |
c53c88db9b | ||
![]() |
8599ae2c5a | ||
![]() |
8d9b6630a5 | ||
![]() |
be80244eb3 | ||
![]() |
a0f3ba095a | ||
![]() |
11aea1d489 | ||
![]() |
3ef41d9a6d | ||
![]() |
7dea9eac14 | ||
![]() |
86737c51ee | ||
![]() |
75732e0086 | ||
![]() |
18ca98fd4a | ||
![]() |
8ce02d0236 | ||
![]() |
cbce2cac34 | ||
![]() |
1eeaab259d | ||
![]() |
505ff4d839 | ||
![]() |
20418b4c1f | ||
![]() |
68072b5f68 | ||
![]() |
c79dc2a481 | ||
![]() |
4dde7bde77 | ||
![]() |
2c5d2f86e4 |
130 changed files with 5208 additions and 1846 deletions
|
@ -24,7 +24,20 @@ 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)
|
||||
################################################################################
|
||||
## example: 'https://iket.me'.
|
||||
NEXT_PUBLIC_INSTANCE_MAIN_URL=
|
||||
## eg: 'zyachel'
|
||||
NEXT_PUBLIC_INSTANCE_NAME=
|
29
.github/workflows/release.yml
vendored
29
.github/workflows/release.yml
vendored
|
@ -1,29 +0,0 @@
|
|||
name: release
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- next
|
||||
|
||||
jobs:
|
||||
changelog-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: generate changelog and bump version
|
||||
id: changelog
|
||||
uses: TriPSs/conventional-changelog-action@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: create release
|
||||
uses: actions/create-release@v1
|
||||
if: ${{ steps.changelog.outputs.skipped == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ steps.changelog.outputs.tag }}
|
||||
release_name: ${{ steps.changelog.outputs.tag }}
|
||||
body: ${{ steps.changelog.outputs.clean_changelog }}
|
|
@ -4,5 +4,6 @@
|
|||
"arrowParens": "avoid",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
|
|
36
.versionrc
Normal file
36
.versionrc
Normal file
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"types": [
|
||||
{
|
||||
"type": "feat",
|
||||
"section": "Features"
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"section": "Bug Fixes"
|
||||
},
|
||||
{
|
||||
"type": "chore",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"type": "docs",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"type": "style",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"type": "refactor",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"type": "perf",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"type": "test",
|
||||
"hidden": true
|
||||
}
|
||||
]
|
||||
}
|
56
CHANGELOG.md
56
CHANGELOG.md
|
@ -1,3 +1,56 @@
|
|||
# Changelog
|
||||
|
||||
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)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **title:** older versions won't work, at least for title route
|
||||
|
||||
### Features
|
||||
|
||||
* add info related to the current instance ([2c5d2f8](https://github.com/zyachel/libremdb/commit/2c5d2f86e46a52223f07d573b152bad5174ee2d9))
|
||||
* **route:** add name route ([75732e0](https://github.com/zyachel/libremdb/commit/75732e00869f9777e87e767a48648996345f02f7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **title:** fix title page crash ([8ce02d0](https://github.com/zyachel/libremdb/commit/8ce02d02364c8e1f03a8b16594bc20ee6766a8c6))
|
||||
|
||||
# [2.4.0](https://github.com/zyachel/libremdb/compare/v2.3.1...v2.4.0) (2023-01-22)
|
||||
|
||||
|
||||
|
@ -50,6 +103,3 @@
|
|||
### Bug Fixes
|
||||
|
||||
* **title:** fix site crash ([dd75df0](https://github.com/zyachel/libremdb/commit/dd75df01eb7c03d8945a8bd20ed231a66bd88b8f))
|
||||
|
||||
|
||||
|
||||
|
|
13
Dockerfile
13
Dockerfile
|
@ -4,8 +4,9 @@
|
|||
FROM node:lts-alpine AS deps
|
||||
|
||||
WORKDIR /opt/app
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
# This is where because may be the case that you would try
|
||||
|
@ -15,13 +16,13 @@ FROM node:lts-alpine AS builder
|
|||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /opt/app
|
||||
RUN npm install -g pnpm
|
||||
COPY . .
|
||||
COPY --from=deps /opt/app/node_modules ./node_modules
|
||||
RUN yarn build
|
||||
RUN pnpm build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM node:lts-alpine AS runner
|
||||
|
||||
FROM gcr.io/distroless/nodejs18-debian11 AS runner
|
||||
ARG X_TAG
|
||||
WORKDIR /opt/app
|
||||
ENV NODE_ENV=production
|
||||
|
@ -31,4 +32,4 @@ COPY --from=builder /opt/app/.next ./.next
|
|||
COPY --from=builder /opt/app/node_modules ./node_modules
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
CMD ["node_modules/.bin/next", "start"]
|
||||
CMD ["./node_modules/next/dist/bin/next", "start"]
|
25
README.md
25
README.md
|
@ -6,7 +6,7 @@ Inspired by projects like [teddit](https://codeberg.org/teddit/teddit), [nitter]
|
|||
|
||||
| | |
|
||||
| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| <img src="./public/img/misc/preview.png" title="screenshot (desktop screen, light mode)" width="1500" /> | <img src="./public/img/misc/preview2.png" title="screenshot (mobile screen, dark mode)" width="385" /> |
|
||||
| <img src="./public/img/misc/preview.jpg" title="screenshot (desktop screen, light mode)" width="1500" /> | <img src="./public/img/misc/preview2.jpg" title="screenshot (mobile screen, dark mode)" width="400" /> |
|
||||
|
||||
---
|
||||
|
||||
|
@ -38,17 +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/) | - | Operated by [lunar.icu](https://lunar.icu/) |
|
||||
| [libremdb.lunar.icu](https://libremdb.lunar.icu) | Germany (Cloudflare) | Operated by [lunar.icu](https://lunar.icu/) |
|
||||
| [libremdb.jeikobu.net](https://libremdb.jeikobu.net) | Germany (Cloudflare) | Operated by [shindouj](https://github.com/shindouj) |
|
||||
| [lmdb.hostux.net](https://lmdb.hostux.net) | France | Operated by [Hostux.net](https://hostux.net) |
|
||||
| [binge.whateveritworks.org](https://binge.whateveritworks.org) | Germany (Cloudflare) | Operated by [WhateverItWorks](https://github.com/WhateverItWorks) |
|
||||
| [libremdb.nerdyfam.tech](https://libremdb.nerdyfam.tech) | US | Operated by [Nerdyfam.tech](https://nerdyfam.tech/) |
|
||||
| [libremdb.tux.pizza](https://libremdb.tux.pizza) | US | Operated by [tux.pizza](https://tux.pizza) |
|
||||
| [libremdb.frontendfriendly.xyz](https://libremdb.frontendfriendly.xyz) | — | Operated by [frontendfriendly.xyz](https://frontendfriendly.xyz) |
|
||||
[d.opnxng.com](https://d.opnxng.com) | Singapore | Operated by [Opnxng](https://about.opnxng.com/)
|
||||
[libremdb.catsarch.com](https://libremdb.catsarch.com) | US | Operated by [Butter Cat](https://catsarch.com/)
|
||||
[mdb.sudovanilla.com](https://mdb.sudovanilla.com) | US (Cloudflare) | Operated by [SudoVanilla](https://sudovanilla.com/)
|
||||
| 2. Onion | | |
|
||||
| [libremdb.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion](http://libremdb.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion) | Canada | Operated by [Esmail EL BoB](https://esmailelbob.xyz) |
|
||||
| [ld.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://ld.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) | US | Operated by [~vern](https://vern.cc) |
|
||||
| [ld.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://ld.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) | US | Operated by [~vern](https://vern.cc) |
|
||||
| 3. I2P | | |
|
||||
| [vernz3ubrntql4wrgyrssd6u3qzi36zrhz2agbo6vibzbs5olk2q.b32.i2p](http://vernz3ubrntql4wrgyrssd6u3qzi36zrhz2agbo6vibzbs5olk2q.b32.i2p) | US | Operated by [~vern](https://vern.cc) |
|
||||
| [vernz3ubrntql4wrgyrssd6u3qzi36zrhz2agbo6vibzbs5olk2q.b32.i2p](http://vernz3ubrntql4wrgyrssd6u3qzi36zrhz2agbo6vibzbs5olk2q.b32.i2p) | US | Operated by [~vern](https://vern.cc) |
|
||||
|
||||
---
|
||||
|
||||
|
@ -105,11 +110,11 @@ Inspired by projects like [teddit](https://codeberg.org/teddit/teddit), [nitter]
|
|||
|
||||
- [ ] lists
|
||||
- [ ] moviemeter
|
||||
- [ ] person info(includes directors and actors)
|
||||
- [x] person info(includes directors and actors)
|
||||
- [ ] 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
|
||||
|
||||
|
|
|
@ -3,21 +3,45 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
libremdb:
|
||||
container_name: libremdb
|
||||
build:
|
||||
context: .
|
||||
network: host
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file: .env.local
|
||||
env_file: .env.local.example
|
||||
depends_on:
|
||||
- redis
|
||||
- libremdb-redis
|
||||
restart: always
|
||||
redis:
|
||||
user: 65534:65534 # equivalent to the nobody user
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /opt/app/.next/cache/:size=10M,mode=0770,uid=65534,gid=65534,noexec,nosuid,nodev
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
networks:
|
||||
- libremdb
|
||||
|
||||
libremdb-redis:
|
||||
container_name: libremdb_redis
|
||||
image: redis
|
||||
# FOR DEBUGGING ONLY
|
||||
# ports:
|
||||
# - "6379:6379"
|
||||
restart: always
|
||||
restart: always
|
||||
user: nobody
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /data:size=10M,mode=0770,uid=65534,gid=65534,noexec,nosuid,nodev
|
||||
cap_drop:
|
||||
- ALL
|
||||
networks:
|
||||
- libremdb
|
||||
|
||||
networks:
|
||||
libremdb:
|
|
@ -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'],
|
||||
|
|
16
package.json
16
package.json
|
@ -1,11 +1,15 @@
|
|||
{
|
||||
"name": "libremdb",
|
||||
"version": "2.4.0",
|
||||
"version": "3.2.0",
|
||||
"description": "a free & open source IMDb front-end",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"author": "libremdb-contributors",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/zyachel/libremdb/"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
|
@ -15,11 +19,11 @@
|
|||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"ioredis": "^5.2.3",
|
||||
"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",
|
||||
|
@ -27,11 +31,11 @@
|
|||
"@types/react-dom": "18.0.6",
|
||||
"eslint": "8.22.0",
|
||||
"eslint-config-next": "12.2.5",
|
||||
"sass": "^1.54.4",
|
||||
"sass": "^1.62.1",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.5.0",
|
||||
"pnpm": ">=7.0.0"
|
||||
"pnpm": ">=8.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
1396
pnpm-lock.yaml
generated
1396
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
BIN
public/img/misc/preview.jpg
Normal file
BIN
public/img/misc/preview.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
Before Width: | Height: | Size: 805 KiB |
BIN
public/img/misc/preview2.jpg
Normal file
BIN
public/img/misc/preview2.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
Before Width: | Height: | Size: 1.6 MiB |
|
@ -1,7 +1,6 @@
|
|||
import { useContext } from 'react';
|
||||
import { themeContext } from '../../context/theme-context';
|
||||
|
||||
import styles from '../../styles/modules/components/buttons/themeToggler.module.scss';
|
||||
import { themeContext } from 'src/context/theme-context';
|
||||
import styles from 'src/styles/modules/components/buttons/themeToggler.module.scss';
|
||||
|
||||
type Props = {
|
||||
className: string;
|
||||
|
|
29
src/components/card/Card.tsx
Normal file
29
src/components/card/Card.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import type { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react';
|
||||
import styles from 'src/styles/modules/components/card/card.module.scss';
|
||||
|
||||
// ensuring that other attributes to <Card/> are correct based on the value of 'as' prop.
|
||||
// a cheap implementation of as prop found in libraries like CharkaUI or MaterialUI.
|
||||
type Props<T extends ElementType> = {
|
||||
children: ReactNode;
|
||||
as?: T | 'section';
|
||||
hoverable?: true;
|
||||
} & ComponentPropsWithoutRef<T>;
|
||||
|
||||
const Card = <T extends ElementType = 'li'>({
|
||||
children,
|
||||
as,
|
||||
hoverable,
|
||||
className,
|
||||
...rest
|
||||
}: Props<T>) => {
|
||||
const Component = as ?? 'li';
|
||||
const classNames = `${hoverable ? styles.hoverable : ''} ${styles.card} ${className}`;
|
||||
|
||||
return (
|
||||
<Component className={classNames} {...rest}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
45
src/components/card/CardBasic.tsx
Normal file
45
src/components/card/CardBasic.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
|
||||
import Image from 'next/future/image';
|
||||
import Card from './Card';
|
||||
import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
|
||||
import styles from 'src/styles/modules/components/card/card-basic.module.scss';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
image?: string;
|
||||
title: string;
|
||||
} & ComponentPropsWithoutRef<'section'>;
|
||||
|
||||
const CardBasic = ({ image, children, className, title, ...rest }: Props) => {
|
||||
const style: CSSProperties = {
|
||||
backgroundImage: image && `url(${getProxiedIMDbImgUrl(modifyIMDbImg(image, 300))})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card as='section' className={`${styles.container} ${className}`} {...rest}>
|
||||
<div className={styles.imageContainer} style={style}>
|
||||
{image ? (
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={modifyIMDbImg(image)}
|
||||
alt=''
|
||||
priority
|
||||
fill
|
||||
sizes='300px'
|
||||
/>
|
||||
) : (
|
||||
<svg className={styles.imageNA}>
|
||||
<use href='/svg/sprite.svg#icon-image-slash' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<h1 className={`${styles.title} heading heading__primary`}>{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardBasic;
|
51
src/components/card/CardCast.tsx
Normal file
51
src/components/card/CardCast.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import Card from './Card';
|
||||
import styles from 'src/styles/modules/components/card/card-cast.module.scss';
|
||||
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/future/image';
|
||||
import { modifyIMDbImg } from 'src/utils/helpers';
|
||||
|
||||
type Props = {
|
||||
link: string;
|
||||
name: string;
|
||||
characters: string[] | null;
|
||||
attributes: string[] | null;
|
||||
image?: string | null;
|
||||
children?: ReactNode;
|
||||
} & ComponentPropsWithoutRef<'li'>;
|
||||
|
||||
const CardCast = ({ link, name, image, children, characters, attributes, ...rest }: Props) => {
|
||||
return (
|
||||
<Card hoverable {...rest}>
|
||||
<Link href={link}>
|
||||
<a className={styles.item}>
|
||||
<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.textContainer}>
|
||||
<p className={`heading ${styles.name}`}>{name}</p>
|
||||
<p className={styles.role}>
|
||||
{characters?.join(', ')}
|
||||
{attributes && <span> ({attributes.join(', ')})</span>}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardCast;
|
40
src/components/card/CardResult.tsx
Normal file
40
src/components/card/CardResult.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/future/image';
|
||||
import Card from './Card';
|
||||
import { modifyIMDbImg } from 'src/utils/helpers';
|
||||
import styles from 'src/styles/modules/components/card/card-result.module.scss';
|
||||
|
||||
type Props = {
|
||||
link: string;
|
||||
name: string;
|
||||
image?: string;
|
||||
showImage?: true;
|
||||
children?: ReactNode;
|
||||
} & ComponentPropsWithoutRef<'li'>;
|
||||
|
||||
const CardResult = ({ link, name, image, showImage, children, ...rest }: Props) => {
|
||||
let ImageComponent = null;
|
||||
if (showImage)
|
||||
ImageComponent = 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>
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardResult;
|
63
src/components/card/CardTitle.tsx
Normal file
63
src/components/card/CardTitle.tsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
import Card from './Card';
|
||||
import styles from 'src/styles/modules/components/card/card-title.module.scss';
|
||||
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/future/image';
|
||||
import { formatNumber, modifyIMDbImg } from 'src/utils/helpers';
|
||||
|
||||
type Props = {
|
||||
link: string;
|
||||
name: string;
|
||||
titleType: string;
|
||||
year?: { start: number; end: number | null };
|
||||
ratings?: { avg: number | null; numVotes: number };
|
||||
image?: string;
|
||||
children?: ReactNode;
|
||||
} & ComponentPropsWithoutRef<'li'>;
|
||||
|
||||
const CardTitle = ({ link, name, year, image, ratings, titleType, children, ...rest }: Props) => {
|
||||
const years = year?.end ? `${year.start}-${year.end}` : year?.start;
|
||||
|
||||
return (
|
||||
<Card hoverable {...rest}>
|
||||
<Link href={link}>
|
||||
<a className={styles.item}>
|
||||
<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.textContainer}>
|
||||
<p className={`heading ${styles.name}`}>{name}</p>
|
||||
<p>
|
||||
<span>{titleType}</span>
|
||||
<span>{years && ` (${years})`}</span>
|
||||
</p>
|
||||
{ratings?.avg && (
|
||||
<p className={styles.rating}>
|
||||
<span className={styles.ratingNum}>{ratings.avg}</span>
|
||||
<svg className={styles.ratingIcon}>
|
||||
<use href='/svg/sprite.svg#icon-rating'></use>
|
||||
</svg>
|
||||
<span> ({formatNumber(ratings.numVotes)} votes)</span>
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.children}>{children}</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardTitle;
|
5
src/components/card/index.tsx
Normal file
5
src/components/card/index.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
export { default as Card } from './Card';
|
||||
export { default as CardTitle } from './CardTitle';
|
||||
export { default as CardBasic } from './CardBasic';
|
||||
export { default as CardCast } from './CardCast';
|
||||
export { default as CardResult } from './CardResult';
|
|
@ -1,9 +1,7 @@
|
|||
import { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Layout from '../../layouts/Layout';
|
||||
import Meta from '../meta/Meta';
|
||||
|
||||
import styles from '../../styles/modules/components/error/error-info.module.scss';
|
||||
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';
|
||||
|
||||
// for details regarding the svg, go to sadgnu.svg file
|
||||
// description copied verbatim from https://www.gnu.org/graphics/sventsitsky-sadgnu.html
|
||||
|
@ -12,7 +10,8 @@ import styles from '../../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;
|
||||
|
@ -20,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'
|
||||
|
@ -34,15 +33,12 @@ const ErrorInfo = ({ message, statusCode, misc }: Props) => {
|
|||
>
|
||||
<title id='gnu-title'>GNU and Tux</title>
|
||||
<desc id='gnu-desc'>
|
||||
A pencil drawing of a big gnu and a small penguin, both very sad.
|
||||
GNU is despondently sitting on a bench, and Tux stands beside him,
|
||||
looking down and patting him on the back.
|
||||
A pencil drawing of a big gnu and a small penguin, both very sad. GNU is despondently
|
||||
sitting on a bench, and Tux stands beside him, looking down and patting him on the back.
|
||||
</desc>
|
||||
<use href='/svg/sadgnu.svg#sad-gnu'></use>
|
||||
</svg>
|
||||
<h1 className={`heading heading__primary ${styles.heading}`}>
|
||||
{title}
|
||||
</h1>
|
||||
<h1 className={`heading heading__primary ${styles.heading}`}>{title}</h1>
|
||||
{misc ? (
|
||||
<>
|
||||
<p>{misc.subtext}</p>
|
||||
|
@ -53,9 +49,18 @@ const ErrorInfo = ({ message, statusCode, misc }: Props) => {
|
|||
) : (
|
||||
<p>
|
||||
Go back to{' '}
|
||||
<Link href='/about'>
|
||||
<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>
|
||||
)}
|
||||
|
|
|
@ -1,22 +1,13 @@
|
|||
import { Companies } from '../../interfaces/shared/search';
|
||||
import Link from 'next/link';
|
||||
import { CardResult } from 'src/components/card';
|
||||
import { Companies } from 'src/interfaces/shared/search';
|
||||
|
||||
import styles from '../../styles/modules/components/find/company.module.scss';
|
||||
type Props = { company: Companies[number] };
|
||||
|
||||
type Props = {
|
||||
company: Companies[0];
|
||||
};
|
||||
|
||||
const Company = ({ company }: Props) => {
|
||||
return (
|
||||
<li className={styles.company}>
|
||||
<Link href={`name/${company.id}`}>
|
||||
<a className={`heading ${styles.heading}`}>{company.name}</a>
|
||||
</Link>
|
||||
{company.country && <p>{company.country}</p>}
|
||||
{!!company.type && <p>{company.type}</p>}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
const Company = ({ company }: Props) => (
|
||||
<CardResult name={company.name} link={`/search/title?companies=${company.id}`}>
|
||||
{company.country && <p>{company.country}</p>}
|
||||
{!!company.type && <p>{company.type}</p>}
|
||||
</CardResult>
|
||||
);
|
||||
|
||||
export default Company;
|
||||
|
|
|
@ -1,21 +1,12 @@
|
|||
import { Keywords } from '../../interfaces/shared/search';
|
||||
import Link from 'next/link';
|
||||
import { CardResult } from 'src/components/card';
|
||||
import { Keywords } from 'src/interfaces/shared/search';
|
||||
|
||||
import styles from '../../styles/modules/components/find/keyword.module.scss';
|
||||
type Props = { keyword: Keywords[number] };
|
||||
|
||||
type Props = {
|
||||
keyword: Keywords[0];
|
||||
};
|
||||
|
||||
const Keyword = ({ keyword }: Props) => {
|
||||
return (
|
||||
<li className={styles.keyword}>
|
||||
<Link href={`name/${keyword.id}`}>
|
||||
<a className={`heading ${styles.heading}`}>{keyword.text}</a>
|
||||
</Link>
|
||||
{keyword.numTitles && <p>{keyword.numTitles} titles</p>}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
const Keyword = ({ keyword }: Props) => (
|
||||
<CardResult link={`/search/keyword?keywords=${keyword.text}`} name={keyword.text}>
|
||||
{keyword.numTitles && <p>{keyword.numTitles} titles</p>}
|
||||
</CardResult>
|
||||
);
|
||||
|
||||
export default Keyword;
|
||||
|
|
|
@ -1,44 +1,19 @@
|
|||
import { People } from '../../interfaces/shared/search';
|
||||
import Image from 'next/future/image';
|
||||
import Link from 'next/link';
|
||||
import { modifyIMDbImg } from '../../utils/helpers';
|
||||
import styles from '../../styles/modules/components/find/person.module.scss';
|
||||
import { CardResult } from 'src/components/card';
|
||||
import { People } from 'src/interfaces/shared/search';
|
||||
import styles from 'src/styles/modules/components/find/person.module.scss';
|
||||
|
||||
type Props = {
|
||||
person: People[0];
|
||||
};
|
||||
type Props = { person: People[number] };
|
||||
|
||||
const Person = ({ person }: Props) => {
|
||||
return (
|
||||
<li className={styles.person}>
|
||||
<div className={styles.imgContainer} style={{ position: 'relative' }}>
|
||||
{person.image ? (
|
||||
<Image
|
||||
src={modifyIMDbImg(person.image.url, 400)}
|
||||
alt={person.image.caption}
|
||||
fill
|
||||
className={styles.img}
|
||||
/>
|
||||
) : (
|
||||
<svg className={styles.imgNA}>
|
||||
<use href="/svg/sprite.svg#icon-image-slash" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<Link href={`name/${person.id}`}>
|
||||
<a className={`heading ${styles.heading}`}>{person.name}</a>
|
||||
</Link>
|
||||
{person.aka && <p>{person.aka}</p>}
|
||||
{person.jobCateogry && <p>{person.jobCateogry}</p>}
|
||||
{(person.knownForTitle || person.knownInYear) && (
|
||||
<ul className={styles.basicInfo} aria-label="quick facts">
|
||||
{person.knownForTitle && <li>{person.knownForTitle}</li>}
|
||||
{person.knownInYear && <li>{person.knownInYear}</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
<CardResult showImage name={person.name} link={`/name/${person.id}`} image={person.image?.url}>
|
||||
<p>{person.aka}</p>
|
||||
<p>{person.jobCateogry}</p>
|
||||
<ul className={styles.basicInfo} aria-label='quick facts'>
|
||||
{person.knownForTitle && <li>{person.knownForTitle}</li>}
|
||||
{person.knownInYear && <li>{person.knownInYear}</li>}
|
||||
</ul>
|
||||
</CardResult>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,59 +1,36 @@
|
|||
import { Titles } from '../../interfaces/shared/search';
|
||||
import Image from 'next/future/image';
|
||||
import Link from 'next/link';
|
||||
import { modifyIMDbImg } from '../../utils/helpers';
|
||||
import { CardResult } from 'src/components/card';
|
||||
import { Titles } from 'src/interfaces/shared/search';
|
||||
import styles from 'src/styles/modules/components/find/title.module.scss';
|
||||
|
||||
import styles from '../../styles/modules/components/find/title.module.scss';
|
||||
|
||||
type Props = {
|
||||
title: Titles[0];
|
||||
};
|
||||
type Props = { title: Titles[number] };
|
||||
|
||||
const Title = ({ title }: Props) => {
|
||||
return (
|
||||
<li className={styles.title}>
|
||||
<div className={styles.imgContainer}>
|
||||
{title.image ? (
|
||||
<Image
|
||||
src={modifyIMDbImg(title.image.url, 400)}
|
||||
alt={title.image.caption}
|
||||
fill
|
||||
className={styles.img}
|
||||
/>
|
||||
) : (
|
||||
<svg className={styles.imgNA}>
|
||||
<use href="/svg/sprite.svg#icon-image-slash" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<Link href={`/title/${title.id}`}>
|
||||
<a className={`heading ${styles.heading}`}>{title.name}</a>
|
||||
</Link>
|
||||
<ul aria-label="quick facts" className={styles.basicInfo}>
|
||||
{title.type && <li>{title.type}</li>}
|
||||
{title.sAndE && <li>{title.sAndE}</li>}
|
||||
{title.releaseYear && <li>{title.releaseYear}</li>}
|
||||
<CardResult showImage name={title.name} link={`/title/${title.id}`} image={title.image?.url}>
|
||||
<ul aria-label='quick facts' className={styles.basicInfo}>
|
||||
<li>{title.type}</li>
|
||||
<li>{title.sAndE}</li>
|
||||
<li>{title.releaseYear}</li>
|
||||
</ul>
|
||||
{!!title.credits.length && (
|
||||
<p className={styles.stars}>
|
||||
<span>Stars: </span>
|
||||
{title.credits.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{title.seriesId && (
|
||||
<ul aria-label='quick series facts' className={styles.seriesInfo}>
|
||||
{title.seriesType && <li>{title.seriesType}</li>}
|
||||
<li>
|
||||
<Link href={`/title/${title.seriesId}`}>
|
||||
<a className='link'>{title.seriesName}</a>
|
||||
</Link>
|
||||
</li>
|
||||
{title.seriesReleaseYear && <li>{title.seriesReleaseYear}</li>}
|
||||
</ul>
|
||||
{!!title.credits.length && (
|
||||
<p className={styles.stars}>
|
||||
<span>Stars: </span>
|
||||
{title.credits.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{title.seriesId && (
|
||||
<ul aria-label="quick series facts" className={styles.seriesInfo}>
|
||||
{title.seriesType && <li>{title.seriesType}</li>}
|
||||
<li>
|
||||
<Link href={`/title/${title.seriesId}`}>
|
||||
<a className="link">{title.seriesName}</a>
|
||||
</Link>
|
||||
</li>
|
||||
{title.seriesReleaseYear && <li>{title.seriesReleaseYear}</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</CardResult>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import Find from '../../interfaces/shared/search';
|
||||
import Company from './Company';
|
||||
import Person from './Person';
|
||||
import Title from './Title';
|
||||
|
||||
import styles from '../../styles/modules/components/find/results.module.scss';
|
||||
import Keyword from './Keyword';
|
||||
import { getResTitleTypeHeading } from '../../utils/helpers';
|
||||
import Find from 'src/interfaces/shared/search';
|
||||
import { getResTitleTypeHeading } from 'src/utils/helpers';
|
||||
import styles from 'src/styles/modules/components/find/results.module.scss';
|
||||
|
||||
type Props = {
|
||||
results: Find | null;
|
||||
|
@ -13,18 +12,16 @@ type Props = {
|
|||
title: string;
|
||||
};
|
||||
|
||||
const resultsExist = (results: Props['results']) => {
|
||||
if (
|
||||
!results ||
|
||||
(!results.people.length &&
|
||||
!results.keywords.length &&
|
||||
!results.companies.length &&
|
||||
!results.titles.length)
|
||||
)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
const resultsExist = (
|
||||
results: Props['results']
|
||||
): results is NonNullable<Props['results']> =>
|
||||
Boolean(
|
||||
results &&
|
||||
(results.people.length ||
|
||||
results.keywords.length ||
|
||||
results.companies.length ||
|
||||
results.titles.length)
|
||||
);
|
||||
|
||||
// MAIN COMPONENT
|
||||
const Results = ({ results, className, title }: Props) => {
|
||||
|
@ -35,7 +32,7 @@ const Results = ({ results, className, title }: Props) => {
|
|||
</h1>
|
||||
);
|
||||
|
||||
const { titles, people, keywords, companies, meta } = results!;
|
||||
const { titles, people, keywords, companies, meta } = results;
|
||||
const titlesSectionHeading = getResTitleTypeHeading(
|
||||
meta.type,
|
||||
meta.titleType
|
||||
|
@ -43,11 +40,11 @@ const Results = ({ results, className, title }: Props) => {
|
|||
|
||||
return (
|
||||
<article className={`${className} ${styles.results}`}>
|
||||
<h1 className="heading heading__primary">Results for '{title}'</h1>
|
||||
<h1 className='heading heading__primary'>Results for '{title}'</h1>
|
||||
<div className={styles.results__list}>
|
||||
{!!titles.length && (
|
||||
<section className={styles.titles}>
|
||||
<h2 className="heading heading__secondary">
|
||||
<h2 className='heading heading__secondary'>
|
||||
{titlesSectionHeading}
|
||||
</h2>
|
||||
<ul className={styles.titles__list}>
|
||||
|
@ -59,7 +56,7 @@ const Results = ({ results, className, title }: Props) => {
|
|||
)}
|
||||
{!!people.length && (
|
||||
<section className={styles.people}>
|
||||
<h2 className="heading heading__secondary">People</h2>
|
||||
<h2 className='heading heading__secondary'>People</h2>
|
||||
<ul className={styles.people__list}>
|
||||
{people.map(person => (
|
||||
<Person person={person} key={person.id} />
|
||||
|
@ -69,7 +66,7 @@ const Results = ({ results, className, title }: Props) => {
|
|||
)}
|
||||
{!!companies.length && (
|
||||
<section className={styles.people}>
|
||||
<h2 className="heading heading__secondary">Companies</h2>
|
||||
<h2 className='heading heading__secondary'>Companies</h2>
|
||||
<ul className={styles.people__list}>
|
||||
{companies.map(company => (
|
||||
<Company company={company} key={company.id} />
|
||||
|
@ -79,7 +76,7 @@ const Results = ({ results, className, title }: Props) => {
|
|||
)}
|
||||
{!!keywords.length && (
|
||||
<section className={styles.people}>
|
||||
<h2 className="heading heading__secondary">Keywords</h2>
|
||||
<h2 className='heading heading__secondary'>Keywords</h2>
|
||||
<ul className={styles.people__list}>
|
||||
{keywords.map(keyword => (
|
||||
<Keyword keyword={keyword} key={keyword.id} />
|
||||
|
|
|
@ -1,46 +1,20 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { ChangeEventHandler, FormEventHandler, useRef, useState } from 'react';
|
||||
import { cleanQueryStr } from '../../../utils/helpers';
|
||||
import { resultTypes, resultTitleTypes } from '../../../utils/constants/find';
|
||||
|
||||
import styles from '../../../styles/modules/components/form/find.module.scss';
|
||||
import { QueryTypes } from '../../../interfaces/shared/search';
|
||||
|
||||
/**
|
||||
* 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>
|
||||
));
|
||||
};
|
||||
import { useRouter } from 'next/router';
|
||||
import { cleanQueryStr } from 'src/utils/helpers';
|
||||
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';
|
||||
|
||||
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;
|
||||
|
@ -61,59 +35,58 @@ const Form = ({ className }: Props) => {
|
|||
const queryStr = cleanQueryStr(entries);
|
||||
|
||||
if (query) router.push(`/find?${queryStr}`);
|
||||
else setIsDisabled(false);
|
||||
formEl.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
action="/find"
|
||||
action='/find'
|
||||
onSubmit={submitHandler}
|
||||
ref={formRef}
|
||||
className={`${className} ${styles.form}`}
|
||||
>
|
||||
<p className="heading heading__primary">Search</p>
|
||||
<p className='heading heading__primary'>Search</p>
|
||||
|
||||
<p className={styles.searchbar}>
|
||||
<svg
|
||||
className={`icon ${styles.searchbar__icon}`}
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
focusable='false'
|
||||
aria-hidden='true'
|
||||
role='img'
|
||||
>
|
||||
<use href="/svg/sprite.svg#icon-search"></use>
|
||||
<use href='/svg/sprite.svg#icon-search'></use>
|
||||
</svg>
|
||||
<input
|
||||
id="searchbar"
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="movies, people..."
|
||||
id='searchbar'
|
||||
type='search'
|
||||
name='q'
|
||||
placeholder='movies, people...'
|
||||
className={styles.searchbar__input}
|
||||
required
|
||||
minLength={2}
|
||||
/>
|
||||
<label className="visually-hidden" htmlFor="searchbar">
|
||||
<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>
|
||||
<input type="checkbox" name="exact" id="exact" value="true" />
|
||||
<label htmlFor='exact'>Exact Matches</label>
|
||||
<input type='checkbox' name='exact' id='exact' value='true' />
|
||||
</p>
|
||||
<div className={styles.buttons}>
|
||||
<button type="reset" className={styles.button}>
|
||||
<button type='reset' className={styles.button}>
|
||||
Clear
|
||||
</button>
|
||||
<button type="submit" className={styles.button}>
|
||||
<button type='submit' className={styles.button}>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
|
@ -121,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;
|
||||
|
|
52
src/components/layout/Footer.tsx
Normal file
52
src/components/layout/Footer.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import styles from 'src/styles/modules/layout/footer.module.scss';
|
||||
|
||||
const links = [
|
||||
{ path: '/about', text: 'About' },
|
||||
{ path: '/find', text: 'Find' },
|
||||
{ path: '/privacy', text: 'Privacy' },
|
||||
{ path: '/contact', text: 'Contact' },
|
||||
] as const;
|
||||
|
||||
const Footer = () => {
|
||||
const { pathname } = useRouter();
|
||||
|
||||
return (
|
||||
<footer id='footer' className={styles.footer}>
|
||||
<nav aria-label='primary navigation' className={styles.nav}>
|
||||
<ul className={styles.list}>
|
||||
{links.map(link => (
|
||||
<li className={styles.nav__item} key={link.path}>
|
||||
<Link href={link.path}>
|
||||
<a
|
||||
className={styles.nav__link}
|
||||
aria-current={pathname === link.path ? 'page' : undefined}
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li className={styles.nav__item}>
|
||||
<a href='#' className={styles.nav__link}>
|
||||
Back to top
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<p className={styles.licence}>
|
||||
Licensed under{' '}
|
||||
<a
|
||||
className={styles.nav__link}
|
||||
href='https://www.gnu.org/licenses/agpl-3.0-standalone.html'
|
||||
>
|
||||
GNU AGPLv3
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
|
@ -1,22 +1,14 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import ThemeToggler from '../components/buttons/ThemeToggler';
|
||||
import ThemeToggler from 'src/components/buttons/ThemeToggler';
|
||||
import styles from 'src/styles/modules/layout/header.module.scss';
|
||||
|
||||
import styles from '../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>
|
||||
|
@ -24,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}>
|
||||
|
@ -46,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>
|
||||
|
@ -69,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
|
||||
|
@ -84,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>
|
||||
.
|
|
@ -1,17 +1,18 @@
|
|||
import React from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import Footer from './Footer';
|
||||
import Header from './Header';
|
||||
|
||||
type Props = {
|
||||
full?: boolean;
|
||||
children: React.ReactNode;
|
||||
full?: true;
|
||||
children: ReactNode;
|
||||
className: string;
|
||||
originalPath?: string;
|
||||
};
|
||||
|
||||
const Layout = ({ full, children, className }: Props) => {
|
||||
const Layout = ({ full, children, className, originalPath }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Header full={full} />
|
||||
<Header full={full} originalPath={originalPath} />
|
||||
<main id='main' className={`main ${className}`}>
|
||||
{children}
|
||||
</main>
|
23
src/components/list/Data.tsx
Normal file
23
src/components/list/Data.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import type { DataKind, Data as TData } from 'src/interfaces/shared/list';
|
||||
import type { ToArray } from 'src/interfaces/shared';
|
||||
import Images from './Images';
|
||||
import Names from './Names';
|
||||
import Titles from './Titles';
|
||||
|
||||
type Props = {
|
||||
data: ToArray<TData<DataKind>>;
|
||||
};
|
||||
|
||||
const Data = ({ data }: Props) => {
|
||||
if (isDataImages(data)) return <Images images={data} />;
|
||||
if (isDataNames(data)) return <Names names={data} />;
|
||||
|
||||
return <Titles titles={data} />;
|
||||
};
|
||||
export default Data;
|
||||
|
||||
const isDataImages = (data: unknown): data is TData<'images'>[] =>
|
||||
Array.isArray(data) && typeof data[0] === 'string';
|
||||
|
||||
const isDataNames = (data: unknown): data is TData<'names'>[] =>
|
||||
Array.isArray(data) && data[0] && typeof data[0] === 'object' && 'about' in data[0];
|
22
src/components/list/Images.tsx
Normal file
22
src/components/list/Images.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import Image from 'next/future/image';
|
||||
import { modifyIMDbImg } from 'src/utils/helpers';
|
||||
import type { Data } from 'src/interfaces/shared/list';
|
||||
import styles from 'src/styles/modules/components/list/images.module.scss';
|
||||
|
||||
type Props = {
|
||||
images: Data<'images'>[];
|
||||
};
|
||||
|
||||
const Images = ({ images }: Props) => {
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
{images.map(image => (
|
||||
<figure className={styles.imgContainer} key={image}>
|
||||
<Image src={modifyIMDbImg(image, 400)} alt='' fill className={styles.img} sizes='200px'/>
|
||||
</figure>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Images;
|
35
src/components/list/Meta.tsx
Normal file
35
src/components/list/Meta.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import Link from 'next/link';
|
||||
import { formatDate } from 'src/utils/helpers';
|
||||
import List from 'src/interfaces/shared/list';
|
||||
import styles from 'src/styles/modules/components/list/meta.module.scss';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
meta: List['meta'];
|
||||
description: List['description'];
|
||||
};
|
||||
const Meta = ({ title, meta, description }: Props) => {
|
||||
const by = meta.by.link ? (
|
||||
<Link href={meta.by.link}>
|
||||
<a className='link'>{meta.by.name}</a>
|
||||
</Link>
|
||||
) : (
|
||||
meta.by.name
|
||||
);
|
||||
|
||||
return (
|
||||
<header className={styles.container}>
|
||||
<h1 className='heading heading__secondary'>{title}</h1>
|
||||
<ul className={styles.list}>
|
||||
<li>by {by}</li>
|
||||
<li>{meta.created}</li>
|
||||
{meta.updated && <li>{meta.updated}</li>}
|
||||
<li>
|
||||
{meta.num} {meta.type}
|
||||
</li>
|
||||
</ul>
|
||||
{description && <p>{description}</p>}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
export default Meta;
|
57
src/components/list/Names.tsx
Normal file
57
src/components/list/Names.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import Image from 'next/future/image';
|
||||
import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
|
||||
import { Card } from 'src/components/card';
|
||||
import type { Data } from 'src/interfaces/shared/list';
|
||||
import styles from 'src/styles/modules/components/list/names.module.scss';
|
||||
import OptionalLink from './OptionalLink';
|
||||
|
||||
type Props = {
|
||||
names: Data<'names'>[];
|
||||
};
|
||||
|
||||
const Names = ({ names }: Props) => {
|
||||
return (
|
||||
<ul className={styles.names}>
|
||||
{names.map(name => (
|
||||
<Name {...name} key={name.name} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
export default Names;
|
||||
|
||||
const Name = ({ about, image, job, knownFor, knownForLink, name, url }: Props['names'][number]) => {
|
||||
// const style: CSSProperties = {
|
||||
// backgroundImage: image ? `url(${getProxiedIMDbImgUrl(modifyIMDbImg(image, 300))})` : undefined,
|
||||
// };
|
||||
|
||||
return (
|
||||
<Card hoverable className={styles.name}>
|
||||
<div className={styles.imgContainer}>
|
||||
{image ? (
|
||||
<Image src={modifyIMDbImg(image, 400)} alt='' fill className={styles.img} sizes='200px' />
|
||||
) : (
|
||||
<svg className={styles.imgNA}>
|
||||
<use href='/svg/sprite.svg#icon-image-slash' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<h2 className={`heading ${styles.heading}`}>
|
||||
<OptionalLink href={url} className={`heading ${styles.heading}`}>
|
||||
{name}
|
||||
</OptionalLink>
|
||||
</h2>
|
||||
<ul className={styles.basicInfo} aria-label='quick facts'>
|
||||
{job && <li>{job}</li>}
|
||||
{knownFor && (
|
||||
<li>
|
||||
<OptionalLink href={knownForLink}>{knownFor}</OptionalLink>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<p>{about}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
20
src/components/list/OptionalLink.tsx
Normal file
20
src/components/list/OptionalLink.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import type { ReactNode, ComponentPropsWithoutRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const OptionalLink = ({
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: { href?: string | null; children: ReactNode } & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
|
||||
<>
|
||||
{href ? (
|
||||
<Link href={href}>
|
||||
<a {...rest}>{children}</a>
|
||||
</Link>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
export default OptionalLink;
|
33
src/components/list/Pagination.tsx
Normal file
33
src/components/list/Pagination.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import OptionalLink from './OptionalLink';
|
||||
import type List from 'src/interfaces/shared/list';
|
||||
import styles from 'src/styles/modules/components/list/pagination.module.scss';
|
||||
|
||||
type Props = {
|
||||
pagination: List['pagination'];
|
||||
};
|
||||
const Pagination = ({ pagination }: Props) => {
|
||||
const prevLink = pagination.prev && pagination.prev !== '#' ? pagination.prev : null;
|
||||
const nextLink = pagination.next && pagination.next !== '#' ? pagination.next : null;
|
||||
|
||||
if (!prevLink && !nextLink) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label='pagination'>
|
||||
<ul className={styles.nav}>
|
||||
<li aria-hidden={!prevLink}>
|
||||
<OptionalLink href={prevLink} className='link'>
|
||||
Prev
|
||||
</OptionalLink>
|
||||
</li>
|
||||
<li>{pagination.range} shown</li>
|
||||
<li aria-hidden={!nextLink}>
|
||||
<OptionalLink href={nextLink} className='link'>
|
||||
Next
|
||||
</OptionalLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
79
src/components/list/Titles.tsx
Normal file
79
src/components/list/Titles.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import Image from 'next/future/image';
|
||||
import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
|
||||
import { Card } from 'src/components/card';
|
||||
import type { Data } from 'src/interfaces/shared/list';
|
||||
import styles from 'src/styles/modules/components/list/titles.module.scss';
|
||||
import { CSSProperties } from 'react';
|
||||
import OptionalLink from './OptionalLink';
|
||||
|
||||
type Props = {
|
||||
titles: Data<'titles'>[];
|
||||
};
|
||||
|
||||
const Titles = ({ titles }: Props) => {
|
||||
return (
|
||||
<ul className={styles.titles}>
|
||||
{titles.map(title => (
|
||||
<Title {...title} key={title.name} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
export default Titles;
|
||||
|
||||
const Title = (props: Props['titles'][number]) => {
|
||||
const style: CSSProperties = {
|
||||
backgroundImage: props.image
|
||||
? `url(${getProxiedIMDbImgUrl(modifyIMDbImg(props.image, 300))})`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card hoverable className={styles.title}>
|
||||
<div className={styles.imgContainer}>
|
||||
{props.image ? (
|
||||
<Image src={modifyIMDbImg(props.image, 400)} alt='' fill className={styles.img} />
|
||||
) : (
|
||||
<svg className={styles.imgNA}>
|
||||
<use href='/svg/sprite.svg#icon-image-slash' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<h2 className={`heading heading__tertiary ${styles.heading}`}>
|
||||
<OptionalLink href={props.url} className={`heading ${styles.heading}`}>
|
||||
{props.name} {props.year}
|
||||
</OptionalLink>
|
||||
</h2>
|
||||
<ul className={styles.basicInfo} aria-label='quick facts'>
|
||||
{props.certificate && <li>{props.certificate}</li>}
|
||||
{props.runtime && <li>{props.runtime}</li>}
|
||||
{props.genre && <li>{props.genre}</li>}
|
||||
</ul>
|
||||
<ul className={styles.ratings}>
|
||||
{Boolean(props.rating) && <li className={styles.rating}>
|
||||
<span className={styles.rating__num}>{props.rating}</span>
|
||||
<svg className={styles.rating__icon}>
|
||||
<use href='/svg/sprite.svg#icon-rating'></use>
|
||||
</svg>
|
||||
<span className={styles.rating__text}> Avg. rating</span>
|
||||
</li>}
|
||||
{Boolean(props.metascore) && <li className={styles.rating}>
|
||||
<span className={styles.rating__num}>{props.metascore}</span>
|
||||
<span className={styles.rating__text}>Metascore</span>
|
||||
</li>}
|
||||
</ul>
|
||||
<p className={styles.plot}>
|
||||
<span>Plot:</span> {props.plot}
|
||||
</p>
|
||||
<ul className={styles.otherInfo}>
|
||||
{props.otherInfo.map(([infoHeading, info]) => (
|
||||
<li key={infoHeading}>
|
||||
<span>{infoHeading}:</span> {info}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
3
src/components/list/index.ts
Normal file
3
src/components/list/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as Data } from './Data';
|
||||
export { default as Meta } from './Meta';
|
||||
export { default as Pagination } from './Pagination';
|
|
@ -1,4 +1,4 @@
|
|||
import styles from '../../styles/modules/components/loaders/progress-bar.module.scss';
|
||||
import styles from 'src/styles/modules/components/loaders/progress-bar.module.scss';
|
||||
|
||||
const ProgressBar = () => {
|
||||
return <span className={styles.progress} role='progressbar'></span>;
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import Image from 'next/future/image';
|
||||
import Link from 'next/link';
|
||||
import { Media } from '../../interfaces/shared/title';
|
||||
import { getProxiedIMDbImgUrl, modifyIMDbImg } from '../../utils/helpers';
|
||||
|
||||
import styles from '../../styles/modules/components/title/media.module.scss';
|
||||
import { Media } from 'src/interfaces/shared';
|
||||
import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
|
||||
import styles from 'src/styles/modules/components/media/media.module.scss';
|
||||
|
||||
type Props = {
|
||||
className: string;
|
||||
media: Media;
|
||||
};
|
||||
|
||||
// TODO: refactor this component.
|
||||
|
||||
const Media = ({ className, media }: Props) => {
|
||||
return (
|
||||
<div className={`${className} ${styles.media}`}>
|
||||
|
@ -22,13 +23,9 @@ const Media = ({ className, media }: Props) => {
|
|||
<div className={styles.trailer}>
|
||||
<video
|
||||
aria-label='trailer video'
|
||||
// it's a relatively new tag. hence jsx-all1 complains
|
||||
aria-description={media.trailer.caption}
|
||||
controls
|
||||
playsInline
|
||||
poster={getProxiedIMDbImgUrl(
|
||||
modifyIMDbImg(media.trailer.thumbnail)
|
||||
)}
|
||||
poster={getProxiedIMDbImgUrl(modifyIMDbImg(media.trailer.thumbnail))}
|
||||
className={styles.trailer__video}
|
||||
preload='none'
|
||||
>
|
||||
|
@ -77,9 +74,7 @@ const Media = ({ className, media }: Props) => {
|
|||
fill
|
||||
sizes='400px'
|
||||
/>
|
||||
<figcaption className={styles.image__caption}>
|
||||
{image.caption.plainText}
|
||||
</figcaption>
|
||||
<figcaption className={styles.image__caption}>{image.caption.plainText}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
|
@ -1,4 +1,5 @@
|
|||
import Head from 'next/head';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
|
@ -6,11 +7,15 @@ type Props = {
|
|||
imgUrl?: string;
|
||||
};
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_URL ?? 'https://iket.me';
|
||||
|
||||
const Meta = ({
|
||||
title,
|
||||
description = 'libremdb, a free & open source IMDb front-end.',
|
||||
imgUrl = 'icon.svg',
|
||||
}: Props) => {
|
||||
const url = new URL(imgUrl, BASE_URL);
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<meta charSet='UTF-8' />
|
||||
|
@ -30,10 +35,7 @@ const Meta = ({
|
|||
<meta property='og:site_name' content='libremdb' />
|
||||
<meta property='og:locale' content='en_US' />
|
||||
<meta property='og:type' content='video.movie' />
|
||||
<meta
|
||||
property='og:image'
|
||||
content={`${process.env.NEXT_PUBLIC_URL}/${imgUrl}`}
|
||||
/>
|
||||
<meta property='og:image' content={url.toString()} />
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
|
59
src/components/name/Basic.tsx
Normal file
59
src/components/name/Basic.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { CardBasic } from 'src/components/card';
|
||||
import { Basic as BasicType } from 'src/interfaces/shared/name';
|
||||
import { formatNumber } from 'src/utils/helpers';
|
||||
import styles from 'src/styles/modules/components/name/basic.module.scss';
|
||||
|
||||
type Props = {
|
||||
className: string;
|
||||
data: BasicType;
|
||||
};
|
||||
|
||||
const Basic = ({ data, className }: Props) => {
|
||||
return (
|
||||
<CardBasic className={className} image={data.poster?.url} title={data.name}>
|
||||
<div className={styles.ratings}>
|
||||
{data.ranking && (
|
||||
<p className={styles.rating}>
|
||||
<span className={styles.rating__num}>{formatNumber(data.ranking.position)}</span>
|
||||
<svg className={styles.rating__icon}>
|
||||
<use href='/svg/sprite.svg#icon-graph-rising'></use>
|
||||
</svg>
|
||||
<span className={styles.rating__text}>
|
||||
{' '}
|
||||
Popularity (
|
||||
<span className={styles.rating__sub}>{getRankingStats(data.ranking)}</span>)
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!data.primaryProfessions.length && (
|
||||
<p className={styles.genres}>
|
||||
<span className={styles.heading}>Profession: </span>
|
||||
{data.primaryProfessions.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{
|
||||
<p className={styles.overview}>
|
||||
<span className={styles.heading}>About: </span>
|
||||
{data.bio.short}...
|
||||
</p>
|
||||
}
|
||||
{data.knownFor.title && (
|
||||
<p className={styles.genres}>
|
||||
<span className={styles.heading}>Known for: </span>
|
||||
{data.knownFor.title} ({data.knownFor.role})
|
||||
</p>
|
||||
)}
|
||||
</CardBasic>
|
||||
);
|
||||
};
|
||||
|
||||
const getRankingStats = (ranking: NonNullable<Props['data']['ranking']>) => {
|
||||
if (ranking.direction === 'FLAT') return '\u2192';
|
||||
|
||||
const change = formatNumber(ranking.change);
|
||||
return (ranking.direction === 'UP' ? '\u2191' : '\u2193') + change;
|
||||
};
|
||||
|
||||
export default Basic;
|
12
src/components/name/Bio.tsx
Normal file
12
src/components/name/Bio.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import styles from 'src/styles/modules/components/name/did-you-know.module.scss';
|
||||
|
||||
type Props = { bio: string };
|
||||
|
||||
const Bio = ({ bio }: Props) => (
|
||||
<section className={styles.bio}>
|
||||
<h2 className='heading heading__secondary'>About</h2>
|
||||
<div dangerouslySetInnerHTML={{ __html: bio }} />
|
||||
</section>
|
||||
);
|
||||
|
||||
export default Bio;
|
43
src/components/name/Credits.tsx
Normal file
43
src/components/name/Credits.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { Credits } from 'src/interfaces/shared/name';
|
||||
import { CardTitle } from 'src/components/card';
|
||||
import styles from 'src/styles/modules/components/name/credits.module.scss';
|
||||
|
||||
type Props = {
|
||||
className: string;
|
||||
data: Credits;
|
||||
};
|
||||
|
||||
const Credits = ({ className, data }: Props) => {
|
||||
if (!data.total) return null;
|
||||
|
||||
return (
|
||||
<section className={`${className} ${styles.credits}`}>
|
||||
<h2 className='heading heading__secondary'>Credits</h2>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Credits;
|
51
src/components/name/DidYouKnow.tsx
Normal file
51
src/components/name/DidYouKnow.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import Link from 'next/link';
|
||||
import { DidYouKnow } from 'src/interfaces/shared/name';
|
||||
import styles from 'src/styles/modules/components/name/did-you-know.module.scss';
|
||||
|
||||
type Props = {
|
||||
data: DidYouKnow;
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
export default DidYouKnow;
|
184
src/components/name/Info.tsx
Normal file
184
src/components/name/Info.tsx
Normal file
|
@ -0,0 +1,184 @@
|
|||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import Name, { PersonalDetails } from 'src/interfaces/shared/name';
|
||||
import styles from 'src/styles/modules/components/name/info.module.scss';
|
||||
|
||||
type Props = {
|
||||
info: PersonalDetails;
|
||||
accolades: Name['accolades'];
|
||||
};
|
||||
|
||||
const PersonalDetails = ({ info, accolades }: Props) => {
|
||||
const {
|
||||
query: { nameId },
|
||||
} = useRouter();
|
||||
|
||||
return (
|
||||
<div className={styles.info}>
|
||||
<section className={styles.accolades}>
|
||||
<h2 className='heading heading__secondary'>Accolades</h2>
|
||||
<div className={styles.accolades__container}>
|
||||
{accolades.awards && (
|
||||
<p>
|
||||
<span>
|
||||
Won {accolades.awards.wins} {accolades.awards.name}
|
||||
</span>
|
||||
<span> (out of {accolades.awards.nominations} nominations)</span>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{accolades.wins} wins and {accolades.nominations} nominations in total
|
||||
</p>
|
||||
<p>
|
||||
<Link href={`/name/${nameId}/awards`}>
|
||||
<a className='link'>View all awards</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.details}>
|
||||
<h2 className='heading heading__secondary'>Personal details</h2>
|
||||
<div className={styles.details__container}>
|
||||
{!!info.officialSites.length && (
|
||||
<p>
|
||||
<span>Official sites: </span>
|
||||
{info.officialSites.map((site, i) => (
|
||||
<span key={site.url}>
|
||||
{!!i && ', '}
|
||||
<a href={site.url} className='link' target='_blank' rel='noreferrer'>
|
||||
{site.name}
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{!!info.alsoKnownAs.length && (
|
||||
<p>
|
||||
<span>Also known as: </span>
|
||||
<span>{info.alsoKnownAs.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
{info.height && (
|
||||
<p>
|
||||
<span>Height: </span>
|
||||
<span>{info.height}</span>
|
||||
</p>
|
||||
)}
|
||||
{info.birth && (
|
||||
<p>
|
||||
<span>Born: </span>
|
||||
<span>{info.birth.date}</span>
|
||||
<span>
|
||||
{' '}
|
||||
in{' '}
|
||||
<Link href={`/search/name?birth_place=${info.birth.location}`}>
|
||||
<a className='link'>{info.birth.location}</a>
|
||||
</Link>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{info.death.date && (
|
||||
<p>
|
||||
<span>Died: </span>
|
||||
<span>{info.death.date}</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 && (
|
||||
<p>
|
||||
<span>Death cause: </span>
|
||||
<span>{info.death.cause}</span>
|
||||
</p>
|
||||
)}
|
||||
{!!info.spouses?.length && (
|
||||
<p>
|
||||
<span>Spouses: </span>
|
||||
{info.spouses.map((spouse, i) => (
|
||||
<span key={spouse.name}>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(spouse)} {spouse.range}
|
||||
{spouse.attributes && ' (' + spouse.attributes.join(', ') + ')'}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{!!info.children?.length && (
|
||||
<p>
|
||||
<span>Children: </span>
|
||||
{info.children.map((child, i) => (
|
||||
<span key={child.name}>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(child)}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{!!info.parents?.length && (
|
||||
<p>
|
||||
<span>Parents: </span>
|
||||
{info.parents.map((parent, i) => (
|
||||
<span key={parent.name}>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(parent)}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{!!info.relatives?.length && (
|
||||
<p>
|
||||
<span>Relatives: </span>
|
||||
{info.relatives.map((relative, i) => (
|
||||
<span key={relative.name}>
|
||||
{!!i && ', '}
|
||||
{renderPersonNameWithLink(relative)} ({relative.relation})
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{!!info.otherWorks?.length && (
|
||||
<p>
|
||||
<span>Other Works: </span>
|
||||
{info.otherWorks.map((work, i) => (
|
||||
<span key={work.text}>
|
||||
{!!i && ', '}
|
||||
<span dangerouslySetInnerHTML={{ __html: work.text }} />
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{!!info.publicity.total && (
|
||||
<p>
|
||||
<span>Publicity Listings: </span>
|
||||
<span>{info.publicity.articles} Articles</span>,{' '}
|
||||
<span>{info.publicity.interviews} Interviews</span>,{' '}
|
||||
<span>{info.publicity.magazines} Magazines</span>,{' '}
|
||||
<span>{info.publicity.pictorials} Pictorials</span>,{' '}
|
||||
<span>{info.publicity.printBiographies} Print biographies</span>, and{' '}
|
||||
<span>{info.publicity.filmBiographies} Biographies</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalDetails;
|
||||
|
||||
const renderPersonNameWithLink = (person: { name: string; id: string | null }) =>
|
||||
person.id ? (
|
||||
<Link href={`/name/${person.id}`}>
|
||||
<a className='link'>{person.name}</a>
|
||||
</Link>
|
||||
) : (
|
||||
<span>{person.name}</span>
|
||||
);
|
34
src/components/name/KnownFor.tsx
Normal file
34
src/components/name/KnownFor.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import type { KnownFor as KnownForType } from 'src/interfaces/shared/name';
|
||||
import { CardTitle } from 'src/components/card';
|
||||
import styles from 'src/styles/modules/components/name/known-for.module.scss';
|
||||
|
||||
type Props = { data: KnownForType };
|
||||
|
||||
const KnownFor = ({ data }: Props) => {
|
||||
if (!data.length) return null;
|
||||
|
||||
return (
|
||||
<section className={styles.knownFor}>
|
||||
<h2 className='heading heading__secondary'>Known For</h2>
|
||||
<ul className={styles.container}>
|
||||
{data.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 className={styles.item__role}>{getRoles(title)}</p>
|
||||
</CardTitle>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const getRoles = (title: Props['data'][number]) =>
|
||||
(title.summary.characters ?? title.summary.jobs)?.join(', ');
|
||||
|
||||
export default KnownFor;
|
6
src/components/name/index.ts
Normal file
6
src/components/name/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export { default as Basic } from './Basic';
|
||||
export { default as DidYouKnow } from './DidYouKnow';
|
||||
export { default as Info } from './Info';
|
||||
export { default as Credits } from './Credits';
|
||||
export { default as KnownFor } from './KnownFor';
|
||||
export { default as Bio } from './Bio';
|
|
@ -1,15 +1,9 @@
|
|||
import { Fragment } from 'react';
|
||||
import Image from 'next/future/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
formatNumber,
|
||||
formatTime,
|
||||
getProxiedIMDbImgUrl,
|
||||
modifyIMDbImg,
|
||||
} from '../../utils/helpers';
|
||||
import { Basic } from '../../interfaces/shared/title';
|
||||
import styles from '../../styles/modules/components/title/basic.module.scss';
|
||||
import { CardBasic } from 'src/components/card';
|
||||
import { Basic } from 'src/interfaces/shared/title';
|
||||
import { formatNumber, formatTime } from 'src/utils/helpers';
|
||||
import styles from 'src/styles/modules/components/title/basic.module.scss';
|
||||
|
||||
type Props = {
|
||||
className: string;
|
||||
|
@ -24,135 +18,92 @@ const Basic = ({ data, className }: Props) => {
|
|||
: data.releaseYear?.start;
|
||||
|
||||
return (
|
||||
<section
|
||||
// role is valid but not known to jsx-a11y
|
||||
// aria-description={`basic info for '${data.title}'`}
|
||||
// style={{ backgroundImage: data.poster && `url(${data.poster?.url})` }}
|
||||
<CardBasic
|
||||
className={`${styles.container} ${className}`}
|
||||
image={data.poster?.url}
|
||||
title={data.title}
|
||||
>
|
||||
<div
|
||||
className={styles.imageContainer}
|
||||
style={{
|
||||
backgroundImage:
|
||||
data.poster &&
|
||||
`url(${getProxiedIMDbImgUrl(modifyIMDbImg(data.poster.url, 300))})`,
|
||||
}}
|
||||
>
|
||||
{data.poster ? (
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={modifyIMDbImg(data.poster.url)}
|
||||
alt={data.poster.caption}
|
||||
priority
|
||||
fill
|
||||
sizes='300px'
|
||||
/>
|
||||
) : (
|
||||
<svg className={styles.image__NA}>
|
||||
<use href='/svg/sprite.svg#icon-image-slash' />
|
||||
</svg>
|
||||
<ul className={styles.meta} aria-label='quick facts'>
|
||||
{data.status && data.status.id !== 'released' && (
|
||||
<li className={styles.meta__text}>{data.status.text}</li>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<h1 className={`${styles.title} heading heading__primary`}>
|
||||
{data.title}
|
||||
</h1>
|
||||
<ul className={styles.meta} aria-label='quick facts'>
|
||||
{data.status && data.status.id !== 'released' && (
|
||||
<li className={styles.meta__text}>{data.status.text}</li>
|
||||
)}
|
||||
<li className={styles.meta__text}>{data.type.name}</li>
|
||||
{data.releaseYear && (
|
||||
<li className={styles.meta__text}>{releaseTime}</li>
|
||||
)}
|
||||
{data.ceritficate && (
|
||||
<li className={styles.meta__text}>{data.ceritficate}</li>
|
||||
)}
|
||||
{data.runtime && (
|
||||
<li className={styles.meta__text}>{formatTime(data.runtime)}</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className={styles.ratings}>
|
||||
{data.ratings.avg && (
|
||||
<>
|
||||
<p className={styles.rating}>
|
||||
<span className={styles.rating__num}>{data.ratings.avg}</span>
|
||||
<svg className={styles.rating__icon}>
|
||||
<use href='/svg/sprite.svg#icon-rating'></use>
|
||||
</svg>
|
||||
<span className={styles.rating__text}> Avg. rating</span>
|
||||
</p>
|
||||
<p className={styles.rating}>
|
||||
<span className={styles.rating__num}>
|
||||
{formatNumber(data.ratings.numVotes)}
|
||||
</span>
|
||||
<svg className={styles.rating__icon}>
|
||||
<use href='/svg/sprite.svg#icon-like-dislike'></use>
|
||||
</svg>
|
||||
<span className={styles.rating__text}> No. of votes</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{data.ranking && (
|
||||
<li className={styles.meta__text}>{data.type.name}</li>
|
||||
{data.releaseYear && <li className={styles.meta__text}>{releaseTime}</li>}
|
||||
{data.ceritficate && <li className={styles.meta__text}>{data.ceritficate}</li>}
|
||||
{data.runtime && <li className={styles.meta__text}>{formatTime(data.runtime)}</li>}
|
||||
</ul>
|
||||
<div className={styles.ratings}>
|
||||
{data.ratings.avg && (
|
||||
<>
|
||||
<p className={styles.rating}>
|
||||
<span className={styles.rating__num}>
|
||||
{formatNumber(data.ranking.position)}
|
||||
</span>
|
||||
<span className={styles.rating__num}>{data.ratings.avg}</span>
|
||||
<svg className={styles.rating__icon}>
|
||||
<use href='/svg/sprite.svg#icon-graph-rising'></use>
|
||||
<use href='/svg/sprite.svg#icon-rating'></use>
|
||||
</svg>
|
||||
<span className={styles.rating__text}>
|
||||
{' '}
|
||||
Popularity (
|
||||
<span className={styles.rating__sub}>
|
||||
{data.ranking.direction === 'UP'
|
||||
? `\u2191${formatNumber(data.ranking.change)}`
|
||||
: data.ranking.direction === 'DOWN'
|
||||
? `\u2193${formatNumber(data.ranking.change)}`
|
||||
: ''}
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
<span className={styles.rating__text}> Avg. rating</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!data.genres.length && (
|
||||
<p className={styles.genres}>
|
||||
<span className={styles.genres__heading}>Genres: </span>
|
||||
{data.genres.map((genre, i) => (
|
||||
<Fragment key={genre.id}>
|
||||
{i > 0 && ', '}
|
||||
<Link href={`/search/title?genres=${genre.id}`}>
|
||||
<a className={styles.link}>{genre.text}</a>
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
<p className={styles.rating}>
|
||||
<span className={styles.rating__num}>{formatNumber(data.ratings.numVotes)}</span>
|
||||
<svg className={styles.rating__icon}>
|
||||
<use href='/svg/sprite.svg#icon-like-dislike'></use>
|
||||
</svg>
|
||||
<span className={styles.rating__text}> No. of votes</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{data.ranking && (
|
||||
<p className={styles.rating}>
|
||||
<span className={styles.rating__num}>{formatNumber(data.ranking.position)}</span>
|
||||
<svg className={styles.rating__icon}>
|
||||
<use href='/svg/sprite.svg#icon-graph-rising'></use>
|
||||
</svg>
|
||||
<span className={styles.rating__text}>
|
||||
{' '}
|
||||
Popularity (
|
||||
<span className={styles.rating__sub}>
|
||||
{data.ranking.direction === 'UP'
|
||||
? `\u2191${formatNumber(data.ranking.change)}`
|
||||
: data.ranking.direction === 'DOWN'
|
||||
? `\u2193${formatNumber(data.ranking.change)}`
|
||||
: ''}
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{
|
||||
<p className={styles.overview}>
|
||||
<span className={styles.overview__heading}>Plot: </span>
|
||||
<span className={styles.overview__text}>{data.plot || '-'}</span>
|
||||
</p>
|
||||
}
|
||||
{data.primaryCrew.map(crewType => (
|
||||
<p className={styles.crewType} key={crewType.type.id}>
|
||||
<span className={styles.crewType__heading}>
|
||||
{`${crewType.type.category}: `}
|
||||
</span>
|
||||
{crewType.crew.map((crew, i) => (
|
||||
<Fragment key={crew.id}>
|
||||
{i > 0 && ', '}
|
||||
<Link href={`/name/${crew.id}`}>
|
||||
<a className={styles.link}>{crew.name}</a>
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!!data.genres.length && (
|
||||
<p className={styles.genres}>
|
||||
<span className={styles.genres__heading}>Genres: </span>
|
||||
{data.genres.map((genre, i) => (
|
||||
<Fragment key={genre.id}>
|
||||
{i > 0 && ', '}
|
||||
<Link href={`/search/title?genres=${genre.id}`}>
|
||||
<a className={styles.link}>{genre.text}</a>
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<p className={styles.overview}>
|
||||
<span className={styles.overview__heading}>Plot: </span>
|
||||
<span className={styles.overview__text}>{data.plot || '-'}</span>
|
||||
</p>
|
||||
{data.primaryCrew.map(crewType => (
|
||||
<p className={styles.crewType} key={crewType.type.id}>
|
||||
<span className={styles.crewType__heading}>{`${crewType.type.category}: `}</span>
|
||||
{crewType.crew.map((crew, i) => (
|
||||
<Fragment key={crew.id}>
|
||||
{i > 0 && ', '}
|
||||
<Link href={`/name/${crew.id}`}>
|
||||
<a className={styles.link}>{crew.name}</a>
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
))}
|
||||
</CardBasic>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import Image from 'next/future/image';
|
||||
import Link from 'next/link';
|
||||
import { Cast } from '../../interfaces/shared/title';
|
||||
import { modifyIMDbImg } from '../../utils/helpers';
|
||||
|
||||
import styles from '../../styles/modules/components/title/cast.module.scss';
|
||||
import { CardCast } from 'src/components/card';
|
||||
import { Cast } from 'src/interfaces/shared/title';
|
||||
import styles from 'src/styles/modules/components/title/cast.module.scss';
|
||||
|
||||
type Props = {
|
||||
className: string;
|
||||
|
@ -11,46 +8,25 @@ type Props = {
|
|||
};
|
||||
|
||||
const Cast = ({ className, cast }: Props) => {
|
||||
if (!cast.length) return <></>;
|
||||
if (!cast.length) return null;
|
||||
|
||||
return (
|
||||
<section className={`${className} ${styles.container}`}>
|
||||
<h2 className='heading heading__secondary'>Cast</h2>
|
||||
<ul className={styles.cast}>
|
||||
{cast.map(member => (
|
||||
<li key={member.id} className={styles.member}>
|
||||
<div className={styles.member__imgContainer}>
|
||||
{member.image ? (
|
||||
<Image
|
||||
src={modifyIMDbImg(member.image, 400)}
|
||||
alt=''
|
||||
fill
|
||||
className={styles.member__img}
|
||||
sizes='200px'
|
||||
/>
|
||||
) : (
|
||||
<svg className={styles.member__imgNA}>
|
||||
<use href='/svg/sprite.svg#icon-image-slash' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.member__textContainer}>
|
||||
<p>
|
||||
<Link href={`/name/${member.id}`}>
|
||||
<a className={styles.member__name}>{member.name}</a>
|
||||
</Link>
|
||||
</p>
|
||||
<p className={styles.member__role}>
|
||||
{member.characters?.join(', ')}
|
||||
{member.attributes && (
|
||||
<span> ({member.attributes.join(', ')})</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<CardCast
|
||||
key={member.id}
|
||||
link={`/name/${member.id}`}
|
||||
name={member.name}
|
||||
image={member.image}
|
||||
characters={member.characters}
|
||||
attributes={member.attributes}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cast;
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import Link from 'next/link';
|
||||
import { Fragment } from 'react';
|
||||
import { DidYouKnow } from '../../interfaces/shared/title';
|
||||
import styles from '../../styles/modules/components/title/did-you-know.module.scss';
|
||||
import { DidYouKnow } from 'src/interfaces/shared/title';
|
||||
import styles from 'src/styles/modules/components/title/did-you-know.module.scss';
|
||||
|
||||
type Props = {
|
||||
data: DidYouKnow;
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Info } from '../../interfaces/shared/title';
|
||||
import { formatMoney, formatTime } from '../../utils/helpers';
|
||||
|
||||
import styles from '../../styles/modules/components/title/info.module.scss';
|
||||
import { Info } from 'src/interfaces/shared/title';
|
||||
import { formatMoney, formatTime } from 'src/utils/helpers';
|
||||
import styles from 'src/styles/modules/components/title/info.module.scss';
|
||||
|
||||
type Props = {
|
||||
info: Info;
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import Image from 'next/future/image';
|
||||
import Link from 'next/link';
|
||||
import { MoreLikeThis } from '../../interfaces/shared/title';
|
||||
import { formatNumber, modifyIMDbImg } from '../../utils/helpers';
|
||||
import styles from '../../styles/modules/components/title/more-like-this.module.scss';
|
||||
import { CardTitle } from 'src/components/card';
|
||||
import { MoreLikeThis } from 'src/interfaces/shared/title';
|
||||
import styles from 'src/styles/modules/components/title/more-like-this.module.scss';
|
||||
|
||||
type Props = {
|
||||
className: string;
|
||||
|
@ -10,52 +8,22 @@ type Props = {
|
|||
};
|
||||
|
||||
const MoreLikeThis = ({ className, data }: Props) => {
|
||||
if (!data.length) return <></>;
|
||||
if (!data.length) return null;
|
||||
|
||||
return (
|
||||
<section className={`${className} ${styles.morelikethis}`}>
|
||||
<h2 className='heading heading__secondary'>More like this</h2>
|
||||
<ul className={styles.container}>
|
||||
{data.map(title => (
|
||||
<li key={title.id}>
|
||||
<Link href={`/title/${title.id}`}>
|
||||
<a className={styles.item}>
|
||||
<div className={styles.item__imgContainer}>
|
||||
{title.poster ? (
|
||||
<Image
|
||||
src={modifyIMDbImg(title.poster.url, 400)}
|
||||
alt=''
|
||||
fill
|
||||
className={styles.item__img}
|
||||
sizes='200px'
|
||||
/>
|
||||
) : (
|
||||
<svg className={styles.item__imgNA}>
|
||||
<use href='/svg/sprite.svg#icon-image-slash' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.item__textContainer}>
|
||||
<h3 className={`heading ${styles.item__heading}`}>
|
||||
{title.title}
|
||||
</h3>
|
||||
{title.ratings.avg && (
|
||||
<p className={styles.item__rating}>
|
||||
<span className={styles.item__ratingNum}>
|
||||
{title.ratings.avg}
|
||||
</span>
|
||||
<svg className={styles.item__ratingIcon}>
|
||||
<use href='/svg/sprite.svg#icon-rating'></use>
|
||||
</svg>
|
||||
<span>
|
||||
({formatNumber(title.ratings.numVotes)} votes)
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<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>
|
||||
</section>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Reviews } from '../../interfaces/shared/title';
|
||||
import { formatNumber } from '../../utils/helpers';
|
||||
import styles from '../../styles/modules/components/title/reviews.module.scss';
|
||||
import { Reviews } 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;
|
||||
|
|
6
src/components/title/index.ts
Normal file
6
src/components/title/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export { default as Basic } from './Basic';
|
||||
export { default as Cast } from './Cast';
|
||||
export { default as DidYouKnow } from './DidYouKnow';
|
||||
export { default as Info } from './Info';
|
||||
export { default as MoreLikeThis } from './MoreLikeThis';
|
||||
export { default as Reviews } from './Reviews';
|
|
@ -1,9 +0,0 @@
|
|||
import Basic from './Basic';
|
||||
import Cast from './Cast';
|
||||
import DidYouKnow from './DidYouKnow';
|
||||
import Info from './Info';
|
||||
import Media from './Media';
|
||||
import MoreLikeThis from './MoreLikeThis';
|
||||
import Reviews from './Reviews';
|
||||
|
||||
export { Basic, Cast, DidYouKnow, Info, Media, MoreLikeThis, Reviews };
|
|
@ -1,13 +1,13 @@
|
|||
import React, { useState, createContext, ReactNode } from 'react';
|
||||
import { isLocalStorageAvailable } from '../utils/helpers';
|
||||
import { isLocalStorageAvailable } from 'src/utils/helpers';
|
||||
|
||||
const getInitialTheme = () => {
|
||||
// for server-side rendering, as window isn't availabe there
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
|
||||
const userPrefersTheme = isLocalStorageAvailable()
|
||||
? window.localStorage.getItem('theme')
|
||||
: null;
|
||||
const userPrefersTheme = (
|
||||
isLocalStorageAvailable() ? window.localStorage.getItem('theme') : null
|
||||
) as 'light' | 'dark' | null;
|
||||
const browserPrefersDarkTheme = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
).matches;
|
||||
|
@ -28,7 +28,7 @@ const updateMetaTheme = () => {
|
|||
|
||||
const initialContext = {
|
||||
theme: '',
|
||||
setTheme: (theme: string) => {},
|
||||
setTheme: (theme: ReturnType<typeof getInitialTheme>) => { },
|
||||
};
|
||||
|
||||
export const themeContext = createContext(initialContext);
|
||||
|
@ -36,7 +36,7 @@ export const themeContext = createContext(initialContext);
|
|||
const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [curTheme, setCurTheme] = useState(getInitialTheme);
|
||||
|
||||
const setTheme = (theme: string) => {
|
||||
const setTheme = (theme: typeof curTheme) => {
|
||||
setCurTheme(theme);
|
||||
if (isLocalStorageAvailable()) window.localStorage.setItem('theme', theme);
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { ResultMetaTitleTypes, ResultMetaTypes } from '../shared/search';
|
||||
import {
|
||||
ResultMetaTitleTypes,
|
||||
ResultMetaTypes,
|
||||
} from 'src/interfaces/shared/search';
|
||||
|
||||
export default interface RawFind {
|
||||
props: {
|
||||
|
|
1092
src/interfaces/misc/rawName.ts
Normal file
1092
src/interfaces/misc/rawName.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -125,7 +125,7 @@ export default interface RawTitle {
|
|||
runtime: {
|
||||
value: number;
|
||||
};
|
||||
description: {
|
||||
description?: {
|
||||
value: string;
|
||||
language: string;
|
||||
};
|
||||
|
@ -516,9 +516,11 @@ export default interface RawTitle {
|
|||
canRate: {
|
||||
isRatable: boolean;
|
||||
};
|
||||
titleCardGenres: {
|
||||
titleGenres: {
|
||||
genres: Array<{
|
||||
text: string;
|
||||
genre: {
|
||||
text: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
canHaveEpisodes: boolean;
|
||||
|
|
|
@ -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'>;
|
||||
|
|
6
src/interfaces/shared/index.ts
Normal file
6
src/interfaces/shared/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type Name from './name';
|
||||
|
||||
export type Media = Name['media']; // exactly the same in title and name
|
||||
|
||||
// forcefully makes array of individual elements of T, where t is any conditional type.
|
||||
export type ToArray<T> = T extends any ? T[] : never;
|
39
src/interfaces/shared/list.ts
Normal file
39
src/interfaces/shared/list.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import list from 'src/utils/fetchers/list';
|
||||
|
||||
// for full title
|
||||
type List = Awaited<ReturnType<typeof list>>;
|
||||
export type { List as default };
|
||||
|
||||
type DataTitle = {
|
||||
image: string | null;
|
||||
name: string;
|
||||
url: string | null;
|
||||
year: string;
|
||||
certificate: string;
|
||||
runtime: string;
|
||||
genre: string;
|
||||
plot: string;
|
||||
rating: string;
|
||||
metascore: string;
|
||||
otherInfo: string[][];
|
||||
};
|
||||
|
||||
type DataName = {
|
||||
image: string | null;
|
||||
name: string;
|
||||
url: string | null;
|
||||
job: string | null;
|
||||
knownFor: string | null;
|
||||
knownForLink: string | null;
|
||||
about: string;
|
||||
};
|
||||
|
||||
type DataImage = string;
|
||||
|
||||
export type DataKind = 'images' | 'titles' | 'names';
|
||||
|
||||
export type Data<T extends DataKind> = T extends 'images'
|
||||
? DataImage
|
||||
: T extends 'names'
|
||||
? DataName
|
||||
: DataTitle;
|
16
src/interfaces/shared/name.ts
Normal file
16
src/interfaces/shared/name.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import cleanName from 'src/utils/cleaners/name';
|
||||
|
||||
type Name = ReturnType<typeof cleanName>;
|
||||
export type { Name as default };
|
||||
|
||||
export type Basic = Name['basic'];
|
||||
|
||||
export type Media = Name['media'];
|
||||
|
||||
export type Credits = Name['credits'];
|
||||
|
||||
export type DidYouKnow = Name['didYouKnow'];
|
||||
|
||||
export type PersonalDetails = Name['personalDetails'];
|
||||
|
||||
export type KnownFor = Name['knownFor'];
|
|
@ -1,5 +1,5 @@
|
|||
import cleanFind from '../../utils/cleaners/find';
|
||||
import { resultTitleTypes, resultTypes } from '../../utils/constants/find';
|
||||
import cleanFind from 'src/utils/cleaners/find';
|
||||
import { resultTitleTypes, resultTypes } from 'src/utils/constants/find';
|
||||
|
||||
type BasicSearch = ReturnType<typeof cleanFind>;
|
||||
export type { BasicSearch as default };
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import cleanTitle from '../../utils/cleaners/title';
|
||||
import title from '../../utils/fetchers/title';
|
||||
|
||||
export type AxiosTitleRes = Awaited<ReturnType<typeof title>>;
|
||||
import cleanTitle from 'src/utils/cleaners/title';
|
||||
import title from 'src/utils/fetchers/title';
|
||||
|
||||
// for full title
|
||||
type Title = ReturnType<typeof cleanTitle>;
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
import { FC } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import styles from '../styles/modules/layout/footer.module.scss';
|
||||
|
||||
const Footer: FC = () => {
|
||||
const { pathname } = useRouter();
|
||||
const className = (link: string) =>
|
||||
pathname === link ? styles.nav__linkActive : styles.nav__link;
|
||||
|
||||
return (
|
||||
<footer id='footer' className={styles.footer}>
|
||||
<nav aria-label='primary navigation' className={styles.nav}>
|
||||
<ul className={styles.list}>
|
||||
<li className={styles.nav__item}>
|
||||
<Link href='/about'>
|
||||
<a className={className('/about')}>About</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.nav__item}>
|
||||
<Link href='/find'>
|
||||
<a className={className('/find')}>Search</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.nav__item}>
|
||||
<Link href='/privacy'>
|
||||
<a className={className('/privacy')}>Privacy</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.nav__item}>
|
||||
<Link href='/contact'>
|
||||
<a className={className('/contact')}>Contact</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.nav__item}>
|
||||
<a href='#' className={styles.nav__link}>
|
||||
Back to top
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<p className={styles.licence}>
|
||||
Licensed under
|
||||
<a
|
||||
className={styles.nav__link}
|
||||
href='https://www.gnu.org/licenses/agpl-3.0-standalone.html'
|
||||
>
|
||||
GNU AGPLv3
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
|
@ -1,4 +1,4 @@
|
|||
import ErrorInfo from '../components/error/ErrorInfo';
|
||||
import ErrorInfo from 'src/components/error/ErrorInfo';
|
||||
|
||||
const Error404 = () => {
|
||||
return <ErrorInfo message='Not found, sorry.' statusCode={404} />;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import ErrorInfo from '../components/error/ErrorInfo';
|
||||
import ErrorInfo from 'src/components/error/ErrorInfo';
|
||||
|
||||
const Error500 = () => {
|
||||
return <ErrorInfo message='Server messed up, sorry.' statusCode={500} />;
|
||||
|
|
25
src/pages/[...error]/index.tsx
Normal file
25
src/pages/[...error]/index.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import ErrorInfo from 'src/components/error/ErrorInfo';
|
||||
|
||||
const error = {
|
||||
statusCode: 404,
|
||||
message: 'Not found, sorry.',
|
||||
} as const;
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const Error404 = ({ originalPath }: Props) => {
|
||||
return <ErrorInfo {...error} originalPath={originalPath} />;
|
||||
};
|
||||
|
||||
export default Error404;
|
||||
|
||||
type Data = { originalPath: string };
|
||||
type Params = { error: string[] };
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Data, Params> = async ctx => {
|
||||
ctx.res.statusCode = error.statusCode;
|
||||
ctx.res.statusMessage = error.message;
|
||||
|
||||
return { props: { originalPath: ctx.resolvedUrl } };
|
||||
};
|
|
@ -1,10 +1,9 @@
|
|||
import type { AppProps } from 'next/app';
|
||||
import usePageLoading from '../hooks/usePageLoading';
|
||||
import ProgressBar from '../components/loaders/ProgressBar';
|
||||
import ErrorBoundary from '../components/error/ErrorBoundary';
|
||||
import ThemeProvider from '../context/theme-context';
|
||||
|
||||
import '../styles/main.scss';
|
||||
import { AppProps } from 'next/app';
|
||||
import ProgressBar from 'src/components/loaders/ProgressBar';
|
||||
import ErrorBoundary from 'src/components/error/ErrorBoundary';
|
||||
import ThemeProvider from 'src/context/theme-context';
|
||||
import usePageLoading from 'src/hooks/usePageLoading';
|
||||
import 'src/styles/main.scss';
|
||||
|
||||
const ModifiedApp = ({ Component, pageProps }: AppProps) => {
|
||||
const { isPageLoading, key } = usePageLoading();
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
/* eslint-disable react/no-unescaped-entities */
|
||||
import Link from 'next/link';
|
||||
import Meta from '../../components/meta/Meta';
|
||||
import Layout from '../../layouts/Layout';
|
||||
|
||||
import styles from '../../styles/modules/pages/about/about.module.scss';
|
||||
import Meta from 'src/components/meta/Meta';
|
||||
import Layout from 'src/components/layout';
|
||||
import styles from 'src/styles/modules/pages/about/about.module.scss';
|
||||
|
||||
const About = () => {
|
||||
return (
|
||||
|
|
7
src/pages/api/[...error].ts
Normal file
7
src/pages/api/[...error].ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
type ResponseData = { status: false; message: string };
|
||||
|
||||
export default async function handler(_: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
res.status(400).json({ status: false, message: 'Not found' });
|
||||
}
|
32
src/pages/api/find.ts
Normal file
32
src/pages/api/find.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import Find, { type FindQueryParams } from 'src/interfaces/shared/search';
|
||||
import basicSearch from 'src/utils/fetchers/basicSearch';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import { findKey } from 'src/utils/constants/keys';
|
||||
import { AppError, cleanQueryStr } from 'src/utils/helpers';
|
||||
|
||||
type ResponseData =
|
||||
| { status: true; data: { title: null | string; results: null | Find } }
|
||||
| { status: false; message: string };
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
try {
|
||||
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
|
||||
|
||||
const queryObj = req.query as FindQueryParams | Record<string, never>;
|
||||
const query = queryObj.q?.trim();
|
||||
|
||||
if (!query) {
|
||||
return res.status(200).json({ status: true, data: { title: null, results: null } });
|
||||
}
|
||||
|
||||
const entries = Object.entries(queryObj);
|
||||
const queryStr = cleanQueryStr(entries);
|
||||
const results = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr);
|
||||
|
||||
res.status(200).json({ status: true, data: { title: query, results } });
|
||||
} catch (error: any) {
|
||||
const { message = 'Not found', statusCode = 404 } = error;
|
||||
res.status(statusCode).json({ status: false, message });
|
||||
}
|
||||
}
|
23
src/pages/api/list/[listId].ts
Normal file
23
src/pages/api/list/[listId].ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type List from 'src/interfaces/shared/list';
|
||||
import list from 'src/utils/fetchers/list';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import { listKey } from 'src/utils/constants/keys';
|
||||
import { AppError } from 'src/utils/helpers';
|
||||
|
||||
type ResponseData = { status: true; data: List } | { status: false; message: string };
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
try {
|
||||
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
|
||||
|
||||
const listId = req.query.listId as string;
|
||||
const pageNum = req.query.page as string | undefined;
|
||||
|
||||
const data = await getOrSetApiCache(listKey(listId, pageNum), list, listId, pageNum);
|
||||
res.status(200).json({ status: true, data });
|
||||
} catch (error: any) {
|
||||
const { message = 'Not found', statusCode = 404 } = error;
|
||||
res.status(statusCode).json({ status: false, message });
|
||||
}
|
||||
}
|
|
@ -1,30 +1,32 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import redis from '../../utils/redis';
|
||||
import axiosInstance from '../../utils/axiosInstance';
|
||||
import { AxiosRequestHeaders } from 'axios';
|
||||
import redis from 'src/utils/redis';
|
||||
import axiosInstance from 'src/utils/axiosInstance';
|
||||
import { mediaKey } from 'src/utils/constants/keys';
|
||||
|
||||
const getCleanReqHeaders = (headers: NextApiRequest['headers']) => ({
|
||||
...(headers.accept && { accept: headers.accept }),
|
||||
...(headers.range && { range: headers.range }),
|
||||
...(headers['accept-encoding'] && {
|
||||
'accept-encoding': headers['accept-encoding'] as string,
|
||||
}),
|
||||
});
|
||||
const dontCacheMedia =
|
||||
process.env.USE_REDIS_FOR_API_ONLY === 'true' || process.env.USE_REDIS !== 'true';
|
||||
|
||||
const resHeadersArr = [
|
||||
'content-range',
|
||||
'content-length',
|
||||
'content-type',
|
||||
'accept-ranges',
|
||||
];
|
||||
const ttl = process.env.REDIS_CACHE_TTL_MEDIA ?? 30 * 60;
|
||||
|
||||
const getCleanReqHeaders = (headers: NextApiRequest['headers']) => {
|
||||
const cleanHeaders: AxiosRequestHeaders = {};
|
||||
|
||||
if (headers.accept) cleanHeaders.accept = headers.accept;
|
||||
if (headers.range) cleanHeaders.range = headers.range;
|
||||
if (headers['accept-encoding'])
|
||||
cleanHeaders['accept-encoding'] = headers['accept-encoding'].toString();
|
||||
|
||||
return cleanHeaders;
|
||||
};
|
||||
|
||||
const resHeadersArr = ['content-range', 'content-length', 'content-type', 'accept-ranges'];
|
||||
|
||||
// checks if a url is pointing towards a video/image from imdb
|
||||
const regex =
|
||||
/^https:\/\/((m\.)?media-amazon\.com|imdb-video\.media-imdb\.com).*\.(jpg|jpeg|png|mp4|gif|webp).*$/;
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const mediaUrl = req.query.url as string | undefined;
|
||||
const requestHeaders = getCleanReqHeaders(req.headers);
|
||||
|
@ -36,8 +38,8 @@ export default async function handler(
|
|||
message: 'Invalid query',
|
||||
});
|
||||
|
||||
// 2. sending streamed response if redis isn't enabled
|
||||
if (redis === null) {
|
||||
// 2. sending streamed response if redis, or redis for media isn't enabled
|
||||
if (dontCacheMedia) {
|
||||
const mediaRes = await axiosInstance.get(mediaUrl, {
|
||||
responseType: 'stream',
|
||||
headers: requestHeaders,
|
||||
|
@ -54,23 +56,21 @@ export default async function handler(
|
|||
}
|
||||
|
||||
// 3. else if resourced is cached, sending it
|
||||
const cachedMedia = await redis!.getBuffer(mediaUrl);
|
||||
const cachedMedia = await redis.getBuffer(mediaKey(mediaUrl));
|
||||
|
||||
if (cachedMedia) {
|
||||
res.setHeader('x-cached', 'true');
|
||||
res.status(302).send(cachedMedia);
|
||||
res.send(cachedMedia);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. else getting, caching and sending response
|
||||
const mediaRes = await axiosInstance(mediaUrl, {
|
||||
const { data } = await axiosInstance(mediaUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
const { data } = mediaRes;
|
||||
|
||||
// saving in redis for 30 minutes
|
||||
await redis!.setex(mediaUrl, 30 * 60, Buffer.from(data));
|
||||
await redis.setex(mediaKey(mediaUrl), ttl, Buffer.from(data));
|
||||
|
||||
// sending media
|
||||
res.setHeader('x-cached', 'false');
|
||||
|
|
22
src/pages/api/name/[nameId].ts
Normal file
22
src/pages/api/name/[nameId].ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type Name from 'src/interfaces/shared/name';
|
||||
import name from 'src/utils/fetchers/name';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import { nameKey } from 'src/utils/constants/keys';
|
||||
import { AppError } from 'src/utils/helpers';
|
||||
|
||||
type ResponseData = { status: true; data: Name } | { status: false; message: string };
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
try {
|
||||
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
|
||||
|
||||
const nameId = req.query.nameId as string;
|
||||
|
||||
const data = await getOrSetApiCache(nameKey(nameId), name, nameId);
|
||||
res.status(200).json({ status: true, data });
|
||||
} catch (error: any) {
|
||||
const { message = 'Not found', statusCode = 404 } = error;
|
||||
res.status(statusCode).json({ status: false, message });
|
||||
}
|
||||
}
|
21
src/pages/api/title/[titleId].ts
Normal file
21
src/pages/api/title/[titleId].ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type Title from 'src/interfaces/shared/title';
|
||||
import title from 'src/utils/fetchers/title';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import { titleKey } from 'src/utils/constants/keys';
|
||||
import { AppError } from 'src/utils/helpers';
|
||||
|
||||
type ResponseData = { status: true; data: Title } | { status: false; message: string };
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
try {
|
||||
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
|
||||
|
||||
const titleId = req.query.titleId as string;
|
||||
const data = await getOrSetApiCache(titleKey(titleId), title, titleId);
|
||||
res.status(200).json({ status: true, data });
|
||||
} catch (error: any) {
|
||||
const { message = 'Not found', statusCode = 404 } = error;
|
||||
res.status(statusCode).json({ status: false, message });
|
||||
}
|
||||
}
|
|
@ -1,44 +1,63 @@
|
|||
import Meta from '../../components/meta/Meta';
|
||||
import Layout from '../../layouts/Layout';
|
||||
|
||||
import styles from '../../styles/modules/pages/contact/contact.module.scss';
|
||||
import Meta from 'src/components/meta/Meta';
|
||||
import Layout from 'src/components/layout';
|
||||
import styles from 'src/styles/modules/pages/contact/contact.module.scss';
|
||||
|
||||
const Contact = () => {
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title="Contact"
|
||||
description="Contact page of libremdb, a free & open source IMDb front-end."
|
||||
title='Contact'
|
||||
description='Contact page of libremdb, a free & open source IMDb front-end.'
|
||||
/>
|
||||
<Layout className="">
|
||||
<Layout className=''>
|
||||
<section className={styles.contact}>
|
||||
<h1 className={`heading heading__primary ${styles.contact__heading}`}>
|
||||
Contact
|
||||
</h1>
|
||||
|
||||
<div className={styles.list}>
|
||||
<p className={styles.item}>
|
||||
You can use{' '}
|
||||
<a href="https://github.com/zyachel/libremdb" className="link">
|
||||
GitHub
|
||||
</a>{' '}
|
||||
or{' '}
|
||||
<a href="https://codeberg.org/zyachel/libremdb" className="link">
|
||||
Codeberg
|
||||
</a>{' '}
|
||||
for general issues, questions, or requests.
|
||||
</p>
|
||||
<p className={styles.item}>
|
||||
In case you wish to contact me personally, I'm reachable via{' '}
|
||||
<a className="link" href="https://matrix.to/#/@ninal:matrix.org">
|
||||
[matrix]
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a className="link" href="mailto:aricla@protonmail.com">
|
||||
email
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className={styles.item}>
|
||||
<p className={styles.item__text}>
|
||||
For any issues, questions, bugs, or requests regarding the
|
||||
service, you can go to{' '}
|
||||
<a href='https://github.com/zyachel/libremdb' className='link'>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className={styles.item__text}>
|
||||
Alternatively, you can visit{' '}
|
||||
<a
|
||||
href='https://codeberg.org/zyachel/libremdb'
|
||||
className='link'
|
||||
>
|
||||
the repository on Codeberg
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
{process.env.NEXT_PUBLIC_INSTANCE_MAIN_URL && (
|
||||
<div className={styles.item}>
|
||||
<p className={styles.item__text}>
|
||||
If you have some questions related to this instance,{' '}
|
||||
<a
|
||||
href={process.env.NEXT_PUBLIC_INSTANCE_MAIN_URL}
|
||||
className='link'
|
||||
>
|
||||
contact instance maintainer(s)
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.item}>
|
||||
<p className={styles.item__text}>
|
||||
In case you wish to contact me(the dev) personally,{' '}
|
||||
<a href='https://iket.me/contact/' className='link'>
|
||||
here you go
|
||||
</a>
|
||||
<span aria-label='smily text emoji'> :)</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
|
|
@ -1,22 +1,18 @@
|
|||
import { GetServerSideProps } from 'next';
|
||||
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';
|
||||
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';
|
||||
|
||||
import Layout from '../../layouts/Layout';
|
||||
import ErrorInfo from '../../components/error/ErrorInfo';
|
||||
import Meta from '../../components/meta/Meta';
|
||||
import Results from '../../components/find';
|
||||
import basicSearch from '../../utils/fetchers/basicSearch';
|
||||
import Form from '../../components/forms/find';
|
||||
|
||||
import Find, { FindQueryParams } from '../../interfaces/shared/search';
|
||||
import { AppError } from '../../interfaces/shared/error';
|
||||
import { cleanQueryStr } from '../../utils/helpers';
|
||||
|
||||
import styles from '../../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',
|
||||
|
@ -25,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} />
|
||||
)}
|
||||
|
@ -43,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;
|
||||
|
@ -69,6 +75,7 @@ export const getServerSideProps: GetServerSideProps = async ctx => {
|
|||
props: {
|
||||
error: { message, statusCode },
|
||||
data: { title: query, results: null },
|
||||
originalPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
54
src/pages/list/[listId]/index.tsx
Normal file
54
src/pages/list/[listId]/index.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import Meta from 'src/components/meta/Meta';
|
||||
import Layout from 'src/components/layout';
|
||||
import ErrorInfo from 'src/components/error/ErrorInfo';
|
||||
import { Data, Meta as ListMeta, Pagination } from 'src/components/list';
|
||||
import { AppError } from 'src/interfaces/shared/error';
|
||||
import TList from 'src/interfaces/shared/list';
|
||||
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
|
||||
import list from 'src/utils/fetchers/list';
|
||||
import { listKey } from 'src/utils/constants/keys';
|
||||
import styles from 'src/styles/modules/pages/list/list.module.scss';
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const List = ({ data, error, originalPath }: Props) => {
|
||||
if (error) return <ErrorInfo {...error} originalPath={originalPath} />;
|
||||
|
||||
const description = data.description || `List created by ${data.meta.by.name} (${data.meta.num} ${data.meta.type}).`
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={data.title} description={description} />
|
||||
<Layout className={styles.list} originalPath={originalPath}>
|
||||
<ListMeta title={data.title} description={data.description} meta={data.meta} />
|
||||
{/* @ts-expect-error don't have time to fix it. just a type fluff. */}
|
||||
<Data data={data.data} />
|
||||
<Pagination pagination={data.pagination} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TData = ({ data: TList; error: null } | { error: AppError; data: null }) & {
|
||||
originalPath: string;
|
||||
};
|
||||
type Params = { listId: string };
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<TData, Params> = async ctx => {
|
||||
const listId = ctx.params!.listId;
|
||||
const pageNum = (ctx.query.page as string | undefined) ?? '1';
|
||||
const originalPath = ctx.resolvedUrl;
|
||||
try {
|
||||
const data = await getOrSetApiCache(listKey(listId, pageNum), list, listId, pageNum);
|
||||
|
||||
return { props: { data, error: null, originalPath } };
|
||||
} catch (error: any) {
|
||||
const { message = 'Internal server error', statusCode = 500 } = error;
|
||||
ctx.res.statusCode = statusCode;
|
||||
ctx.res.statusMessage = message;
|
||||
return { props: { error: { message, statusCode }, data: null, originalPath } };
|
||||
}
|
||||
};
|
||||
|
||||
export default List;
|
67
src/pages/name/[nameId]/index.tsx
Normal file
67
src/pages/name/[nameId]/index.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
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 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, originalPath }: Props) => {
|
||||
if (error) return <ErrorInfo {...error} originalPath={originalPath} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={data.basic.name}
|
||||
description={data.basic.bio.short + '...'}
|
||||
imgUrl={data.basic.poster?.url && getProxiedIMDbImgUrl(data.basic.poster.url)}
|
||||
/>
|
||||
<Layout className={styles.name} originalPath={originalPath}>
|
||||
<Basic data={data.basic} className={styles.basic} />
|
||||
<Media className={styles.media} media={data.media} />
|
||||
<div className={styles.textarea}>
|
||||
<KnownFor data={data.knownFor} />
|
||||
<Bio bio={data.basic.bio.full} />
|
||||
</div>
|
||||
<div className={styles.infoarea}>
|
||||
<Info info={data.personalDetails} accolades={data.accolades} />
|
||||
<DidYouKnow data={data.didYouKnow} />
|
||||
</div>
|
||||
<Credits className={styles.credits} data={data.credits} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 getOrSetApiCache(nameKey(nameId), name, nameId);
|
||||
|
||||
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, originalPath } };
|
||||
}
|
||||
};
|
||||
|
||||
export default NameInfo;
|
|
@ -1,14 +1,14 @@
|
|||
import Meta from '../../components/meta/Meta';
|
||||
import Layout from '../../layouts/Layout';
|
||||
|
||||
import styles from '../../styles/modules/pages/privacy/privacy.module.scss';
|
||||
import Meta from 'src/components/meta/Meta';
|
||||
import Layout from 'src/components/layout';
|
||||
import packageInfo from 'src/../package.json';
|
||||
import styles from 'src/styles/modules/pages/privacy/privacy.module.scss';
|
||||
|
||||
const Privacy = () => {
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title="Privacy"
|
||||
description="Privacy policy of libremdb, a free & open source IMDb front-end."
|
||||
title='Privacy'
|
||||
description='Privacy policy of libremdb, a free & open source IMDb front-end.'
|
||||
/>
|
||||
<Layout className={styles.privacy}>
|
||||
<section className={styles.policy}>
|
||||
|
@ -16,15 +16,15 @@ const Privacy = () => {
|
|||
Privacy Policy
|
||||
</h1>
|
||||
<div className={styles.list}>
|
||||
<div className={styles.item}>
|
||||
<section className={styles.item}>
|
||||
<h2
|
||||
className={`heading heading__secondary ${styles.item__heading}`}
|
||||
>
|
||||
Information collected
|
||||
</h2>
|
||||
<p className={styles.item__text}>No information is collected.</p>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
</section>
|
||||
<section className={styles.item}>
|
||||
<h2
|
||||
className={`heading heading__secondary ${styles.item__heading}`}
|
||||
>
|
||||
|
@ -40,12 +40,40 @@ const Privacy = () => {
|
|||
prefrences, either turn off JavaScript or disable access to
|
||||
Local Storage for libremdb.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.item}>
|
||||
<h2
|
||||
className={`heading heading__secondary ${styles.item__heading}`}
|
||||
>
|
||||
Instance information
|
||||
</h2>
|
||||
{process.env.NEXT_PUBLIC_INSTANCE_NAME &&
|
||||
process.env.NEXT_PUBLIC_INSTANCE_MAIN_URL && (
|
||||
<p className={styles.item__text}>
|
||||
Operated by:
|
||||
<a
|
||||
className='link'
|
||||
href={process.env.NEXT_PUBLIC_INSTANCE_MAIN_URL}
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_INSTANCE_NAME}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<p className={styles.item__text}>
|
||||
Version:
|
||||
<a
|
||||
className='link'
|
||||
href={`https://github.com/zyachel/libremdb/tree/v${packageInfo.version}`}
|
||||
>
|
||||
{packageInfo.version}
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className={styles.metadata}>
|
||||
<p>
|
||||
Last updated on <time>31 october, 2022.</time>
|
||||
Privacy policy last updated on <time>31 october, 2022.</time>
|
||||
</p>
|
||||
<p>
|
||||
You can see the full revision history of this privacy policy on
|
||||
|
|
|
@ -1,34 +1,22 @@
|
|||
// external
|
||||
import { GetServerSideProps, GetStaticProps, GetStaticPaths } from 'next'
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
// local
|
||||
import Meta from '../../../components/meta/Meta';
|
||||
import Layout from '../../../layouts/Layout';
|
||||
import ErrorInfo from '../../../components/error/ErrorInfo';
|
||||
import {
|
||||
Basic,
|
||||
Cast,
|
||||
DidYouKnow,
|
||||
Info,
|
||||
Media,
|
||||
MoreLikeThis,
|
||||
Reviews,
|
||||
} from '../../../components/title';
|
||||
// misc
|
||||
import Title from '../../../interfaces/shared/title';
|
||||
import { AppError } from '../../../interfaces/shared/error';
|
||||
import title from '../../../utils/fetchers/title';
|
||||
import { getProxiedIMDbImgUrl } from '../../../utils/helpers';
|
||||
// styles
|
||||
import styles from '../../../styles/modules/pages/title/title.module.scss';
|
||||
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 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 = { data: Title; error: null } | { error: AppError; data: null };
|
||||
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,
|
||||
|
@ -42,22 +30,11 @@ const TitleInfo = ({ data, error }: Props) => {
|
|||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={`${data.basic.title} (${
|
||||
data.basic.releaseYear?.start || data.basic.type.name
|
||||
})`}
|
||||
description={data.basic.plot || undefined}
|
||||
title={`${data.basic.title} (${data.basic.releaseYear?.start || data.basic.type.name})`}
|
||||
description={data.basic.plot ?? undefined}
|
||||
imgUrl={data.basic.poster?.url && getProxiedIMDbImgUrl(data.basic.poster.url)}
|
||||
/>
|
||||
<Head>
|
||||
<meta
|
||||
title="og:image"
|
||||
content={
|
||||
data.basic.poster?.url
|
||||
? getProxiedIMDbImgUrl(data.basic.poster?.url)
|
||||
: '/icon-512.png'
|
||||
}
|
||||
/>
|
||||
</Head>
|
||||
<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} />
|
||||
|
@ -73,23 +50,29 @@ const TitleInfo = ({ data, error }: Props) => {
|
|||
};
|
||||
|
||||
// TO-DO: make a getServerSideProps wrapper for handling errors
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const titleId = ctx.params!.titleId as string
|
||||
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
|
||||
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 } };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default TitleInfo
|
||||
export default TitleInfo;
|
||||
|
||||
// could've used getStaticProps instead of getServerSideProps, but meh.
|
||||
/*
|
||||
|
|
|
@ -21,8 +21,8 @@ $breakpoints: (
|
|||
);
|
||||
|
||||
// 1. colors
|
||||
$clr-primary: hsl(240, 31%, 25%);
|
||||
$clr-secondary: hsl(344, 79%, 40%);
|
||||
$clr-tertiary: hsl(176, 43%, 46%);
|
||||
$clr-quatenary: hsl(204, 4%, 23%);
|
||||
$clr-quintenary: hsl(0, 0%, 100%);
|
||||
// $clr-primary: hsl(240, 31%, 25%);
|
||||
// $clr-secondary: hsl(344, 79%, 40%);
|
||||
// $clr-tertiary: hsl(176, 43%, 46%);
|
||||
// $clr-quatenary: hsl(204, 4%, 23%);
|
||||
// $clr-quintenary: hsl(0, 0%, 100%);
|
||||
|
|
|
@ -22,17 +22,13 @@ $_light: (
|
|||
// 4.2 for borders, primarily
|
||||
fill-muted: hsl(0, 0%, 80%),
|
||||
// shadows on cards
|
||||
shadow: 0 0 1rem hsla(0, 0%, 0%, 0.2),
|
||||
shadow: 0 0 0.5em hsla(0, 0%, 0%, 0.2),
|
||||
// keyboard, focus hightlight
|
||||
highlight: hsl(176, 43%, 46%),
|
||||
// for gradient behind hero text on about page.
|
||||
gradient:
|
||||
(
|
||||
radial-gradient(
|
||||
at 23% 32%,
|
||||
hsla(344, 79%, 40%, 0.15) 0px,
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(at 23% 32%, hsla(344, 79%, 40%, 0.15) 0px, transparent 70%),
|
||||
radial-gradient(at 72% 55%, hsla(344, 79%, 40%, 0.2) 0px, transparent 50%)
|
||||
),
|
||||
// changes color of native html elemnts, either 'light' or 'dark' must be set.
|
||||
|
|
97
src/styles/modules/components/card/card-basic.module.scss
Normal file
97
src/styles/modules/components/card/card-basic.module.scss
Normal file
|
@ -0,0 +1,97 @@
|
|||
@use '../../../abstracts' as helper;
|
||||
|
||||
.container {
|
||||
margin-inline: auto;
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: minmax(25rem, 30rem) 1fr;
|
||||
|
||||
@include helper.bp('bp-900') {
|
||||
grid-template-columns: none;
|
||||
grid-template-rows: 30rem min-content;
|
||||
}
|
||||
|
||||
@include helper.bp('bp-700') {
|
||||
grid-template-rows: 25rem min-content;
|
||||
}
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
display: flex; // for bringing out image__NA out of blur
|
||||
|
||||
position: relative;
|
||||
height: auto;
|
||||
width: auto;
|
||||
overflow: hidden;
|
||||
|
||||
background-size: cover;
|
||||
background-position: top;
|
||||
place-items: center;
|
||||
|
||||
@include helper.bp('bp-900') {
|
||||
padding: var(--spacer-2);
|
||||
isolation: isolate;
|
||||
|
||||
// for adding layer of color on top of background image
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--clr-bg-accent) 10%,
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
|
||||
@include helper.bp('bp-900') {
|
||||
z-index: 1;
|
||||
object-fit: contain;
|
||||
|
||||
outline: 3px solid var(--clr-fill);
|
||||
outline-offset: 5px;
|
||||
|
||||
max-height: 100%;
|
||||
margin: auto;
|
||||
|
||||
// overrriding nex/future/image defaults
|
||||
height: initial !important;
|
||||
width: initial !important;
|
||||
position: relative !important;
|
||||
}
|
||||
}
|
||||
|
||||
.imageNA {
|
||||
z-index: 1;
|
||||
fill: var(--clr-fill-muted);
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: var(--spacer-2) var(--spacer-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacer-2);
|
||||
|
||||
@include helper.bp('bp-900') {
|
||||
// text-align: center;
|
||||
// align-items: center;
|
||||
}
|
||||
@include helper.bp('bp-450') {
|
||||
gap: var(--spacer-1);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
line-height: 1;
|
||||
|
||||
@include helper.bp('bp-900') {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
44
src/styles/modules/components/card/card-cast.module.scss
Normal file
44
src/styles/modules/components/card/card-cast.module.scss
Normal file
|
@ -0,0 +1,44 @@
|
|||
.item {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(auto, 65%) auto;
|
||||
|
||||
text-decoration: none;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
justify-self: stretch;
|
||||
position: relative;
|
||||
|
||||
// for icon when image is unavailable
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.imgNA {
|
||||
fill: var(--clr-fill-muted);
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
display: grid;
|
||||
gap: var(--spacer-1);
|
||||
padding: var(--spacer-1);
|
||||
text-align: center;
|
||||
justify-items: center;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.role {
|
||||
font-size: .95em;
|
||||
}
|
59
src/styles/modules/components/card/card-result.module.scss
Normal file
59
src/styles/modules/components/card/card-result.module.scss
Normal file
|
@ -0,0 +1,59 @@
|
|||
@use '../../../abstracts' as helper;
|
||||
|
||||
.item {
|
||||
--width: 10rem;
|
||||
--height: var(--width);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--width) auto;
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
--height: 15rem;
|
||||
grid-template-columns: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sansImage {
|
||||
grid-template-columns: auto;
|
||||
padding: var(--spacer-1);
|
||||
|
||||
.imgContainer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: var(--height);
|
||||
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;
|
||||
}
|
62
src/styles/modules/components/card/card-title.module.scss
Normal file
62
src/styles/modules/components/card/card-title.module.scss
Normal file
|
@ -0,0 +1,62 @@
|
|||
.item {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(auto, 65%) auto;
|
||||
|
||||
text-decoration: none;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
justify-self: stretch;
|
||||
position: relative;
|
||||
|
||||
// for icon when image is unavailable
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.imgNA {
|
||||
fill: var(--clr-fill-muted);
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
display: grid;
|
||||
gap: var(--spacer-1);
|
||||
padding: var(--spacer-1);
|
||||
text-align: center;
|
||||
justify-items: center;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.children {
|
||||
max-height: 7em; // firefox doesn't support lh yet.
|
||||
max-height: 7lh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacer-0);
|
||||
line-height: 1;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ratingIcon {
|
||||
--dim: 1em;
|
||||
height: var(--dim);
|
||||
width: var(--dim);
|
||||
fill: var(--clr-fill);
|
||||
}
|
11
src/styles/modules/components/card/card.module.scss
Normal file
11
src/styles/modules/components/card/card.module.scss
Normal file
|
@ -0,0 +1,11 @@
|
|||
.card {
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
background-color: var(--clr-bg-accent);
|
||||
box-shadow: var(--clr-shadow);
|
||||
}
|
||||
|
||||
.hoverable:hover,
|
||||
.hoverable:focus-within {
|
||||
background-color: var(--clr-bg-muted);
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
.company {
|
||||
background: var(--clr-bg-accent);
|
||||
box-shadow: var(--clr-shadow);
|
||||
border-radius: 5px;
|
||||
display: grid;
|
||||
padding: var(--spacer-3);
|
||||
gap: var(--spacer-0);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: var(--fs-4);
|
||||
text-decoration: none;
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
.keyword {
|
||||
background: var(--clr-bg-accent);
|
||||
box-shadow: var(--clr-shadow);
|
||||
border-radius: 5px;
|
||||
display: grid;
|
||||
padding: var(--spacer-3);
|
||||
gap: var(--spacer-0);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: var(--fs-4);
|
||||
text-decoration: none;
|
||||
}
|
|
@ -1,57 +1,4 @@
|
|||
@use '../../../abstracts' as helper;
|
||||
|
||||
.person {
|
||||
--width: 10rem;
|
||||
--height: var(--width);
|
||||
|
||||
background: var(--clr-bg-accent);
|
||||
box-shadow: var(--clr-shadow);
|
||||
border-radius: 5px;
|
||||
overflow: hidden; // for background image
|
||||
display: grid;
|
||||
grid-template-columns: var(--width) auto;
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
--height: 15rem;
|
||||
grid-template-columns: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: var(--height);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: var(--fs-4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.basicInfo, .seriesInfo {
|
||||
.basicInfo {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
|
@ -64,11 +11,3 @@
|
|||
font-size: var(--fs-5);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.stars {
|
||||
|
||||
span {
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
}
|
|
@ -10,7 +10,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.titles, .people, .companies, .keywords {
|
||||
.titles,
|
||||
.people,
|
||||
.companies,
|
||||
.keywords {
|
||||
display: grid;
|
||||
gap: var(--spacer-2);
|
||||
|
||||
|
@ -18,8 +21,5 @@
|
|||
padding: var(--spacer-2);
|
||||
display: grid;
|
||||
gap: var(--spacer-4);
|
||||
// justify-self: start;
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,57 +1,5 @@
|
|||
@use '../../../abstracts' as helper;
|
||||
|
||||
.title {
|
||||
--width: 10rem;
|
||||
--height: 10rem;
|
||||
|
||||
background: var(--clr-bg-accent);
|
||||
box-shadow: var(--clr-shadow);
|
||||
border-radius: 5px;
|
||||
overflow: hidden; // for background image
|
||||
display: grid;
|
||||
grid-template-columns: var(--width) auto;
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
--height: 15rem;
|
||||
grid-template-columns: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
min-height: var(--height);
|
||||
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.img {
|
||||
object-fit: cover;
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
object-position: center 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.imgNA {
|
||||
width: 80%;
|
||||
fill: var(--clr-fill-muted);
|
||||
}
|
||||
|
||||
.info {
|
||||
display: grid;
|
||||
gap: var(--spacer-0);
|
||||
padding: var(--spacer-3);
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
padding: var(--spacer-1);
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: var(--fs-4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.basicInfo,
|
||||
.seriesInfo {
|
||||
|
@ -59,7 +7,7 @@
|
|||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& * + ::before {
|
||||
& :not(:last-child)::after {
|
||||
content: '\00b7';
|
||||
padding-inline: var(--spacer-1);
|
||||
font-weight: 900;
|
||||
|
|
31
src/styles/modules/components/list/images.module.scss
Normal file
31
src/styles/modules/components/list/images.module.scss
Normal file
|
@ -0,0 +1,31 @@
|
|||
@use '../../../abstracts' as helper;
|
||||
|
||||
.container {
|
||||
--min-width: 22rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--min-width), 1fr));
|
||||
|
||||
gap: var(--spacer-1);
|
||||
|
||||
@include helper.bp('bp-900') {
|
||||
--min-width: 18rem;
|
||||
}
|
||||
@include helper.bp('bp-700') {
|
||||
--min-width: 15rem;
|
||||
}
|
||||
|
||||
@include helper.bp('bp-450') {
|
||||
--min-width: 12rem;
|
||||
}
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
position: relative;
|
||||
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.img {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
18
src/styles/modules/components/list/meta.module.scss
Normal file
18
src/styles/modules/components/list/meta.module.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
.container {
|
||||
display: grid;
|
||||
gap: var(--spacer-1);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& * + *::before {
|
||||
content: '\00b7';
|
||||
padding-inline: var(--spacer-0);
|
||||
font-weight: 900;
|
||||
line-height: 0;
|
||||
font-size: var(--fs-5);
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue