Compare commits

...

58 Commits

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
136 changed files with 11539 additions and 10388 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

@ -7,7 +7,6 @@ next-env.d.ts
next-sitemap.config.js
next.config.js
postcss.config.js
tailwind.config.js
design.config.js
graphql.config.js
prettier.config.js

View File

@ -46,7 +46,7 @@ module.exports = {
"func-style": ["warn", "expression"],
"grouped-accessor-pairs": "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-match": "warn",
"max-classes-per-file": ["error", 1],
@ -161,7 +161,6 @@ module.exports = {
"@typescript-eslint/no-invalid-void-type": "error",
"@typescript-eslint/no-meaningless-void-operator": "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-type-alias": "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/promise-function-async": "error",
"@typescript-eslint/require-array-sort-compare": "error",
"@typescript-eslint/sort-type-union-intersection-members": "warn",
// "@typescript-eslint/strict-boolean-expressions": [
// "error",
// { allowAny: true },
@ -192,7 +190,6 @@ module.exports = {
"@typescript-eslint/unified-signatures": "error",
/* EXTENSION OF ESLINT */
"@typescript-eslint/no-duplicate-imports": "error",
"@typescript-eslint/default-param-last": "warn",
"@typescript-eslint/dot-notation": "warn",
"@typescript-eslint/init-declarations": "warn",

2
.gitignore vendored
View File

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

View File

@ -1,4 +0,0 @@
{
"upgrade": false,
"reject": ["react-hotkeys-hook"]
}

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
public/local-data/*

View File

@ -67,7 +67,7 @@ A detailled look at the technologies used in this repository:
- 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
- UI localizations are downloaded separetely into the `public/local-data` to avoid fetching the same static props for every pages.
- 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/)
@ -102,17 +102,15 @@ A detailled look at the technologies used in this repository:
- 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
- Main and fallback languages can be ordered manually by the user
- At the content level, the user can know which language is available
- Furthermore, the user can temporary select another language then the one that was automatically selected
- 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.
- 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.
- 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.
- UI Localizations
- The translated wordings use [ICU Message Format](https://unicode-org.github.io/icu/userguide/format_parse/messages/) to include variables, plural, dates...
- Use a custom ICU Typescript transformation script to provide type safety when formatting ICU wordings
- Fallback to English if a specific working isn't available in the user's language
- Fallback to English if the translation is missing.
- SEO
@ -134,10 +132,11 @@ A detailled look at the technologies used in this repository:
- [ts-unused-exports](https://www.npmjs.com/package/ts-unused-exports) to find unused exported functions/constants...
- Other
- Custom book reader based on [Okuma-Reader](https://github.com/DrMint/Okuma-Reader)
- Support for [Material Symbols](https://fonts.google.com/icons)
- Custom lightbox using [react-zoom-pan-pinch](https://www.npmjs.com/package/react-zoom-pan-pinch)
- Handle query params using [Zod](https://zod.dev/)
- Handle query params type-validation using [Zod](https://zod.dev/)
- A secret "Terminal" mode. Can you find it?
## Installation
@ -148,30 +147,14 @@ cd accords-library.com
npm install
```
Create a env file:
Create a env file based on the example one:
```bash
cp .env.example .env.local
nano .env.local
```
Enter the following information:
```
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_MEILISEARCH=https://url-to.search-accords-library.com
NEXT_PUBLIC_MEILISEARCH_KEY=abcdef0123456789
NEXT_PUBLIC_UMAMI_URL=https://url-to.umami-accords-library.com
NEXT_PUBLIC_UMAMI_ID=abcdef0123456789
```
Change the variables
Run in dev mode:

View File

@ -1,6 +1,6 @@
/* CONFIG */
const locales = ["en", "es", "fr", "pt-br", "ja"];
const locales = ["en", "es", "fr", "pt-br", "ja", "zh"];
/* END CONFIG */

8867
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,13 @@
"name": "accords-library.com",
"private": true,
"scripts": {
"postinstall": "patch-package",
"dev": "next dev -p 12499",
"precommit": "npm run fetch-local-data && npm run icu-to-ts && 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",
"fetch-local-data": "npm run generate && esrun src/graphql/fetchLocalData.ts --esrun",
"icu-to-ts": "esrun src/graphql/icuToTypescript.ts --icu",
"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-wording-keys": "esrun --send-code-mode=temporaryFile src/graphql/unusedWordingKeys.ts --uwk",
"unused-exports": "ts-unused-exports ./tsconfig.json --excludePathsFromReport='src/pages;tailwind.config.ts;src/graphql/generated.ts;src/shared/meilisearch-graphql-typings/generated.ts'",
"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",
"postbuild": "next-sitemap --config next-sitemap.config.js",
@ -15,74 +17,75 @@
"eslint": "npx eslint .",
"generate": "graphql-codegen --config graphql-codegen.config.js",
"tsc": "tsc",
"prettier": "prettier --end-of-line auto --write ."
"prettier": "prettier --list-different --end-of-line auto --write .",
"upgrade": "ncu"
},
"dependencies": {
"@fontsource/opendyslexic": "^4.5.4",
"@fontsource/share-tech-mono": "^4.5.9",
"@fontsource/vollkorn": "^4.5.14",
"@fontsource/zen-maru-gothic": "^4.5.16",
"@formatjs/icu-messageformat-parser": "^2.3.0",
"@fontsource/noto-serif-jp": "^5.0.7",
"@fontsource/opendyslexic": "^5.0.7",
"@fontsource/share-tech-mono": "^5.0.8",
"@fontsource/vollkorn": "^5.0.9",
"@fontsource/zen-maru-gothic": "^5.0.7",
"@formatjs/icu-messageformat-parser": "^2.6.0",
"@tippyjs/react": "^4.2.6",
"autoprefixer": "^10.4.13",
"autoprefixer": "^10.4.15",
"cuid": "^2.1.8",
"intl-messageformat": "^10.3.1",
"isomorphic-dompurify": "^1.0.0",
"jotai": "^2.0.3",
"markdown-to-jsx": "^7.1.9",
"marked": "^4.2.12",
"material-symbols": "^0.4.6",
"meilisearch": "^0.31.1",
"next": "^13.2.3",
"nodemailer": "^6.9.1",
"rc-slider": "^10.1.1",
"html-to-text": "^9.0.5",
"intl-messageformat": "^10.5.0",
"isomorphic-dompurify": "^1.8.0",
"jotai": "^2.3.1",
"markdown-to-jsx": "^7.3.2",
"marked": "^7.0.3",
"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-hotkeys-hook": "^3.4.7",
"react-swipeable": "^7.0.0",
"react-zoom-pan-pinch": "^3.0.1",
"react-swipeable": "^7.0.1",
"react-zoom-pan-pinch": "^3.1.0",
"string-natural-compare": "^3.0.1",
"throttle-debounce": "^5.0.0",
"tippy.js": "^6.3.7",
"turndown": "^7.1.1",
"ua-parser-js": "^1.0.33",
"turndown": "^7.1.2",
"ua-parser-js": "^1.0.35",
"usehooks-ts": "^2.9.1",
"zod": "^3.21.0"
"zod": "^3.22.1"
},
"devDependencies": {
"@digitak/esrun": "3.2.20",
"@graphql-codegen/cli": "^3.2.1",
"@graphql-codegen/typescript": "3.0.1",
"@graphql-codegen/typescript-graphql-request": "^4.5.8",
"@graphql-codegen/typescript-operations": "^3.0.1",
"@types/marked": "^4.0.8",
"@types/node": "18.14.6",
"@types/nodemailer": "^6.4.7",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@digitak/esrun": "3.2.24",
"@graphql-codegen/cli": "5.0.0",
"@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-graphql-request": "5.0.0",
"@graphql-codegen/typescript-operations": "4.0.1",
"@types/html-to-text": "^9.0.1",
"@types/marked": "^5.0.1",
"@types/node": "20.5.0",
"@types/nodemailer": "^6.4.9",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/string-natural-compare": "^3.0.2",
"@types/throttle-debounce": "^5.0.0",
"@types/turndown": "^5.0.1",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"dotenv": "^16.0.3",
"eslint": "^8.35.0",
"eslint-config-next": "13.2.3",
"eslint-plugin-import": "^2.27.5",
"graphql": "^16.6.0",
"graphql-request": "5.1.0",
"next-sitemap": "^4.0.2",
"prettier": "^2.8.4",
"prettier-plugin-tailwindcss": "^0.2.4",
"tailwindcss": "^3.2.7",
"ts-unused-exports": "^9.0.4",
"typescript": "^4.9.5"
},
"overrides": {
"react-zoom-pan-pinch": {
"react": "$react",
"react-dom": "$react-dom"
}
"@typescript-eslint/eslint-plugin": "^6.4.0",
"@typescript-eslint/parser": "^6.4.0",
"chalk": "^5.3.0",
"dotenv": "^16.3.1",
"eslint": "^8.47.0",
"eslint-config-next": "13.4.17",
"eslint-plugin-import": "^2.28.0",
"graphql": "16.8.0",
"graphql-request": "6.1.0",
"next-sitemap": "^4.2.2",
"prettier": "^3.0.2",
"prettier-plugin-tailwindcss": "^0.5.3",
"tailwindcss": "^3.3.3",
"ts-unused-exports": "^10.0.0",
"typescript": "^5.1.6"
}
}

0
patches/.gitkeep Normal file
View File

Binary file not shown.

View File

@ -1,91 +1 @@
{
"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
}
}
]
}
}
{"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,36 +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,6 +1,8 @@
import Head from "next/head";
import { useSwipeable } from "react-swipeable";
import { MaterialSymbol } from "material-symbols";
import { atom } from "jotai";
import { useRouter } from "next/router";
import { layout } from "../../design.config";
import { Ico } from "./Ico";
import { MainPanel } from "./Panels/MainPanel";
@ -18,6 +20,7 @@ import { useFormat } from "hooks/useFormat";
*/
const SENSIBILITY_SWIPE = 1.1;
const isIOSAtom = atom((get) => get(atoms.userAgent.os) === "iOS");
/*
*
@ -48,11 +51,13 @@ export const AppLayout = ({
const [isSubPanelOpened, setSubPanelOpened] = useAtomPair(atoms.layout.subPanelOpened);
const [isMainPanelOpened, setMainPanelOpened] = useAtomPair(atoms.layout.mainPanelOpened);
const isMenuGesturesEnabled = useAtomGetter(atoms.layout.menuGesturesEnabled);
const { format } = useFormat();
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const isScreenAtLeastXs = useAtomGetter(atoms.containerQueries.isScreenAtLeastXs);
const isIOS = useAtomGetter(isIOSAtom);
const router = useRouter();
const { format } = useFormat();
const handlers = useSwipeable({
onSwipedLeft: (SwipeEventData) => {
@ -102,26 +107,49 @@ export const AppLayout = ({
<title>{openGraph.title}</title>
<meta name="description" content={openGraph.description} />
<meta name="twitter:site" content="@AccordsLibrary" />
<meta name="twitter:title" content={openGraph.title} />
<meta name="twitter:description" content={openGraph.description} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={openGraph.thumbnail.image} />
<meta
property="og:type"
content={openGraph.video ? "video.movie" : openGraph.audio ? "music.song" : "website"}
/>
<meta property="og:locale" content={router.locale} />
<meta property="og:site_name" content="Accords Library" />
<meta property="og:title" content={openGraph.title} />
<meta property="og:description" content={openGraph.description} />
<meta property="og:image" content={openGraph.thumbnail.image} />
<meta property="og:image:secure_url" content={openGraph.thumbnail.image} />
<meta property="og:image:width" content={openGraph.thumbnail.width.toString()} />
<meta property="og:image:height" content={openGraph.thumbnail.height.toString()} />
<meta property="og:image:alt" content={openGraph.thumbnail.alt} />
<meta property="og:image:type" content="image/jpeg" />
{openGraph.audio && (
<>
<meta property="og:audio" content={openGraph.audio} />
<meta property="og:audio:type" content="audio/mpeg" />
</>
)}
{openGraph.video && (
<>
<meta property="og:video" content={openGraph.video} />{" "}
<meta property="og:video:type" content="video/mp4" />
</>
)}
</Head>
{/* Content panel */}
<div
id={Ids.ContentPanel}
className={cJoin(
"bg-light texture-paper-dots [grid-area:content]",
"bg-light [grid-area:content]",
cIf(!isIOS, "texture-paper-dots"),
cIf(contentPanelScroolbar, "overflow-y-scroll")
)}>
{isDefined(contentPanel) ? (
@ -134,13 +162,14 @@ export const AppLayout = ({
</div>
{/* Background when navbar is opened */}
<div
className={cJoin(
`absolute inset-0 transition-filter duration-500
`absolute inset-0 z-40 transition-filter duration-500
[grid-area:content]`,
cIf(
(isMainPanelOpened || isSubPanelOpened) && is1ColumnLayout,
"backdrop-blur",
cIf(!isPerfModeEnabled, "backdrop-blur"),
"pointer-events-none touch-none"
)
)}>
@ -164,7 +193,8 @@ export const AppLayout = ({
<div
className={cJoin(
`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")
)}>
<Ico
@ -201,11 +231,12 @@ export const AppLayout = ({
<div
id={Ids.SubPanel}
className={cJoin(
`z-40 overflow-y-scroll border-r border-dark/50 bg-light
transition-transform duration-300 scrollbar-none texture-paper-dots`,
`overflow-y-scroll border-r border-dark/50 bg-light
transition-transform duration-300 scrollbar-none`,
cIf(!isIOS, "texture-paper-dots"),
cIf(
is1ColumnLayout,
"justify-self-end border-r-0 [grid-area:content]",
"z-40 justify-self-end border-r-0 [grid-area:content]",
"[grid-area:sub]"
),
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)] border-l"),
@ -218,9 +249,10 @@ export const AppLayout = ({
{/* Main panel */}
<div
className={cJoin(
`z-40 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]"),
`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")
)}>

View File

@ -16,8 +16,7 @@ export const Chip = ({ className, text }: Props): JSX.Element => (
<div
className={cJoin(
`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]
hover:opacity-100`,
border-black/70 px-1.5 pb-[0.14rem] text-xs text-black/70`,
className
)}>
{text}

View File

@ -31,7 +31,7 @@ export const ChroniclePreview = ({
onClick,
}: Props): JSX.Element => (
<DownPressable
className="flex w-full gap-4 py-4 px-5"
className="flex w-full gap-4 px-5 py-4"
href={url}
onClick={onClick}
active={active}

View File

@ -1,15 +1,15 @@
import { useCallback } from "react";
import { useBoolean } from "usehooks-ts";
import Collapsible from "react-collapsible";
import { TranslatedChroniclePreview } from "./ChroniclePreview";
import { GetChroniclesChaptersQuery } from "graphql/generated";
import { filterHasAttributes } from "helpers/asserts";
import { prettyInlineTitle, prettySlug, sJoin } from "helpers/formatters";
import { Ico } from "components/Ico";
import { compareDate } from "helpers/date";
import { TranslatedProps } from "types/TranslatedProps";
import { useSmartLanguage } from "hooks/useSmartLanguage";
import { useAtomSetter } from "helpers/atoms";
import { atoms } from "contexts/atoms";
import { Button } from "components/Inputs/Button";
/*
*
@ -24,31 +24,42 @@ interface Props {
>["data"];
currentSlug?: string;
title: string;
open?: boolean;
onTriggerClosing?: () => void;
onOpening?: () => void;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
const ChroniclesList = ({ chronicles, currentSlug, title }: Props): JSX.Element => {
const ChroniclesList = ({
chronicles,
currentSlug,
title,
open,
onTriggerClosing,
onOpening,
}: Props): JSX.Element => {
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
const { value: isOpen, toggle: toggleOpen } = useBoolean(
chronicles.some((chronicle) => chronicle.attributes?.slug === currentSlug)
);
return (
<div>
<div className="grid place-content-center">
<div className="grid cursor-pointer grid-cols-[1em_1fr] gap-4" onClick={toggleOpen}>
<Ico className="!text-xl" icon={isOpen ? "arrow_drop_up" : "arrow_drop_down"} />
<p className="mb-4 font-headers text-xl">{title}</p>
<Collapsible
open={open}
accordionPosition={title}
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
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)
}>
{filterHasAttributes(chronicles, ["attributes.contents", "attributes.translations"])
.sort((a, b) => compareDate(a.attributes.date_start, b.attributes.date_start))
.map((chronicle) => (
<div key={chronicle.id} id={`chronicle-${chronicle.attributes.slug}`}>
@ -56,14 +67,14 @@ const ChroniclesList = ({ chronicles, currentSlug, title }: Props): JSX.Element
chronicle.attributes.contents.data.length === 1
? filterHasAttributes(chronicle.attributes.contents.data, [
"attributes.translations",
] as const).map((content, index) => (
]).map((content, index) => (
<TranslatedChroniclePreview
key={index}
active={chronicle.attributes.slug === currentSlug}
date={chronicle.attributes.date_start}
translations={filterHasAttributes(content.attributes.translations, [
"language.data.attributes.code",
] as const).map((translation) => ({
]).map((translation) => ({
title: prettyInlineTitle(
translation.pre_title,
translation.title,
@ -90,7 +101,7 @@ const ChroniclesList = ({ chronicles, currentSlug, title }: Props): JSX.Element
translations={filterHasAttributes(chronicle.attributes.translations, [
"language.data.attributes.code",
"title",
] as const).map((translation) => ({
]).map((translation) => ({
title: translation.title,
language: translation.language.data.attributes.code,
}))}
@ -107,7 +118,7 @@ const ChroniclesList = ({ chronicles, currentSlug, title }: Props): JSX.Element
)}
</div>
))}
</div>
</Collapsible>
</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

@ -249,7 +249,7 @@ export const Terminal = ({
<div className="relative">
<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}
autoCapitalize="none"
autoCorrect="off"

View File

@ -2,7 +2,7 @@ import { cJoin } from "helpers/className";
/*
*
* CONSTANTS
* COMPONENT
*/
interface Props {
@ -15,7 +15,7 @@ interface Props {
export const ColoredSvg = ({ src, className }: Props): JSX.Element => (
<div
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
)}
style={{ mask: `url('${src}')`, WebkitMask: `url('${src}')` }}

View File

@ -38,7 +38,7 @@ export const ContentPanel = ({
<main
className={cJoin(
"relative justify-self-center",
cIf(isContentPanelAtLeast3xl, "px-10 pt-20 pb-32", "px-4 pt-10 pb-20"),
cIf(isContentPanelAtLeast3xl, "px-10 pb-32 pt-20", "px-4 pb-20 pt-10"),
contentPanelWidthSizesToClassName[width],
className
)}>

View File

@ -5,7 +5,7 @@ import { atoms } from "contexts/atoms";
import { isUndefined } from "helpers/asserts";
import { useAtomGetter } from "helpers/atoms";
import { useFormat } from "hooks/useFormat";
import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { useScrollTopOnChange } from "hooks/useScrollOnChange";
import { Ids } from "types/ids";
/*

View File

@ -1,8 +1,8 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { cIf, cJoin } from "helpers/className";
import { atoms } from "contexts/atoms";
import { useAtomSetter } from "helpers/atoms";
import { useAtomGetter, useAtomSetter } from "helpers/atoms";
import { Button } from "components/Inputs/Button";
/*
@ -11,11 +11,11 @@ import { Button } from "components/Inputs/Button";
*/
interface Props {
onOpen?: () => void;
onCloseRequest?: () => void;
isVisible: boolean;
children: React.ReactNode;
fillViewport?: boolean;
hideBackground?: boolean;
padding?: boolean;
withCloseButton?: boolean;
}
@ -23,15 +23,18 @@ interface Props {
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Popup = ({
onOpen,
onCloseRequest,
isVisible,
children,
fillViewport,
hideBackground = false,
padding = true,
withCloseButton = true,
}: Props): JSX.Element => {
const setMenuGesturesEnabled = useAtomSetter(atoms.layout.menuGesturesEnabled);
const [isHidden, setHidden] = useState(!isVisible);
const [isActuallyVisible, setActuallyVisible] = useState(isVisible && !isHidden);
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
useHotkeys("escape", () => onCloseRequest?.(), { enabled: isVisible }, [onCloseRequest]);
@ -39,31 +42,53 @@ export const Popup = ({
setMenuGesturesEnabled(!isVisible);
}, [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
className={cJoin(
"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
className={cJoin(
"fixed inset-0 transition-colors duration-500",
cIf(isVisible, "bg-shade/50", "bg-shade/0")
cIf(isActuallyVisible, "bg-shade/50", "bg-shade/0")
)}
onClick={onCloseRequest}
/>
<div
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(isVisible, "scale-100", "scale-0"),
cIf(isActuallyVisible, "scale-100", "scale-0"),
cIf(
fillViewport,
"absolute inset-10 content-start overflow-scroll",
"relative max-h-[80vh] overflow-y-auto"
),
cIf(!hideBackground, "rounded-lg bg-light shadow-2xl shadow-shade")
)
)}>
{withCloseButton && (
<div className="absolute right-6 top-6">

View File

@ -19,7 +19,7 @@ export const SubPanel = ({ children }: Props): JSX.Element => {
<div
className={cJoin(
"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}
</div>

View File

@ -1,6 +1,8 @@
import { MouseEventHandler, useState } from "react";
import { Link } from "components/Inputs/Link";
import { cIf, cJoin } from "helpers/className";
import { useAtomGetter } from "helpers/atoms";
import { atoms } from "contexts/atoms";
/*
*
@ -27,20 +29,24 @@ export const UpPressable = ({
onClick,
}: Props): JSX.Element => {
const [isFocused, setFocused] = useState(false);
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
return (
<Link
href={href}
onFocusChanged={setFocused}
onClick={onClick}
className={cJoin(
`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(
disabled,
"cursor-not-allowed opacity-50 grayscale",
cJoin(
"cursor-pointer hover:scale-102 hover:drop-shadow-xl",
cIf(isFocused, "hover:scale-105 hover:drop-shadow-2xl hover:duration-100")
"cursor-pointer hover:scale-102",
cIf(isPerfModeEnabled, "hover:shadow-xl", "hover:drop-shadow-xl"),
cIf(isFocused, "hover:scale-105 hover:duration-100")
)
),
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>
);
};

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

View File

@ -33,8 +33,8 @@ export const Ico = ({
<span
onClick={onClick}
className={cJoin(
`material-symbols-rounded [font-size:inherit]
[line-height:inherit]`,
`material-symbols-rounded select-none
[font-size:inherit] [line-height:inherit]`,
className
)}
style={{

View File

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

View File

@ -20,12 +20,13 @@ interface Props {
icon?: MaterialSymbol;
text?: string | null | undefined;
alwaysNewTab?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>;
onMouseUp?: MouseEventHandler<HTMLDivElement>;
onClick?: MouseEventHandler<HTMLButtonElement>;
onMouseUp?: MouseEventHandler<HTMLButtonElement>;
draggable?: boolean;
badgeNumber?: number;
disabled?: boolean;
size?: "normal" | "small";
type?: "button" | "reset" | "submit";
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
@ -43,31 +44,31 @@ export const Button = ({
alwaysNewTab = false,
badgeNumber,
disabled,
type,
size = "normal",
}: Props): JSX.Element => (
<Link href={href} alwaysNewTab={alwaysNewTab} disabled={disabled}>
<div className="relative">
<div
<button
type={type}
draggable={draggable}
id={id}
onClick={(event) => !disabled && onClick?.(event)}
disabled={disabled}
onClick={(event) => onClick?.(event)}
onMouseUp={onMouseUp}
onFocus={(event) => event.target.blur()}
className={cJoin(
`group grid cursor-pointer select-none grid-flow-col place-content-center
place-items-center gap-2 rounded-full border border-dark
leading-none text-dark transition-all`,
cIf(size === "small", "px-3 py-1 text-xs", "py-3 px-4"),
cIf(active, "!border-black bg-black !text-light drop-shadow-lg shadow-black"),
`group grid w-full grid-flow-col
place-content-center place-items-center gap-2 rounded-full border
border-dark leading-none text-dark transition-all
disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale`,
cIf(size === "small", "h-6 px-3 py-1 text-xs", "h-10 px-4 py-3"),
cIf(active, "!border-black bg-black !text-light shadow-lg shadow-black"),
cIf(
disabled,
"cursor-not-allowed opacity-50 grayscale",
cIf(
!active,
`shadow-shade hover:bg-dark hover:text-light hover:drop-shadow-lg
!disabled && !active,
`shadow-shade hover:bg-dark hover:text-light hover:shadow-lg hover:shadow-shade
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
)}>
@ -76,7 +77,7 @@ export const Button = ({
className={cJoin(
`absolute grid place-items-center rounded-full bg-dark
font-bold text-light transition-opacity group-hover:opacity-0`,
cIf(size === "small", "-top-2 -right-2 h-5 w-5", "-top-3 -right-2 h-8 w-8")
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>
</div>
@ -90,8 +91,10 @@ export const Button = ({
weight={size === "normal" ? 500 : 800}
/>
)}
{isDefinedAndNotEmpty(text) && <p className="-translate-y-[0.05em] text-center">{text}</p>}
</div>
{isDefinedAndNotEmpty(text) && (
<p className="line-clamp-1 -translate-y-[0.05em] text-center leading-5">{text}</p>
)}
</button>
</div>
</Link>
);

View File

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

View File

@ -2,11 +2,9 @@ import { Fragment } from "react";
import { ToolTip } from "../ToolTip";
import { Button } from "./Button";
import { cJoin } from "helpers/className";
import { prettyLanguage } from "helpers/formatters";
import { iterateMap } from "helpers/others";
import { sendAnalytics } from "helpers/analytics";
import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms";
import { useFormat } from "hooks/useFormat";
/*
*
@ -32,7 +30,7 @@ export const LanguageSwitcher = ({
onLanguageChanged,
showBadge = true,
}: Props): JSX.Element => {
const languages = useAtomGetter(atoms.localData.languages);
const { formatLanguage } = useFormat();
return (
<ToolTip
content={
@ -45,7 +43,7 @@ export const LanguageSwitcher = ({
onLanguageChanged(value);
sendAnalytics("Language Switcher", `Switch language (${locale})`);
}}
text={prettyLanguage(locale, languages)}
text={formatLanguage(locale)}
/>
</Fragment>
))}

View File

@ -52,7 +52,7 @@ export const Select = ({
ref={ref}
className={cJoin(
"relative text-center transition-filter",
cIf(isOpened, "z-10 drop-shadow-lg shadow-shade"),
cIf(isOpened, "z-20 drop-shadow-lg shadow-shade"),
className
)}>
<div

View File

@ -1,3 +1,4 @@
import { forwardRef } from "react";
import { Ico } from "components/Ico";
import { cIf, cJoin } from "helpers/className";
import { isDefinedAndNotEmpty } from "helpers/asserts";
@ -18,19 +19,15 @@ interface Props {
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const TextInput = ({
value,
onChange,
className,
name,
placeholder,
disabled = false,
}: Props): JSX.Element => (
export const TextInput = forwardRef<HTMLInputElement, Props>(
({ value, onChange, className, name, placeholder, disabled = false }, ref) => (
<div className={cJoin("relative", className)}>
<input
ref={ref}
className="w-full"
type="text"
name={name}
autoCapitalize="off"
value={value}
disabled={disabled}
placeholder={placeholder ?? undefined}
@ -39,7 +36,7 @@ export const TextInput = ({
}}
/>
{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
className={cJoin("!text-xs", cIf(disabled, "opacity-30 grayscale", "cursor-pointer"))}
icon="close"
@ -48,4 +45,6 @@ export const TextInput = ({
</div>
)}
</div>
)
);
TextInput.displayName = "TextInput";

View File

@ -39,6 +39,7 @@ export const LightBox = ({
onPressNext,
}: Props): JSX.Element => {
const [currentZoom, setCurrentZoom] = useState(1);
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
const { isFullscreen, toggleFullscreen, exitFullscreen, requestFullscreen } = useFullscreen(
Ids.LightBox
);
@ -62,7 +63,7 @@ export const LightBox = ({
id={Ids.LightBox}
className={cJoin(
"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
className={cJoin(
@ -90,8 +91,10 @@ export const LightBox = ({
}}>
{isDefined(src) && (
<Img
className={`h-[calc(100vh-4rem)] w-full object-contain drop-shadow-2xl
shadow-shade`}
className={cJoin(
`h-[calc(100vh-4rem)] w-full object-contain`,
cIf(!isPerfModeEnabled, "drop-shadow-2xl shadow-shade")
)}
src={src}
quality={ImageQuality.Large}
/>
@ -172,7 +175,7 @@ const ControlButtons = ({
<NextButton />
</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 />
</div>
</>
@ -180,20 +183,20 @@ const ControlButtons = ({
<>
{isPreviousImageAvailable && (
<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`}>
<PreviousButton />
</div>
)}
{isNextImageAvailable && (
<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`}>
<NextButton />
</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`}>
<CloseButton />
<FullscreenButton />

View File

@ -1,6 +1,7 @@
import Markdown from "markdown-to-jsx";
import React, { Fragment, MouseEventHandler, useMemo } from "react";
import ReactDOMServer from "react-dom/server";
import { z } from "zod";
import { HorizontalLine } from "components/HorizontalLine";
import { Img } from "components/Img";
import { InsetBox } from "components/Containers/InsetBox";
@ -16,6 +17,8 @@ import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms";
import { Link } from "components/Inputs/Link";
import { useFormat } from "hooks/useFormat";
import { VideoPlayer } from "components/Player";
import { getVideoFile } from "helpers/videos";
/*
*
@ -117,14 +120,43 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
},
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
className={cJoin("!my-0 text-dark/60", cIf(!isContentPanelAtLeastLg, "!-mb-4"))}>
<Markdawn text={compProps.name} />
className={cJoin(
"!my-0 text-dark/60",
cIf(!isContentPanelAtLeastLg, "!-mb-4")
)}>
<Markdawn text={safeProps.name} />
</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"
/>
),
},
@ -186,7 +218,7 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
name?: string;
}) => (
<div
className="mt-8 mb-12 grid cursor-pointer place-content-center"
className="mb-12 mt-8 grid cursor-pointer place-content-center"
onClick={() => {
showLightBox([
compProps.src.startsWith("/uploads/")
@ -215,6 +247,21 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface MarkdawnErrorProps {
message: string;
}
const MarkdawnError = ({ message }: MarkdawnErrorProps): JSX.Element => (
<div
className="flex place-items-center gap-4 whitespace-pre-line rounded-md
bg-[red]/10 px-4 text-[red]">
<Ico icon="error" isFilled={false} />
<p>{message}</p>
</div>
);
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface TableOfContentsProps {
toc: TocInterface;
onContentClicked?: MouseEventHandler<HTMLAnchorElement>;
@ -265,7 +312,7 @@ const Header = ({ level, title, slug }: HeaderProps): JSX.Element => {
<>
<div className="ml-10 flex place-items-center gap-4">
{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="emergency" />
<Ico icon="emergency" />
<Ico icon="emergency" />

View File

@ -2,10 +2,8 @@ import { useCallback } from "react";
import { Button } from "components/Inputs/Button";
import { TranslatedProps } from "types/TranslatedProps";
import { useSmartLanguage } from "hooks/useSmartLanguage";
import { isUndefined } from "helpers/asserts";
import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms";
import { useFormat } from "hooks/useFormat";
import { cJoin } from "helpers/className";
/*
*
@ -15,27 +13,18 @@ import { useFormat } from "hooks/useFormat";
interface Props {
href: string;
title: string | null | undefined;
displayOnlyOn?: "1ColumnLayout" | "3ColumnsLayout";
className?: string;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const ReturnButton = ({ href, title, displayOnlyOn, className }: Props): JSX.Element => {
export const ReturnButton = ({ href, title, className }: Props): JSX.Element => {
const { format } = useFormat();
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
return (
<>
{((is3ColumnsLayout && displayOnlyOn === "3ColumnsLayout") ||
(!is3ColumnsLayout && displayOnlyOn === "1ColumnLayout") ||
isUndefined(displayOnlyOn)) && (
<div className={className}>
<div className={cJoin("mx-auto w-full max-w-lg place-self-center", className)}>
<Button href={href} text={format("return_to_x", { x: title })} icon="navigate_before" />
</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

@ -23,9 +23,12 @@ export const MainPanel = (): JSX.Element => {
const { format } = useFormat();
const [isMainPanelReduced, setMainPanelReduced] = useAtomPair(atoms.layout.mainPanelReduced);
const setMainPanelOpened = useAtomSetter(atoms.layout.mainPanelOpened);
const [isSettingsOpened, setSettingsOpened] = useAtomPair(atoms.layout.settingsOpened);
const [isSearchOpened, setSearchOpened] = useAtomPair(atoms.layout.searchOpened);
const [isDebugMenuOpened, setDebugMenuOpened] = useAtomPair(atoms.layout.debugMenuOpened);
const isDebugMenuAvailable = useAtomGetter(atoms.layout.debugMenuAvailable);
const closeMainPanel = useCallback(() => setMainPanelOpened(false), [setMainPanelOpened]);
const setSettingsOpened = useAtomSetter(atoms.layout.settingsOpened);
const setSearchOpened = useAtomSetter(atoms.layout.searchOpened);
return (
<div
@ -82,6 +85,7 @@ export const MainPanel = (): JSX.Element => {
content={<h3 className="text-2xl">{format("open_settings")}</h3>}
placement={isMainPanelReduced ? "right" : "top"}>
<Button
active={isSettingsOpened}
onClick={() => {
closeMainPanel();
setSettingsOpened(true);
@ -94,6 +98,7 @@ export const MainPanel = (): JSX.Element => {
content={<h3 className="text-2xl">{format("open_search")}</h3>}
placement={isMainPanelReduced ? "right" : "top"}>
<Button
active={isSearchOpened}
onClick={() => {
closeMainPanel();
setSearchOpened(true);
@ -102,6 +107,21 @@ export const MainPanel = (): JSX.Element => {
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>
@ -186,7 +206,7 @@ export const MainPanel = (): JSX.Element => {
<Markdawn text={format("licensing_notice")} />
</p>
)}
<div className="mt-4 mb-8 grid place-content-center">
<div className="mb-8 mt-4 grid place-content-center">
<Link
onClick={() => sendAnalytics("MainPanel", "Visit license")}
aria-label="Read more about the license we use for this website"
@ -212,7 +232,7 @@ export const MainPanel = (): JSX.Element => {
<Markdawn text={format("copyright_notice")} />
</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">
<Link
aria-label="Browse our GitHub repository, which include this website source code"
onClick={() => sendAnalytics("MainPanel", "Visit GitHub")}

View File

@ -1,13 +1,18 @@
import { useEffect, useState } from "react";
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, meiliSearch } from "helpers/search";
import {
containsHighlight,
CustomSearchResponse,
filterHitsWithHighlight,
meiliMultiSearch,
} from "helpers/search";
import { PreviewCard, TranslatedPreviewCard } from "components/PreviewCard";
import { filterDefined, filterHasAttributes, isDefined } from "helpers/asserts";
import { filterHasAttributes, isDefined } from "helpers/asserts";
import {
MeiliContent,
MeiliIndices,
@ -19,7 +24,7 @@ import {
} from "shared/meilisearch-graphql-typings/meiliTypes";
import { getVideoThumbnailURL } from "helpers/videos";
import { UpPressable } from "components/Containers/UpPressable";
import { prettyItemSubType, prettySlug } from "helpers/formatters";
import { prettySlug } from "helpers/formatters";
import { Ico } from "components/Ico";
import { useFormat } from "hooks/useFormat";
@ -35,20 +40,35 @@ 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 } = useFormat();
const [libraryItems, setLibraryItems] = useState<CustomSearchResponse<MeiliLibraryItem>>();
const [contents, setContents] = useState<CustomSearchResponse<MeiliContent>>();
const [videos, setVideos] = useState<CustomSearchResponse<MeiliVideo>>();
const [posts, setPosts] = useState<CustomSearchResponse<MeiliPost>>();
const [wikiPages, setWikiPages] = useState<CustomSearchResponse<MeiliWikiPage>>();
const [weapons, setWeapons] = useState<CustomSearchResponse<MeiliWeapon>>();
const {
format,
formatCategory,
formatContentType,
formatWikiTag,
formatLibraryItemSubType,
formatWeaponType,
} = useFormat();
const [multiResult, setMultiResult] = useState<MultiResult>({});
useEffect(() => {
const fetchLibraryItems = async () => {
const searchResult = await meiliSearch(MeiliIndices.LIBRARY_ITEM, query, {
const fetchSearchResults = useCallback((q: string) => {
const fetchMultiResult = async () => {
const searchResults = (
await meiliMultiSearch([
{
indexUid: MeiliIndices.LIBRARY_ITEM,
q,
limit: SEARCH_LIMIT,
attributesToRetrieve: [
"title",
@ -64,38 +84,18 @@ export const SearchPopup = (): JSX.Element => {
],
attributesToHighlight: ["title", "subtitle", "descriptions"],
attributesToCrop: ["descriptions"],
});
searchResult.hits = searchResult.hits.map((item) => {
if (Object.keys(item._matchesPosition).some((match) => match.startsWith("descriptions"))) {
item._formatted.descriptions = filterDefined(item._formatted.descriptions).filter(
(description) => containsHighlight(JSON.stringify(description))
);
}
return item;
});
setLibraryItems(searchResult);
};
const fetchContents = async () => {
const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, {
},
{
indexUid: MeiliIndices.CONTENT,
q,
limit: SEARCH_LIMIT,
attributesToRetrieve: ["translations", "id", "slug", "categories", "type", "thumbnail"],
attributesToHighlight: ["translations"],
attributesToCrop: ["translations.displayable_description"],
});
searchResult.hits = searchResult.hits.map((item) => {
if (Object.keys(item._matchesPosition).some((match) => match.startsWith("translations"))) {
item._formatted.translations = filterDefined(item._formatted.translations).filter(
(translation) => containsHighlight(JSON.stringify(translation))
);
}
return item;
});
setContents(searchResult);
};
const fetchVideos = async () => {
const searchResult = await meiliSearch(MeiliIndices.VIDEOS, query, {
},
{
indexUid: MeiliIndices.VIDEOS,
q,
limit: SEARCH_LIMIT,
attributesToRetrieve: [
"title",
@ -108,50 +108,27 @@ export const SearchPopup = (): JSX.Element => {
],
attributesToHighlight: ["title", "channel", "description"],
attributesToCrop: ["description"],
});
setVideos(searchResult);
};
const fetchPosts = async () => {
const searchResult = await meiliSearch(MeiliIndices.POST, query, {
},
{
indexUid: MeiliIndices.POST,
q,
limit: SEARCH_LIMIT,
attributesToRetrieve: ["translations", "thumbnail", "slug", "date", "categories"],
attributesToHighlight: ["translations.title", "translations.excerpt", "translations.body"],
attributesToCrop: ["translations.body"],
attributesToHighlight: ["translations.title", "translations.displayable_description"],
attributesToCrop: ["translations.displayable_description"],
filter: ["hidden = false"],
});
searchResult.hits = searchResult.hits.map((item) => {
if (Object.keys(item._matchesPosition).some((match) => match.startsWith("translations"))) {
item._formatted.translations = filterDefined(item._formatted.translations).filter(
(translation) => JSON.stringify(translation).includes("</mark>")
);
}
return item;
});
setPosts(searchResult);
};
const fetchWeapons = async () => {
const searchResult = await meiliSearch(MeiliIndices.WEAPON, query, {
},
{
indexUid: MeiliIndices.WEAPON,
q,
limit: SEARCH_LIMIT,
attributesToRetrieve: ["*"],
attributesToHighlight: ["translations.description", "translations.names"],
attributesToCrop: ["translations.description"],
sort: ["slug:asc"],
});
searchResult.hits = searchResult.hits.map((item) => {
if (Object.keys(item._matchesPosition).some((match) => match.startsWith("translations"))) {
item._formatted.translations = filterDefined(item._formatted.translations).filter(
(translation) => JSON.stringify(translation).includes("</mark>")
);
}
return item;
});
setWeapons(searchResult);
};
const fetchWikiPages = async () => {
const searchResult = await meiliSearch(MeiliIndices.WIKI_PAGE, query, {
},
{
indexUid: MeiliIndices.WIKI_PAGE,
q,
limit: SEARCH_LIMIT,
attributesToHighlight: [
"translations.title",
@ -160,37 +137,66 @@ export const SearchPopup = (): JSX.Element => {
"translations.displayable_description",
],
attributesToCrop: ["translations.displayable_description"],
});
searchResult.hits = searchResult.hits.map((item) => {
if (
Object.keys(item._matchesPosition).filter((match) => match.startsWith("translations"))
.length > 0
) {
item._formatted.translations = filterDefined(item._formatted.translations).filter(
(translation) => JSON.stringify(translation).includes("</mark>")
},
])
).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?");
}
}
return item;
});
setWikiPages(searchResult);
setMultiResult(result);
};
if (query === "") {
setWikiPages(undefined);
setLibraryItems(undefined);
setContents(undefined);
setVideos(undefined);
setPosts(undefined);
setWeapons(undefined);
if (q === "") {
setMultiResult({});
} else {
fetchWikiPages();
fetchLibraryItems();
fetchContents();
fetchVideos();
fetchPosts();
fetchWeapons();
fetchMultiResult();
}
}, [query]);
setQuery(q);
}, []);
const searchInputRef = useRef<HTMLInputElement>(null);
return (
<Popup
@ -199,23 +205,29 @@ export const SearchPopup = (): JSX.Element => {
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 onChange={setQuery} value={query} placeholder={format("search_title")} />
<TextInput
ref={searchInputRef}
onChange={fetchSearchResults}
value={query}
placeholder={format("search_placeholder")}
/>
<div className="flex w-full flex-wrap gap-12 gap-x-16">
{isDefined(libraryItems) && (
{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={libraryItems.estimatedTotalHits}>
totalHits={multiResult.libraryItems.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{libraryItems.hits.map((item) => (
{multiResult.libraryItems.hits.map((item) => (
<TranslatedPreviewCard
key={item.id}
className="w-56"
@ -223,7 +235,7 @@ export const SearchPopup = (): JSX.Element => {
onClick={() => setSearchOpened(false)}
translations={filterHasAttributes(item._formatted.descriptions, [
"language.data.attributes.code",
] as const).map((translation) => ({
]).map((translation) => ({
language: translation.language.data.attributes.code,
title: item.title,
subtitle: item.subtitle,
@ -238,11 +250,11 @@ export const SearchPopup = (): JSX.Element => {
keepInfoVisible
topChips={
item.metadata && item.metadata.length > 0 && item.metadata[0]
? [prettyItemSubType(item.metadata[0])]
? [formatLibraryItemSubType(item.metadata[0])]
: []
}
bottomChips={item.categories?.data.map(
(category) => category.attributes?.short ?? ""
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
(category) => formatCategory(category.attributes.slug)
)}
metadata={{
releaseDate: item.release_date,
@ -255,14 +267,14 @@ export const SearchPopup = (): JSX.Element => {
</SearchResultSection>
)}
{isDefined(contents) && (
{isDefined(multiResult.contents) && (
<SearchResultSection
title={format("contents")}
icon="workspaces"
href={`/contents/all?page=1&query=${query}&sort=0`}
totalHits={contents.estimatedTotalHits}>
totalHits={multiResult.contents.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{contents.hits.map((item) => (
{multiResult.contents.hits.map((item) => (
<TranslatedPreviewCard
key={item.id}
className="w-56"
@ -270,7 +282,7 @@ export const SearchPopup = (): JSX.Element => {
onClick={() => setSearchOpened(false)}
translations={filterHasAttributes(item._formatted.translations, [
"language.data.attributes.code",
] as const).map(({ displayable_description, language, ...otherAttributes }) => ({
]).map(({ displayable_description, language, ...otherAttributes }) => ({
...otherAttributes,
description: containsHighlight(displayable_description)
? displayable_description
@ -283,15 +295,11 @@ export const SearchPopup = (): JSX.Element => {
thumbnailForceAspectRatio
topChips={
item.type?.data?.attributes
? [
item.type.data.attributes.titles?.[0]
? item.type.data.attributes.titles[0]?.title
: prettySlug(item.type.data.attributes.slug),
]
? [formatContentType(item.type.data.attributes.slug)]
: undefined
}
bottomChips={item.categories?.data.map(
(category) => category.attributes?.short ?? ""
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
(category) => formatCategory(category.attributes.slug)
)}
keepInfoVisible
/>
@ -300,14 +308,14 @@ export const SearchPopup = (): JSX.Element => {
</SearchResultSection>
)}
{isDefined(wikiPages) && (
{isDefined(multiResult.wikiPages) && (
<SearchResultSection
title={format("wiki")}
icon="travel_explore"
href={`/wiki?page=1&query=${query}`}
totalHits={wikiPages.estimatedTotalHits}>
totalHits={multiResult.wikiPages.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{wikiPages.hits.map((item) => (
{multiResult.wikiPages.hits.map((item) => (
<TranslatedPreviewCard
key={item.id}
className="w-56"
@ -315,7 +323,7 @@ export const SearchPopup = (): JSX.Element => {
onClick={() => setSearchOpened(false)}
translations={filterHasAttributes(item._formatted.translations, [
"language.data.attributes.code",
] as const).map(
]).map(
({
aliases,
summary,
@ -340,26 +348,26 @@ export const SearchPopup = (): JSX.Element => {
thumbnailRounded
thumbnailForceAspectRatio
keepInfoVisible
topChips={filterHasAttributes(item.tags?.data, ["attributes"] as const).map(
(tag) => tag.attributes.titles?.[0]?.title ?? prettySlug(tag.attributes.slug)
topChips={filterHasAttributes(item.tags?.data, ["attributes"]).map((tag) =>
formatWikiTag(tag.attributes.slug)
)}
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
(category) => formatCategory(category.attributes.slug)
)}
bottomChips={filterHasAttributes(item.categories?.data, [
"attributes",
] as const).map((category) => category.attributes.short)}
/>
))}
</div>
</SearchResultSection>
)}
{isDefined(posts) && (
{isDefined(multiResult.posts) && (
<SearchResultSection
title={format("news")}
icon="newspaper"
href={`/news?page=1&query=${query}`}
totalHits={posts.estimatedTotalHits}>
totalHits={multiResult.posts.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{posts.hits.map((item) => (
{multiResult.posts.hits.map((item) => (
<TranslatedPreviewCard
className="w-56"
key={item.id}
@ -367,12 +375,10 @@ export const SearchPopup = (): JSX.Element => {
onClick={() => setSearchOpened(false)}
translations={filterHasAttributes(item._formatted.translations, [
"language.data.attributes.code",
] as const).map(({ excerpt, body, language, ...otherAttributes }) => ({
]).map(({ excerpt, displayable_description, language, ...otherAttributes }) => ({
...otherAttributes,
description: containsHighlight(excerpt)
? excerpt
: containsHighlight(body)
? body
description: containsHighlight(displayable_description)
? displayable_description
: excerpt,
language: language.data.attributes.code,
}))}
@ -381,8 +387,8 @@ export const SearchPopup = (): JSX.Element => {
thumbnailAspectRatio="3/2"
thumbnailForceAspectRatio
keepInfoVisible
bottomChips={item.categories?.data.map(
(category) => category.attributes?.short ?? ""
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
(category) => formatCategory(category.attributes.slug)
)}
metadata={{
releaseDate: item.date,
@ -395,14 +401,14 @@ export const SearchPopup = (): JSX.Element => {
</SearchResultSection>
)}
{isDefined(videos) && (
{isDefined(multiResult.videos) && (
<SearchResultSection
title={format("videos")}
icon="movie"
href={`/archives/videos?page=1&query=${query}&sort=1&gone=`}
totalHits={videos.estimatedTotalHits}>
totalHits={multiResult.videos.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{videos.hits.map((item) => (
{multiResult.videos.hits.map((item) => (
<PreviewCard
className="w-56"
key={item.uid}
@ -435,28 +441,26 @@ export const SearchPopup = (): JSX.Element => {
</SearchResultSection>
)}
{isDefined(weapons) && (
{isDefined(multiResult.weapons) && (
<SearchResultSection
title={format("weapon", { count: Infinity })}
icon="shield"
href={`/wiki/weapons?page=1&query=${query}`}
totalHits={weapons.estimatedTotalHits}>
totalHits={multiResult.weapons.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{weapons.hits.map((item) => (
{multiResult.weapons.hits.map((item) => (
<TranslatedPreviewCard
key={item.id}
className="w-56"
href={"/"}
translations={filterHasAttributes(item._formatted.translations, [
"language.data.attributes.code",
] as const).map(
({ description, language, names: [primaryName, ...aliases] }) => ({
]).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"
@ -465,12 +469,12 @@ export const SearchPopup = (): JSX.Element => {
keepInfoVisible
topChips={
item.type?.data?.attributes?.slug
? [prettySlug(item.type.data.attributes.slug)]
? [formatWeaponType(item.type.data.attributes.slug)]
: undefined
}
bottomChips={filterHasAttributes(item.categories, [
"attributes.short",
] as const).map((category) => category.attributes.short)}
bottomChips={filterHasAttributes(item.categories, ["attributes"]).map(
(category) => formatCategory(category.attributes.slug)
)}
/>
))}
</div>

View File

@ -1,6 +1,5 @@
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Button } from "components/Inputs/Button";
import { ButtonGroup } from "components/Inputs/ButtonGroup";
import { OrderableList } from "components/Inputs/OrderableList";
import { Select } from "components/Inputs/Select";
@ -8,14 +7,14 @@ import { TextInput } from "components/Inputs/TextInput";
import { Popup } from "components/Containers/Popup";
import { sendAnalytics } from "helpers/analytics";
import { cJoin, cIf } from "helpers/className";
import { prettyLanguage } from "helpers/formatters";
import { filterHasAttributes, isDefined } from "helpers/asserts";
import { atoms } from "contexts/atoms";
import { useAtomGetter, useAtomPair } from "helpers/atoms";
import { ThemeMode } from "contexts/settings";
import { useAtomGetter, useAtomPair, useAtomSetter } from "helpers/atoms";
import { PerfMode, ThemeMode } from "contexts/settings";
import { Ico } from "components/Ico";
import { useFormat } from "hooks/useFormat";
import { ToolTip } from "components/ToolTip";
import { Switch } from "components/Inputs/Switch";
/*
*
@ -32,16 +31,18 @@ export const SettingsPopup = (): JSX.Element => {
const [fontSize, setFontSize] = useAtomPair(atoms.settings.fontSize);
const [playerName, setPlayerName] = useAtomPair(atoms.settings.playerName);
const [themeMode, setThemeMode] = useAtomPair(atoms.settings.themeMode);
const setPerfMode = useAtomSetter(atoms.settings.perfMode);
const perfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
const isPerfModeToggleable = useAtomGetter(atoms.settings.isPerfModeToggleable);
const languages = useAtomGetter(atoms.localData.languages);
const { format } = useFormat();
const { format, formatLanguage } = useFormat();
const currencies = useAtomGetter(atoms.localData.currencies);
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const router = useRouter();
const currencyOptions = filterHasAttributes(currencies, ["attributes"] as const).map(
const currencyOptions = filterHasAttributes(currencies, ["attributes"]).map(
(currentCurrency) => currentCurrency.attributes.code
);
@ -74,7 +75,7 @@ export const SettingsPopup = (): JSX.Element => {
<OrderableList
items={preferredLanguages.map((locale) => ({
code: locale,
name: prettyLanguage(locale, languages),
name: formatLanguage(locale),
}))}
insertLabels={[
{
@ -199,31 +200,41 @@ export const SettingsPopup = (): JSX.Element => {
<div>
<h3 className="text-xl">{format("font")}</h3>
<div className="grid gap-2">
<Button
active={!isDyslexic}
onClick={() => {
<ButtonGroup
vertical
buttonsProps={[
{
active: !isDyslexic,
onClick: () => {
setDyslexic(false);
sendAnalytics("Settings", "Change font (Zen Maru Gothic)");
}}
className="font-zenMaruGothic"
text="Zen Maru Gothic"
/>
<Button
active={isDyslexic}
onClick={() => {
},
className: "font-zenMaruGothic",
text: "Zen Maru Gothic",
},
{
active: isDyslexic,
onClick: () => {
setDyslexic(true);
sendAnalytics("Settings", "Change font (OpenDyslexic)");
}}
className="font-openDyslexic"
text="OpenDyslexic"
},
className: "font-openDyslexic",
text: "OpenDyslexic",
},
]}
/>
</div>
</div>
<div>
<div className="flex place-content-center place-items-center gap-1">
<h3 className="text-xl">{format("player_name")}</h3>
<ToolTip content={format("player_name_tooltip")} placement="top">
<Ico icon="info" />
</ToolTip>
</div>
<TextInput
placeholder="<player>"
placeholder="(player)"
className="w-48"
value={playerName}
onChange={(newName) => {
@ -232,6 +243,20 @@ export const SettingsPopup = (): JSX.Element => {
}}
/>
</div>
<div className="grid place-items-center">
<div className="flex place-content-center place-items-center gap-1">
<h3 className="text-xl">{format("performance_mode")}</h3>
<ToolTip content={format("performance_mode_tooltip")} placement="top">
<Ico icon="info" />
</ToolTip>
</div>
<Switch
value={perfModeEnabled}
onClick={() => setPerfMode(perfModeEnabled ? PerfMode.Off : PerfMode.On)}
disabled={!isPerfModeToggleable}
/>
</div>
</div>
</div>
</Popup>

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,21 +1,20 @@
import { Fragment, useCallback } from "react";
import { useCallback } from "react";
import { AppLayout, AppLayoutRequired } from "./AppLayout";
import { Chip } from "./Chip";
import { getTocFromMarkdawn, Markdawn, TableOfContents } from "./Markdown/Markdawn";
import { ReturnButton } from "./PanelComponents/ReturnButton";
import { ContentPanel } from "./Containers/ContentPanel";
import { SubPanel } from "./Containers/SubPanel";
import { RecorderChip } from "./RecorderChip";
import { ThumbnailHeader } from "./ThumbnailHeader";
import { ToolTip } from "./ToolTip";
import { useSmartLanguage } from "hooks/useSmartLanguage";
import { PostWithTranslations } from "types/types";
import { filterHasAttributes, isDefined } from "helpers/asserts";
import { prettySlug } from "helpers/formatters";
import { useFormat } from "hooks/useFormat";
import { useAtomGetter, useAtomSetter } from "helpers/atoms";
import { atoms } from "contexts/atoms";
import { ElementsSeparator } from "helpers/component";
import { HorizontalLine } from "components/HorizontalLine";
import { Credits } from "components/Credits";
import { useFormat } from "hooks/useFormat";
/*
*
@ -50,7 +49,7 @@ export const PostPage = ({
displayTitle = true,
...otherProps
}: Props): JSX.Element => {
const { format, formatStatusDescription } = useFormat();
const { formatCategory } = useFormat();
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
@ -76,36 +75,7 @@ export const PostPage = ({
<ReturnButton href={returnHref} title={returnTitle} />
),
displayCredits && (
<>
{selectedTranslation && (
<div className="grid grid-flow-col place-content-center place-items-center gap-2">
<p className="font-headers font-bold">{format("status")}:</p>
<ToolTip
content={formatStatusDescription(selectedTranslation.status)}
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>
)}
</>
),
displayCredits && <Credits status={selectedTranslation?.status} authors={post.authors?.data} />,
displayToc && isDefined(toc) && (
<TableOfContents toc={toc} onContentClicked={() => setSubPanelOpened(false)} />
@ -121,27 +91,28 @@ export const PostPage = ({
const contentPanel = (
<ContentPanel>
{returnHref && returnTitle && (
<ReturnButton
href={returnHref}
title={returnTitle}
displayOnlyOn={"1ColumnLayout"}
className="mb-10"
/>
{is1ColumnLayout && returnHref && returnTitle && (
<ReturnButton href={returnHref} title={returnTitle} className="mb-10" />
)}
{displayThumbnailHeader ? (
<>
<ThumbnailHeader
thumbnail={thumbnail}
title={title}
description={excerpt}
categories={post.categories}
categories={filterHasAttributes(post.categories?.data, ["attributes"]).map((category) =>
formatCategory(category.attributes.slug)
)}
releaseDate={post.date}
languageSwitcher={
languageSwitcherProps.locales.size > 1 ? (
<LanguageSwitcher {...languageSwitcherProps} />
) : undefined
}
/>
<HorizontalLine />
</>
) : (
<>
{displayLanguageSwitcher && (

View File

@ -1,5 +1,4 @@
import { MouseEventHandler, useCallback } from "react";
import { useRouter } from "next/router";
import { Markdown } from "./Markdown/Markdown";
import { Chip } from "components/Chip";
import { Ico } from "components/Ico";
@ -7,13 +6,15 @@ import { Img } from "components/Img";
import { UpPressable } from "components/Containers/UpPressable";
import { DatePickerFragment, PricePickerFragment, UploadImageFragment } from "graphql/generated";
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 { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { useSmartLanguage } from "hooks/useSmartLanguage";
import { TranslatedProps } from "types/TranslatedProps";
import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms";
import { useFormat } from "hooks/useFormat";
import { isDefined } from "helpers/asserts";
/*
*
@ -77,25 +78,25 @@ export const PreviewCard = ({
disabled = false,
onClick,
}: Props): JSX.Element => {
const currency = useAtomGetter(atoms.settings.currency);
const currencies = useAtomGetter(atoms.localData.currencies);
const { formatPrice, formatDate } = useFormat();
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
const preferredCurrency = useAtomGetter(atoms.settings.currency);
const isHoverable = useDeviceSupportsHover();
const router = useRouter();
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">
{metadata.releaseDate && (
<p className="text-sm">
<Ico icon="event" className="mr-1 translate-y-[.15em] !text-base" />
{prettyDate(metadata.releaseDate, router.locale)}
{formatDate(metadata.releaseDate)}
</p>
)}
{metadata.price && (
<p className="justify-self-end text-sm">
<Ico icon="shopping_cart" className="mr-1 translate-y-[.15em] !text-base" />
{prettyPrice(metadata.price, currencies, currency)}
{formatPrice(metadata.price, preferredCurrency)}
</p>
)}
{metadata.views && (
@ -117,7 +118,7 @@ export const PreviewCard = ({
return (
<UpPressable
className={cJoin("grid items-end text-left", className)}
className={cJoin("relative grid items-end text-left", className)}
href={href}
onClick={onClick}
noBackground
@ -157,7 +158,7 @@ export const PreviewCard = ({
shadow-shade group-hover:opacity-100 dark:text-black"
/>
</div>
<div className="absolute right-2 bottom-2 rounded-full bg-black/60 px-2 text-light">
<div className="absolute bottom-2 right-2 rounded-full bg-black/60 px-2 text-light">
{prettyDuration(hoverlay.duration)}
</div>
</>
@ -177,11 +178,11 @@ export const PreviewCard = ({
"z-20 grid gap-2 p-4 transition-opacity linearbg-obi",
cIf(
!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%]
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`,
"[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}
@ -204,7 +205,7 @@ export const PreviewCard = ({
)}
{subtitle && <Markdown text={subtitle} className="leading-none break-words" />}
</div>
{description && <Markdown text={description} className="break-words" />}
{description && <Markdown text={description} className="overflow-hidden break-words" />}
{bottomChips && bottomChips.length > 0 && (
<div
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/asserts";
/*
*
* 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,10 +3,12 @@ import { Img } from "./Img";
import { Markdawn } from "./Markdown/Markdawn";
import { ToolTip } from "./ToolTip";
import { Chip } from "components/Chip";
import { RecorderChipFragment } from "graphql/generated";
import { ImageQuality } from "helpers/img";
import { filterHasAttributes } from "helpers/asserts";
import { filterHasAttributes, isUndefined } from "helpers/asserts";
import { useFormat } from "hooks/useFormat";
import { useAtomGetter } from "helpers/atoms";
import { atoms } from "contexts/atoms";
import { useSmartLanguage } from "hooks/useSmartLanguage";
/*
*
@ -14,14 +16,22 @@ import { useFormat } from "hooks/useFormat";
*/
interface Props {
className?: string;
recorder: RecorderChipFragment;
username: string;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const RecorderChip = ({ recorder }: Props): JSX.Element => {
export const RecorderChip = ({ username }: Props): JSX.Element => {
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 (
<ToolTip
@ -40,13 +50,11 @@ export const RecorderChip = ({ recorder }: Props): JSX.Element => {
{recorder.languages?.data && recorder.languages.data.length > 0 && (
<div className="flex flex-row flex-wrap gap-1">
<p>{format("language", { count: recorder.languages.data.length })}:</p>
{filterHasAttributes(recorder.languages.data, ["attributes"] as const).map(
(language) => (
{filterHasAttributes(recorder.languages.data, ["attributes"]).map((language) => (
<Fragment key={language.__typename}>
<Chip text={language.attributes.code.toUpperCase()} />
</Fragment>
)
)}
))}
</div>
)}
{recorder.pronouns && (
@ -57,7 +65,7 @@ export const RecorderChip = ({ recorder }: Props): JSX.Element => {
)}
</div>
</div>
{recorder.bio?.[0] && <Markdawn text={recorder.bio[0].bio ?? ""} />}
{selectedBioTranslation?.bio && <Markdawn text={selectedBioTranslation.bio} />}
</div>
}
placement="top">

View File

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

View File

@ -1,5 +1,5 @@
// eslint-disable-next-line import/named
import Tippy, { TippyProps } from "@tippyjs/react";
import Tippy from "@tippyjs/react";
import type { TippyProps } from "@tippyjs/react";
import { cJoin } from "helpers/className";
import "tippy.js/animations/scale-subtle.css";

View File

@ -1,10 +1,11 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { containerQueries } from "contexts/containerQueries";
import { userAgent } from "contexts/userAgent";
import { atomPairing } from "helpers/atoms";
import { settings } from "contexts/settings";
import { UploadImageFragment } from "graphql/generated";
import { Languages, Currencies, Langui } from "helpers/localData";
import { Languages, Currencies, Langui, Recorders, TypesTranslations } from "helpers/localData";
/* [ LOCAL DATA ATOMS ] */
@ -12,12 +13,29 @@ 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 ] */
@ -40,6 +58,8 @@ const searchOpened = atomPairing(atom(false));
const settingsOpened = atomPairing(atom(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 terminalMode = atom((get) => get(settings.playerName[0]) === "root");
@ -51,6 +71,8 @@ const layout = {
mainPanelOpened,
menuGesturesEnabled,
terminalMode,
debugMenuAvailable,
debugMenuOpened,
};
export const atoms = {
@ -59,10 +81,11 @@ export const atoms = {
localData,
lightBox,
containerQueries,
userAgent,
};
// Do not import outside of the "contexts" folder
export const internalAtoms = {
lightBox: lightBoxAtom,
localData: { languages, currencies, langui, fallbackLangui },
localData: { languages, currencies, langui, fallbackLangui, recorders, typesTranslations },
};

View File

@ -7,10 +7,17 @@ import {
LocalDataGetWebsiteInterfacesQuery,
LocalDataGetCurrenciesQuery,
LocalDataGetLanguagesQuery,
LocalDataGetRecordersQuery,
} from "graphql/generated";
import { LocalDataFile } from "graphql/fetchLocalData";
import { internalAtoms } from "contexts/atoms";
import { processLanguages, processCurrencies, processLangui } from "helpers/localData";
import {
processLanguages,
processCurrencies,
processLangui,
processRecorders,
processTypesTranslations,
} from "helpers/localData";
import { getLogger } from "helpers/logger";
const getFileName = (name: LocalDataFile): string => `/local-data/${name}.json`;
@ -21,6 +28,8 @@ export const useLocalData = (): void => {
const setCurrencies = useAtomSetter(internalAtoms.localData.currencies);
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 { data: rawLanguages } = useFetch<LocalDataGetLanguagesQuery>(getFileName("languages"));
@ -28,6 +37,10 @@ export const useLocalData = (): void => {
const { data: rawLangui } = useFetch<LocalDataGetWebsiteInterfacesQuery>(
getFileName("websiteInterfaces")
);
const { data: rawRecorders } = useFetch<LocalDataGetRecordersQuery>(getFileName("recorders"));
const { data: rawTypesTranslations } = useFetch<LocalDataGetRecordersQuery>(
getFileName("typesTranslations")
);
useEffect(() => {
logger.log("Refresh languages");
@ -48,4 +61,14 @@ export const useLocalData = (): void => {
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

@ -3,9 +3,10 @@ import { useEffect } from "react";
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { atomPairing, useAtomGetter, useAtomPair } from "helpers/atoms";
import { getDefaultPreferredLanguages } from "helpers/locales";
import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { isDefined } from "helpers/asserts";
import { usePrefersDarkMode } from "hooks/useMediaQuery";
import { userAgent } from "contexts/userAgent";
import { getLogger } from "helpers/logger";
export enum ThemeMode {
Dark = "dark",
@ -13,13 +14,45 @@ export enum ThemeMode {
Light = "light",
}
export enum PerfMode {
On = "on",
Auto = "auto",
Off = "off",
}
const logger = getLogger("⚙️ [Settings Context]");
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 fontSizeAtom = atomPairing(atomWithStorage("fontSize", 1));
const dyslexicAtom = atomPairing(atomWithStorage("isDyslexic", false));
const currencyAtom = atomPairing(atomWithStorage("currency", "USD"));
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 = {
preferredLanguages: preferredLanguagesAtom,
@ -29,6 +62,9 @@ export const settings = {
dyslexic: dyslexicAtom,
currency: currencyAtom,
playerName: playerNameAtom,
perfMode: perfModeAtom,
isPerfModeEnabled: isPerfModeEnabledAtom,
isPerfModeToggleable: isPerfModeToggleableAtom,
};
export const useSettings = (): void => {
@ -80,24 +116,33 @@ export const useSettings = (): void => {
}, [isDarkMode]);
/* PREFERRED LANGUAGES */
useEffect(() => {
if (preferredLanguages.length === 0) {
if (isDefinedAndNotEmpty(router.locale) && router.locales) {
setPreferredLanguages(getDefaultPreferredLanguages(router.locale, router.locales));
}
} 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 (!router.locale || !router.locales) return;
const localStorageValue: string[] = JSON.parse(
localStorage.getItem("preferredLanguages") ?? "[]"
);
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

@ -1,18 +0,0 @@
import { useEffect } from "react";
import { isDefined } from "helpers/asserts";
import { useIsWebkit } from "hooks/useIsWebkit";
export const useWebkitFixes = (): void => {
const isWebkit = useIsWebkit();
useEffect(() => {
const next = document.getElementById("__next");
if (isDefined(next)) {
if (isWebkit) {
next.classList.add("webkit-fixes");
} else {
next.classList.remove("webkit-fixes");
}
}
}, [isWebkit]);
};

View File

@ -3,8 +3,22 @@ import { resolve } from "path";
import { readFileSync, writeFileSync } from "fs";
import { config } from "dotenv";
import { getReadySdk } from "./sdk";
import { LocalDataGetWebsiteInterfacesQuery } from "./generated";
import { processLangui, Langui } from "helpers/localData";
import {
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") });
@ -12,7 +26,7 @@ config({ path: resolve(process.cwd(), ".env.local") });
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`;
writeFileSync(path, JSON.stringify(localData), { encoding: "utf-8" });
logger.log(`${name}.json has been written`);
@ -23,22 +37,68 @@ const readLocalData = <T>(name: LocalDataFile): T => {
return JSON.parse(readFileSync(path, { encoding: "utf8" }));
};
export const fetchLocalData = async (): Promise<void> => {
export const fetchWebsiteInterfaces = async (): Promise<void> => {
const sdk = getReadySdk();
writeLocalData("websiteInterfaces", await sdk.localDataGetWebsiteInterfaces());
};
export const fetchCurrencies = async (): Promise<void> => {
const sdk = getReadySdk();
writeLocalData("currencies", await sdk.localDataGetCurrencies());
};
export const fetchLanguages = async (): Promise<void> => {
const sdk = getReadySdk();
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");
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

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

View File

@ -18,7 +18,6 @@ export interface ICUParams {
category: { count: number };
size: never;
release_date: never;
release_year: never;
details: never;
price: never;
width: never;
@ -41,13 +40,9 @@ export interface ICUParams {
front_matter: never;
back_matter: never;
open_content: never;
read_content: never;
watch_content: never;
listen_content: never;
view_scans: never;
paperback: never;
hardcover: never;
select_language: never;
language: { count: number };
library_description: never;
wiki_description: never;
@ -62,7 +57,6 @@ export interface ICUParams {
show_primary_items: never;
show_secondary_items: never;
order_by: never;
group_by: never;
select_option_sidebar: never;
group: never;
settings: never;
@ -84,17 +78,12 @@ export interface ICUParams {
review: never;
done: never;
status: never;
transcribers: never;
translators: never;
proofreaders: never;
transcript_notice: never;
translation_notice: never;
source_language: never;
pronouns: never;
item: { count: number };
content: never;
open_settings: never;
change_language: never;
open_search: never;
chronology: never;
accords_handbook: never;
@ -112,16 +101,14 @@ export interface ICUParams {
item_not_available: never;
primary_language: never;
secondary_language: never;
combine_related_contents: never;
previous_content: never;
followup_content: never;
previous_content: { count: number };
followup_content: { count: number };
videos: never;
view_on: never;
view_on_x: { x: Date | boolean | number | string };
channel: never;
subscribers: never;
description: never;
available_at: never;
search_title: never;
available_at_x: { x: Date | boolean | number | string };
want_it: never;
have_it: never;
source: never;
@ -129,7 +116,6 @@ export interface ICUParams {
only_display_items_i_have: never;
only_display_items_i_want: never;
only_display_unmarked_items: never;
display_all_items: never;
table_of_contents: never;
no_results_message: never;
all: never;
@ -140,7 +126,6 @@ export interface ICUParams {
cleaners: never;
typesetters: never;
notes: never;
cover: never;
tags: never;
no_source_warning: never;
copy_anchor_link: never;
@ -149,7 +134,6 @@ export interface ICUParams {
empty_folder_message: never;
switch_to_grid_view: never;
switch_to_folder_view: never;
content_is_not_available: never;
paper_texture: never;
book_fold: never;
lighting: never;
@ -177,10 +161,20 @@ export interface ICUParams {
x_results: { x: number };
definition_x: { x: Date | boolean | number | string };
subitem_of_x: { x: Date | boolean | number | string };
variant_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

@ -2,12 +2,18 @@
import { createWriteStream } from "fs";
import { parse, TYPE } from "@formatjs/icu-messageformat-parser";
import { getLangui } from "./fetchLocalData";
import { filterDefined } from "helpers/asserts";
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");

View File

@ -1,4 +1,4 @@
query getChronicle($slug: String, $language_code: String) {
query getChronicle($slug: String) {
chronicles(filters: { slug: { eq: $slug } }) {
data {
attributes {
@ -53,21 +53,21 @@ query getChronicle($slug: String, $language_code: String) {
authors {
data {
attributes {
...recorderChip
username
}
}
}
translators {
data {
attributes {
...recorderChip
username
}
}
}
proofreaders {
data {
attributes {
...recorderChip
username
}
}
}
@ -80,10 +80,8 @@ query getChronicle($slug: String, $language_code: String) {
slug
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
slug
}
}
}
@ -91,9 +89,6 @@ query getChronicle($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -123,7 +118,7 @@ query getChronicle($slug: String, $language_code: String) {
data {
id
attributes {
...recorderChip
username
}
}
}
@ -131,7 +126,7 @@ query getChronicle($slug: String, $language_code: String) {
data {
id
attributes {
...recorderChip
username
}
}
}
@ -139,7 +134,7 @@ query getChronicle($slug: String, $language_code: String) {
data {
id
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 } }) {
data {
id
@ -6,10 +6,8 @@ query getContentText($slug: String, $language_code: String) {
slug
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
slug
}
}
}
@ -17,9 +15,6 @@ query getContentText($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -53,10 +48,8 @@ query getContentText($slug: String, $language_code: String) {
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
slug
}
}
}
@ -67,19 +60,15 @@ query getContentText($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
... on ComponentMetadataGame {
platforms(pagination: { limit: -1 }) {
platform {
data {
id
attributes {
short
slug
}
}
}
@ -89,9 +78,6 @@ query getContentText($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -101,9 +87,6 @@ query getContentText($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -113,9 +96,6 @@ query getContentText($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -123,9 +103,6 @@ query getContentText($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -163,7 +140,7 @@ query getContentText($slug: String, $language_code: String) {
data {
id
attributes {
...recorderChip
username
}
}
}
@ -171,7 +148,7 @@ query getContentText($slug: String, $language_code: String) {
data {
id
attributes {
...recorderChip
username
}
}
}
@ -179,7 +156,44 @@ query getContentText($slug: String, $language_code: String) {
data {
id
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
}
sequence
contents(pagination: { limit: -1 }) {
}
}
}
previous_contents(pagination: { limit: -1 }) {
data {
attributes {
slug
translations(pagination: { limit: -1 }) {
pre_title
title
subtitle
language {
...relatedContentPreview
}
}
}
next_contents(pagination: { limit: -1 }) {
data {
attributes {
code
}
}
}
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
type {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}
}
}
...relatedContentPreview
}
}
}

View File

@ -1,37 +1,8 @@
query getContentsFolder($slug: String, $language_code: String) {
query getContentsFolder($slug: String) {
contentsFolders(filters: { slug: { eq: $slug } }) {
data {
attributes {
slug
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
}
}
}
}
...parentFolderPreview
contents(pagination: { limit: -1 }) {
data {
id
@ -51,10 +22,8 @@ query getContentsFolder($slug: String, $language_code: String) {
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
slug
}
}
}
@ -62,9 +31,6 @@ query getContentsFolder($slug: String, $language_code: String) {
data {
attributes {
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 } }) {
data {
id
@ -9,6 +9,7 @@ query getLibraryItem($slug: String, $language_code: String) {
root_item
primary
digital
download_available
thumbnail {
data {
attributes {
@ -32,10 +33,8 @@ query getLibraryItem($slug: String, $language_code: String) {
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
slug
}
}
}
@ -57,9 +56,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -80,19 +76,15 @@ query getLibraryItem($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
... on ComponentMetadataGame {
platforms(pagination: { limit: -1 }) {
platform {
data {
id
attributes {
short
slug
}
}
}
@ -126,21 +118,20 @@ query getLibraryItem($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
}
}
}
tracks(pagination: { limit: -1 }) {
id
slug
title
}
}
}
}
}
... on ComponentMetadataGroup {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -148,9 +139,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -188,10 +176,8 @@ query getLibraryItem($slug: String, $language_code: String) {
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
slug
}
}
}
@ -202,19 +188,16 @@ query getLibraryItem($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
... on ComponentMetadataGame {
platforms {
platform {
data {
id
attributes {
short
slug
}
}
}
@ -224,9 +207,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -236,9 +216,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -248,9 +225,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -258,9 +232,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -311,10 +282,8 @@ query getLibraryItem($slug: String, $language_code: String) {
slug
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
slug
}
}
}
@ -322,9 +291,6 @@ query getLibraryItem($slug: String, $language_code: String) {
data {
attributes {
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 } }) {
data {
id
@ -6,6 +6,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
slug
title
subtitle
download_available
images(pagination: { limit: -1 }) {
status
language {
@ -26,7 +27,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
id
attributes {
...recorderChip
username
}
}
}
@ -34,7 +35,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
id
attributes {
...recorderChip
username
}
}
}
@ -42,7 +43,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
id
attributes {
...recorderChip
username
}
}
}
@ -155,10 +156,8 @@ query getLibraryItemScans($slug: String, $language_code: String) {
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
slug
}
}
}
@ -170,19 +169,16 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
... on ComponentMetadataGame {
platforms {
platform {
data {
id
attributes {
short
slug
}
}
}
@ -192,9 +188,6 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -204,9 +197,6 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -216,9 +206,6 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -226,9 +213,6 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
@ -289,7 +273,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
id
attributes {
...recorderChip
username
}
}
}
@ -297,7 +281,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
id
attributes {
...recorderChip
username
}
}
}
@ -305,7 +289,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
data {
id
attributes {
...recorderChip
username
}
}
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
query getWeapon($slug: String, $language_code: String) {
query getWeapon($slug: String) {
weaponStories(filters: { slug: { eq: $slug } }) {
data {
attributes {
@ -7,10 +7,8 @@ query getWeapon($slug: String, $language_code: String) {
id
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
slug
}
}
}
@ -57,16 +55,6 @@ fragment sharedWeaponFragment on WeaponStory {
id
attributes {
slug
translations(filters: { language: { code: { eq: $language_code } } }) {
name
language {
data {
attributes {
code
}
}
}
}
}
}
}

View File

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

View File

@ -0,0 +1,36 @@
query localDataGetRecorders {
recorders(pagination: { limit: -1 }) {
data {
attributes {
username
anonymize
anonymous_code
pronouns
bio(pagination: { limit: -1 }) {
bio
language {
data {
attributes {
code
}
}
}
}
languages(pagination: { limit: -1 }) {
data {
attributes {
code
}
}
}
avatar {
data {
attributes {
...uploadImage
}
}
}
}
}
}
}

View File

@ -0,0 +1,183 @@
query localDataGetTypesTranslations {
metadataTypes(pagination: { limit: -1 }) {
data {
attributes {
slug
titles {
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
audioSubtypes(pagination: { limit: -1 }) {
data {
attributes {
slug
titles {
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
videoSubtypes(pagination: { limit: -1 }) {
data {
attributes {
slug
titles {
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
textualSubtypes(pagination: { limit: -1 }) {
data {
attributes {
slug
titles {
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
groupSubtypes(pagination: { limit: -1 }) {
data {
attributes {
slug
titles {
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
gamePlatforms(pagination: { limit: -1 }) {
data {
attributes {
slug
titles {
language {
data {
attributes {
code
}
}
}
title
short
}
}
}
}
contentTypes(pagination: { limit: -1 }) {
data {
attributes {
slug
titles {
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
wikiPagesTags(pagination: { limit: -1 }) {
data {
attributes {
slug
titles {
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
weaponStoryTypes(pagination: { limit: -1 }) {
data {
attributes {
slug
translations {
language {
data {
attributes {
code
}
}
}
name
}
}
}
}
categories(pagination: { limit: -1 }) {
data {
attributes {
slug
titles {
language {
data {
attributes {
code
}
}
}
title
short
}
}
}
}
}

View File

@ -28,7 +28,6 @@ query localDataGetWebsiteInterfaces {
category
size
release_date
release_year
details
price
width
@ -51,13 +50,9 @@ query localDataGetWebsiteInterfaces {
front_matter
back_matter
open_content
read_content
watch_content
listen_content
view_scans
paperback
hardcover
select_language
language
library_description
wiki_description
@ -72,7 +67,6 @@ query localDataGetWebsiteInterfaces {
show_primary_items
show_secondary_items
order_by
group_by
select_option_sidebar
group
settings
@ -94,17 +88,12 @@ query localDataGetWebsiteInterfaces {
review
done
status
transcribers
translators
proofreaders
transcript_notice
translation_notice
source_language
pronouns
item
content
open_settings
change_language
open_search
chronology
accords_handbook
@ -122,16 +111,14 @@ query localDataGetWebsiteInterfaces {
item_not_available
primary_language
secondary_language
combine_related_contents
previous_content
followup_content
videos
view_on
view_on_x
channel
subscribers
description
available_at
search_title
available_at_x
want_it
have_it
source
@ -139,7 +126,6 @@ query localDataGetWebsiteInterfaces {
only_display_items_i_have
only_display_items_i_want
only_display_unmarked_items
display_all_items
table_of_contents
no_results_message
all
@ -150,7 +136,6 @@ query localDataGetWebsiteInterfaces {
cleaners
typesetters
notes
cover
tags
no_source_warning
copy_anchor_link
@ -159,7 +144,6 @@ query localDataGetWebsiteInterfaces {
empty_folder_message
switch_to_grid_view
switch_to_folder_view
content_is_not_available
paper_texture
book_fold
lighting
@ -184,12 +168,22 @@ query localDataGetWebsiteInterfaces {
x_results
definition_x
subitem_of_x
variant_of_x
dark_mode_extension_warning
weapon
weapons_description
level_x
story_x
player_name_tooltip
download_archive
search_placeholder
performance_mode
performance_mode_tooltip
transcriber
translator
proofreader
dubber
subber
author
}
}
}

View File

@ -0,0 +1,36 @@
/* eslint-disable import/no-nodejs-modules */
import { exit } from "process";
import { execSync } from "child_process";
import chalk from "chalk";
import { getLangui } from "./fetchLocalData";
import { getLogger } from "helpers/logger";
const logger = getLogger("💽 [Unused wording keys]", "server");
const unusedWordingKeys = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ui_language, ...langui } = getLangui("en");
const results = Object.keys(langui).map((oKey) => {
const buffer = execSync(`grep -r "format(\\"${oKey}\\"" -o src | wc -l`).toString();
const result = parseInt(buffer.trim(), 10);
if (result === 0) {
logger.error(`"${oKey}" was not found in the codebase`);
}
return result;
});
const foundUnusedCount = results.filter((result) => result === 0).length;
if (foundUnusedCount > 0) {
console.log();
console.error(chalk.red(`${chalk.bold(foundUnusedCount)} wording keys are unused`));
exit(1);
} else {
console.log(`${chalk.bold(foundUnusedCount)} wording key are unused`);
exit(0);
}
};
if (process.argv[2] === "--uwk") {
unusedWordingKeys();
}

View File

@ -1,12 +1,17 @@
import { getLogger } from "helpers/logger";
import { TrackingFunction } from "types/global";
const logger = getLogger("📊 [Analytics]");
export const sendAnalytics = (category: string, event: string): void => {
export const sendAnalytics = (
category: string,
event: Parameters<TrackingFunction>[0],
data?: Parameters<TrackingFunction>[1]
): void => {
const eventName = `[${category}] ${event}`;
logger.log(eventName);
try {
umami(eventName);
umami.track(eventName, data);
} catch (error) {
if (error instanceof ReferenceError) return;
logger.error(error);

View File

@ -48,7 +48,7 @@ export const isDefinedAndNotEmpty = (string: string | null | undefined): string
export const filterDefined = <T>(t: T[] | null | undefined): NonNullable<T>[] =>
isUndefined(t) ? [] : (t.filter((item) => isDefined(item)) as NonNullable<T>[]);
export const filterHasAttributes = <T, P extends PathDot<T>>(
export const filterHasAttributes = <T, const P extends PathDot<T>>(
t: T[] | null | undefined,
paths: readonly P[]
): SelectiveNonNullable<T, (typeof paths)[number]>[] =>

View File

@ -1,7 +1,6 @@
import { atom, PrimitiveAtom, Atom, WritableAtom, useAtom } from "jotai";
import { Dispatch, SetStateAction } from "react";
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments
type AtomPair<T> = [Atom<T>, WritableAtom<null, [newText: T], void>];
export const atomPairing = <T>(anAtom: PrimitiveAtom<T>): AtomPair<T> => {

View File

@ -1,3 +1,4 @@
import { ReactNode, useMemo } from "react";
import { HorizontalLine } from "components/HorizontalLine";
import { insertInBetweenArray } from "helpers/others";
import { isDefined } from "helpers/asserts";
@ -34,11 +35,29 @@ export const ConditionalWrapper = <T, U>({
interface ElementsSeparatorProps {
children: React.ReactNode[];
separator?: React.ReactNode;
className?: string;
}
export const ElementsSeparator = ({
children,
separator = <HorizontalLine />,
className,
separator = <HorizontalLine className={className} />,
}: ElementsSeparatorProps): JSX.Element => (
<>{insertInBetweenArray(children.filter(Boolean), separator)}</>
);
interface FormatWithComponentProps {
text: string;
component: React.ReactNode;
}
export const formatWithComponentSplitter = " [SPLITTER] ";
export const FormatWithComponent = ({ text, component }: FormatWithComponentProps): JSX.Element => {
const splittedText = useMemo<ReactNode[]>(() => {
const result = text.split("[SPLITTER]");
return result;
}, [text]);
console.log(splittedText);
return <ElementsSeparator separator={component}>{splittedText}</ElementsSeparator>;
};

View File

@ -1,4 +1,5 @@
import { filterDefined, isDefined, isDefinedAndNotEmpty } from "./asserts";
import { prettyMarkdown } from "helpers/formatters";
import { filterDefined, isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
export const getDescription = (
description: string | null | undefined,
@ -6,13 +7,6 @@ export const getDescription = (
): string => {
let result = "";
if (isDefinedAndNotEmpty(description)) {
result += prettyMarkdown(description);
if (isDefined(chipsGroups)) {
result += "\n\n";
}
}
for (const key in chipsGroups) {
if (Object.hasOwn(chipsGroups, key)) {
const chipsGroup = filterDefined(chipsGroups[key]);
@ -22,12 +16,16 @@ export const getDescription = (
}
}
if (isDefinedAndNotEmpty(description)) {
if (result !== "") {
result += "\n";
}
result += prettyMarkdown(description);
}
return result;
};
export const prettyMarkdown = (markdown: string): string =>
markdown.replace(/[*]/gu, "").replace(/[_]/gu, "");
const prettyChip = (items: (string | undefined)[]): string =>
items
.filter((item) => isDefined(item))

View File

@ -1,39 +1,7 @@
import { convertPrice } from "./numbers";
import { isDefinedAndNotEmpty, isUndefined } from "./asserts";
import { datePickerToDate } from "./date";
import { Currencies, Languages } from "./localData";
import { DatePickerFragment, PricePickerFragment } from "graphql/generated";
export const prettyDate = (
datePicker: DatePickerFragment,
locale = "en",
dateStyle: Intl.DateTimeFormatOptions["dateStyle"] = "medium"
): string => datePickerToDate(datePicker).toLocaleString(locale, { dateStyle });
export const prettyPrice = (
pricePicker: PricePickerFragment,
currencies: Currencies,
targetCurrencyCode?: string
): string => {
if (!targetCurrencyCode) return "";
if (isUndefined(pricePicker.amount)) return "";
const targetCurrency = currencies.find(
(currency) => currency.attributes?.code === targetCurrencyCode
);
if (targetCurrency?.attributes) {
const amountInTargetCurrency = convertPrice(pricePicker, targetCurrency);
return amountInTargetCurrency.toLocaleString("en", {
style: "currency",
currency: targetCurrency.attributes.code,
});
}
return pricePicker.amount.toLocaleString("en", {
style: "currency",
currency: pricePicker.currency?.data?.attributes?.code,
});
};
import { convert } from "html-to-text";
import { sanitize } from "isomorphic-dompurify";
import { Renderer, marked } from "marked";
import { isDefinedAndNotEmpty } from "./asserts";
export const prettySlug = (slug?: string, parentSlug?: string): string => {
let newSlug = slug;
@ -58,136 +26,6 @@ export const prettyInlineTitle = (
return result;
};
/* eslint-disable id-denylist */
export const prettyItemSubType = (
metadata:
| {
__typename: "ComponentMetadataAudio";
subtype?: {
data?: {
attributes?: {
slug: string;
titles?:
| ({
title: string;
} | null)[]
| null;
} | null;
} | null;
} | null;
}
| {
__typename: "ComponentMetadataBooks";
subtype?: {
data?: {
attributes?: {
slug: string;
titles?:
| ({
title: string;
} | null)[]
| null;
} | null;
} | null;
} | null;
}
| {
__typename: "ComponentMetadataGame";
platforms?: {
data: {
id?: string | null;
attributes?: {
short: string;
} | null;
}[];
} | null;
}
| {
__typename: "ComponentMetadataGroup";
subtype?: {
data?: {
attributes?: {
slug: string;
titles?:
| ({
title: string;
} | null)[]
| null;
} | null;
} | null;
} | null;
subitems_type?: {
data?: {
attributes?: {
slug: string;
titles?:
| ({
title: string;
} | null)[]
| null;
} | null;
} | null;
} | null;
}
| {
__typename: "ComponentMetadataVideo";
subtype?: {
data?: {
attributes?: {
slug: string;
titles?:
| ({
title: string;
} | null)[]
| null;
} | null;
} | null;
} | null;
}
| { __typename: "ComponentMetadataOther" }
| { __typename: "Error" }
| null
): string => {
if (metadata) {
switch (metadata.__typename) {
case "ComponentMetadataAudio":
case "ComponentMetadataBooks":
case "ComponentMetadataVideo":
return metadata.subtype?.data?.attributes?.titles &&
metadata.subtype.data.attributes.titles.length > 0 &&
metadata.subtype.data.attributes.titles[0]
? metadata.subtype.data.attributes.titles[0].title
: prettySlug(metadata.subtype?.data?.attributes?.slug);
case "ComponentMetadataGame":
return metadata.platforms?.data &&
metadata.platforms.data.length > 0 &&
metadata.platforms.data[0]?.attributes
? metadata.platforms.data[0].attributes.short
: "";
case "ComponentMetadataGroup": {
const firstPart =
metadata.subtype?.data?.attributes?.titles &&
metadata.subtype.data.attributes.titles.length > 0 &&
metadata.subtype.data.attributes.titles[0]
? metadata.subtype.data.attributes.titles[0].title
: prettySlug(metadata.subtype?.data?.attributes?.slug);
const secondPart =
metadata.subitems_type?.data?.attributes?.titles &&
metadata.subitems_type.data.attributes.titles.length > 0 &&
metadata.subitems_type.data.attributes.titles[0]
? metadata.subitems_type.data.attributes.titles[0].title
: prettySlug(metadata.subitems_type?.data?.attributes?.slug);
return `${secondPart} ${firstPart}`;
}
default:
return "";
}
}
return "";
};
/* eslint-enable id-denylist */
export const prettyShortenNumber = (number: number): string => {
if (number > 1_000_000) {
return `${(number / 1_000_000).toLocaleString(undefined, {
@ -216,15 +54,7 @@ export const prettyDuration = (seconds: number): string => {
let result = "";
if (hours) result += `${hours.toString().padStart(2, "0")}:`;
result += `${minutes.toString().padStart(2, "0")}:`;
result += remainingSeconds.toString().padStart(2, "0");
return result;
};
export const prettyLanguage = (code: string, languages: Languages): string => {
let result = code;
languages.forEach((language) => {
if (language.attributes?.code === code) result = language.attributes.localized_name;
});
result += Math.floor(remainingSeconds).toString().padStart(2, "0");
return result;
};
@ -262,3 +92,41 @@ export const slugify = (string: string | undefined): string => {
};
export const sJoin = (...args: (string | null | undefined)[]): string => args.join("");
export const prettyMarkdown = (markdown: string): string => {
const block = (text: string) => `${text}\n\n`;
const escapeBlock = (text: string) => `${escape(text)}\n\n`;
const line = (text: string) => `${text}\n`;
const inline = (text: string) => text;
const newline = () => "\n";
const empty = () => "";
const TxtRenderer: Renderer = {
// Block elements
code: escapeBlock,
blockquote: block,
html: empty,
heading: block,
hr: newline,
list: (text) => block(text.trim()),
listitem: line,
checkbox: empty,
paragraph: block,
table: (header, body) => line(header + body),
tablerow: (text) => line(text.trim()),
tablecell: (text) => `${text} `,
// Inline elements
strong: inline,
em: inline,
codespan: inline,
br: newline,
del: inline,
link: (_0, _1, text) => text,
image: (_0, _1, text) => text,
text: inline,
// etc.
options: {},
};
return convert(sanitize(marked(markdown, { renderer: TxtRenderer }))).trim();
};

View File

@ -1,8 +1,21 @@
import { IntlMessageFormat } from "intl-messageformat";
import { LibraryItemMetadataDynamicZone } from "graphql/generated";
import {
DatePickerFragment,
LibraryItemMetadataDynamicZone,
PricePickerFragment,
} from "graphql/generated";
import { ICUParams } from "graphql/icuParams";
import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { getLangui } from "graphql/fetchLocalData";
import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts";
import {
getCurrencies,
getLanguages,
getLangui,
getTypesTranslations,
} from "graphql/fetchLocalData";
import { prettySlug } from "helpers/formatters";
import { LibraryItemMetadata } from "types/types";
import { datePickerToDate } from "helpers/date";
import { convertPrice } from "helpers/numbers";
type WordingKey = keyof ICUParams;
type LibraryItemType = Exclude<LibraryItemMetadataDynamicZone["__typename"], undefined>;
@ -29,18 +42,32 @@ const componentSetsTextsetStatusToWording: Record<
};
export const getFormat = (
locale: string | undefined
locale: string | undefined = "en"
): {
format: <K extends WordingKey>(
key: K,
...values: ICUParams[K] extends never ? [undefined?] : [ICUParams[K]]
) => string;
formatLibraryItemType: (metadata: { __typename: LibraryItemType }) => string;
formatLibraryItemType: (metadata: LibraryItemMetadata) => string;
formatLibraryItemSubType: (metadata: LibraryItemMetadata) => string;
formatStatusLabel: (status: ContentStatus) => string;
formatStatusDescription: (status: ContentStatus) => string;
formatCategory: (slug: string, type?: "default" | "full") => string;
formatContentType: (slug: string) => string;
formatWikiTag: (slug: string) => string;
formatWeaponType: (slug: string) => string;
formatLanguage: (code: string) => string;
formatPrice: (price: PricePickerFragment, targetCurrencyCode?: string) => string;
formatDate: (
datePicker: DatePickerFragment,
dateStyle?: Intl.DateTimeFormatOptions["dateStyle"]
) => string;
} => {
const langui = getLangui(locale);
const fallbackLangui = getLangui("en");
const typesTranslations = getTypesTranslations();
const currencies = getCurrencies();
const languages = getLanguages();
const format = (
key: WordingKey,
@ -65,8 +92,90 @@ export const getFormat = (
return key;
};
const formatLibraryItemType = (metadata: { __typename: LibraryItemType }): string =>
format(componentMetadataToWording[metadata.__typename]);
const formatLibraryItemType = (metadata: LibraryItemMetadata): string =>
metadata ? format(componentMetadataToWording[metadata.__typename]) : format("other");
const formatLibraryItemSubType = (metadata: LibraryItemMetadata): string => {
switch (metadata?.__typename) {
case "ComponentMetadataAudio": {
const slug = metadata.subtype?.data?.attributes?.slug;
const subtype = typesTranslations.audioSubtypes.find(
(type) => type.attributes?.slug === slug
);
const findTranslation = (givenLocale: string) =>
subtype?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
}
case "ComponentMetadataBooks": {
const slug = metadata.subtype?.data?.attributes?.slug;
const subtype = typesTranslations.textualSubtypes.find(
(type) => type.attributes?.slug === slug
);
const findTranslation = (givenLocale: string) =>
subtype?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
}
case "ComponentMetadataVideo": {
const slug = metadata.subtype?.data?.attributes?.slug;
const subtype = typesTranslations.videoSubtypes.find(
(type) => type.attributes?.slug === slug
);
const findTranslation = (givenLocale: string) =>
subtype?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
}
case "ComponentMetadataGame": {
const slug = metadata.platform?.data?.attributes?.slug;
const subtype = typesTranslations.gamePlatforms.find(
(type) => type.attributes?.slug === slug
);
console.log(slug);
const findTranslation = (givenLocale: string) =>
subtype?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
}
case "ComponentMetadataGroup": {
const subItemType = (() => {
const subitemTypeSlug = metadata.subitems_type?.data?.attributes?.slug;
const subItemTypeTranslations = typesTranslations.metadataTypes.find(
(type) => type.attributes?.slug === subitemTypeSlug
);
const findTranslation = (givenLocale: string) =>
subItemTypeTranslations?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
})();
const groupType = (() => {
const groupTypeSlug = metadata.subtype?.data?.attributes?.slug;
const groupTypeTranslations = typesTranslations.groupSubtypes.find(
(type) => type.attributes?.slug === groupTypeSlug
);
const findTranslation = (givenLocale: string) =>
groupTypeTranslations?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
})();
return `${groupType} - ${subItemType}`;
}
default:
return format("other");
}
};
const formatStatusLabel = (status: ContentStatus): string =>
format(componentSetsTextsetStatusToWording[status].label);
@ -74,10 +183,97 @@ export const getFormat = (
const formatStatusDescription = (status: ContentStatus): string =>
format(componentSetsTextsetStatusToWording[status].description);
const formatCategory = (slug: string, type: "default" | "full" = "default"): string => {
const category = typesTranslations.categories.find((cat) => cat.attributes?.slug === slug);
if (!category) return prettySlug(slug);
const findTranslation = (givenLocale: string): string | null | undefined => {
const localeTranslation = category.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
);
return type === "default" ? localeTranslation?.title : localeTranslation?.short;
};
return findTranslation(locale) ?? findTranslation("en") ?? prettySlug(slug);
};
const formatContentType = (slug: string): string => {
const contentType = typesTranslations.contentTypes.find(
(type) => type.attributes?.slug === slug
);
if (!contentType) return prettySlug(slug);
const findTranslation = (givenLocale: string): string | null | undefined => {
const localeTranslation = contentType.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
);
return localeTranslation?.title;
};
return findTranslation(locale) ?? findTranslation("en") ?? prettySlug(slug);
};
const formatWikiTag = (slug: string): string => {
const wikiTag = typesTranslations.wikiPagesTags.find((cat) => cat.attributes?.slug === slug);
if (!wikiTag) return prettySlug(slug);
const findTranslation = (givenLocale: string): string | null | undefined => {
const localeTranslation = wikiTag.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
);
return localeTranslation?.title;
};
return findTranslation(locale) ?? findTranslation("en") ?? prettySlug(slug);
};
const formatWeaponType = (slug: string): string => {
const weaponType = typesTranslations.weaponTypes.find((type) => type.attributes?.slug === slug);
if (!weaponType) return prettySlug(slug);
const findTranslation = (givenLocale: string): string | null | undefined => {
const localeTranslation = weaponType.attributes?.translations?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
);
return localeTranslation?.name;
};
return findTranslation(locale) ?? findTranslation("en") ?? prettySlug(slug);
};
const formatLanguage = (code: string) =>
languages.find((language) => language.attributes?.code === code)?.attributes?.localized_name ??
code.toUpperCase();
const formatPrice = (price: PricePickerFragment, targetCurrencyCode?: string) => {
if (isUndefined(price.amount)) return "";
const targetCurrency = currencies.find(
(currency) => currency.attributes?.code === targetCurrencyCode
);
if (targetCurrency?.attributes) {
const amountInTargetCurrency = convertPrice(price, targetCurrency);
return amountInTargetCurrency.toLocaleString(locale, {
style: "currency",
currency: targetCurrency.attributes.code,
});
}
return price.amount.toLocaleString(locale, {
style: "currency",
currency: price.currency?.data?.attributes?.code,
});
};
const formatDate = (
datePicker: DatePickerFragment,
dateStyle: Intl.DateTimeFormatOptions["dateStyle"] = "medium"
): string => datePickerToDate(datePicker).toLocaleString(locale, { dateStyle });
return {
format,
formatLibraryItemType,
formatLibraryItemSubType,
formatStatusLabel,
formatStatusDescription,
formatCategory,
formatContentType,
formatWikiTag,
formatWeaponType,
formatLanguage,
formatPrice,
formatDate,
};
};

View File

@ -4,7 +4,6 @@ export const isUntangibleGroupItem = (
metadata:
| {
__typename: string;
// eslint-disable-next-line id-denylist
subtype?: { data?: { attributes?: { slug: string } | null } | null } | null;
}
| null
@ -14,3 +13,9 @@ export const isUntangibleGroupItem = (
metadata.__typename === "ComponentMetadataGroup" &&
(metadata.subtype?.data?.attributes?.slug === "variant-set" ||
metadata.subtype?.data?.attributes?.slug === "relation-set");
export const getScanArchiveURL = (slug: string): string =>
`${process.env.NEXT_PUBLIC_URL_ASSETS}/library/scans/${slug}.zip`;
export const getTrackURL = (itemSlug: string, trackSlug: string): string =>
`${process.env.NEXT_PUBLIC_URL_ASSETS}/library/tracks/${itemSlug}/${trackSlug}.mp3`;

View File

@ -1,6 +1,8 @@
import {
LocalDataGetCurrenciesQuery,
LocalDataGetLanguagesQuery,
LocalDataGetRecordersQuery,
LocalDataGetTypesTranslationsQuery,
LocalDataGetWebsiteInterfacesQuery,
} from "graphql/generated";
@ -45,3 +47,58 @@ export const processLanguages = (languages: LocalDataGetLanguagesQuery | undefin
}
return languages?.languages?.data ?? [];
};
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export type Recorders = NonNullable<LocalDataGetRecordersQuery["recorders"]>["data"];
export const processRecorders = (recorders: LocalDataGetRecordersQuery | undefined): Recorders =>
recorders?.recorders?.data ?? [];
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export type TypesTranslations = {
audioSubtypes: NonNullable<
NonNullable<LocalDataGetTypesTranslationsQuery>["audioSubtypes"]
>["data"];
gamePlatforms: NonNullable<
NonNullable<LocalDataGetTypesTranslationsQuery>["gamePlatforms"]
>["data"];
groupSubtypes: NonNullable<
NonNullable<LocalDataGetTypesTranslationsQuery>["groupSubtypes"]
>["data"];
metadataTypes: NonNullable<
NonNullable<LocalDataGetTypesTranslationsQuery>["metadataTypes"]
>["data"];
textualSubtypes: NonNullable<
NonNullable<LocalDataGetTypesTranslationsQuery>["textualSubtypes"]
>["data"];
videoSubtypes: NonNullable<
NonNullable<LocalDataGetTypesTranslationsQuery>["videoSubtypes"]
>["data"];
contentTypes: NonNullable<
NonNullable<LocalDataGetTypesTranslationsQuery>["contentTypes"]
>["data"];
wikiPagesTags: NonNullable<
NonNullable<LocalDataGetTypesTranslationsQuery>["wikiPagesTags"]
>["data"];
weaponTypes: NonNullable<
NonNullable<LocalDataGetTypesTranslationsQuery>["weaponStoryTypes"]
>["data"];
categories: NonNullable<NonNullable<LocalDataGetTypesTranslationsQuery>["categories"]>["data"];
};
export const processTypesTranslations = (
data: LocalDataGetTypesTranslationsQuery | undefined
): TypesTranslations => ({
audioSubtypes: data?.audioSubtypes?.data ?? [],
categories: data?.categories?.data ?? [],
contentTypes: data?.contentTypes?.data ?? [],
gamePlatforms: data?.gamePlatforms?.data ?? [],
groupSubtypes: data?.groupSubtypes?.data ?? [],
metadataTypes: data?.metadataTypes?.data ?? [],
textualSubtypes: data?.textualSubtypes?.data ?? [],
videoSubtypes: data?.videoSubtypes?.data ?? [],
weaponTypes: data?.weaponStoryTypes?.data ?? [],
wikiPagesTags: data?.wikiPagesTags?.data ?? [],
});

View File

@ -1,19 +1,11 @@
import { isDefined } from "./asserts";
export const getDefaultPreferredLanguages = (routerLocal: string, locales: string[]): string[] => {
let defaultPreferredLanguages: string[] = [];
if (routerLocal === "en") {
defaultPreferredLanguages = [routerLocal];
locales.map((locale) => {
if (locale !== routerLocal) defaultPreferredLanguages.push(locale);
});
} else {
defaultPreferredLanguages = [routerLocal, "en"];
locales.map((locale) => {
if (locale !== routerLocal && locale !== "en") defaultPreferredLanguages.push(locale);
});
}
return defaultPreferredLanguages;
const defaultPreferredLanguages: Set<string> = new Set();
defaultPreferredLanguages.add(routerLocal);
defaultPreferredLanguages.add("en");
locales.forEach((locale) => defaultPreferredLanguages.add(locale));
return [...defaultPreferredLanguages.values()];
};
export const getPreferredLanguage = (

View File

@ -2,28 +2,30 @@ type LoggerMode = "both" | "client" | "server";
const isServer = typeof window === "undefined";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const getLogger = (prefix: string, mode: LoggerMode = "client") => {
type Logger = {
error: (message?: unknown, ...optionalParams: unknown[]) => void;
warn: (message?: unknown, ...optionalParams: unknown[]) => void;
log: (message?: unknown, ...optionalParams: unknown[]) => void;
info: (message?: unknown, ...optionalParams: unknown[]) => void;
debug: (message?: unknown, ...optionalParams: unknown[]) => void;
};
export const getLogger = (prefix: string, mode: LoggerMode = "client"): Logger => {
if ((mode === "client" && isServer) || (mode === "server" && !isServer)) {
return {
error: () => null,
warn: () => null,
log: () => null,
info: () => null,
debug: () => null,
error: () => undefined,
warn: () => undefined,
log: () => undefined,
info: () => undefined,
debug: () => undefined,
};
}
return {
error: (message?: unknown, ...optionalParams: unknown[]) =>
console.error(prefix, message, ...optionalParams),
warn: (message?: unknown, ...optionalParams: unknown[]) =>
console.warn(prefix, message, ...optionalParams),
log: (message?: unknown, ...optionalParams: unknown[]) =>
console.log(prefix, message, ...optionalParams),
info: (message?: unknown, ...optionalParams: unknown[]) =>
console.info(prefix, message, ...optionalParams),
debug: (message?: unknown, ...optionalParams: unknown[]) =>
console.debug(prefix, message, ...optionalParams),
error: (message, ...optionalParams) => console.error(prefix, message, ...optionalParams),
warn: (message, ...optionalParams) => console.warn(prefix, message, ...optionalParams),
log: (message, ...optionalParams) => console.log(prefix, message, ...optionalParams),
info: (message, ...optionalParams) => console.info(prefix, message, ...optionalParams),
debug: (message, ...optionalParams) => console.debug(prefix, message, ...optionalParams),
};
};

View File

@ -4,11 +4,11 @@ import { getFormat } from "./i18n";
import { UploadImageFragment } from "graphql/generated";
import { useFormat } from "hooks/useFormat";
const DEFAULT_OG_THUMBNAIL = {
const DEFAULT_OG_THUMBNAIL: OgImage = {
image: `${process.env.NEXT_PUBLIC_URL_SELF}/default_og.jpg`,
width: 1200,
height: 630,
alt: "Accord's Library Logo",
alt: "Accords Library Logo",
};
export const TITLE_PREFIX = "Accords Library";
@ -18,20 +18,38 @@ export interface OpenGraph {
title: string;
description: string;
thumbnail: OgImage;
audio?: string;
video?: string;
}
export const getOpenGraph = (
format: ReturnType<typeof getFormat>["format"] | ReturnType<typeof useFormat>["format"],
title?: string | null | undefined,
description?: string | null | undefined,
thumbnail?: UploadImageFragment | null | undefined
thumbnail?: UploadImageFragment | string | null | undefined,
audio?: string,
video?: string
): OpenGraph => ({
title: `${TITLE_PREFIX}${isDefinedAndNotEmpty(title) ? `${TITLE_SEPARATOR}${title}` : ""}`,
description: isDefinedAndNotEmpty(description) ? description : format("default_description"),
description: isDefinedAndNotEmpty(description)
? description.length > 350
? `${description.slice(0, 349)}`
: description
: format("default_description"),
thumbnail: thumbnail ? getOgImage(thumbnail) : DEFAULT_OG_THUMBNAIL,
...(audio ? { audio } : {}),
...(video ? { video } : {}),
});
const getOgImage = (image: UploadImageFragment): OgImage => {
const getOgImage = (image: UploadImageFragment | string): OgImage => {
if (typeof image === "string") {
return {
image,
width: 0,
height: 0,
alt: "",
};
}
const imgSize = getImgSizesByQuality(image.width ?? 0, image.height ?? 0, ImageQuality.Og);
return {
image: getAssetURL(image.url, ImageQuality.Og),

View File

@ -1,7 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// eslint-disable-next-line import/named
import { MatchesPosition, MeiliSearch, SearchParams, SearchResponse } from "meilisearch";
import { isDefined } from "./asserts";
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { MeiliSearch } from "meilisearch";
import type {
SearchParams,
MatchesPosition,
SearchResponse,
MultiSearchQuery,
MultiSearchResponse,
MultiSearchResult,
} from "meilisearch";
import { filterDefined, isDefined } from "./asserts";
import { MeiliDocumentsType } from "shared/meilisearch-graphql-typings/meiliTypes";
const meili = new MeiliSearch({
@ -12,21 +20,61 @@ const meili = new MeiliSearch({
interface CustomSearchParams
extends Omit<
SearchParams,
"cropMarker" | "highlightPostTag" | "highlightPreTag" | "q" | "showMatchesPosition"
| "cropLength"
| "cropMarker"
| "cropMarker"
| "highlightPostTag"
| "highlightPreTag"
| "q"
| "showMatchesPosition"
> {}
type CustomHit<T = Record<string, any>> = T & {
type CustomHit<T = Record<string, unknown>> = T & {
_formatted: Partial<T>;
_matchesPosition: MatchesPosition<T>;
};
type CustomHits<T = Record<string, any>> = CustomHit<T>[];
type CustomHits<T = Record<string, unknown>> = CustomHit<T>[];
export interface CustomSearchResponse<T> extends Omit<SearchResponse<T>, "hits"> {
hits: CustomHits<T>;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const meiliMultiSearch = async (queries: MultiSearchQuery[]): Promise<MultiSearchResponse> =>
await meili.multiSearch({
queries: queries.map((query) => ({
attributesToHighlight: ["*"],
...query,
highlightPreTag: "<mark>",
highlightPostTag: "</mark>",
showMatchesPosition: true,
cropLength: 20,
cropMarker: "...",
})),
});
export const filterHitsWithHighlight = <T extends MeiliDocumentsType["documents"]>(
searchResult: CustomSearchResponse<T> | MultiSearchResult<Record<string, unknown>>,
keyToFilter?: keyof T
): CustomSearchResponse<T> => {
const result = searchResult as unknown as CustomSearchResponse<T>;
if (isDefined(keyToFilter)) {
result.hits = result.hits.map((item) => {
if (
Object.keys(item._matchesPosition).some((match) => match.startsWith(keyToFilter as string))
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
item._formatted[keyToFilter] = filterDefined(item._formatted[keyToFilter]).filter(
(translation) => JSON.stringify(translation).includes("</mark>")
);
}
return item;
});
}
return result;
};
export const meiliSearch = async <I extends MeiliDocumentsType["index"]>(
indexName: I,
query: string,

View File

@ -1,5 +1,5 @@
export const getVideoThumbnailURL = (uid: string): string =>
`${process.env.NEXT_PUBLIC_URL_WATCH}/videos/${uid}.webp`;
`${process.env.NEXT_PUBLIC_URL_ASSETS}/videos/${uid}.webp`;
export const getVideoFile = (uid: string): string =>
`${process.env.NEXT_PUBLIC_URL_WATCH}/videos/${uid}.mp4`;
`${process.env.NEXT_PUBLIC_URL_ASSETS}/videos/${uid}.mp4`;

View File

@ -1,11 +1,20 @@
import { IntlMessageFormat } from "intl-messageformat";
import { useCallback } from "react";
import { useRouter } from "next/router";
import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms";
import { LibraryItemMetadataDynamicZone } from "graphql/generated";
import {
DatePickerFragment,
LibraryItemMetadataDynamicZone,
PricePickerFragment,
} from "graphql/generated";
import { ICUParams } from "graphql/icuParams";
import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts";
import { getLogger } from "helpers/logger";
import { prettySlug } from "helpers/formatters";
import { LibraryItemMetadata } from "types/types";
import { convertPrice } from "helpers/numbers";
import { datePickerToDate } from "helpers/date";
const logger = getLogger("🗺️ [I18n]");
@ -13,6 +22,15 @@ type WordingKey = keyof ICUParams;
type LibraryItemType = Exclude<LibraryItemMetadataDynamicZone["__typename"], undefined>;
export type ContentStatus = "Done" | "Draft" | "Incomplete" | "Review";
/*
* Whitelisting wording keys from being detected as unused
* - format("audio")
* - format("textual")
* - format("game")
* - format("group")
* - format("video")
* - format("other")
*/
const componentMetadataToWording: Record<LibraryItemType, WordingKey> = {
ComponentMetadataAudio: "audio",
ComponentMetadataBooks: "textual",
@ -23,6 +41,17 @@ const componentMetadataToWording: Record<LibraryItemType, WordingKey> = {
Error: "item",
};
/*
* Whitelisting wording keys from being detected as unused
* - format("draft")
* - format("incomplete")
* - format("review")
* - format("done")
* - format("status_draft")
* - format("status_incomplete")
* - format("status_review")
* - format("status_done")
*/
const componentSetsTextsetStatusToWording: Record<
ContentStatus,
{ label: WordingKey; description: WordingKey }
@ -38,12 +67,27 @@ export const useFormat = (): {
key: K,
...values: ICUParams[K] extends never ? [undefined?] : [ICUParams[K]]
) => string;
formatLibraryItemType: (metadata: { __typename: LibraryItemType }) => string;
formatLibraryItemType: (metadata: LibraryItemMetadata) => string;
formatLibraryItemSubType: (metadata: LibraryItemMetadata) => string;
formatStatusLabel: (status: ContentStatus) => string;
formatStatusDescription: (status: ContentStatus) => string;
formatCategory: (slug: string, type?: "default" | "full") => string;
formatContentType: (slug: string) => string;
formatWikiTag: (slug: string) => string;
formatWeaponType: (slug: string) => string;
formatLanguage: (code: string) => string;
formatPrice: (price: PricePickerFragment, targetCurrencyCode?: string) => string;
formatDate: (
datePicker: DatePickerFragment,
dateStyle?: Intl.DateTimeFormatOptions["dateStyle"]
) => string;
} => {
const langui = useAtomGetter(atoms.localData.langui);
const fallbackLangui = useAtomGetter(atoms.localData.fallbackLangui);
const typesTranslations = useAtomGetter(atoms.localData.typesTranslations);
const languages = useAtomGetter(atoms.localData.languages);
const currencies = useAtomGetter(atoms.localData.currencies);
const { locale = "en" } = useRouter();
const format = useCallback(
(
@ -76,12 +120,6 @@ Falling back to en translation.`
[langui, fallbackLangui]
);
const formatLibraryItemType = useCallback(
(metadata: { __typename: LibraryItemType }): string =>
format(componentMetadataToWording[metadata.__typename]),
[format]
);
const formatStatusLabel = useCallback(
(status: ContentStatus): string => format(componentSetsTextsetStatusToWording[status].label),
[format]
@ -93,10 +131,219 @@ Falling back to en translation.`
[format]
);
const formatLibraryItemType = useCallback(
(metadata: LibraryItemMetadata): string =>
metadata ? format(componentMetadataToWording[metadata.__typename]) : format("other"),
[format]
);
const formatLibraryItemSubType = useCallback(
(metadata: LibraryItemMetadata): string => {
switch (metadata?.__typename) {
case "ComponentMetadataAudio": {
const slug = metadata.subtype?.data?.attributes?.slug;
const subtype = typesTranslations.audioSubtypes.find(
(type) => type.attributes?.slug === slug
);
const findTranslation = (givenLocale: string) =>
subtype?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
}
case "ComponentMetadataBooks": {
const slug = metadata.subtype?.data?.attributes?.slug;
const subtype = typesTranslations.textualSubtypes.find(
(type) => type.attributes?.slug === slug
);
const findTranslation = (givenLocale: string) =>
subtype?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
}
case "ComponentMetadataVideo": {
const slug = metadata.subtype?.data?.attributes?.slug;
const subtype = typesTranslations.videoSubtypes.find(
(type) => type.attributes?.slug === slug
);
const findTranslation = (givenLocale: string) =>
subtype?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
}
case "ComponentMetadataGame": {
const slug = metadata.platform?.data?.attributes?.slug;
const subtype = typesTranslations.gamePlatforms.find(
(type) => type.attributes?.slug === slug
);
const findTranslation = (givenLocale: string) =>
subtype?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
}
case "ComponentMetadataGroup": {
const subItemType = (() => {
const subitemTypeSlug = metadata.subitems_type?.data?.attributes?.slug;
const subItemTypeTranslations = typesTranslations.metadataTypes.find(
(type) => type.attributes?.slug === subitemTypeSlug
);
const findTranslation = (givenLocale: string) =>
subItemTypeTranslations?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
})();
const groupType = (() => {
const groupTypeSlug = metadata.subtype?.data?.attributes?.slug;
const groupTypeTranslations = typesTranslations.groupSubtypes.find(
(type) => type.attributes?.slug === groupTypeSlug
);
const findTranslation = (givenLocale: string) =>
groupTypeTranslations?.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
)?.title;
return findTranslation(locale) ?? findTranslation("en") ?? format("other");
})();
return `${groupType} - ${subItemType}`;
}
default:
return format("other");
}
},
[
format,
locale,
typesTranslations.audioSubtypes,
typesTranslations.gamePlatforms,
typesTranslations.groupSubtypes,
typesTranslations.metadataTypes,
typesTranslations.textualSubtypes,
typesTranslations.videoSubtypes,
]
);
const formatCategory = useCallback(
(slug: string, type: "default" | "full" = "default"): string => {
const category = typesTranslations.categories.find((cat) => cat.attributes?.slug === slug);
if (!category) return prettySlug(slug);
const findTranslation = (givenLocale: string): string | null | undefined => {
const localeTranslation = category.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
);
return type === "full" ? localeTranslation?.title : localeTranslation?.short;
};
return findTranslation(locale) ?? findTranslation("en") ?? prettySlug(slug);
},
[locale, typesTranslations.categories]
);
const formatContentType = useCallback(
(slug: string): string => {
const contentType = typesTranslations.contentTypes.find(
(type) => type.attributes?.slug === slug
);
if (!contentType) return prettySlug(slug);
const findTranslation = (givenLocale: string): string | null | undefined => {
const localeTranslation = contentType.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
);
return localeTranslation?.title;
};
return findTranslation(locale) ?? findTranslation("en") ?? prettySlug(slug);
},
[locale, typesTranslations.contentTypes]
);
const formatWikiTag = useCallback(
(slug: string): string => {
const wikiTag = typesTranslations.wikiPagesTags.find((cat) => cat.attributes?.slug === slug);
if (!wikiTag) return prettySlug(slug);
const findTranslation = (givenLocale: string): string | null | undefined => {
const localeTranslation = wikiTag.attributes?.titles?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
);
return localeTranslation?.title;
};
return findTranslation(locale) ?? findTranslation("en") ?? prettySlug(slug);
},
[locale, typesTranslations.wikiPagesTags]
);
const formatWeaponType = useCallback(
(slug: string): string => {
const weaponType = typesTranslations.weaponTypes.find(
(type) => type.attributes?.slug === slug
);
if (!weaponType) return prettySlug(slug);
const findTranslation = (givenLocale: string): string | null | undefined => {
const localeTranslation = weaponType.attributes?.translations?.find(
(translation) => translation?.language?.data?.attributes?.code === givenLocale
);
return localeTranslation?.name;
};
return findTranslation(locale) ?? findTranslation("en") ?? prettySlug(slug);
},
[locale, typesTranslations.weaponTypes]
);
const formatLanguage = useCallback(
(code: string) =>
languages.find((language) => language.attributes?.code === code)?.attributes
?.localized_name ?? code.toUpperCase(),
[languages]
);
const formatPrice = useCallback(
(price: PricePickerFragment, targetCurrencyCode?: string) => {
if (isUndefined(price.amount)) return "";
const targetCurrency = currencies.find(
(currency) => currency.attributes?.code === targetCurrencyCode
);
if (targetCurrency?.attributes) {
const amountInTargetCurrency = convertPrice(price, targetCurrency);
return amountInTargetCurrency.toLocaleString(locale, {
style: "currency",
currency: targetCurrency.attributes.code,
});
}
return price.amount.toLocaleString(locale, {
style: "currency",
currency: price.currency?.data?.attributes?.code,
});
},
[currencies, locale]
);
const formatDate = useCallback(
(
datePicker: DatePickerFragment,
dateStyle: Intl.DateTimeFormatOptions["dateStyle"] = "medium"
) => datePickerToDate(datePicker).toLocaleString(locale, { dateStyle }),
[locale]
);
return {
format,
formatLibraryItemType,
formatLibraryItemSubType,
formatStatusLabel,
formatStatusDescription,
formatCategory,
formatContentType,
formatWikiTag,
formatWeaponType,
formatLanguage,
formatPrice,
formatDate,
};
};

View File

@ -13,7 +13,7 @@ export const useFullscreen = (
const [isFullscreen, setIsFullscreen] = useState(false);
const isClient = useIsClient();
const elem = isClient ? document.querySelector(`#${id}`) : null;
const elem = isClient ? document.querySelector(`#${CSS.escape(id)}`) : null;
const requestFullscreen = useCallback(async () => elem?.requestFullscreen(), [elem]);
const exitFullscreen = useCallback(

View File

@ -1,14 +0,0 @@
import { useMemo } from "react";
import UAParser from "ua-parser-js";
import { useIsClient } from "usehooks-ts";
export const useIsWebkit = (): boolean => {
const isClient = useIsClient();
return useMemo<boolean>(() => {
if (isClient) {
const parser = new UAParser();
return parser.getBrowser().name === "Safari" || parser.getOS().name === "iOS";
}
return false;
}, [isClient]);
};

View File

@ -13,7 +13,7 @@ export const useOnResize = (
useEffect(() => {
logger.log(`Creating observer for ${id}`);
const elem = isClient ? document.querySelector(`#${id}`) : null;
const elem = isClient ? document.querySelector(`#${CSS.escape(id)}`) : null;
const ro = new ResizeObserver((resizeObserverEntry) => {
const entry = resizeObserverEntry[0];
if (isDefined(entry)) {

View File

@ -4,7 +4,7 @@ import { Ids } from "types/ids";
export const useOnScroll = (id: Ids, onScroll: (scroll: number) => void): void => {
const isClient = useIsClient();
const elem = isClient ? document.querySelector(`#${id}`) : null;
const elem = isClient ? document.querySelector(`#${CSS.escape(id)}`) : null;
const listener = useCallback(() => {
if (elem?.scrollTop) {
onScroll(elem.scrollTop);

View File

@ -0,0 +1,28 @@
import { DependencyList, useEffect } from "react";
import { getLogger } from "helpers/logger";
import { Ids } from "types/ids";
const logger = getLogger("⬆️ [Scroll Top On Change]");
// Scroll to top of element "id" when "deps" update.
export const useScrollTopOnChange = (id: Ids, deps: DependencyList, enabled = true): void => {
useEffect(() => {
if (enabled) {
logger.log("Change detected. Scrolling to top");
document.querySelector(`#${CSS.escape(id)}`)?.scrollTo({ top: 0, behavior: "smooth" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, ...deps, enabled]);
};
// Scroll to top of element "id" when "deps" update.
export const useScrollRightOnChange = (id: Ids, deps: DependencyList, enabled = true): void => {
useEffect(() => {
if (enabled) {
logger.log("Change detected. Scrolling to right");
const elem = document.querySelector(`#${CSS.escape(id)}`);
elem?.scrollTo({ left: elem.scrollWidth, behavior: "smooth" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, ...deps, enabled]);
};

View File

@ -1,16 +0,0 @@
import { DependencyList, useEffect } from "react";
import { getLogger } from "helpers/logger";
import { Ids } from "types/ids";
const logger = getLogger("⬆️ [Scroll Top On Change]");
// Scroll to top of element "id" when "deps" update.
export const useScrollTopOnChange = (id: Ids, deps: DependencyList, enabled = true): void => {
useEffect(() => {
if (enabled) {
logger.log("Change detected. Scrolling to top");
document.querySelector(`#${id}`)?.scrollTo({ top: 0, behavior: "smooth" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, ...deps, enabled]);
};

View File

@ -1,7 +1,7 @@
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { LanguageSwitcher } from "components/Inputs/LanguageSwitcher";
import { filterDefined, isDefined } from "helpers/asserts";
import { isDefined } from "helpers/asserts";
import { getPreferredLanguage } from "helpers/locales";
import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms";
@ -21,14 +21,18 @@ export const useSmartLanguage = <T>({
const languages = useAtomGetter(atoms.localData.languages);
const router = useRouter();
const availableLocales = useMemo(() => {
const memo = new Map<string, number>();
filterDefined(items).map((elem, index) => {
const result = languageExtractor(elem);
if (isDefined(result)) memo.set(result, index);
const availableLocales = useMemo<Map<string, number>>(() => {
const memo: [string, number][] = [];
items.map((elem, index) => {
const result = isDefined(elem) ? languageExtractor(elem) : undefined;
if (isDefined(result)) memo.push([result, index]);
});
return memo;
}, [items, languageExtractor]);
memo.sort((a, b) => {
const evaluate = (locale: string) => preferredLanguages.findIndex((elem) => elem === locale);
return evaluate(a[0]) - evaluate(b[0]);
});
return new Map(memo);
}, [items, languageExtractor, preferredLanguages]);
const [selectedTranslationIndex, setSelectedTranslationIndex] = useState<number | undefined>();

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