Compare commits

...

87 Commits
next13 ... main

Author SHA1 Message Date
DrMint e88345f395 More deps upgrade 2023-08-17 15:01:23 +02:00
DrMint 34c4570688 Updated deps 2023-08-17 14:46:35 +02:00
DrMint da916f898a Simplified some DTO 2023-08-17 12:52:40 +02:00
DrMint 7efa43a630 Forgot an import 2023-06-12 09:56:23 +02:00
DrMint 22e1bf4842 Added easy access to search input on mobile pages 2023-06-12 09:53:55 +02:00
DrMint d560008cff Fixed bug where preview card overlay would clip above sidebar's mobile backdrop 2023-06-11 15:17:31 +02:00
DrMint 872f31a6a3 Add language filter for a lot of pages 2023-06-09 21:45:20 +02:00
DrMint 3c7b9aa2d6 Fixes for languages 2023-06-08 18:35:33 +02:00
DrMint 62e64b9319 Fixed problems with user preferred languages 2023-06-08 12:25:03 +02:00
DrMint e0ee70814d Upgrade deps 2023-06-07 23:38:58 +02:00
DrMint 87625ba9ac Updated deps 2023-06-07 23:30:05 +02:00
DrMint fc1b0c1284 Updated deps 2023-06-07 23:08:15 +02:00
DrMint 284bbd6272 Categories and recorders are now localdata 2023-06-05 22:03:27 +02:00
DrMint c3796b4fe8 Some Chinese text fixes 2023-06-03 21:23:03 +02:00
DrMint 7bde24adaa Fix select and tooltip z-index 2023-06-03 21:19:13 +02:00
DrMint 66dbb29871 Add Chinese language support 2023-06-03 17:47:21 +02:00
DrMint 6d0429c21a Added angelic font 2023-06-01 21:40:48 +02:00
DrMint 2f0322c1fa Updated deps 2023-05-22 20:14:58 +02:00
DrMint 6093ef131a Added videos in Markdawn 2023-05-22 20:07:45 +02:00
DrMint ff89031123 Improve Open Graph Metas 2023-05-22 20:07:04 +02:00
DrMint d5e7d704bf Post now use displayable_description 2023-05-19 14:47:44 +02:00
DrMint 22f7c39dff Updated deps 2023-05-19 12:23:21 +02:00
DrMint a047d18c76 Changed "download scans" to "download archive" 2023-05-19 12:04:11 +02:00
DrMint 895fee1bae Added audio and video player 2023-05-19 01:35:47 +02:00
DrMint 3e979c4005 Small fixes 2023-05-16 12:50:53 +02:00
DrMint f12d5b0525 Removed unused wording keys 2023-05-16 12:47:09 +02:00
DrMint ef7b3faeca Small improvements 2023-05-14 11:15:12 +02:00
DrMint d4e6393b9e Updated deps 2023-05-13 10:23:27 +02:00
DrMint 663bf4f08d Improved perf on all browser 2023-05-13 10:09:57 +02:00
DrMint 06d82e1133 Small fixes 2023-05-12 12:52:52 +02:00
DrMint f8f98ec41e Add download button for scans archives 2023-05-11 11:24:36 +02:00
DrMint 5d2fe252ec Focus on search input when opening search popup 2023-05-11 00:41:36 +02:00
DrMint a8960d67ed Bug fix preview cards could overflow 2023-05-11 00:39:33 +02:00
DrMint ebd3f75804 Added horizontal support for transcript tool 2023-05-07 14:44:12 +02:00
DrMint c69b4478f7 Display up to depth-7 parent folders 2023-05-03 23:03:55 +02:00
DrMint 5949c8fb8b Update reader when ranged content upgraded 2023-05-03 16:57:44 +02:00
DrMint 6a33cfa15a Reverted upgrade of marked dep 2023-05-03 04:45:20 +02:00
DrMint c03e92a354 Updated deps 2023-05-03 04:37:39 +02:00
DrMint b9d10f4670 Support for multiple previous/follow-up contents 2023-05-03 04:31:34 +02:00
DrMint e1e107078e Make sure the icons can't be selected 2023-05-03 03:50:45 +02:00
DrMint 3671264984 Added blockquote with source in editor 2023-05-03 03:50:23 +02:00
DrMint a52cb1fe54 Use react-collapsible for chronicles 2023-05-03 03:49:14 +02:00
DrMint bf6bf2e8a8 Tooltip explaining the player's name setting 2023-05-02 23:57:38 +02:00
DrMint b9c7c0828a Properly unload popups when not displayed 2023-05-02 22:46:37 +02:00
DrMint 4f78b4f006 Updated deps 2023-04-30 13:24:26 +02:00
DrMint 9e5ad41e5c Fixed sequential contents in the wrong order 2023-04-30 13:19:35 +02:00
DrMint ca12dc2c29 Image OCR in transcript tool + side by side 2023-04-30 11:56:02 +02:00
DrMint 0c1f252641 Better NCU config inside ncurc 2023-04-30 11:53:25 +02:00
DrMint 6cc6635988 Fixed revalidation 2023-04-30 11:50:44 +02:00
DrMint 0f6339c0f8 Transcript tool persistance + better font 2023-04-27 23:50:16 +02:00
DrMint 2deea6184e Updated deps 2023-04-24 09:32:12 +02:00
DrMint cf3837094e Updated code to use new Umami tracking function 2023-04-24 09:17:22 +02:00
DrMint d19b815275 Patching next13.3.0 2023-04-09 16:32:17 +02:00
DrMint 5be25c656f Updated meilisearch 2023-04-09 09:59:43 +02:00
DrMint 0f735c62cc Updating deps 2023-04-08 17:30:08 +02:00
DrMint d68e238b00 Improve editor and no longer crash if markdawn Line has bad parameters 2023-04-08 16:34:27 +02:00
DrMint b6882cd1e5 Typescript updated to 5.0, removed pesky as const 2023-03-18 23:43:55 +01:00
DrMint bfb753bf21 Update deps 2023-03-18 22:45:46 +01:00
DrMint 113c6566d9 Fixed wiki pages with a body 2023-03-18 22:11:36 +01:00
DrMint e39eb316de Updated deps + minor fix for seperator in content pages 2023-03-05 13:22:36 +01:00
DrMint 7eb7495537 Only display client logging on the browser 2023-02-22 06:45:22 +01:00
DrMint 75de7c5f2a Removed now unused SmartList component 2023-02-22 06:23:39 +01:00
DrMint 5677fb180f Use nicer logger to prefix console logs 2023-02-22 06:23:18 +01:00
DrMint 5b042a77e2 Added missing embellishment to components 2023-02-22 06:21:20 +01:00
DrMint 88a67e4e85 Small fixes 2023-02-22 06:19:29 +01:00
DrMint 0420dc30a4 Add revalidation for weapon stories and groups 2023-02-22 06:12:58 +01:00
DrMint a0706fd52f Dev Editor now save state in localstorage 2023-02-22 06:12:17 +01:00
DrMint ffe7e119e0 Updated deps 2023-02-22 05:35:33 +01:00
DrMint 1fe5952566 Decreased shadow strength 2023-02-22 05:35:18 +01:00
DrMint 7aeb85e4f9 Weapon stories 2023-02-16 09:19:44 +01:00
DrMint df8a7f820d Fix subpanel closing on mobile+ improvements 2023-02-10 13:17:45 +01:00
DrMint fe52ded606 Updated deps 2023-02-08 23:17:05 +01:00
DrMint 8c98f0796b Fixed dark screen when using dark reader extension 2023-02-08 22:42:50 +01:00
DrMint e3e67b8dbc
Merge pull request #102 from Accords-Library/icu-i18n
Icu i18n
2023-01-31 23:41:21 +01:00
DrMint 00da77d785 updated README 2023-01-31 23:36:37 +01:00
DrMint d888588a07 Also included server side ICU parsing 2023-01-30 21:58:59 +01:00
DrMint be1ea95b71 Wordings can now use ICU format 2023-01-30 18:29:47 +01:00
DrMint 77e25c9056 Fixed reader bug and updated deps 2023-01-25 18:50:11 +01:00
DrMint dd3beff508
Added meilisearch (#89)
* Added search on most pages

* Changed material icons to symbols and added wikipage search

* Updated deps

* Changed color of the play button on previewcard overlay

* Updated search params

* Updated deps

* Audit fix

* Removed unused graphql files
2023-01-07 01:59:54 +01:00
DrMint 0ddd46643b Moved assert helpers into their own folder + activated some tsconfig strict options 2022-12-09 23:03:09 +01:00
DrMint e9950602c4 Some texts were using normal markdown and not markdawn 2022-12-05 22:09:21 +01:00
DrMint 6abff354ee Use Next/Link 2022-12-05 22:01:46 +01:00
DrMint 35fdc7af14 Removed one more useMemo 2022-12-05 03:47:21 +01:00
DrMint b5b2dd07ee Updated deps 2022-12-05 03:28:33 +01:00
DrMint 4a71f897a8 Revalidate videos 2022-12-04 19:41:38 +01:00
DrMint 6a1be38613 Put an end to my useMemo craze + fixed ios 2022-12-04 15:31:11 +01:00
DrMint c356679813 Next13 + updated deps + fixed revalidation 2022-11-13 00:35:13 +01:00
182 changed files with 26439 additions and 13792 deletions

44
.env.example Executable file
View File

@ -0,0 +1,44 @@
# /!\ 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,11 +1,12 @@
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 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", "data", "err", "e", "cb", "callback", "i"], "id-denylist": ["error", "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],
@ -161,7 +161,6 @@ module.exports = {
"@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",
@ -182,7 +181,6 @@ 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 },
@ -192,7 +190,6 @@ 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",

2
.gitignore vendored
View File

@ -1,6 +1,8 @@
# Generated content # Generated content
src/graphql/generated.ts src/graphql/generated.ts
public/robots.txt
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp

5
.ncurc.yml Normal file
View File

@ -0,0 +1,5 @@
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 +1,2 @@
.next .next
public/local-data/*

5
.vscode/settings.json vendored Executable file
View File

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

133
README.md
View File

@ -4,69 +4,127 @@
[![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
#### [Back](https://github.com/Accords-Library/strapi.accords-library.com) ### Overview
- CMS: Stapi ![](docs/project-mind-map.png)
- GraphQL endpoint
_Purple connections are actions done at build-time only. Grey connections can be at build-time or run-time._
### [strapi.accords-library.com](https://github.com/Accords-Library/strapi.accords-library.com)
Our Content Management System (CMS) that uses [Strapi](https://strapi.io/).
- Use the official [GraphQL plugin](https://market.strapi.io/plugins/@strapi-plugin-graphql)
- Multilanguage support - Multilanguage support
- Markdown format for the rich text fields - Markdown format for the rich text fields
- Use webhooks to notify the front-end and image processor of updates - Use webhooks to notify the front-end, search engine, and image processor when new content/media has been created/modified/deleted
#### [Image Processor](https://github.com/Accords-Library/img.accords-library.com) ### [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:
- Convert the images from the CMS to 4 formats
- Small: 512x512, quality 60, .webp - Small: 512x512, quality 60, .webp
- Medium: 1024x1024, quality 75, .webp - Medium: 1024x1024, quality 75, .webp
- Large: 2048x2048, quality 80, .webp - Large: 2048x2048, quality 80, .webp
- Og: 512x512, quality 60, .jpg - Og: 512x512, quality 60, .jpg
#### [Front](https://github.com/Accords-Library/accords-library.com) (this repository) ### [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 12](https://nextjs.org/) (React 18)
- 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)
- Support for arbitrary React Components and Component Props!
- Autogenerated multi-level table of content and anchor links for the different headers
- Styling: [Tailwind CSS](https://tailwindcss.com/) - Styling: [Tailwind CSS](https://tailwindcss.com/)
- Support for [Material Icons](https://fonts.google.com/icons)
- Support for creating any arbitrary theming mode by swapping CSS variables - Support for creating any arbitrary theme by swapping CSS variables
- Support for Container Queries (media queries at the element level) - 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. - 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) - Check out our [Design System Showcase](https://accords-library.com/dev/showcase/design-system)
- State Management: [Jōtai](https://jotai.org/) - State Management: [Jōtai](https://jotai.org/)
- Jōtai is a small-weighted library for atomic state management - Jōtai is a small-weighted library for atomic state management
- Persistent app state using LocalStorage and SessionStorage - 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
- 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-hotkeys-hook](https://www.npmjs.com/package/react-hotkeys-hook)
- 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
- Fallback languages are used for content which are not available in 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.
- Main and fallback languages can be ordered manually by the user - 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.
- At the content level, the user can know which language is available - 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.
- Furthermore, the user can temporary select another language then the one that was automatically selected
- SSG + ISR (Static Site Generation + Incremental Static Regeneration) - UI Localizations
- The website is built before running in production
- Performances are great, and possibility to deploy the app on a CDN - The translated wordings use [ICU Message Format](https://unicode-org.github.io/icu/userguide/format_parse/messages/) to include variables, plural, dates...
- On-Demand ISR to continuously update the website when new content is added or existing content is modified/deleted - Use a custom ICU Typescript transformation script to provide type safety when formatting ICU wordings
- UI localizations are downloaded separetely into the `public/local-data` to avoid fetching the same static props for every page. - Fallback to English if the translation is missing.
- SEO - SEO
- Good defaults for the metadata and OpenGraph properties - Good defaults for the metadata and OpenGraph properties
- Each page can provide a custom 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
- Code Quality and Style
- React Strict Mode - 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) - [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)
@ -74,8 +132,12 @@
- [ts-unused-exports](https://www.npmjs.com/package/ts-unused-exports) to find unused exported functions/constants... - [ts-unused-exports](https://www.npmjs.com/package/ts-unused-exports) to find unused exported functions/constants...
- Other - Other
- Custom book reader based on [Okuma-Reader](https://github.com/DrMint/Okuma-Reader) - 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) - 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
@ -85,29 +147,14 @@ cd accords-library.com
npm install npm install
``` ```
Create a env file: Create a env file based on the example one:
```bash ```bash
cp .env.example .env.local
nano .env.local nano .env.local
``` ```
Enter the followind information: Change the variables
```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:

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: 156, g: 102, b: 68 }, shade: { r: 192, g: 132, b: 94 },
black: { r: 27, g: 24, b: 17 }, black: { r: 27, g: 24, b: 17 },
}, },
dark: { dark: {
@ -12,7 +12,7 @@ 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: 0, g: 0, b: 0 }, shade: { r: 25, g: 25, b: 20 },
black: { r: 235, g: 234, b: 231 }, black: { r: 235, g: 234, b: 231 },
}, },
}; };

View File

@ -0,0 +1,134 @@
<?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>

BIN
docs/project-mind-map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -1,12 +1,11 @@
/* CONFIG */ /* CONFIG */
const locales = ["en", "es", "fr", "pt-br", "ja"]; const locales = ["en", "es", "fr", "pt-br", "ja", "zh"];
/* END CONFIG */ /* END CONFIG */
/* @type {import('next').NextConfig} */ /* @type {import('next').NextConfig} */
module.exports = { module.exports = {
swcMinify: true,
reactStrictMode: true, reactStrictMode: true,
poweredByHeader: false, poweredByHeader: false,
i18n: { i18n: {

12240
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,14 @@
"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 prettier && npm run unused-exports && npm run eslint && npm run tsc && echo ALL PRECOMMIT CHECKS PASSED SUCCESSFULLY, LET\\'S FUCKING GO!", "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!",
"unused-exports": "ts-unused-exports ./tsconfig.json --excludePathsFromReport=src/pages --ignoreFiles=generated", "unused-wording-keys": "esrun --send-code-mode=temporaryFile src/graphql/unusedWordingKeys.ts --uwk",
"fetch-local-data": "npm run generate && esrun src/graphql/fetchLocalData.ts", "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 fetch-local-data", "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",
@ -14,68 +17,75 @@
"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 --end-of-line auto --write ." "prettier": "prettier --list-different --end-of-line auto --write .",
"upgrade": "ncu"
}, },
"dependencies": { "dependencies": {
"@fontsource/material-icons": "^4.5.4", "@fontsource/noto-serif-jp": "^5.0.7",
"@fontsource/material-icons-outlined": "^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.12", "@fontsource/zen-maru-gothic": "^5.0.7",
"@fontsource/zen-maru-gothic": "^4.5.13", "@formatjs/icu-messageformat-parser": "^2.6.0",
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.15",
"cuid": "^2.1.8", "cuid": "^2.1.8",
"graphql-request": "^5.0.0", "html-to-text": "^9.0.5",
"jotai": "^1.9.0", "intl-messageformat": "^10.5.0",
"markdown-to-jsx": "^7.1.7", "isomorphic-dompurify": "^1.8.0",
"next": "^12.3.1", "jotai": "^2.3.1",
"nodemailer": "^6.8.0", "markdown-to-jsx": "^7.3.2",
"rc-slider": "^10.0.1", "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-hotkeys-hook": "^3.4.7",
"react-swipeable": "^7.0.0", "react-swipeable": "^7.0.1",
"react-zoom-pan-pinch": "^2.1.3", "react-zoom-pan-pinch": "^3.1.0",
"string-natural-compare": "^3.0.1", "string-natural-compare": "^3.0.1",
"throttle-debounce": "^5.0.0", "throttle-debounce": "^5.0.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.1.1", "turndown": "^7.1.2",
"ua-parser-js": "^1.0.32", "ua-parser-js": "^1.0.35",
"usehooks-ts": "^2.9.1" "usehooks-ts": "^2.9.1",
"zod": "^3.22.1"
}, },
"devDependencies": { "devDependencies": {
"@digitak/esrun": "^3.2.14", "@digitak/esrun": "3.2.24",
"@graphql-codegen/cli": "^2.13.8", "@graphql-codegen/cli": "5.0.0",
"@graphql-codegen/typescript": "2.8.0", "@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-graphql-request": "^4.5.7", "@graphql-codegen/typescript-graphql-request": "5.0.0",
"@graphql-codegen/typescript-operations": "^2.5.5", "@graphql-codegen/typescript-operations": "4.0.1",
"@types/node": "18.11.7", "@types/html-to-text": "^9.0.1",
"@types/nodemailer": "^6.4.6", "@types/marked": "^5.0.1",
"@types/react": "18.0.22", "@types/node": "20.5.0",
"@types/react-dom": "^18.0.8", "@types/nodemailer": "^6.4.9",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/string-natural-compare": "^3.0.2", "@types/string-natural-compare": "^3.0.2",
"@types/throttle-debounce": "^5.0.0", "@types/throttle-debounce": "^5.0.0",
"@types/turndown": "^5.0.1", "@types/turndown": "^5.0.1",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.41.0", "@typescript-eslint/eslint-plugin": "^6.4.0",
"@typescript-eslint/parser": "^5.41.0", "@typescript-eslint/parser": "^6.4.0",
"dotenv": "^16.0.3", "chalk": "^5.3.0",
"eslint": "^8.26.0", "dotenv": "^16.3.1",
"eslint-config-next": "12.3.1", "eslint": "^8.47.0",
"eslint-plugin-import": "^2.26.0", "eslint-config-next": "13.4.17",
"graphql": "^16.6.0", "eslint-plugin-import": "^2.28.0",
"next-sitemap": "^3.1.29", "graphql": "16.8.0",
"prettier": "^2.7.1", "graphql-request": "6.1.0",
"prettier-plugin-tailwindcss": "^0.1.13", "next-sitemap": "^4.2.2",
"tailwindcss": "^3.2.1", "prettier": "^3.0.2",
"ts-unused-exports": "^8.0.0", "prettier-plugin-tailwindcss": "^0.5.3",
"typescript": "^4.8.4" "tailwindcss": "^3.3.3",
}, "ts-unused-exports": "^10.0.0",
"overrides": { "typescript": "^5.1.6"
"react-zoom-pan-pinch": {
"react": "$react",
"react-dom": "$react-dom"
}
} }
} }

0
patches/.gitkeep Normal file
View File

Binary file not shown.

View File

@ -1 +1 @@
{"currencies":{"data":[{"id":"1","attributes":{"code":"EUR","symbol":"€","rate_to_usd":1.1062771,"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}}]}} {"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 +1 @@
{"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 (Traditional)","code":"zh-cht","localized_name":"中文(繁體)"}}]}} {"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

View File

@ -1,8 +1,7 @@
import { Ico, Icon } from "./Ico"; import { Ico } from "./Ico";
import { ToolTip } from "./ToolTip"; import { ToolTip } from "./ToolTip";
import { cJoin } from "helpers/className"; import { cJoin } from "helpers/className";
import { useAtomGetter } from "helpers/atoms"; import { useFormat } from "hooks/useFormat";
import { atoms } from "contexts/atoms";
/* /*
* *
@ -17,13 +16,13 @@ interface Props {
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const AnchorShare = ({ id, className }: Props): JSX.Element => { export const AnchorShare = ({ id, className }: Props): JSX.Element => {
const langui = useAtomGetter(atoms.localData.langui); const { format } = useFormat();
return ( return (
<ToolTip content={langui.copy_anchor_link} trigger="mouseenter" className="text-sm"> <ToolTip content={format("copy_anchor_link")} trigger="mouseenter" className="text-sm">
<ToolTip content={langui.anchor_link_copied} trigger="click" className="text-sm"> <ToolTip content={format("anchor_link_copied")} trigger="click" className="text-sm">
<Ico <Ico
icon={Icon.Link} icon="link"
className={cJoin("transition-color cursor-pointer hover:text-dark", className)} className={cJoin("cursor-pointer transition-colors hover:text-dark", className)}
onClick={() => { onClick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_URL_SELF + window.location.pathname}#${id}` `${process.env.NEXT_PUBLIC_URL_SELF + window.location.pathname}#${id}`

View File

@ -1,16 +1,18 @@
import Head from "next/head"; import Head from "next/head";
import { useMemo } from "react";
import { useSwipeable } from "react-swipeable"; import { useSwipeable } from "react-swipeable";
import { MaterialSymbol } from "material-symbols";
import { atom } from "jotai";
import { useRouter } from "next/router";
import { layout } from "../../design.config"; import { layout } from "../../design.config";
import { Ico, Icon } from "./Ico"; import { Ico } from "./Ico";
import { MainPanel } from "./Panels/MainPanel"; import { MainPanel } from "./Panels/MainPanel";
import { SafariPopup } from "./Panels/SafariPopup"; import { isDefined, isUndefined } from "helpers/asserts";
import { isDefined, isUndefined } from "helpers/others";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { OpenGraph, TITLE_PREFIX, TITLE_SEPARATOR } from "helpers/openGraph"; import { OpenGraph, TITLE_PREFIX, TITLE_SEPARATOR } from "helpers/openGraph";
import { Ids } from "types/ids"; import { Ids } from "types/ids";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter, useAtomPair } from "helpers/atoms"; import { useAtomGetter, useAtomPair } from "helpers/atoms";
import { useFormat } from "hooks/useFormat";
/* /*
* *
@ -18,6 +20,7 @@ import { useAtomGetter, useAtomPair } from "helpers/atoms";
*/ */
const SENSIBILITY_SWIPE = 1.1; const SENSIBILITY_SWIPE = 1.1;
const isIOSAtom = atom((get) => get(atoms.userAgent.os) === "iOS");
/* /*
* *
@ -30,7 +33,7 @@ export interface AppLayoutRequired {
interface Props extends AppLayoutRequired { interface Props extends AppLayoutRequired {
subPanel?: React.ReactNode; subPanel?: React.ReactNode;
subPanelIcon?: Icon; subPanelIcon?: MaterialSymbol;
contentPanel?: React.ReactNode; contentPanel?: React.ReactNode;
contentPanelScroolbar?: boolean; contentPanelScroolbar?: boolean;
} }
@ -41,18 +44,20 @@ export const AppLayout = ({
subPanel, subPanel,
contentPanel, contentPanel,
openGraph, openGraph,
subPanelIcon = Icon.Tune, subPanelIcon = "tune",
contentPanelScroolbar = true, contentPanelScroolbar = true,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const isMainPanelReduced = useAtomGetter(atoms.layout.mainPanelReduced); const isMainPanelReduced = useAtomGetter(atoms.layout.mainPanelReduced);
const [isSubPanelOpened, setSubPanelOpened] = useAtomPair(atoms.layout.subPanelOpened); const [isSubPanelOpened, setSubPanelOpened] = useAtomPair(atoms.layout.subPanelOpened);
const [isMainPanelOpened, setMainPanelOpened] = useAtomPair(atoms.layout.mainPanelOpened); const [isMainPanelOpened, setMainPanelOpened] = useAtomPair(atoms.layout.mainPanelOpened);
const isMenuGesturesEnabled = useAtomGetter(atoms.layout.menuGesturesEnabled); const isMenuGesturesEnabled = useAtomGetter(atoms.layout.menuGesturesEnabled);
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
const langui = useAtomGetter(atoms.localData.langui);
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout); const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const isScreenAtLeastXs = useAtomGetter(atoms.containerQueries.isScreenAtLeastXs); const isScreenAtLeastXs = useAtomGetter(atoms.containerQueries.isScreenAtLeastXs);
const isIOS = useAtomGetter(isIOSAtom);
const router = useRouter();
const { format } = useFormat();
const handlers = useSwipeable({ const handlers = useSwipeable({
onSwipedLeft: (SwipeEventData) => { onSwipedLeft: (SwipeEventData) => {
@ -77,18 +82,19 @@ export const AppLayout = ({
}, },
}); });
const turnSubIntoContent = useMemo( const turnSubIntoContent = isDefined(subPanel) && isUndefined(contentPanel) && is1ColumnLayout;
() => isDefined(subPanel) && isUndefined(contentPanel),
[contentPanel, subPanel]
);
return ( return (
<div <div
{...handlers} {...handlers}
id={Ids.Body} id={Ids.Body}
className={cJoin( className={cJoin(
"fixed inset-0 m-0 grid touch-pan-y bg-light p-0 [grid-template-areas:'main_sub_content']", "fixed inset-0 m-0 grid touch-pan-y bg-light p-0",
cIf(is1ColumnLayout, "grid-rows-[1fr_5rem] [grid-template-areas:'content''navbar']") cIf(
is1ColumnLayout,
"grid-rows-[1fr_5rem] [grid-template-areas:'content''navbar']",
"[grid-template-areas:'main_sub_content']"
)
)} )}
style={{ style={{
gridTemplateColumns: is1ColumnLayout gridTemplateColumns: is1ColumnLayout
@ -101,29 +107,69 @@ export const AppLayout = ({
<title>{openGraph.title}</title> <title>{openGraph.title}</title>
<meta name="description" content={openGraph.description} /> <meta name="description" content={openGraph.description} />
<meta name="twitter:site" content="@AccordsLibrary" />
<meta name="twitter:title" content={openGraph.title} /> <meta name="twitter:title" content={openGraph.title} />
<meta name="twitter:description" content={openGraph.description} /> <meta name="twitter:description" content={openGraph.description} />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={openGraph.thumbnail.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:title" content={openGraph.title} />
<meta property="og:description" content={openGraph.description} /> <meta property="og:description" content={openGraph.description} />
<meta property="og:image" content={openGraph.thumbnail.image} /> <meta property="og:image" content={openGraph.thumbnail.image} />
<meta property="og:image:secure_url" 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:width" content={openGraph.thumbnail.width.toString()} />
<meta property="og:image:height" content={openGraph.thumbnail.height.toString()} /> <meta property="og:image:height" content={openGraph.thumbnail.height.toString()} />
<meta property="og:image:alt" content={openGraph.thumbnail.alt} /> <meta property="og:image:alt" content={openGraph.thumbnail.alt} />
<meta property="og:image:type" content="image/jpeg" /> <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> </Head>
{/* Content panel */}
<div
id={Ids.ContentPanel}
className={cJoin(
"bg-light [grid-area:content]",
cIf(!isIOS, "texture-paper-dots"),
cIf(contentPanelScroolbar, "overflow-y-scroll")
)}>
{isDefined(contentPanel) ? (
contentPanel
) : turnSubIntoContent ? (
subPanel
) : (
<ContentPlaceholder message={format("select_option_sidebar")} icon={"chevron_left"} />
)}
</div>
{/* Background when navbar is opened */} {/* Background when navbar is opened */}
<div <div
className={cJoin( className={cJoin(
`absolute inset-0 transition-filter duration-500 `absolute inset-0 z-40 transition-filter duration-500
[grid-area:content]`, [grid-area:content]`,
cIf( cIf(
(isMainPanelOpened || isSubPanelOpened) && is1ColumnLayout, (isMainPanelOpened || isSubPanelOpened) && is1ColumnLayout,
"z-10 backdrop-blur", cIf(!isPerfModeEnabled, "backdrop-blur"),
"pointer-events-none touch-none" "pointer-events-none touch-none"
) )
)}> )}>
@ -143,64 +189,16 @@ export const AppLayout = ({
/> />
</div> </div>
{/* Content panel */}
<div
id={Ids.ContentPanel}
className={cJoin(
"bg-light texture-paper-dots [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
id={Ids.SubPanel}
className={cJoin(
`z-20 overflow-y-scroll border-r border-dark/50 bg-light
transition-transform duration-300 scrollbar-none texture-paper-dots`,
cIf(
is1ColumnLayout,
"justify-self-end border-r-0 [grid-area:content]",
"[grid-area:sub]"
),
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)] border-l"),
cIf(is1ColumnLayout && !isSubPanelOpened && !turnSubIntoContent, "translate-x-[100vw]"),
cIf(is1ColumnLayout && turnSubIntoContent, "w-full border-l-0")
)}>
{subPanel}
</div>
)}
{/* Main panel */}
<div
className={cJoin(
`z-30 overflow-y-scroll border-r border-dark/50 bg-light
transition-transform duration-300 scrollbar-none texture-paper-dots`,
cIf(is1ColumnLayout, "justify-self-start [grid-area:content]", "[grid-area:main]"),
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)]"),
cIf(!isMainPanelOpened && is1ColumnLayout, "-translate-x-full")
)}>
<MainPanel />
</div>
{/* Navbar */} {/* Navbar */}
<div <div
className={cJoin( className={cJoin(
`z-10 grid grid-cols-[5rem_1fr_5rem] place-items-center border-t `z-40 grid grid-cols-[5rem_1fr_5rem] place-items-center border-t
border-dotted border-black bg-light texture-paper-dots [grid-area:navbar]`, border-dotted border-black bg-light [grid-area:navbar]`,
cIf(!isIOS, "texture-paper-dots"),
cIf(!is1ColumnLayout, "hidden") cIf(!is1ColumnLayout, "hidden")
)}> )}>
<Ico <Ico
icon={isMainPanelOpened ? Icon.Close : Icon.Menu} icon={isMainPanelOpened ? "close" : "menu"}
className="cursor-pointer !text-2xl" className="cursor-pointer !text-2xl"
onClick={() => { onClick={() => {
setMainPanelOpened((current) => !current); setMainPanelOpened((current) => !current);
@ -218,7 +216,7 @@ export const AppLayout = ({
</p> </p>
{isDefined(subPanel) && !turnSubIntoContent && ( {isDefined(subPanel) && !turnSubIntoContent && (
<Ico <Ico
icon={isSubPanelOpened ? Icon.Close : subPanelIcon} icon={isSubPanelOpened ? "close" : subPanelIcon}
className="cursor-pointer !text-2xl" className="cursor-pointer !text-2xl"
onClick={() => { onClick={() => {
setSubPanelOpened((current) => !current); setSubPanelOpened((current) => !current);
@ -227,16 +225,51 @@ export const AppLayout = ({
/> />
)} )}
</div> </div>
<SafariPopup />
{/* Sub panel */}
{isDefined(subPanel) && !turnSubIntoContent && (
<div
id={Ids.SubPanel}
className={cJoin(
`overflow-y-scroll border-r border-dark/50 bg-light
transition-transform duration-300 scrollbar-none`,
cIf(!isIOS, "texture-paper-dots"),
cIf(
is1ColumnLayout,
"z-40 justify-self-end border-r-0 [grid-area:content]",
"[grid-area:sub]"
),
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)] border-l"),
cIf(is1ColumnLayout && !isSubPanelOpened, "translate-x-[100vw]")
)}>
{subPanel}
</div>
)}
{/* Main panel */}
<div
className={cJoin(
`overflow-y-scroll border-r border-dark/50 bg-light
transition-transform duration-300 scrollbar-none`,
cIf(!isIOS, "texture-paper-dots"),
cIf(is1ColumnLayout, "z-40 justify-self-start [grid-area:content]", "[grid-area:main]"),
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)]"),
cIf(!isMainPanelOpened && is1ColumnLayout, "-translate-x-full")
)}>
<MainPanel />
</div>
</div> </div>
); );
}; };
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ /*
*
* PRIVATE COMPONENTS
*/
interface ContentPlaceholderProps { interface ContentPlaceholderProps {
message: string; message: string;
icon?: Icon; icon?: MaterialSymbol;
} }
const ContentPlaceholder = ({ message, icon }: ContentPlaceholderProps): JSX.Element => ( const ContentPlaceholder = ({ message, icon }: ContentPlaceholderProps): JSX.Element => (
@ -245,7 +278,7 @@ const ContentPlaceholder = ({ message, icon }: ContentPlaceholderProps): JSX.Ele
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
border-dark p-8 text-dark opacity-40"> border-dark p-8 text-dark opacity-40">
{isDefined(icon) && <Ico icon={icon} className="!text-[300%]" />} {isDefined(icon) && <Ico icon={icon} className="!text-[300%]" />}
<p className={cJoin("w-64 text-2xl", cIf(!isDefined(icon), "text-center"))}>{message}</p> <p className={cJoin("w-64 text-2xl", cIf(isUndefined(icon), "text-center"))}>{message}</p>
</div> </div>
</div> </div>
); );

View File

@ -16,8 +16,7 @@ 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
px-1.5 pb-[0.14rem] text-xs opacity-70 transition-[color,opacity,border-color] border-black/70 px-1.5 pb-[0.14rem] text-xs text-black/70`,
hover:opacity-100`,
className className
)}> )}>
{text} {text}

View File

@ -1,9 +1,9 @@
import { useCallback } from "react"; import { MouseEventHandler, useCallback } from "react";
import { DatePickerFragment } from "graphql/generated"; import { DatePickerFragment } from "graphql/generated";
import { TranslatedProps } from "types/TranslatedProps"; import { TranslatedProps } from "types/TranslatedProps";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
import { DownPressable } from "components/Containers/DownPressable"; import { DownPressable } from "components/Containers/DownPressable";
import { isDefined } from "helpers/others"; import { isDefined } from "helpers/asserts";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
/* /*
@ -17,12 +17,23 @@ interface Props {
url: string; url: string;
active?: boolean; active?: boolean;
disabled?: boolean; disabled?: boolean;
onClick?: MouseEventHandler<HTMLAnchorElement>;
} }
export const ChroniclePreview = ({ date, url, title, active, disabled }: Props): JSX.Element => ( // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const ChroniclePreview = ({
date,
url,
title,
active,
disabled,
onClick,
}: Props): JSX.Element => (
<DownPressable <DownPressable
className="flex w-full gap-4 py-4 px-5" className="flex w-full gap-4 px-5 py-4"
href={url} href={url}
onClick={onClick}
active={active} active={active}
border border
disabled={disabled}> disabled={disabled}>

View File

@ -1,13 +1,15 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useBoolean } from "usehooks-ts"; import Collapsible from "react-collapsible";
import { TranslatedChroniclePreview } from "./ChroniclePreview"; import { TranslatedChroniclePreview } from "./ChroniclePreview";
import { GetChroniclesChaptersQuery } from "graphql/generated"; import { GetChroniclesChaptersQuery } from "graphql/generated";
import { filterHasAttributes } from "helpers/others"; import { filterHasAttributes } from "helpers/asserts";
import { prettyInlineTitle, prettySlug, sJoin } from "helpers/formatters"; import { prettyInlineTitle, prettySlug, sJoin } from "helpers/formatters";
import { Ico, Icon } from "components/Ico";
import { compareDate } from "helpers/date"; import { compareDate } from "helpers/date";
import { TranslatedProps } from "types/TranslatedProps"; import { TranslatedProps } from "types/TranslatedProps";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
import { useAtomSetter } from "helpers/atoms";
import { atoms } from "contexts/atoms";
import { Button } from "components/Inputs/Button";
/* /*
* *
@ -22,28 +24,42 @@ interface Props {
>["data"]; >["data"];
currentSlug?: string; currentSlug?: string;
title: string; title: string;
open?: boolean;
onTriggerClosing?: () => void;
onOpening?: () => void;
} }
const ChroniclesList = ({ chronicles, currentSlug, title }: Props): JSX.Element => { // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
const { value: isOpen, toggle: toggleOpen } = useBoolean(
chronicles.some((chronicle) => chronicle.attributes?.slug === currentSlug) const ChroniclesList = ({
); chronicles,
currentSlug,
title,
open,
onTriggerClosing,
onOpening,
}: Props): JSX.Element => {
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
return ( return (
<div> <div>
<div className="grid place-content-center"> <Collapsible
<div className="grid cursor-pointer grid-cols-[1em_1fr] gap-4" onClick={toggleOpen}> open={open}
<Ico className="!text-xl" icon={isOpen ? Icon.ArrowDropUp : Icon.ArrowDropDown} /> accordionPosition={title}
<p className="mb-4 font-headers text-xl">{title}</p> contentInnerClassName="grid gap-4 pt-4"
onTriggerClosing={onTriggerClosing}
onOpening={onOpening}
easing="ease-in-out"
transitionTime={400}
lazyRender
contentHiddenWhenClosed
trigger={
<div className="flex place-content-center place-items-center gap-4">
<h2 className="text-center text-xl">{title}</h2>
<Button icon={open ? "expand_less" : "expand_more"} active={open} size="small" />
</div> </div>
</div> }>
<div {filterHasAttributes(chronicles, ["attributes.contents", "attributes.translations"])
className="grid gap-4 overflow-hidden transition-height duration-500"
style={{ maxHeight: isOpen ? `${8 * chronicles.length}rem` : 0 }}>
{filterHasAttributes(chronicles, [
"attributes.contents",
"attributes.translations",
] as const)
.sort((a, b) => compareDate(a.attributes.date_start, b.attributes.date_start)) .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}`}>
@ -51,14 +67,14 @@ const ChroniclesList = ({ chronicles, currentSlug, title }: Props): JSX.Element
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",
] as const).map((content, index) => ( ]).map((content, index) => (
<TranslatedChroniclePreview <TranslatedChroniclePreview
key={index} key={index}
active={chronicle.attributes.slug === currentSlug} active={chronicle.attributes.slug === currentSlug}
date={chronicle.attributes.date_start} date={chronicle.attributes.date_start}
translations={filterHasAttributes(content.attributes.translations, [ translations={filterHasAttributes(content.attributes.translations, [
"language.data.attributes.code", "language.data.attributes.code",
] as const).map((translation) => ({ ]).map((translation) => ({
title: prettyInlineTitle( title: prettyInlineTitle(
translation.pre_title, translation.pre_title,
translation.title, translation.title,
@ -75,6 +91,7 @@ const ChroniclesList = ({ chronicles, currentSlug, title }: Props): JSX.Element
"/#chronicle-", "/#chronicle-",
chronicle.attributes.slug chronicle.attributes.slug
)} )}
onClick={() => setSubPanelOpened(false)}
/> />
)) ))
: chronicle.attributes.translations.length > 0 && ( : chronicle.attributes.translations.length > 0 && (
@ -84,7 +101,7 @@ const ChroniclesList = ({ chronicles, currentSlug, title }: Props): JSX.Element
translations={filterHasAttributes(chronicle.attributes.translations, [ translations={filterHasAttributes(chronicle.attributes.translations, [
"language.data.attributes.code", "language.data.attributes.code",
"title", "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,
}))} }))}
@ -101,7 +118,7 @@ const ChroniclesList = ({ chronicles, currentSlug, title }: Props): JSX.Element
)} )}
</div> </div>
))} ))}
</div> </Collapsible>
</div> </div>
); );
}; };

View File

@ -0,0 +1,54 @@
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,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { atom } from "jotai";
import { cJoin, cIf } from "helpers/className"; import { cJoin, cIf } from "helpers/className";
import { isDefined, isDefinedAndNotEmpty } from "helpers/others"; import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomSetter, useAtomPair } from "helpers/atoms"; import { useAtomSetter, useAtomPair, atomPairing } from "helpers/atoms";
/* /*
* *
@ -12,6 +13,9 @@ import { useAtomSetter, useAtomPair } from "helpers/atoms";
const LINE_PREFIX = "root@accords-library.com:"; const LINE_PREFIX = "root@accords-library.com:";
const previousLinesAtom = atomPairing(atom<string[]>([]));
const previousCommandsAtom = atomPairing(atom<string[]>([]));
/* /*
* *
* COMPONENT * COMPONENT
@ -33,8 +37,8 @@ export const Terminal = ({
const [childrenPaths, setChildrenPaths] = useState(propsChildrenPaths); const [childrenPaths, setChildrenPaths] = useState(propsChildrenPaths);
const setPlayerName = useAtomSetter(atoms.settings.playerName); const setPlayerName = useAtomSetter(atoms.settings.playerName);
const [previousCommands, setPreviousCommands] = useAtomPair(atoms.terminal.previousCommands); const [previousCommands, setPreviousCommands] = useAtomPair(previousCommandsAtom);
const [previousLines, setPreviousLines] = useAtomPair(atoms.terminal.previousLines); const [previousLines, setPreviousLines] = useAtomPair(previousLinesAtom);
const [line, setLine] = useState(""); const [line, setLine] = useState("");
const [displayCurrentLine, setDisplayCurrentLine] = useState(true); const [displayCurrentLine, setDisplayCurrentLine] = useState(true);
@ -112,7 +116,6 @@ export const Terminal = ({
key: "rm", key: "rm",
description: "Remove files or directories", description: "Remove files or directories",
handle: (currentLine, parameters) => { handle: (currentLine, parameters) => {
console.log(parameters);
if (parameters.startsWith("-r ")) { if (parameters.startsWith("-r ")) {
const folder = parameters.slice("-r ".length); const folder = parameters.slice("-r ".length);
if (childrenPaths.includes(folder)) { if (childrenPaths.includes(folder)) {
@ -246,7 +249,7 @@ export const Terminal = ({
<div className="relative"> <div className="relative">
<textarea <textarea
className="absolute -top-1 -left-6 -right-6 w-screen rounded-none opacity-0" className="absolute -left-6 -right-6 -top-1 w-screen rounded-none opacity-0"
spellCheck={false} spellCheck={false}
autoCapitalize="none" autoCapitalize="none"
autoCorrect="off" autoCorrect="off"

View File

@ -1,14 +1,21 @@
import { cJoin } from "helpers/className"; import { cJoin } from "helpers/className";
/*
*
* COMPONENT
*/
interface Props { interface Props {
src: string; src: string;
className?: string; className?: string;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const ColoredSvg = ({ src, className }: Props): JSX.Element => ( export const ColoredSvg = ({ src, className }: Props): JSX.Element => (
<div <div
className={cJoin( className={cJoin(
`transition-colors ![mask-size:contain] ![mask-repeat:no-repeat] ![mask-position:center]`, `transition-colors ![mask-position:center] ![mask-repeat:no-repeat] ![mask-size:contain]`,
className className
)} )}
style={{ mask: `url('${src}')`, WebkitMask: `url('${src}')` }} style={{ mask: `url('${src}')`, WebkitMask: `url('${src}')` }}

View File

@ -19,6 +19,12 @@ 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 = ({
@ -31,13 +37,9 @@ export const ContentPanel = ({
<div className="grid h-full"> <div className="grid h-full">
<main <main
className={cJoin( className={cJoin(
"relative justify-self-center px-4 pt-10 pb-20", "relative justify-self-center",
cIf(isContentPanelAtLeast3xl, "px-10 pt-20 pb-32"), cIf(isContentPanelAtLeast3xl, "px-10 pb-32 pt-20", "px-4 pb-20 pt-10"),
width === ContentPanelWidthSizes.Default contentPanelWidthSizesToClassName[width],
? "max-w-2xl"
: width === ContentPanelWidthSizes.Large
? "max-w-4xl"
: "w-full",
className className
)}> )}>
{children} {children}

View File

@ -14,7 +14,7 @@ interface Props {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
onFocusChanged?: (isFocused: boolean) => void; onFocusChanged?: (isFocused: boolean) => void;
onClick?: MouseEventHandler<HTMLDivElement>; onClick?: MouseEventHandler<HTMLAnchorElement>;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─

View File

@ -0,0 +1,79 @@
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,8 +1,9 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook"; import { useHotkeys } from "react-hotkeys-hook";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomSetter } from "helpers/atoms"; import { useAtomGetter, useAtomSetter } from "helpers/atoms";
import { Button } from "components/Inputs/Button";
/* /*
* *
@ -10,54 +11,90 @@ import { useAtomSetter } from "helpers/atoms";
*/ */
interface Props { interface Props {
onOpen?: () => void;
onCloseRequest?: () => void; onCloseRequest?: () => void;
isVisible: boolean; isVisible: boolean;
children: React.ReactNode; children: React.ReactNode;
fillViewport?: boolean; fillViewport?: boolean;
hideBackground?: boolean;
padding?: boolean; padding?: boolean;
withCloseButton?: boolean;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Popup = ({ export const Popup = ({
onOpen,
onCloseRequest, onCloseRequest,
isVisible, isVisible,
children, children,
fillViewport, fillViewport,
hideBackground = false,
padding = true, padding = true,
withCloseButton = true,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const setMenuGesturesEnabled = useAtomSetter(atoms.layout.menuGesturesEnabled); 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?.(), {}, [onCloseRequest]); useHotkeys("escape", () => onCloseRequest?.(), { enabled: isVisible }, [onCloseRequest]);
useEffect(() => { useEffect(() => {
setMenuGesturesEnabled(!isVisible); setMenuGesturesEnabled(!isVisible);
}, [isVisible, setMenuGesturesEnabled]); }, [isVisible, setMenuGesturesEnabled]);
return ( // 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 <div
className={cJoin( className={cJoin(
"fixed inset-0 z-50 grid place-content-center transition-filter duration-500", "fixed inset-0 z-50 grid place-content-center transition-filter duration-500",
cIf(isVisible, "backdrop-blur", "pointer-events-none touch-none") cIf(!isActuallyVisible, "pointer-events-none touch-none"),
cIf(isActuallyVisible && !isPerfModeEnabled, "backdrop-blur")
)}> )}>
<div <div
className={cJoin( className={cJoin(
"fixed inset-0 bg-shade transition-all duration-500", "fixed inset-0 transition-colors duration-500",
cIf(isVisible, "bg-opacity-50", "bg-opacity-0") cIf(isActuallyVisible, "bg-shade/50", "bg-shade/0")
)} )}
onClick={onCloseRequest} onClick={onCloseRequest}
/> />
<div <div
className={cJoin( className={cJoin(
"grid place-items-center gap-4 transition-transform", `grid place-items-center gap-4 rounded-lg bg-light shadow-2xl transition-transform
shadow-shade`,
cIf(padding, "p-10"), cIf(padding, "p-10"),
cIf(isVisible, "scale-100", "scale-0"), cIf(isActuallyVisible, "scale-100", "scale-0"),
cIf(fillViewport, "absolute inset-10", "relative max-h-[80vh] overflow-y-auto"), cIf(
cIf(!hideBackground, "rounded-lg bg-light shadow-2xl shadow-shade") 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} {children}
</div> </div>
</div> </div>

View File

@ -19,7 +19,7 @@ export const SubPanel = ({ children }: Props): JSX.Element => {
<div <div
className={cJoin( className={cJoin(
"grid gap-y-2 text-center", "grid gap-y-2 text-center",
cIf(isSubPanelAtLeastXs, "px-10 pt-10 pb-20", "p-4") cIf(isSubPanelAtLeastXs, "px-10 pb-20 pt-10", "p-4")
)}> )}>
{children} {children}
</div> </div>

View File

@ -1,6 +1,13 @@
import { useState } from "react"; import { MouseEventHandler, useState } from "react";
import { Link } from "components/Inputs/Link"; import { Link } from "components/Inputs/Link";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { useAtomGetter } from "helpers/atoms";
import { atoms } from "contexts/atoms";
/*
*
* COMPONENT
*/
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
@ -8,29 +15,38 @@ interface Props {
className?: string; className?: string;
noBackground?: boolean; noBackground?: boolean;
disabled?: boolean; disabled?: boolean;
onClick?: MouseEventHandler<HTMLAnchorElement>;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const UpPressable = ({ export const UpPressable = ({
children, children,
href, href,
className, className,
disabled = false, disabled = false,
noBackground = false, noBackground = false,
onClick,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const [isFocused, setFocused] = useState(false); const [isFocused, setFocused] = useState(false);
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
return ( return (
<Link <Link
href={href} href={href}
onFocusChanged={setFocused} onFocusChanged={setFocused}
onClick={onClick}
className={cJoin( className={cJoin(
`drop-shadow-lg transition-all duration-300 shadow-shade`, "transition-all duration-300 !shadow-shade",
cIf(isPerfModeEnabled, "shadow-lg", "drop-shadow-lg"),
cIf(!noBackground, "overflow-hidden rounded-md bg-highlight"), cIf(!noBackground, "overflow-hidden rounded-md bg-highlight"),
cIf( cIf(
disabled, disabled,
"cursor-not-allowed opacity-50 grayscale", "cursor-not-allowed opacity-50 grayscale",
cJoin( cJoin(
"cursor-pointer hover:scale-102 hover:drop-shadow-xl", "cursor-pointer hover:scale-102",
cIf(isFocused, "hover:scale-105 hover:drop-shadow-2xl hover:duration-100") cIf(isPerfModeEnabled, "hover:shadow-xl", "hover:drop-shadow-xl"),
cIf(isFocused, "hover:scale-105 hover:duration-100")
) )
), ),
className className

View File

@ -0,0 +1,60 @@
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

@ -4,12 +4,19 @@ import { TranslatedProps } from "types/TranslatedProps";
import { UpPressable } from "components/Containers/UpPressable"; import { UpPressable } from "components/Containers/UpPressable";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
/*
*
* COMPONENT
*/
interface PreviewFolderProps { interface PreviewFolderProps {
href: string; href: string;
title?: string | null; title?: string | null;
disabled?: boolean; disabled?: boolean;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const PreviewFolder = ({ href, title, disabled }: PreviewFolderProps): JSX.Element => ( export const PreviewFolder = ({ href, title, disabled }: PreviewFolderProps): JSX.Element => (
<UpPressable href={href} disabled={disabled}> <UpPressable href={href} disabled={disabled}>
<div <div
@ -23,7 +30,10 @@ export const PreviewFolder = ({ href, title, disabled }: PreviewFolderProps): JS
</UpPressable> </UpPressable>
); );
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ /*
*
* TRANSLATED VARIANT
*/
export const TranslatedPreviewFolder = ({ export const TranslatedPreviewFolder = ({
translations, translations,

126
src/components/Credits.tsx Normal file
View File

@ -0,0 +1,126 @@
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>
);

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ import { getAssetURL, getImgSizesByQuality, ImageQuality } from "helpers/img";
/* /*
* *
* CONSTANTS * COMPONENT
*/ */
interface Props interface Props

View File

@ -1,9 +1,9 @@
import React, { MouseEventHandler, useCallback } from "react"; import { MouseEventHandler, useCallback } from "react";
import { MaterialSymbol } from "material-symbols";
import { Link } from "./Link"; import { Link } from "./Link";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { ConditionalWrapper, Wrapper } from "helpers/component"; import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { isDefined, isDefinedAndNotEmpty } from "helpers/others";
import { TranslatedProps } from "types/TranslatedProps"; import { TranslatedProps } from "types/TranslatedProps";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
@ -17,15 +17,16 @@ interface Props {
className?: string; className?: string;
href?: string; href?: string;
active?: boolean; active?: boolean;
icon?: Icon; icon?: MaterialSymbol;
text?: string | null | undefined; text?: string | null | undefined;
alwaysNewTab?: boolean; alwaysNewTab?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>; onClick?: MouseEventHandler<HTMLButtonElement>;
onMouseUp?: 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,7 +36,7 @@ export const Button = ({
id, id,
onClick, onClick,
onMouseUp, onMouseUp,
active, active = false,
className, className,
icon, icon,
text, text,
@ -43,54 +44,59 @@ export const Button = ({
alwaysNewTab = false, alwaysNewTab = false,
badgeNumber, badgeNumber,
disabled, disabled,
type,
size = "normal", size = "normal",
}: Props): JSX.Element => ( }: Props): JSX.Element => (
<ConditionalWrapper <Link href={href} alwaysNewTab={alwaysNewTab} disabled={disabled}>
isWrapping={isDefinedAndNotEmpty(href) && !disabled}
wrapperProps={{ href: href ?? "", alwaysNewTab }}
wrapper={LinkWrapper}>
<div className="relative"> <div className="relative">
<div <button
type={type}
draggable={draggable} draggable={draggable}
id={id} id={id}
onClick={(event) => !disabled && onClick?.(event)} disabled={disabled}
onClick={(event) => onClick?.(event)}
onMouseUp={onMouseUp} onMouseUp={onMouseUp}
onFocus={(event) => event.target.blur()} onFocus={(event) => event.target.blur()}
className={cJoin( className={cJoin(
`group grid cursor-pointer select-none grid-flow-col place-content-center `group grid w-full grid-flow-col
place-items-center gap-2 rounded-full border border-dark py-3 px-4 place-content-center place-items-center gap-2 rounded-full border
leading-none text-dark transition-all`, border-dark leading-none text-dark transition-all
cIf(size === "small", "px-3 py-1 text-xs"), disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale`,
cIf(active, "!border-black bg-black !text-light drop-shadow-lg shadow-black"), 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, !disabled && !active,
"cursor-not-allowed opacity-50 grayscale", `shadow-shade hover:bg-dark hover:text-light hover:shadow-lg hover:shadow-shade
cIf(
!active,
`shadow-shade hover:bg-dark hover:text-light hover:drop-shadow-lg
active:hover:!border-black active:hover:bg-black active:hover:!text-light active:hover:!border-black active:hover:bg-black active:hover:!text-light
active:hover:drop-shadow-lg active:hover:shadow-black` active:hover:shadow-lg active:hover:shadow-black`
)
), ),
className className
)}> )}>
{isDefined(badgeNumber) && ( {isDefined(badgeNumber) && (
<div <div
className={cJoin( className={cJoin(
`absolute -top-3 -right-2 grid h-8 w-8 place-items-center rounded-full bg-dark `absolute grid place-items-center rounded-full bg-dark
font-bold text-light transition-opacity group-hover:opacity-0`, font-bold text-light transition-opacity group-hover:opacity-0`,
cIf(size === "small", "-top-2 -right-2 h-5 w-5") cIf(size === "small", "-right-2 -top-2 h-5 w-5", "-right-2 -top-3 h-8 w-8")
)}> )}>
<p className="-translate-y-[0.05em]">{badgeNumber}</p> <p className="-translate-y-[0.05em]">{badgeNumber}</p>
</div> </div>
)} )}
{isDefinedAndNotEmpty(icon) && ( {isDefinedAndNotEmpty(icon) && (
<Ico className="[font-size:150%] [line-height:0.66]" icon={icon} /> <Ico
className="![font-size:150%] ![line-height:0.66]"
icon={icon}
isFilled={active}
opticalSize={size === "normal" ? 24 : 20}
weight={size === "normal" ? 500 : 800}
/>
)} )}
{isDefinedAndNotEmpty(text) && <p className="-translate-y-[0.05em] text-center">{text}</p>} {isDefinedAndNotEmpty(text) && (
<p className="line-clamp-1 -translate-y-[0.05em] text-center leading-5">{text}</p>
)}
</button>
</div> </div>
</div> </Link>
</ConditionalWrapper>
); );
/* /*
@ -110,19 +116,3 @@ export const TranslatedButton = ({
return <Button text={selectedTranslation?.text ?? fallback.text} {...otherProps} />; return <Button text={selectedTranslation?.text ?? fallback.text} {...otherProps} />;
}; };
/*
*
* PRIVATE COMPONENTS
*/
interface LinkWrapperProps {
href: string;
alwaysNewTab: boolean;
}
const LinkWrapper = ({ children, alwaysNewTab, href }: LinkWrapperProps & Wrapper) => (
<Link href={href} alwaysNewTab={alwaysNewTab}>
{children}
</Link>
);

View File

@ -1,45 +1,97 @@
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 { cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { ConditionalWrapper, Wrapper } from "helpers/component"; import { ConditionalWrapper, Wrapper } from "helpers/component";
import { isDefinedAndNotEmpty } from "helpers/others"; import { isDefined } from "helpers/asserts";
/* /*
* *
* COMPONENT * COMPONENT
*/ */
interface Props { type ButtonProps = Parameters<typeof Button>[0];
export interface ButtonGroupProps {
className?: string; className?: string;
buttonsProps: (Parameters<typeof Button>[0] & { vertical?: boolean;
tooltip?: string | null | undefined; size?: ButtonProps["size"];
buttonsProps: (Omit<ButtonProps, "size"> & {
visible?: boolean;
tooltip?: React.ReactNode | null | undefined;
tooltipPlacement?: Placement;
})[]; })[];
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const ButtonGroup = ({ buttonsProps, className }: Props): JSX.Element => ( export const ButtonGroup = ({
<div className={cJoin("grid grid-flow-col", className)}> buttonsProps,
className,
vertical,
size,
}: ButtonGroupProps): JSX.Element => (
<FilteredButtonGroup
buttonsProps={buttonsProps.filter((button) => button.visible !== false)}
className={className}
vertical={vertical}
size={size}
/>
);
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) => ( {buttonsProps.map((buttonProps, index) => (
<ConditionalWrapper <ConditionalWrapper
key={index} key={index}
isWrapping={isDefinedAndNotEmpty(buttonProps.tooltip)} isWrapping={isDefined(buttonProps.tooltip)}
wrapper={ToolTipWrapper} wrapper={ToolTipWrapper}
wrapperProps={{ text: buttonProps.tooltip ?? "" }}> wrapperProps={{
text: buttonProps.tooltip ?? "",
placement: buttonProps.tooltipPlacement,
}}>
<Button <Button
{...buttonProps} {...buttonProps}
className={ 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 index === 0
? "rounded-r-none border-r-0" ? firstClassName
: index === buttonsProps.length - 1 : index === buttonsProps.length - 1
? "rounded-l-none" ? lastClassName
: "rounded-none border-r-0" : middleClassName
} )}
/> />
</ConditionalWrapper> </ConditionalWrapper>
))} ))}
</div> </div>
); );
};
/* /*
* *
@ -47,11 +99,12 @@ export const ButtonGroup = ({ buttonsProps, className }: Props): JSX.Element =>
*/ */
interface ToolTipWrapperProps { interface ToolTipWrapperProps {
text: string; text: React.ReactNode;
placement?: Placement;
} }
const ToolTipWrapper = ({ text, children }: ToolTipWrapperProps & Wrapper) => ( const ToolTipWrapper = ({ text, children, placement }: ToolTipWrapperProps & Wrapper) => (
<ToolTip content={text}> <ToolTip content={text} placement={placement}>
<>{children}</> <>{children}</>
</ToolTip> </ToolTip>
); );

View File

@ -1,13 +1,10 @@
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 { 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 { sendAnalytics } from "helpers/analytics";
import { atoms } from "contexts/atoms"; import { useFormat } from "hooks/useFormat";
import { useAtomGetter } from "helpers/atoms";
/* /*
* *
@ -33,7 +30,7 @@ export const LanguageSwitcher = ({
onLanguageChanged, onLanguageChanged,
showBadge = true, showBadge = true,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const languages = useAtomGetter(atoms.localData.languages); const { formatLanguage } = useFormat();
return ( return (
<ToolTip <ToolTip
content={ content={
@ -46,7 +43,7 @@ export const LanguageSwitcher = ({
onLanguageChanged(value); onLanguageChanged(value);
sendAnalytics("Language Switcher", `Switch language (${locale})`); sendAnalytics("Language Switcher", `Switch language (${locale})`);
}} }}
text={prettyLanguage(locale, languages)} text={formatLanguage(locale)}
/> />
</Fragment> </Fragment>
))} ))}
@ -54,7 +51,7 @@ export const LanguageSwitcher = ({
}> }>
<Button <Button
badgeNumber={showBadge && locales.size > 1 ? locales.size : undefined} badgeNumber={showBadge && locales.size > 1 ? locales.size : undefined}
icon={Icon.Translate} icon="translate"
size={size} size={size}
/> />
</ToolTip> </ToolTip>

View File

@ -1,74 +1,100 @@
import router from "next/router"; import React, { MouseEventHandler } from "react";
import { MouseEventHandler, useState } from "react"; import NextLink from "next/link";
import { isDefined } from "helpers/others"; import { ConditionalWrapper, Wrapper } from "helpers/component";
import { isDefinedAndNotEmpty } from "helpers/asserts";
import { cIf, cJoin } from "helpers/className";
/*
*
* COMPONENT
*/
interface Props { interface Props {
href: string; href: string | null | undefined;
className?: string; className?: string;
allowNewTab?: boolean;
alwaysNewTab?: boolean; alwaysNewTab?: boolean;
children: React.ReactNode; children: React.ReactNode;
onClick?: MouseEventHandler<HTMLDivElement>; onClick?: MouseEventHandler<HTMLAnchorElement>;
onFocusChanged?: (isFocused: boolean) => void; onFocusChanged?: (isFocused: boolean) => void;
disabled?: boolean; disabled?: boolean;
linkStyled?: boolean;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Link = ({ export const Link = ({
href, href,
allowNewTab = true,
alwaysNewTab = false,
disabled = false,
children, children,
className, className,
alwaysNewTab,
disabled,
linkStyled = false,
onClick, onClick,
onFocusChanged, onFocusChanged,
}: Props): JSX.Element => { }: Props): JSX.Element => (
const [isValidClick, setIsValidClick] = useState(false); <ConditionalWrapper
isWrapping={isDefinedAndNotEmpty(href) && !disabled}
return ( wrapperProps={{
<div href: href ?? "",
className={className} alwaysNewTab,
onMouseLeave={() => { onClick,
setIsValidClick(false); onFocusChanged,
onFocusChanged?.(false); className: cJoin(
cIf(
linkStyled,
`underline decoration-dark decoration-dotted underline-offset-2 transition-colors
hover:text-dark`
),
className
),
}} }}
onContextMenu={(event) => event.preventDefault()} wrapper={LinkWrapper}
onMouseDown={(event) => { wrapperFalse={DisabledWrapper}
if (!disabled) { wrapperFalseProps={{ className }}>
event.preventDefault();
onFocusChanged?.(true);
setIsValidClick(true);
}
}}
onMouseUp={(event) => {
onFocusChanged?.(false);
if (!disabled) {
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} {children}
</div> </ConditionalWrapper>
); );
};
enum MouseButton { /*
Left = 0, *
Middle = 1, * PRIVATE COMPONENTS
Right = 2, */
interface LinkWrapperProps {
href: string;
className?: string;
alwaysNewTab?: boolean;
onFocusChanged?: (isFocused: boolean) => void;
onClick?: MouseEventHandler<HTMLAnchorElement>;
} }
const LinkWrapper = ({
children,
className,
onFocusChanged,
onClick,
alwaysNewTab = false,
href,
}: LinkWrapperProps & Wrapper) => (
<NextLink
href={href}
className={className}
target={alwaysNewTab ? "_blank" : "_self"}
replace={href.startsWith("#")}
onClick={onClick}
onMouseLeave={() => onFocusChanged?.(false)}
onMouseDown={() => onFocusChanged?.(true)}
onMouseUp={() => onFocusChanged?.(false)}>
{children}
</NextLink>
);
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface DisabledWrapperProps {
className?: string;
}
const DisabledWrapper = ({ children, className }: DisabledWrapperProps & Wrapper) => (
<div className={className}>{children}</div>
);

View File

@ -1,6 +1,7 @@
import { Fragment, useCallback } from "react"; import { Fragment, useCallback } from "react";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { arrayMove, isDefinedAndNotEmpty } from "helpers/others"; import { arrayMove } from "helpers/others";
import { isDefinedAndNotEmpty } from "helpers/asserts";
/* /*
* *
@ -16,10 +17,17 @@ interface Props {
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface InsertedLabelProps {
label?: string;
}
const InsertedLabel = ({ label }: InsertedLabelProps) => (
<>{isDefinedAndNotEmpty(label) && <p>{label}</p>}</>
);
export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Element => { export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Element => {
const updateOrder = useCallback( const updateOrder = useCallback(
(sourceIndex: number, targetIndex: number) => { (sourceIndex: number, targetIndex: number) => {
console.log("updateOrder");
onChange?.(arrayMove(items, sourceIndex, targetIndex)); onChange?.(arrayMove(items, sourceIndex, targetIndex));
}, },
[items, onChange] [items, onChange]
@ -29,9 +37,8 @@ export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Ele
<div className="grid gap-2"> <div className="grid gap-2">
{items.map((item, index) => ( {items.map((item, index) => (
<Fragment key={index}> <Fragment key={index}>
{insertLabels && isDefinedAndNotEmpty(insertLabels[index]?.name) && ( <InsertedLabel label={insertLabels?.[index]?.name} />
<p>{insertLabels[index].name}</p>
)}
<div <div
onDragStart={(event) => { onDragStart={(event) => {
const source = event.target as HTMLElement; const source = event.target as HTMLElement;
@ -63,7 +70,7 @@ export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Ele
<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={Icon.ArrowDropUp} icon="arrow_drop_up"
className="row-start-1 cursor-pointer" className="row-start-1 cursor-pointer"
onClick={() => { onClick={() => {
updateOrder(index, index - 1); updateOrder(index, index - 1);
@ -72,7 +79,7 @@ export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Ele
)} )}
{index < items.length - 1 && ( {index < items.length - 1 && (
<Ico <Ico
icon={Icon.ArrowDropDown} icon="arrow_drop_down"
className="row-start-2 cursor-pointer" className="row-start-2 cursor-pointer"
onClick={() => { onClick={() => {
updateOrder(index, index + 1); updateOrder(index, index + 1);

View File

@ -1,5 +1,4 @@
import { ButtonGroup } from "./ButtonGroup"; import { ButtonGroup } from "./ButtonGroup";
import { Icon } from "components/Ico";
import { cJoin } from "helpers/className"; import { cJoin } from "helpers/className";
/* /*
@ -21,25 +20,25 @@ export const PageSelector = ({ page, className, pagesCount, onChange }: Props):
className={cJoin("flex flex-row place-content-center", className)} className={cJoin("flex flex-row place-content-center", className)}
buttonsProps={[ buttonsProps={[
{ {
onClick: () => onChange(0), onClick: () => onChange(1),
disabled: page === 0, disabled: page === 1,
icon: Icon.FirstPage, icon: "first_page",
}, },
{ {
onClick: () => page > 0 && onChange(page - 1), onClick: () => page > 1 && onChange(page - 1),
disabled: page === 0, disabled: page === 1,
icon: Icon.NavigateBefore, icon: "navigate_before",
}, },
{ text: `${page + 1} / ${pagesCount}` }, { text: `${page} / ${pagesCount}` },
{ {
onClick: () => page < pagesCount - 1 && onChange(page + 1), onClick: () => page < pagesCount && onChange(page + 1),
disabled: page === pagesCount - 1, disabled: page === pagesCount,
icon: Icon.NavigateNext, icon: "navigate_next",
}, },
{ {
onClick: () => onChange(pagesCount - 1), onClick: () => onChange(pagesCount),
disabled: page === pagesCount - 1, disabled: page === pagesCount,
icon: Icon.LastPage, icon: "last_page",
}, },
]} ]}
/> />

View File

@ -1,6 +1,6 @@
import { Fragment, useCallback, useRef } from "react"; import { Fragment, useCallback, useRef } from "react";
import { useBoolean, useOnClickOutside } from "usehooks-ts"; 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";
/* /*
@ -52,19 +52,18 @@ export const Select = ({
ref={ref} ref={ref}
className={cJoin( className={cJoin(
"relative text-center transition-filter", "relative text-center transition-filter",
cIf(isOpened, "z-10 drop-shadow-lg shadow-shade"), cIf(isOpened, "z-20 drop-shadow-lg shadow-shade"),
className className
)}> )}>
<div <div
className={cJoin( className={cJoin(
`grid cursor-pointer select-none grid-flow-col grid-cols-[1fr_auto_auto] `grid cursor-pointer select-none grid-flow-col grid-cols-[1fr_auto_auto]
place-items-center rounded-3xl p-1 outline outline-1 -outline-offset-1 place-items-center rounded-3xl p-1 outline outline-1 -outline-offset-1`,
outline-mid`,
cIf(isOpened, "rounded-b-none bg-highlight outline-transparent"), cIf(isOpened, "rounded-b-none bg-highlight outline-transparent"),
cIf( cIf(
disabled, disabled,
"cursor-not-allowed text-dark opacity-50 outline-dark/60 grayscale", "cursor-not-allowed text-dark opacity-50 outline-dark/60 grayscale",
"transition-all hover:bg-mid hover:outline-transparent" "outline-mid transition-all hover:bg-mid hover:outline-transparent"
) )
)}> )}>
<p onClick={tryToggling} className="w-full px-4 py-1"> <p onClick={tryToggling} className="w-full px-4 py-1">
@ -72,12 +71,12 @@ export const Select = ({
</p> </p>
{value >= 0 && allowEmpty && ( {value >= 0 && allowEmpty && (
<Ico <Ico
icon={Icon.Close} icon="close"
className="!text-xs" className="!text-xs"
onClick={() => !disabled && onSelectionChanged(-1)} onClick={() => !disabled && onSelectionChanged(-1)}
/> />
)} )}
<Ico onClick={tryToggling} icon={isOpened ? Icon.ArrowDropUp : Icon.ArrowDropDown} /> <Ico onClick={tryToggling} icon={isOpened ? "arrow_drop_up" : "arrow_drop_down"} />
</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) => (

View File

@ -21,10 +21,14 @@ export const Switch = ({ value, onClick, className, disabled = false }: Props):
<div <div
className={cJoin( className={cJoin(
`relative grid h-6 w-12 content-center rounded-full border-mid outline `relative grid h-6 w-12 content-center rounded-full border-mid outline
outline-1 -outline-offset-1 outline-mid transition-colors`, outline-1 -outline-offset-1 transition-colors`,
cIf(value, "border-none bg-mid shadow-inner-sm outline-transparent shadow-shade"), cIf(value, "border-none shadow-inner-sm shadow-shade"),
cIf(disabled, "cursor-not-allowed opacity-50 grayscale", "cursor-pointer"), cIf(disabled, "cursor-not-allowed opacity-50 grayscale", "cursor-pointer outline-mid"),
cIf(disabled, cIf(value, "bg-dark/40 outline-transparent", "outline-dark/60")), cIf(
disabled,
cIf(value, "bg-dark/40 outline-transparent", "outline-dark/60"),
cIf(value, "bg-mid outline-transparent")
),
className className
)} )}
onClick={() => { onClick={() => {

View File

@ -1,6 +1,7 @@
import { Ico, Icon } from "components/Ico"; import { forwardRef } from "react";
import { Ico } from "components/Ico";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { isDefinedAndNotEmpty } from "helpers/others"; import { isDefinedAndNotEmpty } from "helpers/asserts";
/* /*
* *
@ -12,40 +13,38 @@ interface Props {
onChange: (newValue: string) => void; onChange: (newValue: string) => void;
className?: string; className?: string;
name?: string; name?: string;
placeholder?: string; placeholder?: string | null;
disabled?: boolean; disabled?: boolean;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const TextInput = ({ export const TextInput = forwardRef<HTMLInputElement, Props>(
value, ({ value, onChange, className, name, placeholder, disabled = false }, ref) => (
onChange,
className,
name,
placeholder,
disabled = false,
}: Props): JSX.Element => (
<div className={cJoin("relative", className)}> <div className={cJoin("relative", className)}>
<input <input
ref={ref}
className="w-full" className="w-full"
type="text" type="text"
name={name} name={name}
autoCapitalize="off"
value={value} value={value}
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder ?? undefined}
onChange={(event) => { onChange={(event) => {
onChange(event.target.value); onChange(event.target.value);
}} }}
/> />
{isDefinedAndNotEmpty(value) && ( {isDefinedAndNotEmpty(value) && (
<div className="absolute right-4 top-0 bottom-0 grid place-items-center"> <div className="absolute bottom-0 right-4 top-0 grid place-items-center">
<Ico <Ico
className={cJoin("!text-xs", cIf(disabled, "opacity-30 grayscale", "cursor-pointer"))} className={cJoin("!text-xs", cIf(disabled, "opacity-30 grayscale", "cursor-pointer"))}
icon={Icon.Close} icon="close"
onClick={() => !disabled && onChange("")} onClick={() => !disabled && onChange("")}
/> />
</div> </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/others"; import { isDefinedAndNotEmpty } from "helpers/asserts";
/* /*
* *

View File

@ -1,11 +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 { LibraryItemUserStatus } from "types/types";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { useLibraryItemUserStatus } from "hooks/useLibraryItemUserStatus"; import { useLibraryItemUserStatus } from "hooks/useLibraryItemUserStatus";
import { atoms } from "contexts/atoms"; import { useFormat } from "hooks/useFormat";
import { useAtomGetter } from "helpers/atoms";
/* /*
* *
@ -21,7 +19,7 @@ interface Props {
export const PreviewCardCTAs = ({ id, expand = false }: Props): JSX.Element => { export const PreviewCardCTAs = ({ id, expand = false }: Props): JSX.Element => {
const { libraryItemUserStatus, setLibraryItemUserStatus } = useLibraryItemUserStatus(); const { libraryItemUserStatus, setLibraryItemUserStatus } = useLibraryItemUserStatus();
const langui = useAtomGetter(atoms.localData.langui); const { format } = useFormat();
return ( return (
<div <div
@ -29,13 +27,13 @@ export const PreviewCardCTAs = ({ id, expand = false }: Props): JSX.Element => {
"flex flex-row flex-wrap place-content-center place-items-center", "flex flex-row flex-wrap place-content-center place-items-center",
cIf(expand, "gap-4", "gap-2") cIf(expand, "gap-4", "gap-2")
)}> )}>
<ToolTip content={langui.want_it} disabled={expand}> <ToolTip content={format("want_it")} disabled={expand}>
<Button <Button
icon={Icon.Favorite} icon="favorite"
text={expand ? langui.want_it : undefined} text={expand ? format("want_it") : undefined}
active={libraryItemUserStatus[id] === LibraryItemUserStatus.Want} active={libraryItemUserStatus[id] === LibraryItemUserStatus.Want}
onMouseUp={(event) => event.stopPropagation()} onClick={(event) => {
onClick={() => { event.preventDefault();
setLibraryItemUserStatus((current) => { setLibraryItemUserStatus((current) => {
const newLibraryItemUserStatus = { ...current }; const newLibraryItemUserStatus = { ...current };
newLibraryItemUserStatus[id] = newLibraryItemUserStatus[id] =
@ -47,13 +45,13 @@ export const PreviewCardCTAs = ({ id, expand = false }: Props): JSX.Element => {
}} }}
/> />
</ToolTip> </ToolTip>
<ToolTip content={langui.have_it} disabled={expand}> <ToolTip content={format("have_it")} disabled={expand}>
<Button <Button
icon={Icon.BackHand} icon="back_hand"
text={expand ? langui.have_it : undefined} text={expand ? format("have_it") : undefined}
active={libraryItemUserStatus[id] === LibraryItemUserStatus.Have} active={libraryItemUserStatus[id] === LibraryItemUserStatus.Have}
onMouseUp={(event) => event.stopPropagation()} onClick={(event) => {
onClick={() => { event.preventDefault();
setLibraryItemUserStatus((current) => { setLibraryItemUserStatus((current) => {
const newLibraryItemUserStatus = { ...current }; const newLibraryItemUserStatus = { ...current };
newLibraryItemUserStatus[id] = newLibraryItemUserStatus[id] =

View File

@ -3,16 +3,20 @@ import { useState } from "react";
import { useHotkeys } from "react-hotkeys-hook"; import { useHotkeys } from "react-hotkeys-hook";
import { Img } from "./Img"; import { Img } from "./Img";
import { Button } from "./Inputs/Button"; import { Button } from "./Inputs/Button";
import { Icon } from "components/Ico";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { useFullscreen } from "hooks/useFullscreen"; import { useFullscreen } from "hooks/useFullscreen";
import { Ids } from "types/ids"; import { Ids } from "types/ids";
import { UploadImageFragment } from "graphql/generated"; import { UploadImageFragment } from "graphql/generated";
import { ImageQuality } from "helpers/img"; import { ImageQuality } from "helpers/img";
import { isDefined } from "helpers/others"; import { isDefined } from "helpers/asserts";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
/*
*
* COMPONENT
*/
interface Props { interface Props {
onCloseRequest: () => void; onCloseRequest: () => void;
isVisible: boolean; isVisible: boolean;
@ -35,6 +39,7 @@ export const LightBox = ({
onPressNext, onPressNext,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const [currentZoom, setCurrentZoom] = useState(1); const [currentZoom, setCurrentZoom] = useState(1);
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
const { isFullscreen, toggleFullscreen, exitFullscreen, requestFullscreen } = useFullscreen( const { isFullscreen, toggleFullscreen, exitFullscreen, requestFullscreen } = useFullscreen(
Ids.LightBox Ids.LightBox
); );
@ -58,12 +63,12 @@ export const LightBox = ({
id={Ids.LightBox} id={Ids.LightBox}
className={cJoin( className={cJoin(
"fixed inset-0 z-50 grid place-content-center transition-filter duration-500", "fixed inset-0 z-50 grid place-content-center transition-filter duration-500",
cIf(isVisible, "backdrop-blur", "pointer-events-none touch-none") cIf(isVisible, cIf(!isPerfModeEnabled, "backdrop-blur"), "pointer-events-none touch-none")
)}> )}>
<div <div
className={cJoin( className={cJoin(
"fixed inset-0 bg-shade transition-all duration-500", "fixed inset-0 transition-colors duration-500",
cIf(isVisible, "bg-opacity-50", "bg-opacity-0") cIf(isVisible, "bg-shade/50", "bg-shade/0")
)} )}
/> />
<div <div
@ -86,8 +91,10 @@ export const LightBox = ({
}}> }}>
{isDefined(src) && ( {isDefined(src) && (
<Img <Img
className={`h-[calc(100vh-4rem)] w-full object-contain drop-shadow-2xl className={cJoin(
shadow-shade`} `h-[calc(100vh-4rem)] w-full object-contain`,
cIf(!isPerfModeEnabled, "drop-shadow-2xl shadow-shade")
)}
src={src} src={src}
quality={ImageQuality.Large} quality={ImageQuality.Large}
/> />
@ -120,6 +127,11 @@ export const LightBox = ({
); );
}; };
/*
*
* PRIVATE COMPONENTS
*/
interface ControlButtonsProps { interface ControlButtonsProps {
isPreviousImageAvailable: boolean; isPreviousImageAvailable: boolean;
isNextImageAvailable: boolean; isNextImageAvailable: boolean;
@ -142,24 +154,17 @@ const ControlButtons = ({
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout); const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const PreviousButton = () => ( const PreviousButton = () => (
<Button <Button icon="navigate_before" onClick={onPressPrevious} disabled={!isPreviousImageAvailable} />
icon={Icon.NavigateBefore}
onClick={onPressPrevious}
disabled={!isPreviousImageAvailable}
/>
); );
const NextButton = () => ( const NextButton = () => (
<Button icon={Icon.NavigateNext} onClick={onPressNext} disabled={!isNextImageAvailable} /> <Button icon="navigate_next" onClick={onPressNext} disabled={!isNextImageAvailable} />
); );
const FullscreenButton = () => ( const FullscreenButton = () => (
<Button <Button icon={isFullscreen ? "fullscreen_exit" : "fullscreen"} onClick={toggleFullscreen} />
icon={isFullscreen ? Icon.FullscreenExit : Icon.Fullscreen}
onClick={toggleFullscreen}
/>
); );
const CloseButton = () => <Button onClick={onCloseRequest} icon={Icon.Close} />; const CloseButton = () => <Button onClick={onCloseRequest} icon="close" />;
return is1ColumnLayout ? ( return is1ColumnLayout ? (
<> <>
@ -170,7 +175,7 @@ const ControlButtons = ({
<NextButton /> <NextButton />
</div> </div>
</div> </div>
<div className="absolute top-2 right-2 grid gap-4 rounded-4xl p-4 backdrop-blur-lg"> <div className="absolute right-2 top-2 grid gap-4 rounded-4xl p-4 backdrop-blur-lg">
<CloseButton /> <CloseButton />
</div> </div>
</> </>
@ -178,20 +183,20 @@ const ControlButtons = ({
<> <>
{isPreviousImageAvailable && ( {isPreviousImageAvailable && (
<div <div
className={`absolute top-1/2 left-8 grid gap-4 rounded-4xl p-4 className={`absolute left-8 top-1/2 grid gap-4 rounded-4xl p-4
backdrop-blur-lg`}> backdrop-blur-lg`}>
<PreviousButton /> <PreviousButton />
</div> </div>
)} )}
{isNextImageAvailable && ( {isNextImageAvailable && (
<div <div
className={`absolute top-1/2 right-8 grid gap-4 rounded-4xl p-4 className={`absolute right-8 top-1/2 grid gap-4 rounded-4xl p-4
backdrop-blur-lg`}> backdrop-blur-lg`}>
<NextButton /> <NextButton />
</div> </div>
)} )}
<div <div
className={`absolute top-4 right-8 grid gap-4 rounded-4xl p-4 className={`absolute right-8 top-4 grid gap-4 rounded-4xl p-4
backdrop-blur-lg`}> backdrop-blur-lg`}>
<CloseButton /> <CloseButton />
<FullscreenButton /> <FullscreenButton />

View File

@ -1,20 +1,24 @@
import Markdown from "markdown-to-jsx"; import Markdown from "markdown-to-jsx";
import { useRouter } from "next/router"; import React, { Fragment, MouseEventHandler, useMemo } from "react";
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/Containers/InsetBox";
import { cIf, cJoin } from "helpers/className"; import { cIf, 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/others"; import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts";
import { AnchorShare } from "components/AnchorShare"; import { AnchorShare } from "components/AnchorShare";
import { useIntersectionList } from "hooks/useIntersectionList"; import { useIntersectionList } from "hooks/useIntersectionList";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/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";
/* /*
* *
@ -30,16 +34,12 @@ interface MarkdawnProps {
export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Element => { export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Element => {
const playerName = useAtomGetter(atoms.settings.playerName); const playerName = useAtomGetter(atoms.settings.playerName);
const router = useRouter();
const isContentPanelAtLeastLg = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastLg); const isContentPanelAtLeastLg = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastLg);
const { showLightBox } = useAtomGetter(atoms.lightBox); const { showLightBox } = useAtomGetter(atoms.lightBox);
/* eslint-disable no-irregular-whitespace */ /* eslint-disable no-irregular-whitespace */
const text = useMemo( const text = `${preprocessMarkDawn(rawText, playerName)}
() => `${preprocessMarkDawn(rawText, playerName)} `;
`,
[playerName, rawText]
);
/* eslint-enable no-irregular-whitespace */ /* eslint-enable no-irregular-whitespace */
if (isUndefined(text) || text === "") { if (isUndefined(text) || text === "") {
@ -56,13 +56,15 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
component: (compProps: { href: string; children: React.ReactNode }) => { component: (compProps: { href: string; children: React.ReactNode }) => {
if (compProps.href.startsWith("/") || compProps.href.startsWith("#")) { if (compProps.href.startsWith("/") || compProps.href.startsWith("#")) {
return ( return (
<a onClick={async () => router.push(compProps.href)}>{compProps.children}</a> <Link href={compProps.href} linkStyled>
{compProps.children}
</Link>
); );
} }
return ( return (
<a href={compProps.href} target="_blank" rel="noreferrer"> <Link href={compProps.href} alwaysNewTab linkStyled>
{compProps.children} {compProps.children}
</a> </Link>
); );
}, },
}, },
@ -98,9 +100,9 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
? slugify(compProps.target) ? slugify(compProps.target)
: slugify(compProps.children?.toString()); : slugify(compProps.children?.toString());
return ( return (
<a onClick={async () => router.replace(`${compProps.page ?? ""}#${slug}`)}> <Link href={`${compProps.page ?? ""}#${slug}`} linkStyled>
{compProps.children} {compProps.children}
</a> </Link>
); );
}, },
}, },
@ -118,14 +120,43 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
}, },
Line: { Line: {
component: (compProps) => ( component: (compProps) => {
const schema = z.object({ name: z.string(), children: z.any() });
if (!schema.safeParse(compProps).success) {
return (
<MarkdawnError
message={`Error while parsing a <Line/> tag. Here is the correct usage:
<Line name="John">Hello!</Line>`}
/>
);
}
const safeProps: z.infer<typeof schema> = compProps;
return (
<> <>
<strong <strong
className={cJoin("!my-0 text-dark/60", cIf(!isContentPanelAtLeastLg, "!-mb-4"))}> className={cJoin(
<Markdawn text={compProps.name} /> "!my-0 text-dark/60",
cIf(!isContentPanelAtLeastLg, "!-mb-4")
)}>
<Markdawn text={safeProps.name} />
</strong> </strong>
<p className="whitespace-pre-line">{compProps.children}</p> <p className="whitespace-pre-line">{safeProps.children}</p>
</> </>
);
},
},
Angelic: {
component: (comProps) => <span className="font-angelic">{comProps.children}</span>,
},
Video: {
component: (comProps) => (
<VideoPlayer
src={getVideoFile(comProps.id)}
title={comProps.title}
className="my-8"
/>
), ),
}, },
@ -187,7 +218,7 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
name?: string; name?: string;
}) => ( }) => (
<div <div
className="mt-8 mb-12 grid cursor-pointer place-content-center" className="mb-12 mt-8 grid cursor-pointer place-content-center"
onClick={() => { onClick={() => {
showLightBox([ showLightBox([
compProps.src.startsWith("/uploads/") compProps.src.startsWith("/uploads/")
@ -216,38 +247,47 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface TableOfContentsProps { interface MarkdawnErrorProps {
text: string; message: string;
title?: string;
horizontalLine?: boolean;
} }
export const TableOfContents = ({ const MarkdawnError = ({ message }: MarkdawnErrorProps): JSX.Element => (
text, <div
title, 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>
);
horizontalLine = false, // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
}: TableOfContentsProps): JSX.Element => {
const router = useRouter(); interface TableOfContentsProps {
const langui = useAtomGetter(atoms.localData.langui); toc: TocInterface;
const toc = useMemo(() => getTocFromMarkdawn(preprocessMarkDawn(text), title), [text, title]); onContentClicked?: MouseEventHandler<HTMLAnchorElement>;
}
export const TableOfContents = ({ toc, onContentClicked }: TableOfContentsProps): JSX.Element => {
const { format } = useFormat();
return ( return (
<> <>
{toc.children.length > 0 && ( {toc.children.length > 0 && (
<> <>
{horizontalLine && <HorizontalLine />} <h3 className="text-xl">{format("table_of_contents")}</h3>
<h3 className="text-xl">{langui.table_of_contents}</h3>
<div className="max-w-[14.5rem] text-left"> <div className="max-w-[14.5rem] text-left">
<p <p
className="relative my-2 overflow-x-hidden text-ellipsis whitespace-nowrap className="relative my-2 overflow-x-hidden text-ellipsis whitespace-nowrap
text-left"> text-left">
<a onClick={async () => router.replace(`#${toc.slug}`)}> <Link href={`#${toc.slug}`} linkStyled onClick={onContentClicked}>
{<abbr title={toc.title}>{toc.title}</abbr>} {<abbr title={toc.title}>{toc.title}</abbr>}
</a> </Link>
</p> </p>
<TocLevel tocchildren={toc.children} parentNumbering="" /> <TocLevel
tocchildren={toc.children}
parentNumbering=""
onContentClicked={onContentClicked}
/>
</div> </div>
</> </>
)} )}
@ -268,15 +308,14 @@ interface HeaderProps {
const Header = ({ level, title, slug }: HeaderProps): JSX.Element => { const Header = ({ level, title, slug }: HeaderProps): JSX.Element => {
const isHoverable = useDeviceSupportsHover(); const isHoverable = useDeviceSupportsHover();
const innerComponent = useMemo( const innerComponent = (
() => (
<> <>
<div className="ml-10 flex place-items-center gap-4"> <div className="ml-10 flex place-items-center gap-4">
{title === "* * *" ? ( {title === "* * *" ? (
<div className="mt-8 mb-12 space-x-3 text-dark"> <div className="mb-12 mt-8 space-x-3 text-dark">
<Ico icon={Icon.Emergency} /> <Ico icon="emergency" />
<Ico icon={Icon.Emergency} /> <Ico icon="emergency" />
<Ico icon={Icon.Emergency} /> <Ico icon="emergency" />
</div> </div>
) : ( ) : (
<div className="font-headers">{title}</div> <div className="font-headers">{title}</div>
@ -287,8 +326,6 @@ const Header = ({ level, title, slug }: HeaderProps): JSX.Element => {
/> />
</div> </div>
</> </>
),
[isHoverable, slug, title]
); );
switch (level) { switch (level) {
@ -341,15 +378,15 @@ interface LevelProps {
tocchildren: TocInterface[]; tocchildren: TocInterface[];
parentNumbering: string; parentNumbering: string;
allowIntersection?: boolean; allowIntersection?: boolean;
onContentClicked?: MouseEventHandler<HTMLAnchorElement>;
} }
const TocLevel = ({ const TocLevel = ({
tocchildren, tocchildren,
parentNumbering, parentNumbering,
allowIntersection = true, allowIntersection = true,
onContentClicked,
}: LevelProps): JSX.Element => { }: LevelProps): JSX.Element => {
const router = useRouter();
const ids = useMemo(() => tocchildren.map((child) => child.slug), [tocchildren]); const ids = useMemo(() => tocchildren.map((child) => child.slug), [tocchildren]);
const currentIntersection = useIntersectionList(ids); const currentIntersection = useIntersectionList(ids);
@ -363,14 +400,15 @@ const TocLevel = ({
cIf(allowIntersection && currentIntersection === childIndex, "text-dark") cIf(allowIntersection && currentIntersection === childIndex, "text-dark")
)}> )}>
<span className="text-dark">{`${parentNumbering}${childIndex + 1}.`}</span>{" "} <span className="text-dark">{`${parentNumbering}${childIndex + 1}.`}</span>{" "}
<a onClick={async () => router.replace(`#${child.slug}`)}> <Link href={`#${child.slug}`} linkStyled onClick={onContentClicked}>
{<abbr title={child.title}>{child.title}</abbr>} {<abbr title={child.title}>{child.title}</abbr>}
</a> </Link>
</li> </li>
<TocLevel <TocLevel
tocchildren={child.children} tocchildren={child.children}
parentNumbering={`${parentNumbering}${childIndex + 1}.`} parentNumbering={`${parentNumbering}${childIndex + 1}.`}
allowIntersection={allowIntersection && currentIntersection === childIndex} allowIntersection={allowIntersection && currentIntersection === childIndex}
onContentClicked={onContentClicked}
/> />
</Fragment> </Fragment>
))} ))}
@ -438,7 +476,14 @@ const markdawnHeadersParser = (
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
const getTocFromMarkdawn = (text: string, title?: string): TocInterface => { export const getTocFromMarkdawn = (
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),
@ -469,7 +514,7 @@ const getTocFromMarkdawn = (text: string, title?: string): TocInterface => {
h5 = -1; h5 = -1;
scenebreak = 0; scenebreak = 0;
} else if (h2 >= 0 && line.startsWith('<Header level="3"')) { } else if (h2 >= 0 && line.startsWith('<Header level="3"')) {
toc.children[h2].children.push({ toc.children[h2]?.children.push({
title: getTitle(line), title: getTitle(line),
slug: getSlug(line), slug: getSlug(line),
children: [], children: [],
@ -479,7 +524,7 @@ const getTocFromMarkdawn = (text: string, title?: string): TocInterface => {
h5 = -1; h5 = -1;
scenebreak = 0; scenebreak = 0;
} else if (h3 >= 0 && line.startsWith('<Header level="4"')) { } else if (h3 >= 0 && line.startsWith('<Header level="4"')) {
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: [],
@ -488,7 +533,7 @@ const getTocFromMarkdawn = (text: string, title?: string): TocInterface => {
h5 = -1; h5 = -1;
scenebreak = 0; scenebreak = 0;
} else if (h4 >= 0 && line.startsWith('<Header level="5"')) { } else if (h4 >= 0 && line.startsWith('<Header level="5"')) {
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: [],
@ -496,7 +541,7 @@ const getTocFromMarkdawn = (text: string, title?: string): TocInterface => {
h5++; h5++;
scenebreak = 0; scenebreak = 0;
} else if (h5 >= 0 && line.startsWith('<Header level="6"')) { } else if (h5 >= 0 && line.startsWith('<Header level="6"')) {
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: [],
@ -512,18 +557,19 @@ const getTocFromMarkdawn = (text: string, title?: string): TocInterface => {
}; };
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

@ -0,0 +1,21 @@
import DOMPurify from "isomorphic-dompurify";
import { marked } from "marked";
/*
*
* COMPONENT
*/
interface MarkdownProps {
className?: string;
text: string;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Markdown = ({ className, text }: MarkdownProps): JSX.Element => (
<div
className={className}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(text)) }}
/>
);

View File

@ -1,9 +1,10 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { MouseEventHandler, useCallback, useMemo } from "react"; import { MouseEventHandler, useCallback } from "react";
import { Ico, Icon } from "components/Ico"; import { MaterialSymbol } from "material-symbols";
import { Ico } from "components/Ico";
import { ToolTip } from "components/ToolTip"; import { ToolTip } from "components/ToolTip";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { isDefinedAndNotEmpty } from "helpers/others"; import { isDefinedAndNotEmpty } from "helpers/asserts";
import { TranslatedProps } from "types/TranslatedProps"; import { TranslatedProps } from "types/TranslatedProps";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
import { DownPressable } from "components/Containers/DownPressable"; import { DownPressable } from "components/Containers/DownPressable";
@ -15,14 +16,14 @@ import { DownPressable } from "components/Containers/DownPressable";
interface Props { interface Props {
url: string; url: string;
icon?: Icon; icon?: MaterialSymbol;
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; active?: boolean;
disabled?: boolean; disabled?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>; onClick?: MouseEventHandler<HTMLAnchorElement>;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
@ -39,10 +40,7 @@ export const NavOption = ({
onClick, onClick,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const router = useRouter(); const router = useRouter();
const isActive = useMemo( const isActive = active || router.asPath.startsWith(url);
() => active || router.asPath.startsWith(url),
[active, router.asPath, url]
);
return ( return (
<ToolTip <ToolTip

View File

@ -1,5 +1,6 @@
import { Ico, Icon } from "components/Ico"; import { MaterialSymbol } from "material-symbols";
import { isDefinedAndNotEmpty } from "helpers/others"; import { Ico } from "components/Ico";
import { isDefinedAndNotEmpty } from "helpers/asserts";
/* /*
* *
@ -7,7 +8,7 @@ import { isDefinedAndNotEmpty } from "helpers/others";
*/ */
interface Props { interface Props {
icon?: Icon; icon?: MaterialSymbol;
title: string | null | undefined; title: string | null | undefined;
description?: string | null | undefined; description?: string | null | undefined;
} }

View File

@ -1,11 +1,9 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Icon } from "components/Ico";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { TranslatedProps } from "types/TranslatedProps"; import { TranslatedProps } from "types/TranslatedProps";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
import { isDefined } from "helpers/others"; import { useFormat } from "hooks/useFormat";
import { atoms } from "contexts/atoms"; import { cJoin } from "helpers/className";
import { useAtomGetter } from "helpers/atoms";
/* /*
* *
@ -15,27 +13,18 @@ import { useAtomGetter } from "helpers/atoms";
interface Props { interface Props {
href: string; href: string;
title: string | null | undefined; title: string | null | undefined;
displayOnlyOn?: "1ColumnLayout" | "3ColumnsLayout";
className?: string; className?: string;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const ReturnButton = ({ href, title, displayOnlyOn, className }: Props): JSX.Element => { export const ReturnButton = ({ href, title, className }: Props): JSX.Element => {
const langui = useAtomGetter(atoms.localData.langui); const { format } = useFormat();
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
return ( return (
<> <div className={cJoin("mx-auto w-full max-w-lg place-self-center", className)}>
{((is3ColumnsLayout && displayOnlyOn === "3ColumnsLayout") || <Button href={href} text={format("return_to_x", { x: title })} icon="navigate_before" />
(!is3ColumnsLayout && displayOnlyOn === "1ColumnLayout") ||
!isDefined(displayOnlyOn)) && (
<div className={className}>
<Button href={href} text={`${langui.return_to} ${title}`} icon={Icon.NavigateBefore} />
</div> </div>
)}
</>
); );
}; };

View File

@ -0,0 +1,52 @@
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,16 +1,17 @@
import Markdown from "markdown-to-jsx"; import { useCallback } from "react";
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 { Icon } from "components/Ico";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { isDefinedAndNotEmpty } from "helpers/others"; import { isDefinedAndNotEmpty } from "helpers/asserts";
import { Link } from "components/Inputs/Link"; import { Link } from "components/Inputs/Link";
import { sendAnalytics } from "helpers/analytics"; import { sendAnalytics } from "helpers/analytics";
import { ColoredSvg } from "components/ColoredSvg"; import { ColoredSvg } from "components/ColoredSvg";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter, useAtomPair, useAtomSetter } from "helpers/atoms"; import { useAtomGetter, useAtomPair, useAtomSetter } from "helpers/atoms";
import { Markdawn } from "components/Markdown/Markdawn";
import { useFormat } from "hooks/useFormat";
/* /*
* *
@ -19,9 +20,15 @@ import { useAtomGetter, useAtomPair, useAtomSetter } from "helpers/atoms";
export const MainPanel = (): JSX.Element => { export const MainPanel = (): JSX.Element => {
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout); const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
const langui = useAtomGetter(atoms.localData.langui); const { format } = useFormat();
const [isMainPanelReduced, setMainPanelReduced] = useAtomPair(atoms.layout.mainPanelReduced); const [isMainPanelReduced, setMainPanelReduced] = useAtomPair(atoms.layout.mainPanelReduced);
const setSettingsOpened = useAtomSetter(atoms.layout.settingsOpened); 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]);
return ( return (
<div <div
@ -46,13 +53,16 @@ export const MainPanel = (): JSX.Element => {
setMainPanelReduced((current) => !current); setMainPanelReduced((current) => !current);
}} }}
className="z-50 bg-light !px-2" className="z-50 bg-light !px-2"
icon={isMainPanelReduced ? Icon.ChevronRight : Icon.ChevronLeft} icon={isMainPanelReduced ? "chevron_right" : "chevron_left"}
/> />
</div> </div>
)} )}
<div> <div>
<div className="grid place-items-center"> <div className="grid place-items-center">
<Link href="/" className="flex w-full cursor-pointer justify-center"> <Link
href="/"
className="flex w-full cursor-pointer justify-center"
onClick={closeMainPanel}>
<ColoredSvg <ColoredSvg
src="/icons/accords.svg" src="/icons/accords.svg"
className={cJoin( className={cJoin(
@ -72,18 +82,46 @@ export const MainPanel = (): JSX.Element => {
cIf(isMainPanelReduced && is3ColumnsLayout, "flex-col gap-3", "flex-row") cIf(isMainPanelReduced && is3ColumnsLayout, "flex-col gap-3", "flex-row")
)}> )}>
<ToolTip <ToolTip
content={<h3 className="text-2xl">{langui.open_settings}</h3>} content={<h3 className="text-2xl">{format("open_settings")}</h3>}
placement="right" placement={isMainPanelReduced ? "right" : "top"}>
className="text-left"
disabled={!isMainPanelReduced}>
<Button <Button
active={isSettingsOpened}
onClick={() => { onClick={() => {
closeMainPanel();
setSettingsOpened(true); setSettingsOpened(true);
sendAnalytics("Settings", "Open settings"); sendAnalytics("Settings", "Open settings");
}} }}
icon={Icon.Settings} icon="discover_tune"
/> />
</ToolTip> </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>
)}
</div> </div>
</div> </div>
</div> </div>
@ -92,89 +130,89 @@ export const MainPanel = (): JSX.Element => {
<NavOption <NavOption
url="/library" url="/library"
icon={Icon.LibraryBooks} icon="auto_stories"
title={langui.library} title={format("library")}
subtitle={langui.library_short_description} subtitle={format("library_short_description")}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
onClick={closeMainPanel}
/> />
<NavOption <NavOption
url="/contents" url="/contents"
icon={Icon.Workspaces} icon="workspaces"
title={langui.contents} title={format("contents")}
subtitle={langui.contents_short_description} subtitle={format("contents_short_description")}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
onClick={closeMainPanel}
/> />
<NavOption <NavOption
url="/wiki" url="/wiki"
icon={Icon.TravelExplore} icon="travel_explore"
title={langui.wiki} title={format("wiki")}
subtitle={langui.wiki_short_description} subtitle={format("wiki_short_description")}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
onClick={closeMainPanel}
/> />
<NavOption <NavOption
url="/chronicles" url="/chronicles"
icon={Icon.WatchLater} icon="schedule"
title={langui.chronicles} title={format("chronicles")}
subtitle={langui.chronicles_short_description} subtitle={format("chronicles_short_description")}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
onClick={closeMainPanel}
/> />
<HorizontalLine /> <HorizontalLine />
<NavOption <NavOption
url="/news" url="/news"
icon={Icon.Feed} icon="newspaper"
title={langui.news} title={format("news")}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
onClick={closeMainPanel}
/> />
{/*
<NavOption
url="/merch"
icon={Icon.Store}
title={langui.merch}
reduced={isMainPanelReduced && is3ColumnsLayout}
/>
*/}
<NavOption <NavOption
url="https://gallery.accords-library.com/posts/" url="https://gallery.accords-library.com/posts/"
icon={Icon.Collections} icon="perm_media"
title={langui.gallery} title={format("gallery")}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
onClick={closeMainPanel}
/> />
<NavOption <NavOption
url="/archives" url="/archives"
icon={Icon.Inventory2} icon="save"
title={langui.archives} title={format("archives")}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
onClick={closeMainPanel}
/> />
<NavOption <NavOption
url="/about-us" url="/about-us"
icon={Icon.Info} icon="info"
title={langui.about_us} title={format("about_us")}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
onClick={closeMainPanel}
/> />
{(!isMainPanelReduced || !is3ColumnsLayout) && <HorizontalLine />} {(!isMainPanelReduced || !is3ColumnsLayout) && <HorizontalLine />}
<div className={cJoin("text-center", cIf(isMainPanelReduced && is3ColumnsLayout, "hidden"))}> <div className={cJoin("text-center", cIf(isMainPanelReduced && is3ColumnsLayout, "hidden"))}>
{isDefinedAndNotEmpty(langui.licensing_notice) && ( {isDefinedAndNotEmpty(format("licensing_notice")) && (
<p> <p>
<Markdown>{langui.licensing_notice}</Markdown> <Markdawn text={format("licensing_notice")} />
</p> </p>
)} )}
<div className="mt-4 mb-8 grid place-content-center"> <div className="mb-8 mt-4 grid place-content-center">
<a <Link
onClick={() => sendAnalytics("MainPanel", "Visit license")} 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 <ColoredSvg
className="h-6 w-6 bg-black group-hover:bg-dark" className="h-6 w-6 bg-black group-hover:bg-dark"
src="/icons/creative-commons-brands.svg" src="/icons/creative-commons-brands.svg"
@ -187,47 +225,44 @@ export const MainPanel = (): JSX.Element => {
className="h-6 w-6 bg-black group-hover:bg-dark" className="h-6 w-6 bg-black group-hover:bg-dark"
src="/icons/creative-commons-sa-brands.svg" src="/icons/creative-commons-sa-brands.svg"
/> />
</a> </Link>
</div> </div>
{isDefinedAndNotEmpty(langui.copyright_notice) && ( {isDefinedAndNotEmpty(format("copyright_notice")) && (
<p> <p>
<Markdown>{langui.copyright_notice}</Markdown> <Markdawn text={format("copyright_notice")} />
</p> </p>
)} )}
<div className="mt-12 mb-4 grid h-4 grid-flow-col place-content-center gap-8"> <div className="mb-4 mt-12 grid h-4 grid-flow-col place-content-center gap-8">
<a <Link
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")} onClick={() => sendAnalytics("MainPanel", "Visit GitHub")}
href="https://github.com/Accords-Library" href="https://github.com/Accords-Library"
target="_blank" alwaysNewTab>
rel="noopener noreferrer">
<ColoredSvg <ColoredSvg
className="h-10 w-10 bg-black hover:bg-dark" className="h-10 w-10 bg-black hover:bg-dark"
src="/icons/github-brands.svg" src="/icons/github-brands.svg"
/> />
</a> </Link>
<a <Link
aria-label="Follow us on Twitter" aria-label="Follow us on Twitter"
onClick={() => sendAnalytics("MainPanel", "Visit Twitter")} onClick={() => sendAnalytics("MainPanel", "Visit Twitter")}
href="https://twitter.com/AccordsLibrary" href="https://twitter.com/AccordsLibrary"
target="_blank" alwaysNewTab>
rel="noopener noreferrer">
<ColoredSvg <ColoredSvg
className="h-10 w-10 bg-black hover:bg-dark" className="h-10 w-10 bg-black hover:bg-dark"
src="/icons/twitter-brands.svg" src="/icons/twitter-brands.svg"
/> />
</a> </Link>
<a <Link
aria-label="Join our Discord server!" aria-label="Join our Discord server!"
onClick={() => sendAnalytics("MainPanel", "Visit Discord")} onClick={() => sendAnalytics("MainPanel", "Visit Discord")}
href="/discord" href="/discord"
target="_blank" alwaysNewTab>
rel="noopener noreferrer">
<ColoredSvg <ColoredSvg
className="h-10 w-10 bg-black hover:bg-dark" className="h-10 w-10 bg-black hover:bg-dark"
src="/icons/discord-brands.svg" src="/icons/discord-brands.svg"
/> />
</a> </Link>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,45 +0,0 @@
import { useMemo } from "react";
import UAParser from "ua-parser-js";
import { useIsClient, useSessionStorage } from "usehooks-ts";
import { Button } from "components/Inputs/Button";
import { Popup } from "components/Containers/Popup";
import { sendAnalytics } from "helpers/analytics";
export const SafariPopup = (): JSX.Element => {
const [hasDisgardedSafariWarning, setHasDisgardedSafariWarning] = useSessionStorage(
"hasDisgardedSafariWarning",
false
);
const isClient = useIsClient();
const isSafari = useMemo<boolean>(() => {
if (isClient) {
const parser = new UAParser();
return parser.getBrowser().name === "Safari" || parser.getOS().name === "iOS";
}
return false;
}, [isClient]);
return (
<Popup isVisible={isSafari && !hasDisgardedSafariWarning}>
<h1 className="text-2xl">Hi, you are using Safari!</h1>
<p className="max-w-lg text-center">
In most cases this wouldn&rsquo;t be a problem but our website isfor some obscure
reasonperforming terribly on Safari (WebKit). Because of that, we have decided to display
this message instead of letting you have a slow and painful experience. We are looking into
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={() => {
setHasDisgardedSafariWarning(true);
sendAnalytics("Safari", "Disgard warning");
}}
/>
</Popup>
);
};

View File

@ -0,0 +1,535 @@
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,7 +1,5 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { Icon } from "components/Ico";
import { Button } from "components/Inputs/Button";
import { ButtonGroup } from "components/Inputs/ButtonGroup"; import { ButtonGroup } from "components/Inputs/ButtonGroup";
import { OrderableList } from "components/Inputs/OrderableList"; import { OrderableList } from "components/Inputs/OrderableList";
import { Select } from "components/Inputs/Select"; import { Select } from "components/Inputs/Select";
@ -9,11 +7,19 @@ import { TextInput } from "components/Inputs/TextInput";
import { Popup } from "components/Containers/Popup"; import { Popup } from "components/Containers/Popup";
import { sendAnalytics } from "helpers/analytics"; import { sendAnalytics } from "helpers/analytics";
import { cJoin, cIf } from "helpers/className"; import { cJoin, cIf } from "helpers/className";
import { prettyLanguage } from "helpers/formatters"; import { filterHasAttributes, isDefined } from "helpers/asserts";
import { filterHasAttributes, isDefined } from "helpers/others";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter, useAtomPair } from "helpers/atoms"; import { useAtomGetter, useAtomPair, useAtomSetter } from "helpers/atoms";
import { ThemeMode } from "contexts/settings"; 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 => { export const SettingsPopup = (): JSX.Element => {
const [preferredLanguages, setPreferredLanguages] = useAtomPair( const [preferredLanguages, setPreferredLanguages] = useAtomPair(
@ -25,33 +31,26 @@ export const SettingsPopup = (): JSX.Element => {
const [fontSize, setFontSize] = useAtomPair(atoms.settings.fontSize); const [fontSize, setFontSize] = useAtomPair(atoms.settings.fontSize);
const [playerName, setPlayerName] = useAtomPair(atoms.settings.playerName); const [playerName, setPlayerName] = useAtomPair(atoms.settings.playerName);
const [themeMode, setThemeMode] = useAtomPair(atoms.settings.themeMode); 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 languages = useAtomGetter(atoms.localData.languages); const { format, formatLanguage } = useFormat();
const langui = useAtomGetter(atoms.localData.langui);
const currencies = useAtomGetter(atoms.localData.currencies); const currencies = useAtomGetter(atoms.localData.currencies);
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout); const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const router = useRouter(); const router = useRouter();
const currencyOptions = useMemo( const currencyOptions = filterHasAttributes(currencies, ["attributes"]).map(
() =>
filterHasAttributes(currencies, ["attributes"] as const).map(
(currentCurrency) => currentCurrency.attributes.code (currentCurrency) => currentCurrency.attributes.code
),
[currencies]
); );
const [currencySelect, setCurrencySelect] = useState<number>(-1); const [currencySelect, setCurrencySelect] = useState<number>(-1);
useEffect(() => { useEffect(() => {
if (isDefined(currency)) setCurrencySelect(currencyOptions.indexOf(currency)); if (isDefined(currency)) setCurrencySelect(currencyOptions.indexOf(currency));
}, [currency, currencyOptions]); }, [currency, currencyOptions]);
useEffect(() => {
if (currencySelect >= 0) setCurrency(currencyOptions[currencySelect]);
}, [currencyOptions, currencySelect, setCurrency]);
return ( return (
<Popup <Popup
isVisible={isSettingsOpened} isVisible={isSettingsOpened}
@ -59,7 +58,10 @@ export const SettingsPopup = (): JSX.Element => {
setSettingsOpened(false); setSettingsOpened(false);
sendAnalytics("Settings", "Close settings"); sendAnalytics("Settings", "Close settings");
}}> }}>
<h2 className="text-2xl">{langui.settings}</h2> <h2 className="inline-flex place-items-center gap-2 text-2xl">
<Ico icon="discover_tune" isFilled />
{format("settings")}
</h2>
<div <div
className={cJoin( className={cJoin(
@ -68,21 +70,21 @@ export const SettingsPopup = (): JSX.Element => {
)}> )}>
{router.locales && ( {router.locales && (
<div> <div>
<h3 className="text-xl">{langui.languages}</h3> <h3 className="text-xl">{format("language", { count: preferredLanguages.length })}</h3>
{preferredLanguages.length > 0 && ( {preferredLanguages.length > 0 && (
<OrderableList <OrderableList
items={preferredLanguages.map((locale) => ({ items={preferredLanguages.map((locale) => ({
code: locale, code: locale,
name: prettyLanguage(locale, languages), name: formatLanguage(locale),
}))} }))}
insertLabels={[ insertLabels={[
{ {
insertAt: 0, insertAt: 0,
name: langui.primary_language ?? "Primary language", name: format("primary_language"),
}, },
{ {
insertAt: 1, insertAt: 1,
name: langui.secondary_language ?? "Secondary languages", name: format("secondary_language"),
}, },
]} ]}
onChange={(items) => { onChange={(items) => {
@ -100,7 +102,12 @@ export const SettingsPopup = (): JSX.Element => {
cIf(!is1ColumnLayout, "grid-cols-2") cIf(!is1ColumnLayout, "grid-cols-2")
)}> )}>
<div> <div>
<h3 className="text-xl">{langui.theme}</h3> <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 <ButtonGroup
buttonsProps={[ buttonsProps={[
{ {
@ -109,7 +116,7 @@ export const SettingsPopup = (): JSX.Element => {
sendAnalytics("Settings", "Change theme (light)"); sendAnalytics("Settings", "Change theme (light)");
}, },
active: themeMode === ThemeMode.Light, active: themeMode === ThemeMode.Light,
text: langui.light, text: format("light"),
}, },
{ {
onClick: () => { onClick: () => {
@ -117,7 +124,7 @@ export const SettingsPopup = (): JSX.Element => {
sendAnalytics("Settings", "Change theme (auto)"); sendAnalytics("Settings", "Change theme (auto)");
}, },
active: themeMode === ThemeMode.Auto, active: themeMode === ThemeMode.Auto,
text: langui.auto, text: format("auto"),
}, },
{ {
onClick: () => { onClick: () => {
@ -125,21 +132,24 @@ export const SettingsPopup = (): JSX.Element => {
sendAnalytics("Settings", "Change theme (dark)"); sendAnalytics("Settings", "Change theme (dark)");
}, },
active: themeMode === ThemeMode.Dark, active: themeMode === ThemeMode.Dark,
text: langui.dark, text: format("dark"),
}, },
]} ]}
/> />
</div> </div>
<div> <div>
<h3 className="text-xl">{langui.currency}</h3> <h3 className="text-xl">{format("currency")}</h3>
<div> <div>
<Select <Select
options={currencyOptions} options={currencyOptions}
value={currencySelect} value={currencySelect}
onChange={(newCurrency) => { onChange={(newCurrency) => {
setCurrencySelect(newCurrency); const newCurrencyName = currencyOptions[newCurrency];
sendAnalytics("Settings", `Change currency (${currencyOptions[newCurrency]})}`); if (isDefined(newCurrencyName)) {
setCurrency(newCurrencyName);
sendAnalytics("Settings", `Change currency (${currencyOptions[newCurrency]})`);
}
}} }}
className="w-28" className="w-28"
/> />
@ -147,7 +157,7 @@ export const SettingsPopup = (): JSX.Element => {
</div> </div>
<div> <div>
<h3 className="text-xl">{langui.font_size}</h3> <h3 className="text-xl">{format("font_size")}</h3>
<ButtonGroup <ButtonGroup
buttonsProps={[ buttonsProps={[
{ {
@ -160,7 +170,7 @@ export const SettingsPopup = (): JSX.Element => {
})}%)` })}%)`
); );
}, },
icon: Icon.TextDecrease, icon: "text_decrease",
}, },
{ {
onClick: () => { onClick: () => {
@ -181,40 +191,50 @@ export const SettingsPopup = (): JSX.Element => {
})}%)` })}%)`
); );
}, },
icon: Icon.TextIncrease, icon: "text_increase",
}, },
]} ]}
/> />
</div> </div>
<div> <div>
<h3 className="text-xl">{langui.font}</h3> <h3 className="text-xl">{format("font")}</h3>
<div className="grid gap-2"> <div className="grid gap-2">
<Button <ButtonGroup
active={!isDyslexic} vertical
onClick={() => { buttonsProps={[
{
active: !isDyslexic,
onClick: () => {
setDyslexic(false); setDyslexic(false);
sendAnalytics("Settings", "Change font (Zen Maru Gothic)"); sendAnalytics("Settings", "Change font (Zen Maru Gothic)");
}} },
className="font-zenMaruGothic" className: "font-zenMaruGothic",
text="Zen Maru Gothic" text: "Zen Maru Gothic",
/> },
<Button {
active={isDyslexic} active: isDyslexic,
onClick={() => { onClick: () => {
setDyslexic(true); setDyslexic(true);
sendAnalytics("Settings", "Change font (OpenDyslexic)"); sendAnalytics("Settings", "Change font (OpenDyslexic)");
}} },
className="font-openDyslexic" className: "font-openDyslexic",
text="OpenDyslexic" text: "OpenDyslexic",
},
]}
/> />
</div> </div>
</div> </div>
<div> <div>
<h3 className="text-xl">{langui.player_name}</h3> <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 <TextInput
placeholder="<player>" placeholder="(player)"
className="w-48" className="w-48"
value={playerName} value={playerName}
onChange={(newName) => { onChange={(newName) => {
@ -223,6 +243,20 @@ export const SettingsPopup = (): JSX.Element => {
}} }}
/> />
</div> </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>
</div> </div>
</Popup> </Popup>

301
src/components/Player.tsx Normal file
View File

@ -0,0 +1,301 @@
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>
);
};

View File

@ -1,20 +1,20 @@
import { Fragment, useCallback, useMemo } from "react"; import { useCallback } from "react";
import { AppLayout, AppLayoutRequired } from "./AppLayout"; import { AppLayout, AppLayoutRequired } from "./AppLayout";
import { Chip } from "./Chip"; import { getTocFromMarkdawn, Markdawn, TableOfContents } from "./Markdown/Markdawn";
import { HorizontalLine } from "./HorizontalLine";
import { Markdawn, TableOfContents } from "./Markdown/Markdawn";
import { ReturnButton } from "./PanelComponents/ReturnButton"; import { ReturnButton } from "./PanelComponents/ReturnButton";
import { ContentPanel } from "./Containers/ContentPanel"; import { ContentPanel } from "./Containers/ContentPanel";
import { SubPanel } from "./Containers/SubPanel"; import { SubPanel } from "./Containers/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 "types/types";
import { filterHasAttributes, getStatusDescription } from "helpers/others"; import { filterHasAttributes, isDefined } from "helpers/asserts";
import { prettySlug } from "helpers/formatters"; import { prettySlug } from "helpers/formatters";
import { useAtomGetter, useAtomSetter } from "helpers/atoms";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { ElementsSeparator } from "helpers/component";
import { HorizontalLine } from "components/HorizontalLine";
import { Credits } from "components/Credits";
import { useFormat } from "hooks/useFormat";
/* /*
* *
@ -49,7 +49,10 @@ export const PostPage = ({
displayTitle = true, displayTitle = true,
...otherProps ...otherProps
}: Props): JSX.Element => { }: Props): JSX.Element => {
const langui = useAtomGetter(atoms.localData.langui); const { formatCategory } = useFormat();
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({ const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
items: post.translations, items: post.translations,
languageExtractor: useCallback( languageExtractor: useCallback(
@ -59,84 +62,37 @@ export const PostPage = ({
), ),
}); });
const { thumbnail, body, title, excerpt } = useMemo( const thumbnail =
() => ({ selectedTranslation?.thumbnail?.data?.attributes ?? post.thumbnail?.data?.attributes;
thumbnail: const body = selectedTranslation?.body ?? "";
selectedTranslation?.thumbnail?.data?.attributes ?? post.thumbnail?.data?.attributes, const title = selectedTranslation?.title ?? prettySlug(post.slug);
body: selectedTranslation?.body ?? "", const excerpt = selectedTranslation?.excerpt ?? "";
title: selectedTranslation?.title ?? prettySlug(post.slug),
excerpt: selectedTranslation?.excerpt ?? "",
}),
[post.slug, post.thumbnail, selectedTranslation]
);
const subPanel = useMemo( const toc = getTocFromMarkdawn(body, title);
() =>
returnHref || returnTitle || displayCredits || displayToc ? ( 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> <SubPanel>
{returnHref && returnTitle && ( <ElementsSeparator>{subPanelElems}</ElementsSeparator>
<ReturnButton href={returnHref} title={returnTitle} displayOnlyOn={"3ColumnsLayout"} />
)}
{displayCredits && (
<>
<HorizontalLine />
{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 recorder={author.attributes} />
</Fragment>
)
)}
</div>
</div>
)}
</>
)}
{displayToc && <TableOfContents text={body} title={title} horizontalLine />}
</SubPanel> </SubPanel>
) : undefined, ) : undefined;
[
body,
displayCredits,
displayToc,
langui,
post.authors,
returnHref,
returnTitle,
selectedTranslation,
title,
]
);
const contentPanel = useMemo( const contentPanel = (
() => (
<ContentPanel> <ContentPanel>
{returnHref && returnTitle && ( {is1ColumnLayout && returnHref && returnTitle && (
<ReturnButton <ReturnButton href={returnHref} title={returnTitle} className="mb-10" />
href={returnHref}
title={returnTitle}
displayOnlyOn={"1ColumnLayout"}
className="mb-10"
/>
)} )}
{displayThumbnailHeader ? ( {displayThumbnailHeader ? (
@ -145,13 +101,17 @@ export const PostPage = ({
thumbnail={thumbnail} thumbnail={thumbnail}
title={title} title={title}
description={excerpt} description={excerpt}
categories={post.categories} categories={filterHasAttributes(post.categories?.data, ["attributes"]).map((category) =>
formatCategory(category.attributes.slug)
)}
releaseDate={post.date}
languageSwitcher={ languageSwitcher={
languageSwitcherProps.locales.size > 1 ? ( languageSwitcherProps.locales.size > 1 ? (
<LanguageSwitcher {...languageSwitcherProps} /> <LanguageSwitcher {...languageSwitcherProps} />
) : undefined ) : undefined
} }
/> />
<HorizontalLine />
</> </>
) : ( ) : (
<> <>
@ -167,32 +127,10 @@ export const PostPage = ({
)} )}
{prependBody} {prependBody}
{body && ( {body && <Markdawn text={body} />}
<>
{displayThumbnailHeader && <HorizontalLine />}
<Markdawn text={body} />
</>
)}
{appendBody} {appendBody}
</ContentPanel> </ContentPanel>
),
[
LanguageSwitcher,
appendBody,
body,
displayLanguageSwitcher,
displayThumbnailHeader,
displayTitle,
excerpt,
languageSwitcherProps,
post.categories,
prependBody,
returnHref,
returnTitle,
thumbnail,
title,
]
); );
return <AppLayout {...otherProps} contentPanel={contentPanel} subPanel={subPanel} />; return <AppLayout {...otherProps} contentPanel={contentPanel} subPanel={subPanel} />;

View File

@ -1,18 +1,20 @@
import { useCallback, useMemo } from "react"; import { MouseEventHandler, useCallback } from "react";
import { useRouter } from "next/router"; import { Markdown } from "./Markdown/Markdown";
import { Chip } from "./Chip"; import { Chip } from "components/Chip";
import { Ico, Icon } from "./Ico"; import { Ico } from "components/Ico";
import { Img } from "./Img"; import { Img } from "components/Img";
import { UpPressable } from "./Containers/UpPressable"; import { UpPressable } from "components/Containers/UpPressable";
import { DatePickerFragment, PricePickerFragment, UploadImageFragment } from "graphql/generated"; import { DatePickerFragment, PricePickerFragment, UploadImageFragment } from "graphql/generated";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { prettyDate, prettyDuration, prettyPrice, prettyShortenNumber } from "helpers/formatters"; import { prettyDuration, prettyShortenNumber } from "helpers/formatters";
import { ImageQuality } from "helpers/img"; import { ImageQuality } from "helpers/img";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
import { TranslatedProps } from "types/TranslatedProps"; import { TranslatedProps } from "types/TranslatedProps";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { useFormat } from "hooks/useFormat";
import { isDefined } from "helpers/asserts";
/* /*
* *
@ -23,6 +25,7 @@ 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;
@ -48,6 +51,8 @@ interface Props {
} }
| { __typename: "anotherHoverlayName" }; | { __typename: "anotherHoverlayName" };
disabled?: boolean; disabled?: boolean;
className?: string;
onClick?: MouseEventHandler<HTMLAnchorElement>;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
@ -57,6 +62,7 @@ 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,
@ -68,51 +74,55 @@ export const PreviewCard = ({
metadata, metadata,
hoverlay, hoverlay,
infoAppend, infoAppend,
className,
disabled = false, disabled = false,
onClick,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const currency = useAtomGetter(atoms.settings.currency); const { formatPrice, formatDate } = useFormat();
const currencies = useAtomGetter(atoms.localData.currencies); const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
const preferredCurrency = useAtomGetter(atoms.settings.currency);
const isHoverable = useDeviceSupportsHover(); const isHoverable = useDeviceSupportsHover();
const router = useRouter();
const metadataJSX = useMemo( const metadataJSX = (
() => (
<> <>
{metadata && (metadata.releaseDate || metadata.price) && ( {metadata && (isDefined(metadata.releaseDate) || isDefined(metadata.price)) && (
<div className="flex w-full flex-row flex-wrap gap-x-3"> <div className="flex w-full flex-row flex-wrap gap-x-3">
{metadata.releaseDate && ( {metadata.releaseDate && (
<p className="text-sm"> <p className="text-sm">
<Ico icon={Icon.Event} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="event" className="mr-1 translate-y-[.15em] !text-base" />
{prettyDate(metadata.releaseDate, router.locale)} {formatDate(metadata.releaseDate)}
</p> </p>
)} )}
{metadata.price && ( {metadata.price && (
<p className="justify-self-end text-sm"> <p className="justify-self-end text-sm">
<Ico icon={Icon.ShoppingCart} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="shopping_cart" className="mr-1 translate-y-[.15em] !text-base" />
{prettyPrice(metadata.price, currencies, currency)} {formatPrice(metadata.price, preferredCurrency)}
</p> </p>
)} )}
{metadata.views && ( {metadata.views && (
<p className="text-sm"> <p className="text-sm">
<Ico icon={Icon.Visibility} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="visibility" className="mr-1 translate-y-[.15em] !text-base" />
{prettyShortenNumber(metadata.views)} {prettyShortenNumber(metadata.views)}
</p> </p>
)} )}
{metadata.author && ( {metadata.author && (
<p className="text-sm"> <p className="text-sm">
<Ico icon={Icon.Person} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="person" className="mr-1 translate-y-[.15em] !text-base" />
{metadata.author} <Markdown text={metadata.author} className="inline-block" />
</p> </p>
)} )}
</div> </div>
)} )}
</> </>
),
[currencies, currency, metadata, router.locale]
); );
return ( return (
<UpPressable className="grid items-end text-left" href={href} noBackground disabled={disabled}> <UpPressable
className={cJoin("relative grid items-end text-left", className)}
href={href}
onClick={onClick}
noBackground
disabled={disabled}>
<div className={cJoin("group", cIf(disabled, "pointer-events-none touch-none select-none"))}> <div className={cJoin("group", cIf(disabled, "pointer-events-none touch-none select-none"))}>
{thumbnail ? ( {thumbnail ? (
<div <div
@ -126,7 +136,12 @@ export const PreviewCard = ({
thumbnailRounded, thumbnailRounded,
cIf(keepInfoVisible, "rounded-t-md", "rounded-md notHoverable:rounded-b-none") cIf(keepInfoVisible, "rounded-t-md", "rounded-md notHoverable:rounded-b-none")
), ),
cIf(thumbnailForceAspectRatio, "h-full w-full object-cover") cIf(thumbnailForceAspectRatio, "h-full w-full"),
cIf(
thumbnailForceAspectRatio && thumbnailFitMethod === "contain",
"object-contain",
"object-cover"
)
)} )}
src={thumbnail} src={thumbnail}
quality={ImageQuality.Medium} quality={ImageQuality.Medium}
@ -135,18 +150,15 @@ export const PreviewCard = ({
{hoverlay && hoverlay.__typename === "Video" && ( {hoverlay && hoverlay.__typename === "Video" && (
<> <>
<div <div
className="group absolute inset-0 grid place-content-center bg-shade bg-opacity-0 className="absolute inset-0 grid place-content-center rounded-t-md
text-light transition-colors bg-shade/0 text-light transition-colors group-hover:bg-shade/50">
hover:bg-opacity-50">
<Ico <Ico
icon={Icon.PlayCircleOutline} icon="play_circle"
className="!text-6xl text-black opacity-0 drop-shadow-lg transition-opacity className="!text-6xl text-light opacity-0 drop-shadow-lg transition-opacity
shadow-shade group-hover:opacity-100" shadow-shade group-hover:opacity-100 dark:text-black"
/> />
</div> </div>
<div <div className="absolute bottom-2 right-2 rounded-full bg-black/60 px-2 text-light">
className="absolute right-2 bottom-2 rounded-full bg-black bg-opacity-60 px-2
text-light">
{prettyDuration(hoverlay.duration)} {prettyDuration(hoverlay.duration)}
</div> </div>
</> </>
@ -166,11 +178,11 @@ export const PreviewCard = ({
"z-20 grid gap-2 p-4 transition-opacity linearbg-obi", "z-20 grid gap-2 p-4 transition-opacity linearbg-obi",
cIf( cIf(
!keepInfoVisible && isHoverable, !keepInfoVisible && isHoverable,
`-inset-x-0.5 bottom-2 opacity-0 shadow-shade `-inset-x-0.5 bottom-2 opacity-0 !shadow-shade
[border-radius:10%_10%_10%_10%_/_1%_1%_3%_3%] [border-radius:10%_10%_10%_10%_/_1%_1%_3%_3%]
group-hover:opacity-100 hoverable:absolute hoverable:drop-shadow-lg group-hover:opacity-100 hoverable:absolute hoverable:shadow-lg
notHoverable:rounded-b-md notHoverable:opacity-100`, notHoverable:rounded-b-md notHoverable:opacity-100`,
"[border-radius:0%_0%_10%_10%_/_0%_0%_3%_3%]" cIf(!isPerfModeEnabled, "[border-radius:0%_0%_10%_10%_/_0%_0%_3%_3%]")
) )
)}> )}>
{metadata?.position === "Top" && metadataJSX} {metadata?.position === "Top" && metadataJSX}
@ -184,13 +196,16 @@ export const PreviewCard = ({
</div> </div>
)} )}
<div className="my-1"> <div className="my-1">
{pre_title && <p className="mb-1 leading-none break-words">{pre_title}</p>} {pre_title && <Markdown text={pre_title} className="mb-1 leading-none break-words" />}
{title && ( {title && (
<p className="font-headers text-lg font-bold leading-none break-words">{title}</p> <Markdown
text={title}
className="font-headers text-lg font-bold leading-none break-words"
/>
)} )}
{subtitle && <p className="leading-none break-words">{subtitle}</p>} {subtitle && <Markdown text={subtitle} className="leading-none break-words" />}
</div> </div>
{description && <p>{description}</p>} {description && <Markdown text={description} className="overflow-hidden break-words" />}
{bottomChips && bottomChips.length > 0 && ( {bottomChips && bottomChips.length > 0 && (
<div <div
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll

View File

@ -1,103 +0,0 @@
import { useCallback } from "react";
import { Chip } from "./Chip";
import { Img } from "./Img";
import { UpPressable } from "./Containers/UpPressable";
import { UploadImageFragment } from "graphql/generated";
import { ImageQuality } from "helpers/img";
import { TranslatedProps } from "types/TranslatedProps";
import { useSmartLanguage } from "hooks/useSmartLanguage";
import { cIf, cJoin } from "helpers/className";
import { isDefined } from "helpers/others";
/*
*
* COMPONENT
*/
interface Props {
thumbnail?: UploadImageFragment | string | null | undefined;
href: string;
pre_title?: string | null | undefined;
title: string | null | undefined;
subtitle?: string | null | undefined;
topChips?: string[];
bottomChips?: string[];
disabled?: boolean;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const PreviewLine = ({
href,
thumbnail,
pre_title,
title,
subtitle,
topChips,
disabled,
bottomChips,
}: Props): JSX.Element => (
<UpPressable href={href} disabled={disabled}>
<div
className={cJoin(
"grid w-full grid-flow-col place-items-center gap-4",
cIf(disabled, "pointer-events-none touch-none select-none")
)}>
{thumbnail && (
<div className="h-full w-full">
<Img className="h-full object-cover" src={thumbnail} quality={ImageQuality.Medium} />
</div>
)}
<div className={cJoin("grid gap-2 py-4", cIf(isDefined(thumbnail), "pr-3", "px-6"))}>
{topChips && topChips.length > 0 && (
<div
className="grid grid-flow-col place-content-start gap-1 overflow-scroll
scrollbar-none">
{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-scroll
scrollbar-none">
{bottomChips.map((text, index) => (
<Chip key={index} className="text-sm" text={text} />
))}
</div>
)}
</div>
</div>
</UpPressable>
);
/*
*
* TRANSLATED VARIANT
*/
export const TranslatedPreviewLine = ({
translations,
fallback,
...otherProps
}: TranslatedProps<Props, "pre_title" | "subtitle" | "title">): JSX.Element => {
const [selectedTranslation] = useSmartLanguage({
items: translations,
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
});
return (
<PreviewLine
pre_title={selectedTranslation?.pre_title ?? fallback.pre_title}
title={selectedTranslation?.title ?? fallback.title}
subtitle={selectedTranslation?.subtitle ?? fallback.subtitle}
{...otherProps}
/>
);
};

View File

@ -3,11 +3,12 @@ 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 { ImageQuality } from "helpers/img"; import { ImageQuality } from "helpers/img";
import { filterHasAttributes } from "helpers/others"; import { filterHasAttributes, isUndefined } from "helpers/asserts";
import { atoms } from "contexts/atoms"; import { useFormat } from "hooks/useFormat";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { atoms } from "contexts/atoms";
import { useSmartLanguage } from "hooks/useSmartLanguage";
/* /*
* *
@ -15,14 +16,22 @@ import { useAtomGetter } from "helpers/atoms";
*/ */
interface Props { interface Props {
className?: string; username: string;
recorder: RecorderChipFragment;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const RecorderChip = ({ recorder }: Props): JSX.Element => { export const RecorderChip = ({ username }: Props): JSX.Element => {
const langui = useAtomGetter(atoms.localData.langui); const { format } = useFormat();
const recorders = useAtomGetter(atoms.localData.recorders);
const recorder = recorders.find((elem) => elem.attributes?.username === username)?.attributes;
const [selectedBioTranslation] = useSmartLanguage({
items: recorder?.bio ?? [],
languageExtractor: (bio) => bio.language?.data?.attributes?.code,
});
if (isUndefined(recorder)) return <></>;
return ( return (
<ToolTip <ToolTip
@ -40,25 +49,23 @@ export const RecorderChip = ({ recorder }: Props): JSX.Element => {
<h3 className=" text-2xl">{recorder.username}</h3> <h3 className=" text-2xl">{recorder.username}</h3>
{recorder.languages?.data && recorder.languages.data.length > 0 && ( {recorder.languages?.data && recorder.languages.data.length > 0 && (
<div className="flex flex-row flex-wrap gap-1"> <div className="flex flex-row flex-wrap gap-1">
<p>{langui.languages}:</p> <p>{format("language", { count: recorder.languages.data.length })}:</p>
{filterHasAttributes(recorder.languages.data, ["attributes"] as const).map( {filterHasAttributes(recorder.languages.data, ["attributes"]).map((language) => (
(language) => (
<Fragment key={language.__typename}> <Fragment key={language.__typename}>
<Chip text={language.attributes.code.toUpperCase()} /> <Chip text={language.attributes.code.toUpperCase()} />
</Fragment> </Fragment>
) ))}
)}
</div> </div>
)} )}
{recorder.pronouns && ( {recorder.pronouns && (
<div className="flex flex-row flex-wrap gap-1"> <div className="flex flex-row flex-wrap gap-1">
<p>{langui.pronouns}:</p> <p>{format("pronouns")}:</p>
<Chip text={recorder.pronouns} /> <Chip text={recorder.pronouns} />
</div> </div>
)} )}
</div> </div>
</div> </div>
{recorder.bio?.[0] && <Markdawn text={recorder.bio[0].bio ?? ""} />} {selectedBioTranslation?.bio && <Markdawn text={selectedBioTranslation.bio} />}
</div> </div>
} }
placement="top"> placement="top">

View File

@ -1,244 +0,0 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import naturalCompare from "string-natural-compare";
import { Chip } from "./Chip";
import { PageSelector } from "./Inputs/PageSelector";
import { Ico, Icon } from "./Ico";
import { cJoin } from "helpers/className";
import { isDefined, isDefinedAndNotEmpty } from "helpers/others";
import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { Ids } from "types/ids";
import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms";
interface Group<T> {
name: string;
items: T[];
totalCount: number;
}
const defaultGroupSortingFunction = <T,>(a: Group<T>, b: Group<T>) =>
naturalCompare(a.name, 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;
}
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,
}: Props<T>): JSX.Element => {
const [page, setPage] = useState(0);
const langui = useAtomGetter(atoms.localData.langui);
useScrollTopOnChange(Ids.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]);
useHotkeys("left", () => setPage((current) => current - 1), { enabled: page > 0 });
useHotkeys("right", () => setPage((current) => current + 1), {
enabled: page < pages.length - 1,
});
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-2 border-dotted pb-12
last-of-type:border-0`,
className
)}>
{group.items.map((item) => (
<RenderItem item={item} key={getItemId(item)} />
))}
</div>
</Fragment>
)
)
) : isDefined(RenderWhenEmpty) ? (
<RenderWhenEmpty />
) : (
<DefaultRenderWhenEmpty />
)}
</div>
{pages.length > 1 && paginationSelectorBottom && (
<PageSelector className="mb-12" page={page} pagesCount={pages.length} onChange={setPage} />
)}
</>
);
};
/*
*
* PRIVATE COMPONENTS
*/
const DefaultRenderWhenEmpty = () => {
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
const langui = useAtomGetter(atoms.localData.langui);
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={Icon.ChevronLeft} className="!text-[300%]" />}
<p className="max-w-xs text-2xl">{langui.no_results_message}</p>
{!is3ColumnsLayout && <Ico icon={Icon.ChevronRight} className="!text-[300%]" />}
</div>
</div>
);
};

View File

@ -2,12 +2,12 @@ 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/Containers/InsetBox";
import { Markdawn } from "components/Markdown/Markdawn"; import { Markdawn } from "components/Markdown/Markdawn";
import { GetContentTextQuery, UploadImageFragment } from "graphql/generated"; import { DatePickerFragment, UploadImageFragment } from "graphql/generated";
import { prettyInlineTitle, prettySlug, slugify } from "helpers/formatters"; import { prettyInlineTitle, slugify } from "helpers/formatters";
import { ImageQuality } from "helpers/img"; import { ImageQuality } from "helpers/img";
import { filterHasAttributes } from "helpers/others";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useFormat } from "hooks/useFormat";
/* /*
* *
@ -19,14 +19,11 @@ 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?: NonNullable< type?: string;
NonNullable<GetContentTextQuery["contents"]>["data"][number]["attributes"] categories?: string[];
>["type"]; releaseDate?: DatePickerFragment;
categories?: NonNullable<
NonNullable<GetContentTextQuery["contents"]>["data"][number]["attributes"]
>["categories"];
thumbnail?: UploadImageFragment | null | undefined; thumbnail?: UploadImageFragment | null | undefined;
className?: string;
languageSwitcher?: JSX.Element; languageSwitcher?: JSX.Element;
} }
@ -41,13 +38,15 @@ export const ThumbnailHeader = ({
categories, categories,
description, description,
languageSwitcher, languageSwitcher,
releaseDate,
className,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const langui = useAtomGetter(atoms.localData.langui); const { format, formatDate } = useFormat();
const { showLightBox } = useAtomGetter(atoms.lightBox); const { showLightBox } = useAtomGetter(atoms.lightBox);
return ( return (
<> <div className={className}>
<div className="mb-12 grid place-items-center gap-12"> <div className={"mb-12 grid place-items-center gap-12"}>
<div className="drop-shadow-lg shadow-shade"> <div className="drop-shadow-lg shadow-shade">
{thumbnail ? ( {thumbnail ? (
<Img <Img
@ -70,34 +69,37 @@ 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?.data?.attributes && ( {type && (
<div className="flex flex-col place-items-center gap-2"> <div className="flex flex-col place-items-center gap-2">
<h3 className="text-xl">{langui.type}</h3> <h3 className="text-xl">{format("type", { count: 1 })}</h3>
<div className="flex flex-row flex-wrap"> <div className="flex flex-row flex-wrap">
<Chip <Chip text={type} />
text={
type.data.attributes.titles?.[0]?.title ?? prettySlug(type.data.attributes.slug)
}
/>
</div> </div>
</div> </div>
)} )}
{categories && categories.data.length > 0 && ( {releaseDate && (
<div className="flex flex-col place-items-center gap-2"> <div className="flex flex-col place-items-center gap-2">
<h3 className="text-xl">{langui.categories}</h3> <h3 className="text-xl">{format("release_date")}</h3>
<div className="flex flex-row flex-wrap place-content-center gap-2"> <div className="flex flex-row flex-wrap">
{filterHasAttributes(categories.data, ["attributes", "id"] as const).map( <Chip text={formatDate(releaseDate)} />
(category) => ( </div>
<Chip key={category.id} text={category.attributes.name} /> </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">
{categories.map((category) => (
<Chip key={category} text={category} />
))}
</div> </div>
</div> </div>
)} )}
{languageSwitcher} {languageSwitcher}
</div> </div>
{description && <InsetBox className="mt-8">{<Markdawn text={description} />}</InsetBox>} {description && <InsetBox className="mt-8">{<Markdawn text={description} />}</InsetBox>}
</> </div>
); );
}; };

View File

@ -1,5 +1,5 @@
// eslint-disable-next-line import/named import Tippy from "@tippyjs/react";
import Tippy, { TippyProps } from "@tippyjs/react"; import type { 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";

View File

@ -1,12 +1,12 @@
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 { 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 { cIf, cJoin } from "helpers/className";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { ContentStatus, useFormat } from "hooks/useFormat";
/* /*
* *
@ -21,7 +21,7 @@ interface Props {
translations: { translations: {
language: string | undefined; language: string | undefined;
definition: string | null | undefined; definition: string | null | undefined;
status: string | undefined; status: ContentStatus | undefined;
}[]; }[];
index: number; index: number;
categories: string[]; categories: string[];
@ -31,7 +31,7 @@ interface Props {
const DefinitionCard = ({ source, translations = [], index, categories }: Props): JSX.Element => { const DefinitionCard = ({ source, translations = [], index, categories }: Props): JSX.Element => {
const isContentPanelAtLeastMd = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastMd); const isContentPanelAtLeastMd = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastMd);
const langui = useAtomGetter(atoms.localData.langui); const { format, formatStatusDescription } = useFormat();
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({ const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
items: translations, items: translations,
languageExtractor: useCallback((item: Props["translations"][number]) => item.language, []), languageExtractor: useCallback((item: Props["translations"][number]) => item.language, []),
@ -40,7 +40,7 @@ const DefinitionCard = ({ source, translations = [], index, categories }: Props)
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">{`${langui.definition} ${index}`}</p> <p className="font-headers text-lg font-bold">{format("definition_x", { x: index })}</p>
{translations.length > 1 && ( {translations.length > 1 && (
<> <>
@ -53,7 +53,7 @@ const DefinitionCard = ({ source, translations = [], index, categories }: Props)
<> <>
<Separator /> <Separator />
<ToolTip <ToolTip
content={getStatusDescription(selectedTranslation.status, langui)} content={formatStatusDescription(selectedTranslation.status)}
maxWidth={"20rem"}> maxWidth={"20rem"}>
<Chip text={selectedTranslation.status} /> <Chip text={selectedTranslation.status} />
</ToolTip> </ToolTip>
@ -80,7 +80,7 @@ const DefinitionCard = ({ source, translations = [], index, categories }: Props)
"mt-3 flex place-items-center gap-2", "mt-3 flex place-items-center gap-2",
cIf(!isContentPanelAtLeastMd, "flex-col text-center") cIf(!isContentPanelAtLeastMd, "flex-col text-center")
)}> )}>
<p>{langui.source}: </p> <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

@ -1,28 +1,19 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useEffectOnce } from "usehooks-ts"; import { useEffectOnce } from "usehooks-ts";
import { atom } from "jotai";
import { UploadImageFragment } from "graphql/generated"; import { UploadImageFragment } from "graphql/generated";
import { LightBox } from "components/LightBox"; import { LightBox } from "components/LightBox";
import { filterDefined } from "helpers/others"; import { filterDefined } from "helpers/asserts";
import { atomPairing, useAtomSetter } from "helpers/atoms"; import { useAtomSetter } from "helpers/atoms";
import { internalAtoms } from "contexts/atoms";
const lightBoxAtom = atomPairing(
atom<{
showLightBox: (
images: (UploadImageFragment | string | null | undefined)[],
index?: number
) => void;
}>({ showLightBox: () => null })
);
export const lightBox = lightBoxAtom[0];
export const LightBoxProvider = (): JSX.Element => { export const LightBoxProvider = (): JSX.Element => {
const router = useRouter();
const [isLightBoxVisible, setLightBoxVisibility] = useState(false); const [isLightBoxVisible, setLightBoxVisibility] = useState(false);
const [lightBoxImages, setLightBoxImages] = useState<(UploadImageFragment | string)[]>([]); const [lightBoxImages, setLightBoxImages] = useState<(UploadImageFragment | string)[]>([]);
const [lightBoxIndex, setLightBoxIndex] = useState(0); const [lightBoxIndex, setLightBoxIndex] = useState(0);
const setShowLightBox = useAtomSetter(lightBoxAtom); const setShowLightBox = useAtomSetter(internalAtoms.lightBox);
useEffectOnce(() => useEffectOnce(() =>
setShowLightBox({ setShowLightBox({
@ -40,6 +31,8 @@ export const LightBoxProvider = (): JSX.Element => {
setTimeout(() => setLightBoxImages([]), 100); setTimeout(() => setLightBoxImages([]), 100);
}, []); }, []);
useEffect(() => router.events.on("routeChangeStart", closeLightBox));
return ( return (
<LightBox <LightBox
isVisible={isLightBoxVisible} isVisible={isLightBoxVisible}

View File

@ -1,29 +0,0 @@
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useScrollIntoView } from "hooks/useScrollIntoView";
import { useAtomSetter } from "helpers/atoms";
import { atoms } from "contexts/atoms";
export const useAppLayout = (): void => {
const router = useRouter();
const setSettingsOpened = useAtomSetter(atoms.layout.settingsOpened);
const setMainPanelOpened = useAtomSetter(atoms.layout.mainPanelOpened);
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
useEffect(() => {
router.events.on("routeChangeStart", () => {
console.log("[Router Events] on routeChangeStart");
setSettingsOpened(false);
setMainPanelOpened(false);
setSubPanelOpened(false);
});
router.events.on("hashChangeStart", () => {
console.log("[Router Events] on hashChangeStart");
setSubPanelOpened(false);
});
}, [router, setSettingsOpened, setMainPanelOpened, setSubPanelOpened]);
useScrollIntoView();
};

View File

@ -1,49 +1,91 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils"; import { atomWithStorage } from "jotai/utils";
import { localData } from "contexts/localData";
import { containerQueries } from "contexts/containerQueries"; import { containerQueries } from "contexts/containerQueries";
import { userAgent } from "contexts/userAgent";
import { atomPairing } from "helpers/atoms"; import { atomPairing } from "helpers/atoms";
import { settings } from "contexts/settings"; import { settings } from "contexts/settings";
import { lightBox } from "contexts/LightBoxProvider"; import { UploadImageFragment } from "graphql/generated";
import { Languages, Currencies, Langui, Recorders, TypesTranslations } from "helpers/localData";
/* /* [ LOCAL DATA ATOMS ] */
* I'm getting a weird error if I put those atoms in appLayout.ts
* So I'm putting the atoms here. Sucks, I know. const languages = atomPairing(atom<Languages>([]));
*/ const currencies = atomPairing(atom<Currencies>([]));
const langui = atomPairing(atom<Langui>({}));
const fallbackLangui = atomPairing(atom<Langui>({}));
const recorders = atomPairing(atom<Recorders>([]));
const typesTranslations = atomPairing(
atom<TypesTranslations>({
audioSubtypes: [],
categories: [],
contentTypes: [],
gamePlatforms: [],
groupSubtypes: [],
metadataTypes: [],
textualSubtypes: [],
videoSubtypes: [],
wikiPagesTags: [],
weaponTypes: [],
})
);
const localData = {
languages: languages[0],
currencies: currencies[0],
langui: langui[0],
fallbackLangui: fallbackLangui[0],
recorders: recorders[0],
typesTranslations: typesTranslations[0],
};
/* [ LIGHTBOX ATOMS ] */
const lightBoxAtom = atomPairing(
atom<{
showLightBox: (
images: (UploadImageFragment | string | null | undefined)[],
index?: number
) => void;
}>({ showLightBox: () => null })
);
const lightBox = lightBoxAtom[0];
/* [ APPLAYOUT ATOMS ] */ /* [ APPLAYOUT ATOMS ] */
const mainPanelReduced = atomPairing(atomWithStorage("isMainPanelReduced", false)); const mainPanelReduced = atomPairing(atomWithStorage("isMainPanelReduced", false));
const settingsOpened = atomPairing(atomWithStorage("isSettingsOpened", false)); const searchOpened = atomPairing(atom(false));
const subPanelOpened = atomPairing(atomWithStorage("isSubPanelOpened", false)); const settingsOpened = atomPairing(atom(false));
const mainPanelOpened = atomPairing(atomWithStorage("isMainPanelOpened", false)); const subPanelOpened = atomPairing(atom(false));
const mainPanelOpened = atomPairing(atom(false));
const debugMenuOpened = atomPairing(atom(false));
const debugMenuAvailable = atom((get) => get(settings.playerName[0]) === "debug");
const menuGesturesEnabled = atomPairing(atomWithStorage("isMenuGesturesEnabled", false)); const menuGesturesEnabled = atomPairing(atomWithStorage("isMenuGesturesEnabled", false));
const terminalMode = atom((get) => get(settings.playerName[0]) === "root"); const terminalMode = atom((get) => get(settings.playerName[0]) === "root");
const layout = { const layout = {
searchOpened,
mainPanelReduced, mainPanelReduced,
settingsOpened, settingsOpened,
subPanelOpened, subPanelOpened,
mainPanelOpened, mainPanelOpened,
menuGesturesEnabled, menuGesturesEnabled,
terminalMode, terminalMode,
}; debugMenuAvailable,
debugMenuOpened,
/* [ TERMINAL ATOMS ] */
const previousLines = atomPairing(atom<string[]>([]));
const previousCommands = atomPairing(atom<string[]>([]));
const terminal = {
previousLines,
previousCommands,
}; };
export const atoms = { export const atoms = {
settings, settings,
layout, layout,
terminal,
localData, localData,
lightBox, lightBox,
containerQueries, containerQueries,
userAgent,
};
// Do not import outside of the "contexts" folder
export const internalAtoms = {
lightBox: lightBoxAtom,
localData: { languages, currencies, langui, fallbackLangui, recorders, typesTranslations },
}; };

View File

@ -1,39 +1,35 @@
import { atom } from "jotai";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useFetch } from "usehooks-ts"; import { useFetch } from "usehooks-ts";
import { atomPairing, useAtomSetter } from "helpers/atoms"; import { useAtomSetter } from "helpers/atoms";
import {
Languages,
Currencies,
Langui,
processLangui,
processCurrencies,
processLanguages,
} from "helpers/localData";
import { import {
LocalDataGetWebsiteInterfacesQuery, LocalDataGetWebsiteInterfacesQuery,
LocalDataGetCurrenciesQuery, LocalDataGetCurrenciesQuery,
LocalDataGetLanguagesQuery, LocalDataGetLanguagesQuery,
LocalDataGetRecordersQuery,
} from "graphql/generated"; } from "graphql/generated";
import { LocalDataFile } from "graphql/fetchLocalData"; import { LocalDataFile } from "graphql/fetchLocalData";
import { internalAtoms } from "contexts/atoms";
const languages = atomPairing(atom<Languages>([])); import {
const currencies = atomPairing(atom<Currencies>([])); processLanguages,
const langui = atomPairing(atom<Langui>({})); processCurrencies,
processLangui,
export const localData = { processRecorders,
languages: languages[0], processTypesTranslations,
currencies: currencies[0], } from "helpers/localData";
langui: langui[0], import { getLogger } from "helpers/logger";
};
const getFileName = (name: LocalDataFile): string => `/local-data/${name}.json`; const getFileName = (name: LocalDataFile): string => `/local-data/${name}.json`;
const logger = getLogger("💽 [Local Data]");
export const useLocalData = (): void => { export const useLocalData = (): void => {
const setLanguages = useAtomSetter(languages); const setLanguages = useAtomSetter(internalAtoms.localData.languages);
const setCurrencies = useAtomSetter(currencies); const setCurrencies = useAtomSetter(internalAtoms.localData.currencies);
const setLangui = useAtomSetter(langui); const setLangui = useAtomSetter(internalAtoms.localData.langui);
const setFallbackLangui = useAtomSetter(internalAtoms.localData.fallbackLangui);
const setRecorders = useAtomSetter(internalAtoms.localData.recorders);
const setTypesTranslations = useAtomSetter(internalAtoms.localData.typesTranslations);
const { locale } = useRouter(); const { locale } = useRouter();
const { data: rawLanguages } = useFetch<LocalDataGetLanguagesQuery>(getFileName("languages")); const { data: rawLanguages } = useFetch<LocalDataGetLanguagesQuery>(getFileName("languages"));
@ -41,19 +37,38 @@ export const useLocalData = (): void => {
const { data: rawLangui } = useFetch<LocalDataGetWebsiteInterfacesQuery>( const { data: rawLangui } = useFetch<LocalDataGetWebsiteInterfacesQuery>(
getFileName("websiteInterfaces") getFileName("websiteInterfaces")
); );
const { data: rawRecorders } = useFetch<LocalDataGetRecordersQuery>(getFileName("recorders"));
const { data: rawTypesTranslations } = useFetch<LocalDataGetRecordersQuery>(
getFileName("typesTranslations")
);
useEffect(() => { useEffect(() => {
console.log("[useLocalData] Refresh languages"); logger.log("Refresh languages");
setLanguages(processLanguages(rawLanguages)); setLanguages(processLanguages(rawLanguages));
}, [rawLanguages, setLanguages]); }, [rawLanguages, setLanguages]);
useEffect(() => { useEffect(() => {
console.log("[useLocalData] Refresh currencies"); logger.log("Refresh currencies");
setCurrencies(processCurrencies(rawCurrencies)); setCurrencies(processCurrencies(rawCurrencies));
}, [rawCurrencies, setCurrencies]); }, [rawCurrencies, setCurrencies]);
useEffect(() => { useEffect(() => {
console.log("[useLocalData] Refresh langui"); logger.log("Refresh langui");
setLangui(processLangui(rawLangui, locale)); setLangui(processLangui(rawLangui, locale));
}, [locale, rawLangui, setLangui]); }, [locale, rawLangui, setLangui]);
useEffect(() => {
logger.log("Refresh fallback langui");
setFallbackLangui(processLangui(rawLangui, "en"));
}, [rawLangui, setFallbackLangui]);
useEffect(() => {
logger.log("Refresh recorders");
setRecorders(processRecorders(rawRecorders));
}, [rawRecorders, setRecorders]);
useEffect(() => {
logger.log("Refresh types translations");
setTypesTranslations(processTypesTranslations(rawTypesTranslations));
}, [rawTypesTranslations, setTypesTranslations]);
}; };

View File

@ -1,11 +1,12 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useLayoutEffect, useEffect } from "react"; import { useEffect } from "react";
import { atom } from "jotai"; import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils"; import { atomWithStorage } from "jotai/utils";
import { atomPairing, useAtomGetter, useAtomPair } from "helpers/atoms"; import { atomPairing, useAtomGetter, useAtomPair } from "helpers/atoms";
import { getDefaultPreferredLanguages } from "helpers/locales"; import { isDefined } from "helpers/asserts";
import { isDefined, isDefinedAndNotEmpty } from "helpers/others";
import { usePrefersDarkMode } from "hooks/useMediaQuery"; import { usePrefersDarkMode } from "hooks/useMediaQuery";
import { userAgent } from "contexts/userAgent";
import { getLogger } from "helpers/logger";
export enum ThemeMode { export enum ThemeMode {
Dark = "dark", Dark = "dark",
@ -13,13 +14,45 @@ export enum ThemeMode {
Light = "light", Light = "light",
} }
export enum PerfMode {
On = "on",
Auto = "auto",
Off = "off",
}
const logger = getLogger("⚙️ [Settings Context]");
const preferredLanguagesAtom = atomPairing(atomWithStorage<string[]>("preferredLanguages", [])); const preferredLanguagesAtom = atomPairing(atomWithStorage<string[]>("preferredLanguages", []));
const themeModeAtom = atomPairing(atomWithStorage<ThemeMode>("themeMode", ThemeMode.Auto)); const themeModeAtom = atomPairing(atomWithStorage("themeMode", ThemeMode.Auto));
const darkModeAtom = atomPairing(atom(false)); const darkModeAtom = atomPairing(atom(false));
const fontSizeAtom = atomPairing(atomWithStorage("fontSize", 1)); const fontSizeAtom = atomPairing(atomWithStorage("fontSize", 1));
const dyslexicAtom = atomPairing(atomWithStorage("isDyslexic", false)); const dyslexicAtom = atomPairing(atomWithStorage("isDyslexic", false));
const currencyAtom = atomPairing(atomWithStorage("currency", "USD")); const currencyAtom = atomPairing(atomWithStorage("currency", "USD"));
const playerNameAtom = atomPairing(atomWithStorage("playerName", "")); const playerNameAtom = atomPairing(atomWithStorage("playerName", ""));
const perfModeAtom = atomPairing(atomWithStorage("perfMode", PerfMode.Auto));
const isPerfModeEnabledAtom = atom((get) => {
const os = get(userAgent.os);
const engine = get(userAgent.engine);
const perfMode = get(perfModeAtom[0]);
if (os === "iOS") return true;
if (engine === "WebKit") return true;
if (perfMode === "auto") {
if (engine === "Blink") return false;
if (os === "Linux") return true;
if (os === "Android") return true;
}
return perfMode === PerfMode.On;
});
const isPerfModeToggleableAtom = atom((get) => {
const engine = get(userAgent.engine);
const os = get(userAgent.os);
if (os === "iOS") return false;
if (engine === "WebKit") return false;
return true;
});
export const settings = { export const settings = {
preferredLanguages: preferredLanguagesAtom, preferredLanguages: preferredLanguagesAtom,
@ -29,6 +62,9 @@ export const settings = {
dyslexic: dyslexicAtom, dyslexic: dyslexicAtom,
currency: currencyAtom, currency: currencyAtom,
playerName: playerNameAtom, playerName: playerNameAtom,
perfMode: perfModeAtom,
isPerfModeEnabled: isPerfModeEnabledAtom,
isPerfModeToggleable: isPerfModeToggleableAtom,
}; };
export const useSettings = (): void => { export const useSettings = (): void => {
@ -39,14 +75,14 @@ export const useSettings = (): void => {
const [isDarkMode, setDarkMode] = useAtomPair(darkModeAtom); const [isDarkMode, setDarkMode] = useAtomPair(darkModeAtom);
const themeMode = useAtomGetter(themeModeAtom); const themeMode = useAtomGetter(themeModeAtom);
useLayoutEffect(() => { useEffect(() => {
const html = document.getElementsByTagName("html")[0]; const html = document.getElementsByTagName("html")[0];
if (isDefined(html)) { if (isDefined(html)) {
html.style.fontSize = `${fontSize * 100}%`; html.style.fontSize = `${fontSize * 100}%`;
} }
}, [fontSize]); }, [fontSize]);
useLayoutEffect(() => { useEffect(() => {
const next = document.getElementById("__next"); const next = document.getElementById("__next");
if (isDefined(next)) { if (isDefined(next)) {
if (isDyslexic) { if (isDyslexic) {
@ -66,7 +102,7 @@ export const useSettings = (): void => {
setDarkMode(themeMode === ThemeMode.Auto ? prefersDarkMode : themeMode === ThemeMode.Dark); setDarkMode(themeMode === ThemeMode.Auto ? prefersDarkMode : themeMode === ThemeMode.Dark);
}, [prefersDarkMode, setDarkMode, themeMode]); }, [prefersDarkMode, setDarkMode, themeMode]);
useLayoutEffect(() => { useEffect(() => {
const next = document.getElementById("__next"); const next = document.getElementById("__next");
if (isDefined(next)) { if (isDefined(next)) {
if (isDarkMode) { if (isDarkMode) {
@ -80,24 +116,33 @@ export const useSettings = (): void => {
}, [isDarkMode]); }, [isDarkMode]);
/* PREFERRED LANGUAGES */ /* PREFERRED LANGUAGES */
useEffect(() => { useEffect(() => {
if (preferredLanguages.length === 0) { if (!router.locale || !router.locales) return;
if (isDefinedAndNotEmpty(router.locale) && router.locales) { const localStorageValue: string[] = JSON.parse(
setPreferredLanguages(getDefaultPreferredLanguages(router.locale, router.locales)); localStorage.getItem("preferredLanguages") ?? "[]"
}
} else if (router.locale !== preferredLanguages[0]) {
/*
* Using a timeout to the code getting stuck into a loop when reaching the website with a
* different preferredLanguages[0] from router.locale
*/
setTimeout(
async () =>
router.replace(router.asPath, router.asPath, {
locale: preferredLanguages[0],
}),
250
); );
if (localStorageValue.length === 0) {
const defaultLanguages = router.locales;
defaultLanguages.sort((a, b) => {
const evaluate = (value: string) =>
navigator.languages.includes(value)
? navigator.languages.findIndex((v) => value === v)
: navigator.languages.length;
return evaluate(a) - evaluate(b);
});
logger.log("First time visitor, initializing preferred languages to", defaultLanguages);
setPreferredLanguages(defaultLanguages);
} else if (router.locale !== localStorageValue[0]) {
logger.log(
"Router locale",
router.locale,
"doesn't correspond to preferred locale. Switching to",
localStorageValue[0]
);
router.replace(router.asPath, router.asPath, {
locale: localStorageValue[0],
});
} }
}, [preferredLanguages, router, setPreferredLanguages]); }, [router, setPreferredLanguages, preferredLanguages]);
}; };

45
src/contexts/userAgent.ts Normal file
View File

@ -0,0 +1,45 @@
import { atom } from "jotai";
import { useIsClient } from "usehooks-ts";
import { useEffect } from "react";
import { UAParser } from "ua-parser-js";
import { atomPairing, useAtomSetter } from "helpers/atoms";
import { getLogger } from "helpers/logger";
const logger = getLogger("📱 [User Agent]");
const osAtom = atomPairing(atom<string | undefined>(undefined));
const browserAtom = atomPairing(atom<string | undefined>(undefined));
const engineAtom = atomPairing(atom<string | undefined>(undefined));
const deviceTypeAtom = atomPairing(atom<string | undefined>(undefined));
export const userAgent = {
os: osAtom[0],
browser: browserAtom[0],
engine: engineAtom[0],
deviceType: deviceTypeAtom[0],
};
export const useUserAgent = (): void => {
const setOs = useAtomSetter(osAtom);
const setBrowser = useAtomSetter(browserAtom);
const setEngine = useAtomSetter(engineAtom);
const setDeviceType = useAtomSetter(deviceTypeAtom);
const isClient = useIsClient();
useEffect(() => {
const parser = new UAParser();
const os = parser.getOS().name;
const browser = parser.getBrowser().name;
const engine = parser.getEngine().name;
const deviceType = parser.getDevice().type;
setOs(os);
setBrowser(browser);
setEngine(engine);
setDeviceType(deviceType);
logger.log({ os, browser, engine, deviceType });
}, [isClient, setBrowser, setDeviceType, setEngine, setOs]);
};

View File

@ -3,17 +3,33 @@ import { resolve } from "path";
import { readFileSync, writeFileSync } from "fs"; import { readFileSync, writeFileSync } from "fs";
import { config } from "dotenv"; import { config } from "dotenv";
import { getReadySdk } from "./sdk"; import { getReadySdk } from "./sdk";
import { LocalDataGetWebsiteInterfacesQuery } from "./generated"; import {
import { processLangui, Langui } from "helpers/localData"; LocalDataGetCurrenciesQuery,
LocalDataGetLanguagesQuery,
LocalDataGetTypesTranslationsQuery,
LocalDataGetWebsiteInterfacesQuery,
} from "./generated";
import {
processLangui,
Langui,
TypesTranslations,
processTypesTranslations,
Currencies,
processCurrencies,
Languages,
processLanguages,
} from "helpers/localData";
import { getLogger } from "helpers/logger";
config({ path: resolve(process.cwd(), ".env.local") }); config({ path: resolve(process.cwd(), ".env.local") });
const LOCAL_DATA_FOLDER = `${process.cwd()}/public/local-data`; const LOCAL_DATA_FOLDER = `${process.cwd()}/public/local-data`;
const logger = getLogger("💽 [Local Data]", "server");
const writeLocalData = (name: LocalDataFile, localData: unknown) => { const writeLocalData = (name: LocalDataFile, localData: object) => {
const path = `${LOCAL_DATA_FOLDER}/${name}.json`; const path = `${LOCAL_DATA_FOLDER}/${name}.json`;
writeFileSync(path, JSON.stringify(localData), { encoding: "utf-8" }); writeFileSync(path, JSON.stringify(localData), { encoding: "utf-8" });
console.log(`${path} has been written!`); logger.log(`${name}.json has been written`);
}; };
const readLocalData = <T>(name: LocalDataFile): T => { const readLocalData = <T>(name: LocalDataFile): T => {
@ -21,19 +37,68 @@ const readLocalData = <T>(name: LocalDataFile): T => {
return JSON.parse(readFileSync(path, { encoding: "utf8" })); return JSON.parse(readFileSync(path, { encoding: "utf8" }));
}; };
export const fetchWebsiteInterfaces = async (): Promise<void> => {
const sdk = getReadySdk(); const sdk = getReadySdk();
(async () => {
writeLocalData("websiteInterfaces", await sdk.localDataGetWebsiteInterfaces()); writeLocalData("websiteInterfaces", await sdk.localDataGetWebsiteInterfaces());
};
export const fetchCurrencies = async (): Promise<void> => {
const sdk = getReadySdk();
writeLocalData("currencies", await sdk.localDataGetCurrencies()); writeLocalData("currencies", await sdk.localDataGetCurrencies());
};
export const fetchLanguages = async (): Promise<void> => {
const sdk = getReadySdk();
writeLocalData("languages", await sdk.localDataGetLanguages()); writeLocalData("languages", await sdk.localDataGetLanguages());
})(); };
export const fetchRecorders = async (): Promise<void> => {
const sdk = getReadySdk();
writeLocalData("recorders", await sdk.localDataGetRecorders());
};
export const fetchTypesTranslations = async (): Promise<void> => {
const sdk = getReadySdk();
writeLocalData("typesTranslations", await sdk.localDataGetTypesTranslations());
};
const fetchLocalData = async (): Promise<void> => {
await fetchWebsiteInterfaces();
await fetchCurrencies();
await fetchLanguages();
await fetchRecorders();
await fetchTypesTranslations();
};
if (process.argv[2] === "--esrun") {
fetchLocalData();
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export type LocalDataFile = "currencies" | "languages" | "websiteInterfaces"; export type LocalDataFile =
| "currencies"
| "languages"
| "recorders"
| "typesTranslations"
| "websiteInterfaces";
export const getLangui = (locale: string | undefined): Langui => { export const getLangui = (locale: string): Langui => {
const websiteInterfaces = readLocalData<LocalDataGetWebsiteInterfacesQuery>("websiteInterfaces"); const websiteInterfaces = readLocalData<LocalDataGetWebsiteInterfacesQuery>("websiteInterfaces");
return processLangui(websiteInterfaces, locale); return processLangui(websiteInterfaces, locale);
}; };
export const getTypesTranslations = (): TypesTranslations => {
const typesTranslations = readLocalData<LocalDataGetTypesTranslationsQuery>("typesTranslations");
return processTypesTranslations(typesTranslations);
};
export const getCurrencies = (): Currencies => {
const currencies = readLocalData<LocalDataGetCurrenciesQuery>("currencies");
return processCurrencies(currencies);
};
export const getLanguages = (): Languages => {
const languages = readLocalData<LocalDataGetLanguagesQuery>("languages");
return processLanguages(languages);
};

View File

@ -0,0 +1,14 @@
fragment parentFolderPreview on ContentsFolder {
slug
titles(pagination: { limit: -1 }) {
id
language {
data {
attributes {
code
}
}
}
title
}
}

View File

@ -1,23 +0,0 @@
fragment recorderChip on Recorder {
username
anonymize
anonymous_code
pronouns
bio(filters: { language: { code: { eq: $language_code } } }) {
bio
}
languages(pagination: { limit: -1 }) {
data {
attributes {
code
}
}
}
avatar {
data {
attributes {
...uploadImage
}
}
}
}

View File

@ -0,0 +1,36 @@
fragment relatedContentPreview on Content {
slug
translations(pagination: { limit: -1 }) {
pre_title
title
subtitle
language {
data {
attributes {
code
}
}
}
}
categories(pagination: { limit: -1 }) {
data {
attributes {
slug
}
}
}
type {
data {
attributes {
slug
}
}
}
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}

View File

@ -1,13 +1,13 @@
import { GetStaticProps } from "next"; import { GetStaticProps } from "next";
import { getReadySdk } from "./sdk"; import { getReadySdk } from "./sdk";
import { getLangui } from "./fetchLocalData";
import { PostWithTranslations } from "types/types"; import { PostWithTranslations } from "types/types";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { prettyDate, prettySlug } from "helpers/formatters"; import { prettySlug } from "helpers/formatters";
import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales"; import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales";
import { filterHasAttributes, isDefined } from "helpers/others"; import { filterHasAttributes } from "helpers/asserts";
import { getDescription } from "helpers/description"; import { getDescription } from "helpers/description";
import { AppLayoutRequired } from "components/AppLayout"; import { AppLayoutRequired } from "components/AppLayout";
import { getFormat } from "helpers/i18n";
export interface PostStaticProps extends AppLayoutRequired { export interface PostStaticProps extends AppLayoutRequired {
post: PostWithTranslations; post: PostWithTranslations;
@ -17,18 +17,15 @@ export const getPostStaticProps =
(slug: string): GetStaticProps => (slug: string): GetStaticProps =>
async (context) => { async (context) => {
const sdk = getReadySdk(); const sdk = getReadySdk();
const langui = getLangui(context.locale); const { format, formatCategory, formatDate } = getFormat(context.locale);
const post = await sdk.getPost({ const post = await sdk.getPost({
slug: slug, slug: slug,
language_code: context.locale ?? "en",
}); });
if (
post.posts?.data && if (!post.posts?.data[0]?.attributes?.translations || !context.locale || !context.locales) {
post.posts.data.length > 0 && return { notFound: true };
post.posts.data[0].attributes?.translations && }
isDefined(context.locale) &&
isDefined(context.locales)
) {
const selectedTranslation = staticSmartLanguage({ const selectedTranslation = staticSmartLanguage({
items: post.posts.data[0].attributes.translations, items: post.posts.data[0].attributes.translations,
languageExtractor: (item) => item.language?.data?.attributes?.code, languageExtractor: (item) => item.language?.data?.attributes?.code,
@ -37,14 +34,12 @@ export const getPostStaticProps =
const title = selectedTranslation?.title ?? prettySlug(slug); const title = selectedTranslation?.title ?? prettySlug(slug);
const description = getDescription(selectedTranslation?.excerpt, { const description = getDescription(selectedTranslation?.excerpt ?? selectedTranslation?.body, {
[langui.release_date ?? "Release date"]: [ [format("release_date")]: [formatDate(post.posts.data[0].attributes.date)],
prettyDate(post.posts.data[0].attributes.date, context.locale), [format("category", { count: Infinity })]: filterHasAttributes(
],
[langui.categories ?? "Categories"]: filterHasAttributes(
post.posts.data[0].attributes.categories?.data, post.posts.data[0].attributes.categories?.data,
["attributes"] as const ["attributes"]
).map((category) => category.attributes.short), ).map((category) => formatCategory(category.attributes.slug)),
}); });
const thumbnail = const thumbnail =
@ -53,11 +48,9 @@ export const getPostStaticProps =
const props: PostStaticProps = { const props: PostStaticProps = {
post: post.posts.data[0].attributes as PostWithTranslations, post: post.posts.data[0].attributes as PostWithTranslations,
openGraph: getOpenGraph(langui, title, description, thumbnail), openGraph: getOpenGraph(format, title, description, thumbnail),
}; };
return { return {
props: props, props: props,
}; };
}
return { notFound: true };
}; };

180
src/graphql/icuParams.ts Normal file
View File

@ -0,0 +1,180 @@
export interface ICUParams {
library: never;
contents: never;
wiki: never;
chronicles: never;
library_short_description: never;
contents_short_description: never;
wiki_short_description: never;
chronicles_short_description: never;
news: never;
gallery: never;
archives: never;
about_us: never;
licensing_notice: never;
copyright_notice: never;
contents_description: never;
type: { count: number };
category: { count: number };
size: never;
release_date: never;
details: never;
price: never;
width: never;
height: never;
thickness: never;
subitem: { count: number };
variant: { count: number };
summary: never;
audio: never;
video: never;
textual: never;
game: never;
other: never;
left_to_right: never;
right_to_left: never;
page: { count: number };
page_order: never;
binding: never;
type_information: never;
front_matter: never;
back_matter: never;
open_content: never;
view_scans: never;
paperback: never;
hardcover: never;
language: { count: number };
library_description: never;
wiki_description: never;
chronicles_description: never;
news_description: never;
archives_description: never;
about_us_description: never;
page_not_found: never;
default_description: never;
name: never;
show_subitems: never;
show_primary_items: never;
show_secondary_items: never;
order_by: never;
select_option_sidebar: never;
group: never;
settings: never;
theme: never;
light: never;
auto: never;
dark: never;
font_size: never;
player_name: never;
currency: never;
font: never;
calculated: never;
status_incomplete: never;
status_draft: never;
status_review: never;
status_done: never;
incomplete: never;
draft: never;
review: never;
done: never;
status: never;
transcript_notice: never;
translation_notice: never;
source_language: never;
pronouns: never;
item: { count: number };
open_settings: never;
open_search: never;
chronology: never;
accords_handbook: never;
legality: never;
sharing_policy: never;
contact_us: never;
email: never;
email_gdpr_notice: never;
message: never;
send: never;
response_invalid_code: never;
response_invalid_email: never;
response_email_success: never;
always_show_info: never;
item_not_available: never;
primary_language: never;
secondary_language: never;
previous_content: { count: number };
followup_content: { count: number };
videos: never;
view_on_x: { x: Date | boolean | number | string };
channel: never;
subscribers: never;
description: never;
available_at_x: { x: Date | boolean | number | string };
want_it: never;
have_it: never;
source: never;
reset_all_filters: never;
only_display_items_i_have: never;
only_display_items_i_want: never;
only_display_unmarked_items: never;
table_of_contents: never;
no_results_message: never;
all: never;
special_pages: never;
scan: never;
scanlation: never;
scanners: never;
cleaners: never;
typesetters: never;
notes: never;
tags: never;
no_source_warning: never;
copy_anchor_link: never;
anchor_link_copied: never;
folders: never;
empty_folder_message: never;
switch_to_grid_view: never;
switch_to_folder_view: never;
paper_texture: never;
book_fold: never;
lighting: never;
side_pages: never;
shadow: never;
night_reader: never;
single_page_view: never;
double_page_view: never;
reset_all_options: never;
reading_layout: never;
quality: never;
only_unavailable_videos: never;
oldest: never;
newest: never;
least_popular: never;
most_popular: never;
shortest: never;
longest: never;
search: never;
showing_x_out_of_y_results: {
x: Date | boolean | number | string;
y: Date | boolean | number | string;
};
return_to_x: { x: undefined | null | Date | boolean | number | string };
x_results: { x: number };
definition_x: { x: Date | boolean | number | string };
subitem_of_x: { x: Date | boolean | number | string };
dark_mode_extension_warning: never;
weapon: { count: number };
weapons_description: never;
level_x: { x: Date | boolean | number | string };
story_x: { x: Date | boolean | number | string };
player_name_tooltip: never;
download_archive: never;
search_placeholder: never;
performance_mode: never;
performance_mode_tooltip: never;
transcriber: { count: number };
translator: { count: number };
proofreader: { count: number };
dubber: { count: number };
subber: { count: number };
author: { count: number };
}

View File

@ -0,0 +1,61 @@
/* eslint-disable import/no-nodejs-modules */
import { createWriteStream } from "fs";
import { parse, TYPE } from "@formatjs/icu-messageformat-parser";
import { getLangui } from "./fetchLocalData";
import { getLogger } from "helpers/logger";
const OUTPUT_FOLDER = `${process.cwd()}/src/graphql`;
const logger = getLogger("💽 [ICU to TS]", "server");
const isDefined = <T>(t: T): t is NonNullable<T> => t !== null && t !== undefined;
const isUndefined = <T>(t: T | null | undefined): t is null | undefined => !isDefined(t);
const filterDefined = <T>(t: T[] | null | undefined): NonNullable<T>[] =>
isUndefined(t) ? [] : (t.filter((item) => isDefined(item)) as NonNullable<T>[]);
const icuToTypescript = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ui_language, ...langui } = getLangui("en");
const output = createWriteStream(`${OUTPUT_FOLDER}/icuParams.ts`);
output.write("export interface ICUParams {\n");
Object.keys(langui).map((oKey) => {
const key = oKey as keyof typeof langui;
const parsedMessage = parse(langui[key] ?? "");
const variables = filterDefined(
parsedMessage.map((elem) => {
if (elem.type === TYPE.argument) {
return `${elem.value}: Date | boolean | number | string`;
} else if (elem.type === TYPE.plural) {
return `${elem.value}: number`;
} else if (elem.type === TYPE.select) {
const options = Object.keys(elem.options);
const stringOptions = options.filter(
(option) => option !== "undefined" && option !== "other"
);
const type: string[] = stringOptions.map((option) => `"${option}"`);
if (options.includes("undefined")) type.push(...["undefined", "null"]);
if (options.includes("other")) type.push(...["Date", "boolean", "number", "string"]);
return `${elem.value}: ${type.join(` | `)}`;
}
return undefined;
})
);
const variablesType = variables.length > 0 ? `{ ${variables.join(";")} }` : "never";
output.write(` ${key}: ${variablesType};\n`);
});
output.write("}\n");
logger.log(`icu-params.ts has been written!`);
};
if (process.argv[2] === "--icu") {
icuToTypescript();
}

View File

@ -1,4 +1,4 @@
query getChronicle($slug: String, $language_code: String) { query getChronicle($slug: String) {
chronicles(filters: { slug: { eq: $slug } }) { chronicles(filters: { slug: { eq: $slug } }) {
data { data {
attributes { attributes {
@ -53,21 +53,21 @@ query getChronicle($slug: String, $language_code: String) {
authors { authors {
data { data {
attributes { attributes {
...recorderChip username
} }
} }
} }
translators { translators {
data { data {
attributes { attributes {
...recorderChip username
} }
} }
} }
proofreaders { proofreaders {
data { data {
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -80,10 +80,8 @@ query getChronicle($slug: String, $language_code: String) {
slug slug
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }
@ -91,9 +89,6 @@ query getChronicle($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -123,7 +118,7 @@ query getChronicle($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -131,7 +126,7 @@ query getChronicle($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -139,7 +134,7 @@ query getChronicle($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }

View File

@ -1,4 +1,4 @@
query getContentText($slug: String, $language_code: String) { query getContentText($slug: String) {
contents(filters: { slug: { eq: $slug } }) { contents(filters: { slug: { eq: $slug } }) {
data { data {
id id
@ -6,10 +6,8 @@ query getContentText($slug: String, $language_code: String) {
slug slug
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }
@ -17,9 +15,6 @@ query getContentText($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -53,10 +48,8 @@ query getContentText($slug: String, $language_code: String) {
} }
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }
@ -67,19 +60,15 @@ query getContentText($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
} }
... on ComponentMetadataGame { ... on ComponentMetadataGame {
platforms(pagination: { limit: -1 }) { platform {
data { data {
id
attributes { attributes {
short slug
} }
} }
} }
@ -89,9 +78,6 @@ query getContentText($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -101,9 +87,6 @@ query getContentText($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -113,9 +96,6 @@ query getContentText($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -123,9 +103,6 @@ query getContentText($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -163,7 +140,7 @@ query getContentText($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -171,7 +148,7 @@ query getContentText($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -179,7 +156,44 @@ query getContentText($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
}
}
}
notes
}
video_set {
status
source_language {
data {
attributes {
code
}
}
}
has_subfile
subbers(pagination: { limit: -1 }) {
data {
attributes {
username
}
}
}
notes
}
audio_set {
status
source_language {
data {
attributes {
code
}
}
}
dubbers(pagination: { limit: -1 }) {
data {
attributes {
username
} }
} }
} }
@ -208,51 +222,20 @@ query getContentText($slug: String, $language_code: String) {
} }
title title
} }
sequence }
contents(pagination: { limit: -1 }) { }
}
previous_contents(pagination: { limit: -1 }) {
data { data {
attributes { attributes {
slug ...relatedContentPreview
translations(pagination: { limit: -1 }) { }
pre_title }
title }
subtitle next_contents(pagination: { limit: -1 }) {
language {
data { data {
attributes { attributes {
code ...relatedContentPreview
}
}
}
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
type {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}
}
}
} }
} }
} }

View File

@ -1,75 +0,0 @@
query getContents($language_code: String) {
contents(pagination: { limit: -1 }) {
data {
id
attributes {
slug
translations(pagination: { limit: -1 }) {
pre_title
title
subtitle
language {
data {
attributes {
code
}
}
}
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
}
}
}
type {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
ranged_contents(pagination: { limit: -1 }) {
data {
id
attributes {
slug
scan_set {
id
}
library_item {
data {
attributes {
slug
title
subtitle
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}
}
}
}
}
}
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}
}
}
}

View File

@ -1,37 +1,8 @@
query getContentsFolder($slug: String, $language_code: String) { query getContentsFolder($slug: String) {
contentsFolders(filters: { slug: { eq: $slug } }) { contentsFolders(filters: { slug: { eq: $slug } }) {
data { data {
attributes { attributes {
slug ...parentFolderPreview
titles(pagination: { limit: -1 }) {
id
language {
data {
attributes {
code
}
}
}
title
}
parent_folder {
data {
attributes {
slug
titles(pagination: { limit: -1 }) {
id
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
contents(pagination: { limit: -1 }) { contents(pagination: { limit: -1 }) {
data { data {
id id
@ -51,10 +22,8 @@ query getContentsFolder($slug: String, $language_code: String) {
} }
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }
@ -62,9 +31,6 @@ query getContentsFolder($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -124,6 +90,55 @@ query getContentsFolder($slug: String, $language_code: String) {
} }
} }
} }
parent_folder {
data {
attributes {
...parentFolderPreview
parent_folder {
data {
attributes {
...parentFolderPreview
parent_folder {
data {
attributes {
...parentFolderPreview
parent_folder {
data {
attributes {
...parentFolderPreview
parent_folder {
data {
attributes {
...parentFolderPreview
parent_folder {
data {
attributes {
...parentFolderPreview
parent_folder {
data {
attributes {
...parentFolderPreview
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
} }
} }
} }

View File

@ -1,4 +1,4 @@
query getLibraryItem($slug: String, $language_code: String) { query getLibraryItem($slug: String) {
libraryItems(filters: { slug: { eq: $slug } }) { libraryItems(filters: { slug: { eq: $slug } }) {
data { data {
id id
@ -9,6 +9,7 @@ query getLibraryItem($slug: String, $language_code: String) {
root_item root_item
primary primary
digital digital
download_available
thumbnail { thumbnail {
data { data {
attributes { attributes {
@ -32,10 +33,8 @@ query getLibraryItem($slug: String, $language_code: String) {
} }
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }
@ -57,9 +56,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -80,19 +76,15 @@ query getLibraryItem($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
} }
... on ComponentMetadataGame { ... on ComponentMetadataGame {
platforms(pagination: { limit: -1 }) { platform {
data { data {
id
attributes { attributes {
short slug
} }
} }
} }
@ -126,21 +118,20 @@ query getLibraryItem($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) { }
}
}
tracks(pagination: { limit: -1 }) {
id
slug
title title
} }
} }
}
}
}
... on ComponentMetadataGroup { ... on ComponentMetadataGroup {
subtype { subtype {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -148,9 +139,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -188,10 +176,8 @@ query getLibraryItem($slug: String, $language_code: String) {
} }
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }
@ -202,19 +188,16 @@ query getLibraryItem($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
} }
... on ComponentMetadataGame { ... on ComponentMetadataGame {
platforms { platform {
data { data {
id id
attributes { attributes {
short slug
} }
} }
} }
@ -224,9 +207,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -236,9 +216,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -248,9 +225,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -258,9 +232,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -311,10 +282,8 @@ query getLibraryItem($slug: String, $language_code: String) {
slug slug
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }
@ -322,9 +291,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }

View File

@ -1,4 +1,4 @@
query getLibraryItemScans($slug: String, $language_code: String) { query getLibraryItemScans($slug: String) {
libraryItems(filters: { slug: { eq: $slug } }) { libraryItems(filters: { slug: { eq: $slug } }) {
data { data {
id id
@ -6,6 +6,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
slug slug
title title
subtitle subtitle
download_available
images(pagination: { limit: -1 }) { images(pagination: { limit: -1 }) {
status status
language { language {
@ -26,7 +27,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -34,7 +35,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -42,7 +43,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -155,10 +156,8 @@ query getLibraryItemScans($slug: String, $language_code: String) {
} }
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }
@ -170,19 +169,16 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
} }
... on ComponentMetadataGame { ... on ComponentMetadataGame {
platforms { platform {
data { data {
id id
attributes { attributes {
short slug
} }
} }
} }
@ -192,9 +188,6 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -204,9 +197,6 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -216,9 +206,6 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -226,9 +213,6 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -289,7 +273,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -297,7 +281,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -305,7 +289,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }

View File

@ -1,107 +0,0 @@
query getLibraryItemsPreview($language_code: String) {
libraryItems(pagination: { limit: -1 }) {
data {
id
attributes {
title
subtitle
slug
root_item
primary
thumbnail {
data {
attributes {
...uploadImage
}
}
}
release_date {
...datePicker
}
price {
...pricePicker
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
}
}
}
metadata {
__typename
... on ComponentMetadataBooks {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
... on ComponentMetadataGame {
platforms(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
}
... on ComponentMetadataVideo {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
... on ComponentMetadataAudio {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
... on ComponentMetadataGroup {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
subitems_type {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
}
}
}
}
}

View File

@ -1,4 +1,4 @@
query getPost($slug: String, $language_code: String) { query getPost($slug: String) {
posts(filters: { slug: { eq: $slug } }) { posts(filters: { slug: { eq: $slug } }) {
data { data {
id id
@ -12,16 +12,14 @@ query getPost($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }

View File

@ -1,46 +0,0 @@
query getPostsPreview {
posts(filters: { hidden: { eq: false } }) {
data {
id
attributes {
slug
date {
...datePicker
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
thumbnail {
data {
attributes {
...uploadImage
}
}
}
translations(pagination: { limit: -1 }) {
language {
data {
attributes {
code
}
}
}
title
excerpt
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}
}
}
}
}

View File

@ -23,9 +23,8 @@ query getVideo($uid: String) {
} }
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
short slug
} }
} }
} }

View File

@ -5,29 +5,6 @@ query getVideoChannel($channel: String) {
uid uid
title title
subscribers subscribers
videos(pagination: { limit: -1 }) {
data {
id
attributes {
uid
title
views
duration
gone
categories(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
published_date {
...datePicker
}
}
}
}
} }
} }
} }

View File

@ -1,33 +0,0 @@
query getVideosPreview {
videos(pagination: { limit: -1 }) {
data {
id
attributes {
uid
title
views
duration
gone
categories(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
published_date {
...datePicker
}
channel {
data {
attributes {
uid
title
}
}
}
}
}
}
}

View File

@ -0,0 +1,80 @@
query getWeapon($slug: String) {
weaponStories(filters: { slug: { eq: $slug } }) {
data {
attributes {
...sharedWeaponFragment
stories(pagination: { limit: -1 }) {
id
categories(pagination: { limit: -1 }) {
data {
attributes {
slug
}
}
}
translations(pagination: { limit: -1 }) {
id
description
level_1
level_2
level_3
level_4
status
language {
data {
attributes {
code
}
}
}
}
}
weapon_group {
data {
attributes {
slug
weapons(pagination: { limit: -1 }, filters: { slug: { ne: $slug } }) {
data {
id
attributes {
...sharedWeaponFragment
}
}
}
}
}
}
}
}
}
}
fragment sharedWeaponFragment on WeaponStory {
type {
data {
id
attributes {
slug
}
}
}
name(pagination: { limit: -1 }) {
id
name
language {
data {
attributes {
code
}
}
}
}
slug
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}

View File

@ -0,0 +1,10 @@
query getWeaponsSlugs {
weaponStories(pagination: { limit: -1 }) {
data {
id
attributes {
slug
}
}
}
}

View File

@ -1,4 +1,4 @@
query getWikiPage($slug: String, $language_code: String) { query getWikiPage($slug: String) {
wikiPages(filters: { slug: { eq: $slug } }) { wikiPages(filters: { slug: { eq: $slug } }) {
data { data {
id id
@ -13,21 +13,15 @@ query getWikiPage($slug: String, $language_code: String) {
} }
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }
tags { tags {
data { data {
id
attributes { attributes {
slug slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
} }
} }
} }
@ -58,7 +52,7 @@ query getWikiPage($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -66,7 +60,7 @@ query getWikiPage($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -74,7 +68,7 @@ query getWikiPage($slug: String, $language_code: String) {
data { data {
id id
attributes { attributes {
...recorderChip username
} }
} }
} }
@ -90,10 +84,8 @@ query getWikiPage($slug: String, $language_code: String) {
} }
categories(pagination: { limit: -1 }) { categories(pagination: { limit: -1 }) {
data { data {
id
attributes { attributes {
name slug
short
} }
} }
} }

View File

@ -1,58 +0,0 @@
query getWikiPagesPreviews($language_code: String) {
wikiPages(pagination: { limit: -1 }) {
data {
id
attributes {
slug
thumbnail {
data {
attributes {
...uploadImage
}
}
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
}
}
}
tags(pagination: { limit: -1 }) {
data {
id
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
translations(pagination: { limit: -1 }) {
title
aliases(pagination: { limit: -1 }) {
alias
}
summary
language {
data {
attributes {
code
}
}
}
}
}
}
}
}

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