Compare commits
1 Commits
main
...
BetterLigh
Author | SHA1 | Date |
---|---|---|
DrMint | 9a8608a8e3 |
44
.env.example
|
@ -1,44 +0,0 @@
|
||||||
# /!\ For URLs, don't include the trailing '/'
|
|
||||||
|
|
||||||
# ┌─────────────────────┐
|
|
||||||
# │ PRIVATE VARIABLES │
|
|
||||||
# └─────────────────────┘
|
|
||||||
|
|
||||||
## STRAPI
|
|
||||||
|
|
||||||
URL_GRAPHQL=https://url-to.strapi-accords-library.com/graphql
|
|
||||||
ACCESS_TOKEN=abcdef0123456789
|
|
||||||
REVALIDATION_TOKEN=abcdef0123456789
|
|
||||||
|
|
||||||
## MAILING
|
|
||||||
|
|
||||||
SMTP_HOST=email.provider.com
|
|
||||||
SMTP_USER=email@example.com
|
|
||||||
SMTP_PASSWORD=mypassword123
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ┌────────────────────┐
|
|
||||||
# │ PUBLIC VARIABLES │
|
|
||||||
# └────────────────────┘
|
|
||||||
|
|
||||||
## ASSETS
|
|
||||||
|
|
||||||
NEXT_PUBLIC_URL_CMS=https://url-to.strapi-accords-library.com
|
|
||||||
NEXT_PUBLIC_URL_IMG=https://url-to.img-accords-library.com
|
|
||||||
NEXT_PUBLIC_URL_SELF=https://url-to.front-accords-library.com
|
|
||||||
NEXT_PUBLIC_URL_ASSETS=https://url-to.assets-accords-library.com
|
|
||||||
|
|
||||||
## MEILISEARCH
|
|
||||||
|
|
||||||
NEXT_PUBLIC_URL_MEILISEARCH=https://url-to.search-accords-library.com
|
|
||||||
NEXT_PUBLIC_MEILISEARCH_KEY=abcdef0123456789
|
|
||||||
|
|
||||||
## UMAMI
|
|
||||||
|
|
||||||
NEXT_PUBLIC_UMAMI_URL=https://url-to.umami-accords-library.com
|
|
||||||
NEXT_PUBLIC_UMAMI_ID=abcdef0123456789
|
|
||||||
|
|
||||||
## OCR.SPACE
|
|
||||||
|
|
||||||
NEXT_PUBLIC_API_OCR_KEY=abcdef0123456789
|
|
|
@ -1,12 +1,10 @@
|
||||||
src/graphql/generated.ts
|
src/graphql/generated.ts
|
||||||
src/graphql/icuParams.ts
|
|
||||||
src/shared
|
|
||||||
.eslintrc.js
|
.eslintrc.js
|
||||||
graphql-codegen.config.js
|
graphql-codegen.config.js
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
next-sitemap.config.js
|
next-sitemap.config.js
|
||||||
next.config.js
|
next.config.js
|
||||||
postcss.config.js
|
postcss.config.js
|
||||||
|
tailwind.config.js
|
||||||
design.config.js
|
design.config.js
|
||||||
graphql.config.js
|
graphql.config.js
|
||||||
prettier.config.js
|
|
22
.eslintrc.js
|
@ -46,7 +46,7 @@ module.exports = {
|
||||||
"func-style": ["warn", "expression"],
|
"func-style": ["warn", "expression"],
|
||||||
"grouped-accessor-pairs": "warn",
|
"grouped-accessor-pairs": "warn",
|
||||||
"guard-for-in": "warn",
|
"guard-for-in": "warn",
|
||||||
"id-denylist": ["error", "err", "e", "cb", "callback", "i"],
|
"id-denylist": ["error", "data", "err", "e", "cb", "callback", "i"],
|
||||||
// "id-length": "warn",
|
// "id-length": "warn",
|
||||||
"id-match": "warn",
|
"id-match": "warn",
|
||||||
"max-classes-per-file": ["error", 1],
|
"max-classes-per-file": ["error", 1],
|
||||||
|
@ -81,7 +81,7 @@ module.exports = {
|
||||||
// "no-magic-numbers": "warn",
|
// "no-magic-numbers": "warn",
|
||||||
// "no-mixed-operators": "warn",
|
// "no-mixed-operators": "warn",
|
||||||
"no-multi-assign": "warn",
|
"no-multi-assign": "warn",
|
||||||
// "no-multi-str": "warn",
|
"no-multi-str": "warn",
|
||||||
"no-negated-condition": "warn",
|
"no-negated-condition": "warn",
|
||||||
// "no-nested-ternary": "warn",
|
// "no-nested-ternary": "warn",
|
||||||
"no-new": "warn",
|
"no-new": "warn",
|
||||||
|
@ -149,18 +149,28 @@ module.exports = {
|
||||||
"@typescript-eslint/ban-tslint-comment": "warn",
|
"@typescript-eslint/ban-tslint-comment": "warn",
|
||||||
"@typescript-eslint/class-literal-property-style": "warn",
|
"@typescript-eslint/class-literal-property-style": "warn",
|
||||||
"@typescript-eslint/consistent-indexed-object-style": "warn",
|
"@typescript-eslint/consistent-indexed-object-style": "warn",
|
||||||
"@typescript-eslint/consistent-type-assertions": ["warn", { assertionStyle: "as" }],
|
"@typescript-eslint/consistent-type-assertions": [
|
||||||
|
"warn",
|
||||||
|
{ assertionStyle: "as" },
|
||||||
|
],
|
||||||
"@typescript-eslint/consistent-type-exports": "error",
|
"@typescript-eslint/consistent-type-exports": "error",
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "warn",
|
"@typescript-eslint/explicit-module-boundary-types": "warn",
|
||||||
"@typescript-eslint/method-signature-style": ["error", "property"],
|
"@typescript-eslint/method-signature-style": ["error", "property"],
|
||||||
"@typescript-eslint/no-base-to-string": "warn",
|
"@typescript-eslint/no-base-to-string": "warn",
|
||||||
"@typescript-eslint/no-confusing-non-null-assertion": "warn",
|
"@typescript-eslint/no-confusing-non-null-assertion": "warn",
|
||||||
"@typescript-eslint/no-confusing-void-expression": ["error", { ignoreArrowShorthand: true }],
|
"@typescript-eslint/no-confusing-void-expression": [
|
||||||
|
"error",
|
||||||
|
{ ignoreArrowShorthand: true },
|
||||||
|
],
|
||||||
"@typescript-eslint/no-dynamic-delete": "error",
|
"@typescript-eslint/no-dynamic-delete": "error",
|
||||||
"@typescript-eslint/no-empty-interface": ["error", { allowSingleExtends: true }],
|
"@typescript-eslint/no-empty-interface": [
|
||||||
|
"error",
|
||||||
|
{ allowSingleExtends: true },
|
||||||
|
],
|
||||||
"@typescript-eslint/no-invalid-void-type": "error",
|
"@typescript-eslint/no-invalid-void-type": "error",
|
||||||
"@typescript-eslint/no-meaningless-void-operator": "error",
|
"@typescript-eslint/no-meaningless-void-operator": "error",
|
||||||
"@typescript-eslint/no-non-null-asserted-nullish-coalescing": "error",
|
"@typescript-eslint/no-non-null-asserted-nullish-coalescing": "error",
|
||||||
|
"@typescript-eslint/no-parameter-properties": "error",
|
||||||
"@typescript-eslint/no-require-imports": "error",
|
"@typescript-eslint/no-require-imports": "error",
|
||||||
// "@typescript-eslint/no-type-alias": "warn",
|
// "@typescript-eslint/no-type-alias": "warn",
|
||||||
"@typescript-eslint/no-unnecessary-boolean-literal-compare": "warn",
|
"@typescript-eslint/no-unnecessary-boolean-literal-compare": "warn",
|
||||||
|
@ -181,6 +191,7 @@ module.exports = {
|
||||||
"@typescript-eslint/prefer-string-starts-ends-with": "error",
|
"@typescript-eslint/prefer-string-starts-ends-with": "error",
|
||||||
"@typescript-eslint/promise-function-async": "error",
|
"@typescript-eslint/promise-function-async": "error",
|
||||||
"@typescript-eslint/require-array-sort-compare": "error",
|
"@typescript-eslint/require-array-sort-compare": "error",
|
||||||
|
"@typescript-eslint/sort-type-union-intersection-members": "warn",
|
||||||
// "@typescript-eslint/strict-boolean-expressions": [
|
// "@typescript-eslint/strict-boolean-expressions": [
|
||||||
// "error",
|
// "error",
|
||||||
// { allowAny: true },
|
// { allowAny: true },
|
||||||
|
@ -190,6 +201,7 @@ module.exports = {
|
||||||
"@typescript-eslint/unified-signatures": "error",
|
"@typescript-eslint/unified-signatures": "error",
|
||||||
|
|
||||||
/* EXTENSION OF ESLINT */
|
/* EXTENSION OF ESLINT */
|
||||||
|
"@typescript-eslint/no-duplicate-imports": "error",
|
||||||
"@typescript-eslint/default-param-last": "warn",
|
"@typescript-eslint/default-param-last": "warn",
|
||||||
"@typescript-eslint/dot-notation": "warn",
|
"@typescript-eslint/dot-notation": "warn",
|
||||||
"@typescript-eslint/init-declarations": "warn",
|
"@typescript-eslint/init-declarations": "warn",
|
||||||
|
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
- run: npm ci --force
|
- run: npm ci
|
||||||
- run: npm run precommit
|
- run: npm run precommit
|
||||||
env:
|
env:
|
||||||
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
# Generated content
|
# Generated content
|
||||||
src/graphql/generated.ts
|
src/graphql/generated.ts
|
||||||
|
|
||||||
public/robots.txt
|
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
upgrade: true
|
|
||||||
interactive: true
|
|
||||||
format: "group"
|
|
||||||
reject:
|
|
||||||
- "react-hotkeys-hook" # we are stuck at version 3.4.7 because 4.X is not working well. Need more experimenting.
|
|
|
@ -1,2 +1 @@
|
||||||
.next
|
.next
|
||||||
public/local-data/*
|
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"css.lint.unknownAtRules": "ignore",
|
|
||||||
"editor.rulers": [100],
|
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
|
||||||
}
|
|
|
@ -77,9 +77,4 @@ interface ComponentProps {}
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const Component = () => {};
|
export const Component = () => {};
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
```
|
```
|
||||||
|
|
170
README.md
|
@ -4,141 +4,66 @@
|
||||||
[![GitHub](https://img.shields.io/github/license/Accords-Library/accords-library.com?style=flat-square)](https://github.com/Accords-Library/accords-library.com/blob/main/LICENSE)
|
[![GitHub](https://img.shields.io/github/license/Accords-Library/accords-library.com?style=flat-square)](https://github.com/Accords-Library/accords-library.com/blob/main/LICENSE)
|
||||||
![Libraries.io dependency status for GitHub repo](https://img.shields.io/librariesio/github/Accords-Library/accords-library.com?style=flat-square)
|
![Libraries.io dependency status for GitHub repo](https://img.shields.io/librariesio/github/Accords-Library/accords-library.com?style=flat-square)
|
||||||
|
|
||||||
## Introduction
|
|
||||||
|
|
||||||
Accord’s Library is a fan-site that aims at gathering and archiving all of Yoko Taro’s work.
|
|
||||||
Yoko Taro is a Japanese video game director and scenario writer. He is best-known for his work on the NieR and Drakengard (Drag-on Dragoon) franchises.
|
|
||||||
|
|
||||||
## Technologies
|
## Technologies
|
||||||
|
|
||||||
### Overview
|
#### [Back](https://github.com/Accords-Library/strapi.accords-library.com)
|
||||||
|
|
||||||
![](docs/project-mind-map.png)
|
- CMS: Stapi
|
||||||
|
- GraphQL endpoint
|
||||||
|
- Multilanguage support
|
||||||
|
- Markdown format for the rich text fields
|
||||||
|
- Use webhooks to notify the front-end and image processor of updates
|
||||||
|
|
||||||
_Purple connections are actions done at build-time only. Grey connections can be at build-time or run-time._
|
#### [Image Processor](https://github.com/Accords-Library/img.accords-library.com)
|
||||||
|
|
||||||
### [strapi.accords-library.com](https://github.com/Accords-Library/strapi.accords-library.com)
|
- Convert the images from the CMS to 4 formats
|
||||||
|
- Small: 512x512, quality 60, .webp
|
||||||
|
- Medium: 1024x1024, quality 75, .webp
|
||||||
|
- Large: 2048x2048, quality 80, .webp
|
||||||
|
- Og: 512x512, quality 60, .jpg
|
||||||
|
|
||||||
Our Content Management System (CMS) that uses [Strapi](https://strapi.io/).
|
#### [Front](https://github.com/Accords-Library/accords-library.com) (this repository)
|
||||||
|
|
||||||
- Use the official [GraphQL plugin](https://market.strapi.io/plugins/@strapi-plugin-graphql)
|
|
||||||
- Multilanguage support
|
|
||||||
- Markdown format for the rich text fields
|
|
||||||
- Use webhooks to notify the front-end, search engine, and image processor when new content/media has been created/modified/deleted
|
|
||||||
|
|
||||||
### [img.accords-library.com](https://github.com/Accords-Library/img.accords-library.com)
|
|
||||||
|
|
||||||
A custom made image processor to overcome the lack of customization offered by Strapi build-in image processor. There is a python script to bulk process all images uploaded to Strapi. Subsequent changes to Strapi's media library can be handled using webhooks. The repo includes a server that listen to these webhook calls, and another to serve the images.
|
|
||||||
|
|
||||||
Each image in Strapi's media library is converted to four different formats:
|
|
||||||
|
|
||||||
- Small: 512x512, quality 60, .webp
|
|
||||||
- Medium: 1024x1024, quality 75, .webp
|
|
||||||
- Large: 2048x2048, quality 80, .webp
|
|
||||||
- Og: 512x512, quality 60, .jpg
|
|
||||||
|
|
||||||
### [search.accords-library.com](https://github.com/Accords-Library/search.accords-library.com)
|
|
||||||
|
|
||||||
A search engine that uses [Meilisearch](https://www.meilisearch.com/).
|
|
||||||
The repo includes a docker-compose file to run an instance of Meilisearch. There is also a server that populates Meilisearch's documents at startup, then listen to webhooks sent by Strapi for subsequent changes.
|
|
||||||
|
|
||||||
### [gallery.accords-library.com](https://github.com/Accords-Library/gallery.accords-library.com)
|
|
||||||
|
|
||||||
An image board engine, uses [Szurubooru](https://github.com/rr-/szurubooru), a lighweight engine inspired by Danbooru (and Booru-type galleries in general). Unlike the other subdomains, this repo is completely separated from the rest of the stack.
|
|
||||||
|
|
||||||
### [watch.accords-library.com](https://github.com/Accords-Library/watch.accords-library.com)
|
|
||||||
|
|
||||||
A set of tools to archive videos on multiple platforms. The repo contains a CLI tool to archive YouTube videos. There is also a Python script which import the videos metadata to Strapi using GraphQL mutations. Finally, there's a server to serve the video files and thumbnail.
|
|
||||||
|
|
||||||
### [umami.accords-library.com](https://umami.is/)
|
|
||||||
|
|
||||||
An open-source self-hosted alternative to Google Analytics which doesn't require a cookie notice to be GDPR compliant.
|
|
||||||
|
|
||||||
### [accords-library.com](https://github.com/Accords-Library/accords-library.com) (this repository)
|
|
||||||
|
|
||||||
A detailled look at the technologies used in this repository:
|
|
||||||
|
|
||||||
- Language: [TypeScript](https://www.typescriptlang.org/)
|
- Language: [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
- Framework: [Next.js](https://nextjs.org/) (React)
|
||||||
- Framework: [Next.js 13](https://nextjs.org/) (React 18)
|
|
||||||
|
|
||||||
- SSG + ISR (Static Site Generation + Incremental Static Regeneration)
|
|
||||||
|
|
||||||
- The website is built before running in production
|
|
||||||
- Performances are great, and it's possible to deploy the app on a CDN
|
|
||||||
- On-Demand ISR to continuously update the website when new content is added or existing content is modified/deleted
|
|
||||||
- Some widely used data (e.g: UI localizations) are downloaded separetely into `public/local-data` as some form of request deduping + it make this data hot-swappable without the need to rebuild the entire website.
|
|
||||||
|
|
||||||
- Queries: [GraphQL Code Generator](https://www.graphql-code-generator.com/)
|
- Queries: [GraphQL Code Generator](https://www.graphql-code-generator.com/)
|
||||||
|
|
||||||
- Fetch the GraphQL schema from the GraphQL back-end endpoint
|
- Fetch the GraphQL schema from the GraphQL back-end endpoint
|
||||||
- Read the operations and fragments stored as graphql files in the `src/graphql` folder
|
- Read the operations and fragments stored as graphql files in the `src/graphql` folder
|
||||||
- Automatically generates a typesafe ready to use SDK using [graphql-request](https://www.npmjs.com/package/graphql-request) as the GraphQL client
|
- Automatically generates a typesafe ready to use SDK using [graphql-request](https://www.npmjs.com/package/graphql-request) as the GraphQL client
|
||||||
|
- Markdown: [markdown-to-jsx](https://www.npmjs.com/package/markdown-to-jsx)
|
||||||
- Styling: [Tailwind CSS](https://tailwindcss.com/)
|
- Support for Arbitrary React Components and Component Props!
|
||||||
|
|
||||||
- Support for creating any arbitrary theme by swapping CSS variables
|
|
||||||
- Support for Container Queries (media queries at the element level)
|
|
||||||
- The website has a three-column layout, which turns into one-column + 2 toggleable side-menus if the screen is too narrow.
|
|
||||||
- Check out our [Design System Showcase](https://accords-library.com/dev/showcase/design-system)
|
|
||||||
|
|
||||||
- State Management: [Jōtai](https://jotai.org/)
|
|
||||||
|
|
||||||
- Jōtai is a small-weighted library for atomic state management
|
|
||||||
- Persistent app state using LocalStorage and SessionStorage
|
|
||||||
|
|
||||||
- Markdown
|
|
||||||
|
|
||||||
- Use [Marked](https://www.npmjs.com/package/marked) to convert markdown to HTML (which is then sanitized using [DOMPurify](https://www.npmjs.com/package/isomorphic-dompurify))
|
|
||||||
- Support for arbitrary React Components and Component Props using [markdown-to-jsx](https://www.npmjs.com/package/markdown-to-jsx)
|
|
||||||
- Autogenerated multi-level table of content and anchor links for the different headers
|
- Autogenerated multi-level table of content and anchor links for the different headers
|
||||||
|
- Styling: [Tailwind CSS](https://tailwindcss.com/)
|
||||||
|
- Manually added support for scrollbar styling
|
||||||
|
- Support for [Material Icons](https://fonts.google.com/icons)
|
||||||
|
- Support for creating any arbitrary theming mode by swapping CSS variables
|
||||||
|
- Support for many screen sizes and resolutions
|
||||||
|
- State Management: [React Context](https://reactjs.org/docs/context.html)
|
||||||
|
- Persistent app state using LocalStorage
|
||||||
- Accessibility
|
- Accessibility
|
||||||
|
|
||||||
- Gestures using [react-swipeable](https://www.npmjs.com/package/react-swipeable)
|
- Gestures using [react-swipeable](https://www.npmjs.com/package/react-swipeable)
|
||||||
- Keyboard hotkeys using [react-hotkeys-hook](https://www.npmjs.com/package/react-hotkeys-hook)
|
- Keyboard hotkeys using [react-hot-keys](https://www.npmjs.com/package/react-hot-keys)
|
||||||
- Support for light and dark mode with a manual switch and system's selected theme by default
|
- Support for light and dark mode with a manual switch and system's selected theme by default
|
||||||
- Fonts can be swaped to [OpenDyslexic](https://www.npmjs.com/package/@fontsource/opendyslexic)
|
- Fonts can be swaped to [OpenDyslexic](https://www.npmjs.com/package/@fontsource/opendyslexic)
|
||||||
|
|
||||||
- Multilingual
|
- Multilingual
|
||||||
|
- By default, use the browser's language as the main language
|
||||||
- Users are given a list of supported languages. The first language in this list is the primary language (the language of the UI), the others are fallback languages. The others are fallback languages.
|
- Fallback languages are used for content which are not available in the main language
|
||||||
- By default, the list is ordered following the browser's languages (and most spoken languages woldwide for the remaining languages). The list can also be reordered manually.
|
- Main and fallback languages can be ordered manually by the user
|
||||||
- Contents can be available in any number of languages. By default, the best matching language will be presented to the user. However, the user can also decide to temporary select another language for a specific content, without affecting their list of preferred languages.
|
- At the content level, the user can know which language is available
|
||||||
|
- Furthermore, the user can temporary select another language then the one that was automatically selected
|
||||||
- UI Localizations
|
- SSG + ISR (Static Site Generation + Incremental Static Regeneration):
|
||||||
|
- The website is built before running in production
|
||||||
- The translated wordings use [ICU Message Format](https://unicode-org.github.io/icu/userguide/format_parse/messages/) to include variables, plural, dates...
|
- Performances are great, and possibility to deploy the app using a CDN
|
||||||
- Use a custom ICU Typescript transformation script to provide type safety when formatting ICU wordings
|
- On-Demand ISR to continuously update the website when new content is added or existing content is modified/deleted
|
||||||
- Fallback to English if the translation is missing.
|
|
||||||
|
|
||||||
- SEO
|
- SEO
|
||||||
|
- Good defaults for the metadate and OpenGraph properties
|
||||||
- Good defaults for the metadata and OpenGraph properties
|
- Each page can provide the thumbnail, title, description to be used
|
||||||
- Each page can provide a custom thumbnail, title, description to be used
|
|
||||||
- Automatic generation of the sitemap using [next-sitemap](https://www.npmjs.com/package/next-sitemap)
|
- Automatic generation of the sitemap using [next-sitemap](https://www.npmjs.com/package/next-sitemap)
|
||||||
|
- Data quality testing
|
||||||
- Data Quality Testing
|
|
||||||
|
|
||||||
- Data from the CMS is subject to a battery of tests (about 20 warning types and 40 error types) at build time
|
- Data from the CMS is subject to a battery of tests (about 20 warning types and 40 error types) at build time
|
||||||
- Each warning/error comes with a front-end link to the incriminating element, as well as a link to the CMS to fix it
|
- Each warning/error comes with a front-end link to the incriminating element, as well as a link to the CMS to fix it
|
||||||
- Check for completeness, conformity, and integrity
|
- Check for completeness, conformity, and integrity
|
||||||
|
|
||||||
- Code Quality and Style
|
|
||||||
|
|
||||||
- React Strict Mode
|
|
||||||
- [Eslint](https://www.npmjs.com/package/eslint) with [eslint-plugin-import](https://www.npmjs.com/package/eslint-plugin-import), [typescript-eslint](https://www.npmjs.com/package/@typescript-eslint/eslint-plugin)
|
|
||||||
- [Prettier](https://www.npmjs.com/package/prettier) with [prettier-plugin-tailwindcss](https://www.npmjs.com/package/prettier-plugin-tailwindcss)
|
|
||||||
- [ts-unused-exports](https://www.npmjs.com/package/ts-unused-exports) to find unused exported functions/constants...
|
|
||||||
|
|
||||||
- Other
|
|
||||||
|
|
||||||
- Custom book reader based on [Okuma-Reader](https://github.com/DrMint/Okuma-Reader)
|
|
||||||
- Support for [Material Symbols](https://fonts.google.com/icons)
|
|
||||||
- Custom lightbox using [react-zoom-pan-pinch](https://www.npmjs.com/package/react-zoom-pan-pinch)
|
|
||||||
- Handle query params type-validation using [Zod](https://zod.dev/)
|
|
||||||
- A secret "Terminal" mode. Can you find it?
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
@ -147,14 +72,29 @@ cd accords-library.com
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a env file based on the example one:
|
Create a env file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
|
||||||
nano .env.local
|
nano .env.local
|
||||||
```
|
```
|
||||||
|
|
||||||
Change the variables
|
Enter the followind information:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
URL_GRAPHQL=https://url-to.strapi-accords-library.com/graphql
|
||||||
|
ACCESS_TOKEN=abcdef0123456789
|
||||||
|
REVALIDATION_TOKEN=abcdef0123456789
|
||||||
|
SMTP_HOST=email.provider.com
|
||||||
|
SMTP_USER=email@example.com
|
||||||
|
SMTP_PASSWORD=mypassword123
|
||||||
|
NEXT_PUBLIC_URL_CMS=https://url-to.strapi-accords-library.com
|
||||||
|
NEXT_PUBLIC_URL_IMG=https://url-to.img-accords-library.com
|
||||||
|
NEXT_PUBLIC_URL_WATCH=https://url-to.watch-accords-library.com
|
||||||
|
NEXT_PUBLIC_URL_SELF=https://url-to-front-accords-library.com
|
||||||
|
NEXT_PUBLIC_URL_SEARCH=https://url-to.search-accords-library.com
|
||||||
|
NEXT_PUBLIC_UMAMI_URL=https://url-to.umami-accords-library.com
|
||||||
|
NEXT_PUBLIC_UMAMI_ID=abcdef0123456789
|
||||||
|
```
|
||||||
|
|
||||||
Run in dev mode:
|
Run in dev mode:
|
||||||
|
|
||||||
|
@ -165,6 +105,6 @@ Run in dev mode:
|
||||||
OR build and run in production mode
|
OR build and run in production mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
./run_accords_build.sh
|
||||||
./run_accords_prod.sh
|
./run_accords_prod.sh
|
||||||
```
|
```
|
||||||
|
|
|
@ -4,7 +4,7 @@ const colors = {
|
||||||
light: { r: 255, g: 237, b: 216 },
|
light: { r: 255, g: 237, b: 216 },
|
||||||
mid: { r: 240, g: 209, b: 179 },
|
mid: { r: 240, g: 209, b: 179 },
|
||||||
dark: { r: 156, g: 102, b: 68 },
|
dark: { r: 156, g: 102, b: 68 },
|
||||||
shade: { r: 192, g: 132, b: 94 },
|
shade: { r: 156, g: 102, b: 68 },
|
||||||
black: { r: 27, g: 24, b: 17 },
|
black: { r: 27, g: 24, b: 17 },
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
|
@ -12,11 +12,17 @@ const colors = {
|
||||||
light: { r: 38, g: 34, b: 30 },
|
light: { r: 38, g: 34, b: 30 },
|
||||||
mid: { r: 57, g: 45, b: 34 },
|
mid: { r: 57, g: 45, b: 34 },
|
||||||
dark: { r: 192, g: 132, b: 94 },
|
dark: { r: 192, g: 132, b: 94 },
|
||||||
shade: { r: 25, g: 25, b: 20 },
|
shade: { r: 0, g: 0, b: 0 },
|
||||||
black: { r: 235, g: 234, b: 231 },
|
black: { r: 235, g: 234, b: 231 },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const breaks = {
|
||||||
|
thin: { raw: "(max-width: 25rem)" },
|
||||||
|
mobile: { raw: "(max-width: 60rem)" },
|
||||||
|
desktop: { raw: "(min-width: 60rem)" },
|
||||||
|
};
|
||||||
|
|
||||||
const fonts = {
|
const fonts = {
|
||||||
openDyslexic: "OpenDyslexic",
|
openDyslexic: "OpenDyslexic",
|
||||||
vollkorn: "Vollkorn",
|
vollkorn: "Vollkorn",
|
||||||
|
@ -37,17 +43,9 @@ const fontFamilies = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const layout = {
|
|
||||||
// all values in rem
|
|
||||||
mainMenuReduced: 6,
|
|
||||||
mainMenu: 20,
|
|
||||||
subMenu: 20,
|
|
||||||
navbar: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
colors,
|
colors,
|
||||||
layout,
|
breaks,
|
||||||
fonts,
|
fonts,
|
||||||
fontFamilies,
|
fontFamilies,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,134 +0,0 @@
|
||||||
<?xml version="1.0"?>
|
|
||||||
<minder version="1.14.0" parent-etag="2844169042" etag="3777682473">
|
|
||||||
<theme name="dark" label="Dark" index="-1"/>
|
|
||||||
<styles>
|
|
||||||
<style level="0" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="rounded" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="10" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<style level="1" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<style level="2" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<style level="3" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<style level="4" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<style level="5" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<style level="6" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<style level="7" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<style level="8" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<style level="9" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<style level="10" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
</styles>
|
|
||||||
<drawarea x="-736.36765543619742" y="32.05864461263036" scale="1.5"/>
|
|
||||||
<images/>
|
|
||||||
<nodes>
|
|
||||||
<node id="0" posx="648.76816813151015" posy="501.13512166341127" width="181" height="56" side="left" fold="false" treesize="56" layout="Horizontal" group="false">
|
|
||||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
|
||||||
<nodename posx="667.76816813151015" posy="520.13512166341127" maxwidth="200">
|
|
||||||
<text data="accords-library.com"/>
|
|
||||||
</nodename>
|
|
||||||
<nodenote></nodenote>
|
|
||||||
</node>
|
|
||||||
<node id="1" posx="603.1911417643222" posy="198.59891764322904" width="228" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
|
|
||||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
|
||||||
<nodename posx="622.1911417643222" posy="217.59891764322904" maxwidth="200">
|
|
||||||
<text data="strapi.accords-library.com"/>
|
|
||||||
</nodename>
|
|
||||||
<nodenote></nodenote>
|
|
||||||
</node>
|
|
||||||
<node id="2" posx="508.77593994140574" posy="358.0493469238279" width="230" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
|
|
||||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
|
||||||
<nodename posx="527.7759399414058" posy="377.0493469238279" maxwidth="200">
|
|
||||||
<text data="watch.accords-library.com"/>
|
|
||||||
</nodename>
|
|
||||||
<nodenote></nodenote>
|
|
||||||
</node>
|
|
||||||
<node id="3" posx="959.78491210937489" posy="490.81981404622388" width="235" height="56" side="left" fold="false" treesize="56" layout="Horizontal" group="false">
|
|
||||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
|
||||||
<nodename posx="978.78491210937489" posy="509.81981404622388" maxwidth="200">
|
|
||||||
<text data="search.accords-library.com"/>
|
|
||||||
</nodename>
|
|
||||||
<nodenote></nodenote>
|
|
||||||
</node>
|
|
||||||
<node id="4" posx="300.46666463216098" posy="474.56320190429688" width="213" height="56" side="left" fold="false" treesize="56" layout="Horizontal" group="false">
|
|
||||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
|
||||||
<nodename posx="319.46666463216098" posy="493.56320190429688" maxwidth="200">
|
|
||||||
<text data="img.accords-library.com"/>
|
|
||||||
</nodename>
|
|
||||||
<nodenote></nodenote>
|
|
||||||
</node>
|
|
||||||
<node id="5" posx="753.05198160807242" posy="709.79446411132812" width="236" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
|
|
||||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
|
||||||
<nodename posx="772.05198160807242" posy="728.79446411132812" maxwidth="200">
|
|
||||||
<text data="umami.accords-library.com"/>
|
|
||||||
</nodename>
|
|
||||||
<nodenote></nodenote>
|
|
||||||
</node>
|
|
||||||
<node id="6" posx="468.00632731119703" posy="709.85610961914062" width="234" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
|
|
||||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
|
||||||
<nodename posx="487.00632731119703" posy="728.85610961914062" maxwidth="200">
|
|
||||||
<text data="gallery.accords-library.com"/>
|
|
||||||
</nodename>
|
|
||||||
<nodenote></nodenote>
|
|
||||||
</node>
|
|
||||||
</nodes>
|
|
||||||
<groups/>
|
|
||||||
<connections>
|
|
||||||
<connection from_id="3" to_id="1" drag_x="1038.6269124348951" drag_y="296.62844848632801" color="#813d9c">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<title>GraphQL queries</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="1" to_id="3" drag_x="982.0184326171875" drag_y="347.55404663085926">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<title>Webhook</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="2" to_id="1" drag_x="640.25118001302098" drag_y="308.37991333007801" color="#813d9c">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<title>GraphQL mutations</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="0" to_id="5" drag_x="801.02655029296898" drag_y="644.59735107421943">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<title>Sends events</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="4" to_id="0" drag_x="531.34985351562477" drag_y="582.42803955078148">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="150"/>
|
|
||||||
<title>Provides the images</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="3" to_id="0" drag_x="917.41172281901027" drag_y="585.41107177734443">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="150"/>
|
|
||||||
<title>Provides search results</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="0" to_id="1" drag_x="872.998291015625" drag_y="408.14896647135413">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<title>GraphQL queries</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="0" to_id="6" drag_x="664.23213704427053" drag_y="645.67006429036542">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<title>Links to</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="4" to_id="1" drag_x="400.83854166666663" drag_y="295.4715677897135" color="#813d9c">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<title>Python script</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="2" to_id="0" drag_x="645.96777343749955" drag_y="448.91068522135413">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="150"/>
|
|
||||||
<title>Provides the videos</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="1" to_id="4" drag_x="441.14660644531227" drag_y="342.38249715169252">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<title>Webhook</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
<connection from_id="1" to_id="0" drag_x="790.7373046875" drag_y="369.4803466796875">
|
|
||||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
|
||||||
<title>Webhook</title>
|
|
||||||
<note></note>
|
|
||||||
</connection>
|
|
||||||
</connections>
|
|
||||||
<stickers/>
|
|
||||||
</minder>
|
|
Before Width: | Height: | Size: 2.1 MiB |
|
@ -8,10 +8,17 @@ module.exports = {
|
||||||
headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` },
|
headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
documents: ["src/graphql/operations/**/*.graphql", "src/graphql/fragments/*.graphql"],
|
documents: [
|
||||||
|
"src/graphql/operations/*.graphql",
|
||||||
|
"src/graphql/fragments/*.graphql",
|
||||||
|
],
|
||||||
generates: {
|
generates: {
|
||||||
"src/graphql/generated.ts": {
|
"src/graphql/generated.ts": {
|
||||||
plugins: ["typescript", "typescript-operations", "typescript-graphql-request"],
|
plugins: [
|
||||||
|
"typescript",
|
||||||
|
"typescript-operations",
|
||||||
|
"typescript-graphql-request",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
module.exports = {
|
||||||
|
projects: {
|
||||||
|
app: {
|
||||||
|
schema: process.env.URL_GRAPHQL,
|
||||||
|
documents: [
|
||||||
|
"src/graphql/operations/*.graphql",
|
||||||
|
"src/graphql/fragments/*.graphql",
|
||||||
|
],
|
||||||
|
extensions: {
|
||||||
|
endpoints: {
|
||||||
|
default: {
|
||||||
|
url: process.env.URL_GRAPHQL,
|
||||||
|
headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -24,5 +24,14 @@ module.exports = {
|
||||||
hreflang: "ja",
|
hreflang: "ja",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exclude: ["/en/*", "/fr/*", "/ja/*", "/es/*", "/pt-br/*", "/404", "/500", "/dev/*"],
|
exclude: [
|
||||||
|
"/en/*",
|
||||||
|
"/fr/*",
|
||||||
|
"/ja/*",
|
||||||
|
"/es/*",
|
||||||
|
"/pt-br/*",
|
||||||
|
"/404",
|
||||||
|
"/500",
|
||||||
|
"/dev/*",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
/* CONFIG */
|
/* CONFIG */
|
||||||
|
|
||||||
const locales = ["en", "es", "fr", "pt-br", "ja", "zh"];
|
const locales = ["en", "es", "fr", "pt-br", "ja"];
|
||||||
|
|
||||||
/* END CONFIG */
|
/* END CONFIG */
|
||||||
|
|
||||||
/* @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
swcMinify: true,
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
poweredByHeader: false,
|
|
||||||
i18n: {
|
i18n: {
|
||||||
locales: locales,
|
locales: locales,
|
||||||
defaultLocale: "en",
|
defaultLocale: "en",
|
||||||
|
@ -15,6 +15,9 @@ module.exports = {
|
||||||
images: {
|
images: {
|
||||||
domains: ["img.accords-library.com", "watch.accords-library.com"],
|
domains: ["img.accords-library.com", "watch.accords-library.com"],
|
||||||
},
|
},
|
||||||
|
serverRuntimeConfig: {
|
||||||
|
locales: locales,
|
||||||
|
},
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
110
package.json
|
@ -2,14 +2,10 @@
|
||||||
"name": "accords-library.com",
|
"name": "accords-library.com",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "patch-package",
|
|
||||||
"dev": "next dev -p 12499",
|
"dev": "next dev -p 12499",
|
||||||
"precommit": "npm run fetch-local-data && npm run icu-to-ts && npm run unused-wording-keys && npm run unused-exports && npm run prettier && npm run eslint && npm run tsc && echo ALL PRECOMMIT CHECKS PASSED SUCCESSFULLY, LET\\'S FUCKING GO!",
|
"precommit": "npm run generate && npm run prettier && npm run unused-exports && npm run eslint && npm run tsc && echo ALL PRECOMMIT CHECKS PASSED SUCCESSFULLY, LET\\'S FUCKING GO!",
|
||||||
"unused-wording-keys": "esrun --send-code-mode=temporaryFile src/graphql/unusedWordingKeys.ts --uwk",
|
"unused-exports": "ts-unused-exports ./tsconfig.json --excludePathsFromReport=src/pages --ignoreFiles=generated",
|
||||||
"unused-exports": "ts-unused-exports ./tsconfig.json --excludePathsFromReport='src/pages;tailwind.config.ts;src/graphql/generated.ts;src/shared/meilisearch-graphql-typings/generated.ts'",
|
"prebuild": "npm run generate",
|
||||||
"fetch-local-data": "npm run generate && esrun --send-code-mode=temporaryFile src/graphql/fetchLocalData.ts --esrun",
|
|
||||||
"icu-to-ts": "esrun --send-code-mode=temporaryFile src/graphql/icuToTypescript.ts --icu",
|
|
||||||
"prebuild": "npm run fetch-local-data && npm run icu-to-ts",
|
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"postbuild": "next-sitemap --config next-sitemap.config.js",
|
"postbuild": "next-sitemap --config next-sitemap.config.js",
|
||||||
"start": "next start -p 12500",
|
"start": "next start -p 12500",
|
||||||
|
@ -17,75 +13,51 @@
|
||||||
"eslint": "npx eslint .",
|
"eslint": "npx eslint .",
|
||||||
"generate": "graphql-codegen --config graphql-codegen.config.js",
|
"generate": "graphql-codegen --config graphql-codegen.config.js",
|
||||||
"tsc": "tsc",
|
"tsc": "tsc",
|
||||||
"prettier": "prettier --list-different --end-of-line auto --write .",
|
"prettier": "prettier --end-of-line auto --write ."
|
||||||
"upgrade": "ncu"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/noto-serif-jp": "^5.0.7",
|
"@fontsource/material-icons": "^4.5.4",
|
||||||
"@fontsource/opendyslexic": "^5.0.7",
|
"@fontsource/opendyslexic": "^4.5.4",
|
||||||
"@fontsource/share-tech-mono": "^5.0.8",
|
"@fontsource/share-tech-mono": "^4.5.9",
|
||||||
"@fontsource/vollkorn": "^5.0.9",
|
"@fontsource/vollkorn": "^4.5.11",
|
||||||
"@fontsource/zen-maru-gothic": "^5.0.7",
|
"@fontsource/zen-maru-gothic": "^4.5.12",
|
||||||
"@formatjs/icu-messageformat-parser": "^2.6.0",
|
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"autoprefixer": "^10.4.15",
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
"cuid": "^2.1.8",
|
"autoprefixer": "^10.4.8",
|
||||||
"html-to-text": "^9.0.5",
|
"graphql-request": "^4.3.0",
|
||||||
"intl-messageformat": "^10.5.0",
|
"markdown-to-jsx": "^7.1.7",
|
||||||
"isomorphic-dompurify": "^1.8.0",
|
"meilisearch": "^0.27.0",
|
||||||
"jotai": "^2.3.1",
|
"next": "^12.2.5",
|
||||||
"markdown-to-jsx": "^7.3.2",
|
"nodemailer": "^6.7.8",
|
||||||
"marked": "^7.0.3",
|
"react": "18.2.0",
|
||||||
"material-symbols": "^0.10.4",
|
|
||||||
"meilisearch": "^0.34.1",
|
|
||||||
"next": "^13.4.17",
|
|
||||||
"nodemailer": "^6.9.4",
|
|
||||||
"patch-package": "^8.0.0",
|
|
||||||
"rc-slider": "^10.2.1",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-collapsible": "^2.10.0",
|
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hotkeys-hook": "^3.4.7",
|
"react-hot-keys": "^2.7.2",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-swipeable": "^7.0.0",
|
||||||
"react-zoom-pan-pinch": "^3.1.0",
|
|
||||||
"string-natural-compare": "^3.0.1",
|
|
||||||
"throttle-debounce": "^5.0.0",
|
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"turndown": "^7.1.2",
|
"turndown": "^7.1.1",
|
||||||
"ua-parser-js": "^1.0.35",
|
"ua-parser-js": "^1.0.2"
|
||||||
"usehooks-ts": "^2.9.1",
|
|
||||||
"zod": "^3.22.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@digitak/esrun": "3.2.24",
|
"@graphql-codegen/cli": "^2.11.6",
|
||||||
"@graphql-codegen/cli": "5.0.0",
|
"@graphql-codegen/typescript": "2.7.3",
|
||||||
"@graphql-codegen/typescript": "4.0.1",
|
"@graphql-codegen/typescript-graphql-request": "^4.5.3",
|
||||||
"@graphql-codegen/typescript-graphql-request": "5.0.0",
|
"@graphql-codegen/typescript-operations": "^2.5.3",
|
||||||
"@graphql-codegen/typescript-operations": "4.0.1",
|
"@types/node": "18.7.2",
|
||||||
"@types/html-to-text": "^9.0.1",
|
"@types/nodemailer": "^6.4.5",
|
||||||
"@types/marked": "^5.0.1",
|
"@types/react": "18.0.17",
|
||||||
"@types/node": "20.5.0",
|
"@types/react-dom": "^18.0.6",
|
||||||
"@types/nodemailer": "^6.4.9",
|
|
||||||
"@types/react": "^18.2.20",
|
|
||||||
"@types/react-dom": "^18.2.7",
|
|
||||||
"@types/string-natural-compare": "^3.0.2",
|
|
||||||
"@types/throttle-debounce": "^5.0.0",
|
|
||||||
"@types/turndown": "^5.0.1",
|
"@types/turndown": "^5.0.1",
|
||||||
"@types/ua-parser-js": "^0.7.36",
|
"@typescript-eslint/eslint-plugin": "^5.33.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.4.0",
|
"@typescript-eslint/parser": "^5.33.0",
|
||||||
"@typescript-eslint/parser": "^6.4.0",
|
"eslint": "^8.21.0",
|
||||||
"chalk": "^5.3.0",
|
"eslint-config-next": "12.2.5",
|
||||||
"dotenv": "^16.3.1",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
"eslint": "^8.47.0",
|
"graphql": "^16.5.0",
|
||||||
"eslint-config-next": "13.4.17",
|
"next-sitemap": "^3.1.17",
|
||||||
"eslint-plugin-import": "^2.28.0",
|
"prettier": "^2.7.1",
|
||||||
"graphql": "16.8.0",
|
"prettier-plugin-tailwindcss": "^0.1.13",
|
||||||
"graphql-request": "6.1.0",
|
"tailwindcss": "^3.1.8",
|
||||||
"next-sitemap": "^4.2.2",
|
"ts-unused-exports": "^8.0.0",
|
||||||
"prettier": "^3.0.2",
|
"typescript": "^4.7.4"
|
||||||
"prettier-plugin-tailwindcss": "^0.5.3",
|
|
||||||
"tailwindcss": "^3.3.3",
|
|
||||||
"ts-unused-exports": "^10.0.0",
|
|
||||||
"typescript": "^5.1.6"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
printWidth: 100,
|
|
||||||
tabWidth: 2,
|
|
||||||
useTabs: false,
|
|
||||||
semi: true,
|
|
||||||
singleQuote: false,
|
|
||||||
quoteProps: "as-needed",
|
|
||||||
jsxSingleQuote: false,
|
|
||||||
trailingComma: "es5",
|
|
||||||
bracketSpacing: true,
|
|
||||||
bracketSameLine: true,
|
|
||||||
arrowParens: "always",
|
|
||||||
rangeStart: 0,
|
|
||||||
rangeEnd: Infinity,
|
|
||||||
requirePragma: false,
|
|
||||||
insertPragma: false,
|
|
||||||
proseWrap: "preserve",
|
|
||||||
htmlWhitespaceSensitivity: "ignore",
|
|
||||||
endOfLine: "lf",
|
|
||||||
singleAttributePerLine: false,
|
|
||||||
};
|
|
Before Width: | Height: | Size: 278 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 12 KiB |
|
@ -1 +1 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M245.8 214.9l-33.22 17.28c-9.43-19.58-25.24-19.93-27.46-19.93-22.13 0-33.22 14.61-33.22 43.84 0 23.57 9.21 43.84 33.22 43.84 14.47 0 24.65-7.09 30.57-21.26l30.55 15.5c-6.17 11.51-25.69 38.98-65.1 38.98-22.6 0-73.96-10.32-73.96-77.05 0-58.69 43-77.06 72.63-77.06 30.72-.01 52.7 11.95 65.99 35.86zm143.1 0l-32.78 17.28c-9.5-19.77-25.72-19.93-27.9-19.93-22.14 0-33.22 14.61-33.22 43.84 0 23.55 9.23 43.84 33.22 43.84 14.45 0 24.65-7.09 30.54-21.26l31 15.5c-2.1 3.75-21.39 38.98-65.09 38.98-22.69 0-73.96-9.87-73.96-77.05 0-58.67 42.97-77.06 72.63-77.06 30.71-.01 52.58 11.95 65.56 35.86zM247.6 8.05C104.7 8.05 0 123.1 0 256c0 138.5 113.6 248 247.6 248C377.5 504 496 403.1 496 256 496 118.1 389.4 8 247.6 8zm.87 450.8c-112.5 0-203.7-93.04-203.7-202.8 0-105.4 85.43-203.3 203.7-203.3 112.5 0 202.8 89.46 202.8 203.3-.01 121.7-99.68 202.8-202.8 202.8z"/></svg>
|
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="creative-commons" class="svg-inline--fa fa-creative-commons" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M245.8 214.9l-33.22 17.28c-9.43-19.58-25.24-19.93-27.46-19.93-22.13 0-33.22 14.61-33.22 43.84 0 23.57 9.21 43.84 33.22 43.84 14.47 0 24.65-7.09 30.57-21.26l30.55 15.5c-6.17 11.51-25.69 38.98-65.1 38.98-22.6 0-73.96-10.32-73.96-77.05 0-58.69 43-77.06 72.63-77.06 30.72-.01 52.7 11.95 65.99 35.86zm143.1 0l-32.78 17.28c-9.5-19.77-25.72-19.93-27.9-19.93-22.14 0-33.22 14.61-33.22 43.84 0 23.55 9.23 43.84 33.22 43.84 14.45 0 24.65-7.09 30.54-21.26l31 15.5c-2.1 3.75-21.39 38.98-65.09 38.98-22.69 0-73.96-9.87-73.96-77.05 0-58.67 42.97-77.06 72.63-77.06 30.71-.01 52.58 11.95 65.56 35.86zM247.6 8.05C104.7 8.05 0 123.1 0 256c0 138.5 113.6 248 247.6 248 129.9 0 248.4-100.9 248.4-248 0-137.9-106.6-248-248.4-248zm.87 450.8c-112.5 0-203.7-93.04-203.7-202.8 0-105.4 85.43-203.3 203.7-203.3 112.5 0 202.8 89.46 202.8 203.3-.01 121.7-99.68 202.8-202.8 202.8z"></path></svg>
|
Before Width: | Height: | Size: 925 B After Width: | Height: | Size: 1.1 KiB |
|
@ -1 +1 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M314.9 194.4v101.4h-28.3v120.5h-77.1V295.9h-28.3V194.4c0-4.4 1.6-8.2 4.6-11.3 3.1-3.1 6.9-4.7 11.3-4.7H299c4.1 0 7.8 1.6 11.1 4.7 3.1 3.2 4.8 6.9 4.8 11.3zm-101.5-63.7c0-23.3 11.5-35 34.5-35s34.5 11.7 34.5 35c0 23-11.5 34.5-34.5 34.5s-34.5-11.5-34.5-34.5zM247.6 8C389.4 8 496 118.1 496 256c0 147.1-118.5 248-248.4 248C113.6 504 0 394.5 0 256 0 123.1 104.7 8 247.6 8zm.8 44.7C130.2 52.7 44.7 150.6 44.7 256c0 109.8 91.2 202.8 203.7 202.8 103.2 0 202.8-81.1 202.8-202.8.1-113.8-90.2-203.3-202.8-203.3z"/></svg>
|
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="creative-commons-by" class="svg-inline--fa fa-creative-commons-by" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M314.9 194.4v101.4h-28.3v120.5h-77.1V295.9h-28.3V194.4c0-4.4 1.6-8.2 4.6-11.3 3.1-3.1 6.9-4.7 11.3-4.7H299c4.1 0 7.8 1.6 11.1 4.7 3.1 3.2 4.8 6.9 4.8 11.3zm-101.5-63.7c0-23.3 11.5-35 34.5-35s34.5 11.7 34.5 35c0 23-11.5 34.5-34.5 34.5s-34.5-11.5-34.5-34.5zM247.6 8C389.4 8 496 118.1 496 256c0 147.1-118.5 248-248.4 248C113.6 504 0 394.5 0 256 0 123.1 104.7 8 247.6 8zm.8 44.7C130.2 52.7 44.7 150.6 44.7 256c0 109.8 91.2 202.8 203.7 202.8 103.2 0 202.8-81.1 202.8-202.8 .1-113.8-90.2-203.3-202.8-203.3z"></path></svg>
|
Before Width: | Height: | Size: 579 B After Width: | Height: | Size: 750 B |
|
@ -1 +1 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M247.6 8C389.4 8 496 118.1 496 256c0 147.1-118.5 248-248.4 248C113.6 504 0 394.5 0 256 0 123.1 104.7 8 247.6 8zm.8 44.7C130.2 52.7 44.7 150.6 44.7 256c0 109.8 91.2 202.8 203.7 202.8 103.2 0 202.8-81.1 202.8-202.8.1-113.8-90.2-203.3-202.8-203.3zM137.7 221c13-83.9 80.5-95.7 108.9-95.7 99.8 0 127.5 82.5 127.5 134.2 0 63.6-41 132.9-128.9 132.9-38.9 0-99.1-20-109.4-97h62.5c1.5 30.1 19.6 45.2 54.5 45.2 23.3 0 58-18.2 58-82.8 0-82.5-49.1-80.6-56.7-80.6-33.1 0-51.7 14.6-55.8 43.8h18.2l-49.2 49.2-49-49.2h19.4z"/></svg>
|
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="creative-commons-sa" class="svg-inline--fa fa-creative-commons-sa" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M247.6 8C389.4 8 496 118.1 496 256c0 147.1-118.5 248-248.4 248C113.6 504 0 394.5 0 256 0 123.1 104.7 8 247.6 8zm.8 44.7C130.2 52.7 44.7 150.6 44.7 256c0 109.8 91.2 202.8 203.7 202.8 103.2 0 202.8-81.1 202.8-202.8 .1-113.8-90.2-203.3-202.8-203.3zM137.7 221c13-83.9 80.5-95.7 108.9-95.7 99.8 0 127.5 82.5 127.5 134.2 0 63.6-41 132.9-128.9 132.9-38.9 0-99.1-20-109.4-97h62.5c1.5 30.1 19.6 45.2 54.5 45.2 23.3 0 58-18.2 58-82.8 0-82.5-49.1-80.6-56.7-80.6-33.1 0-51.7 14.6-55.8 43.8h18.2l-49.2 49.2-49-49.2h19.4z"></path></svg>
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 757 B |
|
@ -1 +1 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path d="M524.5 69.84a1.5 1.5 0 00-.764-.7A485.1 485.1 0 00404.1 32.03a1.816 1.816 0 00-1.923.91 337.5 337.5 0 00-14.9 30.6 447.8 447.8 0 00-134.4 0 309.5 309.5 0 00-15.14-30.6 1.89 1.89 0 00-1.924-.91A483.7 483.7 0 00116.1 69.14a1.712 1.712 0 00-.788.676C39.07 183.7 18.19 294.7 28.43 404.4a2.016 2.016 0 00.765 1.375A487.7 487.7 0 00176 479.9a1.9 1.9 0 002.063-.676A348.2 348.2 0 00208.1 430.4a1.86 1.86 0 00-1.019-2.588 321.2 321.2 0 01-45.87-21.85 1.885 1.885 0 01-.185-3.126 251.047 251.047 0 009.109-7.137 1.819 1.819 0 011.9-.256c96.23 43.92 200.4 43.92 295.5 0a1.812 1.812 0 011.924.233 234.533 234.533 0 009.132 7.16 1.884 1.884 0 01-.162 3.126 301.4 301.4 0 01-45.89 21.83 1.875 1.875 0 00-1 2.611 391.1 391.1 0 0030.01 48.81 1.864 1.864 0 002.063.7A486 486 0 00610.7 405.7a1.882 1.882 0 00.765-1.352C623.7 277.6 590.9 167.5 524.5 69.84zm-302 267.76c-28.97 0-52.84-26.59-52.84-59.24s23.44-59.26 52.84-59.26c29.67 0 53.31 26.82 52.84 59.24-.04 31.76-23.44 59.26-52.84 59.26zm195.4 0c-28.97 0-52.84-26.59-52.84-59.24s23.34-59.26 52.84-59.26c29.67 0 53.31 26.82 52.84 59.24-.04 31.76-23.24 59.26-52.84 59.26z"/></svg>
|
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="discord" class="svg-inline--fa fa-discord" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M524.5 69.84a1.5 1.5 0 0 0 -.764-.7A485.1 485.1 0 0 0 404.1 32.03a1.816 1.816 0 0 0 -1.923 .91 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.14-30.6 1.89 1.89 0 0 0 -1.924-.91A483.7 483.7 0 0 0 116.1 69.14a1.712 1.712 0 0 0 -.788 .676C39.07 183.7 18.19 294.7 28.43 404.4a2.016 2.016 0 0 0 .765 1.375A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.063-.676A348.2 348.2 0 0 0 208.1 430.4a1.86 1.86 0 0 0 -1.019-2.588 321.2 321.2 0 0 1 -45.87-21.85 1.885 1.885 0 0 1 -.185-3.126c3.082-2.309 6.166-4.711 9.109-7.137a1.819 1.819 0 0 1 1.9-.256c96.23 43.92 200.4 43.92 295.5 0a1.812 1.812 0 0 1 1.924 .233c2.944 2.426 6.027 4.851 9.132 7.16a1.884 1.884 0 0 1 -.162 3.126 301.4 301.4 0 0 1 -45.89 21.83 1.875 1.875 0 0 0 -1 2.611 391.1 391.1 0 0 0 30.01 48.81 1.864 1.864 0 0 0 2.063 .7A486 486 0 0 0 610.7 405.7a1.882 1.882 0 0 0 .765-1.352C623.7 277.6 590.9 167.5 524.5 69.84zM222.5 337.6c-28.97 0-52.84-26.59-52.84-59.24S193.1 219.1 222.5 219.1c29.67 0 53.31 26.82 52.84 59.24C275.3 310.1 251.9 337.6 222.5 337.6zm195.4 0c-28.97 0-52.84-26.59-52.84-59.24S388.4 219.1 417.9 219.1c29.67 0 53.31 26.82 52.84 59.24C470.7 310.1 447.5 337.6 417.9 337.6z"></path></svg>
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
@ -1 +1 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
|
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="github" class="svg-inline--fa fa-github" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.5 KiB |
|
@ -1 +1 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"/></svg>
|
Before Width: | Height: | Size: 871 B After Width: | Height: | Size: 1.0 KiB |
|
@ -1 +0,0 @@
|
||||||
{"currencies":{"data":[{"id":"1","attributes":{"code":"EUR","symbol":"€","rate_to_usd":1.036166,"display_decimals":true}},{"id":"2","attributes":{"code":"CAD","symbol":"$","rate_to_usd":0.79319156,"display_decimals":true}},{"id":"3","attributes":{"code":"USD","symbol":"$","rate_to_usd":1,"display_decimals":true}},{"id":"4","attributes":{"code":"JPY","symbol":"¥","rate_to_usd":0.0083864261,"display_decimals":false}},{"id":"5","attributes":{"code":"BRL","symbol":"R$","rate_to_usd":0.19904328,"display_decimals":true}},{"id":"6","attributes":{"code":"GBP","symbol":"£","rate_to_usd":1.3181323,"display_decimals":true}},{"id":"7","attributes":{"code":"AUD","symbol":"$","rate_to_usd":0.7422,"display_decimals":true}},{"id":"8","attributes":{"code":"INR","symbol":"₹","rate_to_usd":0.013162881,"display_decimals":false}},{"id":"9","attributes":{"code":"NZD","symbol":"$","rate_to_usd":0.69089984,"display_decimals":true}},{"id":"10","attributes":{"code":"CHF","symbol":"CHF","rate_to_usd":1.0728706,"display_decimals":true}},{"id":"11","attributes":{"code":"CNY","symbol":"¥","rate_to_usd":0.141546,"display_decimals":true}}]}}
|
|
|
@ -1 +0,0 @@
|
||||||
{"languages":{"data":[{"id":"1","attributes":{"name":"French","code":"fr","localized_name":"Français"}},{"id":"2","attributes":{"name":"English","code":"en","localized_name":"English"}},{"id":"3","attributes":{"name":"Japanese","code":"ja","localized_name":"日本語"}},{"id":"4","attributes":{"name":"Spanish","code":"es","localized_name":"Español"}},{"id":"6","attributes":{"name":"Portuguese (Brazil)","code":"pt-br","localized_name":"Português (Brasil)"}},{"id":"8","attributes":{"name":"German","code":"de","localized_name":"Deutsch"}},{"id":"9","attributes":{"name":"Italian","code":"it","localized_name":"Italiano"}},{"id":"10","attributes":{"name":"Russian","code":"ru","localized_name":"русский"}},{"id":"11","attributes":{"name":"Korean","code":"ko","localized_name":"한국어"}},{"id":"12","attributes":{"name":"Chinese","code":"zh","localized_name":"中文"}}]}}
|
|
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 167 KiB |
Before Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 2.6 KiB |
|
@ -1,7 +1,6 @@
|
||||||
import { Ico } from "./Ico";
|
import { Ico, Icon } from "./Ico";
|
||||||
import { ToolTip } from "./ToolTip";
|
import { ToolTip } from "./ToolTip";
|
||||||
import { cJoin } from "helpers/className";
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -10,26 +9,33 @@ import { useFormat } from "hooks/useFormat";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
className?: string;
|
langui: AppStaticProps["langui"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const AnchorShare = ({ id, className }: Props): JSX.Element => {
|
export const AnchorShare = ({ id, langui }: Props): JSX.Element => (
|
||||||
const { format } = useFormat();
|
<ToolTip
|
||||||
return (
|
content={langui.copy_anchor_link}
|
||||||
<ToolTip content={format("copy_anchor_link")} trigger="mouseenter" className="text-sm">
|
trigger="mouseenter"
|
||||||
<ToolTip content={format("anchor_link_copied")} trigger="click" className="text-sm">
|
className="text-sm"
|
||||||
<Ico
|
>
|
||||||
icon="link"
|
<ToolTip
|
||||||
className={cJoin("cursor-pointer transition-colors hover:text-dark", className)}
|
content={langui.anchor_link_copied}
|
||||||
onClick={() => {
|
trigger="click"
|
||||||
navigator.clipboard.writeText(
|
className="text-sm"
|
||||||
`${process.env.NEXT_PUBLIC_URL_SELF + window.location.pathname}#${id}`
|
>
|
||||||
);
|
<Ico
|
||||||
}}
|
icon={Icon.Link}
|
||||||
/>
|
className="transition-color cursor-pointer hover:text-dark"
|
||||||
</ToolTip>
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${
|
||||||
|
process.env.NEXT_PUBLIC_URL_SELF + window.location.pathname
|
||||||
|
}#${id}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
);
|
</ToolTip>
|
||||||
};
|
);
|
||||||
|
|
|
@ -1,18 +1,34 @@
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useSwipeable } from "react-swipeable";
|
|
||||||
import { MaterialSymbol } from "material-symbols";
|
|
||||||
import { atom } from "jotai";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { layout } from "../../design.config";
|
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||||
import { Ico } from "./Ico";
|
import { useSwipeable } from "react-swipeable";
|
||||||
|
import UAParser from "ua-parser-js";
|
||||||
|
import { Ico, Icon } from "./Ico";
|
||||||
|
import { ButtonGroup } from "./Inputs/ButtonGroup";
|
||||||
|
import { OrderableList } from "./Inputs/OrderableList";
|
||||||
|
import { Select } from "./Inputs/Select";
|
||||||
|
import { TextInput } from "./Inputs/TextInput";
|
||||||
|
import { ContentPlaceholder } from "./PanelComponents/ContentPlaceholder";
|
||||||
import { MainPanel } from "./Panels/MainPanel";
|
import { MainPanel } from "./Panels/MainPanel";
|
||||||
import { isDefined, isUndefined } from "helpers/asserts";
|
import { Popup } from "./Popup";
|
||||||
|
import { AnchorIds } from "hooks/useScrollTopOnChange";
|
||||||
|
import { useMediaMobile } from "hooks/useMediaQuery";
|
||||||
|
import {
|
||||||
|
filterHasAttributes,
|
||||||
|
isDefined,
|
||||||
|
isDefinedAndNotEmpty,
|
||||||
|
isUndefined,
|
||||||
|
iterateMap,
|
||||||
|
} from "helpers/others";
|
||||||
|
import { prettyLanguage } from "helpers/formatters";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cIf, cJoin } from "helpers/className";
|
||||||
import { OpenGraph, TITLE_PREFIX, TITLE_SEPARATOR } from "helpers/openGraph";
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
import { Ids } from "types/ids";
|
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||||
import { atoms } from "contexts/atoms";
|
import { Button } from "components/Inputs/Button";
|
||||||
import { useAtomGetter, useAtomPair } from "helpers/atoms";
|
import { OpenGraph, TITLE_PREFIX } from "helpers/openGraph";
|
||||||
import { useFormat } from "hooks/useFormat";
|
import { getDefaultPreferredLanguages } from "helpers/locales";
|
||||||
|
import useIsClient from "hooks/useIsClient";
|
||||||
|
import { useBoolean } from "hooks/useBoolean";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -20,7 +36,6 @@ import { useFormat } from "hooks/useFormat";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SENSIBILITY_SWIPE = 1.1;
|
const SENSIBILITY_SWIPE = 1.1;
|
||||||
const isIOSAtom = atom((get) => get(atoms.userAgent.os) === "iOS");
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -31,9 +46,9 @@ export interface AppLayoutRequired {
|
||||||
openGraph: OpenGraph;
|
openGraph: OpenGraph;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props extends AppLayoutRequired {
|
interface Props extends AppStaticProps, AppLayoutRequired {
|
||||||
subPanel?: React.ReactNode;
|
subPanel?: React.ReactNode;
|
||||||
subPanelIcon?: MaterialSymbol;
|
subPanelIcon?: Icon;
|
||||||
contentPanel?: React.ReactNode;
|
contentPanel?: React.ReactNode;
|
||||||
contentPanelScroolbar?: boolean;
|
contentPanelScroolbar?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -41,244 +56,495 @@ interface Props extends AppLayoutRequired {
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const AppLayout = ({
|
export const AppLayout = ({
|
||||||
|
langui,
|
||||||
|
currencies,
|
||||||
|
languages,
|
||||||
subPanel,
|
subPanel,
|
||||||
contentPanel,
|
contentPanel,
|
||||||
openGraph,
|
openGraph,
|
||||||
subPanelIcon = "tune",
|
subPanelIcon = Icon.Tune,
|
||||||
contentPanelScroolbar = true,
|
contentPanelScroolbar = true,
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const isMainPanelReduced = useAtomGetter(atoms.layout.mainPanelReduced);
|
const {
|
||||||
const [isSubPanelOpened, setSubPanelOpened] = useAtomPair(atoms.layout.subPanelOpened);
|
configPanelOpen,
|
||||||
const [isMainPanelOpened, setMainPanelOpened] = useAtomPair(atoms.layout.mainPanelOpened);
|
currency,
|
||||||
const isMenuGesturesEnabled = useAtomGetter(atoms.layout.menuGesturesEnabled);
|
darkMode,
|
||||||
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
dyslexic,
|
||||||
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
|
fontSize,
|
||||||
const isScreenAtLeastXs = useAtomGetter(atoms.containerQueries.isScreenAtLeastXs);
|
mainPanelOpen,
|
||||||
const isIOS = useAtomGetter(isIOSAtom);
|
mainPanelReduced,
|
||||||
const router = useRouter();
|
menuGestures,
|
||||||
|
playerName,
|
||||||
|
preferredLanguages,
|
||||||
|
selectedThemeMode,
|
||||||
|
subPanelOpen,
|
||||||
|
setConfigPanelOpen,
|
||||||
|
setCurrency,
|
||||||
|
setDarkMode,
|
||||||
|
setDyslexic,
|
||||||
|
setFontSize,
|
||||||
|
setMainPanelOpen,
|
||||||
|
setPlayerName,
|
||||||
|
setPreferredLanguages,
|
||||||
|
setSelectedThemeMode,
|
||||||
|
setSubPanelOpen,
|
||||||
|
toggleMainPanelOpen,
|
||||||
|
toggleSubPanelOpen,
|
||||||
|
} = useAppLayout();
|
||||||
|
|
||||||
const { format } = useFormat();
|
const router = useRouter();
|
||||||
|
const isMobile = useMediaMobile();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.events.on("routeChangeStart", () => {
|
||||||
|
setConfigPanelOpen(false);
|
||||||
|
setMainPanelOpen(false);
|
||||||
|
setSubPanelOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.events.on("hashChangeStart", () => {
|
||||||
|
setSubPanelOpen(false);
|
||||||
|
});
|
||||||
|
}, [router.events, setConfigPanelOpen, setMainPanelOpen, setSubPanelOpen]);
|
||||||
|
|
||||||
const handlers = useSwipeable({
|
const handlers = useSwipeable({
|
||||||
onSwipedLeft: (SwipeEventData) => {
|
onSwipedLeft: (SwipeEventData) => {
|
||||||
if (isMenuGesturesEnabled) {
|
if (menuGestures) {
|
||||||
if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
|
if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
|
||||||
if (isMainPanelOpened) {
|
if (mainPanelOpen === true) {
|
||||||
setMainPanelOpened(false);
|
setMainPanelOpen(false);
|
||||||
} else if (isDefined(subPanel) && isDefined(contentPanel)) {
|
} else if (isDefined(subPanel) && isDefined(contentPanel)) {
|
||||||
setSubPanelOpened(true);
|
setSubPanelOpen(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSwipedRight: (SwipeEventData) => {
|
onSwipedRight: (SwipeEventData) => {
|
||||||
if (isMenuGesturesEnabled) {
|
if (menuGestures) {
|
||||||
if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
|
if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
|
||||||
if (isSubPanelOpened) {
|
if (subPanelOpen === true) {
|
||||||
setSubPanelOpened(false);
|
setSubPanelOpen(false);
|
||||||
} else {
|
} else {
|
||||||
setMainPanelOpened(true);
|
setMainPanelOpen(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const turnSubIntoContent = isDefined(subPanel) && isUndefined(contentPanel) && is1ColumnLayout;
|
const turnSubIntoContent = useMemo(
|
||||||
|
() => isDefined(subPanel) && isUndefined(contentPanel),
|
||||||
|
[contentPanel, subPanel]
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
document.getElementsByTagName("html")[0].style.fontSize = `${
|
||||||
|
(fontSize ?? 1) * 100
|
||||||
|
}%`;
|
||||||
|
}, [fontSize]);
|
||||||
|
|
||||||
|
const currencyOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
filterHasAttributes(currencies, ["attributes"] as const).map(
|
||||||
|
(currentCurrency) => currentCurrency.attributes.code
|
||||||
|
),
|
||||||
|
[currencies]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [currencySelect, setCurrencySelect] = useState<number>(-1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDefined(currency))
|
||||||
|
setCurrencySelect(currencyOptions.indexOf(currency));
|
||||||
|
}, [currency, currencyOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currencySelect >= 0) setCurrency(currencyOptions[currencySelect]);
|
||||||
|
}, [currencyOptions, currencySelect, setCurrency]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (preferredLanguages) {
|
||||||
|
if (preferredLanguages.length === 0) {
|
||||||
|
if (isDefinedAndNotEmpty(router.locale) && router.locales) {
|
||||||
|
setPreferredLanguages(
|
||||||
|
getDefaultPreferredLanguages(router.locale, router.locales)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (router.locale !== preferredLanguages[0]) {
|
||||||
|
router.replace(router.asPath, router.asPath, {
|
||||||
|
locale: preferredLanguages[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
preferredLanguages,
|
||||||
|
router,
|
||||||
|
router.locale,
|
||||||
|
router.locales,
|
||||||
|
setPreferredLanguages,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const gridCol = useMemo(() => {
|
||||||
|
if (isDefined(subPanel)) {
|
||||||
|
if (mainPanelReduced === true) {
|
||||||
|
return "grid-cols-[6rem_20rem_1fr]";
|
||||||
|
}
|
||||||
|
return "grid-cols-[20rem_20rem_1fr]";
|
||||||
|
} else if (mainPanelReduced === true) {
|
||||||
|
return "grid-cols-[6rem_0px_1fr]";
|
||||||
|
}
|
||||||
|
return "grid-cols-[20rem_0px_1fr]";
|
||||||
|
}, [mainPanelReduced, subPanel]);
|
||||||
|
|
||||||
|
const isClient = useIsClient();
|
||||||
|
const { state: hasDisgardSafariWarning, setTrue: disgardSafariWarning } =
|
||||||
|
useBoolean(false);
|
||||||
|
const isSafari = useMemo<boolean>(() => {
|
||||||
|
if (isClient) {
|
||||||
|
const parser = new UAParser();
|
||||||
|
return (
|
||||||
|
parser.getBrowser().name === "Safari" || parser.getOS().name === "iOS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [isClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...handlers}
|
|
||||||
id={Ids.Body}
|
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
"fixed inset-0 m-0 grid touch-pan-y bg-light p-0",
|
cIf(darkMode, "set-theme-dark", "set-theme-light"),
|
||||||
cIf(
|
cIf(dyslexic, "set-theme-font-dyslexic", "set-theme-font-standard")
|
||||||
is1ColumnLayout,
|
|
||||||
"grid-rows-[1fr_5rem] [grid-template-areas:'content''navbar']",
|
|
||||||
"[grid-template-areas:'main_sub_content']"
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
style={{
|
>
|
||||||
gridTemplateColumns: is1ColumnLayout
|
|
||||||
? "1fr"
|
|
||||||
: `${isMainPanelReduced ? layout.mainMenuReduced : layout.mainMenu}rem ${
|
|
||||||
isDefined(subPanel) ? layout.subMenu : 0
|
|
||||||
}rem 1fr`,
|
|
||||||
}}>
|
|
||||||
<Head>
|
|
||||||
<title>{openGraph.title}</title>
|
|
||||||
<meta name="description" content={openGraph.description} />
|
|
||||||
|
|
||||||
<meta name="twitter:site" content="@AccordsLibrary" />
|
|
||||||
<meta name="twitter:title" content={openGraph.title} />
|
|
||||||
<meta name="twitter:description" content={openGraph.description} />
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:image" content={openGraph.thumbnail.image} />
|
|
||||||
|
|
||||||
<meta
|
|
||||||
property="og:type"
|
|
||||||
content={openGraph.video ? "video.movie" : openGraph.audio ? "music.song" : "website"}
|
|
||||||
/>
|
|
||||||
<meta property="og:locale" content={router.locale} />
|
|
||||||
<meta property="og:site_name" content="Accord’s Library" />
|
|
||||||
|
|
||||||
<meta property="og:title" content={openGraph.title} />
|
|
||||||
<meta property="og:description" content={openGraph.description} />
|
|
||||||
|
|
||||||
<meta property="og:image" content={openGraph.thumbnail.image} />
|
|
||||||
<meta property="og:image:secure_url" content={openGraph.thumbnail.image} />
|
|
||||||
<meta property="og:image:width" content={openGraph.thumbnail.width.toString()} />
|
|
||||||
<meta property="og:image:height" content={openGraph.thumbnail.height.toString()} />
|
|
||||||
<meta property="og:image:alt" content={openGraph.thumbnail.alt} />
|
|
||||||
<meta property="og:image:type" content="image/jpeg" />
|
|
||||||
|
|
||||||
{openGraph.audio && (
|
|
||||||
<>
|
|
||||||
<meta property="og:audio" content={openGraph.audio} />
|
|
||||||
<meta property="og:audio:type" content="audio/mpeg" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{openGraph.video && (
|
|
||||||
<>
|
|
||||||
<meta property="og:video" content={openGraph.video} />{" "}
|
|
||||||
<meta property="og:video:type" content="video/mp4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
{/* Content panel */}
|
|
||||||
<div
|
<div
|
||||||
id={Ids.ContentPanel}
|
{...handlers}
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
"bg-light [grid-area:content]",
|
`fixed inset-0 m-0 grid touch-pan-y bg-light p-0 text-black
|
||||||
cIf(!isIOS, "texture-paper-dots"),
|
[grid-template-areas:'main_sub_content'] mobile:grid-cols-[1fr]
|
||||||
cIf(contentPanelScroolbar, "overflow-y-scroll")
|
mobile:grid-rows-[1fr_5rem] mobile:[grid-template-areas:'content''navbar']`,
|
||||||
)}>
|
gridCol
|
||||||
{isDefined(contentPanel) ? (
|
|
||||||
contentPanel
|
|
||||||
) : turnSubIntoContent ? (
|
|
||||||
subPanel
|
|
||||||
) : (
|
|
||||||
<ContentPlaceholder message={format("select_option_sidebar")} icon={"chevron_left"} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
|
<Head>
|
||||||
|
<title>{openGraph.title}</title>
|
||||||
|
<meta name="description" content={openGraph.description} />
|
||||||
|
|
||||||
{/* Background when navbar is opened */}
|
<meta name="twitter:title" content={openGraph.title} />
|
||||||
|
<meta name="twitter:description" content={openGraph.description} />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:image" content={openGraph.thumbnail.image} />
|
||||||
|
|
||||||
<div
|
<meta property="og:title" content={openGraph.title} />
|
||||||
className={cJoin(
|
<meta property="og:description" content={openGraph.description} />
|
||||||
`absolute inset-0 z-40 transition-filter duration-500
|
<meta property="og:image" content={openGraph.thumbnail.image} />
|
||||||
[grid-area:content]`,
|
<meta
|
||||||
cIf(
|
property="og:image:secure_url"
|
||||||
(isMainPanelOpened || isSubPanelOpened) && is1ColumnLayout,
|
content={openGraph.thumbnail.image}
|
||||||
cIf(!isPerfModeEnabled, "backdrop-blur"),
|
/>
|
||||||
"pointer-events-none touch-none"
|
<meta
|
||||||
)
|
property="og:image:width"
|
||||||
)}>
|
content={openGraph.thumbnail.width.toString()}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:image:height"
|
||||||
|
content={openGraph.thumbnail.height.toString()}
|
||||||
|
/>
|
||||||
|
<meta property="og:image:alt" content={openGraph.thumbnail.alt} />
|
||||||
|
<meta property="og:image:type" content="image/jpeg" />
|
||||||
|
|
||||||
|
<script
|
||||||
|
async
|
||||||
|
defer
|
||||||
|
data-website-id={process.env.NEXT_PUBLIC_UMAMI_ID}
|
||||||
|
src={`${process.env.NEXT_PUBLIC_UMAMI_URL}/umami.js`}
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
{/* Background when navbar is opened */}
|
||||||
<div
|
<div
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
"absolute inset-0 bg-shade transition-opacity duration-500",
|
`absolute inset-0 transition-[backdrop-filter] duration-500 [grid-area:content]
|
||||||
|
mobile:z-10`,
|
||||||
cIf(
|
cIf(
|
||||||
(isMainPanelOpened || isSubPanelOpened) && is1ColumnLayout,
|
(mainPanelOpen === true || subPanelOpen === true) && isMobile,
|
||||||
"opacity-60",
|
"[backdrop-filter:blur(2px)]",
|
||||||
"opacity-0"
|
"pointer-events-none touch-none"
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
>
|
||||||
setMainPanelOpened(false);
|
<div
|
||||||
setSubPanelOpened(false);
|
className={cJoin(
|
||||||
}}
|
"absolute inset-0 bg-shade transition-opacity duration-500",
|
||||||
/>
|
cIf(
|
||||||
</div>
|
(mainPanelOpen === true || subPanelOpen === true) && isMobile,
|
||||||
|
"opacity-60",
|
||||||
{/* Navbar */}
|
"opacity-0"
|
||||||
<div
|
)
|
||||||
className={cJoin(
|
)}
|
||||||
`z-40 grid grid-cols-[5rem_1fr_5rem] place-items-center border-t
|
|
||||||
border-dotted border-black bg-light [grid-area:navbar]`,
|
|
||||||
cIf(!isIOS, "texture-paper-dots"),
|
|
||||||
cIf(!is1ColumnLayout, "hidden")
|
|
||||||
)}>
|
|
||||||
<Ico
|
|
||||||
icon={isMainPanelOpened ? "close" : "menu"}
|
|
||||||
className="cursor-pointer !text-2xl"
|
|
||||||
onClick={() => {
|
|
||||||
setMainPanelOpened((current) => !current);
|
|
||||||
setSubPanelOpened(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
className={cJoin(
|
|
||||||
"overflow-hidden text-center font-headers font-black",
|
|
||||||
cIf(openGraph.title.length > 30, "max-h-14 text-xl", "max-h-16 text-2xl")
|
|
||||||
)}>
|
|
||||||
{openGraph.title.substring(TITLE_PREFIX.length + TITLE_SEPARATOR.length)
|
|
||||||
? openGraph.title.substring(TITLE_PREFIX.length + TITLE_SEPARATOR.length)
|
|
||||||
: "Accord’s Library"}
|
|
||||||
</p>
|
|
||||||
{isDefined(subPanel) && !turnSubIntoContent && (
|
|
||||||
<Ico
|
|
||||||
icon={isSubPanelOpened ? "close" : subPanelIcon}
|
|
||||||
className="cursor-pointer !text-2xl"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSubPanelOpened((current) => !current);
|
setMainPanelOpen(false);
|
||||||
setMainPanelOpened(false);
|
setSubPanelOpen(false);
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content panel */}
|
||||||
|
<div
|
||||||
|
id={AnchorIds.ContentPanel}
|
||||||
|
className={cJoin(
|
||||||
|
"texture-paper-dots bg-light [grid-area:content]",
|
||||||
|
cIf(contentPanelScroolbar, "overflow-y-scroll")
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isDefined(contentPanel) ? (
|
||||||
|
contentPanel
|
||||||
|
) : (
|
||||||
|
<ContentPlaceholder
|
||||||
|
message={langui.select_option_sidebar ?? ""}
|
||||||
|
icon={Icon.ChevronLeft}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub panel */}
|
||||||
|
{isDefined(subPanel) && (
|
||||||
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
`texture-paper-dots overflow-y-scroll border-r-[1px] border-dark/50 bg-light
|
||||||
|
transition-transform duration-300 [grid-area:sub] [scrollbar-width:none]
|
||||||
|
webkit-scrollbar:w-0 mobile:z-10 mobile:w-[90%] mobile:justify-self-end
|
||||||
|
mobile:border-r-0 mobile:border-l-[1px] mobile:[grid-area:content]`,
|
||||||
|
turnSubIntoContent
|
||||||
|
? "mobile:w-full mobile:border-l-0"
|
||||||
|
: subPanelOpen === true
|
||||||
|
? ""
|
||||||
|
: "mobile:translate-x-[100vw]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{subPanel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main panel */}
|
||||||
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
`texture-paper-dots overflow-y-scroll border-r-[1px] border-dark/50 bg-light
|
||||||
|
transition-transform duration-300 [grid-area:main] [scrollbar-width:none]
|
||||||
|
webkit-scrollbar:w-0 mobile:z-10 mobile:w-[90%] mobile:justify-self-start
|
||||||
|
mobile:[grid-area:content]`,
|
||||||
|
cIf(mainPanelOpen === false, "mobile:-translate-x-full")
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MainPanel langui={langui} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navbar */}
|
||||||
|
<div
|
||||||
|
className="texture-paper-dots grid grid-cols-[5rem_1fr_5rem] place-items-center
|
||||||
|
border-t-[1px] border-dotted border-black bg-light [grid-area:navbar] desktop:hidden"
|
||||||
|
>
|
||||||
|
<Ico
|
||||||
|
icon={mainPanelOpen === true ? Icon.Close : Icon.Menu}
|
||||||
|
className="mt-[.1em] cursor-pointer !text-2xl"
|
||||||
|
onClick={() => {
|
||||||
|
toggleMainPanelOpen();
|
||||||
|
setSubPanelOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
<p
|
||||||
</div>
|
className={cJoin(
|
||||||
|
"overflow-hidden text-center font-headers font-black",
|
||||||
{/* Sub panel */}
|
cIf(
|
||||||
{isDefined(subPanel) && !turnSubIntoContent && (
|
openGraph.title.length > 30,
|
||||||
<div
|
"max-h-14 text-xl",
|
||||||
id={Ids.SubPanel}
|
"max-h-16 text-2xl"
|
||||||
className={cJoin(
|
)
|
||||||
`overflow-y-scroll border-r border-dark/50 bg-light
|
)}
|
||||||
transition-transform duration-300 scrollbar-none`,
|
>
|
||||||
cIf(!isIOS, "texture-paper-dots"),
|
{isDefinedAndNotEmpty(
|
||||||
cIf(
|
openGraph.title.substring(TITLE_PREFIX.length)
|
||||||
is1ColumnLayout,
|
)
|
||||||
"z-40 justify-self-end border-r-0 [grid-area:content]",
|
? openGraph.title.substring(TITLE_PREFIX.length)
|
||||||
"[grid-area:sub]"
|
: "Accord’s Library"}
|
||||||
),
|
</p>
|
||||||
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)] border-l"),
|
{isDefined(subPanel) && !turnSubIntoContent && (
|
||||||
cIf(is1ColumnLayout && !isSubPanelOpened, "translate-x-[100vw]")
|
<Ico
|
||||||
)}>
|
icon={subPanelOpen === true ? Icon.Close : subPanelIcon}
|
||||||
{subPanel}
|
className="mt-[.1em] cursor-pointer !text-2xl"
|
||||||
|
onClick={() => {
|
||||||
|
toggleSubPanelOpen();
|
||||||
|
setMainPanelOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main panel */}
|
<Popup
|
||||||
<div
|
state={isSafari && !hasDisgardSafariWarning}
|
||||||
className={cJoin(
|
onClose={() => null}
|
||||||
`overflow-y-scroll border-r border-dark/50 bg-light
|
>
|
||||||
transition-transform duration-300 scrollbar-none`,
|
<h1 className="text-2xl">Hi, you are using Safari!</h1>
|
||||||
cIf(!isIOS, "texture-paper-dots"),
|
<p className="max-w-lg text-center">
|
||||||
cIf(is1ColumnLayout, "z-40 justify-self-start [grid-area:content]", "[grid-area:main]"),
|
In most cases this wouldn’t be a problem but our website
|
||||||
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)]"),
|
is—for some obscure reason—performing terribly on Safari (WebKit).
|
||||||
cIf(!isMainPanelOpened && is1ColumnLayout, "-translate-x-full")
|
Because of that, we have decided to display this message instead of
|
||||||
)}>
|
letting you have a slow and painful experience. We are looking into
|
||||||
<MainPanel />
|
the problem, and are hoping to fix this soon.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In the meanwhile, if you are using an iPhone/iPad, please try using
|
||||||
|
another device.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you are on macOS, please use another browser such as Firefox or
|
||||||
|
Chrome.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
text="Let me in regardless"
|
||||||
|
className="mt-8"
|
||||||
|
onClick={disgardSafariWarning}
|
||||||
|
/>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
|
<Popup
|
||||||
|
state={configPanelOpen ?? false}
|
||||||
|
onClose={() => setConfigPanelOpen(false)}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl">{langui.settings}</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mt-4 grid justify-items-center gap-16
|
||||||
|
text-center desktop:grid-cols-[auto_auto]"
|
||||||
|
>
|
||||||
|
{router.locales && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl">{langui.languages}</h3>
|
||||||
|
{preferredLanguages && preferredLanguages.length > 0 && (
|
||||||
|
<OrderableList
|
||||||
|
items={
|
||||||
|
new Map(
|
||||||
|
preferredLanguages.map((locale) => [
|
||||||
|
locale,
|
||||||
|
prettyLanguage(locale, languages),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
insertLabels={
|
||||||
|
new Map([
|
||||||
|
[0, langui.primary_language],
|
||||||
|
[1, langui.secondary_language],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
onChange={(items) => {
|
||||||
|
const newPreferredLanguages = iterateMap(
|
||||||
|
items,
|
||||||
|
(code) => code
|
||||||
|
);
|
||||||
|
setPreferredLanguages(newPreferredLanguages);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid place-items-center gap-8 text-center desktop:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl">{langui.theme}</h3>
|
||||||
|
<ButtonGroup
|
||||||
|
buttonsProps={[
|
||||||
|
{
|
||||||
|
onClick: () => {
|
||||||
|
setDarkMode(false);
|
||||||
|
setSelectedThemeMode(true);
|
||||||
|
},
|
||||||
|
active: selectedThemeMode === true && darkMode === false,
|
||||||
|
text: langui.light,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedThemeMode(false);
|
||||||
|
},
|
||||||
|
active: selectedThemeMode === false,
|
||||||
|
text: langui.auto,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClick: () => {
|
||||||
|
setDarkMode(true);
|
||||||
|
setSelectedThemeMode(true);
|
||||||
|
},
|
||||||
|
active: selectedThemeMode === true && darkMode === true,
|
||||||
|
text: langui.dark,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl">{langui.currency}</h3>
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
options={currencyOptions}
|
||||||
|
value={currencySelect}
|
||||||
|
onChange={setCurrencySelect}
|
||||||
|
className="w-28"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl">{langui.font_size}</h3>
|
||||||
|
<ButtonGroup
|
||||||
|
buttonsProps={[
|
||||||
|
{
|
||||||
|
onClick: () => setFontSize((fontSize ?? 1) / 1.05),
|
||||||
|
icon: Icon.TextDecrease,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClick: () => setFontSize(1),
|
||||||
|
text: `${((fontSize ?? 1) * 100).toLocaleString(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}
|
||||||
|
)}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClick: () => setFontSize((fontSize ?? 1) * 1.05),
|
||||||
|
icon: Icon.TextIncrease,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl">{langui.font}</h3>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Button
|
||||||
|
active={dyslexic === false}
|
||||||
|
onClick={() => setDyslexic(false)}
|
||||||
|
className="font-zenMaruGothic"
|
||||||
|
text="Zen Maru Gothic"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
active={dyslexic === true}
|
||||||
|
onClick={() => setDyslexic(true)}
|
||||||
|
className="font-openDyslexic"
|
||||||
|
text="OpenDyslexic"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl">{langui.player_name}</h3>
|
||||||
|
<TextInput
|
||||||
|
placeholder="<player>"
|
||||||
|
className="w-48"
|
||||||
|
value={playerName ?? ""}
|
||||||
|
onChange={setPlayerName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface ContentPlaceholderProps {
|
|
||||||
message: string;
|
|
||||||
icon?: MaterialSymbol;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContentPlaceholder = ({ message, icon }: ContentPlaceholderProps): JSX.Element => (
|
|
||||||
<div className="grid h-full place-content-center">
|
|
||||||
<div
|
|
||||||
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
|
|
||||||
border-dark p-8 text-dark opacity-40">
|
|
||||||
{isDefined(icon) && <Ico icon={icon} className="!text-[300%]" />}
|
|
||||||
<p className={cJoin("w-64 text-2xl", cIf(isUndefined(icon), "text-center"))}>{message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
|
@ -15,10 +15,12 @@ interface Props {
|
||||||
export const Chip = ({ className, text }: Props): JSX.Element => (
|
export const Chip = ({ className, text }: Props): JSX.Element => (
|
||||||
<div
|
<div
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
`grid place-content-center place-items-center whitespace-nowrap rounded-full border
|
`grid place-content-center place-items-center whitespace-nowrap rounded-full
|
||||||
border-black/70 px-1.5 pb-[0.14rem] text-xs text-black/70`,
|
border-[1px] px-1.5 pb-[0.14rem] text-xs opacity-70
|
||||||
|
transition-[color,_opacity,_border-color] hover:opacity-100`,
|
||||||
className
|
className
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
import { MouseEventHandler, useCallback } from "react";
|
import { Link } from "components/Inputs/Link";
|
||||||
import { DatePickerFragment } from "graphql/generated";
|
import { DatePickerFragment } from "graphql/generated";
|
||||||
import { TranslatedProps } from "types/TranslatedProps";
|
|
||||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
|
||||||
import { DownPressable } from "components/Containers/DownPressable";
|
|
||||||
import { isDefined } from "helpers/asserts";
|
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cIf, cJoin } from "helpers/className";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -15,63 +11,35 @@ interface Props {
|
||||||
date: DatePickerFragment;
|
date: DatePickerFragment;
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
active?: boolean;
|
isActive?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const ChroniclePreview = ({
|
export const ChroniclePreview = ({
|
||||||
date,
|
date,
|
||||||
url,
|
url,
|
||||||
title,
|
title,
|
||||||
active,
|
isActive,
|
||||||
disabled,
|
|
||||||
onClick,
|
|
||||||
}: Props): JSX.Element => (
|
}: Props): JSX.Element => (
|
||||||
<DownPressable
|
<Link
|
||||||
className="flex w-full gap-4 px-5 py-4"
|
|
||||||
href={url}
|
href={url}
|
||||||
onClick={onClick}
|
className={cJoin(
|
||||||
active={active}
|
`flex w-full cursor-pointer gap-4 rounded-2xl py-4 px-5
|
||||||
border
|
text-left align-top outline outline-2 outline-offset-[-2px] outline-mid transition-all
|
||||||
disabled={disabled}>
|
hover:bg-mid hover:shadow-inner-sm hover:shadow-shade
|
||||||
{isDefined(date.year) && (
|
hover:outline-[transparent] hover:active:shadow-inner hover:active:shadow-shade`,
|
||||||
<div className="text-right">
|
cIf(isActive, "bg-mid shadow-inner-sm shadow-shade outline-[transparent]")
|
||||||
<p>{date.year}</p>
|
|
||||||
<p className="text-sm text-dark">{prettyMonthDay(date.month, date.day)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
<p
|
<div className="text-right">
|
||||||
className={cJoin(
|
<p>{date.year}</p>
|
||||||
"text-lg leading-tight",
|
<p className="text-sm text-dark">
|
||||||
cIf(isDefined(date.year), "text-left", "w-full text-center")
|
{prettyMonthDay(date.month, date.day)}
|
||||||
)}>
|
</p>
|
||||||
{title}
|
</div>
|
||||||
</p>
|
<p className="text-lg leading-tight">{title}</p>
|
||||||
</DownPressable>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const TranslatedChroniclePreview = ({
|
|
||||||
translations,
|
|
||||||
fallback,
|
|
||||||
...otherProps
|
|
||||||
}: TranslatedProps<Parameters<typeof ChroniclePreview>[0], "title">): JSX.Element => {
|
|
||||||
const [selectedTranslation] = useSmartLanguage({
|
|
||||||
items: translations,
|
|
||||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
|
||||||
});
|
|
||||||
|
|
||||||
return <ChroniclePreview title={selectedTranslation?.title ?? fallback.title} {...otherProps} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭───────────────────╮
|
* ╭───────────────────╮
|
||||||
* ─────────────────────────────────────╯ PRIVATE METHODS ╰───────────────────────────────────────
|
* ─────────────────────────────────────╯ PRIVATE METHODS ╰───────────────────────────────────────
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
import { useCallback } from "react";
|
|
||||||
import Collapsible from "react-collapsible";
|
|
||||||
import { TranslatedChroniclePreview } from "./ChroniclePreview";
|
|
||||||
import { GetChroniclesChaptersQuery } from "graphql/generated";
|
import { GetChroniclesChaptersQuery } from "graphql/generated";
|
||||||
import { filterHasAttributes } from "helpers/asserts";
|
import { filterHasAttributes } from "helpers/others";
|
||||||
import { prettyInlineTitle, prettySlug, sJoin } from "helpers/formatters";
|
import { TranslatedChroniclePreview } from "components/Translated";
|
||||||
|
import { prettyInlineTitle, prettySlug } from "helpers/formatters";
|
||||||
|
import { Ico, Icon } from "components/Ico";
|
||||||
|
import { useBoolean } from "hooks/useBoolean";
|
||||||
import { compareDate } from "helpers/date";
|
import { compareDate } from "helpers/date";
|
||||||
import { TranslatedProps } from "types/TranslatedProps";
|
|
||||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
|
||||||
import { useAtomSetter } from "helpers/atoms";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { Button } from "components/Inputs/Button";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -19,62 +14,68 @@ import { Button } from "components/Inputs/Button";
|
||||||
interface Props {
|
interface Props {
|
||||||
chronicles: NonNullable<
|
chronicles: NonNullable<
|
||||||
NonNullable<
|
NonNullable<
|
||||||
NonNullable<GetChroniclesChaptersQuery["chroniclesChapters"]>["data"][number]["attributes"]
|
NonNullable<
|
||||||
|
GetChroniclesChaptersQuery["chroniclesChapters"]
|
||||||
|
>["data"][number]["attributes"]
|
||||||
>["chronicles"]
|
>["chronicles"]
|
||||||
>["data"];
|
>["data"];
|
||||||
currentSlug?: string;
|
currentSlug?: string;
|
||||||
title: string;
|
title: string;
|
||||||
open?: boolean;
|
|
||||||
onTriggerClosing?: () => void;
|
|
||||||
onOpening?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
export const ChroniclesList = ({
|
||||||
|
|
||||||
const ChroniclesList = ({
|
|
||||||
chronicles,
|
chronicles,
|
||||||
currentSlug,
|
currentSlug,
|
||||||
title,
|
title,
|
||||||
open,
|
|
||||||
onTriggerClosing,
|
|
||||||
onOpening,
|
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
|
const { state: isOpen, toggleState: toggleOpen } = useBoolean(
|
||||||
|
chronicles.some((chronicle) => chronicle.attributes?.slug === currentSlug)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Collapsible
|
<div className="grid place-content-center">
|
||||||
open={open}
|
<div
|
||||||
accordionPosition={title}
|
className="grid cursor-pointer grid-cols-[1em_1fr] gap-4"
|
||||||
contentInnerClassName="grid gap-4 pt-4"
|
onClick={toggleOpen}
|
||||||
onTriggerClosing={onTriggerClosing}
|
>
|
||||||
onOpening={onOpening}
|
<Ico
|
||||||
easing="ease-in-out"
|
className="!text-xl"
|
||||||
transitionTime={400}
|
icon={isOpen ? Icon.ArrowDropUp : Icon.ArrowDropDown}
|
||||||
lazyRender
|
/>
|
||||||
contentHiddenWhenClosed
|
<p className="mb-4 font-headers text-xl">{title}</p>
|
||||||
trigger={
|
</div>
|
||||||
<div className="flex place-content-center place-items-center gap-4">
|
</div>
|
||||||
<h2 className="text-center text-xl">{title}</h2>
|
<div
|
||||||
<Button icon={open ? "expand_less" : "expand_more"} active={open} size="small" />
|
className="grid gap-4 overflow-hidden transition-[max-height] duration-500"
|
||||||
</div>
|
style={{ maxHeight: isOpen ? `${8 * chronicles.length}rem` : 0 }}
|
||||||
}>
|
>
|
||||||
{filterHasAttributes(chronicles, ["attributes.contents", "attributes.translations"])
|
{filterHasAttributes(chronicles, [
|
||||||
.sort((a, b) => compareDate(a.attributes.date_start, b.attributes.date_start))
|
"attributes.contents",
|
||||||
|
"attributes.translations",
|
||||||
|
] as const)
|
||||||
|
.sort((a, b) =>
|
||||||
|
compareDate(a.attributes.date_start, b.attributes.date_start)
|
||||||
|
)
|
||||||
.map((chronicle) => (
|
.map((chronicle) => (
|
||||||
<div key={chronicle.id} id={`chronicle-${chronicle.attributes.slug}`}>
|
<div
|
||||||
|
key={chronicle.id}
|
||||||
|
id={`chronicle-${chronicle.attributes.slug}`}
|
||||||
|
className="scroll-m-[45vh]"
|
||||||
|
>
|
||||||
{chronicle.attributes.translations.length === 0 &&
|
{chronicle.attributes.translations.length === 0 &&
|
||||||
chronicle.attributes.contents.data.length === 1
|
chronicle.attributes.contents.data.length === 1
|
||||||
? filterHasAttributes(chronicle.attributes.contents.data, [
|
? filterHasAttributes(chronicle.attributes.contents.data, [
|
||||||
"attributes.translations",
|
"attributes.translations",
|
||||||
]).map((content, index) => (
|
] as const).map((content, index) => (
|
||||||
<TranslatedChroniclePreview
|
<TranslatedChroniclePreview
|
||||||
key={index}
|
key={index}
|
||||||
active={chronicle.attributes.slug === currentSlug}
|
isActive={chronicle.attributes.slug === currentSlug}
|
||||||
date={chronicle.attributes.date_start}
|
date={chronicle.attributes.date_start}
|
||||||
translations={filterHasAttributes(content.attributes.translations, [
|
translations={filterHasAttributes(
|
||||||
"language.data.attributes.code",
|
content.attributes.translations,
|
||||||
]).map((translation) => ({
|
["language.data.attributes.code"] as const
|
||||||
|
).map((translation) => ({
|
||||||
title: prettyInlineTitle(
|
title: prettyInlineTitle(
|
||||||
translation.pre_title,
|
translation.pre_title,
|
||||||
translation.title,
|
translation.title,
|
||||||
|
@ -85,58 +86,29 @@ const ChroniclesList = ({
|
||||||
fallback={{
|
fallback={{
|
||||||
title: prettySlug(chronicle.attributes.slug),
|
title: prettySlug(chronicle.attributes.slug),
|
||||||
}}
|
}}
|
||||||
url={sJoin(
|
url={`/chronicles/${chronicle.attributes.slug}/#chronicle-${chronicle.attributes.slug}`}
|
||||||
"/chronicles/",
|
|
||||||
chronicle.attributes.slug,
|
|
||||||
"/#chronicle-",
|
|
||||||
chronicle.attributes.slug
|
|
||||||
)}
|
|
||||||
onClick={() => setSubPanelOpened(false)}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
: chronicle.attributes.translations.length > 0 && (
|
: chronicle.attributes.translations.length > 0 && (
|
||||||
<TranslatedChroniclePreview
|
<TranslatedChroniclePreview
|
||||||
date={chronicle.attributes.date_start}
|
date={chronicle.attributes.date_start}
|
||||||
active={chronicle.attributes.slug === currentSlug}
|
isActive={chronicle.attributes.slug === currentSlug}
|
||||||
translations={filterHasAttributes(chronicle.attributes.translations, [
|
translations={filterHasAttributes(
|
||||||
"language.data.attributes.code",
|
chronicle.attributes.translations,
|
||||||
"title",
|
["language.data.attributes.code", "title"] as const
|
||||||
]).map((translation) => ({
|
).map((translation) => ({
|
||||||
title: translation.title,
|
title: translation.title,
|
||||||
language: translation.language.data.attributes.code,
|
language: translation.language.data.attributes.code,
|
||||||
}))}
|
}))}
|
||||||
fallback={{
|
fallback={{
|
||||||
title: prettySlug(chronicle.attributes.slug),
|
title: prettySlug(chronicle.attributes.slug),
|
||||||
}}
|
}}
|
||||||
url={sJoin(
|
url={`/chronicles/${chronicle.attributes.slug}/#chronicle-${chronicle.attributes.slug}`}
|
||||||
"/chronicles/",
|
|
||||||
chronicle.attributes.slug,
|
|
||||||
"/#chronicle-",
|
|
||||||
chronicle.attributes.slug
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Collapsible>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const TranslatedChroniclesList = ({
|
|
||||||
translations,
|
|
||||||
fallback,
|
|
||||||
...otherProps
|
|
||||||
}: TranslatedProps<Props, "title">): JSX.Element => {
|
|
||||||
const [selectedTranslation] = useSmartLanguage({
|
|
||||||
items: translations,
|
|
||||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
|
||||||
});
|
|
||||||
|
|
||||||
return <ChroniclesList title={selectedTranslation?.title ?? fallback.title} {...otherProps} />;
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { GetChroniclesChaptersQuery } from "graphql/generated";
|
|
||||||
import { filterHasAttributes } from "helpers/asserts";
|
|
||||||
import { TranslatedChroniclesList } from "components/Chronicles/ChroniclesList";
|
|
||||||
import { prettySlug } from "helpers/formatters";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
chapters: NonNullable<GetChroniclesChaptersQuery["chroniclesChapters"]>["data"];
|
|
||||||
currentChronicleSlug?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const ChroniclesLists = ({ chapters, currentChronicleSlug }: Props): JSX.Element => {
|
|
||||||
const [openedIndex, setOpenedIndex] = useState(
|
|
||||||
currentChronicleSlug
|
|
||||||
? chapters.findIndex(
|
|
||||||
(chapter) =>
|
|
||||||
chapter.attributes?.chronicles?.data.some(
|
|
||||||
(chronicle) => chronicle.attributes?.slug === currentChronicleSlug
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: -1
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-16">
|
|
||||||
{filterHasAttributes(chapters, ["attributes.chronicles", "id"]).map(
|
|
||||||
(chapter, chapterIndex) => (
|
|
||||||
<TranslatedChroniclesList
|
|
||||||
currentSlug={currentChronicleSlug}
|
|
||||||
open={openedIndex === chapterIndex}
|
|
||||||
onOpening={() => setOpenedIndex(chapterIndex)}
|
|
||||||
onTriggerClosing={() => setOpenedIndex(-1)}
|
|
||||||
key={chapter.id}
|
|
||||||
chronicles={chapter.attributes.chronicles.data}
|
|
||||||
translations={filterHasAttributes(chapter.attributes.titles, [
|
|
||||||
"language.data.attributes.code",
|
|
||||||
]).map((translation) => ({
|
|
||||||
title: translation.title,
|
|
||||||
language: translation.language.data.attributes.code,
|
|
||||||
}))}
|
|
||||||
fallback={{ title: prettySlug(chapter.attributes.slug) }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,325 +0,0 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { atom } from "jotai";
|
|
||||||
import { cJoin, cIf } from "helpers/className";
|
|
||||||
import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { useAtomSetter, useAtomPair, atomPairing } from "helpers/atoms";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
const LINE_PREFIX = "root@accords-library.com:";
|
|
||||||
|
|
||||||
const previousLinesAtom = atomPairing(atom<string[]>([]));
|
|
||||||
const previousCommandsAtom = atomPairing(atom<string[]>([]));
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
childrenPaths: string[];
|
|
||||||
parentPath: string;
|
|
||||||
content?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const Terminal = ({
|
|
||||||
parentPath,
|
|
||||||
childrenPaths: propsChildrenPaths,
|
|
||||||
content,
|
|
||||||
}: Props): JSX.Element => {
|
|
||||||
const [childrenPaths, setChildrenPaths] = useState(propsChildrenPaths);
|
|
||||||
const setPlayerName = useAtomSetter(atoms.settings.playerName);
|
|
||||||
|
|
||||||
const [previousCommands, setPreviousCommands] = useAtomPair(previousCommandsAtom);
|
|
||||||
const [previousLines, setPreviousLines] = useAtomPair(previousLinesAtom);
|
|
||||||
|
|
||||||
const [line, setLine] = useState("");
|
|
||||||
const [displayCurrentLine, setDisplayCurrentLine] = useState(true);
|
|
||||||
const [previousCommandIndex, setPreviousCommandIndex] = useState(0);
|
|
||||||
const [carretPosition, setCarretPosition] = useState(0);
|
|
||||||
const router = useRouter();
|
|
||||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
|
|
||||||
|
|
||||||
const terminalInputRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const terminalWindowRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
router.events.on("routeChangeComplete", () => {
|
|
||||||
terminalInputRef.current?.focus();
|
|
||||||
setDisplayCurrentLine(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onRouteChangeRequest = useCallback(
|
|
||||||
(newPath: string) => {
|
|
||||||
if (newPath !== router.asPath) {
|
|
||||||
setDisplayCurrentLine(false);
|
|
||||||
router.push(newPath);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const prependLine = useCallback(
|
|
||||||
(text: string) => `${LINE_PREFIX}${router.asPath}# ${text}`,
|
|
||||||
[router.asPath]
|
|
||||||
);
|
|
||||||
|
|
||||||
type Command = {
|
|
||||||
key: string;
|
|
||||||
description: string;
|
|
||||||
handle: (currentLine: string, parameters: string) => string[];
|
|
||||||
};
|
|
||||||
const commands = useMemo<Command[]>(() => {
|
|
||||||
const result: Command[] = [
|
|
||||||
{
|
|
||||||
key: "ls",
|
|
||||||
description: "List directory contents",
|
|
||||||
handle: (currentLine) => [
|
|
||||||
...previousLines,
|
|
||||||
prependLine(currentLine),
|
|
||||||
childrenPaths.join(" "),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: "clear",
|
|
||||||
description: "Clear the terminal screen",
|
|
||||||
handle: () => [],
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: "cat",
|
|
||||||
description: "Concatenate files and print on the standard output",
|
|
||||||
handle: (currentLine) => [
|
|
||||||
...previousLines,
|
|
||||||
prependLine(currentLine),
|
|
||||||
isDefinedAndNotEmpty(content) ? `\n${content}\n` : `-bash: cat: Nothing to display`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: "reboot",
|
|
||||||
description: "Reboot the machine",
|
|
||||||
handle: () => {
|
|
||||||
setPlayerName("");
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: "rm",
|
|
||||||
description: "Remove files or directories",
|
|
||||||
handle: (currentLine, parameters) => {
|
|
||||||
if (parameters.startsWith("-r ")) {
|
|
||||||
const folder = parameters.slice("-r ".length);
|
|
||||||
if (childrenPaths.includes(folder)) {
|
|
||||||
setChildrenPaths((current) => current.filter((path) => path !== folder));
|
|
||||||
return [...previousLines, prependLine(currentLine)];
|
|
||||||
} else if (folder === "*") {
|
|
||||||
setChildrenPaths([]);
|
|
||||||
return [...previousLines, prependLine(currentLine)];
|
|
||||||
} else if (folder === "") {
|
|
||||||
return [
|
|
||||||
...previousLines,
|
|
||||||
prependLine(currentLine),
|
|
||||||
`rm: missing operand\nTry 'rm -r <path>' to remove a folder`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
...previousLines,
|
|
||||||
prependLine(currentLine),
|
|
||||||
`rm: cannot remove '${folder}': No such file or directory`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
...previousLines,
|
|
||||||
prependLine(currentLine),
|
|
||||||
`rm: missing operand\nTry 'rm -r <path>' to remove a folder`,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: "help",
|
|
||||||
description: "Display this list",
|
|
||||||
handle: (currentLine) => [
|
|
||||||
...previousLines,
|
|
||||||
prependLine(currentLine),
|
|
||||||
`
|
|
||||||
GNU bash, version 5.1.4(1)-release (x86_64-pc-linux-gnu)
|
|
||||||
These shell commands are defined internally. Type 'help' to see this list.
|
|
||||||
|
|
||||||
${result.map((command) => `${command.key}: ${command.description}`).join("\n")}
|
|
||||||
|
|
||||||
`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: "cd",
|
|
||||||
description: "Change the shell working directory",
|
|
||||||
handle: (currentLine, parameters) => {
|
|
||||||
const newLines = [];
|
|
||||||
switch (parameters) {
|
|
||||||
case "..": {
|
|
||||||
onRouteChangeRequest(parentPath);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "/": {
|
|
||||||
onRouteChangeRequest("/");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ".": {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
if (childrenPaths.includes(parameters)) {
|
|
||||||
onRouteChangeRequest(`${router.asPath === "/" ? "" : router.asPath}/${parameters}`);
|
|
||||||
} else {
|
|
||||||
newLines.push(`-bash: cd: ${parameters}: No such file or directory`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...previousLines, prependLine(currentLine), ...newLines];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
|
||||||
...result,
|
|
||||||
{
|
|
||||||
key: "",
|
|
||||||
description: "Unhandled command",
|
|
||||||
handle: (currentLine) => [
|
|
||||||
...previousLines,
|
|
||||||
prependLine(currentLine),
|
|
||||||
`-bash: ${currentLine}: command not found`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [
|
|
||||||
childrenPaths,
|
|
||||||
parentPath,
|
|
||||||
content,
|
|
||||||
onRouteChangeRequest,
|
|
||||||
prependLine,
|
|
||||||
previousLines,
|
|
||||||
router.asPath,
|
|
||||||
setPlayerName,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onNewLine = useCallback(
|
|
||||||
(newLine: string) => {
|
|
||||||
for (const command of commands) {
|
|
||||||
if (newLine.startsWith(command.key)) {
|
|
||||||
setPreviousLines(command.handle(newLine, newLine.slice(command.key.length + 1)));
|
|
||||||
setPreviousCommands([newLine, ...previousCommands]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[commands, previousCommands, setPreviousCommands, setPreviousLines]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (terminalWindowRef.current) {
|
|
||||||
terminalWindowRef.current.scrollTo({
|
|
||||||
top: terminalWindowRef.current.scrollHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [line]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cJoin("h-screen overflow-hidden bg-light set-theme-font-standard")}>
|
|
||||||
<div
|
|
||||||
ref={terminalWindowRef}
|
|
||||||
className="h-full overflow-scroll scroll-auto p-6 scrollbar-none">
|
|
||||||
{previousLines.map((previousLine, index) => (
|
|
||||||
<p key={index} className="whitespace-pre-line font-realmono">
|
|
||||||
{previousLine}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<textarea
|
|
||||||
className="absolute -left-6 -right-6 -top-1 w-screen rounded-none opacity-0"
|
|
||||||
spellCheck={false}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="off"
|
|
||||||
placeholder="placeholder"
|
|
||||||
ref={terminalInputRef}
|
|
||||||
value={line}
|
|
||||||
onSelect={() => {
|
|
||||||
if (terminalInputRef.current) {
|
|
||||||
setCarretPosition(terminalInputRef.current.selectionStart);
|
|
||||||
terminalInputRef.current.selectionEnd = terminalInputRef.current.selectionStart;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => setIsTextAreaFocused(false)}
|
|
||||||
onFocus={() => setIsTextAreaFocused(true)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "ArrowUp") {
|
|
||||||
event.preventDefault();
|
|
||||||
let newPreviousCommandIndex = previousCommandIndex;
|
|
||||||
if (previousCommandIndex < previousCommands.length - 1) {
|
|
||||||
newPreviousCommandIndex += 1;
|
|
||||||
}
|
|
||||||
setPreviousCommandIndex(newPreviousCommandIndex);
|
|
||||||
const previousCommand = previousCommands[newPreviousCommandIndex];
|
|
||||||
if (isDefined(previousCommand)) {
|
|
||||||
setLine(previousCommand);
|
|
||||||
setCarretPosition(previousCommand.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.key === "ArrowDown") {
|
|
||||||
event.preventDefault();
|
|
||||||
let newPreviousCommandIndex = previousCommandIndex;
|
|
||||||
if (previousCommandIndex > 0) {
|
|
||||||
newPreviousCommandIndex -= 1;
|
|
||||||
}
|
|
||||||
setPreviousCommandIndex(newPreviousCommandIndex);
|
|
||||||
const previousCommand = previousCommands[newPreviousCommandIndex];
|
|
||||||
if (isDefined(previousCommand)) {
|
|
||||||
setLine(previousCommand);
|
|
||||||
setCarretPosition(previousCommand.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onInput={() => {
|
|
||||||
if (terminalInputRef.current) {
|
|
||||||
if (terminalInputRef.current.value.includes("\n")) {
|
|
||||||
setLine("");
|
|
||||||
onNewLine(line);
|
|
||||||
} else {
|
|
||||||
setLine(terminalInputRef.current.value);
|
|
||||||
}
|
|
||||||
setCarretPosition(terminalInputRef.current.selectionStart);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{displayCurrentLine && (
|
|
||||||
<p className="whitespace-normal font-realmono">
|
|
||||||
{prependLine("")}
|
|
||||||
{line.slice(0, carretPosition)}
|
|
||||||
<span
|
|
||||||
className={cJoin(
|
|
||||||
"whitespace-pre font-realmono",
|
|
||||||
cIf(isTextAreaFocused, "animate-carret border-b-2 border-black")
|
|
||||||
)}>
|
|
||||||
{line[carretPosition] ?? " "}
|
|
||||||
</span>
|
|
||||||
{line.slice(carretPosition + 1)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,23 +0,0 @@
|
||||||
import { cJoin } from "helpers/className";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ────────────────────────────────────────╯ COMPONENT ╰──────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
src: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const ColoredSvg = ({ src, className }: Props): JSX.Element => (
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
`transition-colors ![mask-position:center] ![mask-repeat:no-repeat] ![mask-size:contain]`,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={{ mask: `url('${src}')`, WebkitMask: `url('${src}')` }}
|
|
||||||
/>
|
|
||||||
);
|
|
|
@ -1,61 +0,0 @@
|
||||||
import { MouseEventHandler, useState } from "react";
|
|
||||||
import { cJoin, cIf } from "helpers/className";
|
|
||||||
import { Link } from "components/Inputs/Link";
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
border?: boolean;
|
|
||||||
active?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
href: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
onFocusChanged?: (isFocused: boolean) => void;
|
|
||||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const DownPressable = ({
|
|
||||||
href,
|
|
||||||
border = false,
|
|
||||||
active = false,
|
|
||||||
disabled = false,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
onFocusChanged,
|
|
||||||
onClick,
|
|
||||||
}: Props): JSX.Element => {
|
|
||||||
const [isFocused, setFocused] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
onClick={onClick}
|
|
||||||
onFocusChanged={(focus) => {
|
|
||||||
setFocused(focus);
|
|
||||||
onFocusChanged?.(focus);
|
|
||||||
}}
|
|
||||||
className={cJoin(
|
|
||||||
`rounded-2xl p-4 transition-all`,
|
|
||||||
cIf(border, "outline outline-2 -outline-offset-2 outline-mid"),
|
|
||||||
cIf(active, "!bg-mid shadow-inner-sm outline-transparent shadow-shade"),
|
|
||||||
cIf(
|
|
||||||
disabled,
|
|
||||||
"cursor-not-allowed select-none opacity-50 grayscale",
|
|
||||||
cJoin(
|
|
||||||
"cursor-pointer hover:bg-mid hover:shadow-inner-sm hover:shadow-shade",
|
|
||||||
cIf(isFocused, "!shadow-inner !shadow-shade"),
|
|
||||||
cIf(border, "hover:outline-transparent")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={disabled}>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,79 +0,0 @@
|
||||||
import { useHotkeys } from "react-hotkeys-hook";
|
|
||||||
import { Ico } from "components/Ico";
|
|
||||||
import { PageSelector } from "components/Inputs/PageSelector";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { isUndefined } from "helpers/asserts";
|
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
import { useScrollTopOnChange } from "hooks/useScrollOnChange";
|
|
||||||
import { Ids } from "types/ids";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
page: number;
|
|
||||||
onPageChange: (newPage: number) => void;
|
|
||||||
totalNumberOfPages: number | null | undefined;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const Paginator = ({
|
|
||||||
page,
|
|
||||||
onPageChange,
|
|
||||||
totalNumberOfPages,
|
|
||||||
children,
|
|
||||||
}: Props): JSX.Element => {
|
|
||||||
useScrollTopOnChange(Ids.ContentPanel, [page]);
|
|
||||||
useHotkeys("left", () => onPageChange(page - 1), { enabled: page > 1 }, [page]);
|
|
||||||
useHotkeys("right", () => onPageChange(page + 1), { enabled: page < (totalNumberOfPages ?? 0) }, [
|
|
||||||
page,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (totalNumberOfPages === 0) return <DefaultRenderWhenEmpty />;
|
|
||||||
if (isUndefined(totalNumberOfPages) || totalNumberOfPages < 2) return <>{children}</>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageSelector
|
|
||||||
page={page}
|
|
||||||
onChange={onPageChange}
|
|
||||||
pagesCount={totalNumberOfPages}
|
|
||||||
className="mb-12"
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
<PageSelector
|
|
||||||
page={page}
|
|
||||||
onChange={onPageChange}
|
|
||||||
pagesCount={totalNumberOfPages}
|
|
||||||
className="mt-12"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DefaultRenderWhenEmpty = () => {
|
|
||||||
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
|
|
||||||
const { format } = useFormat();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid h-full place-content-center">
|
|
||||||
<div
|
|
||||||
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
|
|
||||||
border-dark p-8 text-dark opacity-40">
|
|
||||||
{is3ColumnsLayout && <Ico icon="chevron_left" className="!text-[300%]" />}
|
|
||||||
<p className="max-w-xs text-2xl">{format("no_results_message")}</p>
|
|
||||||
{!is3ColumnsLayout && <Ico icon="chevron_right" className="!text-[300%]" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,102 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useHotkeys } from "react-hotkeys-hook";
|
|
||||||
import { cIf, cJoin } from "helpers/className";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { useAtomGetter, useAtomSetter } from "helpers/atoms";
|
|
||||||
import { Button } from "components/Inputs/Button";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onOpen?: () => void;
|
|
||||||
onCloseRequest?: () => void;
|
|
||||||
isVisible: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
fillViewport?: boolean;
|
|
||||||
padding?: boolean;
|
|
||||||
withCloseButton?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const Popup = ({
|
|
||||||
onOpen,
|
|
||||||
onCloseRequest,
|
|
||||||
isVisible,
|
|
||||||
children,
|
|
||||||
fillViewport,
|
|
||||||
padding = true,
|
|
||||||
withCloseButton = true,
|
|
||||||
}: Props): JSX.Element => {
|
|
||||||
const setMenuGesturesEnabled = useAtomSetter(atoms.layout.menuGesturesEnabled);
|
|
||||||
const [isHidden, setHidden] = useState(!isVisible);
|
|
||||||
const [isActuallyVisible, setActuallyVisible] = useState(isVisible && !isHidden);
|
|
||||||
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
|
||||||
|
|
||||||
useHotkeys("escape", () => onCloseRequest?.(), { enabled: isVisible }, [onCloseRequest]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMenuGesturesEnabled(!isVisible);
|
|
||||||
}, [isVisible, setMenuGesturesEnabled]);
|
|
||||||
|
|
||||||
// Used to unload the component if not visible
|
|
||||||
useEffect(() => {
|
|
||||||
const timeouts: NodeJS.Timeout[] = [];
|
|
||||||
if (isVisible) {
|
|
||||||
setHidden(false);
|
|
||||||
// We delay the visiblity of the element so that the opening animation is played
|
|
||||||
timeouts.push(
|
|
||||||
setTimeout(() => {
|
|
||||||
setActuallyVisible(true);
|
|
||||||
onOpen?.();
|
|
||||||
}, 100)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setActuallyVisible(false);
|
|
||||||
timeouts.push(setTimeout(() => setHidden(true), 600));
|
|
||||||
}
|
|
||||||
return () => timeouts.forEach(clearTimeout);
|
|
||||||
}, [isVisible, onOpen]);
|
|
||||||
|
|
||||||
return isHidden ? (
|
|
||||||
<></>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
"fixed inset-0 z-50 grid place-content-center transition-filter duration-500",
|
|
||||||
cIf(!isActuallyVisible, "pointer-events-none touch-none"),
|
|
||||||
cIf(isActuallyVisible && !isPerfModeEnabled, "backdrop-blur")
|
|
||||||
)}>
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
"fixed inset-0 transition-colors duration-500",
|
|
||||||
cIf(isActuallyVisible, "bg-shade/50", "bg-shade/0")
|
|
||||||
)}
|
|
||||||
onClick={onCloseRequest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
`grid place-items-center gap-4 rounded-lg bg-light shadow-2xl transition-transform
|
|
||||||
shadow-shade`,
|
|
||||||
cIf(padding, "p-10"),
|
|
||||||
cIf(isActuallyVisible, "scale-100", "scale-0"),
|
|
||||||
cIf(
|
|
||||||
fillViewport,
|
|
||||||
"absolute inset-10 content-start overflow-scroll",
|
|
||||||
"relative max-h-[80vh] overflow-y-auto"
|
|
||||||
)
|
|
||||||
)}>
|
|
||||||
{withCloseButton && (
|
|
||||||
<div className="absolute right-6 top-6">
|
|
||||||
<Button icon="close" onClick={onCloseRequest} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,58 +0,0 @@
|
||||||
import { MouseEventHandler, useState } from "react";
|
|
||||||
import { Link } from "components/Inputs/Link";
|
|
||||||
import { cIf, cJoin } from "helpers/className";
|
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: React.ReactNode;
|
|
||||||
href: string;
|
|
||||||
className?: string;
|
|
||||||
noBackground?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const UpPressable = ({
|
|
||||||
children,
|
|
||||||
href,
|
|
||||||
className,
|
|
||||||
disabled = false,
|
|
||||||
noBackground = false,
|
|
||||||
onClick,
|
|
||||||
}: Props): JSX.Element => {
|
|
||||||
const [isFocused, setFocused] = useState(false);
|
|
||||||
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
onFocusChanged={setFocused}
|
|
||||||
onClick={onClick}
|
|
||||||
className={cJoin(
|
|
||||||
"transition-all duration-300 !shadow-shade",
|
|
||||||
cIf(isPerfModeEnabled, "shadow-lg", "drop-shadow-lg"),
|
|
||||||
cIf(!noBackground, "overflow-hidden rounded-md bg-highlight"),
|
|
||||||
cIf(
|
|
||||||
disabled,
|
|
||||||
"cursor-not-allowed opacity-50 grayscale",
|
|
||||||
cJoin(
|
|
||||||
"cursor-pointer hover:scale-102",
|
|
||||||
cIf(isPerfModeEnabled, "hover:shadow-xl", "hover:drop-shadow-xl"),
|
|
||||||
cIf(isFocused, "hover:scale-105 hover:duration-100")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={disabled}>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,60 +0,0 @@
|
||||||
import { useRef } from "react";
|
|
||||||
import { Button, TranslatedButton } from "components/Inputs/Button";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { ParentFolderPreviewFragment } from "graphql/generated";
|
|
||||||
import { useAtomSetter } from "helpers/atoms";
|
|
||||||
import { useScrollRightOnChange } from "hooks/useScrollOnChange";
|
|
||||||
import { Ids } from "types/ids";
|
|
||||||
import { filterHasAttributes } from "helpers/asserts";
|
|
||||||
import { prettySlug } from "helpers/formatters";
|
|
||||||
import { Ico } from "components/Ico";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
path: ParentFolderPreviewFragment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FolderPath = ({ path }: Props): JSX.Element => {
|
|
||||||
useScrollRightOnChange(Ids.ContentsFolderPath, [path]);
|
|
||||||
const setMenuGesturesEnabled = useAtomSetter(atoms.layout.menuGesturesEnabled);
|
|
||||||
const gestureReenableTimeout = useRef<NodeJS.Timeout>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid">
|
|
||||||
<div
|
|
||||||
id={Ids.ContentsFolderPath}
|
|
||||||
onPointerEnter={() => {
|
|
||||||
if (gestureReenableTimeout.current) clearTimeout(gestureReenableTimeout.current);
|
|
||||||
setMenuGesturesEnabled(false);
|
|
||||||
}}
|
|
||||||
onPointerLeave={() => {
|
|
||||||
gestureReenableTimeout.current = setTimeout(() => setMenuGesturesEnabled(true), 500);
|
|
||||||
}}
|
|
||||||
className={`-mx-4 flex place-items-center justify-start gap-x-1 gap-y-4
|
|
||||||
overflow-x-auto px-4 pb-10 scrollbar-none`}>
|
|
||||||
{path.map((pathFolder, index) => (
|
|
||||||
<>
|
|
||||||
{pathFolder.slug === "root" ? (
|
|
||||||
<Button href="/contents" icon="home" active={index === path.length - 1} />
|
|
||||||
) : (
|
|
||||||
<TranslatedButton
|
|
||||||
className="w-max"
|
|
||||||
href={`/contents/folder/${pathFolder.slug}`}
|
|
||||||
translations={filterHasAttributes(pathFolder.titles, [
|
|
||||||
"language.data.attributes.code",
|
|
||||||
]).map((title) => ({
|
|
||||||
language: title.language.data.attributes.code,
|
|
||||||
text: title.title,
|
|
||||||
}))}
|
|
||||||
fallback={{
|
|
||||||
text: prettySlug(pathFolder.slug),
|
|
||||||
}}
|
|
||||||
active={index === path.length - 1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{index < path.length - 1 && <Ico icon="chevron_right" />}
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,48 +0,0 @@
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
|
||||||
import { TranslatedProps } from "types/TranslatedProps";
|
|
||||||
import { UpPressable } from "components/Containers/UpPressable";
|
|
||||||
import { cIf, cJoin } from "helpers/className";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface PreviewFolderProps {
|
|
||||||
href: string;
|
|
||||||
title?: string | null;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const PreviewFolder = ({ href, title, disabled }: PreviewFolderProps): JSX.Element => (
|
|
||||||
<UpPressable href={href} disabled={disabled}>
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
`flex w-full cursor-pointer flex-row place-content-center place-items-center gap-4
|
|
||||||
p-6`,
|
|
||||||
cIf(disabled, "pointer-events-none touch-none select-none")
|
|
||||||
)}>
|
|
||||||
{title && <p className="text-center font-headers text-lg font-bold leading-none">{title}</p>}
|
|
||||||
</div>
|
|
||||||
</UpPressable>
|
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const TranslatedPreviewFolder = ({
|
|
||||||
translations,
|
|
||||||
fallback,
|
|
||||||
...otherProps
|
|
||||||
}: TranslatedProps<PreviewFolderProps, "title">): JSX.Element => {
|
|
||||||
const [selectedTranslation] = useSmartLanguage({
|
|
||||||
items: translations,
|
|
||||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
|
||||||
});
|
|
||||||
return <PreviewFolder title={selectedTranslation?.title ?? fallback.title} {...otherProps} />;
|
|
||||||
};
|
|
|
@ -1,126 +0,0 @@
|
||||||
import { Chip } from "components/Chip";
|
|
||||||
import { Markdawn } from "components/Markdown/Markdawn";
|
|
||||||
import { RecorderChip } from "components/RecorderChip";
|
|
||||||
import { ToolTip } from "components/ToolTip";
|
|
||||||
import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
|
|
||||||
import { ContentStatus, useFormat } from "hooks/useFormat";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
languageCode?: string;
|
|
||||||
sourceLanguageCode?: string;
|
|
||||||
status?: ContentStatus | null;
|
|
||||||
transcribers?: RecorderChipsProps["recorders"];
|
|
||||||
translators?: RecorderChipsProps["recorders"];
|
|
||||||
proofreaders?: RecorderChipsProps["recorders"];
|
|
||||||
dubbers?: RecorderChipsProps["recorders"];
|
|
||||||
subbers?: RecorderChipsProps["recorders"];
|
|
||||||
authors?: RecorderChipsProps["recorders"];
|
|
||||||
notes?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const Credits = ({
|
|
||||||
languageCode,
|
|
||||||
sourceLanguageCode,
|
|
||||||
status,
|
|
||||||
transcribers = [],
|
|
||||||
translators = [],
|
|
||||||
dubbers = [],
|
|
||||||
proofreaders = [],
|
|
||||||
subbers = [],
|
|
||||||
authors = [],
|
|
||||||
notes,
|
|
||||||
}: Props): JSX.Element => {
|
|
||||||
const { format, formatStatusDescription, formatStatusLabel, formatLanguage } = useFormat();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid place-items-center gap-5">
|
|
||||||
{isDefined(languageCode) && isDefined(sourceLanguageCode) && (
|
|
||||||
<>
|
|
||||||
{languageCode === sourceLanguageCode ? (
|
|
||||||
<h2 className="text-xl">{format("transcript_notice")}</h2>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h2 className="text-xl">{format("translation_notice")}</h2>
|
|
||||||
<div className="flex flex-wrap place-content-center place-items-center gap-2">
|
|
||||||
<p className="font-headers font-bold">{format("source_language")}:</p>
|
|
||||||
<Chip text={formatLanguage(sourceLanguageCode)} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status && (
|
|
||||||
<div className="flex flex-wrap place-content-center place-items-center gap-2">
|
|
||||||
<p className="font-headers font-bold">{format("status")}:</p>
|
|
||||||
<ToolTip content={formatStatusDescription(status)} maxWidth={"20rem"}>
|
|
||||||
<Chip text={formatStatusLabel(status)} />
|
|
||||||
</ToolTip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{transcribers.length > 0 && (
|
|
||||||
<RecorderChips
|
|
||||||
title={format("transcriber", { count: transcribers.length })}
|
|
||||||
recorders={transcribers}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{translators.length > 0 && (
|
|
||||||
<RecorderChips
|
|
||||||
title={format("translator", { count: translators.length })}
|
|
||||||
recorders={translators}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{proofreaders.length > 0 && (
|
|
||||||
<RecorderChips
|
|
||||||
title={format("proofreader", { count: proofreaders.length })}
|
|
||||||
recorders={proofreaders}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dubbers.length > 0 && (
|
|
||||||
<RecorderChips title={format("dubber", { count: dubbers.length })} recorders={dubbers} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{subbers.length > 0 && (
|
|
||||||
<RecorderChips title={format("subber", { count: subbers.length })} recorders={subbers} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{authors.length > 0 && (
|
|
||||||
<RecorderChips title={format("author", { count: authors.length })} recorders={authors} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDefinedAndNotEmpty(notes) && (
|
|
||||||
<div>
|
|
||||||
<p className="font-headers font-bold">{format("notes")}:</p>
|
|
||||||
<div className="grid place-content-center place-items-center gap-2">
|
|
||||||
<Markdawn text={notes} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RecorderChipsProps {
|
|
||||||
title: string;
|
|
||||||
recorders: { attributes?: { username: string } | null }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const RecorderChips = ({ title, recorders }: RecorderChipsProps) => (
|
|
||||||
<div className="flex flex-wrap place-content-center place-items-center gap-1">
|
|
||||||
<p className="pr-1 font-headers font-bold">{title}:</p>
|
|
||||||
{filterHasAttributes(recorders, ["attributes"]).map((recorder) => (
|
|
||||||
<RecorderChip key={recorder.attributes.username} username={recorder.attributes.username} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
|
@ -12,5 +12,10 @@ interface Props {
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const HorizontalLine = ({ className }: Props): JSX.Element => (
|
export const HorizontalLine = ({ className }: Props): JSX.Element => (
|
||||||
<div className={cJoin("my-8 h-0 w-full border-t-2 border-dotted border-black", className)} />
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
"my-8 h-0 w-full border-t-[3px] border-dotted border-black",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,45 +1,39 @@
|
||||||
import { DetailedHTMLProps, ImgHTMLAttributes } from "react";
|
import { DetailedHTMLProps, ImgHTMLAttributes } from "react";
|
||||||
import { UploadImageFragment } from "graphql/generated";
|
import { UploadImageFragment } from "graphql/generated";
|
||||||
import { getAssetURL, getImgSizesByQuality, ImageQuality } from "helpers/img";
|
import { getAssetURL, ImageQuality } from "helpers/img";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
* ────────────────────────────────────────╯ COMPONENT ╰──────────────────────────────────────────
|
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Props
|
interface Props
|
||||||
extends Omit<DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, "src"> {
|
extends Omit<
|
||||||
|
DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>,
|
||||||
|
"src"
|
||||||
|
> {
|
||||||
src: UploadImageFragment | string;
|
src: UploadImageFragment | string;
|
||||||
quality?: ImageQuality;
|
quality?: ImageQuality;
|
||||||
sizeMultiplicator?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const Img = ({
|
export const Img = ({
|
||||||
className,
|
className,
|
||||||
src: propsSrc,
|
src: rawSrc,
|
||||||
quality = ImageQuality.Small,
|
quality = ImageQuality.Small,
|
||||||
alt,
|
alt,
|
||||||
loading = "lazy",
|
loading = "lazy",
|
||||||
height,
|
|
||||||
width,
|
|
||||||
...otherProps
|
...otherProps
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const src = typeof propsSrc === "string" ? propsSrc : getAssetURL(propsSrc.url, quality);
|
const src =
|
||||||
const size =
|
typeof rawSrc === "string" ? rawSrc : getAssetURL(rawSrc.url, quality);
|
||||||
typeof propsSrc === "string"
|
|
||||||
? { width, height }
|
|
||||||
: getImgSizesByQuality(propsSrc.width ?? 0, propsSrc.height ?? 0, quality);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className={className}
|
className={className}
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
height={size.height}
|
|
||||||
width={size.width}
|
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { MouseEventHandler, useCallback } from "react";
|
import React, { MouseEventHandler } from "react";
|
||||||
import { MaterialSymbol } from "material-symbols";
|
|
||||||
import { Link } from "./Link";
|
import { Link } from "./Link";
|
||||||
import { Ico } from "components/Ico";
|
import { Ico, Icon } from "components/Ico";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cIf, cJoin } from "helpers/className";
|
||||||
import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
|
import { ConditionalWrapper, Wrapper } from "helpers/component";
|
||||||
import { TranslatedProps } from "types/TranslatedProps";
|
import { isDefined, isDefinedAndNotEmpty } from "helpers/others";
|
||||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -17,16 +15,14 @@ interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
icon?: MaterialSymbol;
|
icon?: Icon;
|
||||||
text?: string | null | undefined;
|
text?: string | null | undefined;
|
||||||
alwaysNewTab?: boolean;
|
alwaysNewTab?: boolean;
|
||||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||||
onMouseUp?: MouseEventHandler<HTMLButtonElement>;
|
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
badgeNumber?: number;
|
badgeNumber?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: "normal" | "small";
|
size?: "normal" | "small";
|
||||||
type?: "button" | "reset" | "submit";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
@ -35,8 +31,7 @@ export const Button = ({
|
||||||
draggable,
|
draggable,
|
||||||
id,
|
id,
|
||||||
onClick,
|
onClick,
|
||||||
onMouseUp,
|
active,
|
||||||
active = false,
|
|
||||||
className,
|
className,
|
||||||
icon,
|
icon,
|
||||||
text,
|
text,
|
||||||
|
@ -44,75 +39,72 @@ export const Button = ({
|
||||||
alwaysNewTab = false,
|
alwaysNewTab = false,
|
||||||
badgeNumber,
|
badgeNumber,
|
||||||
disabled,
|
disabled,
|
||||||
type,
|
|
||||||
size = "normal",
|
size = "normal",
|
||||||
}: Props): JSX.Element => (
|
}: Props): JSX.Element => (
|
||||||
<Link href={href} alwaysNewTab={alwaysNewTab} disabled={disabled}>
|
<ConditionalWrapper
|
||||||
|
isWrapping={isDefinedAndNotEmpty(href)}
|
||||||
|
wrapperProps={{ href: href ?? "", alwaysNewTab }}
|
||||||
|
wrapper={LinkWrapper}
|
||||||
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<div
|
||||||
type={type}
|
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
id={id}
|
id={id}
|
||||||
disabled={disabled}
|
onClick={onClick}
|
||||||
onClick={(event) => onClick?.(event)}
|
|
||||||
onMouseUp={onMouseUp}
|
|
||||||
onFocus={(event) => event.target.blur()}
|
onFocus={(event) => event.target.blur()}
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
`group grid w-full grid-flow-col
|
`group grid cursor-pointer select-none grid-flow-col place-content-center
|
||||||
place-content-center place-items-center gap-2 rounded-full border
|
place-items-center gap-2 rounded-full border-[1px] border-dark py-3 px-4
|
||||||
border-dark leading-none text-dark transition-all
|
leading-none text-dark transition-all`,
|
||||||
disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale`,
|
|
||||||
cIf(size === "small", "h-6 px-3 py-1 text-xs", "h-10 px-4 py-3"),
|
|
||||||
cIf(active, "!border-black bg-black !text-light shadow-lg shadow-black"),
|
|
||||||
cIf(
|
cIf(
|
||||||
!disabled && !active,
|
active,
|
||||||
`shadow-shade hover:bg-dark hover:text-light hover:shadow-lg hover:shadow-shade
|
"!border-black bg-black !text-light drop-shadow-black-lg",
|
||||||
active:hover:!border-black active:hover:bg-black active:hover:!text-light
|
`hover:bg-dark hover:text-light hover:drop-shadow-shade-lg active:hover:!border-black
|
||||||
active:hover:shadow-lg active:hover:shadow-black`
|
active:hover:bg-black active:hover:!text-light active:hover:drop-shadow-black-lg`
|
||||||
),
|
),
|
||||||
|
cIf(size === "small", "px-3 py-1 text-xs"),
|
||||||
|
cIf(disabled, "cursor-not-allowed"),
|
||||||
className
|
className
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{isDefined(badgeNumber) && (
|
{isDefined(badgeNumber) && (
|
||||||
<div
|
<div
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
`absolute grid place-items-center rounded-full bg-dark
|
`absolute -top-3 -right-2 grid h-8 w-8 place-items-center
|
||||||
font-bold text-light transition-opacity group-hover:opacity-0`,
|
rounded-full bg-dark font-bold text-light transition-opacity group-hover:opacity-0`,
|
||||||
cIf(size === "small", "-right-2 -top-2 h-5 w-5", "-right-2 -top-3 h-8 w-8")
|
cIf(size === "small", "-top-2 -right-2 h-5 w-5")
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<p className="-translate-y-[0.05em]">{badgeNumber}</p>
|
<p className="-translate-y-[0.05em]">{badgeNumber}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isDefinedAndNotEmpty(icon) && (
|
{isDefinedAndNotEmpty(icon) && (
|
||||||
<Ico
|
<Ico className="[font-size:150%] [line-height:0.66]" icon={icon} />
|
||||||
className="![font-size:150%] ![line-height:0.66]"
|
|
||||||
icon={icon}
|
|
||||||
isFilled={active}
|
|
||||||
opticalSize={size === "normal" ? 24 : 20}
|
|
||||||
weight={size === "normal" ? 500 : 800}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{isDefinedAndNotEmpty(text) && (
|
{isDefinedAndNotEmpty(text) && (
|
||||||
<p className="line-clamp-1 -translate-y-[0.05em] text-center leading-5">{text}</p>
|
<p className="-translate-y-[0.05em] text-center">{text}</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</ConditionalWrapper>
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭──────────────────────╮
|
* ╭──────────────────────╮
|
||||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const TranslatedButton = ({
|
interface LinkWrapperProps {
|
||||||
translations,
|
href: string;
|
||||||
fallback,
|
alwaysNewTab: boolean;
|
||||||
...otherProps
|
}
|
||||||
}: TranslatedProps<Props, "text">): JSX.Element => {
|
|
||||||
const [selectedTranslation] = useSmartLanguage({
|
|
||||||
items: translations,
|
|
||||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
|
||||||
});
|
|
||||||
|
|
||||||
return <Button text={selectedTranslation?.text ?? fallback.text} {...otherProps} />;
|
const LinkWrapper = ({
|
||||||
};
|
children,
|
||||||
|
alwaysNewTab,
|
||||||
|
href,
|
||||||
|
}: LinkWrapperProps & Wrapper) => (
|
||||||
|
<Link href={href} alwaysNewTab={alwaysNewTab}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
|
@ -1,25 +1,18 @@
|
||||||
import type { Placement } from "tippy.js";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { ToolTip } from "components/ToolTip";
|
import { ToolTip } from "components/ToolTip";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cJoin } from "helpers/className";
|
||||||
import { ConditionalWrapper, Wrapper } from "helpers/component";
|
import { ConditionalWrapper, Wrapper } from "helpers/component";
|
||||||
import { isDefined } from "helpers/asserts";
|
import { isDefinedAndNotEmpty } from "helpers/others";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type ButtonProps = Parameters<typeof Button>[0];
|
interface Props {
|
||||||
|
|
||||||
export interface ButtonGroupProps {
|
|
||||||
className?: string;
|
className?: string;
|
||||||
vertical?: boolean;
|
buttonsProps: (Parameters<typeof Button>[0] & {
|
||||||
size?: ButtonProps["size"];
|
tooltip?: string | null | undefined;
|
||||||
buttonsProps: (Omit<ButtonProps, "size"> & {
|
|
||||||
visible?: boolean;
|
|
||||||
tooltip?: React.ReactNode | null | undefined;
|
|
||||||
tooltipPlacement?: Placement;
|
|
||||||
})[];
|
})[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,83 +21,41 @@ export interface ButtonGroupProps {
|
||||||
export const ButtonGroup = ({
|
export const ButtonGroup = ({
|
||||||
buttonsProps,
|
buttonsProps,
|
||||||
className,
|
className,
|
||||||
vertical,
|
}: Props): JSX.Element => (
|
||||||
size,
|
<div className={cJoin("grid grid-flow-col", className)}>
|
||||||
}: ButtonGroupProps): JSX.Element => (
|
{buttonsProps.map((buttonProps, index) => (
|
||||||
<FilteredButtonGroup
|
<ConditionalWrapper
|
||||||
buttonsProps={buttonsProps.filter((button) => button.visible !== false)}
|
key={index}
|
||||||
className={className}
|
isWrapping={isDefinedAndNotEmpty(buttonProps.tooltip)}
|
||||||
vertical={vertical}
|
wrapper={ToolTipWrapper}
|
||||||
size={size}
|
wrapperProps={{ text: buttonProps.tooltip ?? "" }}
|
||||||
/>
|
>
|
||||||
|
<Button
|
||||||
|
{...buttonProps}
|
||||||
|
className={
|
||||||
|
index === 0
|
||||||
|
? "rounded-r-none border-r-0"
|
||||||
|
: index === buttonsProps.length - 1
|
||||||
|
? "rounded-l-none"
|
||||||
|
: "rounded-none border-r-0"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ConditionalWrapper>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const FilteredButtonGroup = ({
|
|
||||||
buttonsProps,
|
|
||||||
className,
|
|
||||||
vertical = false,
|
|
||||||
size = "normal",
|
|
||||||
}: ButtonGroupProps) => {
|
|
||||||
const firstClassName = cIf(
|
|
||||||
vertical,
|
|
||||||
cJoin("rounded-b-none border-b-0", cIf(size === "normal", "rounded-t-3xl", "rounded-t-xl")),
|
|
||||||
"rounded-r-none border-r-0"
|
|
||||||
);
|
|
||||||
|
|
||||||
const lastClassName = cIf(
|
|
||||||
vertical,
|
|
||||||
cJoin("rounded-t-none border-t-0", cIf(size === "normal", "rounded-b-3xl", "rounded-b-xl")),
|
|
||||||
"rounded-l-none border-l-0"
|
|
||||||
);
|
|
||||||
|
|
||||||
const middleClassName = cIf(vertical, "rounded-none border-y-0", "rounded-none border-x-0");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cJoin("grid", cIf(!vertical, "grid-flow-col"), className)}>
|
|
||||||
{buttonsProps.map((buttonProps, index) => (
|
|
||||||
<ConditionalWrapper
|
|
||||||
key={index}
|
|
||||||
isWrapping={isDefined(buttonProps.tooltip)}
|
|
||||||
wrapper={ToolTipWrapper}
|
|
||||||
wrapperProps={{
|
|
||||||
text: buttonProps.tooltip ?? "",
|
|
||||||
placement: buttonProps.tooltipPlacement,
|
|
||||||
}}>
|
|
||||||
<Button
|
|
||||||
{...buttonProps}
|
|
||||||
size={size}
|
|
||||||
className={cJoin(
|
|
||||||
"relative",
|
|
||||||
cIf(
|
|
||||||
vertical && buttonProps.active && index < buttonsProps.length - 1,
|
|
||||||
"shadow-black/60"
|
|
||||||
),
|
|
||||||
cIf(buttonProps.active, "z-10", "z-0"),
|
|
||||||
index === 0
|
|
||||||
? firstClassName
|
|
||||||
: index === buttonsProps.length - 1
|
|
||||||
? lastClassName
|
|
||||||
: middleClassName
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</ConditionalWrapper>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭──────────────────────╮
|
* ╭──────────────────────╮
|
||||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface ToolTipWrapperProps {
|
interface ToolTipWrapperProps {
|
||||||
text: React.ReactNode;
|
text: string;
|
||||||
placement?: Placement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolTipWrapper = ({ text, children, placement }: ToolTipWrapperProps & Wrapper) => (
|
const ToolTipWrapper = ({ text, children }: ToolTipWrapperProps & Wrapper) => (
|
||||||
<ToolTip content={text} placement={placement}>
|
<ToolTip content={text}>
|
||||||
<>{children}</>
|
<>{children}</>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { ToolTip } from "../ToolTip";
|
import { ToolTip } from "../ToolTip";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
import { Icon } from "components/Ico";
|
||||||
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
import { cJoin } from "helpers/className";
|
import { cJoin } from "helpers/className";
|
||||||
|
import { prettyLanguage } from "helpers/formatters";
|
||||||
import { iterateMap } from "helpers/others";
|
import { iterateMap } from "helpers/others";
|
||||||
import { sendAnalytics } from "helpers/analytics";
|
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -13,6 +14,7 @@ import { useFormat } from "hooks/useFormat";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
languages: AppStaticProps["languages"];
|
||||||
locales: Map<string, number>;
|
locales: Map<string, number>;
|
||||||
localesIndex: number | undefined;
|
localesIndex: number | undefined;
|
||||||
onLanguageChanged: (index: number) => void;
|
onLanguageChanged: (index: number) => void;
|
||||||
|
@ -26,34 +28,30 @@ export const LanguageSwitcher = ({
|
||||||
className,
|
className,
|
||||||
locales,
|
locales,
|
||||||
localesIndex,
|
localesIndex,
|
||||||
|
languages,
|
||||||
size,
|
size,
|
||||||
onLanguageChanged,
|
onLanguageChanged,
|
||||||
showBadge = true,
|
showBadge = true,
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => (
|
||||||
const { formatLanguage } = useFormat();
|
<ToolTip
|
||||||
return (
|
content={
|
||||||
<ToolTip
|
<div className={cJoin("flex flex-col gap-2", className)}>
|
||||||
content={
|
{iterateMap(locales, (locale, value, index) => (
|
||||||
<div className={cJoin("flex flex-col gap-2", className)}>
|
<Fragment key={index}>
|
||||||
{iterateMap(locales, (locale, value, index) => (
|
<Button
|
||||||
<Fragment key={index}>
|
active={value === localesIndex}
|
||||||
<Button
|
onClick={() => onLanguageChanged(value)}
|
||||||
active={value === localesIndex}
|
text={prettyLanguage(locale, languages)}
|
||||||
onClick={() => {
|
/>
|
||||||
onLanguageChanged(value);
|
</Fragment>
|
||||||
sendAnalytics("Language Switcher", `Switch language (${locale})`);
|
))}
|
||||||
}}
|
</div>
|
||||||
text={formatLanguage(locale)}
|
}
|
||||||
/>
|
>
|
||||||
</Fragment>
|
<Button
|
||||||
))}
|
badgeNumber={showBadge && locales.size > 1 ? locales.size : undefined}
|
||||||
</div>
|
icon={Icon.Translate}
|
||||||
}>
|
size={size}
|
||||||
<Button
|
/>
|
||||||
badgeNumber={showBadge && locales.size > 1 ? locales.size : undefined}
|
</ToolTip>
|
||||||
icon="translate"
|
);
|
||||||
size={size}
|
|
||||||
/>
|
|
||||||
</ToolTip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,100 +1,62 @@
|
||||||
import React, { MouseEventHandler } from "react";
|
import router from "next/router";
|
||||||
import NextLink from "next/link";
|
import { MouseEventHandler, useState } from "react";
|
||||||
import { ConditionalWrapper, Wrapper } from "helpers/component";
|
import { isDefined } from "helpers/others";
|
||||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
|
||||||
import { cIf, cJoin } from "helpers/className";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
href: string | null | undefined;
|
href: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
allowNewTab?: boolean;
|
||||||
alwaysNewTab?: boolean;
|
alwaysNewTab?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||||
onFocusChanged?: (isFocused: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
linkStyled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
export const Link = ({
|
export const Link = ({
|
||||||
href,
|
href,
|
||||||
children,
|
allowNewTab = true,
|
||||||
className,
|
|
||||||
alwaysNewTab,
|
|
||||||
disabled,
|
|
||||||
linkStyled = false,
|
|
||||||
onClick,
|
|
||||||
onFocusChanged,
|
|
||||||
}: Props): JSX.Element => (
|
|
||||||
<ConditionalWrapper
|
|
||||||
isWrapping={isDefinedAndNotEmpty(href) && !disabled}
|
|
||||||
wrapperProps={{
|
|
||||||
href: href ?? "",
|
|
||||||
alwaysNewTab,
|
|
||||||
onClick,
|
|
||||||
onFocusChanged,
|
|
||||||
className: cJoin(
|
|
||||||
cIf(
|
|
||||||
linkStyled,
|
|
||||||
`underline decoration-dark decoration-dotted underline-offset-2 transition-colors
|
|
||||||
hover:text-dark`
|
|
||||||
),
|
|
||||||
className
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
wrapper={LinkWrapper}
|
|
||||||
wrapperFalse={DisabledWrapper}
|
|
||||||
wrapperFalseProps={{ className }}>
|
|
||||||
{children}
|
|
||||||
</ConditionalWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface LinkWrapperProps {
|
|
||||||
href: string;
|
|
||||||
className?: string;
|
|
||||||
alwaysNewTab?: boolean;
|
|
||||||
onFocusChanged?: (isFocused: boolean) => void;
|
|
||||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LinkWrapper = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
onFocusChanged,
|
|
||||||
onClick,
|
|
||||||
alwaysNewTab = false,
|
alwaysNewTab = false,
|
||||||
href,
|
children,
|
||||||
}: LinkWrapperProps & Wrapper) => (
|
className,
|
||||||
<NextLink
|
onClick,
|
||||||
href={href}
|
}: Props): JSX.Element => {
|
||||||
className={className}
|
const [isValidClick, setIsValidClick] = useState(false);
|
||||||
target={alwaysNewTab ? "_blank" : "_self"}
|
|
||||||
replace={href.startsWith("#")}
|
|
||||||
onClick={onClick}
|
|
||||||
onMouseLeave={() => onFocusChanged?.(false)}
|
|
||||||
onMouseDown={() => onFocusChanged?.(true)}
|
|
||||||
onMouseUp={() => onFocusChanged?.(false)}>
|
|
||||||
{children}
|
|
||||||
</NextLink>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
onMouseLeave={() => setIsValidClick(false)}
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsValidClick(true);
|
||||||
|
}}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
if (isDefined(onClick)) {
|
||||||
|
onClick(event);
|
||||||
|
} else if (isValidClick && href) {
|
||||||
|
if (event.button !== MouseButton.Right) {
|
||||||
|
if (alwaysNewTab) {
|
||||||
|
window.open(href, "_blank", "noopener");
|
||||||
|
} else if (event.button === MouseButton.Left) {
|
||||||
|
if (href.startsWith("#")) {
|
||||||
|
router.replace(href);
|
||||||
|
} else {
|
||||||
|
router.push(href);
|
||||||
|
}
|
||||||
|
} else if (allowNewTab) {
|
||||||
|
window.open(href, "_blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface DisabledWrapperProps {
|
enum MouseButton {
|
||||||
className?: string;
|
Left = 0,
|
||||||
|
Middle = 1,
|
||||||
|
Right = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DisabledWrapper = ({ children, className }: DisabledWrapperProps & Wrapper) => (
|
|
||||||
<div className={className}>{children}</div>
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { Fragment, useCallback } from "react";
|
import { Fragment, useCallback, useState } from "react";
|
||||||
import { Ico } from "components/Ico";
|
import { Ico, Icon } from "components/Ico";
|
||||||
import { arrayMove } from "helpers/others";
|
import { isDefinedAndNotEmpty, iterateMap, mapMoveEntry } from "helpers/others";
|
||||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -10,35 +9,36 @@ import { isDefinedAndNotEmpty } from "helpers/asserts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
items: { code: string; name: string }[];
|
items: Map<string, string>;
|
||||||
insertLabels?: { insertAt: number; name: string }[];
|
insertLabels?: Map<number, string | null | undefined>;
|
||||||
onChange?: (props: Props["items"]) => void;
|
onChange?: (items: Map<string, string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
interface InsertedLabelProps {
|
export const OrderableList = ({
|
||||||
label?: string;
|
onChange,
|
||||||
}
|
items: propsItems,
|
||||||
|
insertLabels,
|
||||||
|
}: Props): JSX.Element => {
|
||||||
|
const [items, setItems] = useState<Map<string, string>>(propsItems);
|
||||||
|
|
||||||
const InsertedLabel = ({ label }: InsertedLabelProps) => (
|
|
||||||
<>{isDefinedAndNotEmpty(label) && <p>{label}</p>}</>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Element => {
|
|
||||||
const updateOrder = useCallback(
|
const updateOrder = useCallback(
|
||||||
(sourceIndex: number, targetIndex: number) => {
|
(sourceIndex: number, targetIndex: number) => {
|
||||||
onChange?.(arrayMove(items, sourceIndex, targetIndex));
|
const newItems = mapMoveEntry(items, sourceIndex, targetIndex);
|
||||||
|
setItems(newItems);
|
||||||
|
onChange?.(newItems);
|
||||||
},
|
},
|
||||||
[items, onChange]
|
[items, onChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{items.map((item, index) => (
|
{iterateMap(items, (key, value, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={key}>
|
||||||
<InsertedLabel label={insertLabels?.[index]?.name} />
|
{insertLabels && isDefinedAndNotEmpty(insertLabels.get(index)) && (
|
||||||
|
<p>{insertLabels.get(index)}</p>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
const source = event.target as HTMLElement;
|
const source = event.target as HTMLElement;
|
||||||
|
@ -60,26 +60,30 @@ export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Ele
|
||||||
.filter((element) => element.tagName === "DIV")
|
.filter((element) => element.tagName === "DIV")
|
||||||
.indexOf(target)
|
.indexOf(target)
|
||||||
: -1;
|
: -1;
|
||||||
const sourceIndex = parseInt(event.dataTransfer.getData("text"), 10);
|
const sourceIndex = parseInt(
|
||||||
|
event.dataTransfer.getData("text"),
|
||||||
|
10
|
||||||
|
);
|
||||||
updateOrder(sourceIndex, targetIndex);
|
updateOrder(sourceIndex, targetIndex);
|
||||||
}}
|
}}
|
||||||
className="grid cursor-grab select-none grid-cols-[auto_1fr] place-content-center gap-2
|
className="grid cursor-grab select-none grid-cols-[auto_1fr] place-content-center gap-2
|
||||||
rounded-full border border-dark bg-light px-1 py-2 pr-4 text-dark transition-all
|
rounded-full border-[1px] border-dark bg-light px-1 py-2 pr-4 text-dark transition-all
|
||||||
hover:bg-dark hover:text-light hover:shadow-lg hover:shadow-shade"
|
hover:bg-dark hover:text-light hover:drop-shadow-shade-lg"
|
||||||
draggable>
|
draggable
|
||||||
|
>
|
||||||
<div className="grid grid-rows-[.8em_.8em] place-items-center">
|
<div className="grid grid-rows-[.8em_.8em] place-items-center">
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<Ico
|
<Ico
|
||||||
icon="arrow_drop_up"
|
icon={Icon.ArrowDropUp}
|
||||||
className="row-start-1 cursor-pointer"
|
className="row-start-1 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateOrder(index, index - 1);
|
updateOrder(index, index - 1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{index < items.length - 1 && (
|
{index < items.size - 1 && (
|
||||||
<Ico
|
<Ico
|
||||||
icon="arrow_drop_down"
|
icon={Icon.ArrowDropDown}
|
||||||
className="row-start-2 cursor-pointer"
|
className="row-start-2 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateOrder(index, index + 1);
|
updateOrder(index, index + 1);
|
||||||
|
@ -87,7 +91,7 @@ export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Ele
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{item.name}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { ButtonGroup } from "./ButtonGroup";
|
import { ButtonGroup } from "./ButtonGroup";
|
||||||
|
import { Icon } from "components/Ico";
|
||||||
import { cJoin } from "helpers/className";
|
import { cJoin } from "helpers/className";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -15,30 +16,35 @@ interface Props {
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const PageSelector = ({ page, className, pagesCount, onChange }: Props): JSX.Element => (
|
export const PageSelector = ({
|
||||||
|
page,
|
||||||
|
className,
|
||||||
|
pagesCount,
|
||||||
|
onChange,
|
||||||
|
}: Props): JSX.Element => (
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
className={cJoin("flex flex-row place-content-center", className)}
|
className={cJoin("flex flex-row place-content-center", className)}
|
||||||
buttonsProps={[
|
buttonsProps={[
|
||||||
{
|
{
|
||||||
onClick: () => onChange(1),
|
onClick: () => onChange(0),
|
||||||
disabled: page === 1,
|
disabled: page === 0,
|
||||||
icon: "first_page",
|
icon: Icon.FirstPage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onClick: () => page > 1 && onChange(page - 1),
|
onClick: () => page > 0 && onChange(page - 1),
|
||||||
disabled: page === 1,
|
disabled: page === 0,
|
||||||
icon: "navigate_before",
|
icon: Icon.NavigateBefore,
|
||||||
},
|
},
|
||||||
{ text: `${page} / ${pagesCount}` },
|
{ text: `${page + 1} / ${pagesCount}` },
|
||||||
{
|
{
|
||||||
onClick: () => page < pagesCount && onChange(page + 1),
|
onClick: () => page < pagesCount - 1 && onChange(page + 1),
|
||||||
disabled: page === pagesCount,
|
disabled: page === pagesCount - 1,
|
||||||
icon: "navigate_next",
|
icon: Icon.NavigateNext,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onClick: () => onChange(pagesCount),
|
onClick: () => onChange(pagesCount - 1),
|
||||||
disabled: page === pagesCount,
|
disabled: page === pagesCount - 1,
|
||||||
icon: "last_page",
|
icon: Icon.LastPage,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Fragment, useCallback, useRef } from "react";
|
import { Fragment, useCallback } from "react";
|
||||||
import { useBoolean, useOnClickOutside } from "usehooks-ts";
|
import { Ico, Icon } from "components/Ico";
|
||||||
import { Ico } from "components/Ico";
|
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cIf, cJoin } from "helpers/className";
|
||||||
|
import { useBoolean } from "hooks/useBoolean";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -15,7 +15,6 @@ interface Props {
|
||||||
allowEmpty?: boolean;
|
allowEmpty?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
@ -25,60 +24,59 @@ export const Select = ({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
allowEmpty,
|
allowEmpty,
|
||||||
disabled = false,
|
|
||||||
onChange,
|
onChange,
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const { value: isOpened, setFalse: setClosed, toggle: toggleOpened } = useBoolean(false);
|
const {
|
||||||
|
state: isOpened,
|
||||||
|
setFalse: setClosed,
|
||||||
|
toggleState: toggleOpened,
|
||||||
|
} = useBoolean(false);
|
||||||
|
|
||||||
const tryToggling = useCallback(() => {
|
const tryToggling = useCallback(() => {
|
||||||
if (disabled) return;
|
|
||||||
const optionCount = options.length + (value === -1 ? 1 : 0);
|
const optionCount = options.length + (value === -1 ? 1 : 0);
|
||||||
if (optionCount > 1) toggleOpened();
|
if (optionCount > 1) toggleOpened();
|
||||||
}, [disabled, options.length, value, toggleOpened]);
|
}, [options.length, value, toggleOpened]);
|
||||||
|
|
||||||
const onSelectionChanged = useCallback(
|
|
||||||
(newIndex: number) => {
|
|
||||||
setClosed();
|
|
||||||
onChange(newIndex);
|
|
||||||
},
|
|
||||||
[onChange, setClosed]
|
|
||||||
);
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useOnClickOutside(ref, setClosed);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
"relative text-center transition-filter",
|
"relative text-center transition-[filter]",
|
||||||
cIf(isOpened, "z-20 drop-shadow-lg shadow-shade"),
|
cIf(isOpened, "z-10 drop-shadow-shade-lg"),
|
||||||
className
|
className
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
`grid cursor-pointer select-none grid-flow-col grid-cols-[1fr_auto_auto]
|
`grid cursor-pointer grid-flow-col grid-cols-[1fr_auto_auto] place-items-center
|
||||||
place-items-center rounded-3xl p-1 outline outline-1 -outline-offset-1`,
|
rounded-[1em] bg-light p-1 outline outline-2 outline-offset-[-2px] outline-mid
|
||||||
cIf(isOpened, "rounded-b-none bg-highlight outline-transparent"),
|
transition-all hover:bg-mid hover:outline-[transparent]`,
|
||||||
cIf(
|
cIf(isOpened, "rounded-b-none bg-highlight outline-[transparent]")
|
||||||
disabled,
|
)}
|
||||||
"cursor-not-allowed text-dark opacity-50 outline-dark/60 grayscale",
|
>
|
||||||
"outline-mid transition-all hover:bg-mid hover:outline-transparent"
|
<p onClick={tryToggling} className="w-full">
|
||||||
)
|
|
||||||
)}>
|
|
||||||
<p onClick={tryToggling} className="w-full px-4 py-1">
|
|
||||||
{value === -1 ? "—" : options[value]}
|
{value === -1 ? "—" : options[value]}
|
||||||
</p>
|
</p>
|
||||||
{value >= 0 && allowEmpty && (
|
{value >= 0 && allowEmpty && (
|
||||||
<Ico
|
<Ico
|
||||||
icon="close"
|
icon={Icon.Close}
|
||||||
className="!text-xs"
|
className="!text-xs"
|
||||||
onClick={() => !disabled && onSelectionChanged(-1)}
|
onClick={() => {
|
||||||
|
setClosed();
|
||||||
|
onChange(-1);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Ico onClick={tryToggling} icon={isOpened ? "arrow_drop_up" : "arrow_drop_down"} />
|
<Ico
|
||||||
|
onClick={tryToggling}
|
||||||
|
icon={isOpened ? Icon.ArrowDropUp : Icon.ArrowDropDown}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={cJoin("left-0 right-0 rounded-b-[1em]", cIf(isOpened, "absolute", "hidden"))}>
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
"left-0 right-0 rounded-b-[1em]",
|
||||||
|
cIf(isOpened, "absolute", "hidden")
|
||||||
|
)}
|
||||||
|
>
|
||||||
{options.map((option, index) => (
|
{options.map((option, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{index !== value && (
|
{index !== value && (
|
||||||
|
@ -88,7 +86,11 @@ export const Select = ({
|
||||||
cIf(isOpened, "bg-highlight", "bg-light")
|
cIf(isOpened, "bg-highlight", "bg-light")
|
||||||
)}
|
)}
|
||||||
id={option}
|
id={option}
|
||||||
onClick={() => onSelectionChanged(index)}>
|
onClick={() => {
|
||||||
|
setClosed();
|
||||||
|
onChange(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{option}
|
{option}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cIf, cJoin } from "helpers/className";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -15,36 +14,32 @@ interface Props {
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const Switch = ({ value, onClick, className, disabled = false }: Props): JSX.Element => {
|
export const Switch = ({
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
value,
|
||||||
return (
|
onClick,
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
}: Props): JSX.Element => (
|
||||||
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
"relative grid h-6 w-12 rounded-full border-2 border-mid transition-colors",
|
||||||
|
cIf(disabled, "cursor-not-allowed", "cursor-pointer"),
|
||||||
|
cIf(value, "border-none bg-mid shadow-inner-sm shadow-shade", "bg-light"),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
`relative grid h-6 w-12 content-center rounded-full border-mid outline
|
"absolute aspect-square rounded-full bg-dark transition-transform",
|
||||||
outline-1 -outline-offset-1 transition-colors`,
|
|
||||||
cIf(value, "border-none shadow-inner-sm shadow-shade"),
|
|
||||||
cIf(disabled, "cursor-not-allowed opacity-50 grayscale", "cursor-pointer outline-mid"),
|
|
||||||
cIf(
|
cIf(
|
||||||
disabled,
|
value,
|
||||||
cIf(value, "bg-dark/40 outline-transparent", "outline-dark/60"),
|
"top-[2px] bottom-[2px] left-[2px] translate-x-[120%]",
|
||||||
cIf(value, "bg-mid outline-transparent")
|
"top-0 bottom-0 left-0"
|
||||||
),
|
)
|
||||||
className
|
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
></div>
|
||||||
if (!disabled) onClick();
|
</div>
|
||||||
}}
|
);
|
||||||
onPointerDown={() => !disabled && setIsFocused(true)}
|
|
||||||
onPointerOut={() => setIsFocused(false)}
|
|
||||||
onPointerLeave={() => setIsFocused(false)}
|
|
||||||
onPointerUp={() => setIsFocused(false)}>
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
"pointer-events-none ml-1 h-4 w-4 touch-none rounded-full bg-dark transition-transform",
|
|
||||||
cIf(value, "translate-x-6"),
|
|
||||||
cIf(isFocused, cIf(value, "translate-x-5", "translate-x-1"))
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { forwardRef } from "react";
|
import { Ico, Icon } from "components/Ico";
|
||||||
import { Ico } from "components/Ico";
|
import { cJoin } from "helpers/className";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { isDefinedAndNotEmpty } from "helpers/others";
|
||||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -13,38 +12,39 @@ interface Props {
|
||||||
onChange: (newValue: string) => void;
|
onChange: (newValue: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
placeholder?: string | null;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const TextInput = forwardRef<HTMLInputElement, Props>(
|
export const TextInput = ({
|
||||||
({ value, onChange, className, name, placeholder, disabled = false }, ref) => (
|
value,
|
||||||
<div className={cJoin("relative", className)}>
|
onChange,
|
||||||
<input
|
className,
|
||||||
ref={ref}
|
name,
|
||||||
className="w-full"
|
placeholder,
|
||||||
type="text"
|
}: Props): JSX.Element => (
|
||||||
name={name}
|
<div className={cJoin("relative", className)}>
|
||||||
autoCapitalize="off"
|
<input
|
||||||
value={value}
|
className="w-full"
|
||||||
disabled={disabled}
|
type="text"
|
||||||
placeholder={placeholder ?? undefined}
|
name={name}
|
||||||
onChange={(event) => {
|
value={value}
|
||||||
onChange(event.target.value);
|
placeholder={placeholder}
|
||||||
}}
|
onChange={(event) => {
|
||||||
/>
|
onChange(event.target.value);
|
||||||
{isDefinedAndNotEmpty(value) && (
|
}}
|
||||||
<div className="absolute bottom-0 right-4 top-0 grid place-items-center">
|
/>
|
||||||
<Ico
|
{isDefinedAndNotEmpty(value) && (
|
||||||
className={cJoin("!text-xs", cIf(disabled, "opacity-30 grayscale", "cursor-pointer"))}
|
<div className="absolute right-4 top-0 bottom-0 grid place-items-center">
|
||||||
icon="close"
|
<Ico
|
||||||
onClick={() => !disabled && onChange("")}
|
className="cursor-pointer !text-xs"
|
||||||
/>
|
icon={Icon.Close}
|
||||||
</div>
|
onClick={() => {
|
||||||
)}
|
onChange("");
|
||||||
</div>
|
}}
|
||||||
)
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
TextInput.displayName = "TextInput";
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cIf, cJoin } from "helpers/className";
|
||||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
import { isDefinedAndNotEmpty } from "helpers/others";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -8,16 +8,26 @@ import { isDefinedAndNotEmpty } from "helpers/asserts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string | null | undefined;
|
label: string | null | undefined;
|
||||||
children: React.ReactNode;
|
input: JSX.Element;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const WithLabel = ({ label, children }: Props): JSX.Element => (
|
export const WithLabel = ({ label, input, disabled }: Props): JSX.Element => (
|
||||||
<div className="flex flex-row place-content-between place-items-center gap-2">
|
<div
|
||||||
{isDefinedAndNotEmpty(label) && (
|
className={cJoin(
|
||||||
<p className={cJoin("text-left", cIf(label.length < 10, "flex-shrink-0"))}>{label}:</p>
|
"flex flex-row place-content-between place-items-center gap-2",
|
||||||
|
cIf(disabled, "text-dark brightness-150 contrast-75 grayscale")
|
||||||
)}
|
)}
|
||||||
{children}
|
>
|
||||||
|
{isDefinedAndNotEmpty(label) && (
|
||||||
|
<p
|
||||||
|
className={cJoin("text-left", cIf(label.length < 10, "flex-shrink-0"))}
|
||||||
|
>
|
||||||
|
{label}:
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{input}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -16,7 +16,11 @@ interface Props {
|
||||||
export const InsetBox = ({ id, className, children }: Props): JSX.Element => (
|
export const InsetBox = ({ id, className, children }: Props): JSX.Element => (
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
className={cJoin("w-full rounded-xl bg-mid p-8 shadow-inner-sm shadow-shade", className)}>
|
className={cJoin(
|
||||||
|
"w-full rounded-xl bg-mid p-8 shadow-inner-sm shadow-shade",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
|
@ -1,9 +1,9 @@
|
||||||
|
import { Icon } from "components/Ico";
|
||||||
import { Button } from "components/Inputs/Button";
|
import { Button } from "components/Inputs/Button";
|
||||||
import { ToolTip } from "components/ToolTip";
|
import { ToolTip } from "components/ToolTip";
|
||||||
import { LibraryItemUserStatus } from "types/types";
|
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
import { useLibraryItemUserStatus } from "hooks/useLibraryItemUserStatus";
|
import { LibraryItemUserStatus } from "helpers/types";
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -13,56 +13,61 @@ import { useFormat } from "hooks/useFormat";
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
expand?: boolean;
|
expand?: boolean;
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const PreviewCardCTAs = ({ id, expand = false }: Props): JSX.Element => {
|
export const PreviewCardCTAs = ({
|
||||||
const { libraryItemUserStatus, setLibraryItemUserStatus } = useLibraryItemUserStatus();
|
id,
|
||||||
const { format } = useFormat();
|
expand = false,
|
||||||
|
langui,
|
||||||
|
}: Props): JSX.Element => {
|
||||||
|
const { libraryItemUserStatus, setLibraryItemUserStatus } = useAppLayout();
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={cJoin(
|
<div
|
||||||
"flex flex-row flex-wrap place-content-center place-items-center",
|
className={`flex flex-row place-content-center place-items-center ${
|
||||||
cIf(expand, "gap-4", "gap-2")
|
expand ? "gap-4" : "gap-2"
|
||||||
)}>
|
}`}
|
||||||
<ToolTip content={format("want_it")} disabled={expand}>
|
>
|
||||||
<Button
|
<ToolTip content={langui.want_it} disabled={expand}>
|
||||||
icon="favorite"
|
<Button
|
||||||
text={expand ? format("want_it") : undefined}
|
icon={Icon.Favorite}
|
||||||
active={libraryItemUserStatus[id] === LibraryItemUserStatus.Want}
|
text={expand ? langui.want_it : undefined}
|
||||||
onClick={(event) => {
|
active={libraryItemUserStatus?.[id] === LibraryItemUserStatus.Want}
|
||||||
event.preventDefault();
|
onClick={(event) => {
|
||||||
setLibraryItemUserStatus((current) => {
|
event.preventDefault();
|
||||||
const newLibraryItemUserStatus = { ...current };
|
setLibraryItemUserStatus((current) => {
|
||||||
newLibraryItemUserStatus[id] =
|
const newLibraryItemUserStatus = current ? { ...current } : {};
|
||||||
newLibraryItemUserStatus[id] === LibraryItemUserStatus.Want
|
newLibraryItemUserStatus[id] =
|
||||||
? LibraryItemUserStatus.None
|
newLibraryItemUserStatus[id] === LibraryItemUserStatus.Want
|
||||||
: LibraryItemUserStatus.Want;
|
? LibraryItemUserStatus.None
|
||||||
return newLibraryItemUserStatus;
|
: LibraryItemUserStatus.Want;
|
||||||
});
|
return newLibraryItemUserStatus;
|
||||||
}}
|
});
|
||||||
/>
|
}}
|
||||||
</ToolTip>
|
/>
|
||||||
<ToolTip content={format("have_it")} disabled={expand}>
|
</ToolTip>
|
||||||
<Button
|
<ToolTip content={langui.have_it} disabled={expand}>
|
||||||
icon="back_hand"
|
<Button
|
||||||
text={expand ? format("have_it") : undefined}
|
icon={Icon.BackHand}
|
||||||
active={libraryItemUserStatus[id] === LibraryItemUserStatus.Have}
|
text={expand ? langui.have_it : undefined}
|
||||||
onClick={(event) => {
|
active={libraryItemUserStatus?.[id] === LibraryItemUserStatus.Have}
|
||||||
event.preventDefault();
|
onClick={(event) => {
|
||||||
setLibraryItemUserStatus((current) => {
|
event.preventDefault();
|
||||||
const newLibraryItemUserStatus = { ...current };
|
setLibraryItemUserStatus((current) => {
|
||||||
newLibraryItemUserStatus[id] =
|
const newLibraryItemUserStatus = current ? { ...current } : {};
|
||||||
newLibraryItemUserStatus[id] === LibraryItemUserStatus.Have
|
newLibraryItemUserStatus[id] =
|
||||||
? LibraryItemUserStatus.None
|
newLibraryItemUserStatus[id] === LibraryItemUserStatus.Have
|
||||||
: LibraryItemUserStatus.Have;
|
? LibraryItemUserStatus.None
|
||||||
return newLibraryItemUserStatus;
|
: LibraryItemUserStatus.Have;
|
||||||
});
|
return newLibraryItemUserStatus;
|
||||||
}}
|
});
|
||||||
/>
|
}}
|
||||||
</ToolTip>
|
/>
|
||||||
</div>
|
</ToolTip>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,246 @@
|
||||||
|
import { Fragment, useCallback, useMemo } from "react";
|
||||||
|
import { Chip } from "components/Chip";
|
||||||
|
import { Img } from "components/Img";
|
||||||
|
import { Button } from "components/Inputs/Button";
|
||||||
|
import { RecorderChip } from "components/RecorderChip";
|
||||||
|
import { ToolTip } from "components/ToolTip";
|
||||||
|
import { GetLibraryItemScansQuery } from "graphql/generated";
|
||||||
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
|
import { getAssetFilename, getAssetURL, ImageQuality } from "helpers/img";
|
||||||
|
import { isInteger } from "helpers/numbers";
|
||||||
|
import {
|
||||||
|
filterHasAttributes,
|
||||||
|
getStatusDescription,
|
||||||
|
isDefined,
|
||||||
|
isDefinedAndNotEmpty,
|
||||||
|
} from "helpers/others";
|
||||||
|
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ╭─────────────╮
|
||||||
|
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
openLightBox: (images: string[], index?: number) => void;
|
||||||
|
scanSet: NonNullable<
|
||||||
|
NonNullable<
|
||||||
|
NonNullable<
|
||||||
|
NonNullable<
|
||||||
|
NonNullable<
|
||||||
|
GetLibraryItemScansQuery["libraryItems"]
|
||||||
|
>["data"][number]["attributes"]
|
||||||
|
>["contents"]
|
||||||
|
>["data"][number]["attributes"]
|
||||||
|
>["scan_set"]
|
||||||
|
>;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
languages: AppStaticProps["languages"];
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
|
content: NonNullable<
|
||||||
|
NonNullable<
|
||||||
|
NonNullable<
|
||||||
|
NonNullable<
|
||||||
|
GetLibraryItemScansQuery["libraryItems"]
|
||||||
|
>["data"][number]["attributes"]
|
||||||
|
>["contents"]
|
||||||
|
>["data"][number]["attributes"]
|
||||||
|
>["content"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const ScanSet = ({
|
||||||
|
openLightBox,
|
||||||
|
scanSet,
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
languages,
|
||||||
|
langui,
|
||||||
|
content,
|
||||||
|
}: Props): JSX.Element => {
|
||||||
|
const [selectedScan, LanguageSwitcher, languageSwitcherProps] =
|
||||||
|
useSmartLanguage({
|
||||||
|
items: scanSet,
|
||||||
|
languages: languages,
|
||||||
|
languageExtractor: useCallback(
|
||||||
|
(item: NonNullable<Props["scanSet"][number]>) =>
|
||||||
|
item.language?.data?.attributes?.code,
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
transform: useCallback((item: NonNullable<Props["scanSet"][number]>) => {
|
||||||
|
item.pages?.data.sort((a, b) => {
|
||||||
|
if (
|
||||||
|
a.attributes &&
|
||||||
|
b.attributes &&
|
||||||
|
isDefinedAndNotEmpty(a.attributes.url) &&
|
||||||
|
isDefinedAndNotEmpty(b.attributes.url)
|
||||||
|
) {
|
||||||
|
let aName = getAssetFilename(a.attributes.url);
|
||||||
|
let bName = getAssetFilename(b.attributes.url);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If the number is a succession of 0s, make the number
|
||||||
|
* incrementally smaller than 0 (i.e: 00 becomes -1)
|
||||||
|
*/
|
||||||
|
if (aName.replaceAll("0", "").length === 0) {
|
||||||
|
aName = (1 - aName.length).toString(10);
|
||||||
|
}
|
||||||
|
if (bName.replaceAll("0", "").length === 0) {
|
||||||
|
bName = (1 - bName.length).toString(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInteger(aName) && isInteger(bName)) {
|
||||||
|
return parseInt(aName, 10) - parseInt(bName, 10);
|
||||||
|
}
|
||||||
|
return a.attributes.url.localeCompare(b.attributes.url);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}, []),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pages = useMemo(
|
||||||
|
() => filterHasAttributes(selectedScan?.pages?.data, ["attributes"]),
|
||||||
|
[selectedScan]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectedScan && isDefined(pages) && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="flex flex-row flex-wrap place-items-center
|
||||||
|
gap-6 pt-10 text-base first-of-type:pt-0"
|
||||||
|
>
|
||||||
|
<h2 id={id} className="text-2xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
text={
|
||||||
|
selectedScan.language?.data?.attributes?.code ===
|
||||||
|
selectedScan.source_language?.data?.attributes?.code
|
||||||
|
? langui.scan ?? "Scan"
|
||||||
|
: langui.scanlation ?? "Scanlation"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row flex-wrap place-items-center gap-4 pb-6">
|
||||||
|
{content?.data?.attributes &&
|
||||||
|
isDefinedAndNotEmpty(content.data.attributes.slug) && (
|
||||||
|
<Button
|
||||||
|
href={`/contents/${content.data.attributes.slug}`}
|
||||||
|
text={langui.open_content}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{languageSwitcherProps.locales.size > 1 && (
|
||||||
|
<LanguageSwitcher {...languageSwitcherProps} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid place-content-center place-items-center">
|
||||||
|
<p className="font-headers font-bold">{langui.status}:</p>
|
||||||
|
<ToolTip
|
||||||
|
content={getStatusDescription(selectedScan.status, langui)}
|
||||||
|
maxWidth={"20rem"}
|
||||||
|
>
|
||||||
|
<Chip text={selectedScan.status} />
|
||||||
|
</ToolTip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedScan.scanners && selectedScan.scanners.data.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="font-headers font-bold">{langui.scanners}:</p>
|
||||||
|
<div className="grid place-content-center place-items-center gap-2">
|
||||||
|
{filterHasAttributes(selectedScan.scanners.data, [
|
||||||
|
"id",
|
||||||
|
"attributes",
|
||||||
|
] as const).map((scanner) => (
|
||||||
|
<Fragment key={scanner.id}>
|
||||||
|
<RecorderChip
|
||||||
|
langui={langui}
|
||||||
|
recorder={scanner.attributes}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedScan.cleaners && selectedScan.cleaners.data.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="font-headers font-bold">{langui.cleaners}:</p>
|
||||||
|
<div className="grid place-content-center place-items-center gap-2">
|
||||||
|
{filterHasAttributes(selectedScan.cleaners.data, [
|
||||||
|
"id",
|
||||||
|
"attributes",
|
||||||
|
] as const).map((cleaner) => (
|
||||||
|
<Fragment key={cleaner.id}>
|
||||||
|
<RecorderChip
|
||||||
|
langui={langui}
|
||||||
|
recorder={cleaner.attributes}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedScan.typesetters &&
|
||||||
|
selectedScan.typesetters.data.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="font-headers font-bold">
|
||||||
|
{langui.typesetters}:
|
||||||
|
</p>
|
||||||
|
<div className="grid place-content-center place-items-center gap-2">
|
||||||
|
{filterHasAttributes(selectedScan.typesetters.data, [
|
||||||
|
"id",
|
||||||
|
"attributes",
|
||||||
|
] as const).map((typesetter) => (
|
||||||
|
<Fragment key={typesetter.id}>
|
||||||
|
<RecorderChip
|
||||||
|
langui={langui}
|
||||||
|
recorder={typesetter.attributes}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDefinedAndNotEmpty(selectedScan.notes) && (
|
||||||
|
<ToolTip content={selectedScan.notes}>
|
||||||
|
<Chip text={langui.notes ?? "Notes"} />
|
||||||
|
</ToolTip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid items-end gap-8 border-b-[3px] border-dotted pb-12 last-of-type:border-0
|
||||||
|
desktop:grid-cols-[repeat(auto-fill,_minmax(10rem,1fr))] mobile:grid-cols-2"
|
||||||
|
>
|
||||||
|
{pages.map((page, index) => (
|
||||||
|
<div
|
||||||
|
key={page.id}
|
||||||
|
className="cursor-pointer transition-transform
|
||||||
|
drop-shadow-shade-lg hover:scale-[1.02]"
|
||||||
|
onClick={() => {
|
||||||
|
const images = pages.map((image) =>
|
||||||
|
getAssetURL(image.attributes.url, ImageQuality.Large)
|
||||||
|
);
|
||||||
|
openLightBox(images, index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Img src={page.attributes} quality={ImageQuality.Small} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { Fragment, useCallback, useMemo } from "react";
|
||||||
|
import { Chip } from "components/Chip";
|
||||||
|
import { Img } from "components/Img";
|
||||||
|
import { RecorderChip } from "components/RecorderChip";
|
||||||
|
import { ToolTip } from "components/ToolTip";
|
||||||
|
import {
|
||||||
|
GetLibraryItemScansQuery,
|
||||||
|
UploadImageFragment,
|
||||||
|
} from "graphql/generated";
|
||||||
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
|
import { getAssetURL, ImageQuality } from "helpers/img";
|
||||||
|
import { filterHasAttributes, getStatusDescription } from "helpers/others";
|
||||||
|
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ╭─────────────╮
|
||||||
|
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
openLightBox: (images: string[], index?: number) => void;
|
||||||
|
images: NonNullable<
|
||||||
|
NonNullable<
|
||||||
|
NonNullable<
|
||||||
|
GetLibraryItemScansQuery["libraryItems"]
|
||||||
|
>["data"][number]["attributes"]
|
||||||
|
>["images"]
|
||||||
|
>;
|
||||||
|
languages: AppStaticProps["languages"];
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const ScanSetCover = ({
|
||||||
|
openLightBox,
|
||||||
|
images,
|
||||||
|
languages,
|
||||||
|
langui,
|
||||||
|
}: Props): JSX.Element => {
|
||||||
|
const [selectedScan, LanguageSwitcher, languageSwitcherProps] =
|
||||||
|
useSmartLanguage({
|
||||||
|
items: images,
|
||||||
|
languages: languages,
|
||||||
|
languageExtractor: useCallback(
|
||||||
|
(item: NonNullable<Props["images"][number]>) =>
|
||||||
|
item.language?.data?.attributes?.code,
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const coverImages = useMemo(() => {
|
||||||
|
const memo: UploadImageFragment[] = [];
|
||||||
|
if (selectedScan?.obi_belt?.full?.data?.attributes)
|
||||||
|
memo.push(selectedScan.obi_belt.full.data.attributes);
|
||||||
|
if (selectedScan?.obi_belt?.inside_full?.data?.attributes)
|
||||||
|
memo.push(selectedScan.obi_belt.inside_full.data.attributes);
|
||||||
|
if (selectedScan?.dust_jacket?.full?.data?.attributes)
|
||||||
|
memo.push(selectedScan.dust_jacket.full.data.attributes);
|
||||||
|
if (selectedScan?.dust_jacket?.inside_full?.data?.attributes)
|
||||||
|
memo.push(selectedScan.dust_jacket.inside_full.data.attributes);
|
||||||
|
if (selectedScan?.cover?.full?.data?.attributes)
|
||||||
|
memo.push(selectedScan.cover.full.data.attributes);
|
||||||
|
if (selectedScan?.cover?.inside_full?.data?.attributes)
|
||||||
|
memo.push(selectedScan.cover.inside_full.data.attributes);
|
||||||
|
return memo;
|
||||||
|
}, [selectedScan]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{coverImages.length > 0 && selectedScan && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="flex flex-row flex-wrap place-items-center
|
||||||
|
gap-6 pt-10 text-base first-of-type:pt-0"
|
||||||
|
>
|
||||||
|
<h2 id={"cover"} className="text-2xl">
|
||||||
|
{langui.cover}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
text={
|
||||||
|
selectedScan.language?.data?.attributes?.code ===
|
||||||
|
selectedScan.source_language?.data?.attributes?.code
|
||||||
|
? langui.scan ?? "Scan"
|
||||||
|
: langui.scanlation ?? "Scanlation"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row flex-wrap place-items-center gap-4 pb-6">
|
||||||
|
<LanguageSwitcher {...languageSwitcherProps} />
|
||||||
|
|
||||||
|
<div className="grid place-content-center place-items-center">
|
||||||
|
<p className="font-headers font-bold">{langui.status}:</p>
|
||||||
|
<ToolTip
|
||||||
|
content={getStatusDescription(selectedScan.status, langui)}
|
||||||
|
maxWidth={"20rem"}
|
||||||
|
>
|
||||||
|
<Chip text={selectedScan.status} />
|
||||||
|
</ToolTip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedScan.scanners && selectedScan.scanners.data.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="font-headers font-bold">{langui.scanners}:</p>
|
||||||
|
<div className="grid place-content-center place-items-center gap-2">
|
||||||
|
{filterHasAttributes(selectedScan.scanners.data, [
|
||||||
|
"id",
|
||||||
|
"attributes",
|
||||||
|
] as const).map((scanner) => (
|
||||||
|
<Fragment key={scanner.id}>
|
||||||
|
<RecorderChip
|
||||||
|
langui={langui}
|
||||||
|
recorder={scanner.attributes}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedScan.cleaners && selectedScan.cleaners.data.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="font-headers font-bold">{langui.cleaners}:</p>
|
||||||
|
<div className="grid place-content-center place-items-center gap-2">
|
||||||
|
{filterHasAttributes(selectedScan.cleaners.data, [
|
||||||
|
"id",
|
||||||
|
"attributes",
|
||||||
|
] as const).map((cleaner) => (
|
||||||
|
<Fragment key={cleaner.id}>
|
||||||
|
<RecorderChip
|
||||||
|
langui={langui}
|
||||||
|
recorder={cleaner.attributes}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedScan.typesetters &&
|
||||||
|
selectedScan.typesetters.data.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="font-headers font-bold">
|
||||||
|
{langui.typesetters}:
|
||||||
|
</p>
|
||||||
|
<div className="grid place-content-center place-items-center gap-2">
|
||||||
|
{filterHasAttributes(selectedScan.typesetters.data, [
|
||||||
|
"id",
|
||||||
|
"attributes",
|
||||||
|
] as const).map((typesetter) => (
|
||||||
|
<Fragment key={typesetter.id}>
|
||||||
|
<RecorderChip
|
||||||
|
langui={langui}
|
||||||
|
recorder={typesetter.attributes}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid items-end gap-8 border-b-[3px] border-dotted pb-12
|
||||||
|
last-of-type:border-0 desktop:grid-cols-[repeat(auto-fill,_minmax(10rem,1fr))]
|
||||||
|
mobile:grid-cols-2"
|
||||||
|
>
|
||||||
|
{coverImages.map((image, index) => (
|
||||||
|
<div
|
||||||
|
key={image.url}
|
||||||
|
className="cursor-pointer transition-transform
|
||||||
|
drop-shadow-shade-lg hover:scale-[1.02]"
|
||||||
|
onClick={() => {
|
||||||
|
const imgs = coverImages.map((img) =>
|
||||||
|
getAssetURL(img.url, ImageQuality.Large)
|
||||||
|
);
|
||||||
|
|
||||||
|
openLightBox(imgs, index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Img src={image} quality={ImageQuality.Small} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,16 +1,25 @@
|
||||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
import { Dispatch, SetStateAction, useCallback, useState } from "react";
|
||||||
import { useState } from "react";
|
import Hotkeys from "react-hot-keys";
|
||||||
import { useHotkeys } from "react-hotkeys-hook";
|
import { useSwipeable } from "react-swipeable";
|
||||||
import { Img } from "./Img";
|
import { Img } from "./Img";
|
||||||
import { Button } from "./Inputs/Button";
|
import { Button } from "./Inputs/Button";
|
||||||
|
import { Popup } from "./Popup";
|
||||||
|
import { Icon } from "components/Ico";
|
||||||
|
import { clamp } from "helpers/numbers";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cIf, cJoin } from "helpers/className";
|
||||||
import { useFullscreen } from "hooks/useFullscreen";
|
import { useElementSize } from "hooks/useElementSize";
|
||||||
import { Ids } from "types/ids";
|
|
||||||
import { UploadImageFragment } from "graphql/generated";
|
/*
|
||||||
import { ImageQuality } from "helpers/img";
|
* ╭─────────────╮
|
||||||
import { isDefined } from "helpers/asserts";
|
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
*/
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
|
const SENSIBILITY_SWIPE = 0.5;
|
||||||
|
const TRANSLATION_PADDING = 100;
|
||||||
|
const SCALE_MAX = 5;
|
||||||
|
const SCALE_ON_DOUBLE_CLICK = 2;
|
||||||
|
const IMGWIDTH = 876;
|
||||||
|
const IMGHEIGHT = 1247;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -18,189 +27,167 @@ import { atoms } from "contexts/atoms";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onCloseRequest: () => void;
|
setState:
|
||||||
isVisible: boolean;
|
| Dispatch<SetStateAction<boolean | undefined>>
|
||||||
image?: UploadImageFragment | string;
|
| Dispatch<SetStateAction<boolean>>;
|
||||||
isNextImageAvailable: boolean;
|
state: boolean;
|
||||||
isPreviousImageAvailable: boolean;
|
images: string[];
|
||||||
onPressNext: () => void;
|
index: number;
|
||||||
onPressPrevious: () => void;
|
setIndex: Dispatch<SetStateAction<number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const LightBox = ({
|
export const LightBox = ({
|
||||||
onCloseRequest,
|
state,
|
||||||
isVisible,
|
setState,
|
||||||
image: src,
|
images,
|
||||||
isPreviousImageAvailable = false,
|
index,
|
||||||
onPressPrevious,
|
setIndex,
|
||||||
isNextImageAvailable = false,
|
|
||||||
onPressNext,
|
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const [currentZoom, setCurrentZoom] = useState(1);
|
const handlePrevious = useCallback(() => {
|
||||||
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
if (index > 0) setIndex(index - 1);
|
||||||
const { isFullscreen, toggleFullscreen, exitFullscreen, requestFullscreen } = useFullscreen(
|
}, [index, setIndex]);
|
||||||
Ids.LightBox
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (index < images.length - 1) setIndex(index + 1);
|
||||||
|
}, [images.length, index, setIndex]);
|
||||||
|
|
||||||
|
const handlers = useSwipeable({
|
||||||
|
onSwipedLeft: (SwipeEventData) => {
|
||||||
|
if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
|
||||||
|
handleNext();
|
||||||
|
},
|
||||||
|
onSwipedRight: (SwipeEventData) => {
|
||||||
|
if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
|
||||||
|
handlePrevious();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [translation, setTranslation] = useState({ x: 0, y: 0 });
|
||||||
|
const [isTranslating, setIsTranslating] = useState(false);
|
||||||
|
const [imgContainerRef, { width: containerWidth, height: containerHeight }] =
|
||||||
|
useElementSize();
|
||||||
|
const [imgRef, { width: imgWidth, height: imgHeight }] = useElementSize();
|
||||||
|
|
||||||
|
const changeTranslation = useCallback(
|
||||||
|
(movementX: number, movementY: number) => {
|
||||||
|
const diffX =
|
||||||
|
Math.abs(containerWidth - IMGWIDTH * scale) - TRANSLATION_PADDING;
|
||||||
|
const diffY =
|
||||||
|
Math.abs(containerHeight - IMGHEIGHT * scale) + TRANSLATION_PADDING;
|
||||||
|
setTranslation((current) => ({
|
||||||
|
x: clamp(current.x + movementX, -diffX / 2, diffX / 2),
|
||||||
|
y: clamp(current.y + movementY, -diffY / 2, diffY / 2),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[containerHeight, containerWidth, scale]
|
||||||
);
|
);
|
||||||
|
|
||||||
useHotkeys("left", () => onPressPrevious(), { enabled: isVisible && isPreviousImageAvailable }, [
|
const changeScale = useCallback(
|
||||||
onPressPrevious,
|
(deltaY: number) =>
|
||||||
]);
|
setScale((current) =>
|
||||||
|
clamp(current * (deltaY > 0 ? 0.9 : 1.1), 1, SCALE_MAX)
|
||||||
useHotkeys("f", () => requestFullscreen(), { enabled: isVisible && !isFullscreen }, [
|
),
|
||||||
requestFullscreen,
|
[]
|
||||||
]);
|
);
|
||||||
|
|
||||||
useHotkeys("right", () => onPressNext(), { enabled: isVisible && isNextImageAvailable }, [
|
|
||||||
onPressNext,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useHotkeys("escape", onCloseRequest, { enabled: isVisible }, [onCloseRequest]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
id={Ids.LightBox}
|
{state && (
|
||||||
className={cJoin(
|
<Hotkeys
|
||||||
"fixed inset-0 z-50 grid place-content-center transition-filter duration-500",
|
keyName="left,right"
|
||||||
cIf(isVisible, cIf(!isPerfModeEnabled, "backdrop-blur"), "pointer-events-none touch-none")
|
allowRepeat
|
||||||
)}>
|
onKeyDown={(keyName) => {
|
||||||
<div
|
if (keyName === "left") {
|
||||||
className={cJoin(
|
handlePrevious();
|
||||||
"fixed inset-0 transition-colors duration-500",
|
} else {
|
||||||
cIf(isVisible, "bg-shade/50", "bg-shade/0")
|
handleNext();
|
||||||
)}
|
}
|
||||||
/>
|
}}
|
||||||
<div
|
>
|
||||||
className={cJoin(
|
<Popup
|
||||||
"absolute inset-0 grid transition-transform",
|
onClose={() => setState(false)}
|
||||||
cIf(isVisible, "scale-100", "scale-0")
|
state={state}
|
||||||
)}>
|
padding={false}
|
||||||
<TransformWrapper
|
fillViewport
|
||||||
onZoom={(zoom) => setCurrentZoom(zoom.state.scale)}
|
>
|
||||||
panning={{ disabled: currentZoom <= 1, velocityDisabled: false }}
|
<div
|
||||||
doubleClick={{ disabled: true, mode: "reset" }}
|
{...handlers}
|
||||||
zoomAnimation={{ size: 0.1 }}
|
className={`grid h-full w-full grid-cols-[4em,1fr,4em] place-items-center
|
||||||
velocityAnimation={{ animationTime: 0, equalToMove: true }}>
|
overflow-hidden [grid-template-areas:"left_image_right"] first-letter:gap-4
|
||||||
{({ resetTransform }) => (
|
mobile:grid-cols-2 mobile:[grid-template-areas:"image_image""left_right"]`}
|
||||||
<>
|
ref={imgContainerRef}
|
||||||
<TransformComponent
|
onDragStart={(event) => event.preventDefault()}
|
||||||
wrapperStyle={{
|
onPointerDown={() => setIsTranslating(true)}
|
||||||
overflow: "visible",
|
onPointerUp={() => setIsTranslating(false)}
|
||||||
placeSelf: "center",
|
onPointerMove={(event) => {
|
||||||
}}>
|
if (isTranslating) {
|
||||||
{isDefined(src) && (
|
event.preventDefault();
|
||||||
<Img
|
changeTranslation(event.movementX, event.movementY);
|
||||||
className={cJoin(
|
}
|
||||||
`h-[calc(100vh-4rem)] w-full object-contain`,
|
}}
|
||||||
cIf(!isPerfModeEnabled, "drop-shadow-2xl shadow-shade")
|
onWheel={(event) => {
|
||||||
)}
|
changeScale(event.deltaY);
|
||||||
src={src}
|
changeTranslation(0, 0);
|
||||||
quality={ImageQuality.Large}
|
}}
|
||||||
/>
|
onDoubleClick={() => {
|
||||||
|
if (scale === 1) {
|
||||||
|
setScale(SCALE_ON_DOUBLE_CLICK);
|
||||||
|
} else {
|
||||||
|
setScale(1);
|
||||||
|
setTranslation({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="[grid-area:left]">
|
||||||
|
{index > 0 && (
|
||||||
|
<Button onClick={handlePrevious} icon={Icon.ChevronLeft} />
|
||||||
)}
|
)}
|
||||||
</TransformComponent>
|
</div>
|
||||||
<ControlButtons
|
|
||||||
isNextImageAvailable={isNextImageAvailable}
|
<Img
|
||||||
isPreviousImageAvailable={isPreviousImageAvailable}
|
ref={imgRef}
|
||||||
isFullscreen={isFullscreen}
|
className={cJoin(
|
||||||
onCloseRequest={() => {
|
"max-h-full min-h-fit origin-center [grid-area:image]",
|
||||||
resetTransform();
|
cIf(!isTranslating, "transition-transform")
|
||||||
exitFullscreen();
|
)}
|
||||||
onCloseRequest();
|
style={{
|
||||||
|
transform: `scale(${scale}) translate(${
|
||||||
|
translation.x / scale
|
||||||
|
}px, ${translation.y / scale}px)`,
|
||||||
}}
|
}}
|
||||||
onPressPrevious={() => {
|
src={images[index]}
|
||||||
resetTransform();
|
|
||||||
onPressPrevious();
|
|
||||||
}}
|
|
||||||
onPressNext={() => {
|
|
||||||
resetTransform();
|
|
||||||
onPressNext();
|
|
||||||
}}
|
|
||||||
toggleFullscreen={toggleFullscreen}
|
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TransformWrapper>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
<div className="[grid-area:right]">
|
||||||
* ╭──────────────────────╮
|
{index < images.length - 1 && (
|
||||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
<Button onClick={handleNext} icon={Icon.ChevronRight} />
|
||||||
*/
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
interface ControlButtonsProps {
|
<div className="absolute left-2 top-2 z-10 bg-light p-4">
|
||||||
isPreviousImageAvailable: boolean;
|
<p>
|
||||||
isNextImageAvailable: boolean;
|
Scale:{" "}
|
||||||
isFullscreen: boolean;
|
{scale.toLocaleString(undefined, {
|
||||||
onPressPrevious?: () => void;
|
maximumSignificantDigits: 3,
|
||||||
onPressNext?: () => void;
|
})}
|
||||||
onCloseRequest: () => void;
|
</p>
|
||||||
toggleFullscreen: () => void;
|
<p>
|
||||||
}
|
Translation: {translation.x} {translation.y}
|
||||||
|
</p>
|
||||||
const ControlButtons = ({
|
<p>
|
||||||
isFullscreen,
|
Container: {containerWidth}px {containerHeight}px
|
||||||
isPreviousImageAvailable,
|
</p>
|
||||||
isNextImageAvailable,
|
<p>
|
||||||
onPressPrevious,
|
Image: {imgWidth}px {imgHeight}px
|
||||||
onPressNext,
|
</p>
|
||||||
onCloseRequest,
|
</div>
|
||||||
toggleFullscreen,
|
</div>
|
||||||
}: ControlButtonsProps): JSX.Element => {
|
</Popup>
|
||||||
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
|
</Hotkeys>
|
||||||
|
|
||||||
const PreviousButton = () => (
|
|
||||||
<Button icon="navigate_before" onClick={onPressPrevious} disabled={!isPreviousImageAvailable} />
|
|
||||||
);
|
|
||||||
const NextButton = () => (
|
|
||||||
<Button icon="navigate_next" onClick={onPressNext} disabled={!isNextImageAvailable} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const FullscreenButton = () => (
|
|
||||||
<Button icon={isFullscreen ? "fullscreen_exit" : "fullscreen"} onClick={toggleFullscreen} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const CloseButton = () => <Button onClick={onCloseRequest} icon="close" />;
|
|
||||||
|
|
||||||
return is1ColumnLayout ? (
|
|
||||||
<>
|
|
||||||
<div className="absolute bottom-2 left-0 right-0 grid place-content-center">
|
|
||||||
<div className="grid grid-flow-col gap-4 rounded-4xl p-4 backdrop-blur-lg">
|
|
||||||
<PreviousButton />
|
|
||||||
<FullscreenButton />
|
|
||||||
<NextButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute right-2 top-2 grid gap-4 rounded-4xl p-4 backdrop-blur-lg">
|
|
||||||
<CloseButton />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{isPreviousImageAvailable && (
|
|
||||||
<div
|
|
||||||
className={`absolute left-8 top-1/2 grid gap-4 rounded-4xl p-4
|
|
||||||
backdrop-blur-lg`}>
|
|
||||||
<PreviousButton />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{isNextImageAvailable && (
|
|
||||||
<div
|
|
||||||
className={`absolute right-8 top-1/2 grid gap-4 rounded-4xl p-4
|
|
||||||
backdrop-blur-lg`}>
|
|
||||||
<NextButton />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`absolute right-8 top-4 grid gap-4 rounded-4xl p-4
|
|
||||||
backdrop-blur-lg`}>
|
|
||||||
<CloseButton />
|
|
||||||
<FullscreenButton />
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,24 +1,18 @@
|
||||||
import Markdown from "markdown-to-jsx";
|
import Markdown from "markdown-to-jsx";
|
||||||
import React, { Fragment, MouseEventHandler, useMemo } from "react";
|
import { useRouter } from "next/router";
|
||||||
|
import React, { Fragment, useMemo } from "react";
|
||||||
import ReactDOMServer from "react-dom/server";
|
import ReactDOMServer from "react-dom/server";
|
||||||
import { z } from "zod";
|
|
||||||
import { HorizontalLine } from "components/HorizontalLine";
|
import { HorizontalLine } from "components/HorizontalLine";
|
||||||
import { Img } from "components/Img";
|
import { Img } from "components/Img";
|
||||||
import { InsetBox } from "components/Containers/InsetBox";
|
import { InsetBox } from "components/InsetBox";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||||
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
|
import { cJoin } from "helpers/className";
|
||||||
import { slugify } from "helpers/formatters";
|
import { slugify } from "helpers/formatters";
|
||||||
import { getAssetURL, ImageQuality } from "helpers/img";
|
import { getAssetURL, ImageQuality } from "helpers/img";
|
||||||
import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts";
|
import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/others";
|
||||||
|
import { useLightBox } from "hooks/useLightBox";
|
||||||
import { AnchorShare } from "components/AnchorShare";
|
import { AnchorShare } from "components/AnchorShare";
|
||||||
import { useIntersectionList } from "hooks/useIntersectionList";
|
|
||||||
import { Ico } from "components/Ico";
|
|
||||||
import { useDeviceSupportsHover } from "hooks/useMediaQuery";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
|
||||||
import { Link } from "components/Inputs/Link";
|
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
import { VideoPlayer } from "components/Player";
|
|
||||||
import { getVideoFile } from "helpers/videos";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -28,18 +22,26 @@ import { getVideoFile } from "helpers/videos";
|
||||||
interface MarkdawnProps {
|
interface MarkdawnProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Element => {
|
export const Markdawn = ({
|
||||||
const playerName = useAtomGetter(atoms.settings.playerName);
|
className,
|
||||||
const isContentPanelAtLeastLg = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastLg);
|
text: rawText,
|
||||||
const { showLightBox } = useAtomGetter(atoms.lightBox);
|
langui,
|
||||||
|
}: MarkdawnProps): JSX.Element => {
|
||||||
|
const { playerName } = useAppLayout();
|
||||||
|
const router = useRouter();
|
||||||
|
const [openLightBox, LightBox] = useLightBox();
|
||||||
|
|
||||||
/* eslint-disable no-irregular-whitespace */
|
/* eslint-disable no-irregular-whitespace */
|
||||||
const text = `${preprocessMarkDawn(rawText, playerName)}
|
const text = useMemo(
|
||||||
`;
|
() => `${preprocessMarkDawn(rawText, playerName)}
|
||||||
|
`,
|
||||||
|
[playerName, rawText]
|
||||||
|
);
|
||||||
/* eslint-enable no-irregular-whitespace */
|
/* eslint-enable no-irregular-whitespace */
|
||||||
|
|
||||||
if (isUndefined(text) || text === "") {
|
if (isUndefined(text) || text === "") {
|
||||||
|
@ -47,250 +49,293 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Markdown
|
<>
|
||||||
className={cJoin("formatted", className)}
|
<LightBox />
|
||||||
options={{
|
<Markdown
|
||||||
slugify: slugify,
|
className={cJoin("formatted", className)}
|
||||||
overrides: {
|
options={{
|
||||||
a: {
|
slugify: slugify,
|
||||||
component: (compProps: { href: string; children: React.ReactNode }) => {
|
overrides: {
|
||||||
if (compProps.href.startsWith("/") || compProps.href.startsWith("#")) {
|
a: {
|
||||||
|
component: (compProps: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
if (
|
||||||
|
compProps.href.startsWith("/") ||
|
||||||
|
compProps.href.startsWith("#")
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<a onClick={async () => router.push(compProps.href)}>
|
||||||
|
{compProps.children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Link href={compProps.href} linkStyled>
|
<a href={compProps.href} target="_blank" rel="noreferrer">
|
||||||
{compProps.children}
|
{compProps.children}
|
||||||
</Link>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return (
|
|
||||||
<Link href={compProps.href} alwaysNewTab linkStyled>
|
|
||||||
{compProps.children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
|
||||||
|
|
||||||
Header: {
|
h1: {
|
||||||
component: (compProps: {
|
component: (compProps: {
|
||||||
id: string;
|
id: string;
|
||||||
style: React.CSSProperties;
|
style: React.CSSProperties;
|
||||||
children: string;
|
children: React.ReactNode;
|
||||||
level: string;
|
}) => (
|
||||||
}) => (
|
<h1 id={compProps.id} style={compProps.style}>
|
||||||
<Header
|
|
||||||
title={compProps.children}
|
|
||||||
level={parseInt(compProps.level, 10)}
|
|
||||||
slug={compProps.id}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
SceneBreak: {
|
|
||||||
component: (compProps: { id: string }) => (
|
|
||||||
<Header title={"* * *"} level={6} slug={compProps.id} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
IntraLink: {
|
|
||||||
component: (compProps: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
target?: string;
|
|
||||||
page?: string;
|
|
||||||
}) => {
|
|
||||||
const slug = isDefinedAndNotEmpty(compProps.target)
|
|
||||||
? slugify(compProps.target)
|
|
||||||
: slugify(compProps.children?.toString());
|
|
||||||
return (
|
|
||||||
<Link href={`${compProps.page ?? ""}#${slug}`} linkStyled>
|
|
||||||
{compProps.children}
|
{compProps.children}
|
||||||
</Link>
|
<AnchorShare id={compProps.id} langui={langui} />
|
||||||
);
|
</h1>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
|
||||||
|
|
||||||
Transcript: {
|
h2: {
|
||||||
component: (compProps) => (
|
component: (compProps: {
|
||||||
<div
|
id: string;
|
||||||
className={cJoin(
|
style: React.CSSProperties;
|
||||||
"grid gap-x-6 gap-y-2",
|
children: React.ReactNode;
|
||||||
cIf(isContentPanelAtLeastLg, "grid-cols-[auto_1fr]", "grid-cols-1")
|
}) => (
|
||||||
)}>
|
<h2 id={compProps.id} style={compProps.style}>
|
||||||
{compProps.children}
|
{compProps.children}
|
||||||
</div>
|
<AnchorShare id={compProps.id} langui={langui} />
|
||||||
),
|
</h2>
|
||||||
},
|
),
|
||||||
|
},
|
||||||
|
|
||||||
Line: {
|
h3: {
|
||||||
component: (compProps) => {
|
component: (compProps: {
|
||||||
const schema = z.object({ name: z.string(), children: z.any() });
|
id: string;
|
||||||
if (!schema.safeParse(compProps).success) {
|
style: React.CSSProperties;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<h3 id={compProps.id} style={compProps.style}>
|
||||||
|
{compProps.children}
|
||||||
|
<AnchorShare id={compProps.id} langui={langui} />
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
h4: {
|
||||||
|
component: (compProps: {
|
||||||
|
id: string;
|
||||||
|
style: React.CSSProperties;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<h4 id={compProps.id} style={compProps.style}>
|
||||||
|
{compProps.children}
|
||||||
|
<AnchorShare id={compProps.id} langui={langui} />
|
||||||
|
</h4>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
h5: {
|
||||||
|
component: (compProps: {
|
||||||
|
id: string;
|
||||||
|
style: React.CSSProperties;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<h5 id={compProps.id} style={compProps.style}>
|
||||||
|
{compProps.children}
|
||||||
|
<AnchorShare id={compProps.id} langui={langui} />
|
||||||
|
</h5>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
h6: {
|
||||||
|
component: (compProps: {
|
||||||
|
id: string;
|
||||||
|
style: React.CSSProperties;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<h6 id={compProps.id} style={compProps.style}>
|
||||||
|
{compProps.children}
|
||||||
|
<AnchorShare id={compProps.id} langui={langui} />
|
||||||
|
</h6>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
SceneBreak: {
|
||||||
|
component: (compProps: { id: string }) => (
|
||||||
|
<div
|
||||||
|
id={compProps.id}
|
||||||
|
className={"mt-16 mb-20 h-0 text-center text-3xl text-dark"}
|
||||||
|
>
|
||||||
|
* * *
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
IntraLink: {
|
||||||
|
component: (compProps: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
target?: string;
|
||||||
|
page?: string;
|
||||||
|
}) => {
|
||||||
|
const slug = isDefinedAndNotEmpty(compProps.target)
|
||||||
|
? slugify(compProps.target)
|
||||||
|
: slugify(compProps.children?.toString());
|
||||||
return (
|
return (
|
||||||
<MarkdawnError
|
<a
|
||||||
message={`Error while parsing a <Line/> tag. Here is the correct usage:
|
onClick={async () =>
|
||||||
<Line name="John">Hello!</Line>`}
|
router.replace(`${compProps.page ?? ""}#${slug}`)
|
||||||
/>
|
}
|
||||||
|
>
|
||||||
|
{compProps.children}
|
||||||
|
</a>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
const safeProps: z.infer<typeof schema> = compProps;
|
},
|
||||||
return (
|
|
||||||
|
Transcript: {
|
||||||
|
component: (compProps) => (
|
||||||
|
<div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 mobile:grid-cols-1">
|
||||||
|
{compProps.children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
Line: {
|
||||||
|
component: (compProps) => (
|
||||||
<>
|
<>
|
||||||
<strong
|
<strong className="!my-0 text-dark/60 mobile:!-mb-4">
|
||||||
className={cJoin(
|
<Markdawn text={compProps.name} langui={langui} />
|
||||||
"!my-0 text-dark/60",
|
|
||||||
cIf(!isContentPanelAtLeastLg, "!-mb-4")
|
|
||||||
)}>
|
|
||||||
<Markdawn text={safeProps.name} />
|
|
||||||
</strong>
|
</strong>
|
||||||
<p className="whitespace-pre-line">{safeProps.children}</p>
|
<p className="whitespace-pre-line">{compProps.children}</p>
|
||||||
</>
|
</>
|
||||||
);
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
InsetBox: {
|
||||||
|
component: (compProps) => (
|
||||||
|
<InsetBox className="my-12">{compProps.children}</InsetBox>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
li: {
|
||||||
|
component: (compProps: { children: React.ReactNode }) => (
|
||||||
|
<li
|
||||||
|
className={
|
||||||
|
isDefined(compProps.children) &&
|
||||||
|
ReactDOMServer.renderToStaticMarkup(
|
||||||
|
<>{compProps.children}</>
|
||||||
|
).length > 100
|
||||||
|
? "my-4"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{compProps.children}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
Highlight: {
|
||||||
|
component: (compProps: { children: React.ReactNode }) => (
|
||||||
|
<mark>{compProps.children}</mark>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
footer: {
|
||||||
|
component: (compProps: { children: React.ReactNode }) => (
|
||||||
|
<>
|
||||||
|
<HorizontalLine />
|
||||||
|
<div className="grid gap-8">{compProps.children}</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
blockquote: {
|
||||||
|
component: (compProps: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
cite?: string;
|
||||||
|
}) => (
|
||||||
|
<blockquote>
|
||||||
|
{isDefinedAndNotEmpty(compProps.cite) ? (
|
||||||
|
<>
|
||||||
|
“{compProps.children}”
|
||||||
|
<cite>— {compProps.cite}</cite>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
compProps.children
|
||||||
|
)}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
img: {
|
||||||
|
component: (compProps: {
|
||||||
|
alt: string;
|
||||||
|
src: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
caption?: string;
|
||||||
|
name?: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className="mt-8 mb-12 grid cursor-pointer place-content-center"
|
||||||
|
onClick={() => {
|
||||||
|
openLightBox([
|
||||||
|
compProps.src.startsWith("/uploads/")
|
||||||
|
? getAssetURL(compProps.src, ImageQuality.Large)
|
||||||
|
: compProps.src,
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Img
|
||||||
|
src={
|
||||||
|
compProps.src.startsWith("/uploads/")
|
||||||
|
? getAssetURL(compProps.src, ImageQuality.Small)
|
||||||
|
: compProps.src
|
||||||
|
}
|
||||||
|
quality={ImageQuality.Medium}
|
||||||
|
className="drop-shadow-shade-lg"
|
||||||
|
></Img>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
}}
|
||||||
Angelic: {
|
>
|
||||||
component: (comProps) => <span className="font-angelic">{comProps.children}</span>,
|
{text}
|
||||||
},
|
</Markdown>
|
||||||
|
</>
|
||||||
Video: {
|
|
||||||
component: (comProps) => (
|
|
||||||
<VideoPlayer
|
|
||||||
src={getVideoFile(comProps.id)}
|
|
||||||
title={comProps.title}
|
|
||||||
className="my-8"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
InsetBox: {
|
|
||||||
component: (compProps) => <InsetBox className="my-12">{compProps.children}</InsetBox>,
|
|
||||||
},
|
|
||||||
|
|
||||||
li: {
|
|
||||||
component: (compProps: { children: React.ReactNode }) => (
|
|
||||||
<li
|
|
||||||
className={
|
|
||||||
isDefined(compProps.children) &&
|
|
||||||
ReactDOMServer.renderToStaticMarkup(<>{compProps.children}</>).length > 100
|
|
||||||
? "my-4"
|
|
||||||
: ""
|
|
||||||
}>
|
|
||||||
{compProps.children}
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
Highlight: {
|
|
||||||
component: (compProps: { children: React.ReactNode }) => (
|
|
||||||
<mark>{compProps.children}</mark>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
footer: {
|
|
||||||
component: (compProps: { children: React.ReactNode }) => (
|
|
||||||
<>
|
|
||||||
<HorizontalLine />
|
|
||||||
<div className="grid gap-8">{compProps.children}</div>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
blockquote: {
|
|
||||||
component: (compProps: { children: React.ReactNode; cite?: string }) => (
|
|
||||||
<blockquote>
|
|
||||||
{isDefinedAndNotEmpty(compProps.cite) ? (
|
|
||||||
<>
|
|
||||||
“{compProps.children}”
|
|
||||||
<cite>— {compProps.cite}</cite>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
compProps.children
|
|
||||||
)}
|
|
||||||
</blockquote>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
img: {
|
|
||||||
component: (compProps: {
|
|
||||||
alt: string;
|
|
||||||
src: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
caption?: string;
|
|
||||||
name?: string;
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
className="mb-12 mt-8 grid cursor-pointer place-content-center"
|
|
||||||
onClick={() => {
|
|
||||||
showLightBox([
|
|
||||||
compProps.src.startsWith("/uploads/")
|
|
||||||
? getAssetURL(compProps.src, ImageQuality.Large)
|
|
||||||
: compProps.src,
|
|
||||||
]);
|
|
||||||
}}>
|
|
||||||
<Img
|
|
||||||
src={
|
|
||||||
compProps.src.startsWith("/uploads/")
|
|
||||||
? getAssetURL(compProps.src, ImageQuality.Small)
|
|
||||||
: compProps.src
|
|
||||||
}
|
|
||||||
quality={ImageQuality.Medium}
|
|
||||||
className="drop-shadow-lg shadow-shade"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}>
|
|
||||||
{text}
|
|
||||||
</Markdown>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
interface MarkdawnErrorProps {
|
interface TableOfContentsProps {
|
||||||
message: string;
|
text: string;
|
||||||
|
title?: string;
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdawnError = ({ message }: MarkdawnErrorProps): JSX.Element => (
|
|
||||||
<div
|
|
||||||
className="flex place-items-center gap-4 whitespace-pre-line rounded-md
|
|
||||||
bg-[red]/10 px-4 text-[red]">
|
|
||||||
<Ico icon="error" isFilled={false} />
|
|
||||||
<p>{message}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
interface TableOfContentsProps {
|
export const TableOfContents = ({
|
||||||
toc: TocInterface;
|
text,
|
||||||
onContentClicked?: MouseEventHandler<HTMLAnchorElement>;
|
title,
|
||||||
}
|
langui,
|
||||||
|
}: TableOfContentsProps): JSX.Element => {
|
||||||
export const TableOfContents = ({ toc, onContentClicked }: TableOfContentsProps): JSX.Element => {
|
const router = useRouter();
|
||||||
const { format } = useFormat();
|
const toc = useMemo(
|
||||||
|
() => getTocFromMarkdawn(preprocessMarkDawn(text), title),
|
||||||
|
[text, title]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{toc.children.length > 0 && (
|
<h3 className="text-xl">{langui.table_of_contents}</h3>
|
||||||
<>
|
<div className="max-w-[14.5rem] text-left">
|
||||||
<h3 className="text-xl">{format("table_of_contents")}</h3>
|
<p className="relative my-2 overflow-x-hidden text-ellipsis whitespace-nowrap text-left">
|
||||||
<div className="max-w-[14.5rem] text-left">
|
<a onClick={async () => router.replace(`#${toc.slug}`)}>
|
||||||
<p
|
{<abbr title={toc.title}>{toc.title}</abbr>}
|
||||||
className="relative my-2 overflow-x-hidden text-ellipsis whitespace-nowrap
|
</a>
|
||||||
text-left">
|
</p>
|
||||||
<Link href={`#${toc.slug}`} linkStyled onClick={onContentClicked}>
|
<TocLevel tocchildren={toc.children} parentNumbering="" />
|
||||||
{<abbr title={toc.title}>{toc.title}</abbr>}
|
</div>
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<TocLevel
|
|
||||||
tocchildren={toc.children}
|
|
||||||
parentNumbering=""
|
|
||||||
onContentClicked={onContentClicked}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -300,74 +345,6 @@ export const TableOfContents = ({ toc, onContentClicked }: TableOfContentsProps)
|
||||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface HeaderProps {
|
|
||||||
level: number;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Header = ({ level, title, slug }: HeaderProps): JSX.Element => {
|
|
||||||
const isHoverable = useDeviceSupportsHover();
|
|
||||||
const innerComponent = (
|
|
||||||
<>
|
|
||||||
<div className="ml-10 flex place-items-center gap-4">
|
|
||||||
{title === "* * *" ? (
|
|
||||||
<div className="mb-12 mt-8 space-x-3 text-dark">
|
|
||||||
<Ico icon="emergency" />
|
|
||||||
<Ico icon="emergency" />
|
|
||||||
<Ico icon="emergency" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="font-headers">{title}</div>
|
|
||||||
)}
|
|
||||||
<AnchorShare
|
|
||||||
className={cIf(isHoverable, "opacity-0 transition-opacity group-hover:opacity-100")}
|
|
||||||
id={slug}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (level) {
|
|
||||||
case 1:
|
|
||||||
return (
|
|
||||||
<h1 id={slug} className="group">
|
|
||||||
{innerComponent}
|
|
||||||
</h1>
|
|
||||||
);
|
|
||||||
case 2:
|
|
||||||
return (
|
|
||||||
<h2 id={slug} className="group">
|
|
||||||
{innerComponent}
|
|
||||||
</h2>
|
|
||||||
);
|
|
||||||
case 3:
|
|
||||||
return (
|
|
||||||
<h3 id={slug} className="group">
|
|
||||||
{innerComponent}
|
|
||||||
</h3>
|
|
||||||
);
|
|
||||||
case 4:
|
|
||||||
return (
|
|
||||||
<h4 id={slug} className="group">
|
|
||||||
{innerComponent}
|
|
||||||
</h4>
|
|
||||||
);
|
|
||||||
case 5:
|
|
||||||
return (
|
|
||||||
<h5 id={slug} className="group">
|
|
||||||
{innerComponent}
|
|
||||||
</h5>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<h6 id={slug} className="group">
|
|
||||||
{innerComponent}
|
|
||||||
</h6>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TocInterface {
|
interface TocInterface {
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
@ -377,38 +354,29 @@ interface TocInterface {
|
||||||
interface LevelProps {
|
interface LevelProps {
|
||||||
tocchildren: TocInterface[];
|
tocchildren: TocInterface[];
|
||||||
parentNumbering: string;
|
parentNumbering: string;
|
||||||
allowIntersection?: boolean;
|
|
||||||
onContentClicked?: MouseEventHandler<HTMLAnchorElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TocLevel = ({
|
const TocLevel = ({
|
||||||
tocchildren,
|
tocchildren,
|
||||||
parentNumbering,
|
parentNumbering,
|
||||||
allowIntersection = true,
|
|
||||||
onContentClicked,
|
|
||||||
}: LevelProps): JSX.Element => {
|
}: LevelProps): JSX.Element => {
|
||||||
const ids = useMemo(() => tocchildren.map((child) => child.slug), [tocchildren]);
|
const router = useRouter();
|
||||||
const currentIntersection = useIntersectionList(ids);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ol className="pl-4 text-left">
|
<ol className="pl-4 text-left">
|
||||||
{tocchildren.map((child, childIndex) => (
|
{tocchildren.map((child, childIndex) => (
|
||||||
<Fragment key={child.slug}>
|
<Fragment key={child.slug}>
|
||||||
<li
|
<li className="my-2 w-full overflow-x-hidden text-ellipsis whitespace-nowrap">
|
||||||
className={cJoin(
|
<span className="text-dark">{`${parentNumbering}${
|
||||||
"my-2 w-full overflow-x-hidden text-ellipsis whitespace-nowrap",
|
childIndex + 1
|
||||||
cIf(allowIntersection && currentIntersection === childIndex, "text-dark")
|
}.`}</span>{" "}
|
||||||
)}>
|
<a onClick={async () => router.replace(`#${child.slug}`)}>
|
||||||
<span className="text-dark">{`${parentNumbering}${childIndex + 1}.`}</span>{" "}
|
|
||||||
<Link href={`#${child.slug}`} linkStyled onClick={onContentClicked}>
|
|
||||||
{<abbr title={child.title}>{child.title}</abbr>}
|
{<abbr title={child.title}>{child.title}</abbr>}
|
||||||
</Link>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<TocLevel
|
<TocLevel
|
||||||
tocchildren={child.children}
|
tocchildren={child.children}
|
||||||
parentNumbering={`${parentNumbering}${childIndex + 1}.`}
|
parentNumbering={`${parentNumbering}${childIndex + 1}.`}
|
||||||
allowIntersection={allowIntersection && currentIntersection === childIndex}
|
|
||||||
onContentClicked={onContentClicked}
|
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
@ -417,20 +385,33 @@ const TocLevel = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭───────────────────╮
|
* ╭──────────────────────╮
|
||||||
* ─────────────────────────────────────╯ PRIVATE METHODS ╰───────────────────────────────────────
|
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
enum HeaderLevels {
|
||||||
|
H1 = 1,
|
||||||
|
H2 = 2,
|
||||||
|
H3 = 3,
|
||||||
|
H4 = 4,
|
||||||
|
H5 = 5,
|
||||||
|
H6 = 6,
|
||||||
|
}
|
||||||
|
|
||||||
const preprocessMarkDawn = (text: string, playerName = ""): string => {
|
const preprocessMarkDawn = (text: string, playerName = ""): string => {
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
|
|
||||||
const processedPlayerName = playerName.replaceAll("_", "\\_").replaceAll("*", "\\*");
|
const processedPlayerName = playerName
|
||||||
|
.replaceAll("_", "\\_")
|
||||||
|
.replaceAll("*", "\\*");
|
||||||
|
|
||||||
let preprocessed = text
|
let preprocessed = text
|
||||||
.replaceAll("--", "—")
|
.replaceAll("--", "—")
|
||||||
.replaceAll(
|
.replaceAll(
|
||||||
"@player",
|
"@player",
|
||||||
isDefinedAndNotEmpty(processedPlayerName) ? processedPlayerName : "(player)"
|
isDefinedAndNotEmpty(processedPlayerName)
|
||||||
|
? processedPlayerName
|
||||||
|
: "(player)"
|
||||||
);
|
);
|
||||||
|
|
||||||
let scenebreakIndex = 0;
|
let scenebreakIndex = 0;
|
||||||
|
@ -444,8 +425,28 @@ const preprocessMarkDawn = (text: string, playerName = ""): string => {
|
||||||
return `<SceneBreak id="scene-break-${scenebreakIndex}">`;
|
return `<SceneBreak id="scene-break-${scenebreakIndex}">`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^[#]+ /u.test(line)) {
|
if (line.startsWith("# ")) {
|
||||||
return markdawnHeadersParser(line.indexOf(" "), line, visitedSlugs);
|
return markdawnHeadersParser(HeaderLevels.H1, line, visitedSlugs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("## ")) {
|
||||||
|
return markdawnHeadersParser(HeaderLevels.H2, line, visitedSlugs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("### ")) {
|
||||||
|
return markdawnHeadersParser(HeaderLevels.H3, line, visitedSlugs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("#### ")) {
|
||||||
|
return markdawnHeadersParser(HeaderLevels.H4, line, visitedSlugs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("##### ")) {
|
||||||
|
return markdawnHeadersParser(HeaderLevels.H5, line, visitedSlugs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("###### ")) {
|
||||||
|
return markdawnHeadersParser(HeaderLevels.H6, line, visitedSlugs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return line;
|
return line;
|
||||||
|
@ -458,7 +459,7 @@ const preprocessMarkDawn = (text: string, playerName = ""): string => {
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
const markdawnHeadersParser = (
|
const markdawnHeadersParser = (
|
||||||
headerLevel: number,
|
headerLevel: HeaderLevels,
|
||||||
line: string,
|
line: string,
|
||||||
visitedSlugs: string[]
|
visitedSlugs: string[]
|
||||||
): string => {
|
): string => {
|
||||||
|
@ -471,19 +472,12 @@ const markdawnHeadersParser = (
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
visitedSlugs.push(newSlug);
|
visitedSlugs.push(newSlug);
|
||||||
return `<Header level="${headerLevel}" id="${newSlug}">${lineText}</Header>`;
|
return `<h${headerLevel} id="${newSlug}">${lineText}</h${headerLevel}>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const getTocFromMarkdawn = (
|
const getTocFromMarkdawn = (text: string, title?: string): TocInterface => {
|
||||||
markdawn: string | null | undefined,
|
|
||||||
title?: string
|
|
||||||
): TocInterface | undefined => {
|
|
||||||
if (isUndefined(markdawn)) return undefined;
|
|
||||||
|
|
||||||
const text = preprocessMarkDawn(markdawn);
|
|
||||||
|
|
||||||
const toc: TocInterface = {
|
const toc: TocInterface = {
|
||||||
title: title ?? "Return to top",
|
title: title ?? "Return to top",
|
||||||
slug: slugify(title),
|
slug: slugify(title),
|
||||||
|
@ -496,13 +490,17 @@ export const getTocFromMarkdawn = (
|
||||||
let scenebreak = 0;
|
let scenebreak = 0;
|
||||||
let scenebreakIndex = 0;
|
let scenebreakIndex = 0;
|
||||||
|
|
||||||
const getTitle = (line: string): string => line.slice(line.indexOf(`">`) + 2, line.indexOf("</"));
|
const getTitle = (line: string): string =>
|
||||||
|
line.slice(line.indexOf(`">`) + 2, line.indexOf("</"));
|
||||||
|
|
||||||
const getSlug = (line: string): string =>
|
const getSlug = (line: string): string =>
|
||||||
line.slice(line.indexOf(`id="`) + 4, line.indexOf(`">`));
|
line.slice(line.indexOf(`id="`) + 4, line.indexOf(`">`));
|
||||||
|
|
||||||
text.split("\n").map((line) => {
|
text.split("\n").map((line) => {
|
||||||
if (line.startsWith('<Header level="2"')) {
|
if (line.startsWith("<h1 id=")) {
|
||||||
|
toc.title = getTitle(line);
|
||||||
|
toc.slug = getSlug(line);
|
||||||
|
} else if (line.startsWith("<h2 id=")) {
|
||||||
toc.children.push({
|
toc.children.push({
|
||||||
title: getTitle(line),
|
title: getTitle(line),
|
||||||
slug: getSlug(line),
|
slug: getSlug(line),
|
||||||
|
@ -513,8 +511,8 @@ export const getTocFromMarkdawn = (
|
||||||
h4 = -1;
|
h4 = -1;
|
||||||
h5 = -1;
|
h5 = -1;
|
||||||
scenebreak = 0;
|
scenebreak = 0;
|
||||||
} else if (h2 >= 0 && line.startsWith('<Header level="3"')) {
|
} else if (h2 >= 0 && line.startsWith("<h3 id=")) {
|
||||||
toc.children[h2]?.children.push({
|
toc.children[h2].children.push({
|
||||||
title: getTitle(line),
|
title: getTitle(line),
|
||||||
slug: getSlug(line),
|
slug: getSlug(line),
|
||||||
children: [],
|
children: [],
|
||||||
|
@ -523,8 +521,8 @@ export const getTocFromMarkdawn = (
|
||||||
h4 = -1;
|
h4 = -1;
|
||||||
h5 = -1;
|
h5 = -1;
|
||||||
scenebreak = 0;
|
scenebreak = 0;
|
||||||
} else if (h3 >= 0 && line.startsWith('<Header level="4"')) {
|
} else if (h3 >= 0 && line.startsWith("<h4 id=")) {
|
||||||
toc.children[h2]?.children[h3]?.children.push({
|
toc.children[h2].children[h3].children.push({
|
||||||
title: getTitle(line),
|
title: getTitle(line),
|
||||||
slug: getSlug(line),
|
slug: getSlug(line),
|
||||||
children: [],
|
children: [],
|
||||||
|
@ -532,16 +530,16 @@ export const getTocFromMarkdawn = (
|
||||||
h4++;
|
h4++;
|
||||||
h5 = -1;
|
h5 = -1;
|
||||||
scenebreak = 0;
|
scenebreak = 0;
|
||||||
} else if (h4 >= 0 && line.startsWith('<Header level="5"')) {
|
} else if (h4 >= 0 && line.startsWith("<h5 id=")) {
|
||||||
toc.children[h2]?.children[h3]?.children[h4]?.children.push({
|
toc.children[h2].children[h3].children[h4].children.push({
|
||||||
title: getTitle(line),
|
title: getTitle(line),
|
||||||
slug: getSlug(line),
|
slug: getSlug(line),
|
||||||
children: [],
|
children: [],
|
||||||
});
|
});
|
||||||
h5++;
|
h5++;
|
||||||
scenebreak = 0;
|
scenebreak = 0;
|
||||||
} else if (h5 >= 0 && line.startsWith('<Header level="6"')) {
|
} else if (h5 >= 0 && line.startsWith("<h6 id=")) {
|
||||||
toc.children[h2]?.children[h3]?.children[h4]?.children[h5]?.children.push({
|
toc.children[h2].children[h3].children[h4].children[h5].children.push({
|
||||||
title: getTitle(line),
|
title: getTitle(line),
|
||||||
slug: getSlug(line),
|
slug: getSlug(line),
|
||||||
children: [],
|
children: [],
|
||||||
|
@ -557,19 +555,20 @@ export const getTocFromMarkdawn = (
|
||||||
};
|
};
|
||||||
|
|
||||||
if (h5 >= 0) {
|
if (h5 >= 0) {
|
||||||
toc.children[h2]?.children[h3]?.children[h4]?.children[h5]?.children.push(child);
|
toc.children[h2].children[h3].children[h4].children[h5].children.push(
|
||||||
|
child
|
||||||
|
);
|
||||||
} else if (h4 >= 0) {
|
} else if (h4 >= 0) {
|
||||||
toc.children[h2]?.children[h3]?.children[h4]?.children.push(child);
|
toc.children[h2].children[h3].children[h4].children.push(child);
|
||||||
} else if (h3 >= 0) {
|
} else if (h3 >= 0) {
|
||||||
toc.children[h2]?.children[h3]?.children.push(child);
|
toc.children[h2].children[h3].children.push(child);
|
||||||
} else if (h2 >= 0) {
|
} else if (h2 >= 0) {
|
||||||
toc.children[h2]?.children.push(child);
|
toc.children[h2].children.push(child);
|
||||||
} else {
|
} else {
|
||||||
toc.children.push(child);
|
toc.children.push(child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (toc.children.length === 0) return undefined;
|
|
||||||
return toc;
|
return toc;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { atoms } from "contexts/atoms";
|
import { Ico, Icon } from "components/Ico";
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cIf, cJoin } from "helpers/className";
|
||||||
|
import { isDefined } from "helpers/others";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -8,20 +8,24 @@ import { cIf, cJoin } from "helpers/className";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
message: string;
|
||||||
|
icon?: Icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const SubPanel = ({ children }: Props): JSX.Element => {
|
export const ContentPlaceholder = ({ message, icon }: Props): JSX.Element => (
|
||||||
const isSubPanelAtLeastXs = useAtomGetter(atoms.containerQueries.isSubPanelAtLeastXs);
|
<div className="grid h-full place-content-center">
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
className={cJoin(
|
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
|
||||||
"grid gap-y-2 text-center",
|
border-dark p-8 text-dark opacity-40"
|
||||||
cIf(isSubPanelAtLeastXs, "px-10 pb-20 pt-10", "p-4")
|
>
|
||||||
)}>
|
{isDefined(icon) && <Ico icon={icon} className="!text-[300%]" />}
|
||||||
{children}
|
<p
|
||||||
|
className={cJoin("w-64 text-2xl", cIf(!isDefined(icon), "text-center"))}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
};
|
);
|
|
@ -1,13 +1,10 @@
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { MouseEventHandler, useCallback } from "react";
|
import { MouseEventHandler, useMemo } from "react";
|
||||||
import { MaterialSymbol } from "material-symbols";
|
import { Ico, Icon } from "components/Ico";
|
||||||
import { Ico } from "components/Ico";
|
|
||||||
import { ToolTip } from "components/ToolTip";
|
import { ToolTip } from "components/ToolTip";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cJoin, cIf } from "helpers/className";
|
||||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
import { isDefinedAndNotEmpty } from "helpers/others";
|
||||||
import { TranslatedProps } from "types/TranslatedProps";
|
import { Link } from "components/Inputs/Link";
|
||||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
|
||||||
import { DownPressable } from "components/Containers/DownPressable";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -16,14 +13,12 @@ import { DownPressable } from "components/Containers/DownPressable";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
icon?: MaterialSymbol;
|
icon?: Icon;
|
||||||
title: string | null | undefined;
|
title: string | null | undefined;
|
||||||
subtitle?: string | null | undefined;
|
subtitle?: string | null | undefined;
|
||||||
border?: boolean;
|
border?: boolean;
|
||||||
reduced?: boolean;
|
reduced?: boolean;
|
||||||
active?: boolean;
|
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||||
disabled?: boolean;
|
|
||||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
@ -35,65 +30,54 @@ export const NavOption = ({
|
||||||
subtitle,
|
subtitle,
|
||||||
border = false,
|
border = false,
|
||||||
reduced = false,
|
reduced = false,
|
||||||
active = false,
|
|
||||||
disabled = false,
|
|
||||||
onClick,
|
onClick,
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isActive = active || router.asPath.startsWith(url);
|
const isActive = useMemo(
|
||||||
|
() => router.asPath.startsWith(url),
|
||||||
|
[url, router.asPath]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolTip
|
<ToolTip
|
||||||
content={
|
content={
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl">{title}</h3>
|
<h3 className="text-2xl">{title}</h3>
|
||||||
{isDefinedAndNotEmpty(subtitle) && <p className="col-start-2">{subtitle}</p>}
|
{isDefinedAndNotEmpty(subtitle) && (
|
||||||
|
<p className="col-start-2">{subtitle}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
placement="right"
|
placement="right"
|
||||||
className="text-left"
|
className="text-left"
|
||||||
disabled={!reduced || disabled}>
|
disabled={!reduced}
|
||||||
<DownPressable
|
>
|
||||||
className={cJoin(
|
<Link
|
||||||
"grid w-full auto-cols-fr grid-flow-col grid-cols-[auto] justify-center gap-x-5",
|
|
||||||
cIf(icon, "text-left", "text-center")
|
|
||||||
)}
|
|
||||||
href={url}
|
href={url}
|
||||||
border={border}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
active={isActive}
|
className={cJoin(
|
||||||
disabled={disabled}>
|
`relative grid w-full cursor-pointer auto-cols-fr grid-flow-col grid-cols-[auto]
|
||||||
{icon && <Ico icon={icon} className="mt-[-.1em] !text-2xl" isFilled={isActive} />}
|
justify-center gap-x-5 rounded-2xl p-4 transition-all hover:bg-mid hover:shadow-inner-sm
|
||||||
|
hover:shadow-shade hover:active:shadow-inner hover:active:shadow-shade`,
|
||||||
|
cIf(icon, "text-left", "text-center"),
|
||||||
|
cIf(
|
||||||
|
border,
|
||||||
|
"outline outline-2 outline-offset-[-2px] outline-mid hover:outline-[transparent]"
|
||||||
|
),
|
||||||
|
cIf(isActive, "bg-mid shadow-inner-sm shadow-shade")
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon && <Ico icon={icon} className="mt-[-.1em] !text-2xl" />}
|
||||||
|
|
||||||
{!reduced && (
|
{!reduced && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl">{title}</h3>
|
<h3 className="text-2xl">{title}</h3>
|
||||||
{isDefinedAndNotEmpty(subtitle) && <p className="col-start-2">{subtitle}</p>}
|
{isDefinedAndNotEmpty(subtitle) && (
|
||||||
|
<p className="col-start-2">{subtitle}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DownPressable>
|
</Link>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const TranslatedNavOption = ({
|
|
||||||
translations,
|
|
||||||
fallback,
|
|
||||||
...otherProps
|
|
||||||
}: TranslatedProps<Props, "subtitle" | "title">): JSX.Element => {
|
|
||||||
const [selectedTranslation] = useSmartLanguage({
|
|
||||||
items: translations,
|
|
||||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<NavOption
|
|
||||||
title={selectedTranslation?.title ?? fallback.title}
|
|
||||||
subtitle={selectedTranslation?.subtitle ?? fallback.subtitle}
|
|
||||||
{...otherProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { MaterialSymbol } from "material-symbols";
|
import { HorizontalLine } from "components/HorizontalLine";
|
||||||
import { Ico } from "components/Ico";
|
import { Ico, Icon } from "components/Ico";
|
||||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
import { isDefinedAndNotEmpty } from "helpers/others";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -8,19 +8,24 @@ import { isDefinedAndNotEmpty } from "helpers/asserts";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon?: MaterialSymbol;
|
icon?: Icon;
|
||||||
title: string | null | undefined;
|
title: string | null | undefined;
|
||||||
description?: string | null | undefined;
|
description?: string | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const PanelHeader = ({ icon, description, title }: Props): JSX.Element => (
|
export const PanelHeader = ({
|
||||||
|
icon,
|
||||||
|
description,
|
||||||
|
title,
|
||||||
|
}: Props): JSX.Element => (
|
||||||
<>
|
<>
|
||||||
<div className="grid w-full place-items-center">
|
<div className="grid w-full place-items-center">
|
||||||
{icon && <Ico icon={icon} className="mb-3 !text-4xl" />}
|
{icon && <Ico icon={icon} className="mb-3 !text-4xl" />}
|
||||||
<h2 className="text-2xl">{title}</h2>
|
<h2 className="text-2xl">{title}</h2>
|
||||||
{isDefinedAndNotEmpty(description) && <p>{description}</p>}
|
{isDefinedAndNotEmpty(description) && <p>{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
<HorizontalLine />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { useCallback } from "react";
|
import { HorizontalLine } from "components/HorizontalLine";
|
||||||
|
import { Icon } from "components/Ico";
|
||||||
import { Button } from "components/Inputs/Button";
|
import { Button } from "components/Inputs/Button";
|
||||||
import { TranslatedProps } from "types/TranslatedProps";
|
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
import { cJoin } from "helpers/className";
|
import { cJoin } from "helpers/className";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -13,35 +13,48 @@ import { cJoin } from "helpers/className";
|
||||||
interface Props {
|
interface Props {
|
||||||
href: string;
|
href: string;
|
||||||
title: string | null | undefined;
|
title: string | null | undefined;
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
|
displayOn: ReturnButtonType;
|
||||||
|
horizontalLine?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ReturnButtonType {
|
||||||
|
Mobile = "mobile",
|
||||||
|
Desktop = "desktop",
|
||||||
|
Both = "both",
|
||||||
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const ReturnButton = ({ href, title, className }: Props): JSX.Element => {
|
export const ReturnButton = ({
|
||||||
const { format } = useFormat();
|
href,
|
||||||
|
title,
|
||||||
|
langui,
|
||||||
|
displayOn,
|
||||||
|
horizontalLine,
|
||||||
|
className,
|
||||||
|
}: Props): JSX.Element => {
|
||||||
|
const { setSubPanelOpen } = useAppLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cJoin("mx-auto w-full max-w-lg place-self-center", className)}>
|
<div
|
||||||
<Button href={href} text={format("return_to_x", { x: title })} icon="navigate_before" />
|
className={cJoin(
|
||||||
|
displayOn === ReturnButtonType.Mobile
|
||||||
|
? "desktop:hidden"
|
||||||
|
: displayOn === ReturnButtonType.Desktop
|
||||||
|
? "mobile:hidden"
|
||||||
|
: "",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => setSubPanelOpen(false)}
|
||||||
|
href={href}
|
||||||
|
text={`${langui.return_to} ${title}`}
|
||||||
|
icon={Icon.NavigateBefore}
|
||||||
|
/>
|
||||||
|
{horizontalLine === true && <HorizontalLine />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const TranslatedReturnButton = ({
|
|
||||||
translations,
|
|
||||||
fallback,
|
|
||||||
...otherProps
|
|
||||||
}: TranslatedProps<Props, "title">): JSX.Element => {
|
|
||||||
const [selectedTranslation] = useSmartLanguage({
|
|
||||||
items: translations,
|
|
||||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
|
||||||
});
|
|
||||||
|
|
||||||
return <ReturnButton title={selectedTranslation?.title ?? fallback.title} {...otherProps} />;
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import { atoms } from "contexts/atoms";
|
import { cJoin } from "helpers/className";
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
|
||||||
import { cIf, cJoin } from "helpers/className";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -19,31 +17,26 @@ export enum ContentPanelWidthSizes {
|
||||||
Full = "full",
|
Full = "full",
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentPanelWidthSizesToClassName: Record<ContentPanelWidthSizes, string> = {
|
|
||||||
default: "max-w-2xl",
|
|
||||||
large: "max-w-4xl",
|
|
||||||
full: "w-full",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const ContentPanel = ({
|
export const ContentPanel = ({
|
||||||
width = ContentPanelWidthSizes.Default,
|
width = ContentPanelWidthSizes.Default,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => (
|
||||||
const isContentPanelAtLeast3xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast3xl);
|
<div className="grid h-full">
|
||||||
return (
|
<main
|
||||||
<div className="grid h-full">
|
className={cJoin(
|
||||||
<main
|
"justify-self-center px-4 pt-10 pb-20 desktop:px-10 desktop:pt-20 desktop:pb-32",
|
||||||
className={cJoin(
|
width === ContentPanelWidthSizes.Default
|
||||||
"relative justify-self-center",
|
? "max-w-2xl"
|
||||||
cIf(isContentPanelAtLeast3xl, "px-10 pb-32 pt-20", "px-4 pb-20 pt-10"),
|
: width === ContentPanelWidthSizes.Large
|
||||||
contentPanelWidthSizesToClassName[width],
|
? "max-w-4xl"
|
||||||
className
|
: "w-full",
|
||||||
)}>
|
className
|
||||||
{children}
|
)}
|
||||||
</main>
|
>
|
||||||
</div>
|
{children}
|
||||||
);
|
</main>
|
||||||
};
|
</div>
|
||||||
|
);
|
|
@ -1,52 +0,0 @@
|
||||||
import { Popup } from "components/Containers/Popup";
|
|
||||||
import { Ico } from "components/Ico";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { sendAnalytics } from "helpers/analytics";
|
|
||||||
import { useAtomGetter, useAtomPair } from "helpers/atoms";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const DebugPopup = (): JSX.Element => {
|
|
||||||
const [isDebugMenuOpened, setDebugMenuOpened] = useAtomPair(atoms.layout.debugMenuOpened);
|
|
||||||
|
|
||||||
const os = useAtomGetter(atoms.userAgent.os);
|
|
||||||
const browser = useAtomGetter(atoms.userAgent.browser);
|
|
||||||
const engine = useAtomGetter(atoms.userAgent.engine);
|
|
||||||
const deviceType = useAtomGetter(atoms.userAgent.deviceType);
|
|
||||||
|
|
||||||
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
|
||||||
const isPerfModeToggleable = useAtomGetter(atoms.settings.isPerfModeToggleable);
|
|
||||||
const perfMode = useAtomGetter(atoms.settings.perfMode);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popup
|
|
||||||
isVisible={isDebugMenuOpened}
|
|
||||||
onCloseRequest={() => {
|
|
||||||
setDebugMenuOpened(false);
|
|
||||||
sendAnalytics("Debug", "Close debug menu");
|
|
||||||
}}>
|
|
||||||
<h2 className="inline-flex place-items-center gap-2 text-2xl">
|
|
||||||
<Ico icon="bug_report" isFilled />
|
|
||||||
Debug Menu
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<h3>User Agent</h3>
|
|
||||||
<div>
|
|
||||||
<p>OS: {os}</p>
|
|
||||||
<p>Device type: {deviceType ?? "undefined"}</p>
|
|
||||||
<p>Browser: {browser}</p>
|
|
||||||
<p>Engine: {engine}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Settings</h3>
|
|
||||||
<div>
|
|
||||||
<p>Raw perf mode: {perfMode}</p>
|
|
||||||
<p>Perf mode: {isPerfModeEnabled ? "true" : "false"}</p>
|
|
||||||
<p>Perf mode toggleable: {isPerfModeToggleable ? "true" : "false"}</p>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,127 +1,107 @@
|
||||||
import { useCallback } from "react";
|
import Markdown from "markdown-to-jsx";
|
||||||
import { HorizontalLine } from "components/HorizontalLine";
|
import { HorizontalLine } from "components/HorizontalLine";
|
||||||
import { Button } from "components/Inputs/Button";
|
import { Button } from "components/Inputs/Button";
|
||||||
import { NavOption } from "components/PanelComponents/NavOption";
|
import { NavOption } from "components/PanelComponents/NavOption";
|
||||||
import { ToolTip } from "components/ToolTip";
|
import { ToolTip } from "components/ToolTip";
|
||||||
|
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||||
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
|
|
||||||
|
import { useMediaDesktop } from "hooks/useMediaQuery";
|
||||||
|
import { Icon } from "components/Ico";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cIf, cJoin } from "helpers/className";
|
||||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
import { isDefinedAndNotEmpty } from "helpers/others";
|
||||||
import { Link } from "components/Inputs/Link";
|
import { Link } from "components/Inputs/Link";
|
||||||
import { sendAnalytics } from "helpers/analytics";
|
|
||||||
import { ColoredSvg } from "components/ColoredSvg";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { useAtomGetter, useAtomPair, useAtomSetter } from "helpers/atoms";
|
|
||||||
import { Markdawn } from "components/Markdown/Markdawn";
|
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MainPanel = (): JSX.Element => {
|
interface Props {
|
||||||
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
|
langui: AppStaticProps["langui"];
|
||||||
const { format } = useFormat();
|
}
|
||||||
const [isMainPanelReduced, setMainPanelReduced] = useAtomPair(atoms.layout.mainPanelReduced);
|
|
||||||
const setMainPanelOpened = useAtomSetter(atoms.layout.mainPanelOpened);
|
|
||||||
const [isSettingsOpened, setSettingsOpened] = useAtomPair(atoms.layout.settingsOpened);
|
|
||||||
const [isSearchOpened, setSearchOpened] = useAtomPair(atoms.layout.searchOpened);
|
|
||||||
const [isDebugMenuOpened, setDebugMenuOpened] = useAtomPair(atoms.layout.debugMenuOpened);
|
|
||||||
const isDebugMenuAvailable = useAtomGetter(atoms.layout.debugMenuAvailable);
|
|
||||||
|
|
||||||
const closeMainPanel = useCallback(() => setMainPanelOpened(false), [setMainPanelOpened]);
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const MainPanel = ({ langui }: Props): JSX.Element => {
|
||||||
|
const isDesktop = useMediaDesktop();
|
||||||
|
const {
|
||||||
|
mainPanelReduced = false,
|
||||||
|
toggleMainPanelReduced,
|
||||||
|
setConfigPanelOpen,
|
||||||
|
} = useAppLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
"grid content-start justify-center gap-y-2 p-8 text-center",
|
"grid content-start justify-center gap-y-2 p-8 text-center",
|
||||||
cIf(isMainPanelReduced && is3ColumnsLayout, "px-4")
|
cIf(mainPanelReduced && isDesktop, "px-4")
|
||||||
)}>
|
|
||||||
{/* Reduce/expand main menu */}
|
|
||||||
{is3ColumnsLayout && (
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
"fixed top-1/2",
|
|
||||||
cIf(isMainPanelReduced, "left-[4.65rem]", "left-[18.65rem]")
|
|
||||||
)}>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (isMainPanelReduced) {
|
|
||||||
sendAnalytics("MainPanel", "Expand");
|
|
||||||
} else {
|
|
||||||
sendAnalytics("MainPanel", "Reduce");
|
|
||||||
}
|
|
||||||
setMainPanelReduced((current) => !current);
|
|
||||||
}}
|
|
||||||
className="z-50 bg-light !px-2"
|
|
||||||
icon={isMainPanelReduced ? "chevron_right" : "chevron_left"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{/* Reduce/expand main menu */}
|
||||||
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
"fixed top-1/2 mobile:hidden",
|
||||||
|
cIf(mainPanelReduced, "left-[4.65rem]", "left-[18.65rem]")
|
||||||
|
)}
|
||||||
|
onClick={toggleMainPanelReduced}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="bg-light !px-2"
|
||||||
|
icon={mainPanelReduced ? Icon.ChevronRight : Icon.ChevronLeft}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="grid place-items-center">
|
<div className="grid place-items-center">
|
||||||
<Link
|
<Link href="/" className="flex w-full justify-center">
|
||||||
href="/"
|
<div
|
||||||
className="flex w-full cursor-pointer justify-center"
|
|
||||||
onClick={closeMainPanel}>
|
|
||||||
<ColoredSvg
|
|
||||||
src="/icons/accords.svg"
|
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
"mb-4 aspect-square bg-black hover:bg-dark",
|
`mb-4 aspect-square cursor-pointer bg-black transition-colors
|
||||||
cIf(isMainPanelReduced && is3ColumnsLayout, "w-12", "w-1/2")
|
[mask:url('/icons/accords.svg')] ![mask-size:contain] ![mask-repeat:no-repeat]
|
||||||
|
![mask-position:center] hover:bg-dark`,
|
||||||
|
cIf(mainPanelReduced && isDesktop, "w-12", "w-1/2")
|
||||||
)}
|
)}
|
||||||
/>
|
></div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{(!isMainPanelReduced || !is3ColumnsLayout) && (
|
{(!mainPanelReduced || !isDesktop) && (
|
||||||
<h2 className="mb-4 text-3xl">Accord’s Library</h2>
|
<h2 className="mb-4 text-3xl">Accord’s Library</h2>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cJoin(
|
className={cJoin(
|
||||||
"flex flex-wrap gap-2",
|
"flex flex-wrap gap-2",
|
||||||
cIf(isMainPanelReduced && is3ColumnsLayout, "flex-col gap-3", "flex-row")
|
cIf(mainPanelReduced && isDesktop, "flex-col gap-3", "flex-row")
|
||||||
)}>
|
|
||||||
<ToolTip
|
|
||||||
content={<h3 className="text-2xl">{format("open_settings")}</h3>}
|
|
||||||
placement={isMainPanelReduced ? "right" : "top"}>
|
|
||||||
<Button
|
|
||||||
active={isSettingsOpened}
|
|
||||||
onClick={() => {
|
|
||||||
closeMainPanel();
|
|
||||||
setSettingsOpened(true);
|
|
||||||
sendAnalytics("Settings", "Open settings");
|
|
||||||
}}
|
|
||||||
icon="discover_tune"
|
|
||||||
/>
|
|
||||||
</ToolTip>
|
|
||||||
<ToolTip
|
|
||||||
content={<h3 className="text-2xl">{format("open_search")}</h3>}
|
|
||||||
placement={isMainPanelReduced ? "right" : "top"}>
|
|
||||||
<Button
|
|
||||||
active={isSearchOpened}
|
|
||||||
onClick={() => {
|
|
||||||
closeMainPanel();
|
|
||||||
setSearchOpened(true);
|
|
||||||
sendAnalytics("Search", "Open search");
|
|
||||||
}}
|
|
||||||
icon="search"
|
|
||||||
/>
|
|
||||||
</ToolTip>
|
|
||||||
{isDebugMenuAvailable && (
|
|
||||||
<ToolTip
|
|
||||||
content={<h3 className="text-2xl">Debug menu</h3>}
|
|
||||||
placement={isMainPanelReduced ? "right" : "top"}>
|
|
||||||
<Button
|
|
||||||
active={isDebugMenuOpened}
|
|
||||||
onClick={() => {
|
|
||||||
closeMainPanel();
|
|
||||||
setDebugMenuOpened(true);
|
|
||||||
sendAnalytics("Debug", "Open debug menu");
|
|
||||||
}}
|
|
||||||
icon="bug_report"
|
|
||||||
/>
|
|
||||||
</ToolTip>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<ToolTip
|
||||||
|
content={<h3 className="text-2xl">{langui.open_settings}</h3>}
|
||||||
|
placement="right"
|
||||||
|
className="text-left"
|
||||||
|
disabled={!mainPanelReduced}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setConfigPanelOpen(true);
|
||||||
|
}}
|
||||||
|
icon={Icon.Settings}
|
||||||
|
/>
|
||||||
|
</ToolTip>
|
||||||
|
|
||||||
|
{/* <ToolTip
|
||||||
|
content={<h3 className="text-2xl">{langui.open_search}</h3>}
|
||||||
|
placement="right"
|
||||||
|
className="text-left"
|
||||||
|
disabled={!mainPanelReduced}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchPanelOpen(true);
|
||||||
|
}}
|
||||||
|
icon={Icon.Search}
|
||||||
|
/>
|
||||||
|
</ToolTip> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -130,139 +110,144 @@ export const MainPanel = (): JSX.Element => {
|
||||||
|
|
||||||
<NavOption
|
<NavOption
|
||||||
url="/library"
|
url="/library"
|
||||||
icon="auto_stories"
|
icon={Icon.LibraryBooks}
|
||||||
title={format("library")}
|
title={langui.library}
|
||||||
subtitle={format("library_short_description")}
|
subtitle={langui.library_short_description}
|
||||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
reduced={mainPanelReduced && isDesktop}
|
||||||
onClick={closeMainPanel}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NavOption
|
<NavOption
|
||||||
url="/contents"
|
url="/contents"
|
||||||
icon="workspaces"
|
icon={Icon.Workspaces}
|
||||||
title={format("contents")}
|
title={langui.contents}
|
||||||
subtitle={format("contents_short_description")}
|
subtitle={langui.contents_short_description}
|
||||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
reduced={mainPanelReduced && isDesktop}
|
||||||
onClick={closeMainPanel}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NavOption
|
<NavOption
|
||||||
url="/wiki"
|
url="/wiki"
|
||||||
icon="travel_explore"
|
icon={Icon.TravelExplore}
|
||||||
title={format("wiki")}
|
title={langui.wiki}
|
||||||
subtitle={format("wiki_short_description")}
|
subtitle={langui.wiki_short_description}
|
||||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
reduced={mainPanelReduced && isDesktop}
|
||||||
onClick={closeMainPanel}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NavOption
|
<NavOption
|
||||||
url="/chronicles"
|
url="/chronicles"
|
||||||
icon="schedule"
|
icon={Icon.WatchLater}
|
||||||
title={format("chronicles")}
|
title={langui.chronicles}
|
||||||
subtitle={format("chronicles_short_description")}
|
subtitle={langui.chronicles_short_description}
|
||||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
reduced={mainPanelReduced && isDesktop}
|
||||||
onClick={closeMainPanel}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HorizontalLine />
|
<HorizontalLine />
|
||||||
|
|
||||||
<NavOption
|
<NavOption
|
||||||
url="/news"
|
url="/news"
|
||||||
icon="newspaper"
|
icon={Icon.Feed}
|
||||||
title={format("news")}
|
title={langui.news}
|
||||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
reduced={mainPanelReduced && isDesktop}
|
||||||
onClick={closeMainPanel}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
<NavOption
|
||||||
|
url="/merch"
|
||||||
|
icon={Icon.Store}
|
||||||
|
title={langui.merch}
|
||||||
|
reduced={mainPanelReduced && isDesktop}
|
||||||
|
/>
|
||||||
|
*/}
|
||||||
|
|
||||||
<NavOption
|
<NavOption
|
||||||
url="https://gallery.accords-library.com/posts/"
|
url="https://gallery.accords-library.com/posts/"
|
||||||
icon="perm_media"
|
icon={Icon.Collections}
|
||||||
title={format("gallery")}
|
title={langui.gallery}
|
||||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
reduced={mainPanelReduced && isDesktop}
|
||||||
onClick={closeMainPanel}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NavOption
|
<NavOption
|
||||||
url="/archives"
|
url="/archives"
|
||||||
icon="save"
|
icon={Icon.Inventory}
|
||||||
title={format("archives")}
|
title={langui.archives}
|
||||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
reduced={mainPanelReduced && isDesktop}
|
||||||
onClick={closeMainPanel}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NavOption
|
<NavOption
|
||||||
url="/about-us"
|
url="/about-us"
|
||||||
icon="info"
|
icon={Icon.Info}
|
||||||
title={format("about_us")}
|
title={langui.about_us}
|
||||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
reduced={mainPanelReduced && isDesktop}
|
||||||
onClick={closeMainPanel}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(!isMainPanelReduced || !is3ColumnsLayout) && <HorizontalLine />}
|
{mainPanelReduced && isDesktop ? "" : <HorizontalLine />}
|
||||||
|
|
||||||
<div className={cJoin("text-center", cIf(isMainPanelReduced && is3ColumnsLayout, "hidden"))}>
|
<div
|
||||||
{isDefinedAndNotEmpty(format("licensing_notice")) && (
|
className={cJoin(
|
||||||
|
"text-center",
|
||||||
|
cIf(mainPanelReduced && isDesktop, "hidden")
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isDefinedAndNotEmpty(langui.licensing_notice) && (
|
||||||
<p>
|
<p>
|
||||||
<Markdawn text={format("licensing_notice")} />
|
<Markdown>{langui.licensing_notice}</Markdown>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mb-8 mt-4 grid place-content-center">
|
<div className="mt-4 mb-8 grid place-content-center">
|
||||||
<Link
|
<a
|
||||||
onClick={() => sendAnalytics("MainPanel", "Visit license")}
|
|
||||||
aria-label="Read more about the license we use for this website"
|
aria-label="Read more about the license we use for this website"
|
||||||
className="group grid grid-flow-col place-content-center gap-1 transition-filter"
|
className="group grid grid-flow-col place-content-center gap-1 transition-[filter]"
|
||||||
href="https://creativecommons.org/licenses/by-sa/4.0/"
|
href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||||
alwaysNewTab>
|
>
|
||||||
<ColoredSvg
|
<div
|
||||||
className="h-6 w-6 bg-black group-hover:bg-dark"
|
className="aspect-square w-6 bg-black transition-colors
|
||||||
src="/icons/creative-commons-brands.svg"
|
[mask:url('/icons/creative-commons-brands.svg')] ![mask-size:contain]
|
||||||
|
![mask-repeat:no-repeat] ![mask-position:center] group-hover:bg-dark"
|
||||||
/>
|
/>
|
||||||
<ColoredSvg
|
<div
|
||||||
className="h-6 w-6 bg-black group-hover:bg-dark"
|
className="aspect-square w-6 bg-black transition-colors
|
||||||
src="/icons/creative-commons-by-brands.svg"
|
[mask:url('/icons/creative-commons-by-brands.svg')] ![mask-size:contain]
|
||||||
|
![mask-repeat:no-repeat] ![mask-position:center] group-hover:bg-dark"
|
||||||
/>
|
/>
|
||||||
<ColoredSvg
|
<div
|
||||||
className="h-6 w-6 bg-black group-hover:bg-dark"
|
className="aspect-square w-6 bg-black transition-colors
|
||||||
src="/icons/creative-commons-sa-brands.svg"
|
[mask:url('/icons/creative-commons-sa-brands.svg')] ![mask-size:contain]
|
||||||
|
![mask-repeat:no-repeat] ![mask-position:center] group-hover:bg-dark"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{isDefinedAndNotEmpty(format("copyright_notice")) && (
|
{isDefinedAndNotEmpty(langui.copyright_notice) && (
|
||||||
<p>
|
<p>
|
||||||
<Markdawn text={format("copyright_notice")} />
|
<Markdown>{langui.copyright_notice}</Markdown>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mb-4 mt-12 grid h-4 grid-flow-col place-content-center gap-8">
|
<div className="mt-12 mb-4 grid h-4 grid-flow-col place-content-center gap-8">
|
||||||
<Link
|
<a
|
||||||
aria-label="Browse our GitHub repository, which include this website source code"
|
aria-label="Browse our GitHub repository, which include this website source code"
|
||||||
onClick={() => sendAnalytics("MainPanel", "Visit GitHub")}
|
className="aspect-square w-10
|
||||||
|
bg-black transition-colors [mask:url('/icons/github-brands.svg')]
|
||||||
|
![mask-size:contain] ![mask-repeat:no-repeat] ![mask-position:center] hover:bg-dark"
|
||||||
href="https://github.com/Accords-Library"
|
href="https://github.com/Accords-Library"
|
||||||
alwaysNewTab>
|
target="_blank"
|
||||||
<ColoredSvg
|
rel="noopener noreferrer"
|
||||||
className="h-10 w-10 bg-black hover:bg-dark"
|
></a>
|
||||||
src="/icons/github-brands.svg"
|
<a
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
aria-label="Follow us on Twitter"
|
aria-label="Follow us on Twitter"
|
||||||
onClick={() => sendAnalytics("MainPanel", "Visit Twitter")}
|
className="aspect-square w-10
|
||||||
|
bg-black transition-colors [mask:url('/icons/twitter-brands.svg')]
|
||||||
|
![mask-size:contain] ![mask-repeat:no-repeat] ![mask-position:center] hover:bg-dark"
|
||||||
href="https://twitter.com/AccordsLibrary"
|
href="https://twitter.com/AccordsLibrary"
|
||||||
alwaysNewTab>
|
target="_blank"
|
||||||
<ColoredSvg
|
rel="noopener noreferrer"
|
||||||
className="h-10 w-10 bg-black hover:bg-dark"
|
></a>
|
||||||
src="/icons/twitter-brands.svg"
|
<a
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
aria-label="Join our Discord server!"
|
aria-label="Join our Discord server!"
|
||||||
onClick={() => sendAnalytics("MainPanel", "Visit Discord")}
|
className="aspect-square w-10
|
||||||
|
bg-black transition-colors [mask:url('/icons/discord-brands.svg')]
|
||||||
|
![mask-size:contain] ![mask-repeat:no-repeat] ![mask-position:center] hover:bg-dark"
|
||||||
href="/discord"
|
href="/discord"
|
||||||
alwaysNewTab>
|
target="_blank"
|
||||||
<ColoredSvg
|
rel="noopener noreferrer"
|
||||||
className="h-10 w-10 bg-black hover:bg-dark"
|
></a>
|
||||||
src="/icons/discord-brands.svg"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,535 +0,0 @@
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { MaterialSymbol } from "material-symbols";
|
|
||||||
import { Popup } from "components/Containers/Popup";
|
|
||||||
import { sendAnalytics } from "helpers/analytics";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { useAtomPair, useAtomSetter } from "helpers/atoms";
|
|
||||||
import { TextInput } from "components/Inputs/TextInput";
|
|
||||||
import {
|
|
||||||
containsHighlight,
|
|
||||||
CustomSearchResponse,
|
|
||||||
filterHitsWithHighlight,
|
|
||||||
meiliMultiSearch,
|
|
||||||
} from "helpers/search";
|
|
||||||
import { PreviewCard, TranslatedPreviewCard } from "components/PreviewCard";
|
|
||||||
import { filterHasAttributes, isDefined } from "helpers/asserts";
|
|
||||||
import {
|
|
||||||
MeiliContent,
|
|
||||||
MeiliIndices,
|
|
||||||
MeiliLibraryItem,
|
|
||||||
MeiliPost,
|
|
||||||
MeiliVideo,
|
|
||||||
MeiliWeapon,
|
|
||||||
MeiliWikiPage,
|
|
||||||
} from "shared/meilisearch-graphql-typings/meiliTypes";
|
|
||||||
import { getVideoThumbnailURL } from "helpers/videos";
|
|
||||||
import { UpPressable } from "components/Containers/UpPressable";
|
|
||||||
import { prettySlug } from "helpers/formatters";
|
|
||||||
import { Ico } from "components/Ico";
|
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SEARCH_LIMIT = 8;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ────────────────────────────────────────╯ COMPONENT ╰──────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface MultiResult {
|
|
||||||
libraryItems?: CustomSearchResponse<MeiliLibraryItem>;
|
|
||||||
contents?: CustomSearchResponse<MeiliContent>;
|
|
||||||
videos?: CustomSearchResponse<MeiliVideo>;
|
|
||||||
posts?: CustomSearchResponse<MeiliPost>;
|
|
||||||
wikiPages?: CustomSearchResponse<MeiliWikiPage>;
|
|
||||||
weapons?: CustomSearchResponse<MeiliWeapon>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SearchPopup = (): JSX.Element => {
|
|
||||||
const [isSearchOpened, setSearchOpened] = useAtomPair(atoms.layout.searchOpened);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const {
|
|
||||||
format,
|
|
||||||
formatCategory,
|
|
||||||
formatContentType,
|
|
||||||
formatWikiTag,
|
|
||||||
formatLibraryItemSubType,
|
|
||||||
formatWeaponType,
|
|
||||||
} = useFormat();
|
|
||||||
const [multiResult, setMultiResult] = useState<MultiResult>({});
|
|
||||||
|
|
||||||
const fetchSearchResults = useCallback((q: string) => {
|
|
||||||
const fetchMultiResult = async () => {
|
|
||||||
const searchResults = (
|
|
||||||
await meiliMultiSearch([
|
|
||||||
{
|
|
||||||
indexUid: MeiliIndices.LIBRARY_ITEM,
|
|
||||||
q,
|
|
||||||
limit: SEARCH_LIMIT,
|
|
||||||
attributesToRetrieve: [
|
|
||||||
"title",
|
|
||||||
"subtitle",
|
|
||||||
"descriptions",
|
|
||||||
"id",
|
|
||||||
"slug",
|
|
||||||
"thumbnail",
|
|
||||||
"release_date",
|
|
||||||
"price",
|
|
||||||
"categories",
|
|
||||||
"metadata",
|
|
||||||
],
|
|
||||||
attributesToHighlight: ["title", "subtitle", "descriptions"],
|
|
||||||
attributesToCrop: ["descriptions"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
indexUid: MeiliIndices.CONTENT,
|
|
||||||
q,
|
|
||||||
limit: SEARCH_LIMIT,
|
|
||||||
attributesToRetrieve: ["translations", "id", "slug", "categories", "type", "thumbnail"],
|
|
||||||
attributesToHighlight: ["translations"],
|
|
||||||
attributesToCrop: ["translations.displayable_description"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
indexUid: MeiliIndices.VIDEOS,
|
|
||||||
q,
|
|
||||||
limit: SEARCH_LIMIT,
|
|
||||||
attributesToRetrieve: [
|
|
||||||
"title",
|
|
||||||
"channel",
|
|
||||||
"uid",
|
|
||||||
"published_date",
|
|
||||||
"views",
|
|
||||||
"duration",
|
|
||||||
"description",
|
|
||||||
],
|
|
||||||
attributesToHighlight: ["title", "channel", "description"],
|
|
||||||
attributesToCrop: ["description"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
indexUid: MeiliIndices.POST,
|
|
||||||
q,
|
|
||||||
limit: SEARCH_LIMIT,
|
|
||||||
attributesToRetrieve: ["translations", "thumbnail", "slug", "date", "categories"],
|
|
||||||
attributesToHighlight: ["translations.title", "translations.displayable_description"],
|
|
||||||
attributesToCrop: ["translations.displayable_description"],
|
|
||||||
filter: ["hidden = false"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
indexUid: MeiliIndices.WEAPON,
|
|
||||||
q,
|
|
||||||
limit: SEARCH_LIMIT,
|
|
||||||
attributesToHighlight: ["translations.description", "translations.names"],
|
|
||||||
attributesToCrop: ["translations.description"],
|
|
||||||
sort: ["slug:asc"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
indexUid: MeiliIndices.WIKI_PAGE,
|
|
||||||
q,
|
|
||||||
limit: SEARCH_LIMIT,
|
|
||||||
attributesToHighlight: [
|
|
||||||
"translations.title",
|
|
||||||
"translations.aliases",
|
|
||||||
"translations.summary",
|
|
||||||
"translations.displayable_description",
|
|
||||||
],
|
|
||||||
attributesToCrop: ["translations.displayable_description"],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
).results;
|
|
||||||
|
|
||||||
const result: MultiResult = {};
|
|
||||||
|
|
||||||
searchResults.map((searchResult) => {
|
|
||||||
switch (searchResult.indexUid) {
|
|
||||||
case MeiliIndices.LIBRARY_ITEM: {
|
|
||||||
result.libraryItems = filterHitsWithHighlight<MeiliLibraryItem>(
|
|
||||||
searchResult,
|
|
||||||
"descriptions"
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case MeiliIndices.CONTENT: {
|
|
||||||
result.contents = filterHitsWithHighlight<MeiliContent>(searchResult, "translations");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case MeiliIndices.VIDEOS: {
|
|
||||||
result.videos = filterHitsWithHighlight<MeiliVideo>(searchResult);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case MeiliIndices.POST: {
|
|
||||||
result.posts = filterHitsWithHighlight<MeiliPost>(searchResult, "translations");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case MeiliIndices.WEAPON: {
|
|
||||||
result.weapons = filterHitsWithHighlight<MeiliWeapon>(searchResult, "translations");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case MeiliIndices.WIKI_PAGE: {
|
|
||||||
result.wikiPages = filterHitsWithHighlight<MeiliWikiPage>(searchResult, "translations");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
console.log("What the fuck?");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setMultiResult(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (q === "") {
|
|
||||||
setMultiResult({});
|
|
||||||
} else {
|
|
||||||
fetchMultiResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
setQuery(q);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popup
|
|
||||||
isVisible={isSearchOpened}
|
|
||||||
onCloseRequest={() => {
|
|
||||||
setSearchOpened(false);
|
|
||||||
sendAnalytics("Search", "Close search");
|
|
||||||
}}
|
|
||||||
onOpen={() => searchInputRef.current?.focus()}
|
|
||||||
fillViewport>
|
|
||||||
<h2 className="inline-flex place-items-center gap-2 text-2xl">
|
|
||||||
<Ico icon="search" isFilled />
|
|
||||||
{format("search")}
|
|
||||||
</h2>
|
|
||||||
<TextInput
|
|
||||||
ref={searchInputRef}
|
|
||||||
onChange={fetchSearchResults}
|
|
||||||
value={query}
|
|
||||||
placeholder={format("search_placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-wrap gap-12 gap-x-16">
|
|
||||||
{isDefined(multiResult.libraryItems) && (
|
|
||||||
<SearchResultSection
|
|
||||||
title={format("library")}
|
|
||||||
icon="auto_stories"
|
|
||||||
href={`/library?page=1&query=${query}\
|
|
||||||
&sort=0&primary=true&secondary=true&subitems=true&status=all`}
|
|
||||||
totalHits={multiResult.libraryItems.estimatedTotalHits}>
|
|
||||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
|
||||||
{multiResult.libraryItems.hits.map((item) => (
|
|
||||||
<TranslatedPreviewCard
|
|
||||||
key={item.id}
|
|
||||||
className="w-56"
|
|
||||||
href={`/library/${item.slug}`}
|
|
||||||
onClick={() => setSearchOpened(false)}
|
|
||||||
translations={filterHasAttributes(item._formatted.descriptions, [
|
|
||||||
"language.data.attributes.code",
|
|
||||||
]).map((translation) => ({
|
|
||||||
language: translation.language.data.attributes.code,
|
|
||||||
title: item.title,
|
|
||||||
subtitle: item.subtitle,
|
|
||||||
description: containsHighlight(translation.description)
|
|
||||||
? translation.description
|
|
||||||
: undefined,
|
|
||||||
}))}
|
|
||||||
fallback={{ title: item._formatted.title, subtitle: item._formatted.subtitle }}
|
|
||||||
thumbnail={item.thumbnail?.data?.attributes}
|
|
||||||
thumbnailAspectRatio="21/29.7"
|
|
||||||
thumbnailRounded={false}
|
|
||||||
keepInfoVisible
|
|
||||||
topChips={
|
|
||||||
item.metadata && item.metadata.length > 0 && item.metadata[0]
|
|
||||||
? [formatLibraryItemSubType(item.metadata[0])]
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
|
|
||||||
(category) => formatCategory(category.attributes.slug)
|
|
||||||
)}
|
|
||||||
metadata={{
|
|
||||||
releaseDate: item.release_date,
|
|
||||||
price: item.price,
|
|
||||||
position: "Bottom",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SearchResultSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDefined(multiResult.contents) && (
|
|
||||||
<SearchResultSection
|
|
||||||
title={format("contents")}
|
|
||||||
icon="workspaces"
|
|
||||||
href={`/contents/all?page=1&query=${query}&sort=0`}
|
|
||||||
totalHits={multiResult.contents.estimatedTotalHits}>
|
|
||||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
|
||||||
{multiResult.contents.hits.map((item) => (
|
|
||||||
<TranslatedPreviewCard
|
|
||||||
key={item.id}
|
|
||||||
className="w-56"
|
|
||||||
href={`/contents/${item.slug}`}
|
|
||||||
onClick={() => setSearchOpened(false)}
|
|
||||||
translations={filterHasAttributes(item._formatted.translations, [
|
|
||||||
"language.data.attributes.code",
|
|
||||||
]).map(({ displayable_description, language, ...otherAttributes }) => ({
|
|
||||||
...otherAttributes,
|
|
||||||
description: containsHighlight(displayable_description)
|
|
||||||
? displayable_description
|
|
||||||
: undefined,
|
|
||||||
language: language.data.attributes.code,
|
|
||||||
}))}
|
|
||||||
fallback={{ title: prettySlug(item.slug) }}
|
|
||||||
thumbnail={item.thumbnail?.data?.attributes}
|
|
||||||
thumbnailAspectRatio="3/2"
|
|
||||||
thumbnailForceAspectRatio
|
|
||||||
topChips={
|
|
||||||
item.type?.data?.attributes
|
|
||||||
? [formatContentType(item.type.data.attributes.slug)]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
|
|
||||||
(category) => formatCategory(category.attributes.slug)
|
|
||||||
)}
|
|
||||||
keepInfoVisible
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SearchResultSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDefined(multiResult.wikiPages) && (
|
|
||||||
<SearchResultSection
|
|
||||||
title={format("wiki")}
|
|
||||||
icon="travel_explore"
|
|
||||||
href={`/wiki?page=1&query=${query}`}
|
|
||||||
totalHits={multiResult.wikiPages.estimatedTotalHits}>
|
|
||||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
|
||||||
{multiResult.wikiPages.hits.map((item) => (
|
|
||||||
<TranslatedPreviewCard
|
|
||||||
key={item.id}
|
|
||||||
className="w-56"
|
|
||||||
href={`/wiki/${item.slug}`}
|
|
||||||
onClick={() => setSearchOpened(false)}
|
|
||||||
translations={filterHasAttributes(item._formatted.translations, [
|
|
||||||
"language.data.attributes.code",
|
|
||||||
]).map(
|
|
||||||
({
|
|
||||||
aliases,
|
|
||||||
summary,
|
|
||||||
displayable_description,
|
|
||||||
language,
|
|
||||||
...otherAttributes
|
|
||||||
}) => ({
|
|
||||||
...otherAttributes,
|
|
||||||
subtitle:
|
|
||||||
aliases && aliases.length > 0
|
|
||||||
? aliases.map((alias) => alias?.alias).join("・")
|
|
||||||
: undefined,
|
|
||||||
description: containsHighlight(displayable_description)
|
|
||||||
? displayable_description
|
|
||||||
: summary,
|
|
||||||
language: language.data.attributes.code,
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
fallback={{ title: prettySlug(item.slug) }}
|
|
||||||
thumbnail={item.thumbnail?.data?.attributes}
|
|
||||||
thumbnailAspectRatio={"4/3"}
|
|
||||||
thumbnailRounded
|
|
||||||
thumbnailForceAspectRatio
|
|
||||||
keepInfoVisible
|
|
||||||
topChips={filterHasAttributes(item.tags?.data, ["attributes"]).map((tag) =>
|
|
||||||
formatWikiTag(tag.attributes.slug)
|
|
||||||
)}
|
|
||||||
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
|
|
||||||
(category) => formatCategory(category.attributes.slug)
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SearchResultSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDefined(multiResult.posts) && (
|
|
||||||
<SearchResultSection
|
|
||||||
title={format("news")}
|
|
||||||
icon="newspaper"
|
|
||||||
href={`/news?page=1&query=${query}`}
|
|
||||||
totalHits={multiResult.posts.estimatedTotalHits}>
|
|
||||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
|
||||||
{multiResult.posts.hits.map((item) => (
|
|
||||||
<TranslatedPreviewCard
|
|
||||||
className="w-56"
|
|
||||||
key={item.id}
|
|
||||||
href={`/news/${item.slug}`}
|
|
||||||
onClick={() => setSearchOpened(false)}
|
|
||||||
translations={filterHasAttributes(item._formatted.translations, [
|
|
||||||
"language.data.attributes.code",
|
|
||||||
]).map(({ excerpt, displayable_description, language, ...otherAttributes }) => ({
|
|
||||||
...otherAttributes,
|
|
||||||
description: containsHighlight(displayable_description)
|
|
||||||
? displayable_description
|
|
||||||
: excerpt,
|
|
||||||
language: language.data.attributes.code,
|
|
||||||
}))}
|
|
||||||
fallback={{ title: prettySlug(item.slug) }}
|
|
||||||
thumbnail={item.thumbnail?.data?.attributes}
|
|
||||||
thumbnailAspectRatio="3/2"
|
|
||||||
thumbnailForceAspectRatio
|
|
||||||
keepInfoVisible
|
|
||||||
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
|
|
||||||
(category) => formatCategory(category.attributes.slug)
|
|
||||||
)}
|
|
||||||
metadata={{
|
|
||||||
releaseDate: item.date,
|
|
||||||
releaseDateFormat: "long",
|
|
||||||
position: "Top",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SearchResultSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDefined(multiResult.videos) && (
|
|
||||||
<SearchResultSection
|
|
||||||
title={format("videos")}
|
|
||||||
icon="movie"
|
|
||||||
href={`/archives/videos?page=1&query=${query}&sort=1&gone=`}
|
|
||||||
totalHits={multiResult.videos.estimatedTotalHits}>
|
|
||||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
|
||||||
{multiResult.videos.hits.map((item) => (
|
|
||||||
<PreviewCard
|
|
||||||
className="w-56"
|
|
||||||
key={item.uid}
|
|
||||||
href={`/archives/videos/v/${item.uid}`}
|
|
||||||
onClick={() => setSearchOpened(false)}
|
|
||||||
title={item._formatted.title}
|
|
||||||
thumbnail={getVideoThumbnailURL(item.uid)}
|
|
||||||
thumbnailAspectRatio="16/9"
|
|
||||||
thumbnailForceAspectRatio
|
|
||||||
keepInfoVisible
|
|
||||||
metadata={{
|
|
||||||
releaseDate: item.published_date,
|
|
||||||
views: item.views,
|
|
||||||
author: item._formatted.channel?.data?.attributes?.title,
|
|
||||||
position: "Top",
|
|
||||||
}}
|
|
||||||
description={
|
|
||||||
item._matchesPosition.description &&
|
|
||||||
item._matchesPosition.description.length > 0
|
|
||||||
? item._formatted.description
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
hoverlay={{
|
|
||||||
__typename: "Video",
|
|
||||||
duration: item.duration,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SearchResultSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDefined(multiResult.weapons) && (
|
|
||||||
<SearchResultSection
|
|
||||||
title={format("weapon", { count: Infinity })}
|
|
||||||
icon="shield"
|
|
||||||
href={`/wiki/weapons?page=1&query=${query}`}
|
|
||||||
totalHits={multiResult.weapons.estimatedTotalHits}>
|
|
||||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
|
||||||
{multiResult.weapons.hits.map((item) => (
|
|
||||||
<TranslatedPreviewCard
|
|
||||||
key={item.id}
|
|
||||||
className="w-56"
|
|
||||||
href={"/"}
|
|
||||||
translations={filterHasAttributes(item._formatted.translations, [
|
|
||||||
"language.data.attributes.code",
|
|
||||||
]).map(({ description, language, names: [primaryName, ...aliases] }) => ({
|
|
||||||
language: language.data.attributes.code,
|
|
||||||
title: primaryName,
|
|
||||||
subtitle: aliases.join("・"),
|
|
||||||
description: containsHighlight(description) ? description : undefined,
|
|
||||||
}))}
|
|
||||||
fallback={{ title: prettySlug(item.slug) }}
|
|
||||||
thumbnail={item.thumbnail?.data?.attributes}
|
|
||||||
thumbnailAspectRatio="1/1"
|
|
||||||
thumbnailForceAspectRatio
|
|
||||||
thumbnailFitMethod="contain"
|
|
||||||
keepInfoVisible
|
|
||||||
topChips={
|
|
||||||
item.type?.data?.attributes?.slug
|
|
||||||
? [formatWeaponType(item.type.data.attributes.slug)]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
bottomChips={filterHasAttributes(item.categories, ["attributes"]).map(
|
|
||||||
(category) => formatCategory(category.attributes.slug)
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SearchResultSection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface SearchResultSectionProps {
|
|
||||||
title?: string | null;
|
|
||||||
icon: MaterialSymbol;
|
|
||||||
href: string;
|
|
||||||
totalHits?: number;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchResultSection = ({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
href,
|
|
||||||
totalHits,
|
|
||||||
children,
|
|
||||||
}: SearchResultSectionProps) => {
|
|
||||||
const { format } = useFormat();
|
|
||||||
const setSearchOpened = useAtomSetter(atoms.layout.searchOpened);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isDefined(totalHits) && totalHits > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-6 grid place-content-start">
|
|
||||||
<UpPressable
|
|
||||||
className="grid grid-cols-[auto_1fr] place-items-center gap-6 px-6 py-4"
|
|
||||||
href={href}
|
|
||||||
onClick={() => setSearchOpened(false)}>
|
|
||||||
<Ico icon={icon} className="!text-3xl" isFilled={false} />
|
|
||||||
<div>
|
|
||||||
<p className="font-headers text-lg">{title}</p>
|
|
||||||
{isDefined(totalHits) && totalHits > SEARCH_LIMIT && (
|
|
||||||
<p className="text-sm">
|
|
||||||
({format("showing_x_out_of_y_results", { x: SEARCH_LIMIT, y: totalHits })})
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</UpPressable>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,264 +0,0 @@
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ButtonGroup } from "components/Inputs/ButtonGroup";
|
|
||||||
import { OrderableList } from "components/Inputs/OrderableList";
|
|
||||||
import { Select } from "components/Inputs/Select";
|
|
||||||
import { TextInput } from "components/Inputs/TextInput";
|
|
||||||
import { Popup } from "components/Containers/Popup";
|
|
||||||
import { sendAnalytics } from "helpers/analytics";
|
|
||||||
import { cJoin, cIf } from "helpers/className";
|
|
||||||
import { filterHasAttributes, isDefined } from "helpers/asserts";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { useAtomGetter, useAtomPair, useAtomSetter } from "helpers/atoms";
|
|
||||||
import { PerfMode, ThemeMode } from "contexts/settings";
|
|
||||||
import { Ico } from "components/Ico";
|
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
import { ToolTip } from "components/ToolTip";
|
|
||||||
import { Switch } from "components/Inputs/Switch";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const SettingsPopup = (): JSX.Element => {
|
|
||||||
const [preferredLanguages, setPreferredLanguages] = useAtomPair(
|
|
||||||
atoms.settings.preferredLanguages
|
|
||||||
);
|
|
||||||
const [isSettingsOpened, setSettingsOpened] = useAtomPair(atoms.layout.settingsOpened);
|
|
||||||
const [currency, setCurrency] = useAtomPair(atoms.settings.currency);
|
|
||||||
const [isDyslexic, setDyslexic] = useAtomPair(atoms.settings.dyslexic);
|
|
||||||
const [fontSize, setFontSize] = useAtomPair(atoms.settings.fontSize);
|
|
||||||
const [playerName, setPlayerName] = useAtomPair(atoms.settings.playerName);
|
|
||||||
const [themeMode, setThemeMode] = useAtomPair(atoms.settings.themeMode);
|
|
||||||
const setPerfMode = useAtomSetter(atoms.settings.perfMode);
|
|
||||||
const perfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
|
||||||
const isPerfModeToggleable = useAtomGetter(atoms.settings.isPerfModeToggleable);
|
|
||||||
|
|
||||||
const { format, formatLanguage } = useFormat();
|
|
||||||
const currencies = useAtomGetter(atoms.localData.currencies);
|
|
||||||
|
|
||||||
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const currencyOptions = filterHasAttributes(currencies, ["attributes"]).map(
|
|
||||||
(currentCurrency) => currentCurrency.attributes.code
|
|
||||||
);
|
|
||||||
|
|
||||||
const [currencySelect, setCurrencySelect] = useState<number>(-1);
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDefined(currency)) setCurrencySelect(currencyOptions.indexOf(currency));
|
|
||||||
}, [currency, currencyOptions]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popup
|
|
||||||
isVisible={isSettingsOpened}
|
|
||||||
onCloseRequest={() => {
|
|
||||||
setSettingsOpened(false);
|
|
||||||
sendAnalytics("Settings", "Close settings");
|
|
||||||
}}>
|
|
||||||
<h2 className="inline-flex place-items-center gap-2 text-2xl">
|
|
||||||
<Ico icon="discover_tune" isFilled />
|
|
||||||
{format("settings")}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
`mt-4 grid justify-items-center gap-16 text-center`,
|
|
||||||
cIf(!is1ColumnLayout, "grid-cols-[auto_auto]")
|
|
||||||
)}>
|
|
||||||
{router.locales && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl">{format("language", { count: preferredLanguages.length })}</h3>
|
|
||||||
{preferredLanguages.length > 0 && (
|
|
||||||
<OrderableList
|
|
||||||
items={preferredLanguages.map((locale) => ({
|
|
||||||
code: locale,
|
|
||||||
name: formatLanguage(locale),
|
|
||||||
}))}
|
|
||||||
insertLabels={[
|
|
||||||
{
|
|
||||||
insertAt: 0,
|
|
||||||
name: format("primary_language"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
insertAt: 1,
|
|
||||||
name: format("secondary_language"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onChange={(items) => {
|
|
||||||
const newPreferredLanguages = items.map((item) => item.code);
|
|
||||||
setPreferredLanguages(newPreferredLanguages);
|
|
||||||
sendAnalytics("Settings", "Change preferred languages");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
"grid place-items-center gap-8 text-center",
|
|
||||||
cIf(!is1ColumnLayout, "grid-cols-2")
|
|
||||||
)}>
|
|
||||||
<div>
|
|
||||||
<div className="flex place-content-center place-items-center gap-1">
|
|
||||||
<h3 className="text-xl">{format("theme")}</h3>
|
|
||||||
<ToolTip content={format("dark_mode_extension_warning")} placement="top">
|
|
||||||
<Ico icon="info" />
|
|
||||||
</ToolTip>
|
|
||||||
</div>
|
|
||||||
<ButtonGroup
|
|
||||||
buttonsProps={[
|
|
||||||
{
|
|
||||||
onClick: () => {
|
|
||||||
setThemeMode(ThemeMode.Light);
|
|
||||||
sendAnalytics("Settings", "Change theme (light)");
|
|
||||||
},
|
|
||||||
active: themeMode === ThemeMode.Light,
|
|
||||||
text: format("light"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onClick: () => {
|
|
||||||
setThemeMode(ThemeMode.Auto);
|
|
||||||
sendAnalytics("Settings", "Change theme (auto)");
|
|
||||||
},
|
|
||||||
active: themeMode === ThemeMode.Auto,
|
|
||||||
text: format("auto"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onClick: () => {
|
|
||||||
setThemeMode(ThemeMode.Dark);
|
|
||||||
sendAnalytics("Settings", "Change theme (dark)");
|
|
||||||
},
|
|
||||||
active: themeMode === ThemeMode.Dark,
|
|
||||||
text: format("dark"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl">{format("currency")}</h3>
|
|
||||||
<div>
|
|
||||||
<Select
|
|
||||||
options={currencyOptions}
|
|
||||||
value={currencySelect}
|
|
||||||
onChange={(newCurrency) => {
|
|
||||||
const newCurrencyName = currencyOptions[newCurrency];
|
|
||||||
if (isDefined(newCurrencyName)) {
|
|
||||||
setCurrency(newCurrencyName);
|
|
||||||
sendAnalytics("Settings", `Change currency (${currencyOptions[newCurrency]})`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-28"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl">{format("font_size")}</h3>
|
|
||||||
<ButtonGroup
|
|
||||||
buttonsProps={[
|
|
||||||
{
|
|
||||||
onClick: () => {
|
|
||||||
setFontSize((current) => current / 1.05);
|
|
||||||
sendAnalytics(
|
|
||||||
"Settings",
|
|
||||||
`Change font size (${((fontSize / 1.05) * 100).toLocaleString(undefined, {
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
})}%)`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: "text_decrease",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onClick: () => {
|
|
||||||
setFontSize(1);
|
|
||||||
sendAnalytics("Settings", "Change font size (100%)");
|
|
||||||
},
|
|
||||||
text: `${(fontSize * 100).toLocaleString(undefined, {
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
})}%`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onClick: () => {
|
|
||||||
setFontSize((current) => current * 1.05);
|
|
||||||
sendAnalytics(
|
|
||||||
"Settings",
|
|
||||||
`Change font size (${(fontSize * 1.05 * 100).toLocaleString(undefined, {
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
})}%)`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: "text_increase",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl">{format("font")}</h3>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<ButtonGroup
|
|
||||||
vertical
|
|
||||||
buttonsProps={[
|
|
||||||
{
|
|
||||||
active: !isDyslexic,
|
|
||||||
onClick: () => {
|
|
||||||
setDyslexic(false);
|
|
||||||
sendAnalytics("Settings", "Change font (Zen Maru Gothic)");
|
|
||||||
},
|
|
||||||
className: "font-zenMaruGothic",
|
|
||||||
text: "Zen Maru Gothic",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
active: isDyslexic,
|
|
||||||
onClick: () => {
|
|
||||||
setDyslexic(true);
|
|
||||||
sendAnalytics("Settings", "Change font (OpenDyslexic)");
|
|
||||||
},
|
|
||||||
className: "font-openDyslexic",
|
|
||||||
text: "OpenDyslexic",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex place-content-center place-items-center gap-1">
|
|
||||||
<h3 className="text-xl">{format("player_name")}</h3>
|
|
||||||
<ToolTip content={format("player_name_tooltip")} placement="top">
|
|
||||||
<Ico icon="info" />
|
|
||||||
</ToolTip>
|
|
||||||
</div>
|
|
||||||
<TextInput
|
|
||||||
placeholder="(player)"
|
|
||||||
className="w-48"
|
|
||||||
value={playerName}
|
|
||||||
onChange={(newName) => {
|
|
||||||
setPlayerName(newName);
|
|
||||||
sendAnalytics("Settings", "Change username");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid place-items-center">
|
|
||||||
<div className="flex place-content-center place-items-center gap-1">
|
|
||||||
<h3 className="text-xl">{format("performance_mode")}</h3>
|
|
||||||
<ToolTip content={format("performance_mode_tooltip")} placement="top">
|
|
||||||
<Ico icon="info" />
|
|
||||||
</ToolTip>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
value={perfModeEnabled}
|
|
||||||
onClick={() => setPerfMode(perfModeEnabled ? PerfMode.Off : PerfMode.On)}
|
|
||||||
disabled={!isPerfModeToggleable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,21 +1,16 @@
|
||||||
import DOMPurify from "isomorphic-dompurify";
|
|
||||||
import { marked } from "marked";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface Props {
|
||||||
className?: string;
|
children: React.ReactNode;
|
||||||
text: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const Markdown = ({ className, text }: MarkdownProps): JSX.Element => (
|
export const SubPanel = ({ children }: Props): JSX.Element => (
|
||||||
<div
|
<div className="grid gap-y-2 px-6 pt-10 pb-20 text-center desktop:py-8 desktop:px-10">
|
||||||
className={className}
|
{children}
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(text)) }}
|
</div>
|
||||||
/>
|
|
||||||
);
|
);
|
|
@ -1,301 +0,0 @@
|
||||||
import { useCallback, useEffect, useId, useState } from "react";
|
|
||||||
import Slider from "rc-slider";
|
|
||||||
import { useHotkeys } from "react-hotkeys-hook";
|
|
||||||
import { Button } from "components/Inputs/Button";
|
|
||||||
import { prettyDuration } from "helpers/formatters";
|
|
||||||
import { ButtonGroup } from "components/Inputs/ButtonGroup";
|
|
||||||
import { cIf, cJoin } from "helpers/className";
|
|
||||||
import { useFullscreen } from "hooks/useFullscreen";
|
|
||||||
import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts";
|
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { ToolTip } from "components/ToolTip";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
const STEP_MULTIPLIER = 100;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭─────────────╮
|
|
||||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface AudioPlayerProps {
|
|
||||||
src?: string;
|
|
||||||
className?: string;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AudioPlayer = ({ src, className, title }: AudioPlayerProps): JSX.Element => {
|
|
||||||
const [ref, setRef] = useState<HTMLAudioElement | null>(null);
|
|
||||||
const [isFocused, setFocus] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cJoin("w-full", className)}
|
|
||||||
tabIndex={0}
|
|
||||||
onFocus={() => setFocus(true)}
|
|
||||||
onBlur={() => setFocus(false)}>
|
|
||||||
<audio ref={setRef} src={src} />
|
|
||||||
{ref && (
|
|
||||||
<PlayerControls
|
|
||||||
className={className}
|
|
||||||
mediaRef={ref}
|
|
||||||
type="audio"
|
|
||||||
src={src}
|
|
||||||
title={title}
|
|
||||||
isFocused={isFocused}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
interface VideoPlayerProps {
|
|
||||||
src?: string;
|
|
||||||
className?: string;
|
|
||||||
title?: string;
|
|
||||||
rounded?: boolean;
|
|
||||||
subSrc?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoPlayer = ({
|
|
||||||
src,
|
|
||||||
className,
|
|
||||||
title,
|
|
||||||
subSrc,
|
|
||||||
rounded = true,
|
|
||||||
}: VideoPlayerProps): JSX.Element => {
|
|
||||||
const [ref, setRef] = useState<HTMLVideoElement | null>(null);
|
|
||||||
const videoId = useId();
|
|
||||||
const { isFullscreen, toggleFullscreen } = useFullscreen(videoId);
|
|
||||||
const [isPlaying, setPlaying] = useState(false);
|
|
||||||
const [isFocused, setFocus] = useState(false);
|
|
||||||
|
|
||||||
const togglePlayback = useCallback(
|
|
||||||
async () => (isPlaying ? ref?.pause() : await ref?.play()),
|
|
||||||
[isPlaying, ref]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cJoin("grid w-full", className)}
|
|
||||||
id={videoId}
|
|
||||||
tabIndex={0}
|
|
||||||
onFocus={() => setFocus(true)}
|
|
||||||
onBlur={() => setFocus(false)}>
|
|
||||||
<video
|
|
||||||
ref={setRef}
|
|
||||||
className={cJoin("h-full w-full", cIf(!isFullscreen && rounded, "rounded-t-4xl"))}
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
onClick={togglePlayback}
|
|
||||||
onDoubleClick={toggleFullscreen}>
|
|
||||||
<source type="video/mp4" src={src} />
|
|
||||||
{subSrc && <track label="English" kind="subtitles" srcLang="en" src={subSrc} default />}
|
|
||||||
</video>
|
|
||||||
{ref && (
|
|
||||||
<PlayerControls
|
|
||||||
title={title}
|
|
||||||
mediaRef={ref}
|
|
||||||
src={src}
|
|
||||||
type="video"
|
|
||||||
className={cIf(isFullscreen || !rounded, "rounded-none", "rounded-b-4xl rounded-t-none")}
|
|
||||||
fullscreen={{ isFullscreen, toggleFullscreen }}
|
|
||||||
onPlaybackChanged={setPlaying}
|
|
||||||
isFocused={isFocused}
|
|
||||||
hasCC={isDefined(subSrc)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
|
|
||||||
interface PlayerControls {
|
|
||||||
mediaRef: HTMLMediaElement;
|
|
||||||
src?: string;
|
|
||||||
title?: string;
|
|
||||||
className?: string;
|
|
||||||
isFocused?: boolean;
|
|
||||||
type: "audio" | "video";
|
|
||||||
fullscreen?: {
|
|
||||||
isFullscreen: boolean;
|
|
||||||
toggleFullscreen: () => void;
|
|
||||||
};
|
|
||||||
onPlaybackChanged?: (isPlaying: boolean) => void;
|
|
||||||
hasCC?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlayerControls = ({
|
|
||||||
mediaRef,
|
|
||||||
className,
|
|
||||||
src,
|
|
||||||
title,
|
|
||||||
fullscreen,
|
|
||||||
isFocused = false,
|
|
||||||
hasCC = false,
|
|
||||||
type,
|
|
||||||
onPlaybackChanged,
|
|
||||||
}: PlayerControls) => {
|
|
||||||
const [isPlaying, setPlaying] = useState(false);
|
|
||||||
const [duration, setDuration] = useState(mediaRef.duration);
|
|
||||||
const [currentTime, setCurrentTime] = useState(mediaRef.currentTime);
|
|
||||||
const [isMuted, setMuted] = useState(mediaRef.volume === 0);
|
|
||||||
const [hasEnded, setEnded] = useState(false);
|
|
||||||
const [ccVisible, setCCVisible] = useState(hasCC);
|
|
||||||
const isContentPanelAtLeastXl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastXl);
|
|
||||||
const isContentPanelAtLeastMd = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastMd);
|
|
||||||
|
|
||||||
const togglePlayback = useCallback(
|
|
||||||
async () => (isPlaying ? mediaRef.pause() : await mediaRef.play()),
|
|
||||||
[isPlaying, mediaRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotkeys(
|
|
||||||
"left",
|
|
||||||
() => {
|
|
||||||
mediaRef.currentTime -= 5;
|
|
||||||
},
|
|
||||||
{ enabled: isFocused }
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotkeys(
|
|
||||||
"right",
|
|
||||||
() => {
|
|
||||||
mediaRef.currentTime += 5;
|
|
||||||
},
|
|
||||||
{ enabled: isFocused }
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const audio = mediaRef;
|
|
||||||
audio.addEventListener("loadedmetadata", () => {
|
|
||||||
setDuration(audio.duration);
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.addEventListener("play", () => {
|
|
||||||
setPlaying(true);
|
|
||||||
onPlaybackChanged?.(true);
|
|
||||||
setEnded(false);
|
|
||||||
});
|
|
||||||
audio.addEventListener("pause", () => {
|
|
||||||
setPlaying(false);
|
|
||||||
onPlaybackChanged?.(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.addEventListener("ended", () => setEnded(true));
|
|
||||||
|
|
||||||
audio.addEventListener("timeupdate", () => {
|
|
||||||
setCurrentTime(audio.currentTime);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => audio.pause();
|
|
||||||
}, [mediaRef, onPlaybackChanged]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const textTrack = mediaRef.textTracks[0];
|
|
||||||
if (isUndefined(textTrack)) return;
|
|
||||||
textTrack.mode = ccVisible ? "showing" : "hidden";
|
|
||||||
}, [ccVisible, mediaRef.textTracks]);
|
|
||||||
|
|
||||||
const buttonGroup = (
|
|
||||||
<ButtonGroup
|
|
||||||
vertical={!isContentPanelAtLeastXl && type === "video"}
|
|
||||||
buttonsProps={[
|
|
||||||
{
|
|
||||||
icon: isMuted ? "volume_off" : "volume_up",
|
|
||||||
active: isMuted,
|
|
||||||
onClick: () => {
|
|
||||||
setMuted((oldMutedValue) => {
|
|
||||||
const newMutedValue = !oldMutedValue;
|
|
||||||
mediaRef.volume = newMutedValue ? 0 : 1;
|
|
||||||
return newMutedValue;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "closed_caption",
|
|
||||||
active: ccVisible,
|
|
||||||
onClick: () => setCCVisible((value) => !value),
|
|
||||||
visible: hasCC,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: fullscreen?.isFullscreen ? "fullscreen_exit" : "fullscreen",
|
|
||||||
active: fullscreen?.isFullscreen,
|
|
||||||
onClick: fullscreen?.toggleFullscreen,
|
|
||||||
visible: isDefined(fullscreen),
|
|
||||||
},
|
|
||||||
{ icon: "download", href: src, alwaysNewTab: true },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
`relative flex w-full place-items-center rounded-full
|
|
||||||
bg-highlight p-3 shadow-md shadow-shade/50`,
|
|
||||||
cIf(isContentPanelAtLeastMd, "gap-5", "gap-3"),
|
|
||||||
className
|
|
||||||
)}>
|
|
||||||
<Button
|
|
||||||
icon={hasEnded ? "replay" : isPlaying ? "pause" : "play_arrow"}
|
|
||||||
active={isPlaying}
|
|
||||||
onClick={togglePlayback}
|
|
||||||
/>
|
|
||||||
<div className="grid w-full place-items-start">
|
|
||||||
{isDefinedAndNotEmpty(title) && (
|
|
||||||
<p className="!my-0 line-clamp-1 text-left text-xs text-dark">{title}</p>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={cJoin(
|
|
||||||
"flex w-full place-content-between place-items-center",
|
|
||||||
cIf(isContentPanelAtLeastMd, "gap-5", "gap-3")
|
|
||||||
)}>
|
|
||||||
<p
|
|
||||||
className={cJoin(
|
|
||||||
"!my-0 font-mono",
|
|
||||||
cIf(isContentPanelAtLeastMd, "h-5", "h-3 text-xs")
|
|
||||||
)}>
|
|
||||||
{prettyDuration(currentTime)}
|
|
||||||
</p>
|
|
||||||
<Slider
|
|
||||||
className={cIf(
|
|
||||||
!isContentPanelAtLeastXl && type === "video",
|
|
||||||
"!absolute left-0 right-0 top-[-5px]"
|
|
||||||
)}
|
|
||||||
value={currentTime * STEP_MULTIPLIER}
|
|
||||||
onChange={(value) => {
|
|
||||||
const newTime = (value as number) / STEP_MULTIPLIER;
|
|
||||||
mediaRef.currentTime = newTime;
|
|
||||||
setCurrentTime(newTime);
|
|
||||||
}}
|
|
||||||
onAfterChange={async () => await mediaRef.play()}
|
|
||||||
max={duration * STEP_MULTIPLIER}
|
|
||||||
/>
|
|
||||||
{!isContentPanelAtLeastXl && type === "video" && <p>/</p>}
|
|
||||||
<p
|
|
||||||
className={cJoin(
|
|
||||||
"!my-0 font-mono",
|
|
||||||
cIf(isContentPanelAtLeastMd, "h-5", "h-3 text-xs")
|
|
||||||
)}>
|
|
||||||
{prettyDuration(duration)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isContentPanelAtLeastXl ? (
|
|
||||||
buttonGroup
|
|
||||||
) : (
|
|
||||||
<ToolTip content={buttonGroup}>
|
|
||||||
<Button icon="more_vert" />
|
|
||||||
</ToolTip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Hotkeys from "react-hot-keys";
|
||||||
|
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||||
|
import { cIf, cJoin } from "helpers/className";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ╭─────────────╮
|
||||||
|
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
state: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
fillViewport?: boolean;
|
||||||
|
hideBackground?: boolean;
|
||||||
|
padding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const Popup = ({
|
||||||
|
onClose,
|
||||||
|
state,
|
||||||
|
children,
|
||||||
|
fillViewport,
|
||||||
|
hideBackground = false,
|
||||||
|
padding = true,
|
||||||
|
}: Props): JSX.Element => {
|
||||||
|
const { setMenuGestures } = useAppLayout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMenuGestures(!state);
|
||||||
|
}, [setMenuGestures, state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Hotkeys keyName="escape" allowRepeat onKeyDown={onClose}>
|
||||||
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
"fixed inset-0 z-50 grid place-content-center transition-[backdrop-filter] duration-500",
|
||||||
|
cIf(
|
||||||
|
state,
|
||||||
|
"[backdrop-filter:blur(2px)]",
|
||||||
|
"pointer-events-none touch-none"
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
"fixed inset-0 bg-shade transition-all duration-500",
|
||||||
|
cIf(state, "bg-opacity-50", "bg-opacity-0")
|
||||||
|
)}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
"grid place-items-center gap-4 transition-transform",
|
||||||
|
cIf(padding, "p-10 mobile:p-6"),
|
||||||
|
cIf(state, "scale-100", "scale-0"),
|
||||||
|
cIf(
|
||||||
|
fillViewport,
|
||||||
|
"absolute inset-10",
|
||||||
|
"relative max-h-[80vh] overflow-y-auto mobile:w-[85vw]"
|
||||||
|
),
|
||||||
|
cIf(!hideBackground, "rounded-lg bg-light shadow-2xl shadow-shade")
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Hotkeys>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,20 +1,19 @@
|
||||||
import { useCallback } from "react";
|
import { Fragment, useCallback, useMemo } from "react";
|
||||||
import { AppLayout, AppLayoutRequired } from "./AppLayout";
|
import { AppLayout, AppLayoutRequired } from "./AppLayout";
|
||||||
import { getTocFromMarkdawn, Markdawn, TableOfContents } from "./Markdown/Markdawn";
|
import { Chip } from "./Chip";
|
||||||
import { ReturnButton } from "./PanelComponents/ReturnButton";
|
import { HorizontalLine } from "./HorizontalLine";
|
||||||
import { ContentPanel } from "./Containers/ContentPanel";
|
import { Markdawn, TableOfContents } from "./Markdown/Markdawn";
|
||||||
import { SubPanel } from "./Containers/SubPanel";
|
import { ReturnButton, ReturnButtonType } from "./PanelComponents/ReturnButton";
|
||||||
|
import { ContentPanel } from "./Panels/ContentPanel";
|
||||||
|
import { SubPanel } from "./Panels/SubPanel";
|
||||||
|
import { RecorderChip } from "./RecorderChip";
|
||||||
import { ThumbnailHeader } from "./ThumbnailHeader";
|
import { ThumbnailHeader } from "./ThumbnailHeader";
|
||||||
|
import { ToolTip } from "./ToolTip";
|
||||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||||
import { PostWithTranslations } from "types/types";
|
import { PostWithTranslations } from "helpers/types";
|
||||||
import { filterHasAttributes, isDefined } from "helpers/asserts";
|
import { filterHasAttributes, getStatusDescription } from "helpers/others";
|
||||||
import { prettySlug } from "helpers/formatters";
|
import { prettySlug } from "helpers/formatters";
|
||||||
import { useAtomGetter, useAtomSetter } from "helpers/atoms";
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { ElementsSeparator } from "helpers/component";
|
|
||||||
import { HorizontalLine } from "components/HorizontalLine";
|
|
||||||
import { Credits } from "components/Credits";
|
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -23,6 +22,9 @@ import { useFormat } from "hooks/useFormat";
|
||||||
|
|
||||||
interface Props extends AppLayoutRequired {
|
interface Props extends AppLayoutRequired {
|
||||||
post: PostWithTranslations;
|
post: PostWithTranslations;
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
|
languages: AppStaticProps["languages"];
|
||||||
|
currencies: AppStaticProps["currencies"];
|
||||||
returnHref?: string;
|
returnHref?: string;
|
||||||
returnTitle?: string | null | undefined;
|
returnTitle?: string | null | undefined;
|
||||||
displayCredits?: boolean;
|
displayCredits?: boolean;
|
||||||
|
@ -38,6 +40,8 @@ interface Props extends AppLayoutRequired {
|
||||||
|
|
||||||
export const PostPage = ({
|
export const PostPage = ({
|
||||||
post,
|
post,
|
||||||
|
langui,
|
||||||
|
languages,
|
||||||
returnHref,
|
returnHref,
|
||||||
returnTitle,
|
returnTitle,
|
||||||
displayCredits,
|
displayCredits,
|
||||||
|
@ -49,89 +53,178 @@ export const PostPage = ({
|
||||||
displayTitle = true,
|
displayTitle = true,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const { formatCategory } = useFormat();
|
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] =
|
||||||
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
|
useSmartLanguage({
|
||||||
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
|
items: post.translations,
|
||||||
|
languages: languages,
|
||||||
|
languageExtractor: useCallback(
|
||||||
|
(item: NonNullable<PostWithTranslations["translations"][number]>) =>
|
||||||
|
item.language?.data?.attributes?.code,
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
|
const { thumbnail, body, title, excerpt } = useMemo(
|
||||||
items: post.translations,
|
() => ({
|
||||||
languageExtractor: useCallback(
|
thumbnail:
|
||||||
(item: NonNullable<PostWithTranslations["translations"][number]>) =>
|
selectedTranslation?.thumbnail?.data?.attributes ??
|
||||||
item.language?.data?.attributes?.code,
|
post.thumbnail?.data?.attributes,
|
||||||
[]
|
body: selectedTranslation?.body ?? "",
|
||||||
),
|
title: selectedTranslation?.title ?? prettySlug(post.slug),
|
||||||
});
|
excerpt: selectedTranslation?.excerpt ?? "",
|
||||||
|
}),
|
||||||
const thumbnail =
|
[post.slug, post.thumbnail, selectedTranslation]
|
||||||
selectedTranslation?.thumbnail?.data?.attributes ?? post.thumbnail?.data?.attributes;
|
|
||||||
const body = selectedTranslation?.body ?? "";
|
|
||||||
const title = selectedTranslation?.title ?? prettySlug(post.slug);
|
|
||||||
const excerpt = selectedTranslation?.excerpt ?? "";
|
|
||||||
|
|
||||||
const toc = getTocFromMarkdawn(body, title);
|
|
||||||
|
|
||||||
const subPanelElems = [
|
|
||||||
returnHref && returnTitle && !is1ColumnLayout && (
|
|
||||||
<ReturnButton href={returnHref} title={returnTitle} />
|
|
||||||
),
|
|
||||||
|
|
||||||
displayCredits && <Credits status={selectedTranslation?.status} authors={post.authors?.data} />,
|
|
||||||
|
|
||||||
displayToc && isDefined(toc) && (
|
|
||||||
<TableOfContents toc={toc} onContentClicked={() => setSubPanelOpened(false)} />
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
const subPanel =
|
|
||||||
subPanelElems.filter(Boolean).length > 0 ? (
|
|
||||||
<SubPanel>
|
|
||||||
<ElementsSeparator>{subPanelElems}</ElementsSeparator>
|
|
||||||
</SubPanel>
|
|
||||||
) : undefined;
|
|
||||||
|
|
||||||
const contentPanel = (
|
|
||||||
<ContentPanel>
|
|
||||||
{is1ColumnLayout && returnHref && returnTitle && (
|
|
||||||
<ReturnButton href={returnHref} title={returnTitle} className="mb-10" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{displayThumbnailHeader ? (
|
|
||||||
<>
|
|
||||||
<ThumbnailHeader
|
|
||||||
thumbnail={thumbnail}
|
|
||||||
title={title}
|
|
||||||
description={excerpt}
|
|
||||||
categories={filterHasAttributes(post.categories?.data, ["attributes"]).map((category) =>
|
|
||||||
formatCategory(category.attributes.slug)
|
|
||||||
)}
|
|
||||||
releaseDate={post.date}
|
|
||||||
languageSwitcher={
|
|
||||||
languageSwitcherProps.locales.size > 1 ? (
|
|
||||||
<LanguageSwitcher {...languageSwitcherProps} />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<HorizontalLine />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{displayLanguageSwitcher && (
|
|
||||||
<div className="grid place-content-end place-items-start">
|
|
||||||
<LanguageSwitcher {...languageSwitcherProps} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{displayTitle && (
|
|
||||||
<h1 className="my-16 flex justify-center gap-3 text-center text-4xl">{title}</h1>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{prependBody}
|
|
||||||
{body && <Markdawn text={body} />}
|
|
||||||
|
|
||||||
{appendBody}
|
|
||||||
</ContentPanel>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AppLayout {...otherProps} contentPanel={contentPanel} subPanel={subPanel} />;
|
const subPanel = useMemo(
|
||||||
|
() =>
|
||||||
|
returnHref || returnTitle || displayCredits || displayToc ? (
|
||||||
|
<SubPanel>
|
||||||
|
{returnHref && returnTitle && (
|
||||||
|
<ReturnButton
|
||||||
|
href={returnHref}
|
||||||
|
title={returnTitle}
|
||||||
|
langui={langui}
|
||||||
|
displayOn={ReturnButtonType.Desktop}
|
||||||
|
horizontalLine
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayCredits && (
|
||||||
|
<>
|
||||||
|
{selectedTranslation && (
|
||||||
|
<div className="grid grid-flow-col place-content-center place-items-center gap-2">
|
||||||
|
<p className="font-headers font-bold">{langui.status}:</p>
|
||||||
|
|
||||||
|
<ToolTip
|
||||||
|
content={getStatusDescription(
|
||||||
|
selectedTranslation.status,
|
||||||
|
langui
|
||||||
|
)}
|
||||||
|
maxWidth={"20rem"}
|
||||||
|
>
|
||||||
|
<Chip text={selectedTranslation.status} />
|
||||||
|
</ToolTip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.authors && post.authors.data.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="font-headers font-bold">{"Authors"}:</p>
|
||||||
|
<div className="grid place-content-center place-items-center gap-2">
|
||||||
|
{filterHasAttributes(post.authors.data, [
|
||||||
|
"id",
|
||||||
|
"attributes",
|
||||||
|
] as const).map((author) => (
|
||||||
|
<Fragment key={author.id}>
|
||||||
|
<RecorderChip
|
||||||
|
langui={langui}
|
||||||
|
recorder={author.attributes}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HorizontalLine />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayToc && (
|
||||||
|
<TableOfContents text={body} title={title} langui={langui} />
|
||||||
|
)}
|
||||||
|
</SubPanel>
|
||||||
|
) : undefined,
|
||||||
|
[
|
||||||
|
body,
|
||||||
|
displayCredits,
|
||||||
|
displayToc,
|
||||||
|
langui,
|
||||||
|
post.authors,
|
||||||
|
returnHref,
|
||||||
|
returnTitle,
|
||||||
|
selectedTranslation,
|
||||||
|
title,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentPanel = useMemo(
|
||||||
|
() => (
|
||||||
|
<ContentPanel>
|
||||||
|
{returnHref && returnTitle && (
|
||||||
|
<ReturnButton
|
||||||
|
href={returnHref}
|
||||||
|
title={returnTitle}
|
||||||
|
langui={langui}
|
||||||
|
displayOn={ReturnButtonType.Mobile}
|
||||||
|
horizontalLine
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayThumbnailHeader ? (
|
||||||
|
<>
|
||||||
|
<ThumbnailHeader
|
||||||
|
thumbnail={thumbnail}
|
||||||
|
title={title}
|
||||||
|
description={excerpt}
|
||||||
|
langui={langui}
|
||||||
|
categories={post.categories}
|
||||||
|
languageSwitcher={
|
||||||
|
languageSwitcherProps.locales.size > 1 ? (
|
||||||
|
<LanguageSwitcher {...languageSwitcherProps} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HorizontalLine />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{displayLanguageSwitcher && (
|
||||||
|
<div className="grid place-content-end place-items-start">
|
||||||
|
<LanguageSwitcher {...languageSwitcherProps} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayTitle && (
|
||||||
|
<h1 className="my-16 flex justify-center gap-3 text-center text-4xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prependBody}
|
||||||
|
<Markdawn text={body} langui={langui} />
|
||||||
|
{appendBody}
|
||||||
|
</ContentPanel>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
LanguageSwitcher,
|
||||||
|
appendBody,
|
||||||
|
body,
|
||||||
|
displayLanguageSwitcher,
|
||||||
|
displayThumbnailHeader,
|
||||||
|
displayTitle,
|
||||||
|
excerpt,
|
||||||
|
languageSwitcherProps,
|
||||||
|
langui,
|
||||||
|
post.categories,
|
||||||
|
prependBody,
|
||||||
|
returnHref,
|
||||||
|
returnTitle,
|
||||||
|
thumbnail,
|
||||||
|
title,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout
|
||||||
|
{...otherProps}
|
||||||
|
contentPanel={contentPanel}
|
||||||
|
subPanel={subPanel}
|
||||||
|
languages={languages}
|
||||||
|
langui={langui}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,20 +1,25 @@
|
||||||
import { MouseEventHandler, useCallback } from "react";
|
import { useMemo } from "react";
|
||||||
import { Markdown } from "./Markdown/Markdown";
|
import { useRouter } from "next/router";
|
||||||
import { Chip } from "components/Chip";
|
import { Chip } from "./Chip";
|
||||||
import { Ico } from "components/Ico";
|
import { Ico, Icon } from "./Ico";
|
||||||
import { Img } from "components/Img";
|
import { Img } from "./Img";
|
||||||
import { UpPressable } from "components/Containers/UpPressable";
|
import { Link } from "./Inputs/Link";
|
||||||
import { DatePickerFragment, PricePickerFragment, UploadImageFragment } from "graphql/generated";
|
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||||
|
import {
|
||||||
|
DatePickerFragment,
|
||||||
|
PricePickerFragment,
|
||||||
|
UploadImageFragment,
|
||||||
|
} from "graphql/generated";
|
||||||
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
import { cIf, cJoin } from "helpers/className";
|
||||||
import { prettyDuration, prettyShortenNumber } from "helpers/formatters";
|
import {
|
||||||
|
prettyDate,
|
||||||
|
prettyDuration,
|
||||||
|
prettyPrice,
|
||||||
|
prettyShortenNumber,
|
||||||
|
} from "helpers/formatters";
|
||||||
import { ImageQuality } from "helpers/img";
|
import { ImageQuality } from "helpers/img";
|
||||||
import { useDeviceSupportsHover } from "hooks/useMediaQuery";
|
import { useMediaHoverable } from "hooks/useMediaQuery";
|
||||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
|
||||||
import { TranslatedProps } from "types/TranslatedProps";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
import { isDefined } from "helpers/asserts";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -25,7 +30,6 @@ interface Props {
|
||||||
thumbnail?: UploadImageFragment | string | null | undefined;
|
thumbnail?: UploadImageFragment | string | null | undefined;
|
||||||
thumbnailAspectRatio?: string;
|
thumbnailAspectRatio?: string;
|
||||||
thumbnailForceAspectRatio?: boolean;
|
thumbnailForceAspectRatio?: boolean;
|
||||||
thumbnailFitMethod?: "contain" | "cover";
|
|
||||||
thumbnailRounded?: boolean;
|
thumbnailRounded?: boolean;
|
||||||
href: string;
|
href: string;
|
||||||
pre_title?: string | null | undefined;
|
pre_title?: string | null | undefined;
|
||||||
|
@ -35,7 +39,9 @@ interface Props {
|
||||||
topChips?: string[];
|
topChips?: string[];
|
||||||
bottomChips?: string[];
|
bottomChips?: string[];
|
||||||
keepInfoVisible?: boolean;
|
keepInfoVisible?: boolean;
|
||||||
|
stackNumber?: number;
|
||||||
metadata?: {
|
metadata?: {
|
||||||
|
currencies?: AppStaticProps["currencies"];
|
||||||
releaseDate?: DatePickerFragment | null;
|
releaseDate?: DatePickerFragment | null;
|
||||||
releaseDateFormat?: Intl.DateTimeFormatOptions["dateStyle"];
|
releaseDateFormat?: Intl.DateTimeFormatOptions["dateStyle"];
|
||||||
price?: PricePickerFragment | null;
|
price?: PricePickerFragment | null;
|
||||||
|
@ -50,9 +56,6 @@ interface Props {
|
||||||
duration: number;
|
duration: number;
|
||||||
}
|
}
|
||||||
| { __typename: "anotherHoverlayName" };
|
| { __typename: "anotherHoverlayName" };
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
@ -62,190 +65,234 @@ export const PreviewCard = ({
|
||||||
thumbnail,
|
thumbnail,
|
||||||
thumbnailAspectRatio = "4/3",
|
thumbnailAspectRatio = "4/3",
|
||||||
thumbnailForceAspectRatio = false,
|
thumbnailForceAspectRatio = false,
|
||||||
thumbnailFitMethod = "cover",
|
|
||||||
thumbnailRounded = true,
|
thumbnailRounded = true,
|
||||||
pre_title,
|
pre_title,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
description,
|
description,
|
||||||
|
stackNumber = 0,
|
||||||
topChips,
|
topChips,
|
||||||
bottomChips,
|
bottomChips,
|
||||||
keepInfoVisible,
|
keepInfoVisible,
|
||||||
metadata,
|
metadata,
|
||||||
hoverlay,
|
hoverlay,
|
||||||
infoAppend,
|
infoAppend,
|
||||||
className,
|
|
||||||
disabled = false,
|
|
||||||
onClick,
|
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const { formatPrice, formatDate } = useFormat();
|
const { currency } = useAppLayout();
|
||||||
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
const isHoverable = useMediaHoverable();
|
||||||
const preferredCurrency = useAtomGetter(atoms.settings.currency);
|
const router = useRouter();
|
||||||
const isHoverable = useDeviceSupportsHover();
|
|
||||||
|
|
||||||
const metadataJSX = (
|
const metadataJSX = useMemo(
|
||||||
<>
|
() => (
|
||||||
{metadata && (isDefined(metadata.releaseDate) || isDefined(metadata.price)) && (
|
<>
|
||||||
<div className="flex w-full flex-row flex-wrap gap-x-3">
|
{metadata && (metadata.releaseDate || metadata.price) && (
|
||||||
{metadata.releaseDate && (
|
<div className="flex w-full flex-row flex-wrap gap-x-3">
|
||||||
<p className="text-sm">
|
{metadata.releaseDate && (
|
||||||
<Ico icon="event" className="mr-1 translate-y-[.15em] !text-base" />
|
<p className="text-sm mobile:text-xs">
|
||||||
{formatDate(metadata.releaseDate)}
|
<Ico
|
||||||
</p>
|
icon={Icon.Event}
|
||||||
|
className="mr-1 translate-y-[.15em] !text-base"
|
||||||
|
/>
|
||||||
|
{prettyDate(metadata.releaseDate, router.locale)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{metadata.price && metadata.currencies && (
|
||||||
|
<p className="justify-self-end text-sm mobile:text-xs">
|
||||||
|
<Ico
|
||||||
|
icon={Icon.ShoppingCart}
|
||||||
|
className="mr-1 translate-y-[.15em] !text-base"
|
||||||
|
/>
|
||||||
|
{prettyPrice(metadata.price, metadata.currencies, currency)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{metadata.views && (
|
||||||
|
<p className="text-sm mobile:text-xs">
|
||||||
|
<Ico
|
||||||
|
icon={Icon.Visibility}
|
||||||
|
className="mr-1 translate-y-[.15em] !text-base"
|
||||||
|
/>
|
||||||
|
{prettyShortenNumber(metadata.views)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{metadata.author && (
|
||||||
|
<p className="text-sm mobile:text-xs">
|
||||||
|
<Ico
|
||||||
|
icon={Icon.Person}
|
||||||
|
className="mr-1 translate-y-[.15em] !text-base"
|
||||||
|
/>
|
||||||
|
{metadata.author}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[currency, metadata, router.locale]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group grid cursor-pointer items-end text-left transition-transform
|
||||||
|
drop-shadow-shade-xl hover:scale-[1.02]"
|
||||||
|
>
|
||||||
|
{stackNumber > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
`absolute inset-0 scale-[.85] overflow-hidden bg-light brightness-[0.8] sepia-[0.5]
|
||||||
|
transition-[top_transform] group-hover:-top-9`,
|
||||||
|
cIf(thumbnailRounded, "rounded-md")
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{thumbnail && (
|
||||||
|
<Img
|
||||||
|
className="opacity-30"
|
||||||
|
src={thumbnail}
|
||||||
|
quality={ImageQuality.Medium}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
`absolute inset-0 overflow-hidden bg-light brightness-[0.9] sepia-[0.2]
|
||||||
|
transition-[top_transform] group-hover:-top-4 group-hover:scale-[.94]`,
|
||||||
|
cIf(thumbnailRounded, "rounded-md")
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{thumbnail && (
|
||||||
|
<Img
|
||||||
|
className="opacity-70"
|
||||||
|
src={thumbnail}
|
||||||
|
quality={ImageQuality.Medium}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{thumbnail ? (
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
aspectRatio: thumbnailForceAspectRatio
|
||||||
|
? thumbnailAspectRatio
|
||||||
|
: "unset",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Img
|
||||||
|
className={cJoin(
|
||||||
|
cIf(
|
||||||
|
thumbnailRounded,
|
||||||
|
cIf(
|
||||||
|
keepInfoVisible,
|
||||||
|
"rounded-t-md",
|
||||||
|
"rounded-md notHoverable:rounded-b-none"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
cIf(thumbnailForceAspectRatio, "h-full w-full object-cover")
|
||||||
|
)}
|
||||||
|
src={thumbnail}
|
||||||
|
quality={ImageQuality.Medium}
|
||||||
|
/>
|
||||||
|
{stackNumber > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute right-2 top-2 rounded-full bg-black
|
||||||
|
bg-opacity-60 px-2 text-light"
|
||||||
|
>
|
||||||
|
{stackNumber}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{metadata.price && (
|
{hoverlay && hoverlay.__typename === "Video" && (
|
||||||
<p className="justify-self-end text-sm">
|
<>
|
||||||
<Ico icon="shopping_cart" className="mr-1 translate-y-[.15em] !text-base" />
|
<div
|
||||||
{formatPrice(metadata.price, preferredCurrency)}
|
className="absolute inset-0 grid place-content-center bg-shade bg-opacity-0
|
||||||
</p>
|
text-light transition-colors drop-shadow-shade-lg group-hover:bg-opacity-50"
|
||||||
|
>
|
||||||
|
<Ico
|
||||||
|
icon={Icon.PlayCircleOutline}
|
||||||
|
className="!text-6xl opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute right-2 bottom-2 rounded-full bg-black bg-opacity-60 px-2
|
||||||
|
text-light"
|
||||||
|
>
|
||||||
|
{prettyDuration(hoverlay.duration)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{metadata.views && (
|
</div>
|
||||||
<p className="text-sm">
|
) : (
|
||||||
<Ico icon="visibility" className="mr-1 translate-y-[.15em] !text-base" />
|
<div
|
||||||
{prettyShortenNumber(metadata.views)}
|
style={{ aspectRatio: thumbnailAspectRatio }}
|
||||||
</p>
|
className={cJoin(
|
||||||
|
"relative w-full bg-light",
|
||||||
|
cIf(
|
||||||
|
keepInfoVisible,
|
||||||
|
"rounded-t-md",
|
||||||
|
"rounded-md notHoverable:rounded-b-none"
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{metadata.author && (
|
>
|
||||||
<p className="text-sm">
|
{stackNumber > 0 && (
|
||||||
<Ico icon="person" className="mr-1 translate-y-[.15em] !text-base" />
|
<div
|
||||||
<Markdown text={metadata.author} className="inline-block" />
|
className="absolute right-2 top-2 rounded-full bg-black
|
||||||
</p>
|
bg-opacity-60 px-2 text-light"
|
||||||
|
>
|
||||||
|
{stackNumber}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
<div
|
||||||
);
|
className={cJoin(
|
||||||
|
"z-20 grid gap-2 p-4 transition-opacity linearbg-obi",
|
||||||
return (
|
cIf(
|
||||||
<UpPressable
|
!keepInfoVisible && isHoverable,
|
||||||
className={cJoin("relative grid items-end text-left", className)}
|
`-inset-x-0.5 bottom-2 opacity-0 [border-radius:10%_10%_10%_10%_/_1%_1%_3%_3%]
|
||||||
href={href}
|
group-hover:opacity-100 hoverable:absolute hoverable:drop-shadow-shade-lg
|
||||||
onClick={onClick}
|
notHoverable:rounded-b-md notHoverable:opacity-100`,
|
||||||
noBackground
|
"[border-radius:0%_0%_10%_10%_/_0%_0%_3%_3%]"
|
||||||
disabled={disabled}>
|
)
|
||||||
<div className={cJoin("group", cIf(disabled, "pointer-events-none touch-none select-none"))}>
|
|
||||||
{thumbnail ? (
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
style={{
|
|
||||||
aspectRatio: thumbnailForceAspectRatio ? thumbnailAspectRatio : "unset",
|
|
||||||
}}>
|
|
||||||
<Img
|
|
||||||
className={cJoin(
|
|
||||||
cIf(
|
|
||||||
thumbnailRounded,
|
|
||||||
cIf(keepInfoVisible, "rounded-t-md", "rounded-md notHoverable:rounded-b-none")
|
|
||||||
),
|
|
||||||
cIf(thumbnailForceAspectRatio, "h-full w-full"),
|
|
||||||
cIf(
|
|
||||||
thumbnailForceAspectRatio && thumbnailFitMethod === "contain",
|
|
||||||
"object-contain",
|
|
||||||
"object-cover"
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
src={thumbnail}
|
|
||||||
quality={ImageQuality.Medium}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{hoverlay && hoverlay.__typename === "Video" && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 grid place-content-center rounded-t-md
|
|
||||||
bg-shade/0 text-light transition-colors group-hover:bg-shade/50">
|
|
||||||
<Ico
|
|
||||||
icon="play_circle"
|
|
||||||
className="!text-6xl text-light opacity-0 drop-shadow-lg transition-opacity
|
|
||||||
shadow-shade group-hover:opacity-100 dark:text-black"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-2 right-2 rounded-full bg-black/60 px-2 text-light">
|
|
||||||
{prettyDuration(hoverlay.duration)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{ aspectRatio: thumbnailAspectRatio }}
|
|
||||||
className={cJoin(
|
|
||||||
"relative w-full bg-highlight",
|
|
||||||
cIf(keepInfoVisible, "rounded-t-md", "rounded-md notHoverable:rounded-b-none")
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div
|
>
|
||||||
className={cJoin(
|
{metadata?.position === "Top" && metadataJSX}
|
||||||
"z-20 grid gap-2 p-4 transition-opacity linearbg-obi",
|
{topChips && topChips.length > 0 && (
|
||||||
cIf(
|
<div className="grid grid-flow-col place-content-start gap-1 overflow-hidden">
|
||||||
!keepInfoVisible && isHoverable,
|
{topChips.map((text, index) => (
|
||||||
`-inset-x-0.5 bottom-2 opacity-0 !shadow-shade
|
<Chip key={index} text={text} />
|
||||||
[border-radius:10%_10%_10%_10%_/_1%_1%_3%_3%]
|
))}
|
||||||
group-hover:opacity-100 hoverable:absolute hoverable:shadow-lg
|
|
||||||
notHoverable:rounded-b-md notHoverable:opacity-100`,
|
|
||||||
cIf(!isPerfModeEnabled, "[border-radius:0%_0%_10%_10%_/_0%_0%_3%_3%]")
|
|
||||||
)
|
|
||||||
)}>
|
|
||||||
{metadata?.position === "Top" && metadataJSX}
|
|
||||||
{topChips && topChips.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll
|
|
||||||
scrollbar-none">
|
|
||||||
{topChips.map((text, index) => (
|
|
||||||
<Chip key={index} text={text} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="my-1">
|
|
||||||
{pre_title && <Markdown text={pre_title} className="mb-1 leading-none break-words" />}
|
|
||||||
{title && (
|
|
||||||
<Markdown
|
|
||||||
text={title}
|
|
||||||
className="font-headers text-lg font-bold leading-none break-words"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{subtitle && <Markdown text={subtitle} className="leading-none break-words" />}
|
|
||||||
</div>
|
</div>
|
||||||
{description && <Markdown text={description} className="overflow-hidden break-words" />}
|
)}
|
||||||
{bottomChips && bottomChips.length > 0 && (
|
<div className="my-1">
|
||||||
<div
|
{pre_title && (
|
||||||
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll
|
<p className="mb-1 break-words leading-none">{pre_title}</p>
|
||||||
scrollbar-none">
|
|
||||||
{bottomChips.map((text, index) => (
|
|
||||||
<Chip key={index} className="text-sm" text={text} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{title && (
|
||||||
{metadata?.position === "Bottom" && metadataJSX}
|
<p className="break-words font-headers text-lg font-bold leading-none">
|
||||||
|
{title}
|
||||||
{infoAppend}
|
</p>
|
||||||
|
)}
|
||||||
|
{subtitle && <p className="break-words leading-none">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
{description && <p>{description}</p>}
|
||||||
|
{bottomChips && bottomChips.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll
|
||||||
|
[scrollbar-width:none] webkit-scrollbar:h-0"
|
||||||
|
>
|
||||||
|
{bottomChips.map((text, index) => (
|
||||||
|
<Chip key={index} className="text-sm" text={text} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metadata?.position === "Bottom" && metadataJSX}
|
||||||
|
|
||||||
|
{infoAppend}
|
||||||
</div>
|
</div>
|
||||||
</UpPressable>
|
</Link>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ╭──────────────────────╮
|
|
||||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const TranslatedPreviewCard = ({
|
|
||||||
translations,
|
|
||||||
fallback,
|
|
||||||
...otherProps
|
|
||||||
}: TranslatedProps<Props, "description" | "pre_title" | "subtitle" | "title">): JSX.Element => {
|
|
||||||
const [selectedTranslation] = useSmartLanguage({
|
|
||||||
items: translations,
|
|
||||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<PreviewCard
|
|
||||||
pre_title={selectedTranslation?.pre_title ?? fallback.pre_title}
|
|
||||||
title={selectedTranslation?.title ?? fallback.title}
|
|
||||||
subtitle={selectedTranslation?.subtitle ?? fallback.subtitle}
|
|
||||||
description={selectedTranslation?.description ?? fallback.description}
|
|
||||||
{...otherProps}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { Chip } from "./Chip";
|
||||||
|
import { Img } from "./Img";
|
||||||
|
import { Link } from "./Inputs/Link";
|
||||||
|
import { UploadImageFragment } from "graphql/generated";
|
||||||
|
import { ImageQuality } from "helpers/img";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ╭─────────────╮
|
||||||
|
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
thumbnail?: UploadImageFragment | string | null | undefined;
|
||||||
|
thumbnailAspectRatio?: string;
|
||||||
|
href: string;
|
||||||
|
pre_title?: string | null | undefined;
|
||||||
|
title: string | null | undefined;
|
||||||
|
subtitle?: string | null | undefined;
|
||||||
|
topChips?: string[];
|
||||||
|
bottomChips?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const PreviewLine = ({
|
||||||
|
href,
|
||||||
|
thumbnail,
|
||||||
|
pre_title,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
topChips,
|
||||||
|
bottomChips,
|
||||||
|
thumbnailAspectRatio,
|
||||||
|
}: Props): JSX.Element => (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="flex h-36 w-full cursor-pointer flex-row place-items-center gap-4 overflow-hidden
|
||||||
|
rounded-md bg-light pr-4 transition-transform drop-shadow-shade-xl hover:scale-[1.02]"
|
||||||
|
>
|
||||||
|
{thumbnail ? (
|
||||||
|
<div className="aspect-[3/2] h-full">
|
||||||
|
<Img
|
||||||
|
className="h-full object-cover"
|
||||||
|
src={thumbnail}
|
||||||
|
quality={ImageQuality.Medium}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ aspectRatio: thumbnailAspectRatio }}></div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{topChips && topChips.length > 0 && (
|
||||||
|
<div className="grid grid-flow-col place-content-start gap-1 overflow-hidden">
|
||||||
|
{topChips.map((text, index) => (
|
||||||
|
<Chip key={index} text={text} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="my-1 flex flex-col">
|
||||||
|
{pre_title && <p className="mb-1 leading-none">{pre_title}</p>}
|
||||||
|
{title && (
|
||||||
|
<p className="font-headers text-lg font-bold leading-none">{title}</p>
|
||||||
|
)}
|
||||||
|
{subtitle && <p className="leading-none">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{bottomChips && bottomChips.length > 0 && (
|
||||||
|
<div className="grid grid-flow-col place-content-start gap-1 overflow-hidden">
|
||||||
|
{bottomChips.map((text, index) => (
|
||||||
|
<Chip key={index} className="text-sm" text={text} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
|
@ -3,12 +3,10 @@ import { Img } from "./Img";
|
||||||
import { Markdawn } from "./Markdown/Markdawn";
|
import { Markdawn } from "./Markdown/Markdawn";
|
||||||
import { ToolTip } from "./ToolTip";
|
import { ToolTip } from "./ToolTip";
|
||||||
import { Chip } from "components/Chip";
|
import { Chip } from "components/Chip";
|
||||||
|
import { RecorderChipFragment } from "graphql/generated";
|
||||||
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
import { ImageQuality } from "helpers/img";
|
import { ImageQuality } from "helpers/img";
|
||||||
import { filterHasAttributes, isUndefined } from "helpers/asserts";
|
import { filterHasAttributes } from "helpers/others";
|
||||||
import { useFormat } from "hooks/useFormat";
|
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -16,63 +14,61 @@ import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
username: string;
|
className?: string;
|
||||||
|
recorder: RecorderChipFragment;
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const RecorderChip = ({ username }: Props): JSX.Element => {
|
export const RecorderChip = ({ recorder, langui }: Props): JSX.Element => (
|
||||||
const { format } = useFormat();
|
<ToolTip
|
||||||
const recorders = useAtomGetter(atoms.localData.recorders);
|
content={
|
||||||
const recorder = recorders.find((elem) => elem.attributes?.username === username)?.attributes;
|
<div className="grid gap-8 p-2 py-5 text-left">
|
||||||
|
<div className="grid grid-flow-col place-content-start place-items-center gap-6">
|
||||||
const [selectedBioTranslation] = useSmartLanguage({
|
{recorder.avatar?.data?.attributes && (
|
||||||
items: recorder?.bio ?? [],
|
<Img
|
||||||
languageExtractor: (bio) => bio.language?.data?.attributes?.code,
|
className="w-20 rounded-full border-4 border-mid"
|
||||||
});
|
src={recorder.avatar.data.attributes}
|
||||||
|
quality={ImageQuality.Small}
|
||||||
if (isUndefined(recorder)) return <></>;
|
/>
|
||||||
|
)}
|
||||||
return (
|
<div className="grid gap-2">
|
||||||
<ToolTip
|
<h3 className=" text-2xl">{recorder.username}</h3>
|
||||||
content={
|
{recorder.languages?.data && recorder.languages.data.length > 0 && (
|
||||||
<div className="grid gap-8 p-2 py-5 text-left">
|
<div className="flex flex-row flex-wrap gap-1">
|
||||||
<div className="grid grid-flow-col place-content-start place-items-center gap-6">
|
<p>{langui.languages}:</p>
|
||||||
{recorder.avatar?.data?.attributes && (
|
{filterHasAttributes(recorder.languages.data, [
|
||||||
<Img
|
"attributes",
|
||||||
className="aspect-square w-20 rounded-full border-4 border-mid object-cover"
|
] as const).map((language) => (
|
||||||
src={recorder.avatar.data.attributes}
|
<Fragment key={language.__typename}>
|
||||||
quality={ImageQuality.Small}
|
<Chip text={language.attributes.code.toUpperCase()} />
|
||||||
/>
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recorder.pronouns && (
|
||||||
|
<div className="flex flex-row flex-wrap gap-1">
|
||||||
|
<p>{langui.pronouns}:</p>
|
||||||
|
<Chip text={recorder.pronouns} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-2">
|
|
||||||
<h3 className=" text-2xl">{recorder.username}</h3>
|
|
||||||
{recorder.languages?.data && recorder.languages.data.length > 0 && (
|
|
||||||
<div className="flex flex-row flex-wrap gap-1">
|
|
||||||
<p>{format("language", { count: recorder.languages.data.length })}:</p>
|
|
||||||
{filterHasAttributes(recorder.languages.data, ["attributes"]).map((language) => (
|
|
||||||
<Fragment key={language.__typename}>
|
|
||||||
<Chip text={language.attributes.code.toUpperCase()} />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{recorder.pronouns && (
|
|
||||||
<div className="flex flex-row flex-wrap gap-1">
|
|
||||||
<p>{format("pronouns")}:</p>
|
|
||||||
<Chip text={recorder.pronouns} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{selectedBioTranslation?.bio && <Markdawn text={selectedBioTranslation.bio} />}
|
|
||||||
</div>
|
</div>
|
||||||
|
{recorder.bio?.[0] && (
|
||||||
|
<Markdawn text={recorder.bio[0].bio ?? ""} langui={langui} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
key={recorder.anonymous_code}
|
||||||
|
text={
|
||||||
|
recorder.anonymize
|
||||||
|
? `Recorder#${recorder.anonymous_code}`
|
||||||
|
: recorder.username
|
||||||
}
|
}
|
||||||
placement="top">
|
/>
|
||||||
<Chip
|
</ToolTip>
|
||||||
key={recorder.anonymous_code}
|
);
|
||||||
text={recorder.anonymize ? `Recorder#${recorder.anonymous_code}` : recorder.username}
|
|
||||||
/>
|
|
||||||
</ToolTip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
@ -0,0 +1,258 @@
|
||||||
|
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Chip } from "./Chip";
|
||||||
|
import { PageSelector } from "./Inputs/PageSelector";
|
||||||
|
import { Ico, Icon } from "./Ico";
|
||||||
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
|
import { cJoin } from "helpers/className";
|
||||||
|
import { isDefined, isDefinedAndNotEmpty } from "helpers/others";
|
||||||
|
import { AnchorIds, useScrollTopOnChange } from "hooks/useScrollTopOnChange";
|
||||||
|
|
||||||
|
interface Group<T> {
|
||||||
|
name: string;
|
||||||
|
items: T[];
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultGroupSortingFunction = <T,>(a: Group<T>, b: Group<T>) =>
|
||||||
|
a.name.localeCompare(b.name);
|
||||||
|
const defaultGroupCountingFunction = () => 1;
|
||||||
|
const defaultFilteringFunction = () => true;
|
||||||
|
const defaultSortingFunction = () => 0;
|
||||||
|
const defaultGroupingFunction = () => [""];
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
// Items
|
||||||
|
items: T[];
|
||||||
|
getItemId: (item: T) => string;
|
||||||
|
renderItem: (props: { item: T }) => JSX.Element;
|
||||||
|
renderWhenEmpty?: () => JSX.Element;
|
||||||
|
// Pagination
|
||||||
|
paginationItemPerPage?: number;
|
||||||
|
paginationSelectorTop?: boolean;
|
||||||
|
paginationSelectorBottom?: boolean;
|
||||||
|
paginationScroolTop?: boolean;
|
||||||
|
// Searching
|
||||||
|
searchingTerm?: string;
|
||||||
|
searchingBy?: (item: T) => string;
|
||||||
|
searchingCaseInsensitive?: boolean;
|
||||||
|
// Grouping
|
||||||
|
groupingFunction?: (item: T) => string[];
|
||||||
|
groupSortingFunction?: (a: Group<T>, b: Group<T>) => number;
|
||||||
|
groupCountingFunction?: (item: T) => number;
|
||||||
|
// Filtering
|
||||||
|
filteringFunction?: (item: T) => boolean;
|
||||||
|
// Sorting
|
||||||
|
sortingFunction?: (a: T, b: T) => number;
|
||||||
|
// Other
|
||||||
|
className?: string;
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartList = <T,>({
|
||||||
|
items,
|
||||||
|
getItemId,
|
||||||
|
renderItem: RenderItem,
|
||||||
|
renderWhenEmpty: RenderWhenEmpty,
|
||||||
|
paginationItemPerPage = Infinity,
|
||||||
|
paginationSelectorTop = true,
|
||||||
|
paginationSelectorBottom = true,
|
||||||
|
paginationScroolTop = true,
|
||||||
|
searchingTerm,
|
||||||
|
searchingBy,
|
||||||
|
searchingCaseInsensitive = true,
|
||||||
|
groupingFunction = defaultGroupingFunction,
|
||||||
|
groupSortingFunction = defaultGroupSortingFunction,
|
||||||
|
groupCountingFunction = defaultGroupCountingFunction,
|
||||||
|
filteringFunction = defaultFilteringFunction,
|
||||||
|
sortingFunction = defaultSortingFunction,
|
||||||
|
className,
|
||||||
|
langui,
|
||||||
|
}: Props<T>): JSX.Element => {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
useScrollTopOnChange(AnchorIds.ContentPanel, [page], paginationScroolTop);
|
||||||
|
useEffect(
|
||||||
|
() => setPage(0),
|
||||||
|
[searchingTerm, groupingFunction, groupSortingFunction, items]
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchFilter = useCallback(() => {
|
||||||
|
if (isDefinedAndNotEmpty(searchingTerm) && isDefined(searchingBy)) {
|
||||||
|
if (searchingCaseInsensitive) {
|
||||||
|
return items.filter((item) =>
|
||||||
|
searchingBy(item).toLowerCase().includes(searchingTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items.filter((item) => searchingBy(item).includes(searchingTerm));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [items, searchingBy, searchingCaseInsensitive, searchingTerm]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const filteredBySearch = searchFilter();
|
||||||
|
return filteredBySearch.filter(filteringFunction);
|
||||||
|
}, [filteringFunction, searchFilter]);
|
||||||
|
|
||||||
|
const sortedItem = useMemo(
|
||||||
|
() => filteredItems.sort(sortingFunction),
|
||||||
|
[filteredItems, sortingFunction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const memo: Group<T>[] = [];
|
||||||
|
|
||||||
|
sortedItem.forEach((item) => {
|
||||||
|
groupingFunction(item).forEach((category) => {
|
||||||
|
const index = memo.findIndex((group) => group.name === category);
|
||||||
|
if (index === -1) {
|
||||||
|
memo.push({
|
||||||
|
name: category,
|
||||||
|
items: [item],
|
||||||
|
totalCount: groupCountingFunction(item),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
memo[index].items.push(item);
|
||||||
|
memo[index].totalCount += groupCountingFunction(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return memo.sort(groupSortingFunction);
|
||||||
|
}, [
|
||||||
|
groupCountingFunction,
|
||||||
|
groupSortingFunction,
|
||||||
|
groupingFunction,
|
||||||
|
sortedItem,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pages = useMemo(() => {
|
||||||
|
const memo: Group<T>[][] = [];
|
||||||
|
let currentPage: Group<T>[] = [];
|
||||||
|
let remainingSlots = paginationItemPerPage;
|
||||||
|
let loopSafeguard = 1000;
|
||||||
|
|
||||||
|
const newPage = () => {
|
||||||
|
memo.push(currentPage);
|
||||||
|
currentPage = [];
|
||||||
|
remainingSlots = paginationItemPerPage;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
let remainingItems = group.items.length;
|
||||||
|
while (remainingItems > 0 && loopSafeguard >= 0) {
|
||||||
|
loopSafeguard--;
|
||||||
|
const currentIndex = group.items.length - remainingItems;
|
||||||
|
|
||||||
|
if (
|
||||||
|
remainingSlots <= 0 ||
|
||||||
|
(currentIndex === 0 &&
|
||||||
|
remainingItems > remainingSlots &&
|
||||||
|
remainingItems <= paginationItemPerPage)
|
||||||
|
) {
|
||||||
|
newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const slicedGroup: Group<T> = {
|
||||||
|
name: group.name,
|
||||||
|
items: group.items.slice(currentIndex, currentIndex + remainingSlots),
|
||||||
|
totalCount: group.totalCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
remainingItems -= slicedGroup.items.length;
|
||||||
|
remainingSlots -= slicedGroup.items.length;
|
||||||
|
currentPage.push(slicedGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage.length > 0) {
|
||||||
|
newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return memo;
|
||||||
|
}, [groups, paginationItemPerPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pages.length > 1 && paginationSelectorTop && (
|
||||||
|
<PageSelector
|
||||||
|
className="mb-12"
|
||||||
|
page={page}
|
||||||
|
pagesCount={pages.length}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
{pages[page]?.length > 0 ? (
|
||||||
|
pages[page]?.map(
|
||||||
|
(group) =>
|
||||||
|
group.items.length > 0 && (
|
||||||
|
<Fragment key={group.name}>
|
||||||
|
{group.name.length > 0 && (
|
||||||
|
<h2
|
||||||
|
className="flex flex-row place-items-center gap-2 pb-2 pt-10 text-2xl
|
||||||
|
first-of-type:pt-0"
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
<Chip
|
||||||
|
text={`${group.totalCount} ${
|
||||||
|
group.items.length <= 1
|
||||||
|
? langui.result?.toLowerCase() ?? ""
|
||||||
|
: langui.results?.toLowerCase() ?? ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cJoin(
|
||||||
|
`grid items-start gap-8 border-b-[3px] border-dotted pb-12
|
||||||
|
last-of-type:border-0 mobile:gap-4`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<RenderItem item={item} key={getItemId(item)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) : isDefined(RenderWhenEmpty) ? (
|
||||||
|
<RenderWhenEmpty />
|
||||||
|
) : (
|
||||||
|
<DefaultRenderWhenEmpty langui={langui} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pages.length > 1 && paginationSelectorBottom && (
|
||||||
|
<PageSelector
|
||||||
|
className="mb-12"
|
||||||
|
page={page}
|
||||||
|
pagesCount={pages.length}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ╭──────────────────────╮
|
||||||
|
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface DefaultRenderWhenEmptyProps {
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultRenderWhenEmpty = ({ langui }: DefaultRenderWhenEmptyProps) => (
|
||||||
|
<div className="grid h-full place-content-center">
|
||||||
|
<div
|
||||||
|
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
|
||||||
|
border-dark p-8 text-dark opacity-40"
|
||||||
|
>
|
||||||
|
<Ico icon={Icon.ChevronLeft} className="!text-[300%] mobile:hidden" />
|
||||||
|
<p className="max-w-xs text-2xl">{langui.no_results_message}</p>
|
||||||
|
<Ico icon={Icon.ChevronRight} className="!text-[300%] desktop:hidden" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
|
@ -1,13 +1,13 @@
|
||||||
import { Chip } from "components/Chip";
|
import { Chip } from "components/Chip";
|
||||||
import { Img } from "components/Img";
|
import { Img } from "components/Img";
|
||||||
import { InsetBox } from "components/Containers/InsetBox";
|
import { InsetBox } from "components/InsetBox";
|
||||||
import { Markdawn } from "components/Markdown/Markdawn";
|
import { Markdawn } from "components/Markdown/Markdawn";
|
||||||
import { DatePickerFragment, UploadImageFragment } from "graphql/generated";
|
import { GetContentTextQuery, UploadImageFragment } from "graphql/generated";
|
||||||
import { prettyInlineTitle, slugify } from "helpers/formatters";
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
import { ImageQuality } from "helpers/img";
|
import { prettyInlineTitle, prettySlug, slugify } from "helpers/formatters";
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
import { getAssetURL, ImageQuality } from "helpers/img";
|
||||||
import { atoms } from "contexts/atoms";
|
import { filterHasAttributes } from "helpers/others";
|
||||||
import { useFormat } from "hooks/useFormat";
|
import { useLightBox } from "hooks/useLightBox";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -19,17 +19,21 @@ interface Props {
|
||||||
title: string | null | undefined;
|
title: string | null | undefined;
|
||||||
subtitle?: string | null | undefined;
|
subtitle?: string | null | undefined;
|
||||||
description?: string | null | undefined;
|
description?: string | null | undefined;
|
||||||
type?: string;
|
type?: NonNullable<
|
||||||
categories?: string[];
|
NonNullable<GetContentTextQuery["contents"]>["data"][number]["attributes"]
|
||||||
releaseDate?: DatePickerFragment;
|
>["type"];
|
||||||
|
categories?: NonNullable<
|
||||||
|
NonNullable<GetContentTextQuery["contents"]>["data"][number]["attributes"]
|
||||||
|
>["categories"];
|
||||||
thumbnail?: UploadImageFragment | null | undefined;
|
thumbnail?: UploadImageFragment | null | undefined;
|
||||||
className?: string;
|
langui: AppStaticProps["langui"];
|
||||||
languageSwitcher?: JSX.Element;
|
languageSwitcher?: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
export const ThumbnailHeader = ({
|
export const ThumbnailHeader = ({
|
||||||
|
langui,
|
||||||
pre_title,
|
pre_title,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
@ -38,30 +42,33 @@ export const ThumbnailHeader = ({
|
||||||
categories,
|
categories,
|
||||||
description,
|
description,
|
||||||
languageSwitcher,
|
languageSwitcher,
|
||||||
releaseDate,
|
|
||||||
className,
|
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const { format, formatDate } = useFormat();
|
const [openLightBox, LightBox] = useLightBox();
|
||||||
const { showLightBox } = useAtomGetter(atoms.lightBox);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<>
|
||||||
<div className={"mb-12 grid place-items-center gap-12"}>
|
<LightBox />
|
||||||
<div className="drop-shadow-lg shadow-shade">
|
<div className="mb-12 grid place-items-center gap-12">
|
||||||
|
<div className="drop-shadow-shade-lg">
|
||||||
{thumbnail ? (
|
{thumbnail ? (
|
||||||
<Img
|
<Img
|
||||||
className="cursor-pointer rounded-xl"
|
className="cursor-pointer rounded-xl"
|
||||||
src={thumbnail}
|
src={thumbnail}
|
||||||
quality={ImageQuality.Medium}
|
quality={ImageQuality.Medium}
|
||||||
onClick={() => showLightBox([thumbnail])}
|
onClick={() => {
|
||||||
|
openLightBox([getAssetURL(thumbnail.url, ImageQuality.Large)]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="aspect-[4/3] w-96 rounded-xl bg-light" />
|
<div className="aspect-[4/3] w-96 rounded-xl bg-light"></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id={slugify(prettyInlineTitle(pre_title ?? "", title, subtitle ?? ""))}
|
id={slugify(
|
||||||
className="grid place-items-center text-center">
|
prettyInlineTitle(pre_title ?? "", title, subtitle ?? "")
|
||||||
|
)}
|
||||||
|
className="grid place-items-center text-center"
|
||||||
|
>
|
||||||
<p className="text-2xl">{pre_title}</p>
|
<p className="text-2xl">{pre_title}</p>
|
||||||
<h1 className="text-3xl">{title}</h1>
|
<h1 className="text-3xl">{title}</h1>
|
||||||
<h2 className="text-2xl">{subtitle}</h2>
|
<h2 className="text-2xl">{subtitle}</h2>
|
||||||
|
@ -69,37 +76,40 @@ export const ThumbnailHeader = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flew-wrap flex flex-row place-content-center gap-8">
|
<div className="flew-wrap flex flex-row place-content-center gap-8">
|
||||||
{type && (
|
{type?.data?.attributes && (
|
||||||
<div className="flex flex-col place-items-center gap-2">
|
<div className="flex flex-col place-items-center gap-2">
|
||||||
<h3 className="text-xl">{format("type", { count: 1 })}</h3>
|
<h3 className="text-xl">{langui.type}</h3>
|
||||||
<div className="flex flex-row flex-wrap">
|
<div className="flex flex-row flex-wrap">
|
||||||
<Chip text={type} />
|
<Chip
|
||||||
|
text={
|
||||||
|
type.data.attributes.titles?.[0]?.title ??
|
||||||
|
prettySlug(type.data.attributes.slug)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{releaseDate && (
|
{categories && categories.data.length > 0 && (
|
||||||
<div className="flex flex-col place-items-center gap-2">
|
<div className="flex flex-col place-items-center gap-2">
|
||||||
<h3 className="text-xl">{format("release_date")}</h3>
|
<h3 className="text-xl">{langui.categories}</h3>
|
||||||
<div className="flex flex-row flex-wrap">
|
|
||||||
<Chip text={formatDate(releaseDate)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{categories && categories.length > 0 && (
|
|
||||||
<div className="flex flex-col place-items-center gap-2">
|
|
||||||
<h3 className="text-xl">{format("category", { count: categories.length })}</h3>
|
|
||||||
<div className="flex flex-row flex-wrap place-content-center gap-2">
|
<div className="flex flex-row flex-wrap place-content-center gap-2">
|
||||||
{categories.map((category) => (
|
{filterHasAttributes(categories.data, [
|
||||||
<Chip key={category} text={category} />
|
"attributes",
|
||||||
|
"id",
|
||||||
|
] as const).map((category) => (
|
||||||
|
<Chip key={category.id} text={category.attributes.name} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{languageSwitcher}
|
{languageSwitcher}
|
||||||
</div>
|
</div>
|
||||||
{description && <InsetBox className="mt-8">{<Markdawn text={description} />}</InsetBox>}
|
{description && (
|
||||||
</div>
|
<InsetBox className="mt-8">
|
||||||
|
{<Markdawn text={description} langui={langui} />}
|
||||||
|
</InsetBox>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import Tippy from "@tippyjs/react";
|
// eslint-disable-next-line import/named
|
||||||
import type { TippyProps } from "@tippyjs/react";
|
import Tippy, { TippyProps } from "@tippyjs/react";
|
||||||
import { cJoin } from "helpers/className";
|
import { cJoin } from "helpers/className";
|
||||||
import "tippy.js/animations/scale-subtle.css";
|
import "tippy.js/animations/scale-subtle.css";
|
||||||
|
|
||||||
|
@ -25,7 +25,8 @@ export const ToolTip = ({
|
||||||
delay={delay}
|
delay={delay}
|
||||||
interactive={interactive}
|
interactive={interactive}
|
||||||
animation={animation}
|
animation={animation}
|
||||||
{...otherProps}>
|
{...otherProps}
|
||||||
|
>
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
</Tippy>
|
</Tippy>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,226 @@
|
||||||
|
import { PreviewCard } from "./PreviewCard";
|
||||||
|
import { PreviewLine } from "./PreviewLine";
|
||||||
|
import { ScanSet } from "./Library/ScanSet";
|
||||||
|
import { NavOption } from "./PanelComponents/NavOption";
|
||||||
|
import { ChroniclePreview } from "./Chronicles/ChroniclePreview";
|
||||||
|
import { ChroniclesList } from "./Chronicles/ChroniclesList";
|
||||||
|
import { Button } from "./Inputs/Button";
|
||||||
|
import { ReturnButton } from "./PanelComponents/ReturnButton";
|
||||||
|
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||||
|
import { PreviewFolder } from "pages/contents/folder/[slug]";
|
||||||
|
|
||||||
|
export type TranslatedProps<P, K extends keyof P> = Omit<P, K> & {
|
||||||
|
translations: (Pick<P, K> & { language: string })[];
|
||||||
|
fallback: Pick<P, K>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
type TranslatedPreviewCardProps = TranslatedProps<
|
||||||
|
Parameters<typeof PreviewCard>[0],
|
||||||
|
"description" | "pre_title" | "subtitle" | "title"
|
||||||
|
>;
|
||||||
|
|
||||||
|
const languageExtractor = (item: { language: string }): string => item.language;
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const TranslatedPreviewCard = ({
|
||||||
|
translations,
|
||||||
|
fallback,
|
||||||
|
...otherProps
|
||||||
|
}: TranslatedPreviewCardProps): JSX.Element => {
|
||||||
|
const [selectedTranslation] = useSmartLanguage({
|
||||||
|
items: translations,
|
||||||
|
languageExtractor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewCard
|
||||||
|
pre_title={selectedTranslation?.pre_title ?? fallback.pre_title}
|
||||||
|
title={selectedTranslation?.title ?? fallback.title}
|
||||||
|
subtitle={selectedTranslation?.subtitle ?? fallback.subtitle}
|
||||||
|
description={selectedTranslation?.description ?? fallback.description}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const TranslatedPreviewLine = ({
|
||||||
|
translations,
|
||||||
|
fallback,
|
||||||
|
...otherProps
|
||||||
|
}: TranslatedProps<
|
||||||
|
Parameters<typeof PreviewLine>[0],
|
||||||
|
"pre_title" | "subtitle" | "title"
|
||||||
|
>): JSX.Element => {
|
||||||
|
const [selectedTranslation] = useSmartLanguage({
|
||||||
|
items: translations,
|
||||||
|
|
||||||
|
languageExtractor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewLine
|
||||||
|
pre_title={selectedTranslation?.pre_title ?? fallback.pre_title}
|
||||||
|
title={selectedTranslation?.title ?? fallback.title}
|
||||||
|
subtitle={selectedTranslation?.subtitle ?? fallback.subtitle}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const TranslatedScanSet = ({
|
||||||
|
translations,
|
||||||
|
fallback,
|
||||||
|
...otherProps
|
||||||
|
}: TranslatedProps<Parameters<typeof ScanSet>[0], "title">): JSX.Element => {
|
||||||
|
const [selectedTranslation] = useSmartLanguage({
|
||||||
|
items: translations,
|
||||||
|
languageExtractor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScanSet
|
||||||
|
title={selectedTranslation?.title ?? fallback.title}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const TranslatedNavOption = ({
|
||||||
|
translations,
|
||||||
|
fallback,
|
||||||
|
...otherProps
|
||||||
|
}: TranslatedProps<
|
||||||
|
Parameters<typeof NavOption>[0],
|
||||||
|
"subtitle" | "title"
|
||||||
|
>): JSX.Element => {
|
||||||
|
const [selectedTranslation] = useSmartLanguage({
|
||||||
|
items: translations,
|
||||||
|
languageExtractor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavOption
|
||||||
|
title={selectedTranslation?.title ?? fallback.title}
|
||||||
|
subtitle={selectedTranslation?.subtitle ?? fallback.subtitle}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const TranslatedChroniclePreview = ({
|
||||||
|
translations,
|
||||||
|
fallback,
|
||||||
|
...otherProps
|
||||||
|
}: TranslatedProps<
|
||||||
|
Parameters<typeof ChroniclePreview>[0],
|
||||||
|
"title"
|
||||||
|
>): JSX.Element => {
|
||||||
|
const [selectedTranslation] = useSmartLanguage({
|
||||||
|
items: translations,
|
||||||
|
languageExtractor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChroniclePreview
|
||||||
|
title={selectedTranslation?.title ?? fallback.title}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const TranslatedChroniclesList = ({
|
||||||
|
translations,
|
||||||
|
fallback,
|
||||||
|
...otherProps
|
||||||
|
}: TranslatedProps<
|
||||||
|
Parameters<typeof ChroniclesList>[0],
|
||||||
|
"title"
|
||||||
|
>): JSX.Element => {
|
||||||
|
const [selectedTranslation] = useSmartLanguage({
|
||||||
|
items: translations,
|
||||||
|
languageExtractor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChroniclesList
|
||||||
|
title={selectedTranslation?.title ?? fallback.title}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const TranslatedButton = ({
|
||||||
|
translations,
|
||||||
|
fallback,
|
||||||
|
...otherProps
|
||||||
|
}: TranslatedProps<Parameters<typeof Button>[0], "text">): JSX.Element => {
|
||||||
|
const [selectedTranslation] = useSmartLanguage({
|
||||||
|
items: translations,
|
||||||
|
languageExtractor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button text={selectedTranslation?.text ?? fallback.text} {...otherProps} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const TranslatedPreviewFolder = ({
|
||||||
|
translations,
|
||||||
|
fallback,
|
||||||
|
...otherProps
|
||||||
|
}: TranslatedProps<
|
||||||
|
Parameters<typeof PreviewFolder>[0],
|
||||||
|
"title"
|
||||||
|
>): JSX.Element => {
|
||||||
|
const [selectedTranslation] = useSmartLanguage({
|
||||||
|
items: translations,
|
||||||
|
languageExtractor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewFolder
|
||||||
|
title={selectedTranslation?.title ?? fallback.title}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
export const TranslatedReturnButton = ({
|
||||||
|
translations,
|
||||||
|
fallback,
|
||||||
|
...otherProps
|
||||||
|
}: TranslatedProps<
|
||||||
|
Parameters<typeof ReturnButton>[0],
|
||||||
|
"title"
|
||||||
|
>): JSX.Element => {
|
||||||
|
const [selectedTranslation] = useSmartLanguage({
|
||||||
|
items: translations,
|
||||||
|
languageExtractor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReturnButton
|
||||||
|
title={selectedTranslation?.title ?? fallback.title}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,12 +1,10 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Chip } from "components/Chip";
|
import { Chip } from "components/Chip";
|
||||||
import { ToolTip } from "components/ToolTip";
|
import { ToolTip } from "components/ToolTip";
|
||||||
|
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||||
|
import { getStatusDescription } from "helpers/others";
|
||||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||||
import { Button } from "components/Inputs/Button";
|
import { Button } from "components/Inputs/Button";
|
||||||
import { cIf, cJoin } from "helpers/className";
|
|
||||||
import { atoms } from "contexts/atoms";
|
|
||||||
import { useAtomGetter } from "helpers/atoms";
|
|
||||||
import { ContentStatus, useFormat } from "hooks/useFormat";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ╭─────────────╮
|
* ╭─────────────╮
|
||||||
|
@ -21,26 +19,38 @@ interface Props {
|
||||||
translations: {
|
translations: {
|
||||||
language: string | undefined;
|
language: string | undefined;
|
||||||
definition: string | null | undefined;
|
definition: string | null | undefined;
|
||||||
status: ContentStatus | undefined;
|
status: string | undefined;
|
||||||
}[];
|
}[];
|
||||||
|
languages: AppStaticProps["languages"];
|
||||||
|
langui: AppStaticProps["langui"];
|
||||||
index: number;
|
index: number;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
const DefinitionCard = ({ source, translations = [], index, categories }: Props): JSX.Element => {
|
const DefinitionCard = ({
|
||||||
const isContentPanelAtLeastMd = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastMd);
|
source,
|
||||||
const { format, formatStatusDescription } = useFormat();
|
translations = [],
|
||||||
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
|
languages,
|
||||||
items: translations,
|
langui,
|
||||||
languageExtractor: useCallback((item: Props["translations"][number]) => item.language, []),
|
index,
|
||||||
});
|
categories,
|
||||||
|
}: Props): JSX.Element => {
|
||||||
|
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] =
|
||||||
|
useSmartLanguage({
|
||||||
|
items: translations,
|
||||||
|
languages: languages,
|
||||||
|
languageExtractor: useCallback(
|
||||||
|
(item: Props["translations"][number]) => item.language,
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap place-items-center gap-2">
|
<div className="flex flex-wrap place-items-center gap-2">
|
||||||
<p className="font-headers text-lg font-bold">{format("definition_x", { x: index })}</p>
|
<p className="font-headers text-lg font-bold">{`${langui.definition} ${index}`}</p>
|
||||||
|
|
||||||
{translations.length > 1 && (
|
{translations.length > 1 && (
|
||||||
<>
|
<>
|
||||||
|
@ -53,8 +63,9 @@ const DefinitionCard = ({ source, translations = [], index, categories }: Props)
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<ToolTip
|
<ToolTip
|
||||||
content={formatStatusDescription(selectedTranslation.status)}
|
content={getStatusDescription(selectedTranslation.status, langui)}
|
||||||
maxWidth={"20rem"}>
|
maxWidth={"20rem"}
|
||||||
|
>
|
||||||
<Chip text={selectedTranslation.status} />
|
<Chip text={selectedTranslation.status} />
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
</>
|
</>
|
||||||
|
@ -75,12 +86,8 @@ const DefinitionCard = ({ source, translations = [], index, categories }: Props)
|
||||||
<p>{selectedTranslation?.definition}</p>
|
<p>{selectedTranslation?.definition}</p>
|
||||||
|
|
||||||
{source?.url && source.name && (
|
{source?.url && source.name && (
|
||||||
<div
|
<div className="mt-3 flex place-items-center gap-2 mobile:flex-col mobile:text-center">
|
||||||
className={cJoin(
|
<p>{langui.source}: </p>
|
||||||
"mt-3 flex place-items-center gap-2",
|
|
||||||
cIf(!isContentPanelAtLeastMd, "flex-col text-center")
|
|
||||||
)}>
|
|
||||||
<p>{format("source")}: </p>
|
|
||||||
<Button href={source.url} size="small" text={source.name} />
|
<Button href={source.url} size="small" text={source.name} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -0,0 +1,260 @@
|
||||||
|
import React, { ReactNode, useContext, useState } from "react";
|
||||||
|
import { isDefined } from "helpers/others";
|
||||||
|
import { LibraryItemUserStatus, RequiredNonNullable } from "helpers/types";
|
||||||
|
import { useDarkMode } from "hooks/useDarkMode";
|
||||||
|
import { useStateWithLocalStorage } from "hooks/useStateWithLocalStorage";
|
||||||
|
|
||||||
|
interface AppLayoutState {
|
||||||
|
subPanelOpen: boolean | undefined;
|
||||||
|
toggleSubPanelOpen: () => void;
|
||||||
|
setSubPanelOpen: React.Dispatch<
|
||||||
|
React.SetStateAction<AppLayoutState["subPanelOpen"]>
|
||||||
|
>;
|
||||||
|
configPanelOpen: boolean | undefined;
|
||||||
|
toggleConfigPanelOpen: () => void;
|
||||||
|
setConfigPanelOpen: React.Dispatch<
|
||||||
|
React.SetStateAction<AppLayoutState["configPanelOpen"]>
|
||||||
|
>;
|
||||||
|
searchPanelOpen: boolean | undefined;
|
||||||
|
toggleSearchPanelOpen: () => void;
|
||||||
|
setSearchPanelOpen: React.Dispatch<
|
||||||
|
React.SetStateAction<AppLayoutState["searchPanelOpen"]>
|
||||||
|
>;
|
||||||
|
mainPanelReduced: boolean | undefined;
|
||||||
|
toggleMainPanelReduced: () => void;
|
||||||
|
setMainPanelReduced: React.Dispatch<
|
||||||
|
React.SetStateAction<AppLayoutState["mainPanelReduced"]>
|
||||||
|
>;
|
||||||
|
mainPanelOpen: boolean | undefined;
|
||||||
|
toggleMainPanelOpen: () => void;
|
||||||
|
setMainPanelOpen: React.Dispatch<
|
||||||
|
React.SetStateAction<AppLayoutState["mainPanelOpen"]>
|
||||||
|
>;
|
||||||
|
darkMode: boolean | undefined;
|
||||||
|
toggleDarkMode: () => void;
|
||||||
|
setDarkMode: React.Dispatch<React.SetStateAction<AppLayoutState["darkMode"]>>;
|
||||||
|
selectedThemeMode: boolean | undefined;
|
||||||
|
toggleSelectedThemeMode: () => void;
|
||||||
|
setSelectedThemeMode: React.Dispatch<
|
||||||
|
React.SetStateAction<AppLayoutState["selectedThemeMode"]>
|
||||||
|
>;
|
||||||
|
fontSize: number | undefined;
|
||||||
|
setFontSize: React.Dispatch<React.SetStateAction<AppLayoutState["fontSize"]>>;
|
||||||
|
dyslexic: boolean | undefined;
|
||||||
|
toggleDyslexic: () => void;
|
||||||
|
setDyslexic: React.Dispatch<React.SetStateAction<AppLayoutState["dyslexic"]>>;
|
||||||
|
currency: string | undefined;
|
||||||
|
setCurrency: React.Dispatch<React.SetStateAction<AppLayoutState["currency"]>>;
|
||||||
|
playerName: string | undefined;
|
||||||
|
setPlayerName: React.Dispatch<
|
||||||
|
React.SetStateAction<AppLayoutState["playerName"]>
|
||||||
|
>;
|
||||||
|
preferredLanguages: string[] | undefined;
|
||||||
|
setPreferredLanguages: React.Dispatch<
|
||||||
|
React.SetStateAction<AppLayoutState["preferredLanguages"]>
|
||||||
|
>;
|
||||||
|
menuGestures: boolean;
|
||||||
|
toggleMenuGestures: () => void;
|
||||||
|
setMenuGestures: React.Dispatch<
|
||||||
|
React.SetStateAction<AppLayoutState["menuGestures"]>
|
||||||
|
>;
|
||||||
|
libraryItemUserStatus: Record<string, LibraryItemUserStatus> | undefined;
|
||||||
|
setLibraryItemUserStatus: React.Dispatch<
|
||||||
|
React.SetStateAction<AppLayoutState["libraryItemUserStatus"]>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: RequiredNonNullable<AppLayoutState> = {
|
||||||
|
subPanelOpen: false,
|
||||||
|
toggleSubPanelOpen: () => null,
|
||||||
|
setSubPanelOpen: () => null,
|
||||||
|
configPanelOpen: false,
|
||||||
|
setConfigPanelOpen: () => null,
|
||||||
|
toggleConfigPanelOpen: () => null,
|
||||||
|
searchPanelOpen: false,
|
||||||
|
setSearchPanelOpen: () => null,
|
||||||
|
toggleSearchPanelOpen: () => null,
|
||||||
|
mainPanelReduced: false,
|
||||||
|
setMainPanelReduced: () => null,
|
||||||
|
toggleMainPanelReduced: () => null,
|
||||||
|
mainPanelOpen: false,
|
||||||
|
toggleMainPanelOpen: () => null,
|
||||||
|
setMainPanelOpen: () => null,
|
||||||
|
darkMode: false,
|
||||||
|
toggleDarkMode: () => null,
|
||||||
|
setDarkMode: () => null,
|
||||||
|
selectedThemeMode: false,
|
||||||
|
toggleSelectedThemeMode: () => null,
|
||||||
|
setSelectedThemeMode: () => null,
|
||||||
|
fontSize: 1,
|
||||||
|
setFontSize: () => null,
|
||||||
|
dyslexic: false,
|
||||||
|
toggleDyslexic: () => null,
|
||||||
|
setDyslexic: () => null,
|
||||||
|
currency: "USD",
|
||||||
|
setCurrency: () => null,
|
||||||
|
playerName: "",
|
||||||
|
setPlayerName: () => null,
|
||||||
|
preferredLanguages: [],
|
||||||
|
setPreferredLanguages: () => null,
|
||||||
|
menuGestures: true,
|
||||||
|
toggleMenuGestures: () => null,
|
||||||
|
setMenuGestures: () => null,
|
||||||
|
libraryItemUserStatus: {},
|
||||||
|
setLibraryItemUserStatus: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppContext = React.createContext<AppLayoutState>(initialState);
|
||||||
|
|
||||||
|
export const useAppLayout = (): AppLayoutState => useContext(AppContext);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppContextProvider = (props: Props): JSX.Element => {
|
||||||
|
const [subPanelOpen, setSubPanelOpen] = useStateWithLocalStorage(
|
||||||
|
"subPanelOpen",
|
||||||
|
initialState.subPanelOpen
|
||||||
|
);
|
||||||
|
|
||||||
|
const [configPanelOpen, setConfigPanelOpen] = useStateWithLocalStorage(
|
||||||
|
"configPanelOpen",
|
||||||
|
initialState.configPanelOpen
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mainPanelReduced, setMainPanelReduced] = useStateWithLocalStorage(
|
||||||
|
"mainPanelReduced",
|
||||||
|
initialState.mainPanelReduced
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mainPanelOpen, setMainPanelOpen] = useStateWithLocalStorage(
|
||||||
|
"mainPanelOpen",
|
||||||
|
initialState.mainPanelOpen
|
||||||
|
);
|
||||||
|
|
||||||
|
const [darkMode, selectedThemeMode, setDarkMode, setSelectedThemeMode] =
|
||||||
|
useDarkMode("darkMode", initialState.darkMode);
|
||||||
|
|
||||||
|
const [fontSize, setFontSize] = useStateWithLocalStorage(
|
||||||
|
"fontSize",
|
||||||
|
initialState.fontSize
|
||||||
|
);
|
||||||
|
|
||||||
|
const [dyslexic, setDyslexic] = useStateWithLocalStorage(
|
||||||
|
"dyslexic",
|
||||||
|
initialState.dyslexic
|
||||||
|
);
|
||||||
|
|
||||||
|
const [currency, setCurrency] = useStateWithLocalStorage(
|
||||||
|
"currency",
|
||||||
|
initialState.currency
|
||||||
|
);
|
||||||
|
|
||||||
|
const [playerName, setPlayerName] = useStateWithLocalStorage(
|
||||||
|
"playerName",
|
||||||
|
initialState.playerName
|
||||||
|
);
|
||||||
|
|
||||||
|
const [preferredLanguages, setPreferredLanguages] = useStateWithLocalStorage(
|
||||||
|
"preferredLanguages",
|
||||||
|
initialState.preferredLanguages
|
||||||
|
);
|
||||||
|
|
||||||
|
const [menuGestures, setMenuGestures] = useState(false);
|
||||||
|
|
||||||
|
const [searchPanelOpen, setSearchPanelOpen] = useStateWithLocalStorage(
|
||||||
|
"searchPanelOpen",
|
||||||
|
initialState.searchPanelOpen
|
||||||
|
);
|
||||||
|
|
||||||
|
const [libraryItemUserStatus, setLibraryItemUserStatus] =
|
||||||
|
useStateWithLocalStorage(
|
||||||
|
"libraryItemUserStatus",
|
||||||
|
initialState.libraryItemUserStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSubPanelOpen = () => {
|
||||||
|
setSubPanelOpen((current) => (isDefined(current) ? !current : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleConfigPanelOpen = () => {
|
||||||
|
setConfigPanelOpen((current) => (isDefined(current) ? !current : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSearchPanelOpen = () => {
|
||||||
|
setSearchPanelOpen((current) => (isDefined(current) ? !current : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMainPanelReduced = () => {
|
||||||
|
setMainPanelReduced((current) => (isDefined(current) ? !current : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMainPanelOpen = () => {
|
||||||
|
setMainPanelOpen((current) => (isDefined(current) ? !current : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
setDarkMode((current) => (isDefined(current) ? !current : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMenuGestures = () => {
|
||||||
|
setMenuGestures((current) => !current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectedThemeMode = () => {
|
||||||
|
setSelectedThemeMode((current) =>
|
||||||
|
isDefined(current) ? !current : current
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDyslexic = () => {
|
||||||
|
setDyslexic((current) => (isDefined(current) ? !current : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContext.Provider
|
||||||
|
value={{
|
||||||
|
subPanelOpen,
|
||||||
|
configPanelOpen,
|
||||||
|
searchPanelOpen,
|
||||||
|
mainPanelReduced,
|
||||||
|
mainPanelOpen,
|
||||||
|
darkMode,
|
||||||
|
selectedThemeMode,
|
||||||
|
fontSize,
|
||||||
|
dyslexic,
|
||||||
|
currency,
|
||||||
|
playerName,
|
||||||
|
preferredLanguages,
|
||||||
|
menuGestures,
|
||||||
|
libraryItemUserStatus,
|
||||||
|
setSubPanelOpen,
|
||||||
|
setConfigPanelOpen,
|
||||||
|
setSearchPanelOpen,
|
||||||
|
setMainPanelReduced,
|
||||||
|
setMainPanelOpen,
|
||||||
|
setDarkMode,
|
||||||
|
setSelectedThemeMode,
|
||||||
|
setFontSize,
|
||||||
|
setDyslexic,
|
||||||
|
setCurrency,
|
||||||
|
setPlayerName,
|
||||||
|
setPreferredLanguages,
|
||||||
|
setMenuGestures,
|
||||||
|
setLibraryItemUserStatus,
|
||||||
|
toggleSubPanelOpen,
|
||||||
|
toggleConfigPanelOpen,
|
||||||
|
toggleSearchPanelOpen,
|
||||||
|
toggleMainPanelReduced,
|
||||||
|
toggleMainPanelOpen,
|
||||||
|
toggleDarkMode,
|
||||||
|
toggleMenuGestures,
|
||||||
|
toggleSelectedThemeMode,
|
||||||
|
toggleDyslexic,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,47 +0,0 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffectOnce } from "usehooks-ts";
|
|
||||||
import { UploadImageFragment } from "graphql/generated";
|
|
||||||
import { LightBox } from "components/LightBox";
|
|
||||||
import { filterDefined } from "helpers/asserts";
|
|
||||||
import { useAtomSetter } from "helpers/atoms";
|
|
||||||
import { internalAtoms } from "contexts/atoms";
|
|
||||||
|
|
||||||
export const LightBoxProvider = (): JSX.Element => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [isLightBoxVisible, setLightBoxVisibility] = useState(false);
|
|
||||||
const [lightBoxImages, setLightBoxImages] = useState<(UploadImageFragment | string)[]>([]);
|
|
||||||
const [lightBoxIndex, setLightBoxIndex] = useState(0);
|
|
||||||
|
|
||||||
const setShowLightBox = useAtomSetter(internalAtoms.lightBox);
|
|
||||||
|
|
||||||
useEffectOnce(() =>
|
|
||||||
setShowLightBox({
|
|
||||||
showLightBox: (images, index = 0) => {
|
|
||||||
const filteredImages = filterDefined(images);
|
|
||||||
setLightBoxIndex(index);
|
|
||||||
setLightBoxImages(filteredImages);
|
|
||||||
setLightBoxVisibility(true);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeLightBox = useCallback(() => {
|
|
||||||
setLightBoxVisibility(false);
|
|
||||||
setTimeout(() => setLightBoxImages([]), 100);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => router.events.on("routeChangeStart", closeLightBox));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LightBox
|
|
||||||
isVisible={isLightBoxVisible}
|
|
||||||
onCloseRequest={closeLightBox}
|
|
||||||
image={lightBoxImages[lightBoxIndex]}
|
|
||||||
isNextImageAvailable={lightBoxIndex < lightBoxImages.length - 1}
|
|
||||||
isPreviousImageAvailable={lightBoxIndex > 0}
|
|
||||||
onPressNext={() => setLightBoxIndex((current) => current + 1)}
|
|
||||||
onPressPrevious={() => setLightBoxIndex((current) => current - 1)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|