Compare commits

..

1 Commits

Author SHA1 Message Date
DrMint 9a8608a8e3 Tried making my custom lightbox but it's hard man 2022-08-14 10:10:48 +02:00
236 changed files with 17623 additions and 32664 deletions

View File

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

View File

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

View File

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

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

@ -1,2 +1 @@
.next .next
public/local-data/*

View File

@ -1,5 +0,0 @@
{
"css.lint.unknownAtRules": "ignore",
"editor.rulers": [100],
"typescript.preferences.importModuleSpecifier": "non-relative"
}

View File

@ -77,9 +77,4 @@ interface ComponentProps {}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Component = () => {}; export const Component = () => {};
/*
* ╭──────────────────────╮
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
*/
``` ```

170
README.md
View File

@ -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
Accords Library is a fan-site that aims at gathering and archiving all of Yoko Taros 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
``` ```

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

View File

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

19
graphql.config.js Normal file
View File

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

View File

@ -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/*",
],
}; };

View File

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

12038
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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":"中文"}}]}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

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

View File

@ -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="Accords 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)
: "Accords 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]" : "Accords 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&rsquo;t be a problem but our website
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)]"), isfor some obscure reasonperforming 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>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) ? (
<>
&ldquo;{compProps.children}&rdquo;
<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) ? (
<>
&ldquo;{compProps.children}&rdquo;
<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;
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&rsquo;s Library</h2> <h2 className="mb-4 text-3xl">Accord&rsquo;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>

View File

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

View File

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

View File

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

View File

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

74
src/components/Popup.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More