Compare commits
230 Commits
Author | SHA1 | Date |
---|---|---|
DrMint | e88345f395 | |
DrMint | 34c4570688 | |
DrMint | da916f898a | |
DrMint | 7efa43a630 | |
DrMint | 22e1bf4842 | |
DrMint | d560008cff | |
DrMint | 872f31a6a3 | |
DrMint | 3c7b9aa2d6 | |
DrMint | 62e64b9319 | |
DrMint | e0ee70814d | |
DrMint | 87625ba9ac | |
DrMint | fc1b0c1284 | |
DrMint | 284bbd6272 | |
DrMint | c3796b4fe8 | |
DrMint | 7bde24adaa | |
DrMint | 66dbb29871 | |
DrMint | 6d0429c21a | |
DrMint | 2f0322c1fa | |
DrMint | 6093ef131a | |
DrMint | ff89031123 | |
DrMint | d5e7d704bf | |
DrMint | 22f7c39dff | |
DrMint | a047d18c76 | |
DrMint | 895fee1bae | |
DrMint | 3e979c4005 | |
DrMint | f12d5b0525 | |
DrMint | ef7b3faeca | |
DrMint | d4e6393b9e | |
DrMint | 663bf4f08d | |
DrMint | 06d82e1133 | |
DrMint | f8f98ec41e | |
DrMint | 5d2fe252ec | |
DrMint | a8960d67ed | |
DrMint | ebd3f75804 | |
DrMint | c69b4478f7 | |
DrMint | 5949c8fb8b | |
DrMint | 6a33cfa15a | |
DrMint | c03e92a354 | |
DrMint | b9d10f4670 | |
DrMint | e1e107078e | |
DrMint | 3671264984 | |
DrMint | a52cb1fe54 | |
DrMint | bf6bf2e8a8 | |
DrMint | b9c7c0828a | |
DrMint | 4f78b4f006 | |
DrMint | 9e5ad41e5c | |
DrMint | ca12dc2c29 | |
DrMint | 0c1f252641 | |
DrMint | 6cc6635988 | |
DrMint | 0f6339c0f8 | |
DrMint | 2deea6184e | |
DrMint | cf3837094e | |
DrMint | d19b815275 | |
DrMint | 5be25c656f | |
DrMint | 0f735c62cc | |
DrMint | d68e238b00 | |
DrMint | b6882cd1e5 | |
DrMint | bfb753bf21 | |
DrMint | 113c6566d9 | |
DrMint | e39eb316de | |
DrMint | 7eb7495537 | |
DrMint | 75de7c5f2a | |
DrMint | 5677fb180f | |
DrMint | 5b042a77e2 | |
DrMint | 88a67e4e85 | |
DrMint | 0420dc30a4 | |
DrMint | a0706fd52f | |
DrMint | ffe7e119e0 | |
DrMint | 1fe5952566 | |
DrMint | 7aeb85e4f9 | |
DrMint | df8a7f820d | |
DrMint | fe52ded606 | |
DrMint | 8c98f0796b | |
DrMint | e3e67b8dbc | |
DrMint | 00da77d785 | |
DrMint | d888588a07 | |
DrMint | be1ea95b71 | |
DrMint | 77e25c9056 | |
DrMint | dd3beff508 | |
DrMint | 0ddd46643b | |
DrMint | e9950602c4 | |
DrMint | 6abff354ee | |
DrMint | 35fdc7af14 | |
DrMint | b5b2dd07ee | |
DrMint | 4a71f897a8 | |
DrMint | 6a1be38613 | |
DrMint | c356679813 | |
DrMint | 35b58982d0 | |
DrMint | f4ff30e279 | |
DrMint | 16db6a9a39 | |
DrMint | 9fa3848456 | |
DrMint | b1b08e299a | |
DrMint | 42821a7490 | |
DrMint | fe24a77d6e | |
DrMint | cffe26a29a | |
DrMint | 922a6af4c5 | |
DrMint | 0d9bf73f9d | |
DrMint | 230df12c22 | |
DrMint | 8aeae06432 | |
DrMint | 155e7246d2 | |
DrMint | 25d99ee294 | |
DrMint | 89ad4620d6 | |
DrMint | c95e142ca0 | |
DrMint | e4b39a4c38 | |
DrMint | 0328e730e1 | |
DrMint | 2dacf190d2 | |
DrMint | ee9a9a67fc | |
DrMint | 06c61d0222 | |
DrMint | 7db5578b3c | |
DrMint | 8862be4118 | |
DrMint | 296dd194a4 | |
DrMint | 9abd9f03f2 | |
DrMint | 1b347ad357 | |
DrMint | 7b303f81ad | |
DrMint | 5fc1d26243 | |
DrMint | 73c25fd924 | |
DrMint | 0453a83d2f | |
DrMint | c73e6a0bb4 | |
DrMint | 51c20a57eb | |
DrMint | a13e916cae | |
DrMint | 77d96a3dc3 | |
DrMint | 669d4358e7 | |
DrMint | 40d893eba8 | |
DrMint | bd0185358c | |
DrMint | c464cb1402 | |
DrMint | a4467a6ee4 | |
DrMint | 119794a236 | |
DrMint | 43994ade36 | |
DrMint | 75d18e4c1c | |
DrMint | 3afaea7027 | |
DrMint | acd2d7d482 | |
DrMint | e947fd7a0e | |
DrMint | b7ebda4f4f | |
DrMint | 19e1c7784b | |
DrMint | 1bbf3b164a | |
DrMint | 625f436163 | |
DrMint | 5a963294b7 | |
DrMint | eaef34a766 | |
DrMint | 93645a2f53 | |
DrMint | 57ba34aae3 | |
DrMint | 43a1b5c24b | |
DrMint | 6a3410d251 | |
DrMint | 82c605086b | |
DrMint | c2c434eb80 | |
DrMint | 918b9b8502 | |
DrMint | 922d54f41e | |
DrMint | 4df187436a | |
DrMint | 18ad9eedb5 | |
DrMint | 692e9ab1b4 | |
DrMint | a87d886785 | |
DrMint | 70a5cbae91 | |
DrMint | 45b670de4e | |
DrMint | ac38f1dae0 | |
DrMint | a04f1b50c3 | |
DrMint | 7832b71f5c | |
DrMint | 74b77431a9 | |
DrMint | ceaacc8242 | |
DrMint | 2aab536a94 | |
DrMint | 5edc2d7f27 | |
DrMint | 9fe7a777ff | |
DrMint | 89f4168e72 | |
DrMint | 9a3d76a356 | |
DrMint | c6ee213903 | |
DrMint | 0df66815c8 | |
DrMint | 930da37d64 | |
DrMint | 260bdd5577 | |
DrMint | b6c2363093 | |
DrMint | ae169e62e7 | |
DrMint | d1d055de29 | |
DrMint | a91c5bf6ba | |
DrMint | bb42e2a56f | |
DrMint | 1a790a597d | |
DrMint | 9436c266cc | |
DrMint | d3ac283c0f | |
DrMint | c350ecc3e3 | |
DrMint | 4cf3158790 | |
DrMint | de3f385458 | |
DrMint | ae25df8d72 | |
DrMint | ba13c736b0 | |
DrMint | 18186f2014 | |
DrMint | be1a32181e | |
DrMint | 8b80ec4ca3 | |
DrMint | 8a9d354503 | |
DrMint | 5c539a0b4b | |
DrMint | f62602f922 | |
DrMint | b9570e903e | |
DrMint | df92d97bfa | |
DrMint | 3e1ebf74fd | |
DrMint | 520c4e3e35 | |
DrMint | 63c5dc0dd3 | |
DrMint | 93c079ec9f | |
DrMint | 2443dee83f | |
DrMint | d0b91f9db6 | |
DrMint | efcf01e8a0 | |
DrMint | bc0764c0d0 | |
DrMint | 6ae54c39d4 | |
DrMint | 24a8b43701 | |
DrMint | c076ec06ad | |
DrMint | 1510366bc8 | |
DrMint | 1ee5ff1292 | |
DrMint | b0fb445518 | |
DrMint | 4b1a9d570f | |
DrMint | fe5c99ee8f | |
DrMint | 31165f966c | |
DrMint | bd7330489f | |
DrMint | ede23194de | |
DrMint | 97c8670924 | |
DrMint | 46c4fece41 | |
DrMint | 670b2b8469 | |
DrMint | 2073199971 | |
DrMint | e1cd5424f7 | |
DrMint | 7446ad3f42 | |
DrMint | f2c572e576 | |
DrMint | 59283fa465 | |
DrMint | 8b6abd6379 | |
DrMint | ae1d1d735e | |
DrMint | 3f7cad9053 | |
DrMint | 2dee361f20 | |
DrMint | 622493a869 | |
DrMint | 89bfc7ea89 | |
DrMint | 2a799cf9e0 | |
DrMint | cedc25862d | |
DrMint | 2775d446d8 | |
DrMint | 56e89dbbe4 | |
DrMint | 16c540181d | |
DrMint | 6adae3fb3f | |
DrMint | 3a379f98a1 | |
DrMint | a7c5ca61fd | |
DrMint | 88b60077df | |
DrMint | 435785f31b |
|
@ -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
|
|
@ -1,2 +1,12 @@
|
|||
*.js
|
||||
*.ts
|
||||
src/graphql/generated.ts
|
||||
src/graphql/icuParams.ts
|
||||
src/shared
|
||||
.eslintrc.js
|
||||
graphql-codegen.config.js
|
||||
next-env.d.ts
|
||||
next-sitemap.config.js
|
||||
next.config.js
|
||||
postcss.config.js
|
||||
design.config.js
|
||||
graphql.config.js
|
||||
prettier.config.js
|
86
.eslintrc.js
|
@ -7,6 +7,8 @@ module.exports = {
|
|||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"next/core-web-vitals",
|
||||
],
|
||||
rules: {
|
||||
|
@ -41,10 +43,10 @@ module.exports = {
|
|||
eqeqeq: "error",
|
||||
"func-name-matching": "warn",
|
||||
"func-names": "warn",
|
||||
"func-style": ["warn", "declaration"],
|
||||
"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],
|
||||
|
@ -60,7 +62,7 @@ module.exports = {
|
|||
"no-alert": "warn",
|
||||
"no-bitwise": "warn",
|
||||
"no-caller": "warn",
|
||||
"no-confusing-arrow": "warn",
|
||||
// "no-confusing-arrow": "warn",
|
||||
"no-continue": "warn",
|
||||
"no-else-return": "warn",
|
||||
"no-eq-null": "warn",
|
||||
|
@ -77,9 +79,9 @@ module.exports = {
|
|||
"no-lone-blocks": "warn",
|
||||
"no-lonely-if": "warn",
|
||||
// "no-magic-numbers": "warn",
|
||||
"no-mixed-operators": "warn",
|
||||
// "no-mixed-operators": "warn",
|
||||
"no-multi-assign": "warn",
|
||||
"no-multi-str": "warn",
|
||||
// "no-multi-str": "warn",
|
||||
"no-negated-condition": "warn",
|
||||
// "no-nested-ternary": "warn",
|
||||
"no-new": "warn",
|
||||
|
@ -88,7 +90,7 @@ module.exports = {
|
|||
"no-new-wrappers": "warn",
|
||||
"no-octal-escape": "warn",
|
||||
"no-param-reassign": "warn",
|
||||
"no-plusplus": "warn",
|
||||
// "no-plusplus": "warn",
|
||||
"no-proto": "warn",
|
||||
"no-restricted-exports": "warn",
|
||||
"no-restricted-globals": "warn",
|
||||
|
@ -122,7 +124,7 @@ module.exports = {
|
|||
"prefer-exponentiation-operator": "warn",
|
||||
"prefer-named-capture-group": "warn",
|
||||
"prefer-numeric-literals": "warn",
|
||||
// "prefer-object-has-own": "warn",
|
||||
"prefer-object-has-own": "warn",
|
||||
"prefer-object-spread": "warn",
|
||||
"prefer-promise-reject-errors": "warn",
|
||||
"prefer-regex-literals": "warn",
|
||||
|
@ -147,28 +149,18 @@ module.exports = {
|
|||
"@typescript-eslint/ban-tslint-comment": "warn",
|
||||
"@typescript-eslint/class-literal-property-style": "warn",
|
||||
"@typescript-eslint/consistent-indexed-object-style": "warn",
|
||||
"@typescript-eslint/consistent-type-assertions": [
|
||||
"warn",
|
||||
{ assertionStyle: "as" },
|
||||
],
|
||||
"@typescript-eslint/consistent-type-assertions": ["warn", { assertionStyle: "as" }],
|
||||
"@typescript-eslint/consistent-type-exports": "error",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "warn",
|
||||
"@typescript-eslint/method-signature-style": ["error", "property"],
|
||||
"@typescript-eslint/no-base-to-string": "warn",
|
||||
"@typescript-eslint/no-confusing-non-null-assertion": "warn",
|
||||
"@typescript-eslint/no-confusing-void-expression": [
|
||||
"error",
|
||||
{ ignoreArrowShorthand: true },
|
||||
],
|
||||
"@typescript-eslint/no-confusing-void-expression": ["error", { ignoreArrowShorthand: true }],
|
||||
"@typescript-eslint/no-dynamic-delete": "error",
|
||||
"@typescript-eslint/no-empty-interface": [
|
||||
"error",
|
||||
{ allowSingleExtends: true },
|
||||
],
|
||||
"@typescript-eslint/no-empty-interface": ["error", { allowSingleExtends: true }],
|
||||
"@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",
|
||||
|
@ -189,14 +181,15 @@ 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",
|
||||
// "@typescript-eslint/strict-boolean-expressions": [
|
||||
// "error",
|
||||
// { allowAny: true },
|
||||
// ],
|
||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||
"@typescript-eslint/typedef": "error",
|
||||
"@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",
|
||||
|
@ -211,5 +204,52 @@ module.exports = {
|
|||
|
||||
/* NEXTJS */
|
||||
"@next/next/no-img-element": "off",
|
||||
|
||||
/* IMPORTS */
|
||||
"import/no-unresolved": "error",
|
||||
"import/named": "error",
|
||||
"import/default": "error",
|
||||
"import/namespace": "error",
|
||||
"import/no-restricted-paths": "error",
|
||||
"import/no-absolute-path": "error",
|
||||
"import/no-dynamic-require": "error",
|
||||
// "import/no-internal-modules": "error",
|
||||
"import/no-webpack-loader-syntax": "error",
|
||||
"import/no-self-import": "error",
|
||||
// "import/no-cycle": "error",
|
||||
"import/no-useless-path-segments": "error",
|
||||
// "import/no-relative-parent-imports": "error",
|
||||
"import/no-relative-packages": "error",
|
||||
|
||||
"import/export": "error",
|
||||
"import/no-named-as-default": "error",
|
||||
"import/no-named-as-default-member": "error",
|
||||
"import/no-deprecated": "error",
|
||||
"import/no-extraneous-dependencies": "error",
|
||||
"import/no-mutable-exports": "error",
|
||||
"import/no-unused-modules": "error",
|
||||
|
||||
"import/unambiguous": "error",
|
||||
"import/no-commonjs": "error",
|
||||
"import/no-amd": "error",
|
||||
"import/no-nodejs-modules": "error",
|
||||
"import/no-import-module-exports": "error",
|
||||
|
||||
"import/first": "error",
|
||||
// "import/exports-last": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"import/no-namespace": "error",
|
||||
"import/extensions": "error",
|
||||
"import/order": "warn",
|
||||
"import/newline-after-import": "error",
|
||||
// "import/prefer-default-export": "error",
|
||||
// "import/max-dependencies": "error",
|
||||
// "import/no-unassigned-import": "error",
|
||||
"import/no-named-default": "error",
|
||||
// "import/no-default-export": "error",
|
||||
// "import/no-named-export": "error",
|
||||
"import/no-anonymous-default-export": "error",
|
||||
// "import/group-exports": "error",
|
||||
// "import/dynamic-import-chunkname)": "error",
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
|
@ -0,0 +1,71 @@
|
|||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "42 0 * * 6"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["javascript"]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
|
@ -1,11 +1,11 @@
|
|||
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Node.js CI
|
||||
name: Preflight checks
|
||||
|
||||
on:
|
||||
# push:
|
||||
# branches: [ main ]
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
|
@ -25,12 +25,8 @@ jobs:
|
|||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run build --if-present
|
||||
- run: npm ci --force
|
||||
- run: npm run precommit
|
||||
env:
|
||||
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
NEXT_PUBLIC_URL_CMS: ${{ secrets.NEXT_PUBLIC_URL_CMS }}
|
||||
NEXT_PUBLIC_URL_IMG: ${{ secrets.NEXT_PUBLIC_URL_IMG }}
|
||||
NEXT_PUBLIC_URL_SELF: ${{ secrets.NEXT_PUBLIC_URL_SELF }}
|
||||
URL_GRAPHQL: ${{ secrets.URL_GRAPHQL }}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# Generated content
|
||||
src/graphql/generated.ts
|
||||
|
||||
public/robots.txt
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
|
|
@ -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.
|
|
@ -1 +1,2 @@
|
|||
.next
|
||||
public/local-data/*
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"editor.rulers": [100],
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
# CONTRIBUTING
|
||||
|
||||
## Styling choices
|
||||
|
||||
### Pages
|
||||
|
||||
```tsx
|
||||
import ...
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
||||
*/
|
||||
|
||||
const MY_CONSTANT = "value"
|
||||
const DEFAULT_FILTERS_STATE = {}
|
||||
|
||||
/*
|
||||
* ╭────────╮
|
||||
* ──────────────────────────────────────────╯ PAGE ╰─────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {}
|
||||
|
||||
const PageName = () => {}
|
||||
export default PageName;
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ NEXT DATA FETCHING ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (context) => {}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async (context) => {}
|
||||
|
||||
/*
|
||||
* ╭───────────────────╮
|
||||
* ─────────────────────────────────────╯ PRIVATE METHODS ╰───────────────────────────────────────
|
||||
*/
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Component1Interface {}
|
||||
const Component1 = () => {}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
interface Component2Interface {}
|
||||
const Component2 = () => {}
|
||||
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
```tsx
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
||||
*/
|
||||
|
||||
const MY_CONSTANT = "value";
|
||||
const DEFAULT_FILTERS_STATE = {};
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface ComponentProps {}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Component = () => {};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
||||
*/
|
||||
```
|
|
@ -0,0 +1,102 @@
|
|||
# Data Testing
|
||||
|
||||
The following is all the tests done on the data entries coming from Strapi. This way we can detect weird situation (missing fields, duplicated values)...
|
||||
|
||||
## Contents
|
||||
|
||||
| Subitem | Name | Type | Severity | Description | Recommendation |
|
||||
| --------- | --------------------- | ----------- | --------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| | No Category | Missing | Medium | The Content has no Category. | Select a Category in relation with the Content |
|
||||
| | No Type | Missing | Medium | The Content has no Category. | If unsure, use the "Other" Category. |
|
||||
| | No Ranged Content | Improvement | Low | The Content has no Ranged Content. | If this Content is available in one or multiple Library Item(s), create a Range Content to connect the Content to its Library Item(s). |
|
||||
| | Self Recommendation | Error | Very High | The Content is referring to itself as a Next or Previous Recommended. | |
|
||||
| | No Thumbnail | Missing | High | The Content has no Thumbnail. | |
|
||||
| | No Titles | Missing | High | The Content has no Titles. | |
|
||||
| Titles | No Title | Missing | High | | |
|
||||
| Titles | No Language | Error | Very High | | |
|
||||
| Titles | No Description | Missing | Medium | | |
|
||||
| Titles | Duplicate Language | Error | High | | |
|
||||
| | No Sets | Missing | Medium | The Content has no Sets. | |
|
||||
| | No Video Set | Missing | Very Low | The Content has no Video Set. | |
|
||||
| | No Audio Set | Missing | Very Low | The Content has no Audio Set. | |
|
||||
| | No Text Set | Missing | Medium | The Content has no Text Set. | |
|
||||
| Text Sets | No Language | Error | Very High | | |
|
||||
| Text Sets | No Source Language | Error | Very High | | |
|
||||
| Text Sets | Not Done Status | Improvement | Low | | |
|
||||
| Text Sets | No Text | Missing | Medium | | |
|
||||
| Text Sets | No Transcribers | Missing | High | The Content is a Transcription but doesn't credit any Transcribers. | Add the appropriate Transcribers. |
|
||||
| Text Sets | No Translators | Missing | High | The Content is a Translation but doesn't credit any Translators. | Add the appropriate Translators. |
|
||||
| Text Sets | Credited Transcribers | Error | High | The Content is a Translation but credits one or more Transcribers. | If appropriate, create a Transcription Text Set with the Transcriber credited there. |
|
||||
| Text Sets | Credited Translators | Error | High | The Content is a Transcription but credits one or more Translators. | If appropriate, create a Translation Text Set with the Translator credited there. |
|
||||
| Text Sets | Duplicate Language | Error | High | | |
|
||||
|
||||
## LibraryItems
|
||||
|
||||
| Subitem | Name | Type | Severity | Description | Recommendation |
|
||||
| -------------------- | ---------------------- | ----------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| | No Category | Missing | Medium | The Item has no Category. | Select a Category in relation with the Item. |
|
||||
| | Disconnected Item | Error | Very High | The Item is neither a Root Item, nor is it a subitem of another item. | |
|
||||
| | No Contents | Missing | Low | The Item has no Contents. | |
|
||||
| | No Thumbnail | Missing | High | The Item has no Thumbnail. | |
|
||||
| | No Images | Missing | High | The Item has no Images. | |
|
||||
| Images | No Language | Error | Very High | | |
|
||||
| Images | No Source Language | Error | Very High | | |
|
||||
| Images | Not Done Status | Improvement | Low | | |
|
||||
| Images | No Scanners | Missing | High | The Item is a Scan but doesn't credit any Scanners. | Add the appropriate Scanners. |
|
||||
| Images | No Cleaners | Missing | High | The Item is a Scan but doesn't credit any Cleaners. | Add the appropriate Cleaners. |
|
||||
| Images | Credited Typesetters | Error | High | The Item is a Scan but credits one or more Typesetters. | If appropriate, create a Scanlation Images Set Set with the Typesetters credited there. |
|
||||
| Images | No Typesetters | Missing | High | The Item is a Scanlation but doesn't credit any Typesetters. | Add the appropriate Typesetters. |
|
||||
| Images | Credited Scanners | Error | High | The Item is a Scanlation but credits one or more Scanners. | If appropriate, create a Scan Images Set with the Scanners credited there. |
|
||||
| Images | No Cover | Missing | High | | |
|
||||
| Images > Cover | No Front | Missing | Very High | | |
|
||||
| Images > Cover | No Spine | Missing | Low | | |
|
||||
| Images > Cover | No Back | Missing | High | | |
|
||||
| Images > Cover | No Full | Missing | Low | | |
|
||||
| Images | No Dust Jacket | Missing | Very Low | | |
|
||||
| Images > Dust Jacket | No Front | Missing | Very High | | |
|
||||
| Images > Dust Jacket | No Spine | Missing | Low | | |
|
||||
| Images > Dust Jacket | No Back | Missing | High | | |
|
||||
| Images > Dust Jacket | No Flap Front | Missing | Medium | | |
|
||||
| Images > Dust Jacket | No Flat Back | Missing | Medium | | |
|
||||
| Images > Dust Jacket | No Full | Missing | Low | | |
|
||||
| Images | No Obi Belt | Missing | Very Low | | |
|
||||
| Images > Obi Belt | No Front | Missing | Very High | | |
|
||||
| Images > Obi Belt | No Spine | Missing | Low | | |
|
||||
| Images > Obi Belt | No Back | Missing | High | | |
|
||||
| Images > Obi Belt | No Flap Front | Missing | Medium | | |
|
||||
| Images > Obi Belt | No Flat Back | Missing | Medium | | |
|
||||
| Images > Obi Belt | No Full | Missing | Low | | |
|
||||
| Images | Duplicate Language | Error | High | | |
|
||||
| Description | No Language | Error | Very High | | |
|
||||
| Description | No Text | Error | Very High | | |
|
||||
| Description | Duplicate Language | Error | High | | |
|
||||
| | No URLs | Missing | Very Low | Unless the Item is a Variant Set. | |
|
||||
| | No Release Date | Missing | Low | | |
|
||||
| Release Date | No Year | Error | Very High | | |
|
||||
| Release Date | No Month | Missing | Medium | | |
|
||||
| Release Date | No Year | Missing | Low | | |
|
||||
| | No Price | Missing | Low | | |
|
||||
| Price | No Currency | Error | Very High | Unless the Item is a Variant Set. | |
|
||||
| Price | No Amount | Error | Very High | | |
|
||||
| | No Physical Size | Missing | Low | Unless the Item is Digital or a Variant or Relation Set. | |
|
||||
| Physical Size | No Width | Error | Very High | | |
|
||||
| Physical Size | No Height | Error | Very High | | |
|
||||
| Physical Size | No Thickness | Missing | Medium | | |
|
||||
| | No Metadata | Error | High | | |
|
||||
| Metadata Audio | No Subtype | Error | Very High | | |
|
||||
| Metadata Textual | No Subtype | Error | Very High | | |
|
||||
| Metadata Textual | No Languages | Missing | Medium | | |
|
||||
| Metadata Textual | No Page Count | Missing | Medium | | |
|
||||
| Metadata Game | No Platforms | Missing | Very High | | |
|
||||
| Metadata Game | No Audio Languages | Missing | High | | |
|
||||
| Metadata Game | No Sub Languages | Missing | High | | |
|
||||
| Metadata Game | No Interface Languages | Missing | High | | |
|
||||
| Metadata Video | No Subtype | Error | Very High | | |
|
||||
| Metadata Group | No Subtype | Error | Very High | | |
|
||||
| Metadata Group | No Subitems Type | Missing | High | | |
|
||||
| Metadata Group | Has Physical Size | Error | High | Variant Sets and Relation Set shouldn't have a Physical Size. | |
|
||||
| Metadata Group | Has Price | Error | High | Variant Sets shouldn't have a Price. | |
|
||||
| Metadata Group | Has URLs | Error | High | Variant Sets shouldn't have URLs. | |
|
||||
| Metadata Group | Has Contents | Error | High | Variant Sets and Relation Set shouldn't have Contents. | |
|
||||
| Metadata Group | Has Images | Error | High | Variant Sets and Relation Set shouldn't have Images. | |
|
||||
| Metadata Group | No Subitems | Missing | High | Group Items should have subitems. |
|
154
README.md
|
@ -4,66 +4,141 @@
|
|||
[![GitHub](https://img.shields.io/github/license/Accords-Library/accords-library.com?style=flat-square)](https://github.com/Accords-Library/accords-library.com/blob/main/LICENSE)
|
||||
![Libraries.io dependency status for GitHub repo](https://img.shields.io/librariesio/github/Accords-Library/accords-library.com?style=flat-square)
|
||||
|
||||
## Introduction
|
||||
|
||||
Accord’s Library is a fan-site that aims at gathering and archiving all of Yoko Taro’s work.
|
||||
Yoko Taro is a Japanese video game director and scenario writer. He is best-known for his work on the NieR and Drakengard (Drag-on Dragoon) franchises.
|
||||
|
||||
## Technologies
|
||||
|
||||
#### [Back](https://github.com/Accords-Library/strapi.accords-library.com)
|
||||
### Overview
|
||||
|
||||
- CMS: Stapi
|
||||
- GraphQL endpoint
|
||||
![](docs/project-mind-map.png)
|
||||
|
||||
_Purple connections are actions done at build-time only. Grey connections can be at build-time or run-time._
|
||||
|
||||
### [strapi.accords-library.com](https://github.com/Accords-Library/strapi.accords-library.com)
|
||||
|
||||
Our Content Management System (CMS) that uses [Strapi](https://strapi.io/).
|
||||
|
||||
- Use the official [GraphQL plugin](https://market.strapi.io/plugins/@strapi-plugin-graphql)
|
||||
- Multilanguage support
|
||||
- Markdown format for the rich text fields
|
||||
- Use webhooks to notify the front-end and image processor of updates
|
||||
- Use webhooks to notify the front-end, search engine, and image processor when new content/media has been created/modified/deleted
|
||||
|
||||
#### [Image Processor](https://github.com/Accords-Library/img.accords-library.com)
|
||||
### [img.accords-library.com](https://github.com/Accords-Library/img.accords-library.com)
|
||||
|
||||
A custom made image processor to overcome the lack of customization offered by Strapi build-in image processor. There is a python script to bulk process all images uploaded to Strapi. Subsequent changes to Strapi's media library can be handled using webhooks. The repo includes a server that listen to these webhook calls, and another to serve the images.
|
||||
|
||||
Each image in Strapi's media library is converted to four different formats:
|
||||
|
||||
- Convert the images from the CMS to 4 formats
|
||||
- Small: 512x512, quality 60, .webp
|
||||
- Medium: 1024x1024, quality 75, .webp
|
||||
- Large: 2048x2048, quality 80, .webp
|
||||
- Og: 512x512, quality 60, .jpg
|
||||
|
||||
#### [Front](https://github.com/Accords-Library/accords-library.com) (this repository)
|
||||
### [search.accords-library.com](https://github.com/Accords-Library/search.accords-library.com)
|
||||
|
||||
A search engine that uses [Meilisearch](https://www.meilisearch.com/).
|
||||
The repo includes a docker-compose file to run an instance of Meilisearch. There is also a server that populates Meilisearch's documents at startup, then listen to webhooks sent by Strapi for subsequent changes.
|
||||
|
||||
### [gallery.accords-library.com](https://github.com/Accords-Library/gallery.accords-library.com)
|
||||
|
||||
An image board engine, uses [Szurubooru](https://github.com/rr-/szurubooru), a lighweight engine inspired by Danbooru (and Booru-type galleries in general). Unlike the other subdomains, this repo is completely separated from the rest of the stack.
|
||||
|
||||
### [watch.accords-library.com](https://github.com/Accords-Library/watch.accords-library.com)
|
||||
|
||||
A set of tools to archive videos on multiple platforms. The repo contains a CLI tool to archive YouTube videos. There is also a Python script which import the videos metadata to Strapi using GraphQL mutations. Finally, there's a server to serve the video files and thumbnail.
|
||||
|
||||
### [umami.accords-library.com](https://umami.is/)
|
||||
|
||||
An open-source self-hosted alternative to Google Analytics which doesn't require a cookie notice to be GDPR compliant.
|
||||
|
||||
### [accords-library.com](https://github.com/Accords-Library/accords-library.com) (this repository)
|
||||
|
||||
A detailled look at the technologies used in this repository:
|
||||
|
||||
- Language: [TypeScript](https://www.typescriptlang.org/)
|
||||
- Framework: [Next.js](https://nextjs.org/) (React)
|
||||
|
||||
- Framework: [Next.js 13](https://nextjs.org/) (React 18)
|
||||
|
||||
- SSG + ISR (Static Site Generation + Incremental Static Regeneration)
|
||||
|
||||
- The website is built before running in production
|
||||
- Performances are great, and it's possible to deploy the app on a CDN
|
||||
- On-Demand ISR to continuously update the website when new content is added or existing content is modified/deleted
|
||||
- Some widely used data (e.g: UI localizations) are downloaded separetely into `public/local-data` as some form of request deduping + it make this data hot-swappable without the need to rebuild the entire website.
|
||||
|
||||
- Queries: [GraphQL Code Generator](https://www.graphql-code-generator.com/)
|
||||
|
||||
- Fetch the GraphQL schema from the GraphQL back-end endpoint
|
||||
- Read the operations and fragments stored as graphql files in the `src/graphql` folder
|
||||
- Automatically generates a typesafe ready to use SDK using [graphql-request](https://www.npmjs.com/package/graphql-request) as the GraphQL client
|
||||
- Markdown: [markdown-to-jsx](https://www.npmjs.com/package/markdown-to-jsx)
|
||||
- Support for Arbitrary React Components and Component Props!
|
||||
- Autogenerated multi-level table of content and anchor links for the different headers
|
||||
|
||||
- Styling: [Tailwind CSS](https://tailwindcss.com/)
|
||||
- Manually added support for scrollbar styling
|
||||
- Support for [Material Icons](https://fonts.google.com/icons)
|
||||
- Support for creating any arbitrary theming mode by swapping CSS variables
|
||||
- Support for many screen sizes and resolutions
|
||||
- State Management: [React Context](https://reactjs.org/docs/context.html)
|
||||
- Persistent app state using LocalStorage
|
||||
|
||||
- Support for creating any arbitrary theme by swapping CSS variables
|
||||
- Support for Container Queries (media queries at the element level)
|
||||
- The website has a three-column layout, which turns into one-column + 2 toggleable side-menus if the screen is too narrow.
|
||||
- Check out our [Design System Showcase](https://accords-library.com/dev/showcase/design-system)
|
||||
|
||||
- State Management: [Jōtai](https://jotai.org/)
|
||||
|
||||
- Jōtai is a small-weighted library for atomic state management
|
||||
- Persistent app state using LocalStorage and SessionStorage
|
||||
|
||||
- Markdown
|
||||
|
||||
- Use [Marked](https://www.npmjs.com/package/marked) to convert markdown to HTML (which is then sanitized using [DOMPurify](https://www.npmjs.com/package/isomorphic-dompurify))
|
||||
- Support for arbitrary React Components and Component Props using [markdown-to-jsx](https://www.npmjs.com/package/markdown-to-jsx)
|
||||
- Autogenerated multi-level table of content and anchor links for the different headers
|
||||
|
||||
- Accessibility
|
||||
|
||||
- Gestures using [react-swipeable](https://www.npmjs.com/package/react-swipeable)
|
||||
- Keyboard hotkeys using [react-hot-keys](https://www.npmjs.com/package/react-hot-keys)
|
||||
- Keyboard hotkeys using [react-hotkeys-hook](https://www.npmjs.com/package/react-hotkeys-hook)
|
||||
- Support for light and dark mode with a manual switch and system's selected theme by default
|
||||
- Fonts can be swaped to [OpenDyslexic](https://www.npmjs.com/package/@fontsource/opendyslexic)
|
||||
|
||||
- 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
|
||||
- SSG + ISR (Static Site Generation + Incremental Static Regeneration):
|
||||
- The website is built before running in production
|
||||
- Performances are great, and possibility to deploy the app using a CDN
|
||||
- On-Demand ISR to continuously update the website when new content is added or existing content is modified/deleted
|
||||
|
||||
- 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 the translation is missing.
|
||||
|
||||
- SEO
|
||||
- Good defaults for the metadate and OpenGraph properties
|
||||
- Each page can provide the thumbnail, title, description to be used
|
||||
|
||||
- Good defaults for the metadata and OpenGraph properties
|
||||
- Each page can provide a custom thumbnail, title, description to be used
|
||||
- Automatic generation of the sitemap using [next-sitemap](https://www.npmjs.com/package/next-sitemap)
|
||||
- Data quality testing
|
||||
|
||||
- Data Quality Testing
|
||||
|
||||
- Data from the CMS is subject to a battery of tests (about 20 warning types and 40 error types) at build time
|
||||
- Each warning/error comes with a front-end link to the incriminating element, as well as a link to the CMS to fix it
|
||||
- Check for completeness, conformity, and integrity
|
||||
|
||||
- Code Quality and Style
|
||||
|
||||
- React Strict Mode
|
||||
- [Eslint](https://www.npmjs.com/package/eslint) with [eslint-plugin-import](https://www.npmjs.com/package/eslint-plugin-import), [typescript-eslint](https://www.npmjs.com/package/@typescript-eslint/eslint-plugin)
|
||||
- [Prettier](https://www.npmjs.com/package/prettier) with [prettier-plugin-tailwindcss](https://www.npmjs.com/package/prettier-plugin-tailwindcss)
|
||||
- [ts-unused-exports](https://www.npmjs.com/package/ts-unused-exports) to find unused exported functions/constants...
|
||||
|
||||
- Other
|
||||
|
||||
- Custom book reader based on [Okuma-Reader](https://github.com/DrMint/Okuma-Reader)
|
||||
- Support for [Material Symbols](https://fonts.google.com/icons)
|
||||
- Custom lightbox using [react-zoom-pan-pinch](https://www.npmjs.com/package/react-zoom-pan-pinch)
|
||||
- Handle query params type-validation using [Zod](https://zod.dev/)
|
||||
- A secret "Terminal" mode. Can you find it?
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
|
@ -72,25 +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 followind information:
|
||||
|
||||
```txt
|
||||
URL_GRAPHQL=https://url-to.strapi-accords-library.com/graphql
|
||||
ACCESS_TOKEN=abcdef0123456789
|
||||
REVALIDATION_TOKEN=abcdef0123456789
|
||||
SMTP_HOST=email.provider.com
|
||||
SMTP_USER=email@example.com
|
||||
SMTP_PASSWORD=mypassword123
|
||||
NEXT_PUBLIC_URL_CMS=https://url-to.strapi-accords-library.com/
|
||||
NEXT_PUBLIC_URL_IMG=https://url-to.img-accords-library.com/
|
||||
NEXT_PUBLIC_URL_SELF=https://url-to-front-accords-library.com
|
||||
```
|
||||
Change the variables
|
||||
|
||||
Run in dev mode:
|
||||
|
||||
|
@ -101,6 +165,6 @@ Run in dev mode:
|
|||
OR build and run in production mode
|
||||
|
||||
```bash
|
||||
./run_accords_build.sh
|
||||
npm run build
|
||||
./run_accords_prod.sh
|
||||
```
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
const colors = {
|
||||
light: {
|
||||
hightlight: { r: 255, g: 241, b: 224 },
|
||||
light: { r: 255, g: 237, b: 216 },
|
||||
mid: { r: 240, g: 209, b: 179 },
|
||||
dark: { r: 156, g: 102, b: 68 },
|
||||
shade: { r: 192, g: 132, b: 94 },
|
||||
black: { r: 27, g: 24, b: 17 },
|
||||
},
|
||||
dark: {
|
||||
highlight: { r: 44, g: 40, b: 37 },
|
||||
light: { r: 38, g: 34, b: 30 },
|
||||
mid: { r: 57, g: 45, b: 34 },
|
||||
dark: { r: 192, g: 132, b: 94 },
|
||||
shade: { r: 25, g: 25, b: 20 },
|
||||
black: { r: 235, g: 234, b: 231 },
|
||||
},
|
||||
};
|
||||
|
||||
const fonts = {
|
||||
openDyslexic: "OpenDyslexic",
|
||||
vollkorn: "Vollkorn",
|
||||
zenMaruGothic: "Zen Maru Gothic",
|
||||
shareTechMono: "Share Tech Mono",
|
||||
};
|
||||
|
||||
const fontFamilies = {
|
||||
standard: {
|
||||
body: fonts.zenMaruGothic,
|
||||
headers: fonts.vollkorn,
|
||||
mono: fonts.shareTechMono,
|
||||
},
|
||||
dyslexic: {
|
||||
body: fonts.openDyslexic,
|
||||
headers: fonts.openDyslexic,
|
||||
mono: fonts.shareTechMono,
|
||||
},
|
||||
};
|
||||
|
||||
const layout = {
|
||||
// all values in rem
|
||||
mainMenuReduced: 6,
|
||||
mainMenu: 20,
|
||||
subMenu: 20,
|
||||
navbar: 5,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
colors,
|
||||
layout,
|
||||
fonts,
|
||||
fontFamilies,
|
||||
};
|
|
@ -0,0 +1,134 @@
|
|||
<?xml version="1.0"?>
|
||||
<minder version="1.14.0" parent-etag="2844169042" etag="3777682473">
|
||||
<theme name="dark" label="Dark" index="-1"/>
|
||||
<styles>
|
||||
<style level="0" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="rounded" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="10" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<style level="1" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<style level="2" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<style level="3" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<style level="4" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<style level="5" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<style level="6" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<style level="7" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<style level="8" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<style level="9" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<style level="10" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
</styles>
|
||||
<drawarea x="-736.36765543619742" y="32.05864461263036" scale="1.5"/>
|
||||
<images/>
|
||||
<nodes>
|
||||
<node id="0" posx="648.76816813151015" posy="501.13512166341127" width="181" height="56" side="left" fold="false" treesize="56" layout="Horizontal" group="false">
|
||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
||||
<nodename posx="667.76816813151015" posy="520.13512166341127" maxwidth="200">
|
||||
<text data="accords-library.com"/>
|
||||
</nodename>
|
||||
<nodenote></nodenote>
|
||||
</node>
|
||||
<node id="1" posx="603.1911417643222" posy="198.59891764322904" width="228" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
|
||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
||||
<nodename posx="622.1911417643222" posy="217.59891764322904" maxwidth="200">
|
||||
<text data="strapi.accords-library.com"/>
|
||||
</nodename>
|
||||
<nodenote></nodenote>
|
||||
</node>
|
||||
<node id="2" posx="508.77593994140574" posy="358.0493469238279" width="230" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
|
||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
||||
<nodename posx="527.7759399414058" posy="377.0493469238279" maxwidth="200">
|
||||
<text data="watch.accords-library.com"/>
|
||||
</nodename>
|
||||
<nodenote></nodenote>
|
||||
</node>
|
||||
<node id="3" posx="959.78491210937489" posy="490.81981404622388" width="235" height="56" side="left" fold="false" treesize="56" layout="Horizontal" group="false">
|
||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
||||
<nodename posx="978.78491210937489" posy="509.81981404622388" maxwidth="200">
|
||||
<text data="search.accords-library.com"/>
|
||||
</nodename>
|
||||
<nodenote></nodenote>
|
||||
</node>
|
||||
<node id="4" posx="300.46666463216098" posy="474.56320190429688" width="213" height="56" side="left" fold="false" treesize="56" layout="Horizontal" group="false">
|
||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
||||
<nodename posx="319.46666463216098" posy="493.56320190429688" maxwidth="200">
|
||||
<text data="img.accords-library.com"/>
|
||||
</nodename>
|
||||
<nodenote></nodenote>
|
||||
</node>
|
||||
<node id="5" posx="753.05198160807242" posy="709.79446411132812" width="236" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
|
||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
||||
<nodename posx="772.05198160807242" posy="728.79446411132812" maxwidth="200">
|
||||
<text data="umami.accords-library.com"/>
|
||||
</nodename>
|
||||
<nodenote></nodenote>
|
||||
</node>
|
||||
<node id="6" posx="468.00632731119703" posy="709.85610961914062" width="234" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
|
||||
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
|
||||
<nodename posx="487.00632731119703" posy="728.85610961914062" maxwidth="200">
|
||||
<text data="gallery.accords-library.com"/>
|
||||
</nodename>
|
||||
<nodenote></nodenote>
|
||||
</node>
|
||||
</nodes>
|
||||
<groups/>
|
||||
<connections>
|
||||
<connection from_id="3" to_id="1" drag_x="1038.6269124348951" drag_y="296.62844848632801" color="#813d9c">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<title>GraphQL queries</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="1" to_id="3" drag_x="982.0184326171875" drag_y="347.55404663085926">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<title>Webhook</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="2" to_id="1" drag_x="640.25118001302098" drag_y="308.37991333007801" color="#813d9c">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<title>GraphQL mutations</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="0" to_id="5" drag_x="801.02655029296898" drag_y="644.59735107421943">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<title>Sends events</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="4" to_id="0" drag_x="531.34985351562477" drag_y="582.42803955078148">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="150"/>
|
||||
<title>Provides the images</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="3" to_id="0" drag_x="917.41172281901027" drag_y="585.41107177734443">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="150"/>
|
||||
<title>Provides search results</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="0" to_id="1" drag_x="872.998291015625" drag_y="408.14896647135413">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<title>GraphQL queries</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="0" to_id="6" drag_x="664.23213704427053" drag_y="645.67006429036542">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<title>Links to</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="4" to_id="1" drag_x="400.83854166666663" drag_y="295.4715677897135" color="#813d9c">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<title>Python script</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="2" to_id="0" drag_x="645.96777343749955" drag_y="448.91068522135413">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="150"/>
|
||||
<title>Provides the videos</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="1" to_id="4" drag_x="441.14660644531227" drag_y="342.38249715169252">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<title>Webhook</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
<connection from_id="1" to_id="0" drag_x="790.7373046875" drag_y="369.4803466796875">
|
||||
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
|
||||
<title>Webhook</title>
|
||||
<note></note>
|
||||
</connection>
|
||||
</connections>
|
||||
<stickers/>
|
||||
</minder>
|
After Width: | Height: | Size: 2.1 MiB |
|
@ -8,17 +8,10 @@ module.exports = {
|
|||
headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` },
|
||||
},
|
||||
},
|
||||
documents: [
|
||||
"src/graphql/operations/*.graphql",
|
||||
"src/graphql/fragments/*.graphql",
|
||||
],
|
||||
documents: ["src/graphql/operations/**/*.graphql", "src/graphql/fragments/*.graphql"],
|
||||
generates: {
|
||||
"src/graphql/generated.ts": {
|
||||
plugins: [
|
||||
"typescript",
|
||||
"typescript-operations",
|
||||
"typescript-graphql-request",
|
||||
],
|
||||
plugins: ["typescript", "typescript-operations", "typescript-graphql-request"],
|
||||
},
|
||||
},
|
||||
};
|
|
@ -7,22 +7,22 @@ module.exports = {
|
|||
href: `${process.env.NEXT_PUBLIC_URL_SELF}/en/`,
|
||||
hreflang: "en",
|
||||
},
|
||||
{
|
||||
href: `${process.env.NEXT_PUBLIC_URL_SELF}/fr/`,
|
||||
hreflang: "fr",
|
||||
},
|
||||
{
|
||||
href: `${process.env.NEXT_PUBLIC_URL_SELF}/ja/`,
|
||||
hreflang: "ja",
|
||||
},
|
||||
{
|
||||
href: `${process.env.NEXT_PUBLIC_URL_SELF}/es/`,
|
||||
hreflang: "es",
|
||||
},
|
||||
{
|
||||
href: `${process.env.NEXT_PUBLIC_URL_SELF}/fr/`,
|
||||
hreflang: "fr",
|
||||
},
|
||||
{
|
||||
href: `${process.env.NEXT_PUBLIC_URL_SELF}/pt-br/`,
|
||||
hreflang: "pt-br",
|
||||
},
|
||||
{
|
||||
href: `${process.env.NEXT_PUBLIC_URL_SELF}/ja/`,
|
||||
hreflang: "ja",
|
||||
},
|
||||
],
|
||||
exclude: ["/en/*", "/fr/*", "/ja/*", "/es/*", "/pt-br/*"],
|
||||
exclude: ["/en/*", "/fr/*", "/ja/*", "/es/*", "/pt-br/*", "/404", "/500", "/dev/*"],
|
||||
};
|
|
@ -1,14 +1,13 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
|
||||
/* CONFIG */
|
||||
|
||||
const locales = ["en", "es", "fr", "pt-br", "ja"];
|
||||
const locales = ["en", "es", "fr", "pt-br", "ja", "zh"];
|
||||
|
||||
/* END CONFIG */
|
||||
|
||||
/* @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
swcMinify: true,
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
i18n: {
|
||||
locales: locales,
|
||||
defaultLocale: "en",
|
||||
|
@ -16,9 +15,6 @@ module.exports = {
|
|||
images: {
|
||||
domains: ["img.accords-library.com", "watch.accords-library.com"],
|
||||
},
|
||||
serverRuntimeConfig: {
|
||||
locales: locales,
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
@ -26,6 +22,16 @@ module.exports = {
|
|||
destination: "https://discord.com/invite/5mcXcRAczj",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/gallery",
|
||||
destination: "https://gallery.accords-library.com/posts",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/contents/folder",
|
||||
destination: "/contents",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
|
111
package.json
|
@ -2,53 +2,90 @@
|
|||
"name": "accords-library.com",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"dev": "next dev -p 12499",
|
||||
"prebuild": "npm run generate",
|
||||
"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",
|
||||
"postbuild": "next-sitemap --config next-sitemap.config.js",
|
||||
"start": "next start -p 12500",
|
||||
"lint": "next lint",
|
||||
"generate": "graphql-codegen --config graphql-codegen.js",
|
||||
"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/material-icons": "^4.5.4",
|
||||
"@fontsource/material-icons-rounded": "^4.5.4",
|
||||
"@fontsource/opendyslexic": "^4.5.4",
|
||||
"@fontsource/vollkorn": "^4.5.9",
|
||||
"@fontsource/zen-maru-gothic": "^4.5.11",
|
||||
"@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.7",
|
||||
"graphql-request": "^4.2.0",
|
||||
"markdown-to-jsx": "^7.1.7",
|
||||
"next": "^12.1.6",
|
||||
"nodemailer": "^6.7.5",
|
||||
"react": "18.1.0",
|
||||
"react-dom": "18.1.0",
|
||||
"react-hot-keys": "^2.7.2",
|
||||
"react-swipeable": "^7.0.0",
|
||||
"turndown": "^7.1.1"
|
||||
"autoprefixer": "^10.4.15",
|
||||
"cuid": "^2.1.8",
|
||||
"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.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.2",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"zod": "^3.22.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^2.6.2",
|
||||
"@graphql-codegen/typescript": "2.4.11",
|
||||
"@graphql-codegen/typescript-graphql-request": "^4.4.8",
|
||||
"@graphql-codegen/typescript-operations": "^2.4.0",
|
||||
"@types/node": "17.0.33",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/react": "18.0.9",
|
||||
"@types/react-dom": "^18.0.4",
|
||||
"@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",
|
||||
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
||||
"@typescript-eslint/parser": "^5.23.0",
|
||||
"eslint": "^8.15.0",
|
||||
"eslint-config-next": "12.1.6",
|
||||
"graphql": "^16.5.0",
|
||||
"next-sitemap": "^2.5.20",
|
||||
"prettier": "^2.6.2",
|
||||
"prettier-plugin-organize-imports": "^2.3.4",
|
||||
"tailwindcss": "^3.0.24",
|
||||
"typescript": "^4.6.4"
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@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,0 +1,21 @@
|
|||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
quoteProps: "as-needed",
|
||||
jsxSingleQuote: false,
|
||||
trailingComma: "es5",
|
||||
bracketSpacing: true,
|
||||
bracketSameLine: true,
|
||||
arrowParens: "always",
|
||||
rangeStart: 0,
|
||||
rangeEnd: Infinity,
|
||||
requirePragma: false,
|
||||
insertPragma: false,
|
||||
proseWrap: "preserve",
|
||||
htmlWhitespaceSensitivity: "ignore",
|
||||
endOfLine: "lf",
|
||||
singleAttributePerLine: false,
|
||||
};
|
After Width: | Height: | Size: 278 KiB |
|
@ -1,10 +0,0 @@
|
|||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#9c6644" />
|
||||
<meta name="apple-mobile-web-app-title" content="Accord's Library" />
|
||||
<meta name="application-name" content="Accord's Library" />
|
||||
<meta name="msapplication-TileColor" content="#feecd6" />
|
||||
<meta name="msapplication-TileImage" content="/mstile-144x144.png" />
|
||||
<meta name="theme-color" content="#feecd6" />
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.4 KiB |
|
@ -1 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="creative-commons" class="svg-inline--fa fa-creative-commons" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M245.8 214.9l-33.22 17.28c-9.43-19.58-25.24-19.93-27.46-19.93-22.13 0-33.22 14.61-33.22 43.84 0 23.57 9.21 43.84 33.22 43.84 14.47 0 24.65-7.09 30.57-21.26l30.55 15.5c-6.17 11.51-25.69 38.98-65.1 38.98-22.6 0-73.96-10.32-73.96-77.05 0-58.69 43-77.06 72.63-77.06 30.72-.01 52.7 11.95 65.99 35.86zm143.1 0l-32.78 17.28c-9.5-19.77-25.72-19.93-27.9-19.93-22.14 0-33.22 14.61-33.22 43.84 0 23.55 9.23 43.84 33.22 43.84 14.45 0 24.65-7.09 30.54-21.26l31 15.5c-2.1 3.75-21.39 38.98-65.09 38.98-22.69 0-73.96-9.87-73.96-77.05 0-58.67 42.97-77.06 72.63-77.06 30.71-.01 52.58 11.95 65.56 35.86zM247.6 8.05C104.7 8.05 0 123.1 0 256c0 138.5 113.6 248 247.6 248 129.9 0 248.4-100.9 248.4-248 0-137.9-106.6-248-248.4-248zm.87 450.8c-112.5 0-203.7-93.04-203.7-202.8 0-105.4 85.43-203.3 203.7-203.3 112.5 0 202.8 89.46 202.8 203.3-.01 121.7-99.68 202.8-202.8 202.8z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M245.8 214.9l-33.22 17.28c-9.43-19.58-25.24-19.93-27.46-19.93-22.13 0-33.22 14.61-33.22 43.84 0 23.57 9.21 43.84 33.22 43.84 14.47 0 24.65-7.09 30.57-21.26l30.55 15.5c-6.17 11.51-25.69 38.98-65.1 38.98-22.6 0-73.96-10.32-73.96-77.05 0-58.69 43-77.06 72.63-77.06 30.72-.01 52.7 11.95 65.99 35.86zm143.1 0l-32.78 17.28c-9.5-19.77-25.72-19.93-27.9-19.93-22.14 0-33.22 14.61-33.22 43.84 0 23.55 9.23 43.84 33.22 43.84 14.45 0 24.65-7.09 30.54-21.26l31 15.5c-2.1 3.75-21.39 38.98-65.09 38.98-22.69 0-73.96-9.87-73.96-77.05 0-58.67 42.97-77.06 72.63-77.06 30.71-.01 52.58 11.95 65.56 35.86zM247.6 8.05C104.7 8.05 0 123.1 0 256c0 138.5 113.6 248 247.6 248C377.5 504 496 403.1 496 256 496 118.1 389.4 8 247.6 8zm.87 450.8c-112.5 0-203.7-93.04-203.7-202.8 0-105.4 85.43-203.3 203.7-203.3 112.5 0 202.8 89.46 202.8 203.3-.01 121.7-99.68 202.8-202.8 202.8z"/></svg>
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 925 B |
|
@ -1 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="creative-commons-by" class="svg-inline--fa fa-creative-commons-by" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M314.9 194.4v101.4h-28.3v120.5h-77.1V295.9h-28.3V194.4c0-4.4 1.6-8.2 4.6-11.3 3.1-3.1 6.9-4.7 11.3-4.7H299c4.1 0 7.8 1.6 11.1 4.7 3.1 3.2 4.8 6.9 4.8 11.3zm-101.5-63.7c0-23.3 11.5-35 34.5-35s34.5 11.7 34.5 35c0 23-11.5 34.5-34.5 34.5s-34.5-11.5-34.5-34.5zM247.6 8C389.4 8 496 118.1 496 256c0 147.1-118.5 248-248.4 248C113.6 504 0 394.5 0 256 0 123.1 104.7 8 247.6 8zm.8 44.7C130.2 52.7 44.7 150.6 44.7 256c0 109.8 91.2 202.8 203.7 202.8 103.2 0 202.8-81.1 202.8-202.8 .1-113.8-90.2-203.3-202.8-203.3z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M314.9 194.4v101.4h-28.3v120.5h-77.1V295.9h-28.3V194.4c0-4.4 1.6-8.2 4.6-11.3 3.1-3.1 6.9-4.7 11.3-4.7H299c4.1 0 7.8 1.6 11.1 4.7 3.1 3.2 4.8 6.9 4.8 11.3zm-101.5-63.7c0-23.3 11.5-35 34.5-35s34.5 11.7 34.5 35c0 23-11.5 34.5-34.5 34.5s-34.5-11.5-34.5-34.5zM247.6 8C389.4 8 496 118.1 496 256c0 147.1-118.5 248-248.4 248C113.6 504 0 394.5 0 256 0 123.1 104.7 8 247.6 8zm.8 44.7C130.2 52.7 44.7 150.6 44.7 256c0 109.8 91.2 202.8 203.7 202.8 103.2 0 202.8-81.1 202.8-202.8.1-113.8-90.2-203.3-202.8-203.3z"/></svg>
|
Before Width: | Height: | Size: 750 B After Width: | Height: | Size: 579 B |
|
@ -1 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="creative-commons-sa" class="svg-inline--fa fa-creative-commons-sa" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M247.6 8C389.4 8 496 118.1 496 256c0 147.1-118.5 248-248.4 248C113.6 504 0 394.5 0 256 0 123.1 104.7 8 247.6 8zm.8 44.7C130.2 52.7 44.7 150.6 44.7 256c0 109.8 91.2 202.8 203.7 202.8 103.2 0 202.8-81.1 202.8-202.8 .1-113.8-90.2-203.3-202.8-203.3zM137.7 221c13-83.9 80.5-95.7 108.9-95.7 99.8 0 127.5 82.5 127.5 134.2 0 63.6-41 132.9-128.9 132.9-38.9 0-99.1-20-109.4-97h62.5c1.5 30.1 19.6 45.2 54.5 45.2 23.3 0 58-18.2 58-82.8 0-82.5-49.1-80.6-56.7-80.6-33.1 0-51.7 14.6-55.8 43.8h18.2l-49.2 49.2-49-49.2h19.4z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M247.6 8C389.4 8 496 118.1 496 256c0 147.1-118.5 248-248.4 248C113.6 504 0 394.5 0 256 0 123.1 104.7 8 247.6 8zm.8 44.7C130.2 52.7 44.7 150.6 44.7 256c0 109.8 91.2 202.8 203.7 202.8 103.2 0 202.8-81.1 202.8-202.8.1-113.8-90.2-203.3-202.8-203.3zM137.7 221c13-83.9 80.5-95.7 108.9-95.7 99.8 0 127.5 82.5 127.5 134.2 0 63.6-41 132.9-128.9 132.9-38.9 0-99.1-20-109.4-97h62.5c1.5 30.1 19.6 45.2 54.5 45.2 23.3 0 58-18.2 58-82.8 0-82.5-49.1-80.6-56.7-80.6-33.1 0-51.7 14.6-55.8 43.8h18.2l-49.2 49.2-49-49.2h19.4z"/></svg>
|
Before Width: | Height: | Size: 757 B After Width: | Height: | Size: 586 B |
|
@ -1 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="discord" class="svg-inline--fa fa-discord" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M524.5 69.84a1.5 1.5 0 0 0 -.764-.7A485.1 485.1 0 0 0 404.1 32.03a1.816 1.816 0 0 0 -1.923 .91 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.14-30.6 1.89 1.89 0 0 0 -1.924-.91A483.7 483.7 0 0 0 116.1 69.14a1.712 1.712 0 0 0 -.788 .676C39.07 183.7 18.19 294.7 28.43 404.4a2.016 2.016 0 0 0 .765 1.375A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.063-.676A348.2 348.2 0 0 0 208.1 430.4a1.86 1.86 0 0 0 -1.019-2.588 321.2 321.2 0 0 1 -45.87-21.85 1.885 1.885 0 0 1 -.185-3.126c3.082-2.309 6.166-4.711 9.109-7.137a1.819 1.819 0 0 1 1.9-.256c96.23 43.92 200.4 43.92 295.5 0a1.812 1.812 0 0 1 1.924 .233c2.944 2.426 6.027 4.851 9.132 7.16a1.884 1.884 0 0 1 -.162 3.126 301.4 301.4 0 0 1 -45.89 21.83 1.875 1.875 0 0 0 -1 2.611 391.1 391.1 0 0 0 30.01 48.81 1.864 1.864 0 0 0 2.063 .7A486 486 0 0 0 610.7 405.7a1.882 1.882 0 0 0 .765-1.352C623.7 277.6 590.9 167.5 524.5 69.84zM222.5 337.6c-28.97 0-52.84-26.59-52.84-59.24S193.1 219.1 222.5 219.1c29.67 0 53.31 26.82 52.84 59.24C275.3 310.1 251.9 337.6 222.5 337.6zm195.4 0c-28.97 0-52.84-26.59-52.84-59.24S388.4 219.1 417.9 219.1c29.67 0 53.31 26.82 52.84 59.24C470.7 310.1 447.5 337.6 417.9 337.6z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path d="M524.5 69.84a1.5 1.5 0 00-.764-.7A485.1 485.1 0 00404.1 32.03a1.816 1.816 0 00-1.923.91 337.5 337.5 0 00-14.9 30.6 447.8 447.8 0 00-134.4 0 309.5 309.5 0 00-15.14-30.6 1.89 1.89 0 00-1.924-.91A483.7 483.7 0 00116.1 69.14a1.712 1.712 0 00-.788.676C39.07 183.7 18.19 294.7 28.43 404.4a2.016 2.016 0 00.765 1.375A487.7 487.7 0 00176 479.9a1.9 1.9 0 002.063-.676A348.2 348.2 0 00208.1 430.4a1.86 1.86 0 00-1.019-2.588 321.2 321.2 0 01-45.87-21.85 1.885 1.885 0 01-.185-3.126 251.047 251.047 0 009.109-7.137 1.819 1.819 0 011.9-.256c96.23 43.92 200.4 43.92 295.5 0a1.812 1.812 0 011.924.233 234.533 234.533 0 009.132 7.16 1.884 1.884 0 01-.162 3.126 301.4 301.4 0 01-45.89 21.83 1.875 1.875 0 00-1 2.611 391.1 391.1 0 0030.01 48.81 1.864 1.864 0 002.063.7A486 486 0 00610.7 405.7a1.882 1.882 0 00.765-1.352C623.7 277.6 590.9 167.5 524.5 69.84zm-302 267.76c-28.97 0-52.84-26.59-52.84-59.24s23.44-59.26 52.84-59.26c29.67 0 53.31 26.82 52.84 59.24-.04 31.76-23.44 59.26-52.84 59.26zm195.4 0c-28.97 0-52.84-26.59-52.84-59.24s23.34-59.26 52.84-59.26c29.67 0 53.31 26.82 52.84 59.24-.04 31.76-23.24 59.26-52.84 59.26z"/></svg>
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
@ -1 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="github" class="svg-inline--fa fa-github" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"/></svg>
|
After Width: | Height: | Size: 871 B |
|
@ -0,0 +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}},{"id":"11","attributes":{"code":"CNY","symbol":"¥","rate_to_usd":0.141546,"display_decimals":true}}]}}
|
|
@ -0,0 +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","code":"zh","localized_name":"中文"}}]}}
|
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 167 KiB |
After Width: | Height: | Size: 70 KiB |
After Width: | Height: | Size: 94 KiB |
After Width: | Height: | Size: 6.6 KiB |
After Width: | Height: | Size: 2.6 KiB |
|
@ -0,0 +1,35 @@
|
|||
import { Ico } from "./Ico";
|
||||
import { ToolTip } from "./ToolTip";
|
||||
import { cJoin } from "helpers/className";
|
||||
import { useFormat } from "hooks/useFormat";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const AnchorShare = ({ id, className }: Props): JSX.Element => {
|
||||
const { format } = useFormat();
|
||||
return (
|
||||
<ToolTip content={format("copy_anchor_link")} trigger="mouseenter" className="text-sm">
|
||||
<ToolTip content={format("anchor_link_copied")} trigger="click" className="text-sm">
|
||||
<Ico
|
||||
icon="link"
|
||||
className={cJoin("cursor-pointer transition-colors hover:text-dark", className)}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${process.env.NEXT_PUBLIC_URL_SELF + window.location.pathname}#${id}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ToolTip>
|
||||
</ToolTip>
|
||||
);
|
||||
};
|
|
@ -1,501 +1,284 @@
|
|||
import { Button } from "components/Inputs/Button";
|
||||
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||
import { UploadImageFragment } from "graphql/generated";
|
||||
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||
import { prettyLanguage, prettySlug } from "helpers/formatters";
|
||||
import { getOgImage, ImageQuality, OgImage } from "helpers/img";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { useMediaMobile } from "hooks/useMediaQuery";
|
||||
import { AnchorIds } from "hooks/useScrollTopOnChange";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSwipeable } from "react-swipeable";
|
||||
import { OrderableList } from "./Inputs/OrderableList";
|
||||
import { Select } from "./Inputs/Select";
|
||||
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";
|
||||
import { Popup } from "./Popup";
|
||||
import { isDefined, isUndefined } from "helpers/asserts";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { OpenGraph, TITLE_PREFIX, TITLE_SEPARATOR } from "helpers/openGraph";
|
||||
import { Ids } from "types/ids";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomGetter, useAtomPair } from "helpers/atoms";
|
||||
import { useFormat } from "hooks/useFormat";
|
||||
|
||||
interface Props extends AppStaticProps {
|
||||
subPanel?: React.ReactNode;
|
||||
subPanelIcon?: string;
|
||||
contentPanel?: React.ReactNode;
|
||||
title?: string;
|
||||
navTitle: string | null | undefined;
|
||||
thumbnail?: UploadImageFragment;
|
||||
description?: string;
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
||||
*/
|
||||
|
||||
const SENSIBILITY_SWIPE = 1.1;
|
||||
const isIOSAtom = atom((get) => get(atoms.userAgent.os) === "iOS");
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
export interface AppLayoutRequired {
|
||||
openGraph: OpenGraph;
|
||||
}
|
||||
|
||||
export function AppLayout(props: Immutable<Props>): JSX.Element {
|
||||
const {
|
||||
langui,
|
||||
currencies,
|
||||
languages,
|
||||
interface Props extends AppLayoutRequired {
|
||||
subPanel?: React.ReactNode;
|
||||
subPanelIcon?: MaterialSymbol;
|
||||
contentPanel?: React.ReactNode;
|
||||
contentPanelScroolbar?: boolean;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const AppLayout = ({
|
||||
subPanel,
|
||||
contentPanel,
|
||||
thumbnail,
|
||||
title,
|
||||
navTitle,
|
||||
description,
|
||||
openGraph,
|
||||
subPanelIcon = "tune",
|
||||
} = props;
|
||||
contentPanelScroolbar = true,
|
||||
}: Props): JSX.Element => {
|
||||
const isMainPanelReduced = useAtomGetter(atoms.layout.mainPanelReduced);
|
||||
const [isSubPanelOpened, setSubPanelOpened] = useAtomPair(atoms.layout.subPanelOpened);
|
||||
const [isMainPanelOpened, setMainPanelOpened] = useAtomPair(atoms.layout.mainPanelOpened);
|
||||
const isMenuGesturesEnabled = useAtomGetter(atoms.layout.menuGesturesEnabled);
|
||||
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 isMobile = useMediaMobile();
|
||||
const appLayout = useAppLayout();
|
||||
|
||||
const sensibilitySwipe = 1.1;
|
||||
|
||||
useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
router.events?.on("routeChangeStart", () => {
|
||||
appLayout.setConfigPanelOpen(false);
|
||||
appLayout.setMainPanelOpen(false);
|
||||
appLayout.setSubPanelOpen(false);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
router.events?.on("hashChangeStart", () => {
|
||||
appLayout.setSubPanelOpen(false);
|
||||
});
|
||||
}, [appLayout, router.events]);
|
||||
const { format } = useFormat();
|
||||
|
||||
const handlers = useSwipeable({
|
||||
onSwipedLeft: (SwipeEventData) => {
|
||||
if (appLayout.menuGestures) {
|
||||
if (SwipeEventData.velocity < sensibilitySwipe) return;
|
||||
if (appLayout.mainPanelOpen) {
|
||||
appLayout.setMainPanelOpen(false);
|
||||
} else if (subPanel && contentPanel) {
|
||||
appLayout.setSubPanelOpen(true);
|
||||
if (isMenuGesturesEnabled) {
|
||||
if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
|
||||
if (isMainPanelOpened) {
|
||||
setMainPanelOpened(false);
|
||||
} else if (isDefined(subPanel) && isDefined(contentPanel)) {
|
||||
setSubPanelOpened(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSwipedRight: (SwipeEventData) => {
|
||||
if (appLayout.menuGestures) {
|
||||
if (SwipeEventData.velocity < sensibilitySwipe) return;
|
||||
if (appLayout.subPanelOpen) {
|
||||
appLayout.setSubPanelOpen(false);
|
||||
if (isMenuGesturesEnabled) {
|
||||
if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
|
||||
if (isSubPanelOpened) {
|
||||
setSubPanelOpened(false);
|
||||
} else {
|
||||
appLayout.setMainPanelOpen(true);
|
||||
setMainPanelOpened(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const turnSubIntoContent = subPanel && !contentPanel;
|
||||
|
||||
const titlePrefix = "Accord’s Library";
|
||||
const metaImage: OgImage = thumbnail
|
||||
? getOgImage(ImageQuality.Og, thumbnail)
|
||||
: {
|
||||
image: "/default_og.jpg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Accord's Library Logo",
|
||||
};
|
||||
const ogTitle =
|
||||
title ?? navTitle ?? prettySlug(router.asPath.split("/").pop());
|
||||
|
||||
const metaDescription = description ?? langui.default_description ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementsByTagName("html")[0].style.fontSize = `${
|
||||
(appLayout.fontSize ?? 1) * 100
|
||||
}%`;
|
||||
}, [appLayout.fontSize]);
|
||||
|
||||
const currencyOptions: string[] = [];
|
||||
currencies.map((currency) => {
|
||||
if (currency.attributes?.code)
|
||||
currencyOptions.push(currency.attributes.code);
|
||||
});
|
||||
const [currencySelect, setCurrencySelect] = useState<number>(-1);
|
||||
|
||||
let defaultPreferredLanguages: string[] = [];
|
||||
|
||||
if (router.locale && router.locales) {
|
||||
if (router.locale === "en") {
|
||||
defaultPreferredLanguages = [router.locale];
|
||||
router.locales.map((locale) => {
|
||||
if (locale !== router.locale) defaultPreferredLanguages.push(locale);
|
||||
});
|
||||
} else {
|
||||
defaultPreferredLanguages = [router.locale, "en"];
|
||||
router.locales.map((locale) => {
|
||||
if (locale !== router.locale && locale !== "en")
|
||||
defaultPreferredLanguages.push(locale);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (appLayout.currency)
|
||||
setCurrencySelect(currencyOptions.indexOf(appLayout.currency));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appLayout.currency]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currencySelect >= 0)
|
||||
appLayout.setCurrency(currencyOptions[currencySelect]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currencySelect]);
|
||||
|
||||
let gridCol = "";
|
||||
if (subPanel) {
|
||||
if (appLayout.mainPanelReduced) {
|
||||
gridCol = "grid-cols-[6rem_20rem_1fr]";
|
||||
} else {
|
||||
gridCol = "grid-cols-[20rem_20rem_1fr]";
|
||||
}
|
||||
} else if (appLayout.mainPanelReduced) {
|
||||
gridCol = "grid-cols-[6rem_0px_1fr]";
|
||||
} else {
|
||||
gridCol = "grid-cols-[20rem_0px_1fr]";
|
||||
}
|
||||
const turnSubIntoContent = isDefined(subPanel) && isUndefined(contentPanel) && is1ColumnLayout;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
appLayout.darkMode ? "set-theme-dark" : "set-theme-light"
|
||||
} ${
|
||||
appLayout.dyslexic
|
||||
? "set-theme-font-dyslexic"
|
||||
: "set-theme-font-standard"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
{...handlers}
|
||||
className={`fixed inset-0 touch-pan-y p-0 m-0 bg-light text-black grid
|
||||
[grid-template-areas:'main_sub_content'] ${gridCol} mobile:grid-cols-[1fr]
|
||||
mobile:grid-rows-[1fr_5rem] mobile:[grid-template-areas:'content''navbar']`}
|
||||
>
|
||||
id={Ids.Body}
|
||||
className={cJoin(
|
||||
"fixed inset-0 m-0 grid touch-pan-y bg-light p-0",
|
||||
cIf(
|
||||
is1ColumnLayout,
|
||||
"grid-rows-[1fr_5rem] [grid-template-areas:'content''navbar']",
|
||||
"[grid-template-areas:'main_sub_content']"
|
||||
)
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: is1ColumnLayout
|
||||
? "1fr"
|
||||
: `${isMainPanelReduced ? layout.mainMenuReduced : layout.mainMenu}rem ${
|
||||
isDefined(subPanel) ? layout.subMenu : 0
|
||||
}rem 1fr`,
|
||||
}}>
|
||||
<Head>
|
||||
<title>{`${titlePrefix} - ${ogTitle}`}</title>
|
||||
<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
|
||||
name="twitter:title"
|
||||
content={`${titlePrefix} - ${ogTitle}`}
|
||||
></meta>
|
||||
property="og:type"
|
||||
content={openGraph.video ? "video.movie" : openGraph.audio ? "music.song" : "website"}
|
||||
/>
|
||||
<meta property="og:locale" content={router.locale} />
|
||||
<meta property="og:site_name" content="Accord’s Library" />
|
||||
|
||||
<meta name="description" content={metaDescription} />
|
||||
<meta name="twitter:description" content={metaDescription}></meta>
|
||||
<meta property="og:title" content={openGraph.title} />
|
||||
<meta property="og:description" content={openGraph.description} />
|
||||
|
||||
<meta property="og:image" content={metaImage.image}></meta>
|
||||
<meta property="og:image:secure_url" content={metaImage.image}></meta>
|
||||
<meta
|
||||
property="og:image:width"
|
||||
content={metaImage.width.toString()}
|
||||
></meta>
|
||||
<meta
|
||||
property="og:image:height"
|
||||
content={metaImage.height.toString()}
|
||||
></meta>
|
||||
<meta property="og:image:alt" content={metaImage.alt}></meta>
|
||||
<meta property="og:image:type" content="image/jpeg"></meta>
|
||||
<meta name="twitter:card" content="summary_large_image"></meta>
|
||||
<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" />
|
||||
|
||||
<meta name="twitter:image" content={metaImage.image}></meta>
|
||||
{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>
|
||||
|
||||
{/* Background when navbar is opened */}
|
||||
<div
|
||||
className={`[grid-area:content] mobile:z-10 absolute
|
||||
inset-0 transition-[backdrop-filter] duration-500 ${
|
||||
(appLayout.mainPanelOpen || appLayout.subPanelOpen) && isMobile
|
||||
? "[backdrop-filter:blur(2px)]"
|
||||
: "pointer-events-none touch-none "
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute bg-shade inset-0 transition-opacity duration-500
|
||||
${turnSubIntoContent ? "" : ""}
|
||||
${
|
||||
(appLayout.mainPanelOpen || appLayout.subPanelOpen) && isMobile
|
||||
? "opacity-60"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
onClick={() => {
|
||||
appLayout.setMainPanelOpen(false);
|
||||
appLayout.setSubPanelOpen(false);
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Content panel */}
|
||||
<div
|
||||
id={AnchorIds.CONTENT_PANEL}
|
||||
className={`[grid-area:content] overflow-y-scroll bg-light texture-paper-dots`}
|
||||
>
|
||||
{contentPanel ? (
|
||||
id={Ids.ContentPanel}
|
||||
className={cJoin(
|
||||
"bg-light [grid-area:content]",
|
||||
cIf(!isIOS, "texture-paper-dots"),
|
||||
cIf(contentPanelScroolbar, "overflow-y-scroll")
|
||||
)}>
|
||||
{isDefined(contentPanel) ? (
|
||||
contentPanel
|
||||
) : turnSubIntoContent ? (
|
||||
subPanel
|
||||
) : (
|
||||
<div className="grid place-content-center h-full">
|
||||
<ContentPlaceholder message={format("select_option_sidebar")} icon={"chevron_left"} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Background when navbar is opened */}
|
||||
|
||||
<div
|
||||
className="text-dark border-dark border-2 border-dotted rounded-2xl
|
||||
p-8 grid grid-flow-col place-items-center gap-9 opacity-40"
|
||||
>
|
||||
<p className="text-4xl">❮</p>
|
||||
<p className="text-2xl w-64">{langui.select_option_sidebar}</p>
|
||||
</div>
|
||||
className={cJoin(
|
||||
`absolute inset-0 z-40 transition-filter duration-500
|
||||
[grid-area:content]`,
|
||||
cIf(
|
||||
(isMainPanelOpened || isSubPanelOpened) && is1ColumnLayout,
|
||||
cIf(!isPerfModeEnabled, "backdrop-blur"),
|
||||
"pointer-events-none touch-none"
|
||||
)
|
||||
)}>
|
||||
<div
|
||||
className={cJoin(
|
||||
"absolute inset-0 bg-shade transition-opacity duration-500",
|
||||
cIf(
|
||||
(isMainPanelOpened || isSubPanelOpened) && is1ColumnLayout,
|
||||
"opacity-60",
|
||||
"opacity-0"
|
||||
)
|
||||
)}
|
||||
onClick={() => {
|
||||
setMainPanelOpened(false);
|
||||
setSubPanelOpened(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navbar */}
|
||||
<div
|
||||
className={cJoin(
|
||||
`z-40 grid grid-cols-[5rem_1fr_5rem] place-items-center border-t
|
||||
border-dotted border-black bg-light [grid-area:navbar]`,
|
||||
cIf(!isIOS, "texture-paper-dots"),
|
||||
cIf(!is1ColumnLayout, "hidden")
|
||||
)}>
|
||||
<Ico
|
||||
icon={isMainPanelOpened ? "close" : "menu"}
|
||||
className="cursor-pointer !text-2xl"
|
||||
onClick={() => {
|
||||
setMainPanelOpened((current) => !current);
|
||||
setSubPanelOpened(false);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={cJoin(
|
||||
"overflow-hidden text-center font-headers font-black",
|
||||
cIf(openGraph.title.length > 30, "max-h-14 text-xl", "max-h-16 text-2xl")
|
||||
)}>
|
||||
{openGraph.title.substring(TITLE_PREFIX.length + TITLE_SEPARATOR.length)
|
||||
? openGraph.title.substring(TITLE_PREFIX.length + TITLE_SEPARATOR.length)
|
||||
: "Accord’s Library"}
|
||||
</p>
|
||||
{isDefined(subPanel) && !turnSubIntoContent && (
|
||||
<Ico
|
||||
icon={isSubPanelOpened ? "close" : subPanelIcon}
|
||||
className="cursor-pointer !text-2xl"
|
||||
onClick={() => {
|
||||
setSubPanelOpened((current) => !current);
|
||||
setMainPanelOpened(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub panel */}
|
||||
{subPanel && (
|
||||
{isDefined(subPanel) && !turnSubIntoContent && (
|
||||
<div
|
||||
className={`[grid-area:sub] mobile:[grid-area:content] mobile:z-10 mobile:w-[90%]
|
||||
mobile:justify-self-end border-r-[1px] mobile:border-r-0 mobile:border-l-[1px]
|
||||
border-black border-dotted overflow-y-scroll webkit-scrollbar:w-0
|
||||
[scrollbar-width:none] transition-transform duration-300 bg-light texture-paper-dots
|
||||
${
|
||||
turnSubIntoContent
|
||||
? "mobile:border-l-0 mobile:w-full"
|
||||
: !appLayout.subPanelOpen && "mobile:translate-x-[100vw]"
|
||||
}`}
|
||||
>
|
||||
id={Ids.SubPanel}
|
||||
className={cJoin(
|
||||
`overflow-y-scroll border-r border-dark/50 bg-light
|
||||
transition-transform duration-300 scrollbar-none`,
|
||||
cIf(!isIOS, "texture-paper-dots"),
|
||||
cIf(
|
||||
is1ColumnLayout,
|
||||
"z-40 justify-self-end border-r-0 [grid-area:content]",
|
||||
"[grid-area:sub]"
|
||||
),
|
||||
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)] border-l"),
|
||||
cIf(is1ColumnLayout && !isSubPanelOpened, "translate-x-[100vw]")
|
||||
)}>
|
||||
{subPanel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main panel */}
|
||||
<div
|
||||
className={`[grid-area:main] mobile:[grid-area:content] mobile:z-10 mobile:w-[90%]
|
||||
mobile:justify-self-start border-r-[1px] border-black border-dotted overflow-y-scroll
|
||||
webkit-scrollbar:w-0 [scrollbar-width:none] transition-transform duration-300 bg-light
|
||||
texture-paper-dots ${
|
||||
appLayout.mainPanelOpen ? "" : "mobile:-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<MainPanel langui={langui} />
|
||||
</div>
|
||||
|
||||
{/* Navbar */}
|
||||
<div
|
||||
className="[grid-area:navbar] border-t-[1px] border-black border-dotted grid
|
||||
grid-cols-[5rem_1fr_5rem] place-items-center desktop:hidden bg-light texture-paper-dots"
|
||||
>
|
||||
<span
|
||||
className="material-icons mt-[.1em] cursor-pointer"
|
||||
onClick={() => {
|
||||
appLayout.setMainPanelOpen(!appLayout.mainPanelOpen);
|
||||
appLayout.setSubPanelOpen(false);
|
||||
}}
|
||||
>
|
||||
{appLayout.mainPanelOpen ? "close" : "menu"}
|
||||
</span>
|
||||
<p
|
||||
className={`font-black font-headers text-center overflow-hidden ${
|
||||
ogTitle && ogTitle.length > 30
|
||||
? "text-xl max-h-14"
|
||||
: "text-2xl max-h-16"
|
||||
}`}
|
||||
>
|
||||
{ogTitle}
|
||||
</p>
|
||||
<span
|
||||
className="material-icons mt-[.1em] cursor-pointer"
|
||||
onClick={() => {
|
||||
appLayout.setSubPanelOpen(!appLayout.subPanelOpen);
|
||||
appLayout.setMainPanelOpen(false);
|
||||
}}
|
||||
>
|
||||
{subPanel && !turnSubIntoContent
|
||||
? appLayout.subPanelOpen
|
||||
? "close"
|
||||
: subPanelIcon
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Popup
|
||||
state={appLayout.configPanelOpen}
|
||||
setState={appLayout.setConfigPanelOpen}
|
||||
>
|
||||
<h2 className="text-2xl">{langui.settings}</h2>
|
||||
|
||||
<div
|
||||
className="mt-4 grid gap-16 justify-items-center
|
||||
text-center desktop:grid-cols-[auto_auto]"
|
||||
>
|
||||
{router.locales && (
|
||||
<div>
|
||||
<h3 className="text-xl">{langui.languages}</h3>
|
||||
{appLayout.preferredLanguages && (
|
||||
<OrderableList
|
||||
items={
|
||||
appLayout.preferredLanguages.length > 0
|
||||
? new Map(
|
||||
appLayout.preferredLanguages.map((locale) => [
|
||||
locale,
|
||||
prettyLanguage(locale, languages),
|
||||
])
|
||||
)
|
||||
: new Map(
|
||||
defaultPreferredLanguages.map((locale) => [
|
||||
locale,
|
||||
prettyLanguage(locale, languages),
|
||||
])
|
||||
)
|
||||
}
|
||||
insertLabels={
|
||||
new Map([
|
||||
[0, langui.primary_language],
|
||||
[1, langui.secondary_language],
|
||||
])
|
||||
}
|
||||
onChange={(items) => {
|
||||
const preferredLanguages = [...items].map(
|
||||
([code]) => code
|
||||
);
|
||||
appLayout.setPreferredLanguages(preferredLanguages);
|
||||
if (router.locale !== preferredLanguages[0]) {
|
||||
router.push(router.asPath, router.asPath, {
|
||||
locale: preferredLanguages[0],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-8 place-items-center text-center desktop:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-xl">{langui.theme}</h3>
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
onClick={() => {
|
||||
appLayout.setDarkMode(false);
|
||||
appLayout.setSelectedThemeMode(true);
|
||||
}}
|
||||
active={
|
||||
appLayout.selectedThemeMode === true &&
|
||||
appLayout.darkMode === false
|
||||
}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
{langui.light}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
appLayout.setSelectedThemeMode(false);
|
||||
}}
|
||||
active={appLayout.selectedThemeMode === false}
|
||||
className="rounded-l-none rounded-r-none border-x-0"
|
||||
>
|
||||
{langui.auto}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
appLayout.setDarkMode(true);
|
||||
appLayout.setSelectedThemeMode(true);
|
||||
}}
|
||||
active={
|
||||
appLayout.selectedThemeMode === true &&
|
||||
appLayout.darkMode === true
|
||||
}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{langui.dark}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl">{langui.currency}</h3>
|
||||
<div>
|
||||
<Select
|
||||
options={currencyOptions}
|
||||
state={currencySelect}
|
||||
setState={setCurrencySelect}
|
||||
className="w-28"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl">{langui.font_size}</h3>
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
className="rounded-r-none"
|
||||
onClick={() =>
|
||||
appLayout.setFontSize(
|
||||
appLayout.fontSize
|
||||
? appLayout.fontSize / 1.05
|
||||
: 1 / 1.05
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="material-icons !text-base">
|
||||
text_decrease
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-l-none rounded-r-none border-x-0"
|
||||
onClick={() => appLayout.setFontSize(1)}
|
||||
>
|
||||
{((appLayout.fontSize ?? 1) * 100).toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
maximumFractionDigits: 0,
|
||||
}
|
||||
)}
|
||||
%
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
onClick={() =>
|
||||
appLayout.setFontSize(
|
||||
appLayout.fontSize
|
||||
? appLayout.fontSize * 1.05
|
||||
: 1 * 1.05
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="material-icons !text-base">
|
||||
text_increase
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl">{langui.font}</h3>
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
active={appLayout.dyslexic === false}
|
||||
onClick={() => appLayout.setDyslexic(false)}
|
||||
className="font-zenMaruGothic"
|
||||
>
|
||||
Zen Maru Gothic
|
||||
</Button>
|
||||
<Button
|
||||
active={appLayout.dyslexic === true}
|
||||
onClick={() => appLayout.setDyslexic(true)}
|
||||
className="font-openDyslexic"
|
||||
>
|
||||
OpenDyslexic
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl">{langui.player_name}</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="<player>"
|
||||
className="w-48"
|
||||
onInput={(event) =>
|
||||
appLayout.setPlayerName(
|
||||
(event.target as HTMLInputElement).value
|
||||
)
|
||||
}
|
||||
value={appLayout.playerName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
className={cJoin(
|
||||
`overflow-y-scroll border-r border-dark/50 bg-light
|
||||
transition-transform duration-300 scrollbar-none`,
|
||||
cIf(!isIOS, "texture-paper-dots"),
|
||||
cIf(is1ColumnLayout, "z-40 justify-self-start [grid-area:content]", "[grid-area:main]"),
|
||||
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)]"),
|
||||
cIf(!isMainPanelOpened && is1ColumnLayout, "-translate-x-full")
|
||||
)}>
|
||||
<MainPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface ContentPlaceholderProps {
|
||||
message: string;
|
||||
icon?: MaterialSymbol;
|
||||
}
|
||||
|
||||
const ContentPlaceholder = ({ message, icon }: ContentPlaceholderProps): JSX.Element => (
|
||||
<div className="grid h-full place-content-center">
|
||||
<div
|
||||
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
|
||||
border-dark p-8 text-dark opacity-40">
|
||||
{isDefined(icon) && <Ico icon={icon} className="!text-[300%]" />}
|
||||
<p className={cJoin("w-64 text-2xl", cIf(isUndefined(icon), "text-center"))}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,18 +1,24 @@
|
|||
import { Immutable } from "helpers/types";
|
||||
import { cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function Chip(props: Immutable<Props>): JSX.Element {
|
||||
return (
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Chip = ({ className, text }: Props): JSX.Element => (
|
||||
<div
|
||||
className={`grid place-content-center place-items-center text-xs pb-[0.14rem]
|
||||
whitespace-nowrap px-1.5 border-[1px] rounded-full opacity-70
|
||||
transition-[color,_opacity,_border-color] hover:opacity-100 ${props.className}`}
|
||||
>
|
||||
{props.children}
|
||||
className={cJoin(
|
||||
`grid place-content-center place-items-center whitespace-nowrap rounded-full border
|
||||
border-black/70 px-1.5 pb-[0.14rem] text-xs text-black/70`,
|
||||
className
|
||||
)}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
import { MouseEventHandler, useCallback } from "react";
|
||||
import { DatePickerFragment } from "graphql/generated";
|
||||
import { TranslatedProps } from "types/TranslatedProps";
|
||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||
import { DownPressable } from "components/Containers/DownPressable";
|
||||
import { isDefined } from "helpers/asserts";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
date: DatePickerFragment;
|
||||
title: string;
|
||||
url: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const ChroniclePreview = ({
|
||||
date,
|
||||
url,
|
||||
title,
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
}: Props): JSX.Element => (
|
||||
<DownPressable
|
||||
className="flex w-full gap-4 px-5 py-4"
|
||||
href={url}
|
||||
onClick={onClick}
|
||||
active={active}
|
||||
border
|
||||
disabled={disabled}>
|
||||
{isDefined(date.year) && (
|
||||
<div className="text-right">
|
||||
<p>{date.year}</p>
|
||||
<p className="text-sm text-dark">{prettyMonthDay(date.month, date.day)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p
|
||||
className={cJoin(
|
||||
"text-lg leading-tight",
|
||||
cIf(isDefined(date.year), "text-left", "w-full text-center")
|
||||
)}>
|
||||
{title}
|
||||
</p>
|
||||
</DownPressable>
|
||||
);
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const TranslatedChroniclePreview = ({
|
||||
translations,
|
||||
fallback,
|
||||
...otherProps
|
||||
}: TranslatedProps<Parameters<typeof ChroniclePreview>[0], "title">): JSX.Element => {
|
||||
const [selectedTranslation] = useSmartLanguage({
|
||||
items: translations,
|
||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
||||
});
|
||||
|
||||
return <ChroniclePreview title={selectedTranslation?.title ?? fallback.title} {...otherProps} />;
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭───────────────────╮
|
||||
* ─────────────────────────────────────╯ PRIVATE METHODS ╰───────────────────────────────────────
|
||||
*/
|
||||
|
||||
const prettyMonthDay = (
|
||||
month?: number | null | undefined,
|
||||
day?: number | null | undefined
|
||||
): string => {
|
||||
let result = "";
|
||||
if (month) {
|
||||
result += month.toString().padStart(2, "0");
|
||||
if (day) {
|
||||
result += "/";
|
||||
result += day.toString().padStart(2, "0");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
|
@ -0,0 +1,142 @@
|
|||
import { useCallback } from "react";
|
||||
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 { 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";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
chronicles: NonNullable<
|
||||
NonNullable<
|
||||
NonNullable<GetChroniclesChaptersQuery["chroniclesChapters"]>["data"][number]["attributes"]
|
||||
>["chronicles"]
|
||||
>["data"];
|
||||
currentSlug?: string;
|
||||
title: string;
|
||||
open?: boolean;
|
||||
onTriggerClosing?: () => void;
|
||||
onOpening?: () => void;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
const ChroniclesList = ({
|
||||
chronicles,
|
||||
currentSlug,
|
||||
title,
|
||||
open,
|
||||
onTriggerClosing,
|
||||
onOpening,
|
||||
}: Props): JSX.Element => {
|
||||
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
}>
|
||||
{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}`}>
|
||||
{chronicle.attributes.translations.length === 0 &&
|
||||
chronicle.attributes.contents.data.length === 1
|
||||
? filterHasAttributes(chronicle.attributes.contents.data, [
|
||||
"attributes.translations",
|
||||
]).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",
|
||||
]).map((translation) => ({
|
||||
title: prettyInlineTitle(
|
||||
translation.pre_title,
|
||||
translation.title,
|
||||
translation.subtitle
|
||||
),
|
||||
language: translation.language.data.attributes.code,
|
||||
}))}
|
||||
fallback={{
|
||||
title: prettySlug(chronicle.attributes.slug),
|
||||
}}
|
||||
url={sJoin(
|
||||
"/chronicles/",
|
||||
chronicle.attributes.slug,
|
||||
"/#chronicle-",
|
||||
chronicle.attributes.slug
|
||||
)}
|
||||
onClick={() => setSubPanelOpened(false)}
|
||||
/>
|
||||
))
|
||||
: chronicle.attributes.translations.length > 0 && (
|
||||
<TranslatedChroniclePreview
|
||||
date={chronicle.attributes.date_start}
|
||||
active={chronicle.attributes.slug === currentSlug}
|
||||
translations={filterHasAttributes(chronicle.attributes.translations, [
|
||||
"language.data.attributes.code",
|
||||
"title",
|
||||
]).map((translation) => ({
|
||||
title: translation.title,
|
||||
language: translation.language.data.attributes.code,
|
||||
}))}
|
||||
fallback={{
|
||||
title: prettySlug(chronicle.attributes.slug),
|
||||
}}
|
||||
url={sJoin(
|
||||
"/chronicles/",
|
||||
chronicle.attributes.slug,
|
||||
"/#chronicle-",
|
||||
chronicle.attributes.slug
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const TranslatedChroniclesList = ({
|
||||
translations,
|
||||
fallback,
|
||||
...otherProps
|
||||
}: TranslatedProps<Props, "title">): JSX.Element => {
|
||||
const [selectedTranslation] = useSmartLanguage({
|
||||
items: translations,
|
||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
||||
});
|
||||
|
||||
return <ChroniclesList title={selectedTranslation?.title ?? fallback.title} {...otherProps} />;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,325 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { atom } from "jotai";
|
||||
import { cJoin, cIf } from "helpers/className";
|
||||
import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomSetter, useAtomPair, atomPairing } from "helpers/atoms";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
||||
*/
|
||||
|
||||
const LINE_PREFIX = "root@accords-library.com:";
|
||||
|
||||
const previousLinesAtom = atomPairing(atom<string[]>([]));
|
||||
const previousCommandsAtom = atomPairing(atom<string[]>([]));
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
childrenPaths: string[];
|
||||
parentPath: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Terminal = ({
|
||||
parentPath,
|
||||
childrenPaths: propsChildrenPaths,
|
||||
content,
|
||||
}: Props): JSX.Element => {
|
||||
const [childrenPaths, setChildrenPaths] = useState(propsChildrenPaths);
|
||||
const setPlayerName = useAtomSetter(atoms.settings.playerName);
|
||||
|
||||
const [previousCommands, setPreviousCommands] = useAtomPair(previousCommandsAtom);
|
||||
const [previousLines, setPreviousLines] = useAtomPair(previousLinesAtom);
|
||||
|
||||
const [line, setLine] = useState("");
|
||||
const [displayCurrentLine, setDisplayCurrentLine] = useState(true);
|
||||
const [previousCommandIndex, setPreviousCommandIndex] = useState(0);
|
||||
const [carretPosition, setCarretPosition] = useState(0);
|
||||
const router = useRouter();
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
|
||||
|
||||
const terminalInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const terminalWindowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
router.events.on("routeChangeComplete", () => {
|
||||
terminalInputRef.current?.focus();
|
||||
setDisplayCurrentLine(true);
|
||||
});
|
||||
|
||||
const onRouteChangeRequest = useCallback(
|
||||
(newPath: string) => {
|
||||
if (newPath !== router.asPath) {
|
||||
setDisplayCurrentLine(false);
|
||||
router.push(newPath);
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const prependLine = useCallback(
|
||||
(text: string) => `${LINE_PREFIX}${router.asPath}# ${text}`,
|
||||
[router.asPath]
|
||||
);
|
||||
|
||||
type Command = {
|
||||
key: string;
|
||||
description: string;
|
||||
handle: (currentLine: string, parameters: string) => string[];
|
||||
};
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const result: Command[] = [
|
||||
{
|
||||
key: "ls",
|
||||
description: "List directory contents",
|
||||
handle: (currentLine) => [
|
||||
...previousLines,
|
||||
prependLine(currentLine),
|
||||
childrenPaths.join(" "),
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
key: "clear",
|
||||
description: "Clear the terminal screen",
|
||||
handle: () => [],
|
||||
},
|
||||
|
||||
{
|
||||
key: "cat",
|
||||
description: "Concatenate files and print on the standard output",
|
||||
handle: (currentLine) => [
|
||||
...previousLines,
|
||||
prependLine(currentLine),
|
||||
isDefinedAndNotEmpty(content) ? `\n${content}\n` : `-bash: cat: Nothing to display`,
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
key: "reboot",
|
||||
description: "Reboot the machine",
|
||||
handle: () => {
|
||||
setPlayerName("");
|
||||
return [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: "rm",
|
||||
description: "Remove files or directories",
|
||||
handle: (currentLine, parameters) => {
|
||||
if (parameters.startsWith("-r ")) {
|
||||
const folder = parameters.slice("-r ".length);
|
||||
if (childrenPaths.includes(folder)) {
|
||||
setChildrenPaths((current) => current.filter((path) => path !== folder));
|
||||
return [...previousLines, prependLine(currentLine)];
|
||||
} else if (folder === "*") {
|
||||
setChildrenPaths([]);
|
||||
return [...previousLines, prependLine(currentLine)];
|
||||
} else if (folder === "") {
|
||||
return [
|
||||
...previousLines,
|
||||
prependLine(currentLine),
|
||||
`rm: missing operand\nTry 'rm -r <path>' to remove a folder`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
...previousLines,
|
||||
prependLine(currentLine),
|
||||
`rm: cannot remove '${folder}': No such file or directory`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
...previousLines,
|
||||
prependLine(currentLine),
|
||||
`rm: missing operand\nTry 'rm -r <path>' to remove a folder`,
|
||||
];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: "help",
|
||||
description: "Display this list",
|
||||
handle: (currentLine) => [
|
||||
...previousLines,
|
||||
prependLine(currentLine),
|
||||
`
|
||||
GNU bash, version 5.1.4(1)-release (x86_64-pc-linux-gnu)
|
||||
These shell commands are defined internally. Type 'help' to see this list.
|
||||
|
||||
${result.map((command) => `${command.key}: ${command.description}`).join("\n")}
|
||||
|
||||
`,
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
key: "cd",
|
||||
description: "Change the shell working directory",
|
||||
handle: (currentLine, parameters) => {
|
||||
const newLines = [];
|
||||
switch (parameters) {
|
||||
case "..": {
|
||||
onRouteChangeRequest(parentPath);
|
||||
break;
|
||||
}
|
||||
case "/": {
|
||||
onRouteChangeRequest("/");
|
||||
break;
|
||||
}
|
||||
case ".": {
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (childrenPaths.includes(parameters)) {
|
||||
onRouteChangeRequest(`${router.asPath === "/" ? "" : router.asPath}/${parameters}`);
|
||||
} else {
|
||||
newLines.push(`-bash: cd: ${parameters}: No such file or directory`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [...previousLines, prependLine(currentLine), ...newLines];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
...result,
|
||||
{
|
||||
key: "",
|
||||
description: "Unhandled command",
|
||||
handle: (currentLine) => [
|
||||
...previousLines,
|
||||
prependLine(currentLine),
|
||||
`-bash: ${currentLine}: command not found`,
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [
|
||||
childrenPaths,
|
||||
parentPath,
|
||||
content,
|
||||
onRouteChangeRequest,
|
||||
prependLine,
|
||||
previousLines,
|
||||
router.asPath,
|
||||
setPlayerName,
|
||||
]);
|
||||
|
||||
const onNewLine = useCallback(
|
||||
(newLine: string) => {
|
||||
for (const command of commands) {
|
||||
if (newLine.startsWith(command.key)) {
|
||||
setPreviousLines(command.handle(newLine, newLine.slice(command.key.length + 1)));
|
||||
setPreviousCommands([newLine, ...previousCommands]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[commands, previousCommands, setPreviousCommands, setPreviousLines]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalWindowRef.current) {
|
||||
terminalWindowRef.current.scrollTo({
|
||||
top: terminalWindowRef.current.scrollHeight,
|
||||
});
|
||||
}
|
||||
}, [line]);
|
||||
|
||||
return (
|
||||
<div className={cJoin("h-screen overflow-hidden bg-light set-theme-font-standard")}>
|
||||
<div
|
||||
ref={terminalWindowRef}
|
||||
className="h-full overflow-scroll scroll-auto p-6 scrollbar-none">
|
||||
{previousLines.map((previousLine, index) => (
|
||||
<p key={index} className="whitespace-pre-line font-realmono">
|
||||
{previousLine}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
className="absolute -left-6 -right-6 -top-1 w-screen rounded-none opacity-0"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
placeholder="placeholder"
|
||||
ref={terminalInputRef}
|
||||
value={line}
|
||||
onSelect={() => {
|
||||
if (terminalInputRef.current) {
|
||||
setCarretPosition(terminalInputRef.current.selectionStart);
|
||||
terminalInputRef.current.selectionEnd = terminalInputRef.current.selectionStart;
|
||||
}
|
||||
}}
|
||||
onBlur={() => setIsTextAreaFocused(false)}
|
||||
onFocus={() => setIsTextAreaFocused(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
let newPreviousCommandIndex = previousCommandIndex;
|
||||
if (previousCommandIndex < previousCommands.length - 1) {
|
||||
newPreviousCommandIndex += 1;
|
||||
}
|
||||
setPreviousCommandIndex(newPreviousCommandIndex);
|
||||
const previousCommand = previousCommands[newPreviousCommandIndex];
|
||||
if (isDefined(previousCommand)) {
|
||||
setLine(previousCommand);
|
||||
setCarretPosition(previousCommand.length);
|
||||
}
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
let newPreviousCommandIndex = previousCommandIndex;
|
||||
if (previousCommandIndex > 0) {
|
||||
newPreviousCommandIndex -= 1;
|
||||
}
|
||||
setPreviousCommandIndex(newPreviousCommandIndex);
|
||||
const previousCommand = previousCommands[newPreviousCommandIndex];
|
||||
if (isDefined(previousCommand)) {
|
||||
setLine(previousCommand);
|
||||
setCarretPosition(previousCommand.length);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onInput={() => {
|
||||
if (terminalInputRef.current) {
|
||||
if (terminalInputRef.current.value.includes("\n")) {
|
||||
setLine("");
|
||||
onNewLine(line);
|
||||
} else {
|
||||
setLine(terminalInputRef.current.value);
|
||||
}
|
||||
setCarretPosition(terminalInputRef.current.selectionStart);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{displayCurrentLine && (
|
||||
<p className="whitespace-normal font-realmono">
|
||||
{prependLine("")}
|
||||
{line.slice(0, carretPosition)}
|
||||
<span
|
||||
className={cJoin(
|
||||
"whitespace-pre font-realmono",
|
||||
cIf(isTextAreaFocused, "animate-carret border-b-2 border-black")
|
||||
)}>
|
||||
{line[carretPosition] ?? " "}
|
||||
</span>
|
||||
{line.slice(carretPosition + 1)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
import { cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ────────────────────────────────────────╯ COMPONENT ╰──────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const ColoredSvg = ({ src, className }: Props): JSX.Element => (
|
||||
<div
|
||||
className={cJoin(
|
||||
`transition-colors ![mask-position:center] ![mask-repeat:no-repeat] ![mask-size:contain]`,
|
||||
className
|
||||
)}
|
||||
style={{ mask: `url('${src}')`, WebkitMask: `url('${src}')` }}
|
||||
/>
|
||||
);
|
|
@ -0,0 +1,49 @@
|
|||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomGetter } from "helpers/atoms";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
width?: ContentPanelWidthSizes;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export enum ContentPanelWidthSizes {
|
||||
Default = "default",
|
||||
Large = "large",
|
||||
Full = "full",
|
||||
}
|
||||
|
||||
const contentPanelWidthSizesToClassName: Record<ContentPanelWidthSizes, string> = {
|
||||
default: "max-w-2xl",
|
||||
large: "max-w-4xl",
|
||||
full: "w-full",
|
||||
};
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const ContentPanel = ({
|
||||
width = ContentPanelWidthSizes.Default,
|
||||
children,
|
||||
className,
|
||||
}: Props): JSX.Element => {
|
||||
const isContentPanelAtLeast3xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast3xl);
|
||||
return (
|
||||
<div className="grid h-full">
|
||||
<main
|
||||
className={cJoin(
|
||||
"relative justify-self-center",
|
||||
cIf(isContentPanelAtLeast3xl, "px-10 pb-32 pt-20", "px-4 pb-20 pt-10"),
|
||||
contentPanelWidthSizesToClassName[width],
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,61 @@
|
|||
import { MouseEventHandler, useState } from "react";
|
||||
import { cJoin, cIf } from "helpers/className";
|
||||
import { Link } from "components/Inputs/Link";
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
border?: boolean;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onFocusChanged?: (isFocused: boolean) => void;
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const DownPressable = ({
|
||||
href,
|
||||
border = false,
|
||||
active = false,
|
||||
disabled = false,
|
||||
children,
|
||||
className,
|
||||
onFocusChanged,
|
||||
onClick,
|
||||
}: Props): JSX.Element => {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
onFocusChanged={(focus) => {
|
||||
setFocused(focus);
|
||||
onFocusChanged?.(focus);
|
||||
}}
|
||||
className={cJoin(
|
||||
`rounded-2xl p-4 transition-all`,
|
||||
cIf(border, "outline outline-2 -outline-offset-2 outline-mid"),
|
||||
cIf(active, "!bg-mid shadow-inner-sm outline-transparent shadow-shade"),
|
||||
cIf(
|
||||
disabled,
|
||||
"cursor-not-allowed select-none opacity-50 grayscale",
|
||||
cJoin(
|
||||
"cursor-pointer hover:bg-mid hover:shadow-inner-sm hover:shadow-shade",
|
||||
cIf(isFocused, "!shadow-inner !shadow-shade"),
|
||||
cIf(border, "hover:outline-transparent")
|
||||
)
|
||||
),
|
||||
className
|
||||
)}
|
||||
disabled={disabled}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import { cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const InsetBox = ({ id, className, children }: Props): JSX.Element => (
|
||||
<div
|
||||
id={id}
|
||||
className={cJoin("w-full rounded-xl bg-mid p-8 shadow-inner-sm shadow-shade", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,79 @@
|
|||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { Ico } from "components/Ico";
|
||||
import { PageSelector } from "components/Inputs/PageSelector";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { isUndefined } from "helpers/asserts";
|
||||
import { useAtomGetter } from "helpers/atoms";
|
||||
import { useFormat } from "hooks/useFormat";
|
||||
import { useScrollTopOnChange } from "hooks/useScrollOnChange";
|
||||
import { Ids } from "types/ids";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
page: number;
|
||||
onPageChange: (newPage: number) => void;
|
||||
totalNumberOfPages: number | null | undefined;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Paginator = ({
|
||||
page,
|
||||
onPageChange,
|
||||
totalNumberOfPages,
|
||||
children,
|
||||
}: Props): JSX.Element => {
|
||||
useScrollTopOnChange(Ids.ContentPanel, [page]);
|
||||
useHotkeys("left", () => onPageChange(page - 1), { enabled: page > 1 }, [page]);
|
||||
useHotkeys("right", () => onPageChange(page + 1), { enabled: page < (totalNumberOfPages ?? 0) }, [
|
||||
page,
|
||||
]);
|
||||
|
||||
if (totalNumberOfPages === 0) return <DefaultRenderWhenEmpty />;
|
||||
if (isUndefined(totalNumberOfPages) || totalNumberOfPages < 2) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageSelector
|
||||
page={page}
|
||||
onChange={onPageChange}
|
||||
pagesCount={totalNumberOfPages}
|
||||
className="mb-12"
|
||||
/>
|
||||
{children}
|
||||
<PageSelector
|
||||
page={page}
|
||||
onChange={onPageChange}
|
||||
pagesCount={totalNumberOfPages}
|
||||
className="mt-12"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
const DefaultRenderWhenEmpty = () => {
|
||||
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
|
||||
const { format } = useFormat();
|
||||
|
||||
return (
|
||||
<div className="grid h-full place-content-center">
|
||||
<div
|
||||
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
|
||||
border-dark p-8 text-dark opacity-40">
|
||||
{is3ColumnsLayout && <Ico icon="chevron_left" className="!text-[300%]" />}
|
||||
<p className="max-w-xs text-2xl">{format("no_results_message")}</p>
|
||||
{!is3ColumnsLayout && <Ico icon="chevron_right" className="!text-[300%]" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,102 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomGetter, useAtomSetter } from "helpers/atoms";
|
||||
import { Button } from "components/Inputs/Button";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
onOpen?: () => void;
|
||||
onCloseRequest?: () => void;
|
||||
isVisible: boolean;
|
||||
children: React.ReactNode;
|
||||
fillViewport?: boolean;
|
||||
padding?: boolean;
|
||||
withCloseButton?: boolean;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Popup = ({
|
||||
onOpen,
|
||||
onCloseRequest,
|
||||
isVisible,
|
||||
children,
|
||||
fillViewport,
|
||||
padding = true,
|
||||
withCloseButton = true,
|
||||
}: Props): JSX.Element => {
|
||||
const setMenuGesturesEnabled = useAtomSetter(atoms.layout.menuGesturesEnabled);
|
||||
const [isHidden, setHidden] = useState(!isVisible);
|
||||
const [isActuallyVisible, setActuallyVisible] = useState(isVisible && !isHidden);
|
||||
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
||||
|
||||
useHotkeys("escape", () => onCloseRequest?.(), { enabled: isVisible }, [onCloseRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
setMenuGesturesEnabled(!isVisible);
|
||||
}, [isVisible, setMenuGesturesEnabled]);
|
||||
|
||||
// Used to unload the component if not visible
|
||||
useEffect(() => {
|
||||
const timeouts: NodeJS.Timeout[] = [];
|
||||
if (isVisible) {
|
||||
setHidden(false);
|
||||
// We delay the visiblity of the element so that the opening animation is played
|
||||
timeouts.push(
|
||||
setTimeout(() => {
|
||||
setActuallyVisible(true);
|
||||
onOpen?.();
|
||||
}, 100)
|
||||
);
|
||||
} else {
|
||||
setActuallyVisible(false);
|
||||
timeouts.push(setTimeout(() => setHidden(true), 600));
|
||||
}
|
||||
return () => timeouts.forEach(clearTimeout);
|
||||
}, [isVisible, onOpen]);
|
||||
|
||||
return isHidden ? (
|
||||
<></>
|
||||
) : (
|
||||
<div
|
||||
className={cJoin(
|
||||
"fixed inset-0 z-50 grid place-content-center transition-filter duration-500",
|
||||
cIf(!isActuallyVisible, "pointer-events-none touch-none"),
|
||||
cIf(isActuallyVisible && !isPerfModeEnabled, "backdrop-blur")
|
||||
)}>
|
||||
<div
|
||||
className={cJoin(
|
||||
"fixed inset-0 transition-colors duration-500",
|
||||
cIf(isActuallyVisible, "bg-shade/50", "bg-shade/0")
|
||||
)}
|
||||
onClick={onCloseRequest}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cJoin(
|
||||
`grid place-items-center gap-4 rounded-lg bg-light shadow-2xl transition-transform
|
||||
shadow-shade`,
|
||||
cIf(padding, "p-10"),
|
||||
cIf(isActuallyVisible, "scale-100", "scale-0"),
|
||||
cIf(
|
||||
fillViewport,
|
||||
"absolute inset-10 content-start overflow-scroll",
|
||||
"relative max-h-[80vh] overflow-y-auto"
|
||||
)
|
||||
)}>
|
||||
{withCloseButton && (
|
||||
<div className="absolute right-6 top-6">
|
||||
<Button icon="close" onClick={onCloseRequest} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomGetter } from "helpers/atoms";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const SubPanel = ({ children }: Props): JSX.Element => {
|
||||
const isSubPanelAtLeastXs = useAtomGetter(atoms.containerQueries.isSubPanelAtLeastXs);
|
||||
return (
|
||||
<div
|
||||
className={cJoin(
|
||||
"grid gap-y-2 text-center",
|
||||
cIf(isSubPanelAtLeastXs, "px-10 pb-20 pt-10", "p-4")
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,58 @@
|
|||
import { MouseEventHandler, useState } from "react";
|
||||
import { Link } from "components/Inputs/Link";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { useAtomGetter } from "helpers/atoms";
|
||||
import { atoms } from "contexts/atoms";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
noBackground?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const UpPressable = ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
disabled = false,
|
||||
noBackground = false,
|
||||
onClick,
|
||||
}: Props): JSX.Element => {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onFocusChanged={setFocused}
|
||||
onClick={onClick}
|
||||
className={cJoin(
|
||||
"transition-all duration-300 !shadow-shade",
|
||||
cIf(isPerfModeEnabled, "shadow-lg", "drop-shadow-lg"),
|
||||
cIf(!noBackground, "overflow-hidden rounded-md bg-highlight"),
|
||||
cIf(
|
||||
disabled,
|
||||
"cursor-not-allowed opacity-50 grayscale",
|
||||
cJoin(
|
||||
"cursor-pointer hover:scale-102",
|
||||
cIf(isPerfModeEnabled, "hover:shadow-xl", "hover:drop-shadow-xl"),
|
||||
cIf(isFocused, "hover:scale-105 hover:duration-100")
|
||||
)
|
||||
),
|
||||
className
|
||||
)}
|
||||
disabled={disabled}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,48 @@
|
|||
import { useCallback } from "react";
|
||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||
import { TranslatedProps } from "types/TranslatedProps";
|
||||
import { UpPressable } from "components/Containers/UpPressable";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface PreviewFolderProps {
|
||||
href: string;
|
||||
title?: string | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const PreviewFolder = ({ href, title, disabled }: PreviewFolderProps): JSX.Element => (
|
||||
<UpPressable href={href} disabled={disabled}>
|
||||
<div
|
||||
className={cJoin(
|
||||
`flex w-full cursor-pointer flex-row place-content-center place-items-center gap-4
|
||||
p-6`,
|
||||
cIf(disabled, "pointer-events-none touch-none select-none")
|
||||
)}>
|
||||
{title && <p className="text-center font-headers text-lg font-bold leading-none">{title}</p>}
|
||||
</div>
|
||||
</UpPressable>
|
||||
);
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const TranslatedPreviewFolder = ({
|
||||
translations,
|
||||
fallback,
|
||||
...otherProps
|
||||
}: TranslatedProps<PreviewFolderProps, "title">): JSX.Element => {
|
||||
const [selectedTranslation] = useSmartLanguage({
|
||||
items: translations,
|
||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
||||
});
|
||||
return <PreviewFolder title={selectedTranslation?.title ?? fallback.title} {...otherProps} />;
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -1,13 +1,16 @@
|
|||
import { Immutable } from "helpers/types";
|
||||
import { cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HorizontalLine(props: Immutable<Props>): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={`h-0 w-full my-8 border-t-[3px] border-dotted border-black ${props.className}`}
|
||||
></div>
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const HorizontalLine = ({ className }: Props): JSX.Element => (
|
||||
<div className={cJoin("my-8 h-0 w-full border-t-2 border-dotted border-black", className)} />
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import { MouseEventHandler } from "react";
|
||||
import { MaterialSymbol } from "material-symbols";
|
||||
import { cJoin } from "helpers/className";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomGetter } from "helpers/atoms";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onClick?: MouseEventHandler<HTMLSpanElement> | undefined;
|
||||
icon: MaterialSymbol;
|
||||
isFilled?: boolean;
|
||||
weight?: number;
|
||||
opticalSize?: number;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Ico = ({
|
||||
onClick,
|
||||
icon,
|
||||
className,
|
||||
isFilled = true,
|
||||
weight = 500,
|
||||
opticalSize = 24,
|
||||
}: Props): JSX.Element => {
|
||||
const isDarkMode = useAtomGetter(atoms.settings.darkMode);
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
className={cJoin(
|
||||
`material-symbols-rounded select-none
|
||||
[font-size:inherit] [line-height:inherit]`,
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
fontVariationSettings: `'FILL' ${isFilled ? "1" : "0"}, 'wght' ${weight}, 'GRAD' ${
|
||||
isDarkMode ? "-25" : "0"
|
||||
}, 'opsz' ${opticalSize}`,
|
||||
}}>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
};
|
|
@ -1,43 +1,46 @@
|
|||
import { DetailedHTMLProps, ImgHTMLAttributes } from "react";
|
||||
import { UploadImageFragment } from "graphql/generated";
|
||||
import { getAssetURL, getImgSizesByQuality, ImageQuality } from "helpers/img";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { ImageProps } from "next/image";
|
||||
import { MouseEventHandler } from "react";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
image?: UploadImageFragment | string;
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ────────────────────────────────────────╯ COMPONENT ╰──────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props
|
||||
extends Omit<DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, "src"> {
|
||||
src: UploadImageFragment | string;
|
||||
quality?: ImageQuality;
|
||||
alt?: ImageProps["alt"];
|
||||
onClick?: MouseEventHandler<HTMLImageElement>;
|
||||
sizeMultiplicator?: number;
|
||||
}
|
||||
|
||||
export function Img(props: Immutable<Props>): JSX.Element {
|
||||
const {
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Img = ({
|
||||
className,
|
||||
image,
|
||||
src: propsSrc,
|
||||
quality = ImageQuality.Small,
|
||||
alt,
|
||||
onClick,
|
||||
} = props;
|
||||
loading = "lazy",
|
||||
height,
|
||||
width,
|
||||
...otherProps
|
||||
}: Props): JSX.Element => {
|
||||
const src = typeof propsSrc === "string" ? propsSrc : getAssetURL(propsSrc.url, quality);
|
||||
const size =
|
||||
typeof propsSrc === "string"
|
||||
? { width, height }
|
||||
: getImgSizesByQuality(propsSrc.width ?? 0, propsSrc.height ?? 0, quality);
|
||||
|
||||
if (typeof image === "string") {
|
||||
return (
|
||||
<img className={className} src={image} alt={alt ?? ""} loading="lazy" />
|
||||
);
|
||||
} else if (image?.width && image.height) {
|
||||
const imgSize = getImgSizesByQuality(image.width, image.height, quality);
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
src={getAssetURL(image.url, quality)}
|
||||
alt={alt ?? image.alternativeText ?? ""}
|
||||
width={imgSize.width}
|
||||
height={imgSize.height}
|
||||
loading="lazy"
|
||||
onClick={onClick}
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading={loading}
|
||||
height={size.height}
|
||||
width={size.width}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,80 +1,118 @@
|
|||
import { Immutable } from "helpers/types";
|
||||
import { useRouter } from "next/router";
|
||||
import { MouseEventHandler } from "react";
|
||||
import { MouseEventHandler, useCallback } from "react";
|
||||
import { MaterialSymbol } from "material-symbols";
|
||||
import { Link } from "./Link";
|
||||
import { Ico } from "components/Ico";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
import { TranslatedProps } from "types/TranslatedProps";
|
||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
className?: string;
|
||||
href?: string;
|
||||
children: React.ReactNode;
|
||||
active?: boolean;
|
||||
locale?: string;
|
||||
target?: "_blank";
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
icon?: MaterialSymbol;
|
||||
text?: string | null | undefined;
|
||||
alwaysNewTab?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onMouseUp?: MouseEventHandler<HTMLButtonElement>;
|
||||
draggable?: boolean;
|
||||
badgeNumber?: number;
|
||||
disabled?: boolean;
|
||||
size?: "normal" | "small";
|
||||
type?: "button" | "reset" | "submit";
|
||||
}
|
||||
|
||||
export function Button(props: Immutable<Props>): JSX.Element {
|
||||
const {
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Button = ({
|
||||
draggable,
|
||||
id,
|
||||
onClick,
|
||||
active,
|
||||
onMouseUp,
|
||||
active = false,
|
||||
className,
|
||||
children,
|
||||
target,
|
||||
icon,
|
||||
text,
|
||||
href,
|
||||
locale,
|
||||
alwaysNewTab = false,
|
||||
badgeNumber,
|
||||
} = props;
|
||||
const router = useRouter();
|
||||
|
||||
const button = (
|
||||
<div
|
||||
disabled,
|
||||
type,
|
||||
size = "normal",
|
||||
}: Props): JSX.Element => (
|
||||
<Link href={href} alwaysNewTab={alwaysNewTab} disabled={disabled}>
|
||||
<div className="relative">
|
||||
<button
|
||||
type={type}
|
||||
draggable={draggable}
|
||||
id={id}
|
||||
onClick={onClick}
|
||||
className={`grid place-content-center place-items-center border-[1px]
|
||||
border-dark text-dark rounded-full px-4 pt-[0.4rem] pb-[0.5rem]
|
||||
transition-all select-none hover:[--opacityBadge:0] --opacityBadge:100 ${className} ${
|
||||
active
|
||||
? "text-light bg-black drop-shadow-black-lg !border-black cursor-not-allowed"
|
||||
: `cursor-pointer hover:text-light hover:bg-dark hover:drop-shadow-shade-lg
|
||||
active:bg-black active:text-light active:drop-shadow-black-lg active:border-black`
|
||||
}`}
|
||||
>
|
||||
{badgeNumber && (
|
||||
disabled={disabled}
|
||||
onClick={(event) => onClick?.(event)}
|
||||
onMouseUp={onMouseUp}
|
||||
onFocus={(event) => event.target.blur()}
|
||||
className={cJoin(
|
||||
`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 && !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:shadow-lg active:hover:shadow-black`
|
||||
),
|
||||
className
|
||||
)}>
|
||||
{isDefined(badgeNumber) && (
|
||||
<div
|
||||
className="opacity-[var(--opacityBadge)] transition-opacity grid place-items-center
|
||||
absolute -top-3 -right-2 bg-dark w-8 h-8 text-light font-bold rounded-full"
|
||||
>
|
||||
{badgeNumber}
|
||||
className={cJoin(
|
||||
`absolute grid place-items-center rounded-full bg-dark
|
||||
font-bold text-light transition-opacity group-hover:opacity-0`,
|
||||
cIf(size === "small", "-right-2 -top-2 h-5 w-5", "-right-2 -top-3 h-8 w-8")
|
||||
)}>
|
||||
<p className="-translate-y-[0.05em]">{badgeNumber}</p>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{isDefinedAndNotEmpty(icon) && (
|
||||
<Ico
|
||||
className="![font-size:150%] ![line-height:0.66]"
|
||||
icon={icon}
|
||||
isFilled={active}
|
||||
opticalSize={size === "normal" ? 24 : 20}
|
||||
weight={size === "normal" ? 500 : 800}
|
||||
/>
|
||||
)}
|
||||
{isDefinedAndNotEmpty(text) && (
|
||||
<p className="line-clamp-1 -translate-y-[0.05em] text-center leading-5">{text}</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (target) {
|
||||
return (
|
||||
<a href={href} target={target} rel="noreferrer">
|
||||
<div className="relative">{button}</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onClick={() => {
|
||||
if (href || locale)
|
||||
router.push(href ?? router.asPath, href, {
|
||||
locale: locale,
|
||||
export const TranslatedButton = ({
|
||||
translations,
|
||||
fallback,
|
||||
...otherProps
|
||||
}: TranslatedProps<Props, "text">): JSX.Element => {
|
||||
const [selectedTranslation] = useSmartLanguage({
|
||||
items: translations,
|
||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Button text={selectedTranslation?.text ?? fallback.text} {...otherProps} />;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
import type { Placement } from "tippy.js";
|
||||
import { Button } from "./Button";
|
||||
import { ToolTip } from "components/ToolTip";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { ConditionalWrapper, Wrapper } from "helpers/component";
|
||||
import { isDefined } from "helpers/asserts";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
type ButtonProps = Parameters<typeof Button>[0];
|
||||
|
||||
export interface ButtonGroupProps {
|
||||
className?: string;
|
||||
vertical?: boolean;
|
||||
size?: ButtonProps["size"];
|
||||
buttonsProps: (Omit<ButtonProps, "size"> & {
|
||||
visible?: boolean;
|
||||
tooltip?: React.ReactNode | null | undefined;
|
||||
tooltipPlacement?: Placement;
|
||||
})[];
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
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={isDefined(buttonProps.tooltip)}
|
||||
wrapper={ToolTipWrapper}
|
||||
wrapperProps={{
|
||||
text: buttonProps.tooltip ?? "",
|
||||
placement: buttonProps.tooltipPlacement,
|
||||
}}>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
size={size}
|
||||
className={cJoin(
|
||||
"relative",
|
||||
cIf(
|
||||
vertical && buttonProps.active && index < buttonsProps.length - 1,
|
||||
"shadow-black/60"
|
||||
),
|
||||
cIf(buttonProps.active, "z-10", "z-0"),
|
||||
index === 0
|
||||
? firstClassName
|
||||
: index === buttonsProps.length - 1
|
||||
? lastClassName
|
||||
: middleClassName
|
||||
)}
|
||||
/>
|
||||
</ConditionalWrapper>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface ToolTipWrapperProps {
|
||||
text: React.ReactNode;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
const ToolTipWrapper = ({ text, children, placement }: ToolTipWrapperProps & Wrapper) => (
|
||||
<ToolTip content={text} placement={placement}>
|
||||
<>{children}</>
|
||||
</ToolTip>
|
||||
);
|
|
@ -1,43 +1,59 @@
|
|||
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||
import { prettyLanguage } from "helpers/formatters";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { Dispatch, Fragment, SetStateAction } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { ToolTip } from "../ToolTip";
|
||||
import { Button } from "./Button";
|
||||
import { cJoin } from "helpers/className";
|
||||
import { iterateMap } from "helpers/others";
|
||||
import { sendAnalytics } from "helpers/analytics";
|
||||
import { useFormat } from "hooks/useFormat";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
languages: AppStaticProps["languages"];
|
||||
locales: Map<string, number>;
|
||||
localesIndex: number | undefined;
|
||||
setLocalesIndex: Dispatch<SetStateAction<number | undefined>>;
|
||||
onLanguageChanged: (index: number) => void;
|
||||
size?: Parameters<typeof Button>[0]["size"];
|
||||
showBadge?: boolean;
|
||||
}
|
||||
|
||||
export function LanguageSwitcher(props: Immutable<Props>): JSX.Element {
|
||||
const { locales, className, localesIndex, setLocalesIndex } = props;
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const LanguageSwitcher = ({
|
||||
className,
|
||||
locales,
|
||||
localesIndex,
|
||||
size,
|
||||
onLanguageChanged,
|
||||
showBadge = true,
|
||||
}: Props): JSX.Element => {
|
||||
const { formatLanguage } = useFormat();
|
||||
return (
|
||||
<ToolTip
|
||||
content={
|
||||
<div className={`flex flex-col gap-2 ${className}`}>
|
||||
{[...locales].map(([locale, value], index) => (
|
||||
<div className={cJoin("flex flex-col gap-2", className)}>
|
||||
{iterateMap(locales, (locale, value, index) => (
|
||||
<Fragment key={index}>
|
||||
{locale && (
|
||||
<Button
|
||||
active={value === localesIndex}
|
||||
onClick={() => setLocalesIndex(value)}
|
||||
>
|
||||
{prettyLanguage(locale, props.languages)}
|
||||
</Button>
|
||||
)}
|
||||
onClick={() => {
|
||||
onLanguageChanged(value);
|
||||
sendAnalytics("Language Switcher", `Switch language (${locale})`);
|
||||
}}
|
||||
text={formatLanguage(locale)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button badgeNumber={locales.size > 1 ? locales.size : undefined}>
|
||||
<span className="material-icons">translate</span>
|
||||
</Button>
|
||||
}>
|
||||
<Button
|
||||
badgeNumber={showBadge && locales.size > 1 ? locales.size : undefined}
|
||||
icon="translate"
|
||||
size={size}
|
||||
/>
|
||||
</ToolTip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
import React, { MouseEventHandler } from "react";
|
||||
import NextLink from "next/link";
|
||||
import { ConditionalWrapper, Wrapper } from "helpers/component";
|
||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
href: string | null | undefined;
|
||||
className?: string;
|
||||
alwaysNewTab?: boolean;
|
||||
children: React.ReactNode;
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
onFocusChanged?: (isFocused: boolean) => void;
|
||||
disabled?: boolean;
|
||||
linkStyled?: boolean;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Link = ({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
alwaysNewTab,
|
||||
disabled,
|
||||
linkStyled = false,
|
||||
onClick,
|
||||
onFocusChanged,
|
||||
}: Props): JSX.Element => (
|
||||
<ConditionalWrapper
|
||||
isWrapping={isDefinedAndNotEmpty(href) && !disabled}
|
||||
wrapperProps={{
|
||||
href: href ?? "",
|
||||
alwaysNewTab,
|
||||
onClick,
|
||||
onFocusChanged,
|
||||
className: cJoin(
|
||||
cIf(
|
||||
linkStyled,
|
||||
`underline decoration-dark decoration-dotted underline-offset-2 transition-colors
|
||||
hover:text-dark`
|
||||
),
|
||||
className
|
||||
),
|
||||
}}
|
||||
wrapper={LinkWrapper}
|
||||
wrapperFalse={DisabledWrapper}
|
||||
wrapperFalseProps={{ className }}>
|
||||
{children}
|
||||
</ConditionalWrapper>
|
||||
);
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface LinkWrapperProps {
|
||||
href: string;
|
||||
className?: string;
|
||||
alwaysNewTab?: boolean;
|
||||
onFocusChanged?: (isFocused: boolean) => void;
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
const LinkWrapper = ({
|
||||
children,
|
||||
className,
|
||||
onFocusChanged,
|
||||
onClick,
|
||||
alwaysNewTab = false,
|
||||
href,
|
||||
}: LinkWrapperProps & Wrapper) => (
|
||||
<NextLink
|
||||
href={href}
|
||||
className={className}
|
||||
target={alwaysNewTab ? "_blank" : "_self"}
|
||||
replace={href.startsWith("#")}
|
||||
onClick={onClick}
|
||||
onMouseLeave={() => onFocusChanged?.(false)}
|
||||
onMouseDown={() => onFocusChanged?.(true)}
|
||||
onMouseUp={() => onFocusChanged?.(false)}>
|
||||
{children}
|
||||
</NextLink>
|
||||
);
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
interface DisabledWrapperProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DisabledWrapper = ({ children, className }: DisabledWrapperProps & Wrapper) => (
|
||||
<div className={className}>{children}</div>
|
||||
);
|
|
@ -1,34 +1,44 @@
|
|||
import { Fragment, useCallback } from "react";
|
||||
import { Ico } from "components/Ico";
|
||||
import { arrayMove } from "helpers/others";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
items: Map<string, string>;
|
||||
insertLabels?: Map<number, string | null | undefined>;
|
||||
onChange?: (items: Map<string, string>) => void;
|
||||
items: { code: string; name: string }[];
|
||||
insertLabels?: { insertAt: number; name: string }[];
|
||||
onChange?: (props: Props["items"]) => void;
|
||||
}
|
||||
|
||||
export function OrderableList(props: Immutable<Props>): JSX.Element {
|
||||
const [items, setItems] = useState<Map<string, string>>(props.items);
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
useEffect(() => {
|
||||
props.onChange?.(items);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items]);
|
||||
|
||||
function updateOrder(sourceIndex: number, targetIndex: number) {
|
||||
const newItems = arrayMove([...items], sourceIndex, targetIndex);
|
||||
setItems(new Map(newItems));
|
||||
interface InsertedLabelProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const InsertedLabel = ({ label }: InsertedLabelProps) => (
|
||||
<>{isDefinedAndNotEmpty(label) && <p>{label}</p>}</>
|
||||
);
|
||||
|
||||
export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Element => {
|
||||
const updateOrder = useCallback(
|
||||
(sourceIndex: number, targetIndex: number) => {
|
||||
onChange?.(arrayMove(items, sourceIndex, targetIndex));
|
||||
},
|
||||
[items, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{[...items].map(([key, value], index) => (
|
||||
<Fragment key={key}>
|
||||
{props.insertLabels?.get(index) && (
|
||||
<p>{props.insertLabels.get(index)}</p>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={index}>
|
||||
<InsertedLabel label={insertLabels?.[index]?.name} />
|
||||
|
||||
<div
|
||||
onDragStart={(event) => {
|
||||
const source = event.target as HTMLElement;
|
||||
|
@ -50,44 +60,37 @@ export function OrderableList(props: Immutable<Props>): JSX.Element {
|
|||
.filter((element) => element.tagName === "DIV")
|
||||
.indexOf(target)
|
||||
: -1;
|
||||
const sourceIndex = parseInt(
|
||||
event.dataTransfer.getData("text"),
|
||||
10
|
||||
);
|
||||
const sourceIndex = parseInt(event.dataTransfer.getData("text"), 10);
|
||||
updateOrder(sourceIndex, targetIndex);
|
||||
}}
|
||||
className="grid grid-cols-[auto_1fr] place-content-center
|
||||
border-[1px] transition-all hover:text-light hover:bg-dark
|
||||
hover:drop-shadow-shade-lg border-dark bg-light text-dark
|
||||
rounded-full cursor-grab select-none px-1 py-2 pr-4 gap-2"
|
||||
draggable
|
||||
>
|
||||
className="grid cursor-grab select-none grid-cols-[auto_1fr] place-content-center gap-2
|
||||
rounded-full border border-dark bg-light px-1 py-2 pr-4 text-dark transition-all
|
||||
hover:bg-dark hover:text-light hover:shadow-lg hover:shadow-shade"
|
||||
draggable>
|
||||
<div className="grid grid-rows-[.8em_.8em] place-items-center">
|
||||
{index > 0 && (
|
||||
<span
|
||||
className="material-icons cursor-pointer row-start-1"
|
||||
<Ico
|
||||
icon="arrow_drop_up"
|
||||
className="row-start-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
updateOrder(index, index - 1);
|
||||
}}
|
||||
>
|
||||
arrow_drop_up
|
||||
</span>
|
||||
/>
|
||||
)}
|
||||
{index < items.size - 1 && (
|
||||
<span
|
||||
className="material-icons cursor-pointer row-start-2"
|
||||
{index < items.length - 1 && (
|
||||
<Ico
|
||||
icon="arrow_drop_down"
|
||||
className="row-start-2 cursor-pointer"
|
||||
onClick={() => {
|
||||
updateOrder(index, index + 1);
|
||||
}}
|
||||
>
|
||||
arrow_drop_down
|
||||
</span>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{value}
|
||||
{item.name}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,36 +1,45 @@
|
|||
import { Immutable } from "helpers/types";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { ButtonGroup } from "./ButtonGroup";
|
||||
import { cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
maxPage: number;
|
||||
page: number;
|
||||
setPage: Dispatch<SetStateAction<number>>;
|
||||
pagesCount: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function PageSelector(props: Immutable<Props>): JSX.Element {
|
||||
const { page, setPage, maxPage } = props;
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
return (
|
||||
<div className={`flex place-content-center flex-row ${props.className}`}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (page > 0) setPage(page - 1);
|
||||
}}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
<span className="material-icons">navigate_before</span>
|
||||
</Button>
|
||||
<Button className="rounded-none border-x-0">{page + 1}</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (page < maxPage) setPage(page + 1);
|
||||
}}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
<span className="material-icons">navigate_next</span>
|
||||
</Button>
|
||||
</div>
|
||||
export const PageSelector = ({ page, className, pagesCount, onChange }: Props): JSX.Element => (
|
||||
<ButtonGroup
|
||||
className={cJoin("flex flex-row place-content-center", className)}
|
||||
buttonsProps={[
|
||||
{
|
||||
onClick: () => onChange(1),
|
||||
disabled: page === 1,
|
||||
icon: "first_page",
|
||||
},
|
||||
{
|
||||
onClick: () => page > 1 && onChange(page - 1),
|
||||
disabled: page === 1,
|
||||
icon: "navigate_before",
|
||||
},
|
||||
{ text: `${page} / ${pagesCount}` },
|
||||
{
|
||||
onClick: () => page < pagesCount && onChange(page + 1),
|
||||
disabled: page === pagesCount,
|
||||
icon: "navigate_next",
|
||||
},
|
||||
{
|
||||
onClick: () => onChange(pagesCount),
|
||||
disabled: page === pagesCount,
|
||||
icon: "last_page",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,66 +1,94 @@
|
|||
import { Immutable } from "helpers/types";
|
||||
import { Dispatch, Fragment, SetStateAction, useState } from "react";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { useBoolean, useOnClickOutside } from "usehooks-ts";
|
||||
import { Ico } from "components/Ico";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
setState: Dispatch<SetStateAction<number>>;
|
||||
state: number;
|
||||
value: number;
|
||||
options: string[];
|
||||
selected?: number;
|
||||
allowEmpty?: boolean;
|
||||
className?: string;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Select(props: Immutable<Props>): JSX.Element {
|
||||
const { className, state, options, allowEmpty, setState } = props;
|
||||
const [opened, setOpened] = useState(false);
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Select = ({
|
||||
className,
|
||||
value,
|
||||
options,
|
||||
allowEmpty,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: Props): JSX.Element => {
|
||||
const { value: isOpened, setFalse: setClosed, toggle: toggleOpened } = useBoolean(false);
|
||||
|
||||
const tryToggling = useCallback(() => {
|
||||
if (disabled) return;
|
||||
const optionCount = options.length + (value === -1 ? 1 : 0);
|
||||
if (optionCount > 1) toggleOpened();
|
||||
}, [disabled, options.length, value, toggleOpened]);
|
||||
|
||||
const onSelectionChanged = useCallback(
|
||||
(newIndex: number) => {
|
||||
setClosed();
|
||||
onChange(newIndex);
|
||||
},
|
||||
[onChange, setClosed]
|
||||
);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useOnClickOutside(ref, setClosed);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative text-center transition-[filter] ${
|
||||
opened && "drop-shadow-shade-lg z-10"
|
||||
} ${className}`}
|
||||
>
|
||||
ref={ref}
|
||||
className={cJoin(
|
||||
"relative text-center transition-filter",
|
||||
cIf(isOpened, "z-20 drop-shadow-lg shadow-shade"),
|
||||
className
|
||||
)}>
|
||||
<div
|
||||
className={`outline outline-mid outline-2 outline-offset-[-2px] hover:outline-[transparent]
|
||||
bg-light rounded-[1em] p-1 grid grid-flow-col grid-cols-[1fr_auto_auto] place-items-center
|
||||
cursor-pointer hover:bg-mid transition-all ${
|
||||
opened && "outline-[transparent] rounded-b-none bg-highlight"
|
||||
}`}
|
||||
>
|
||||
<p onClick={() => setOpened(!opened)} className="w-full">
|
||||
{state === -1 ? "—" : options[state]}
|
||||
className={cJoin(
|
||||
`grid cursor-pointer select-none grid-flow-col grid-cols-[1fr_auto_auto]
|
||||
place-items-center rounded-3xl p-1 outline outline-1 -outline-offset-1`,
|
||||
cIf(isOpened, "rounded-b-none bg-highlight outline-transparent"),
|
||||
cIf(
|
||||
disabled,
|
||||
"cursor-not-allowed text-dark opacity-50 outline-dark/60 grayscale",
|
||||
"outline-mid transition-all hover:bg-mid hover:outline-transparent"
|
||||
)
|
||||
)}>
|
||||
<p onClick={tryToggling} className="w-full px-4 py-1">
|
||||
{value === -1 ? "—" : options[value]}
|
||||
</p>
|
||||
{state >= 0 && allowEmpty && (
|
||||
<span
|
||||
onClick={() => setState(-1)}
|
||||
className="material-icons !text-xs"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
{value >= 0 && allowEmpty && (
|
||||
<Ico
|
||||
icon="close"
|
||||
className="!text-xs"
|
||||
onClick={() => !disabled && onSelectionChanged(-1)}
|
||||
/>
|
||||
)}
|
||||
<span onClick={() => setOpened(!opened)} className="material-icons">
|
||||
{opened ? "arrow_drop_up" : "arrow_drop_down"}
|
||||
</span>
|
||||
<Ico onClick={tryToggling} icon={isOpened ? "arrow_drop_up" : "arrow_drop_down"} />
|
||||
</div>
|
||||
<div
|
||||
className={`left-0 right-0 rounded-b-[1em] ${
|
||||
opened ? "absolute" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<div className={cJoin("left-0 right-0 rounded-b-[1em]", cIf(isOpened, "absolute", "hidden"))}>
|
||||
{options.map((option, index) => (
|
||||
<Fragment key={index}>
|
||||
{index !== state && (
|
||||
{index !== value && (
|
||||
<div
|
||||
className={` ${
|
||||
opened ? "bg-highlight" : "bg-light"
|
||||
} hover:bg-mid transition-colors
|
||||
cursor-pointer p-1 last-of-type:rounded-b-[1em]`}
|
||||
className={cJoin(
|
||||
"cursor-pointer p-1 transition-colors last-of-type:rounded-b-[1em] hover:bg-mid",
|
||||
cIf(isOpened, "bg-highlight", "bg-light")
|
||||
)}
|
||||
id={option}
|
||||
onClick={() => {
|
||||
setOpened(false);
|
||||
setState(index);
|
||||
}}
|
||||
>
|
||||
onClick={() => onSelectionChanged(index)}>
|
||||
{option}
|
||||
</div>
|
||||
)}
|
||||
|
@ -69,4 +97,4 @@ export function Select(props: Immutable<Props>): JSX.Element {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,31 +1,50 @@
|
|||
import { Immutable } from "helpers/types";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
setState: Dispatch<SetStateAction<boolean>>;
|
||||
state: boolean;
|
||||
onClick: () => void;
|
||||
value: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Switch(props: Immutable<Props>): JSX.Element {
|
||||
const { state, setState, className, disabled } = props;
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Switch = ({ value, onClick, className, disabled = false }: Props): JSX.Element => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className={`h-6 w-12 rounded-full border-2 border-mid grid
|
||||
transition-colors relative ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} ${className} ${state ? "bg-mid" : "bg-light"}`}
|
||||
className={cJoin(
|
||||
`relative grid h-6 w-12 content-center rounded-full border-mid outline
|
||||
outline-1 -outline-offset-1 transition-colors`,
|
||||
cIf(value, "border-none shadow-inner-sm shadow-shade"),
|
||||
cIf(disabled, "cursor-not-allowed opacity-50 grayscale", "cursor-pointer outline-mid"),
|
||||
cIf(
|
||||
disabled,
|
||||
cIf(value, "bg-dark/40 outline-transparent", "outline-dark/60"),
|
||||
cIf(value, "bg-mid outline-transparent")
|
||||
),
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) setState(!state);
|
||||
if (!disabled) onClick();
|
||||
}}
|
||||
>
|
||||
onPointerDown={() => !disabled && setIsFocused(true)}
|
||||
onPointerOut={() => setIsFocused(false)}
|
||||
onPointerLeave={() => setIsFocused(false)}
|
||||
onPointerUp={() => setIsFocused(false)}>
|
||||
<div
|
||||
className={`bg-dark aspect-square rounded-full absolute
|
||||
top-0 bottom-0 left-0 transition-transform ${
|
||||
state && "translate-x-[115%]"
|
||||
}`}
|
||||
></div>
|
||||
className={cJoin(
|
||||
"pointer-events-none ml-1 h-4 w-4 touch-none rounded-full bg-dark transition-transform",
|
||||
cIf(value, "translate-x-6"),
|
||||
cIf(isFocused, cIf(value, "translate-x-5", "translate-x-1"))
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import { forwardRef } from "react";
|
||||
import { Ico } from "components/Ico";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (newValue: string) => void;
|
||||
className?: string;
|
||||
name?: string;
|
||||
placeholder?: string | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
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}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{isDefinedAndNotEmpty(value) && (
|
||||
<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"
|
||||
onClick={() => !disabled && onChange("")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
TextInput.displayName = "TextInput";
|
|
@ -0,0 +1,23 @@
|
|||
import { cIf, cJoin } from "helpers/className";
|
||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
label: string | null | undefined;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const WithLabel = ({ label, children }: Props): JSX.Element => (
|
||||
<div className="flex flex-row place-content-between place-items-center gap-2">
|
||||
{isDefinedAndNotEmpty(label) && (
|
||||
<p className={cJoin("text-left", cIf(label.length < 10, "flex-shrink-0"))}>{label}:</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
|
@ -1,18 +0,0 @@
|
|||
import { Immutable } from "helpers/types";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function InsetBox(props: Immutable<Props>): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
id={props.id}
|
||||
className={`w-full shadow-inner-sm shadow-shade bg-mid rounded-xl p-8 ${props.className}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
import { Chip } from "components/Chip";
|
||||
import { Button } from "components/Inputs/Button";
|
||||
import { GetLibraryItemQuery } from "graphql/generated";
|
||||
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||
import { prettyinlineTitle, prettySlug } from "helpers/formatters";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
content: NonNullable<
|
||||
NonNullable<
|
||||
NonNullable<
|
||||
GetLibraryItemQuery["libraryItems"]
|
||||
>["data"][number]["attributes"]
|
||||
>["contents"]
|
||||
>["data"][number];
|
||||
parentSlug: string;
|
||||
langui: AppStaticProps["langui"];
|
||||
}
|
||||
|
||||
export function ContentLine(props: Immutable<Props>): JSX.Element {
|
||||
const { content, langui, parentSlug } = props;
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
if (content.attributes) {
|
||||
return (
|
||||
<div
|
||||
className={`grid gap-2 px-4 rounded-lg ${
|
||||
opened && "bg-mid shadow-inner-sm shadow-shade h-auto py-3 my-2"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="grid gap-4 place-items-center
|
||||
grid-cols-[auto_auto_1fr_auto_12ch] thin:grid-cols-[auto_auto_1fr_auto]"
|
||||
>
|
||||
<a>
|
||||
<h3 className="cursor-pointer" onClick={() => setOpened(!opened)}>
|
||||
{content.attributes.content?.data?.attributes?.translations?.[0]
|
||||
? prettyinlineTitle(
|
||||
content.attributes.content.data.attributes.translations[0]
|
||||
?.pre_title,
|
||||
content.attributes.content.data.attributes.translations[0]
|
||||
?.title,
|
||||
content.attributes.content.data.attributes.translations[0]
|
||||
?.subtitle
|
||||
)
|
||||
: prettySlug(content.attributes.slug, props.parentSlug)}
|
||||
</h3>
|
||||
</a>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{content.attributes.content?.data?.attributes?.categories?.data.map(
|
||||
(category) => (
|
||||
<Chip key={category.id}>{category.attributes?.short}</Chip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="border-b-2 h-4 w-full border-black border-dotted opacity-30"></p>
|
||||
<p>
|
||||
{content.attributes.range[0]?.__typename ===
|
||||
"ComponentRangePageRange"
|
||||
? content.attributes.range[0].starting_page
|
||||
: ""}
|
||||
</p>
|
||||
{content.attributes.content?.data?.attributes?.type?.data
|
||||
?.attributes && (
|
||||
<Chip className="justify-self-end thin:hidden">
|
||||
{content.attributes.content.data.attributes.type.data.attributes
|
||||
.titles &&
|
||||
content.attributes.content.data.attributes.type.data.attributes
|
||||
.titles.length > 0
|
||||
? content.attributes.content.data.attributes.type.data
|
||||
.attributes.titles[0]?.title
|
||||
: prettySlug(
|
||||
content.attributes.content.data.attributes.type.data
|
||||
.attributes.slug
|
||||
)}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`grid-flow-col place-content-start place-items-center gap-2 ${
|
||||
opened ? "grid" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<span className="material-icons text-dark">
|
||||
subdirectory_arrow_right
|
||||
</span>
|
||||
|
||||
{content.attributes.scan_set &&
|
||||
content.attributes.scan_set.length > 0 && (
|
||||
<Button
|
||||
href={`/library/${parentSlug}/scans#${content.attributes.slug}`}
|
||||
>
|
||||
{langui.view_scans}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{content.attributes.content?.data && (
|
||||
<Button
|
||||
href={`/contents/${content.attributes.content.data.attributes?.slug}`}
|
||||
>
|
||||
{langui.open_content}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{content.attributes.scan_set &&
|
||||
content.attributes.scan_set.length === 0 &&
|
||||
!content.attributes.content?.data
|
||||
? "The content is not available"
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import { Button } from "components/Inputs/Button";
|
||||
import { ToolTip } from "components/ToolTip";
|
||||
import { LibraryItemUserStatus } from "types/types";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { useLibraryItemUserStatus } from "hooks/useLibraryItemUserStatus";
|
||||
import { useFormat } from "hooks/useFormat";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
expand?: boolean;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const PreviewCardCTAs = ({ id, expand = false }: Props): JSX.Element => {
|
||||
const { libraryItemUserStatus, setLibraryItemUserStatus } = useLibraryItemUserStatus();
|
||||
const { format } = useFormat();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cJoin(
|
||||
"flex flex-row flex-wrap place-content-center place-items-center",
|
||||
cIf(expand, "gap-4", "gap-2")
|
||||
)}>
|
||||
<ToolTip content={format("want_it")} disabled={expand}>
|
||||
<Button
|
||||
icon="favorite"
|
||||
text={expand ? format("want_it") : undefined}
|
||||
active={libraryItemUserStatus[id] === LibraryItemUserStatus.Want}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setLibraryItemUserStatus((current) => {
|
||||
const newLibraryItemUserStatus = { ...current };
|
||||
newLibraryItemUserStatus[id] =
|
||||
newLibraryItemUserStatus[id] === LibraryItemUserStatus.Want
|
||||
? LibraryItemUserStatus.None
|
||||
: LibraryItemUserStatus.Want;
|
||||
return newLibraryItemUserStatus;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ToolTip>
|
||||
<ToolTip content={format("have_it")} disabled={expand}>
|
||||
<Button
|
||||
icon="back_hand"
|
||||
text={expand ? format("have_it") : undefined}
|
||||
active={libraryItemUserStatus[id] === LibraryItemUserStatus.Have}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setLibraryItemUserStatus((current) => {
|
||||
const newLibraryItemUserStatus = { ...current };
|
||||
newLibraryItemUserStatus[id] =
|
||||
newLibraryItemUserStatus[id] === LibraryItemUserStatus.Have
|
||||
? LibraryItemUserStatus.None
|
||||
: LibraryItemUserStatus.Have;
|
||||
return newLibraryItemUserStatus;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ToolTip>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,213 +0,0 @@
|
|||
import { Chip } from "components/Chip";
|
||||
import { Img } from "components/Img";
|
||||
import { Button } from "components/Inputs/Button";
|
||||
import { RecorderChip } from "components/RecorderChip";
|
||||
import { ToolTip } from "components/ToolTip";
|
||||
import { GetLibraryItemScansQuery } from "graphql/generated";
|
||||
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||
import { getAssetFilename, getAssetURL, ImageQuality } from "helpers/img";
|
||||
import { isInteger } from "helpers/numbers";
|
||||
import { getStatusDescription } from "helpers/others";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||
import { Fragment } from "react";
|
||||
|
||||
interface Props {
|
||||
openLightBox: (images: string[], index?: number) => void;
|
||||
scanSet: NonNullable<
|
||||
NonNullable<
|
||||
NonNullable<
|
||||
NonNullable<
|
||||
NonNullable<
|
||||
GetLibraryItemScansQuery["libraryItems"]
|
||||
>["data"][number]["attributes"]
|
||||
>["contents"]
|
||||
>["data"][number]["attributes"]
|
||||
>["scan_set"]
|
||||
>;
|
||||
slug: string;
|
||||
title: string;
|
||||
languages: AppStaticProps["languages"];
|
||||
langui: AppStaticProps["langui"];
|
||||
content: NonNullable<
|
||||
NonNullable<
|
||||
NonNullable<
|
||||
NonNullable<
|
||||
GetLibraryItemScansQuery["libraryItems"]
|
||||
>["data"][number]["attributes"]
|
||||
>["contents"]
|
||||
>["data"][number]["attributes"]
|
||||
>["content"];
|
||||
}
|
||||
|
||||
export function ScanSet(props: Immutable<Props>): JSX.Element {
|
||||
const { openLightBox, scanSet, slug, title, languages, langui, content } =
|
||||
props;
|
||||
|
||||
const [selectedScan, LanguageSwitcher] = useSmartLanguage({
|
||||
items: scanSet,
|
||||
languages: languages,
|
||||
languageExtractor: (item) => item.language?.data?.attributes?.code,
|
||||
transform: (item) => {
|
||||
(item as NonNullable<Props["scanSet"][number]>).pages?.data.sort(
|
||||
(a, b) => {
|
||||
if (a.attributes?.url && b.attributes?.url) {
|
||||
let aName = getAssetFilename(a.attributes.url);
|
||||
let bName = getAssetFilename(b.attributes.url);
|
||||
|
||||
/*
|
||||
* If the number is a succession of 0s, make the number
|
||||
* incrementally smaller than 0 (i.e: 00 becomes -1)
|
||||
*/
|
||||
if (aName.replaceAll("0", "").length === 0) {
|
||||
aName = (1 - aName.length).toString(10);
|
||||
}
|
||||
if (bName.replaceAll("0", "").length === 0) {
|
||||
bName = (1 - bName.length).toString(10);
|
||||
}
|
||||
|
||||
if (isInteger(aName) && isInteger(bName)) {
|
||||
return parseInt(aName, 10) - parseInt(bName, 10);
|
||||
}
|
||||
return a.attributes.url.localeCompare(b.attributes.url);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
return item;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedScan && (
|
||||
<div>
|
||||
<div
|
||||
className="flex flex-row flex-wrap place-items-center
|
||||
gap-6 text-base pt-10 first-of-type:pt-0"
|
||||
>
|
||||
<h2 id={slug} className="text-2xl">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<Chip>
|
||||
{selectedScan.language?.data?.attributes?.code ===
|
||||
selectedScan.source_language?.data?.attributes?.code
|
||||
? "Scan"
|
||||
: "Scanlation"}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-4 pb-6 place-items-center">
|
||||
{content?.data?.attributes?.slug && (
|
||||
<Button href={`/contents/${content.data.attributes.slug}`}>
|
||||
{langui.open_content}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="grid place-items-center place-content-center">
|
||||
<p className="font-headers">{langui.status}:</p>
|
||||
<ToolTip
|
||||
content={getStatusDescription(selectedScan.status, langui)}
|
||||
maxWidth={"20rem"}
|
||||
>
|
||||
<Chip>{selectedScan.status}</Chip>
|
||||
</ToolTip>
|
||||
</div>
|
||||
|
||||
{selectedScan.scanners && selectedScan.scanners.data.length > 0 && (
|
||||
<div>
|
||||
<p className="font-headers">{"Scanners"}:</p>
|
||||
<div className="grid place-items-center place-content-center gap-2">
|
||||
{selectedScan.scanners.data.map((scanner) => (
|
||||
<Fragment key={scanner.id}>
|
||||
{scanner.attributes && (
|
||||
<RecorderChip
|
||||
langui={langui}
|
||||
recorder={scanner.attributes}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScan.cleaners && selectedScan.cleaners.data.length > 0 && (
|
||||
<div>
|
||||
<p className="font-headers">{"Cleaners"}:</p>
|
||||
<div className="grid place-items-center place-content-center gap-2">
|
||||
{selectedScan.cleaners.data.map((cleaner) => (
|
||||
<Fragment key={cleaner.id}>
|
||||
{cleaner.attributes && (
|
||||
<RecorderChip
|
||||
langui={langui}
|
||||
recorder={cleaner.attributes}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScan.typesetters &&
|
||||
selectedScan.typesetters.data.length > 0 && (
|
||||
<div>
|
||||
<p className="font-headers">{"Typesetters"}:</p>
|
||||
<div className="grid place-items-center place-content-center gap-2">
|
||||
{selectedScan.typesetters.data.map((typesetter) => (
|
||||
<Fragment key={typesetter.id}>
|
||||
{typesetter.attributes && (
|
||||
<RecorderChip
|
||||
langui={langui}
|
||||
recorder={typesetter.attributes}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScan.notes && (
|
||||
<ToolTip content={selectedScan.notes}>
|
||||
<Chip>{"Notes"}</Chip>
|
||||
</ToolTip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid gap-8 items-end mobile:grid-cols-2
|
||||
desktop:grid-cols-[repeat(auto-fill,_minmax(10rem,1fr))]
|
||||
pb-12 border-b-[3px] border-dotted last-of-type:border-0"
|
||||
>
|
||||
{selectedScan.pages?.data.map((page, index) => (
|
||||
<div
|
||||
key={page.id}
|
||||
className="drop-shadow-shade-lg hover:scale-[1.02]
|
||||
cursor-pointer transition-transform"
|
||||
onClick={() => {
|
||||
const images: string[] = [];
|
||||
selectedScan.pages?.data.map((image) => {
|
||||
if (image.attributes?.url)
|
||||
images.push(
|
||||
getAssetURL(image.attributes.url, ImageQuality.Large)
|
||||
);
|
||||
});
|
||||
openLightBox(images, index);
|
||||
}}
|
||||
>
|
||||
{page.attributes && (
|
||||
<Img image={page.attributes} quality={ImageQuality.Small} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,171 +0,0 @@
|
|||
import { Chip } from "components/Chip";
|
||||
import { Img } from "components/Img";
|
||||
import { RecorderChip } from "components/RecorderChip";
|
||||
import { ToolTip } from "components/ToolTip";
|
||||
import {
|
||||
GetLibraryItemScansQuery,
|
||||
UploadImageFragment,
|
||||
} from "graphql/generated";
|
||||
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||
import { getAssetURL, ImageQuality } from "helpers/img";
|
||||
import { getStatusDescription } from "helpers/others";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||
import { Fragment } from "react";
|
||||
|
||||
interface Props {
|
||||
openLightBox: (images: string[], index?: number) => void;
|
||||
images: NonNullable<
|
||||
NonNullable<
|
||||
NonNullable<
|
||||
GetLibraryItemScansQuery["libraryItems"]
|
||||
>["data"][number]["attributes"]
|
||||
>["images"]
|
||||
>;
|
||||
languages: AppStaticProps["languages"];
|
||||
langui: AppStaticProps["langui"];
|
||||
}
|
||||
|
||||
export function ScanSetCover(props: Immutable<Props>): JSX.Element {
|
||||
const { openLightBox, images, languages, langui } = props;
|
||||
|
||||
const [selectedScan, LanguageSwitcher] = useSmartLanguage({
|
||||
items: images,
|
||||
languages: languages,
|
||||
languageExtractor: (item) => item.language?.data?.attributes?.code,
|
||||
});
|
||||
|
||||
const coverImages: UploadImageFragment[] = [];
|
||||
if (selectedScan?.obi_belt?.full?.data?.attributes)
|
||||
coverImages.push(selectedScan.obi_belt.full.data.attributes);
|
||||
if (selectedScan?.obi_belt?.inside_full?.data?.attributes)
|
||||
coverImages.push(selectedScan.obi_belt.inside_full.data.attributes);
|
||||
if (selectedScan?.dust_jacket?.full?.data?.attributes)
|
||||
coverImages.push(selectedScan.dust_jacket.full.data.attributes);
|
||||
if (selectedScan?.dust_jacket?.inside_full?.data?.attributes)
|
||||
coverImages.push(selectedScan.dust_jacket.inside_full.data.attributes);
|
||||
if (selectedScan?.cover?.full?.data?.attributes)
|
||||
coverImages.push(selectedScan.cover.full.data.attributes);
|
||||
if (selectedScan?.cover?.inside_full?.data?.attributes)
|
||||
coverImages.push(selectedScan.cover.inside_full.data.attributes);
|
||||
|
||||
if (coverImages.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{selectedScan && (
|
||||
<div>
|
||||
<div
|
||||
className="flex flex-row flex-wrap place-items-center
|
||||
gap-6 text-base pt-10 first-of-type:pt-0"
|
||||
>
|
||||
<h2 id="cover" className="text-2xl">
|
||||
{"Cover"}
|
||||
</h2>
|
||||
|
||||
<Chip>
|
||||
{selectedScan.language?.data?.attributes?.code ===
|
||||
selectedScan.source_language?.data?.attributes?.code
|
||||
? "Scan"
|
||||
: "Scanlation"}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-4 pb-6 place-items-center">
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="grid place-items-center place-content-center">
|
||||
<p className="font-headers">{langui.status}:</p>
|
||||
<ToolTip
|
||||
content={getStatusDescription(selectedScan.status, langui)}
|
||||
maxWidth={"20rem"}
|
||||
>
|
||||
<Chip>{selectedScan.status}</Chip>
|
||||
</ToolTip>
|
||||
</div>
|
||||
|
||||
{selectedScan.scanners && selectedScan.scanners.data.length > 0 && (
|
||||
<div>
|
||||
<p className="font-headers">{"Scanners"}:</p>
|
||||
<div className="grid place-items-center place-content-center gap-2">
|
||||
{selectedScan.scanners.data.map((scanner) => (
|
||||
<Fragment key={scanner.id}>
|
||||
{scanner.attributes && (
|
||||
<RecorderChip
|
||||
langui={langui}
|
||||
recorder={scanner.attributes}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScan.cleaners && selectedScan.cleaners.data.length > 0 && (
|
||||
<div>
|
||||
<p className="font-headers">{"Cleaners"}:</p>
|
||||
<div className="grid place-items-center place-content-center gap-2">
|
||||
{selectedScan.cleaners.data.map((cleaner) => (
|
||||
<Fragment key={cleaner.id}>
|
||||
{cleaner.attributes && (
|
||||
<RecorderChip
|
||||
langui={langui}
|
||||
recorder={cleaner.attributes}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScan.typesetters &&
|
||||
selectedScan.typesetters.data.length > 0 && (
|
||||
<div>
|
||||
<p className="font-headers">{"Typesetters"}:</p>
|
||||
<div className="grid place-items-center place-content-center gap-2">
|
||||
{selectedScan.typesetters.data.map((typesetter) => (
|
||||
<Fragment key={typesetter.id}>
|
||||
{typesetter.attributes && (
|
||||
<RecorderChip
|
||||
langui={langui}
|
||||
recorder={typesetter.attributes}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid gap-8 items-end mobile:grid-cols-2
|
||||
desktop:grid-cols-[repeat(auto-fill,_minmax(10rem,1fr))]
|
||||
pb-12 border-b-[3px] border-dotted last-of-type:border-0"
|
||||
>
|
||||
{coverImages.map((image, index) => (
|
||||
<div
|
||||
key={image.url}
|
||||
className="drop-shadow-shade-lg hover:scale-[1.02]
|
||||
cursor-pointer transition-transform"
|
||||
onClick={() => {
|
||||
const imgs: string[] = [];
|
||||
coverImages.map((img) => {
|
||||
if (img.url)
|
||||
imgs.push(getAssetURL(img.url, ImageQuality.Large));
|
||||
});
|
||||
openLightBox(imgs, index);
|
||||
}}
|
||||
>
|
||||
<Img image={image} quality={ImageQuality.Small} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
}
|
|
@ -1,91 +1,206 @@
|
|||
import { Immutable } from "helpers/types";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import Hotkeys from "react-hot-keys";
|
||||
import { useSwipeable } from "react-swipeable";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import { useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { Img } from "./Img";
|
||||
import { Button } from "./Inputs/Button";
|
||||
import { Popup } from "./Popup";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { useFullscreen } from "hooks/useFullscreen";
|
||||
import { Ids } from "types/ids";
|
||||
import { UploadImageFragment } from "graphql/generated";
|
||||
import { ImageQuality } from "helpers/img";
|
||||
import { isDefined } from "helpers/asserts";
|
||||
import { useAtomGetter } from "helpers/atoms";
|
||||
import { atoms } from "contexts/atoms";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
setState:
|
||||
| Dispatch<SetStateAction<boolean | undefined>>
|
||||
| Dispatch<SetStateAction<boolean>>;
|
||||
state: boolean;
|
||||
images: string[];
|
||||
index: number;
|
||||
setIndex: Dispatch<SetStateAction<number>>;
|
||||
onCloseRequest: () => void;
|
||||
isVisible: boolean;
|
||||
image?: UploadImageFragment | string;
|
||||
isNextImageAvailable: boolean;
|
||||
isPreviousImageAvailable: boolean;
|
||||
onPressNext: () => void;
|
||||
onPressPrevious: () => void;
|
||||
}
|
||||
|
||||
export function LightBox(props: Immutable<Props>): JSX.Element {
|
||||
const { state, setState, images, index, setIndex } = props;
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
function handlePrevious() {
|
||||
if (index > 0) setIndex(index - 1);
|
||||
}
|
||||
export const LightBox = ({
|
||||
onCloseRequest,
|
||||
isVisible,
|
||||
image: src,
|
||||
isPreviousImageAvailable = false,
|
||||
onPressPrevious,
|
||||
isNextImageAvailable = false,
|
||||
onPressNext,
|
||||
}: Props): JSX.Element => {
|
||||
const [currentZoom, setCurrentZoom] = useState(1);
|
||||
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
||||
const { isFullscreen, toggleFullscreen, exitFullscreen, requestFullscreen } = useFullscreen(
|
||||
Ids.LightBox
|
||||
);
|
||||
|
||||
function handleNext() {
|
||||
if (index < images.length - 1) setIndex(index + 1);
|
||||
}
|
||||
useHotkeys("left", () => onPressPrevious(), { enabled: isVisible && isPreviousImageAvailable }, [
|
||||
onPressPrevious,
|
||||
]);
|
||||
|
||||
const sensibilitySwipe = 0.5;
|
||||
useHotkeys("f", () => requestFullscreen(), { enabled: isVisible && !isFullscreen }, [
|
||||
requestFullscreen,
|
||||
]);
|
||||
|
||||
const handlers = useSwipeable({
|
||||
onSwipedLeft: (SwipeEventData) => {
|
||||
if (SwipeEventData.velocity < sensibilitySwipe) return;
|
||||
handleNext();
|
||||
},
|
||||
onSwipedRight: (SwipeEventData) => {
|
||||
if (SwipeEventData.velocity < sensibilitySwipe) return;
|
||||
handlePrevious();
|
||||
},
|
||||
});
|
||||
useHotkeys("right", () => onPressNext(), { enabled: isVisible && isNextImageAvailable }, [
|
||||
onPressNext,
|
||||
]);
|
||||
|
||||
useHotkeys("escape", onCloseRequest, { enabled: isVisible }, [onCloseRequest]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{state && (
|
||||
<Hotkeys
|
||||
keyName="left,right"
|
||||
allowRepeat
|
||||
onKeyDown={(keyName) => {
|
||||
if (keyName === "left") {
|
||||
handlePrevious();
|
||||
} else {
|
||||
handleNext();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popup setState={setState} state={state} padding={false} fillViewport>
|
||||
<div
|
||||
{...handlers}
|
||||
className={`grid grid-cols-[4em,1fr,4em] mobile:grid-cols-2
|
||||
[grid-template-areas:"left_image_right"]
|
||||
mobile:[grid-template-areas:"image_image""left_right"]
|
||||
place-items-center first-letter:gap-4 w-full h-full overflow-hidden`}
|
||||
>
|
||||
<div className="[grid-area:left]">
|
||||
{index > 0 && (
|
||||
<Button onClick={handlePrevious}>
|
||||
<span className="material-icons">chevron_left</span>
|
||||
</Button>
|
||||
id={Ids.LightBox}
|
||||
className={cJoin(
|
||||
"fixed inset-0 z-50 grid place-content-center transition-filter duration-500",
|
||||
cIf(isVisible, cIf(!isPerfModeEnabled, "backdrop-blur"), "pointer-events-none touch-none")
|
||||
)}>
|
||||
<div
|
||||
className={cJoin(
|
||||
"fixed inset-0 transition-colors duration-500",
|
||||
cIf(isVisible, "bg-shade/50", "bg-shade/0")
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Img
|
||||
className="max-h-full [grid-area:image]"
|
||||
image={images[index]}
|
||||
/>
|
||||
<div
|
||||
className={cJoin(
|
||||
"absolute inset-0 grid transition-transform",
|
||||
cIf(isVisible, "scale-100", "scale-0")
|
||||
)}>
|
||||
<TransformWrapper
|
||||
onZoom={(zoom) => setCurrentZoom(zoom.state.scale)}
|
||||
panning={{ disabled: currentZoom <= 1, velocityDisabled: false }}
|
||||
doubleClick={{ disabled: true, mode: "reset" }}
|
||||
zoomAnimation={{ size: 0.1 }}
|
||||
velocityAnimation={{ animationTime: 0, equalToMove: true }}>
|
||||
{({ resetTransform }) => (
|
||||
<>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
overflow: "visible",
|
||||
placeSelf: "center",
|
||||
}}>
|
||||
{isDefined(src) && (
|
||||
<Img
|
||||
className={cJoin(
|
||||
`h-[calc(100vh-4rem)] w-full object-contain`,
|
||||
cIf(!isPerfModeEnabled, "drop-shadow-2xl shadow-shade")
|
||||
)}
|
||||
src={src}
|
||||
quality={ImageQuality.Large}
|
||||
/>
|
||||
)}
|
||||
</TransformComponent>
|
||||
<ControlButtons
|
||||
isNextImageAvailable={isNextImageAvailable}
|
||||
isPreviousImageAvailable={isPreviousImageAvailable}
|
||||
isFullscreen={isFullscreen}
|
||||
onCloseRequest={() => {
|
||||
resetTransform();
|
||||
exitFullscreen();
|
||||
onCloseRequest();
|
||||
}}
|
||||
onPressPrevious={() => {
|
||||
resetTransform();
|
||||
onPressPrevious();
|
||||
}}
|
||||
onPressNext={() => {
|
||||
resetTransform();
|
||||
onPressNext();
|
||||
}}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="[grid-area:right]">
|
||||
{index < images.length - 1 && (
|
||||
<Button onClick={handleNext}>
|
||||
<span className="material-icons">chevron_right</span>
|
||||
</Button>
|
||||
)}
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface ControlButtonsProps {
|
||||
isPreviousImageAvailable: boolean;
|
||||
isNextImageAvailable: boolean;
|
||||
isFullscreen: boolean;
|
||||
onPressPrevious?: () => void;
|
||||
onPressNext?: () => void;
|
||||
onCloseRequest: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
}
|
||||
|
||||
const ControlButtons = ({
|
||||
isFullscreen,
|
||||
isPreviousImageAvailable,
|
||||
isNextImageAvailable,
|
||||
onPressPrevious,
|
||||
onPressNext,
|
||||
onCloseRequest,
|
||||
toggleFullscreen,
|
||||
}: ControlButtonsProps): JSX.Element => {
|
||||
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
|
||||
|
||||
const PreviousButton = () => (
|
||||
<Button icon="navigate_before" onClick={onPressPrevious} disabled={!isPreviousImageAvailable} />
|
||||
);
|
||||
const NextButton = () => (
|
||||
<Button icon="navigate_next" onClick={onPressNext} disabled={!isNextImageAvailable} />
|
||||
);
|
||||
|
||||
const FullscreenButton = () => (
|
||||
<Button icon={isFullscreen ? "fullscreen_exit" : "fullscreen"} onClick={toggleFullscreen} />
|
||||
);
|
||||
|
||||
const CloseButton = () => <Button onClick={onCloseRequest} icon="close" />;
|
||||
|
||||
return is1ColumnLayout ? (
|
||||
<>
|
||||
<div className="absolute bottom-2 left-0 right-0 grid place-content-center">
|
||||
<div className="grid grid-flow-col gap-4 rounded-4xl p-4 backdrop-blur-lg">
|
||||
<PreviousButton />
|
||||
<FullscreenButton />
|
||||
<NextButton />
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Hotkeys>
|
||||
<div className="absolute right-2 top-2 grid gap-4 rounded-4xl p-4 backdrop-blur-lg">
|
||||
<CloseButton />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isPreviousImageAvailable && (
|
||||
<div
|
||||
className={`absolute left-8 top-1/2 grid gap-4 rounded-4xl p-4
|
||||
backdrop-blur-lg`}>
|
||||
<PreviousButton />
|
||||
</div>
|
||||
)}
|
||||
{isNextImageAvailable && (
|
||||
<div
|
||||
className={`absolute right-8 top-1/2 grid gap-4 rounded-4xl p-4
|
||||
backdrop-blur-lg`}>
|
||||
<NextButton />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`absolute right-8 top-4 grid gap-4 rounded-4xl p-4
|
||||
backdrop-blur-lg`}>
|
||||
<CloseButton />
|
||||
<FullscreenButton />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,230 +1,202 @@
|
|||
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/InsetBox";
|
||||
import { ToolTip } from "components/ToolTip";
|
||||
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||
import { InsetBox } from "components/Containers/InsetBox";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { slugify } from "helpers/formatters";
|
||||
import { getAssetURL, ImageQuality } from "helpers/img";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { useLightBox } from "hooks/useLightBox";
|
||||
import Markdown from "markdown-to-jsx";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts";
|
||||
import { AnchorShare } from "components/AnchorShare";
|
||||
import { useIntersectionList } from "hooks/useIntersectionList";
|
||||
import { Ico } from "components/Ico";
|
||||
import { useDeviceSupportsHover } from "hooks/useMediaQuery";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomGetter } from "helpers/atoms";
|
||||
import { Link } from "components/Inputs/Link";
|
||||
import { useFormat } from "hooks/useFormat";
|
||||
import { VideoPlayer } from "components/Player";
|
||||
import { getVideoFile } from "helpers/videos";
|
||||
|
||||
interface Props {
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface MarkdawnProps {
|
||||
className?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function Markdawn(props: Immutable<Props>): JSX.Element {
|
||||
const appLayout = useAppLayout();
|
||||
const text = preprocessMarkDawn(props.text);
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
const router = useRouter();
|
||||
export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Element => {
|
||||
const playerName = useAtomGetter(atoms.settings.playerName);
|
||||
const isContentPanelAtLeastLg = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastLg);
|
||||
const { showLightBox } = useAtomGetter(atoms.lightBox);
|
||||
|
||||
const [openLightBox, LightBox] = useLightBox();
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
const text = `${preprocessMarkDawn(rawText, playerName)}
|
||||
`;
|
||||
/* eslint-enable no-irregular-whitespace */
|
||||
|
||||
if (isUndefined(text) || text === "") {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
return (
|
||||
<>
|
||||
<LightBox />
|
||||
<Markdown
|
||||
className={`formatted ${props.className}`}
|
||||
className={cJoin("formatted", className)}
|
||||
options={{
|
||||
slugify: slugify,
|
||||
overrides: {
|
||||
a: {
|
||||
component: (compProps: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
if (compProps.href.startsWith("/")) {
|
||||
component: (compProps: { href: string; children: React.ReactNode }) => {
|
||||
if (compProps.href.startsWith("/") || compProps.href.startsWith("#")) {
|
||||
return (
|
||||
<a onClick={async () => router.push(compProps.href)}>
|
||||
<Link href={compProps.href} linkStyled>
|
||||
{compProps.children}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={compProps.href} target="_blank" rel="noreferrer">
|
||||
<Link href={compProps.href} alwaysNewTab linkStyled>
|
||||
{compProps.children}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
h1: {
|
||||
|
||||
Header: {
|
||||
component: (compProps: {
|
||||
id: string;
|
||||
style: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
children: string;
|
||||
level: string;
|
||||
}) => (
|
||||
<h1 id={compProps.id} style={compProps.style}>
|
||||
{compProps.children}
|
||||
<HeaderToolTip id={compProps.id} />
|
||||
</h1>
|
||||
<Header
|
||||
title={compProps.children}
|
||||
level={parseInt(compProps.level, 10)}
|
||||
slug={compProps.id}
|
||||
/>
|
||||
),
|
||||
},
|
||||
h2: {
|
||||
component: (compProps: {
|
||||
id: string;
|
||||
style: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<h2 id={compProps.id} style={compProps.style}>
|
||||
{compProps.children}
|
||||
<HeaderToolTip id={compProps.id} />
|
||||
</h2>
|
||||
),
|
||||
},
|
||||
h3: {
|
||||
component: (compProps: {
|
||||
id: string;
|
||||
style: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<h3 id={compProps.id} style={compProps.style}>
|
||||
{compProps.children}
|
||||
<HeaderToolTip id={compProps.id} />
|
||||
</h3>
|
||||
),
|
||||
},
|
||||
h4: {
|
||||
component: (compProps: {
|
||||
id: string;
|
||||
style: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<h4 id={compProps.id} style={compProps.style}>
|
||||
{compProps.children}
|
||||
<HeaderToolTip id={compProps.id} />
|
||||
</h4>
|
||||
),
|
||||
},
|
||||
h5: {
|
||||
component: (compProps: {
|
||||
id: string;
|
||||
style: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<h5 id={compProps.id} style={compProps.style}>
|
||||
{compProps.children}
|
||||
<HeaderToolTip id={compProps.id} />
|
||||
</h5>
|
||||
),
|
||||
},
|
||||
h6: {
|
||||
component: (compProps: {
|
||||
id: string;
|
||||
style: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<h6 id={compProps.id} style={compProps.style}>
|
||||
{compProps.children}
|
||||
<HeaderToolTip id={compProps.id} />
|
||||
</h6>
|
||||
),
|
||||
},
|
||||
Sep: {
|
||||
component: () => <div className="my-18"></div>,
|
||||
},
|
||||
|
||||
SceneBreak: {
|
||||
component: (compProps: { id: string }) => (
|
||||
<div
|
||||
id={compProps.id}
|
||||
className={"h-0 text-center text-3xl text-dark mt-16 mb-20"}
|
||||
>
|
||||
* * *
|
||||
</div>
|
||||
<Header title={"* * *"} level={6} slug={compProps.id} />
|
||||
),
|
||||
},
|
||||
|
||||
IntraLink: {
|
||||
component: (compProps: {
|
||||
children: React.ReactNode;
|
||||
target?: string;
|
||||
page?: string;
|
||||
}) => {
|
||||
const slug = compProps.target
|
||||
const slug = isDefinedAndNotEmpty(compProps.target)
|
||||
? slugify(compProps.target)
|
||||
: slugify(compProps.children?.toString());
|
||||
return (
|
||||
<a
|
||||
onClick={async () =>
|
||||
router.replace(
|
||||
`${compProps.page ? compProps.page : ""}#${slug}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Link href={`${compProps.page ?? ""}#${slug}`} linkStyled>
|
||||
{compProps.children}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
player: {
|
||||
component: () => (
|
||||
<span className="text-dark opacity-70">
|
||||
{appLayout.playerName ? appLayout.playerName : "<player>"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
Transcript: {
|
||||
component: (compProps) => (
|
||||
<div className="grid grid-cols-[auto_1fr] mobile:grid-cols-1 gap-x-6 gap-y-2">
|
||||
<div
|
||||
className={cJoin(
|
||||
"grid gap-x-6 gap-y-2",
|
||||
cIf(isContentPanelAtLeastLg, "grid-cols-[auto_1fr]", "grid-cols-1")
|
||||
)}>
|
||||
{compProps.children}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
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="text-dark opacity-60 mobile:!-mb-4">
|
||||
{compProps.name}
|
||||
<strong
|
||||
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"
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
InsetBox: {
|
||||
component: (compProps) => (
|
||||
<InsetBox className="my-12">{compProps.children}</InsetBox>
|
||||
),
|
||||
component: (compProps) => <InsetBox className="my-12">{compProps.children}</InsetBox>,
|
||||
},
|
||||
|
||||
li: {
|
||||
component: (compProps: { children: React.ReactNode }) => (
|
||||
<li
|
||||
className={
|
||||
compProps.children &&
|
||||
ReactDOMServer.renderToStaticMarkup(
|
||||
<>{compProps.children}</>
|
||||
).length > 100
|
||||
isDefined(compProps.children) &&
|
||||
ReactDOMServer.renderToStaticMarkup(<>{compProps.children}</>).length > 100
|
||||
? "my-4"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
}>
|
||||
{compProps.children}
|
||||
</li>
|
||||
),
|
||||
},
|
||||
|
||||
Highlight: {
|
||||
component: (compProps: { children: React.ReactNode }) => (
|
||||
<mark>{compProps.children}</mark>
|
||||
),
|
||||
},
|
||||
|
||||
footer: {
|
||||
component: (compProps: { children: React.ReactNode }) => (
|
||||
<>
|
||||
<HorizontalLine />
|
||||
<div>{compProps.children}</div>
|
||||
<div className="grid gap-8">{compProps.children}</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
blockquote: {
|
||||
component: (compProps: {
|
||||
children: React.ReactNode;
|
||||
cite?: string;
|
||||
}) => (
|
||||
component: (compProps: { children: React.ReactNode; cite?: string }) => (
|
||||
<blockquote>
|
||||
{compProps.cite ? (
|
||||
{isDefinedAndNotEmpty(compProps.cite) ? (
|
||||
<>
|
||||
“{compProps.children}”
|
||||
<cite>— {compProps.cite}</cite>
|
||||
|
@ -235,6 +207,7 @@ export function Markdawn(props: Immutable<Props>): JSX.Element {
|
|||
</blockquote>
|
||||
),
|
||||
},
|
||||
|
||||
img: {
|
||||
component: (compProps: {
|
||||
alt: string;
|
||||
|
@ -245,125 +218,358 @@ export function Markdawn(props: Immutable<Props>): JSX.Element {
|
|||
name?: string;
|
||||
}) => (
|
||||
<div
|
||||
className="my-8 cursor-pointer place-content-center grid"
|
||||
className="mb-12 mt-8 grid cursor-pointer place-content-center"
|
||||
onClick={() => {
|
||||
openLightBox([
|
||||
showLightBox([
|
||||
compProps.src.startsWith("/uploads/")
|
||||
? getAssetURL(compProps.src, ImageQuality.Large)
|
||||
: compProps.src,
|
||||
]);
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<Img
|
||||
image={
|
||||
src={
|
||||
compProps.src.startsWith("/uploads/")
|
||||
? getAssetURL(compProps.src, ImageQuality.Small)
|
||||
: compProps.src
|
||||
}
|
||||
quality={ImageQuality.Medium}
|
||||
></Img>
|
||||
className="drop-shadow-lg shadow-shade"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
{text}
|
||||
</Markdown>
|
||||
);
|
||||
};
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
export const TableOfContents = ({ toc, onContentClicked }: TableOfContentsProps): JSX.Element => {
|
||||
const { format } = useFormat();
|
||||
|
||||
return (
|
||||
<>
|
||||
{toc.children.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xl">{format("table_of_contents")}</h3>
|
||||
<div className="max-w-[14.5rem] text-left">
|
||||
<p
|
||||
className="relative my-2 overflow-x-hidden text-ellipsis whitespace-nowrap
|
||||
text-left">
|
||||
<Link href={`#${toc.slug}`} linkStyled onClick={onContentClicked}>
|
||||
{<abbr title={toc.title}>{toc.title}</abbr>}
|
||||
</Link>
|
||||
</p>
|
||||
<TocLevel
|
||||
tocchildren={toc.children}
|
||||
parentNumbering=""
|
||||
onContentClicked={onContentClicked}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface HeaderProps {
|
||||
level: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
function HeaderToolTip(props: { id: string }) {
|
||||
const Header = ({ level, title, slug }: HeaderProps): JSX.Element => {
|
||||
const isHoverable = useDeviceSupportsHover();
|
||||
const innerComponent = (
|
||||
<>
|
||||
<div className="ml-10 flex place-items-center gap-4">
|
||||
{title === "* * *" ? (
|
||||
<div className="mb-12 mt-8 space-x-3 text-dark">
|
||||
<Ico icon="emergency" />
|
||||
<Ico icon="emergency" />
|
||||
<Ico icon="emergency" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-headers">{title}</div>
|
||||
)}
|
||||
<AnchorShare
|
||||
className={cIf(isHoverable, "opacity-0 transition-opacity group-hover:opacity-100")}
|
||||
id={slug}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
switch (level) {
|
||||
case 1:
|
||||
return (
|
||||
<ToolTip
|
||||
content={"Copy anchor link"}
|
||||
trigger="mouseenter"
|
||||
className="text-sm"
|
||||
>
|
||||
<ToolTip content={"Copied! 👍"} trigger="click" className="text-sm">
|
||||
<span
|
||||
className="material-icons transition-color hover:text-dark cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${process.env.NEXT_PUBLIC_URL_SELF + window.location.pathname}#${
|
||||
props.id
|
||||
}`
|
||||
<h1 id={slug} className="group">
|
||||
{innerComponent}
|
||||
</h1>
|
||||
);
|
||||
}}
|
||||
>
|
||||
link
|
||||
</span>
|
||||
</ToolTip>
|
||||
</ToolTip>
|
||||
case 2:
|
||||
return (
|
||||
<h2 id={slug} className="group">
|
||||
{innerComponent}
|
||||
</h2>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<h3 id={slug} className="group">
|
||||
{innerComponent}
|
||||
</h3>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<h4 id={slug} className="group">
|
||||
{innerComponent}
|
||||
</h4>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<h5 id={slug} className="group">
|
||||
{innerComponent}
|
||||
</h5>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<h6 id={slug} className="group">
|
||||
{innerComponent}
|
||||
</h6>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function preprocessMarkDawn(text: string): string {
|
||||
interface TocInterface {
|
||||
title: string;
|
||||
slug: string;
|
||||
children: TocInterface[];
|
||||
}
|
||||
|
||||
interface LevelProps {
|
||||
tocchildren: TocInterface[];
|
||||
parentNumbering: string;
|
||||
allowIntersection?: boolean;
|
||||
onContentClicked?: MouseEventHandler<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
const TocLevel = ({
|
||||
tocchildren,
|
||||
parentNumbering,
|
||||
allowIntersection = true,
|
||||
onContentClicked,
|
||||
}: LevelProps): JSX.Element => {
|
||||
const ids = useMemo(() => tocchildren.map((child) => child.slug), [tocchildren]);
|
||||
const currentIntersection = useIntersectionList(ids);
|
||||
|
||||
return (
|
||||
<ol className="pl-4 text-left">
|
||||
{tocchildren.map((child, childIndex) => (
|
||||
<Fragment key={child.slug}>
|
||||
<li
|
||||
className={cJoin(
|
||||
"my-2 w-full overflow-x-hidden text-ellipsis whitespace-nowrap",
|
||||
cIf(allowIntersection && currentIntersection === childIndex, "text-dark")
|
||||
)}>
|
||||
<span className="text-dark">{`${parentNumbering}${childIndex + 1}.`}</span>{" "}
|
||||
<Link href={`#${child.slug}`} linkStyled onClick={onContentClicked}>
|
||||
{<abbr title={child.title}>{child.title}</abbr>}
|
||||
</Link>
|
||||
</li>
|
||||
<TocLevel
|
||||
tocchildren={child.children}
|
||||
parentNumbering={`${parentNumbering}${childIndex + 1}.`}
|
||||
allowIntersection={allowIntersection && currentIntersection === childIndex}
|
||||
onContentClicked={onContentClicked}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭───────────────────╮
|
||||
* ─────────────────────────────────────╯ PRIVATE METHODS ╰───────────────────────────────────────
|
||||
*/
|
||||
|
||||
const preprocessMarkDawn = (text: string, playerName = ""): string => {
|
||||
if (!text) return "";
|
||||
|
||||
const processedPlayerName = playerName.replaceAll("_", "\\_").replaceAll("*", "\\*");
|
||||
|
||||
let preprocessed = text
|
||||
.replaceAll("--", "—")
|
||||
.replaceAll(
|
||||
"@player",
|
||||
isDefinedAndNotEmpty(processedPlayerName) ? processedPlayerName : "(player)"
|
||||
);
|
||||
|
||||
let scenebreakIndex = 0;
|
||||
const visitedSlugs: string[] = [];
|
||||
|
||||
const result = text.split("\n").map((line) => {
|
||||
preprocessed = preprocessed
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line === "* * *" || line === "---") {
|
||||
scenebreakIndex += 1;
|
||||
scenebreakIndex++;
|
||||
return `<SceneBreak id="scene-break-${scenebreakIndex}">`;
|
||||
}
|
||||
|
||||
if (line.startsWith("# ")) {
|
||||
return markdawnHeadersParser(headerLevels.h1, line, visitedSlugs);
|
||||
}
|
||||
|
||||
if (line.startsWith("## ")) {
|
||||
return markdawnHeadersParser(headerLevels.h2, line, visitedSlugs);
|
||||
}
|
||||
|
||||
if (line.startsWith("### ")) {
|
||||
return markdawnHeadersParser(headerLevels.h3, line, visitedSlugs);
|
||||
}
|
||||
|
||||
if (line.startsWith("#### ")) {
|
||||
return markdawnHeadersParser(headerLevels.h4, line, visitedSlugs);
|
||||
}
|
||||
|
||||
if (line.startsWith("##### ")) {
|
||||
return markdawnHeadersParser(headerLevels.h5, line, visitedSlugs);
|
||||
}
|
||||
|
||||
if (line.startsWith("###### ")) {
|
||||
return markdawnHeadersParser(headerLevels.h6, line, visitedSlugs);
|
||||
if (/^[#]+ /u.test(line)) {
|
||||
return markdawnHeadersParser(line.indexOf(" "), line, visitedSlugs);
|
||||
}
|
||||
|
||||
return line;
|
||||
});
|
||||
return result.join("\n");
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
enum headerLevels {
|
||||
h1 = 1,
|
||||
h2 = 2,
|
||||
h3 = 3,
|
||||
h4 = 4,
|
||||
h5 = 5,
|
||||
h6 = 6,
|
||||
}
|
||||
return preprocessed;
|
||||
};
|
||||
|
||||
function markdawnHeadersParser(
|
||||
headerLevel: headerLevels,
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
const markdawnHeadersParser = (
|
||||
headerLevel: number,
|
||||
line: string,
|
||||
visitedSlugs: string[]
|
||||
): string {
|
||||
): string => {
|
||||
const lineText = line.slice(headerLevel + 1);
|
||||
const slug = slugify(lineText);
|
||||
let newSlug = slug;
|
||||
let index = 2;
|
||||
while (visitedSlugs.includes(newSlug)) {
|
||||
newSlug = `${slug}-${index}`;
|
||||
index += 1;
|
||||
index++;
|
||||
}
|
||||
visitedSlugs.push(newSlug);
|
||||
return `<${headerLevels[headerLevel]} id="${newSlug}">${lineText}</${headerLevels[headerLevel]}>`;
|
||||
return `<Header level="${headerLevel}" id="${newSlug}">${lineText}</Header>`;
|
||||
};
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const getTocFromMarkdawn = (
|
||||
markdawn: string | null | undefined,
|
||||
title?: string
|
||||
): TocInterface | undefined => {
|
||||
if (isUndefined(markdawn)) return undefined;
|
||||
|
||||
const text = preprocessMarkDawn(markdawn);
|
||||
|
||||
const toc: TocInterface = {
|
||||
title: title ?? "Return to top",
|
||||
slug: slugify(title),
|
||||
children: [],
|
||||
};
|
||||
let h2 = -1;
|
||||
let h3 = -1;
|
||||
let h4 = -1;
|
||||
let h5 = -1;
|
||||
let scenebreak = 0;
|
||||
let scenebreakIndex = 0;
|
||||
|
||||
const getTitle = (line: string): string => line.slice(line.indexOf(`">`) + 2, line.indexOf("</"));
|
||||
|
||||
const getSlug = (line: string): string =>
|
||||
line.slice(line.indexOf(`id="`) + 4, line.indexOf(`">`));
|
||||
|
||||
text.split("\n").map((line) => {
|
||||
if (line.startsWith('<Header level="2"')) {
|
||||
toc.children.push({
|
||||
title: getTitle(line),
|
||||
slug: getSlug(line),
|
||||
children: [],
|
||||
});
|
||||
h2++;
|
||||
h3 = -1;
|
||||
h4 = -1;
|
||||
h5 = -1;
|
||||
scenebreak = 0;
|
||||
} else if (h2 >= 0 && line.startsWith('<Header level="3"')) {
|
||||
toc.children[h2]?.children.push({
|
||||
title: getTitle(line),
|
||||
slug: getSlug(line),
|
||||
children: [],
|
||||
});
|
||||
h3++;
|
||||
h4 = -1;
|
||||
h5 = -1;
|
||||
scenebreak = 0;
|
||||
} else if (h3 >= 0 && line.startsWith('<Header level="4"')) {
|
||||
toc.children[h2]?.children[h3]?.children.push({
|
||||
title: getTitle(line),
|
||||
slug: getSlug(line),
|
||||
children: [],
|
||||
});
|
||||
h4++;
|
||||
h5 = -1;
|
||||
scenebreak = 0;
|
||||
} else if (h4 >= 0 && line.startsWith('<Header level="5"')) {
|
||||
toc.children[h2]?.children[h3]?.children[h4]?.children.push({
|
||||
title: getTitle(line),
|
||||
slug: getSlug(line),
|
||||
children: [],
|
||||
});
|
||||
h5++;
|
||||
scenebreak = 0;
|
||||
} else if (h5 >= 0 && line.startsWith('<Header level="6"')) {
|
||||
toc.children[h2]?.children[h3]?.children[h4]?.children[h5]?.children.push({
|
||||
title: getTitle(line),
|
||||
slug: getSlug(line),
|
||||
children: [],
|
||||
});
|
||||
} else if (line.startsWith(`<SceneBreak`)) {
|
||||
scenebreak++;
|
||||
scenebreakIndex++;
|
||||
|
||||
const child = {
|
||||
title: `Scene break ${scenebreak}`,
|
||||
slug: slugify(`scene-break-${scenebreakIndex}`),
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (h5 >= 0) {
|
||||
toc.children[h2]?.children[h3]?.children[h4]?.children[h5]?.children.push(child);
|
||||
} else if (h4 >= 0) {
|
||||
toc.children[h2]?.children[h3]?.children[h4]?.children.push(child);
|
||||
} else if (h3 >= 0) {
|
||||
toc.children[h2]?.children[h3]?.children.push(child);
|
||||
} else if (h2 >= 0) {
|
||||
toc.children[h2]?.children.push(child);
|
||||
} else {
|
||||
toc.children.push(child);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (toc.children.length === 0) return undefined;
|
||||
return toc;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface MarkdownProps {
|
||||
className?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Markdown = ({ className, text }: MarkdownProps): JSX.Element => (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(text)) }}
|
||||
/>
|
||||
);
|
|
@ -1,164 +0,0 @@
|
|||
import { slugify } from "helpers/formatters";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment } from "react";
|
||||
import { preprocessMarkDawn } from "./Markdawn";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function TOC(props: Immutable<Props>): JSX.Element {
|
||||
const { text, title } = props;
|
||||
const toc = getTocFromMarkdawn(preprocessMarkDawn(text), title);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-xl">Table of content</h3>
|
||||
<div className="text-left max-w-[14.5rem]">
|
||||
<p className="my-2 overflow-x-hidden relative text-ellipsis whitespace-nowrap text-left">
|
||||
<a className="" onClick={async () => router.replace(`#${toc.slug}`)}>
|
||||
{<abbr title={toc.title}>{toc.title}</abbr>}
|
||||
</a>
|
||||
</p>
|
||||
<TOCLevel tocchildren={toc.children} parentNumbering="" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface LevelProps {
|
||||
tocchildren: TOCInterface[];
|
||||
parentNumbering: string;
|
||||
}
|
||||
|
||||
function TOCLevel(props: LevelProps): JSX.Element {
|
||||
const router = useRouter();
|
||||
const { tocchildren, parentNumbering } = props;
|
||||
return (
|
||||
<ol className="pl-4 text-left">
|
||||
{tocchildren.map((child, childIndex) => (
|
||||
<Fragment key={child.slug}>
|
||||
<li className="my-2 overflow-x-hidden w-full text-ellipsis whitespace-nowrap">
|
||||
<span className="text-dark">{`${parentNumbering}${
|
||||
childIndex + 1
|
||||
}.`}</span>{" "}
|
||||
<a onClick={async () => router.replace(`#${child.slug}`)}>
|
||||
{<abbr title={child.title}>{child.title}</abbr>}
|
||||
</a>
|
||||
</li>
|
||||
<TOCLevel
|
||||
tocchildren={child.children}
|
||||
parentNumbering={`${parentNumbering}${childIndex + 1}.`}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
interface TOCInterface {
|
||||
title: string;
|
||||
slug: string;
|
||||
children: TOCInterface[];
|
||||
}
|
||||
|
||||
export function getTocFromMarkdawn(text: string, title?: string): TOCInterface {
|
||||
const toc: TOCInterface = {
|
||||
title: title ?? "Return to top",
|
||||
slug: slugify(title),
|
||||
children: [],
|
||||
};
|
||||
let h2 = -1;
|
||||
let h3 = -1;
|
||||
let h4 = -1;
|
||||
let h5 = -1;
|
||||
let scenebreak = 0;
|
||||
let scenebreakIndex = 0;
|
||||
|
||||
function getTitle(line: string): string {
|
||||
return line.slice(line.indexOf(`">`) + 2, line.indexOf("</"));
|
||||
}
|
||||
|
||||
function getSlug(line: string): string {
|
||||
return line.slice(line.indexOf(`id="`) + 4, line.indexOf(`">`));
|
||||
}
|
||||
|
||||
text.split("\n").map((line) => {
|
||||
if (line.startsWith("<h1 id=")) {
|
||||
toc.title = getTitle(line);
|
||||
toc.slug = getSlug(line);
|
||||
} else if (line.startsWith("<h2 id=")) {
|
||||
toc.children.push({
|
||||
title: getTitle(line),
|
||||
slug: getSlug(line),
|
||||
children: [],
|
||||
});
|
||||
h2 += 1;
|
||||
h3 = -1;
|
||||
h4 = -1;
|
||||
h5 = -1;
|
||||
scenebreak = 0;
|
||||
} else if (line.startsWith("<h3 id=")) {
|
||||
toc.children[h2].children.push({
|
||||
title: getTitle(line),
|
||||
slug: getSlug(line),
|
||||
children: [],
|
||||
});
|
||||
h3 += 1;
|
||||
h4 = -1;
|
||||
h5 = -1;
|
||||
scenebreak = 0;
|
||||
} else if (line.startsWith("<h4 id=")) {
|
||||
toc.children[h2].children[h3].children.push({
|
||||
title: getTitle(line),
|
||||
slug: getSlug(line),
|
||||
children: [],
|
||||
});
|
||||
h4 += 1;
|
||||
h5 = -1;
|
||||
scenebreak = 0;
|
||||
} else if (line.startsWith("<h5 id=")) {
|
||||
toc.children[h2].children[h3].children[h4].children.push({
|
||||
title: getTitle(line),
|
||||
slug: getSlug(line),
|
||||
children: [],
|
||||
});
|
||||
h5 += 1;
|
||||
scenebreak = 0;
|
||||
} else if (line.startsWith("<h6 id=")) {
|
||||
toc.children[h2].children[h3].children[h4].children[h5].children.push({
|
||||
title: getTitle(line),
|
||||
slug: getSlug(line),
|
||||
children: [],
|
||||
});
|
||||
} else if (line.startsWith(`<SceneBreak`)) {
|
||||
scenebreak += 1;
|
||||
scenebreakIndex += 1;
|
||||
|
||||
const child = {
|
||||
title: `Scene break ${scenebreak}`,
|
||||
slug: slugify(`scene-break-${scenebreakIndex}`),
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (h5 >= 0) {
|
||||
toc.children[h2].children[h3].children[h4].children[h5].children.push(
|
||||
child
|
||||
);
|
||||
} else if (h4 >= 0) {
|
||||
toc.children[h2].children[h3].children[h4].children.push(child);
|
||||
} else if (h3 >= 0) {
|
||||
toc.children[h2].children[h3].children.push(child);
|
||||
} else if (h2 >= 0) {
|
||||
toc.children[h2].children.push(child);
|
||||
} else {
|
||||
toc.children.push(child);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return toc;
|
||||
}
|
|
@ -1,70 +1,99 @@
|
|||
import { ToolTip } from "components/ToolTip";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { useRouter } from "next/router";
|
||||
import { MouseEventHandler } from "react";
|
||||
import { MouseEventHandler, useCallback } from "react";
|
||||
import { MaterialSymbol } from "material-symbols";
|
||||
import { Ico } from "components/Ico";
|
||||
import { ToolTip } from "components/ToolTip";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
import { TranslatedProps } from "types/TranslatedProps";
|
||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||
import { DownPressable } from "components/Containers/DownPressable";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
icon?: string;
|
||||
icon?: MaterialSymbol;
|
||||
title: string | null | undefined;
|
||||
subtitle?: string | null | undefined;
|
||||
border?: boolean;
|
||||
reduced?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
export function NavOption(props: Immutable<Props>): JSX.Element {
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const NavOption = ({
|
||||
url,
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
border = false,
|
||||
reduced = false,
|
||||
active = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: Props): JSX.Element => {
|
||||
const router = useRouter();
|
||||
const isActive = router.asPath.startsWith(props.url);
|
||||
const divActive = "bg-mid shadow-inner-sm shadow-shade";
|
||||
|
||||
const border =
|
||||
"outline outline-mid outline-2 outline-offset-[-2px] hover:outline-[transparent]";
|
||||
|
||||
const divCommon = `gap-x-5 w-full rounded-2xl cursor-pointer p-4 hover:bg-mid
|
||||
hover:shadow-inner-sm hover:shadow-shade hover:active:shadow-inner
|
||||
hover:active:shadow-shade transition-all ${props.border ? border : ""} ${
|
||||
isActive ? divActive : ""
|
||||
}`;
|
||||
const isActive = active || router.asPath.startsWith(url);
|
||||
|
||||
return (
|
||||
<ToolTip
|
||||
content={
|
||||
<div>
|
||||
<h3 className="text-2xl">{props.title}</h3>
|
||||
{props.subtitle && <p className="col-start-2">{props.subtitle}</p>}
|
||||
<h3 className="text-2xl">{title}</h3>
|
||||
{isDefinedAndNotEmpty(subtitle) && <p className="col-start-2">{subtitle}</p>}
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
className="text-left"
|
||||
disabled={!props.reduced}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => {
|
||||
if (props.onClick) props.onClick(event);
|
||||
if (props.url) {
|
||||
if (props.url.startsWith("#")) {
|
||||
router.replace(props.url);
|
||||
} else {
|
||||
router.push(props.url);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`relative grid grid-flow-col grid-cols-[auto] auto-cols-fr justify-center ${
|
||||
props.icon ? "text-left" : "text-center"
|
||||
} ${divCommon}`}
|
||||
>
|
||||
{props.icon && (
|
||||
<span className="material-icons mt-[.1em]">{props.icon}</span>
|
||||
disabled={!reduced || disabled}>
|
||||
<DownPressable
|
||||
className={cJoin(
|
||||
"grid w-full auto-cols-fr grid-flow-col grid-cols-[auto] justify-center gap-x-5",
|
||||
cIf(icon, "text-left", "text-center")
|
||||
)}
|
||||
|
||||
{!props.reduced && (
|
||||
href={url}
|
||||
border={border}
|
||||
onClick={onClick}
|
||||
active={isActive}
|
||||
disabled={disabled}>
|
||||
{icon && <Ico icon={icon} className="mt-[-.1em] !text-2xl" isFilled={isActive} />}
|
||||
{!reduced && (
|
||||
<div>
|
||||
<h3 className="text-2xl">{props.title}</h3>
|
||||
{props.subtitle && <p className="col-start-2">{props.subtitle}</p>}
|
||||
<h3 className="text-2xl">{title}</h3>
|
||||
{isDefinedAndNotEmpty(subtitle) && <p className="col-start-2">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DownPressable>
|
||||
</ToolTip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const TranslatedNavOption = ({
|
||||
translations,
|
||||
fallback,
|
||||
...otherProps
|
||||
}: TranslatedProps<Props, "subtitle" | "title">): JSX.Element => {
|
||||
const [selectedTranslation] = useSmartLanguage({
|
||||
items: translations,
|
||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
||||
});
|
||||
return (
|
||||
<NavOption
|
||||
title={selectedTranslation?.title ?? fallback.title}
|
||||
subtitle={selectedTranslation?.subtitle ?? fallback.subtitle}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,23 +1,26 @@
|
|||
import { HorizontalLine } from "components/HorizontalLine";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { MaterialSymbol } from "material-symbols";
|
||||
import { Ico } from "components/Ico";
|
||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
icon?: string;
|
||||
icon?: MaterialSymbol;
|
||||
title: string | null | undefined;
|
||||
description?: string | null | undefined;
|
||||
}
|
||||
|
||||
export function PanelHeader(props: Immutable<Props>): JSX.Element {
|
||||
return (
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const PanelHeader = ({ icon, description, title }: Props): JSX.Element => (
|
||||
<>
|
||||
<div className="w-full grid place-items-center">
|
||||
{props.icon && (
|
||||
<span className="material-icons !text-4xl mb-3">{props.icon}</span>
|
||||
)}
|
||||
<h2 className="text-2xl">{props.title}</h2>
|
||||
{props.description ? <p>{props.description}</p> : ""}
|
||||
<div className="grid w-full place-items-center">
|
||||
{icon && <Ico icon={icon} className="mb-3 !text-4xl" />}
|
||||
<h2 className="text-2xl">{title}</h2>
|
||||
{isDefinedAndNotEmpty(description) && <p>{description}</p>}
|
||||
</div>
|
||||
<HorizontalLine />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,46 +1,47 @@
|
|||
import { HorizontalLine } from "components/HorizontalLine";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "components/Inputs/Button";
|
||||
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { TranslatedProps } from "types/TranslatedProps";
|
||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||
import { useFormat } from "hooks/useFormat";
|
||||
import { cJoin } from "helpers/className";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
title: string | null | undefined;
|
||||
langui: AppStaticProps["langui"];
|
||||
displayOn: ReturnButtonType;
|
||||
horizontalLine?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export enum ReturnButtonType {
|
||||
mobile = "mobile",
|
||||
desktop = "desktop",
|
||||
both = "both",
|
||||
}
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export function ReturnButton(props: Immutable<Props>): JSX.Element {
|
||||
const appLayout = useAppLayout();
|
||||
export const ReturnButton = ({ href, title, className }: Props): JSX.Element => {
|
||||
const { format } = useFormat();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
props.displayOn === ReturnButtonType.mobile
|
||||
? "desktop:hidden"
|
||||
: props.displayOn === ReturnButtonType.desktop
|
||||
? "mobile:hidden"
|
||||
: ""
|
||||
} ${props.className}`}
|
||||
>
|
||||
<Button
|
||||
onClick={() => appLayout.setSubPanelOpen(false)}
|
||||
href={props.href}
|
||||
className="grid grid-flow-col gap-2"
|
||||
>
|
||||
<span className="material-icons">navigate_before</span>
|
||||
{props.langui.return_to} {props.title}
|
||||
</Button>
|
||||
{props.horizontalLine && <HorizontalLine />}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const TranslatedReturnButton = ({
|
||||
translations,
|
||||
fallback,
|
||||
...otherProps
|
||||
}: TranslatedProps<Props, "title">): JSX.Element => {
|
||||
const [selectedTranslation] = useSmartLanguage({
|
||||
items: translations,
|
||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
||||
});
|
||||
|
||||
return <ReturnButton title={selectedTranslation?.title ?? fallback.title} {...otherProps} />;
|
||||
};
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
import { Immutable } from "helpers/types";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
autoformat?: boolean;
|
||||
width?: ContentPanelWidthSizes;
|
||||
}
|
||||
|
||||
export enum ContentPanelWidthSizes {
|
||||
default = "default",
|
||||
large = "large",
|
||||
}
|
||||
|
||||
export function ContentPanel(props: Immutable<Props>): JSX.Element {
|
||||
const width = props.width ? props.width : ContentPanelWidthSizes.default;
|
||||
const widthCSS =
|
||||
width === ContentPanelWidthSizes.default ? "max-w-2xl" : "w-full";
|
||||
|
||||
return (
|
||||
<div className={`grid pt-10 pb-20 px-4 desktop:py-20 desktop:px-10`}>
|
||||
<main
|
||||
className={`${
|
||||
props.autoformat && "formatted"
|
||||
} ${widthCSS} place-self-center`}
|
||||
>
|
||||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1,106 +1,127 @@
|
|||
import { useCallback } from "react";
|
||||
import { HorizontalLine } from "components/HorizontalLine";
|
||||
import { Button } from "components/Inputs/Button";
|
||||
import { NavOption } from "components/PanelComponents/NavOption";
|
||||
import { ToolTip } from "components/ToolTip";
|
||||
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { useMediaDesktop } from "hooks/useMediaQuery";
|
||||
import Markdown from "markdown-to-jsx";
|
||||
import Link from "next/link";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
import { Link } from "components/Inputs/Link";
|
||||
import { sendAnalytics } from "helpers/analytics";
|
||||
import { ColoredSvg } from "components/ColoredSvg";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomGetter, useAtomPair, useAtomSetter } from "helpers/atoms";
|
||||
import { Markdawn } from "components/Markdown/Markdawn";
|
||||
import { useFormat } from "hooks/useFormat";
|
||||
|
||||
interface Props {
|
||||
langui: AppStaticProps["langui"];
|
||||
}
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
export function MainPanel(props: Immutable<Props>): JSX.Element {
|
||||
const { langui } = props;
|
||||
const isDesktop = useMediaDesktop();
|
||||
const appLayout = useAppLayout();
|
||||
export const MainPanel = (): JSX.Element => {
|
||||
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col justify-center content-start
|
||||
gap-y-2 justify-items-center text-center p-8 ${
|
||||
appLayout.mainPanelReduced && isDesktop && "px-4"
|
||||
}`}
|
||||
>
|
||||
className={cJoin(
|
||||
"grid content-start justify-center gap-y-2 p-8 text-center",
|
||||
cIf(isMainPanelReduced && is3ColumnsLayout, "px-4")
|
||||
)}>
|
||||
{/* Reduce/expand main menu */}
|
||||
{is3ColumnsLayout && (
|
||||
<div
|
||||
className={`mobile:hidden top-1/2 fixed ${
|
||||
appLayout.mainPanelReduced ? "left-[4.65rem]" : "left-[18.65rem]"
|
||||
}`}
|
||||
onClick={() =>
|
||||
appLayout.setMainPanelReduced(!appLayout.mainPanelReduced)
|
||||
className={cJoin(
|
||||
"fixed top-1/2",
|
||||
cIf(isMainPanelReduced, "left-[4.65rem]", "left-[18.65rem]")
|
||||
)}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isMainPanelReduced) {
|
||||
sendAnalytics("MainPanel", "Expand");
|
||||
} else {
|
||||
sendAnalytics("MainPanel", "Reduce");
|
||||
}
|
||||
>
|
||||
<Button className="material-icons bg-light !px-2">
|
||||
{appLayout.mainPanelReduced ? "chevron_right" : "chevron_left"}
|
||||
</Button>
|
||||
setMainPanelReduced((current) => !current);
|
||||
}}
|
||||
className="z-50 bg-light !px-2"
|
||||
icon={isMainPanelReduced ? "chevron_right" : "chevron_left"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
)}
|
||||
<div>
|
||||
<div className="grid place-items-center">
|
||||
<Link href="/" passHref>
|
||||
<div
|
||||
className={`${
|
||||
appLayout.mainPanelReduced && isDesktop ? "w-12" : "w-1/2"
|
||||
} aspect-square cursor-pointer transition-colors [mask:url('/icons/accords.svg')]
|
||||
![mask-size:contain] ![mask-repeat:no-repeat]
|
||||
![mask-position:center] bg-black hover:bg-dark mb-4`}
|
||||
></div>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex w-full cursor-pointer justify-center"
|
||||
onClick={closeMainPanel}>
|
||||
<ColoredSvg
|
||||
src="/icons/accords.svg"
|
||||
className={cJoin(
|
||||
"mb-4 aspect-square bg-black hover:bg-dark",
|
||||
cIf(isMainPanelReduced && is3ColumnsLayout, "w-12", "w-1/2")
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{appLayout.mainPanelReduced && isDesktop ? (
|
||||
""
|
||||
) : (
|
||||
<h2 className="text-3xl">Accord’s Library</h2>
|
||||
{(!isMainPanelReduced || !is3ColumnsLayout) && (
|
||||
<h2 className="mb-4 text-3xl">Accord’s Library</h2>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex ${
|
||||
appLayout.mainPanelReduced && isDesktop
|
||||
? "flex-col gap-3"
|
||||
: "flex-row"
|
||||
} flex-wrap gap-2`}
|
||||
>
|
||||
className={cJoin(
|
||||
"flex flex-wrap gap-2",
|
||||
cIf(isMainPanelReduced && is3ColumnsLayout, "flex-col gap-3", "flex-row")
|
||||
)}>
|
||||
<ToolTip
|
||||
content={<h3 className="text-2xl">{langui.open_settings}</h3>}
|
||||
placement="right"
|
||||
className="text-left"
|
||||
disabled={!appLayout.mainPanelReduced}
|
||||
>
|
||||
content={<h3 className="text-2xl">{format("open_settings")}</h3>}
|
||||
placement={isMainPanelReduced ? "right" : "top"}>
|
||||
<Button
|
||||
active={isSettingsOpened}
|
||||
onClick={() => {
|
||||
appLayout.setConfigPanelOpen(true);
|
||||
closeMainPanel();
|
||||
setSettingsOpened(true);
|
||||
sendAnalytics("Settings", "Open settings");
|
||||
}}
|
||||
>
|
||||
<span className={"material-icons"}>settings</span>
|
||||
</Button>
|
||||
icon="discover_tune"
|
||||
/>
|
||||
</ToolTip>
|
||||
|
||||
{/* <ToolTip
|
||||
content={<h3 className="text-2xl">{langui.open_search}</h3>}
|
||||
placement="right"
|
||||
className="text-left"
|
||||
disabled={!appLayout.mainPanelReduced}
|
||||
>
|
||||
<ToolTip
|
||||
content={<h3 className="text-2xl">{format("open_search")}</h3>}
|
||||
placement={isMainPanelReduced ? "right" : "top"}>
|
||||
<Button
|
||||
className={
|
||||
appLayout.mainPanelReduced && isDesktop
|
||||
? ""
|
||||
: "!py-0.5 !px-2.5"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`material-icons ${
|
||||
!(appLayout.mainPanelReduced && isDesktop) && "!text-sm"
|
||||
} `}
|
||||
>
|
||||
search
|
||||
</span>
|
||||
</Button>
|
||||
</ToolTip> */}
|
||||
active={isSearchOpened}
|
||||
onClick={() => {
|
||||
closeMainPanel();
|
||||
setSearchOpened(true);
|
||||
sendAnalytics("Search", "Open search");
|
||||
}}
|
||||
icon="search"
|
||||
/>
|
||||
</ToolTip>
|
||||
{isDebugMenuAvailable && (
|
||||
<ToolTip
|
||||
content={<h3 className="text-2xl">Debug menu</h3>}
|
||||
placement={isMainPanelReduced ? "right" : "top"}>
|
||||
<Button
|
||||
active={isDebugMenuOpened}
|
||||
onClick={() => {
|
||||
closeMainPanel();
|
||||
setDebugMenuOpened(true);
|
||||
sendAnalytics("Debug", "Open debug menu");
|
||||
}}
|
||||
icon="bug_report"
|
||||
/>
|
||||
</ToolTip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -109,144 +130,141 @@ export function MainPanel(props: Immutable<Props>): JSX.Element {
|
|||
|
||||
<NavOption
|
||||
url="/library"
|
||||
icon="library_books"
|
||||
title={langui.library}
|
||||
subtitle={langui.library_short_description}
|
||||
reduced={appLayout.mainPanelReduced && isDesktop}
|
||||
icon="auto_stories"
|
||||
title={format("library")}
|
||||
subtitle={format("library_short_description")}
|
||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
||||
onClick={closeMainPanel}
|
||||
/>
|
||||
|
||||
<NavOption
|
||||
url="/contents"
|
||||
icon="workspaces"
|
||||
title={langui.contents}
|
||||
subtitle={langui.contents_short_description}
|
||||
reduced={appLayout.mainPanelReduced && isDesktop}
|
||||
title={format("contents")}
|
||||
subtitle={format("contents_short_description")}
|
||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
||||
onClick={closeMainPanel}
|
||||
/>
|
||||
|
||||
<NavOption
|
||||
url="/wiki"
|
||||
icon="travel_explore"
|
||||
title={langui.wiki}
|
||||
subtitle={langui.wiki_short_description}
|
||||
reduced={appLayout.mainPanelReduced && isDesktop}
|
||||
title={format("wiki")}
|
||||
subtitle={format("wiki_short_description")}
|
||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
||||
onClick={closeMainPanel}
|
||||
/>
|
||||
|
||||
{/*
|
||||
|
||||
<NavOption
|
||||
url="/chronicles"
|
||||
icon="watch_later"
|
||||
title={langui.chronicles}
|
||||
subtitle={langui.chronicles_short_description}
|
||||
|
||||
reduced={appLayout.mainPanelReduced && isDesktop}
|
||||
|
||||
icon="schedule"
|
||||
title={format("chronicles")}
|
||||
subtitle={format("chronicles_short_description")}
|
||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
||||
onClick={closeMainPanel}
|
||||
/>
|
||||
|
||||
*/}
|
||||
|
||||
<HorizontalLine />
|
||||
|
||||
<NavOption
|
||||
url="/news"
|
||||
icon="feed"
|
||||
title={langui.news}
|
||||
reduced={appLayout.mainPanelReduced && isDesktop}
|
||||
/>
|
||||
{/*
|
||||
<NavOption
|
||||
url="/merch"
|
||||
icon="store"
|
||||
title={langui.merch}
|
||||
|
||||
reduced={appLayout.mainPanelReduced && isDesktop}
|
||||
|
||||
icon="newspaper"
|
||||
title={format("news")}
|
||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
||||
onClick={closeMainPanel}
|
||||
/>
|
||||
|
||||
*/}
|
||||
|
||||
<NavOption
|
||||
url="/gallery"
|
||||
icon="collections"
|
||||
title={langui.gallery}
|
||||
reduced={appLayout.mainPanelReduced && isDesktop}
|
||||
url="https://gallery.accords-library.com/posts/"
|
||||
icon="perm_media"
|
||||
title={format("gallery")}
|
||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
||||
onClick={closeMainPanel}
|
||||
/>
|
||||
|
||||
<NavOption
|
||||
url="/archives"
|
||||
icon="inventory"
|
||||
title={langui.archives}
|
||||
reduced={appLayout.mainPanelReduced && isDesktop}
|
||||
icon="save"
|
||||
title={format("archives")}
|
||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
||||
onClick={closeMainPanel}
|
||||
/>
|
||||
|
||||
<NavOption
|
||||
url="/about-us"
|
||||
icon="info"
|
||||
title={langui.about_us}
|
||||
reduced={appLayout.mainPanelReduced && isDesktop}
|
||||
title={format("about_us")}
|
||||
reduced={isMainPanelReduced && is3ColumnsLayout}
|
||||
onClick={closeMainPanel}
|
||||
/>
|
||||
|
||||
{appLayout.mainPanelReduced && isDesktop ? "" : <HorizontalLine />}
|
||||
{(!isMainPanelReduced || !is3ColumnsLayout) && <HorizontalLine />}
|
||||
|
||||
<div
|
||||
className={`text-center ${
|
||||
appLayout.mainPanelReduced && isDesktop ? "hidden" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={cJoin("text-center", cIf(isMainPanelReduced && is3ColumnsLayout, "hidden"))}>
|
||||
{isDefinedAndNotEmpty(format("licensing_notice")) && (
|
||||
<p>
|
||||
{langui.licensing_notice && (
|
||||
<Markdown>{langui.licensing_notice}</Markdown>
|
||||
)}
|
||||
<Markdawn text={format("licensing_notice")} />
|
||||
</p>
|
||||
<a
|
||||
)}
|
||||
<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"
|
||||
className="transition-[filter] colorize-black hover:colorize-dark"
|
||||
className="group grid grid-flow-col place-content-center gap-1 transition-filter"
|
||||
href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
>
|
||||
<div
|
||||
className="mt-4 mb-8 grid grid-flow-col place-content-center gap-1
|
||||
hover:[--theme-color-black:var(--theme-color-dark)]"
|
||||
>
|
||||
<div
|
||||
className="w-6 aspect-square [mask:url('/icons/creative-commons-brands.svg')]
|
||||
![mask-size:contain] ![mask-repeat:no-repeat] ![mask-position:center] bg-black"
|
||||
alwaysNewTab>
|
||||
<ColoredSvg
|
||||
className="h-6 w-6 bg-black group-hover:bg-dark"
|
||||
src="/icons/creative-commons-brands.svg"
|
||||
/>
|
||||
<div
|
||||
className="w-6 aspect-square [mask:url('/icons/creative-commons-by-brands.svg')]
|
||||
![mask-size:contain] ![mask-repeat:no-repeat] ![mask-position:center] bg-black"
|
||||
<ColoredSvg
|
||||
className="h-6 w-6 bg-black group-hover:bg-dark"
|
||||
src="/icons/creative-commons-by-brands.svg"
|
||||
/>
|
||||
<div
|
||||
className="w-6 aspect-square [mask:url('/icons/creative-commons-sa-brands.svg')]
|
||||
![mask-size:contain] ![mask-repeat:no-repeat] ![mask-position:center] bg-black"
|
||||
<ColoredSvg
|
||||
className="h-6 w-6 bg-black group-hover:bg-dark"
|
||||
src="/icons/creative-commons-sa-brands.svg"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</a>
|
||||
{isDefinedAndNotEmpty(format("copyright_notice")) && (
|
||||
<p>
|
||||
{langui.copyright_notice && (
|
||||
<Markdown>{langui.copyright_notice}</Markdown>
|
||||
)}
|
||||
<Markdawn text={format("copyright_notice")} />
|
||||
</p>
|
||||
<div className="mt-12 mb-4 grid h-4 grid-flow-col place-content-center gap-8">
|
||||
<a
|
||||
)}
|
||||
<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"
|
||||
className="transition-colors [mask:url('/icons/github-brands.svg')]
|
||||
![mask-size:contain] ![mask-repeat:no-repeat] ![mask-position:center]
|
||||
w-10 aspect-square bg-black hover:bg-dark"
|
||||
onClick={() => sendAnalytics("MainPanel", "Visit GitHub")}
|
||||
href="https://github.com/Accords-Library"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
></a>
|
||||
<a
|
||||
alwaysNewTab>
|
||||
<ColoredSvg
|
||||
className="h-10 w-10 bg-black hover:bg-dark"
|
||||
src="/icons/github-brands.svg"
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Follow us on Twitter"
|
||||
onClick={() => sendAnalytics("MainPanel", "Visit Twitter")}
|
||||
href="https://twitter.com/AccordsLibrary"
|
||||
alwaysNewTab>
|
||||
<ColoredSvg
|
||||
className="h-10 w-10 bg-black hover:bg-dark"
|
||||
src="/icons/twitter-brands.svg"
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Join our Discord server!"
|
||||
className="transition-colors [mask:url('/icons/discord-brands.svg')]
|
||||
![mask-size:contain] ![mask-repeat:no-repeat] ![mask-position:center]
|
||||
w-10 aspect-square bg-black hover:bg-dark"
|
||||
onClick={() => sendAnalytics("MainPanel", "Visit Discord")}
|
||||
href="/discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
></a>
|
||||
alwaysNewTab>
|
||||
<ColoredSvg
|
||||
className="h-10 w-10 bg-black hover:bg-dark"
|
||||
src="/icons/discord-brands.svg"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,535 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { MaterialSymbol } from "material-symbols";
|
||||
import { Popup } from "components/Containers/Popup";
|
||||
import { sendAnalytics } from "helpers/analytics";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomPair, useAtomSetter } from "helpers/atoms";
|
||||
import { TextInput } from "components/Inputs/TextInput";
|
||||
import {
|
||||
containsHighlight,
|
||||
CustomSearchResponse,
|
||||
filterHitsWithHighlight,
|
||||
meiliMultiSearch,
|
||||
} from "helpers/search";
|
||||
import { PreviewCard, TranslatedPreviewCard } from "components/PreviewCard";
|
||||
import { filterHasAttributes, isDefined } from "helpers/asserts";
|
||||
import {
|
||||
MeiliContent,
|
||||
MeiliIndices,
|
||||
MeiliLibraryItem,
|
||||
MeiliPost,
|
||||
MeiliVideo,
|
||||
MeiliWeapon,
|
||||
MeiliWikiPage,
|
||||
} from "shared/meilisearch-graphql-typings/meiliTypes";
|
||||
import { getVideoThumbnailURL } from "helpers/videos";
|
||||
import { UpPressable } from "components/Containers/UpPressable";
|
||||
import { prettySlug } from "helpers/formatters";
|
||||
import { Ico } from "components/Ico";
|
||||
import { useFormat } from "hooks/useFormat";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
||||
*/
|
||||
|
||||
const SEARCH_LIMIT = 8;
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ────────────────────────────────────────╯ COMPONENT ╰──────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface MultiResult {
|
||||
libraryItems?: CustomSearchResponse<MeiliLibraryItem>;
|
||||
contents?: CustomSearchResponse<MeiliContent>;
|
||||
videos?: CustomSearchResponse<MeiliVideo>;
|
||||
posts?: CustomSearchResponse<MeiliPost>;
|
||||
wikiPages?: CustomSearchResponse<MeiliWikiPage>;
|
||||
weapons?: CustomSearchResponse<MeiliWeapon>;
|
||||
}
|
||||
|
||||
export const SearchPopup = (): JSX.Element => {
|
||||
const [isSearchOpened, setSearchOpened] = useAtomPair(atoms.layout.searchOpened);
|
||||
const [query, setQuery] = useState("");
|
||||
const {
|
||||
format,
|
||||
formatCategory,
|
||||
formatContentType,
|
||||
formatWikiTag,
|
||||
formatLibraryItemSubType,
|
||||
formatWeaponType,
|
||||
} = useFormat();
|
||||
const [multiResult, setMultiResult] = useState<MultiResult>({});
|
||||
|
||||
const fetchSearchResults = useCallback((q: string) => {
|
||||
const fetchMultiResult = async () => {
|
||||
const searchResults = (
|
||||
await meiliMultiSearch([
|
||||
{
|
||||
indexUid: MeiliIndices.LIBRARY_ITEM,
|
||||
q,
|
||||
limit: SEARCH_LIMIT,
|
||||
attributesToRetrieve: [
|
||||
"title",
|
||||
"subtitle",
|
||||
"descriptions",
|
||||
"id",
|
||||
"slug",
|
||||
"thumbnail",
|
||||
"release_date",
|
||||
"price",
|
||||
"categories",
|
||||
"metadata",
|
||||
],
|
||||
attributesToHighlight: ["title", "subtitle", "descriptions"],
|
||||
attributesToCrop: ["descriptions"],
|
||||
},
|
||||
{
|
||||
indexUid: MeiliIndices.CONTENT,
|
||||
q,
|
||||
limit: SEARCH_LIMIT,
|
||||
attributesToRetrieve: ["translations", "id", "slug", "categories", "type", "thumbnail"],
|
||||
attributesToHighlight: ["translations"],
|
||||
attributesToCrop: ["translations.displayable_description"],
|
||||
},
|
||||
{
|
||||
indexUid: MeiliIndices.VIDEOS,
|
||||
q,
|
||||
limit: SEARCH_LIMIT,
|
||||
attributesToRetrieve: [
|
||||
"title",
|
||||
"channel",
|
||||
"uid",
|
||||
"published_date",
|
||||
"views",
|
||||
"duration",
|
||||
"description",
|
||||
],
|
||||
attributesToHighlight: ["title", "channel", "description"],
|
||||
attributesToCrop: ["description"],
|
||||
},
|
||||
{
|
||||
indexUid: MeiliIndices.POST,
|
||||
q,
|
||||
limit: SEARCH_LIMIT,
|
||||
attributesToRetrieve: ["translations", "thumbnail", "slug", "date", "categories"],
|
||||
attributesToHighlight: ["translations.title", "translations.displayable_description"],
|
||||
attributesToCrop: ["translations.displayable_description"],
|
||||
filter: ["hidden = false"],
|
||||
},
|
||||
{
|
||||
indexUid: MeiliIndices.WEAPON,
|
||||
q,
|
||||
limit: SEARCH_LIMIT,
|
||||
attributesToHighlight: ["translations.description", "translations.names"],
|
||||
attributesToCrop: ["translations.description"],
|
||||
sort: ["slug:asc"],
|
||||
},
|
||||
{
|
||||
indexUid: MeiliIndices.WIKI_PAGE,
|
||||
q,
|
||||
limit: SEARCH_LIMIT,
|
||||
attributesToHighlight: [
|
||||
"translations.title",
|
||||
"translations.aliases",
|
||||
"translations.summary",
|
||||
"translations.displayable_description",
|
||||
],
|
||||
attributesToCrop: ["translations.displayable_description"],
|
||||
},
|
||||
])
|
||||
).results;
|
||||
|
||||
const result: MultiResult = {};
|
||||
|
||||
searchResults.map((searchResult) => {
|
||||
switch (searchResult.indexUid) {
|
||||
case MeiliIndices.LIBRARY_ITEM: {
|
||||
result.libraryItems = filterHitsWithHighlight<MeiliLibraryItem>(
|
||||
searchResult,
|
||||
"descriptions"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case MeiliIndices.CONTENT: {
|
||||
result.contents = filterHitsWithHighlight<MeiliContent>(searchResult, "translations");
|
||||
break;
|
||||
}
|
||||
|
||||
case MeiliIndices.VIDEOS: {
|
||||
result.videos = filterHitsWithHighlight<MeiliVideo>(searchResult);
|
||||
break;
|
||||
}
|
||||
|
||||
case MeiliIndices.POST: {
|
||||
result.posts = filterHitsWithHighlight<MeiliPost>(searchResult, "translations");
|
||||
break;
|
||||
}
|
||||
|
||||
case MeiliIndices.WEAPON: {
|
||||
result.weapons = filterHitsWithHighlight<MeiliWeapon>(searchResult, "translations");
|
||||
break;
|
||||
}
|
||||
|
||||
case MeiliIndices.WIKI_PAGE: {
|
||||
result.wikiPages = filterHitsWithHighlight<MeiliWikiPage>(searchResult, "translations");
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log("What the fuck?");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setMultiResult(result);
|
||||
};
|
||||
|
||||
if (q === "") {
|
||||
setMultiResult({});
|
||||
} else {
|
||||
fetchMultiResult();
|
||||
}
|
||||
|
||||
setQuery(q);
|
||||
}, []);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
isVisible={isSearchOpened}
|
||||
onCloseRequest={() => {
|
||||
setSearchOpened(false);
|
||||
sendAnalytics("Search", "Close search");
|
||||
}}
|
||||
onOpen={() => searchInputRef.current?.focus()}
|
||||
fillViewport>
|
||||
<h2 className="inline-flex place-items-center gap-2 text-2xl">
|
||||
<Ico icon="search" isFilled />
|
||||
{format("search")}
|
||||
</h2>
|
||||
<TextInput
|
||||
ref={searchInputRef}
|
||||
onChange={fetchSearchResults}
|
||||
value={query}
|
||||
placeholder={format("search_placeholder")}
|
||||
/>
|
||||
|
||||
<div className="flex w-full flex-wrap gap-12 gap-x-16">
|
||||
{isDefined(multiResult.libraryItems) && (
|
||||
<SearchResultSection
|
||||
title={format("library")}
|
||||
icon="auto_stories"
|
||||
href={`/library?page=1&query=${query}\
|
||||
&sort=0&primary=true&secondary=true&subitems=true&status=all`}
|
||||
totalHits={multiResult.libraryItems.estimatedTotalHits}>
|
||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
||||
{multiResult.libraryItems.hits.map((item) => (
|
||||
<TranslatedPreviewCard
|
||||
key={item.id}
|
||||
className="w-56"
|
||||
href={`/library/${item.slug}`}
|
||||
onClick={() => setSearchOpened(false)}
|
||||
translations={filterHasAttributes(item._formatted.descriptions, [
|
||||
"language.data.attributes.code",
|
||||
]).map((translation) => ({
|
||||
language: translation.language.data.attributes.code,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
description: containsHighlight(translation.description)
|
||||
? translation.description
|
||||
: undefined,
|
||||
}))}
|
||||
fallback={{ title: item._formatted.title, subtitle: item._formatted.subtitle }}
|
||||
thumbnail={item.thumbnail?.data?.attributes}
|
||||
thumbnailAspectRatio="21/29.7"
|
||||
thumbnailRounded={false}
|
||||
keepInfoVisible
|
||||
topChips={
|
||||
item.metadata && item.metadata.length > 0 && item.metadata[0]
|
||||
? [formatLibraryItemSubType(item.metadata[0])]
|
||||
: []
|
||||
}
|
||||
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
|
||||
(category) => formatCategory(category.attributes.slug)
|
||||
)}
|
||||
metadata={{
|
||||
releaseDate: item.release_date,
|
||||
price: item.price,
|
||||
position: "Bottom",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SearchResultSection>
|
||||
)}
|
||||
|
||||
{isDefined(multiResult.contents) && (
|
||||
<SearchResultSection
|
||||
title={format("contents")}
|
||||
icon="workspaces"
|
||||
href={`/contents/all?page=1&query=${query}&sort=0`}
|
||||
totalHits={multiResult.contents.estimatedTotalHits}>
|
||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
||||
{multiResult.contents.hits.map((item) => (
|
||||
<TranslatedPreviewCard
|
||||
key={item.id}
|
||||
className="w-56"
|
||||
href={`/contents/${item.slug}`}
|
||||
onClick={() => setSearchOpened(false)}
|
||||
translations={filterHasAttributes(item._formatted.translations, [
|
||||
"language.data.attributes.code",
|
||||
]).map(({ displayable_description, language, ...otherAttributes }) => ({
|
||||
...otherAttributes,
|
||||
description: containsHighlight(displayable_description)
|
||||
? displayable_description
|
||||
: undefined,
|
||||
language: language.data.attributes.code,
|
||||
}))}
|
||||
fallback={{ title: prettySlug(item.slug) }}
|
||||
thumbnail={item.thumbnail?.data?.attributes}
|
||||
thumbnailAspectRatio="3/2"
|
||||
thumbnailForceAspectRatio
|
||||
topChips={
|
||||
item.type?.data?.attributes
|
||||
? [formatContentType(item.type.data.attributes.slug)]
|
||||
: undefined
|
||||
}
|
||||
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
|
||||
(category) => formatCategory(category.attributes.slug)
|
||||
)}
|
||||
keepInfoVisible
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SearchResultSection>
|
||||
)}
|
||||
|
||||
{isDefined(multiResult.wikiPages) && (
|
||||
<SearchResultSection
|
||||
title={format("wiki")}
|
||||
icon="travel_explore"
|
||||
href={`/wiki?page=1&query=${query}`}
|
||||
totalHits={multiResult.wikiPages.estimatedTotalHits}>
|
||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
||||
{multiResult.wikiPages.hits.map((item) => (
|
||||
<TranslatedPreviewCard
|
||||
key={item.id}
|
||||
className="w-56"
|
||||
href={`/wiki/${item.slug}`}
|
||||
onClick={() => setSearchOpened(false)}
|
||||
translations={filterHasAttributes(item._formatted.translations, [
|
||||
"language.data.attributes.code",
|
||||
]).map(
|
||||
({
|
||||
aliases,
|
||||
summary,
|
||||
displayable_description,
|
||||
language,
|
||||
...otherAttributes
|
||||
}) => ({
|
||||
...otherAttributes,
|
||||
subtitle:
|
||||
aliases && aliases.length > 0
|
||||
? aliases.map((alias) => alias?.alias).join("・")
|
||||
: undefined,
|
||||
description: containsHighlight(displayable_description)
|
||||
? displayable_description
|
||||
: summary,
|
||||
language: language.data.attributes.code,
|
||||
})
|
||||
)}
|
||||
fallback={{ title: prettySlug(item.slug) }}
|
||||
thumbnail={item.thumbnail?.data?.attributes}
|
||||
thumbnailAspectRatio={"4/3"}
|
||||
thumbnailRounded
|
||||
thumbnailForceAspectRatio
|
||||
keepInfoVisible
|
||||
topChips={filterHasAttributes(item.tags?.data, ["attributes"]).map((tag) =>
|
||||
formatWikiTag(tag.attributes.slug)
|
||||
)}
|
||||
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
|
||||
(category) => formatCategory(category.attributes.slug)
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SearchResultSection>
|
||||
)}
|
||||
|
||||
{isDefined(multiResult.posts) && (
|
||||
<SearchResultSection
|
||||
title={format("news")}
|
||||
icon="newspaper"
|
||||
href={`/news?page=1&query=${query}`}
|
||||
totalHits={multiResult.posts.estimatedTotalHits}>
|
||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
||||
{multiResult.posts.hits.map((item) => (
|
||||
<TranslatedPreviewCard
|
||||
className="w-56"
|
||||
key={item.id}
|
||||
href={`/news/${item.slug}`}
|
||||
onClick={() => setSearchOpened(false)}
|
||||
translations={filterHasAttributes(item._formatted.translations, [
|
||||
"language.data.attributes.code",
|
||||
]).map(({ excerpt, displayable_description, language, ...otherAttributes }) => ({
|
||||
...otherAttributes,
|
||||
description: containsHighlight(displayable_description)
|
||||
? displayable_description
|
||||
: excerpt,
|
||||
language: language.data.attributes.code,
|
||||
}))}
|
||||
fallback={{ title: prettySlug(item.slug) }}
|
||||
thumbnail={item.thumbnail?.data?.attributes}
|
||||
thumbnailAspectRatio="3/2"
|
||||
thumbnailForceAspectRatio
|
||||
keepInfoVisible
|
||||
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"]).map(
|
||||
(category) => formatCategory(category.attributes.slug)
|
||||
)}
|
||||
metadata={{
|
||||
releaseDate: item.date,
|
||||
releaseDateFormat: "long",
|
||||
position: "Top",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SearchResultSection>
|
||||
)}
|
||||
|
||||
{isDefined(multiResult.videos) && (
|
||||
<SearchResultSection
|
||||
title={format("videos")}
|
||||
icon="movie"
|
||||
href={`/archives/videos?page=1&query=${query}&sort=1&gone=`}
|
||||
totalHits={multiResult.videos.estimatedTotalHits}>
|
||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
||||
{multiResult.videos.hits.map((item) => (
|
||||
<PreviewCard
|
||||
className="w-56"
|
||||
key={item.uid}
|
||||
href={`/archives/videos/v/${item.uid}`}
|
||||
onClick={() => setSearchOpened(false)}
|
||||
title={item._formatted.title}
|
||||
thumbnail={getVideoThumbnailURL(item.uid)}
|
||||
thumbnailAspectRatio="16/9"
|
||||
thumbnailForceAspectRatio
|
||||
keepInfoVisible
|
||||
metadata={{
|
||||
releaseDate: item.published_date,
|
||||
views: item.views,
|
||||
author: item._formatted.channel?.data?.attributes?.title,
|
||||
position: "Top",
|
||||
}}
|
||||
description={
|
||||
item._matchesPosition.description &&
|
||||
item._matchesPosition.description.length > 0
|
||||
? item._formatted.description
|
||||
: undefined
|
||||
}
|
||||
hoverlay={{
|
||||
__typename: "Video",
|
||||
duration: item.duration,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SearchResultSection>
|
||||
)}
|
||||
|
||||
{isDefined(multiResult.weapons) && (
|
||||
<SearchResultSection
|
||||
title={format("weapon", { count: Infinity })}
|
||||
icon="shield"
|
||||
href={`/wiki/weapons?page=1&query=${query}`}
|
||||
totalHits={multiResult.weapons.estimatedTotalHits}>
|
||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
|
||||
{multiResult.weapons.hits.map((item) => (
|
||||
<TranslatedPreviewCard
|
||||
key={item.id}
|
||||
className="w-56"
|
||||
href={"/"}
|
||||
translations={filterHasAttributes(item._formatted.translations, [
|
||||
"language.data.attributes.code",
|
||||
]).map(({ description, language, names: [primaryName, ...aliases] }) => ({
|
||||
language: language.data.attributes.code,
|
||||
title: primaryName,
|
||||
subtitle: aliases.join("・"),
|
||||
description: containsHighlight(description) ? description : undefined,
|
||||
}))}
|
||||
fallback={{ title: prettySlug(item.slug) }}
|
||||
thumbnail={item.thumbnail?.data?.attributes}
|
||||
thumbnailAspectRatio="1/1"
|
||||
thumbnailForceAspectRatio
|
||||
thumbnailFitMethod="contain"
|
||||
keepInfoVisible
|
||||
topChips={
|
||||
item.type?.data?.attributes?.slug
|
||||
? [formatWeaponType(item.type.data.attributes.slug)]
|
||||
: undefined
|
||||
}
|
||||
bottomChips={filterHasAttributes(item.categories, ["attributes"]).map(
|
||||
(category) => formatCategory(category.attributes.slug)
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SearchResultSection>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface SearchResultSectionProps {
|
||||
title?: string | null;
|
||||
icon: MaterialSymbol;
|
||||
href: string;
|
||||
totalHits?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SearchResultSection = ({
|
||||
title,
|
||||
icon,
|
||||
href,
|
||||
totalHits,
|
||||
children,
|
||||
}: SearchResultSectionProps) => {
|
||||
const { format } = useFormat();
|
||||
const setSearchOpened = useAtomSetter(atoms.layout.searchOpened);
|
||||
return (
|
||||
<>
|
||||
{isDefined(totalHits) && totalHits > 0 && (
|
||||
<div>
|
||||
<div className="mb-6 grid place-content-start">
|
||||
<UpPressable
|
||||
className="grid grid-cols-[auto_1fr] place-items-center gap-6 px-6 py-4"
|
||||
href={href}
|
||||
onClick={() => setSearchOpened(false)}>
|
||||
<Ico icon={icon} className="!text-3xl" isFilled={false} />
|
||||
<div>
|
||||
<p className="font-headers text-lg">{title}</p>
|
||||
{isDefined(totalHits) && totalHits > SEARCH_LIMIT && (
|
||||
<p className="text-sm">
|
||||
({format("showing_x_out_of_y_results", { x: SEARCH_LIMIT, y: totalHits })})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</UpPressable>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,264 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ButtonGroup } from "components/Inputs/ButtonGroup";
|
||||
import { OrderableList } from "components/Inputs/OrderableList";
|
||||
import { Select } from "components/Inputs/Select";
|
||||
import { TextInput } from "components/Inputs/TextInput";
|
||||
import { Popup } from "components/Containers/Popup";
|
||||
import { sendAnalytics } from "helpers/analytics";
|
||||
import { cJoin, cIf } from "helpers/className";
|
||||
import { filterHasAttributes, isDefined } from "helpers/asserts";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomGetter, useAtomPair, useAtomSetter } from "helpers/atoms";
|
||||
import { PerfMode, ThemeMode } from "contexts/settings";
|
||||
import { Ico } from "components/Ico";
|
||||
import { useFormat } from "hooks/useFormat";
|
||||
import { ToolTip } from "components/ToolTip";
|
||||
import { Switch } from "components/Inputs/Switch";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const SettingsPopup = (): JSX.Element => {
|
||||
const [preferredLanguages, setPreferredLanguages] = useAtomPair(
|
||||
atoms.settings.preferredLanguages
|
||||
);
|
||||
const [isSettingsOpened, setSettingsOpened] = useAtomPair(atoms.layout.settingsOpened);
|
||||
const [currency, setCurrency] = useAtomPair(atoms.settings.currency);
|
||||
const [isDyslexic, setDyslexic] = useAtomPair(atoms.settings.dyslexic);
|
||||
const [fontSize, setFontSize] = useAtomPair(atoms.settings.fontSize);
|
||||
const [playerName, setPlayerName] = useAtomPair(atoms.settings.playerName);
|
||||
const [themeMode, setThemeMode] = useAtomPair(atoms.settings.themeMode);
|
||||
const setPerfMode = useAtomSetter(atoms.settings.perfMode);
|
||||
const perfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
||||
const isPerfModeToggleable = useAtomGetter(atoms.settings.isPerfModeToggleable);
|
||||
|
||||
const { format, formatLanguage } = useFormat();
|
||||
const currencies = useAtomGetter(atoms.localData.currencies);
|
||||
|
||||
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const currencyOptions = filterHasAttributes(currencies, ["attributes"]).map(
|
||||
(currentCurrency) => currentCurrency.attributes.code
|
||||
);
|
||||
|
||||
const [currencySelect, setCurrencySelect] = useState<number>(-1);
|
||||
useEffect(() => {
|
||||
if (isDefined(currency)) setCurrencySelect(currencyOptions.indexOf(currency));
|
||||
}, [currency, currencyOptions]);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
isVisible={isSettingsOpened}
|
||||
onCloseRequest={() => {
|
||||
setSettingsOpened(false);
|
||||
sendAnalytics("Settings", "Close settings");
|
||||
}}>
|
||||
<h2 className="inline-flex place-items-center gap-2 text-2xl">
|
||||
<Ico icon="discover_tune" isFilled />
|
||||
{format("settings")}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={cJoin(
|
||||
`mt-4 grid justify-items-center gap-16 text-center`,
|
||||
cIf(!is1ColumnLayout, "grid-cols-[auto_auto]")
|
||||
)}>
|
||||
{router.locales && (
|
||||
<div>
|
||||
<h3 className="text-xl">{format("language", { count: preferredLanguages.length })}</h3>
|
||||
{preferredLanguages.length > 0 && (
|
||||
<OrderableList
|
||||
items={preferredLanguages.map((locale) => ({
|
||||
code: locale,
|
||||
name: formatLanguage(locale),
|
||||
}))}
|
||||
insertLabels={[
|
||||
{
|
||||
insertAt: 0,
|
||||
name: format("primary_language"),
|
||||
},
|
||||
{
|
||||
insertAt: 1,
|
||||
name: format("secondary_language"),
|
||||
},
|
||||
]}
|
||||
onChange={(items) => {
|
||||
const newPreferredLanguages = items.map((item) => item.code);
|
||||
setPreferredLanguages(newPreferredLanguages);
|
||||
sendAnalytics("Settings", "Change preferred languages");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cJoin(
|
||||
"grid place-items-center gap-8 text-center",
|
||||
cIf(!is1ColumnLayout, "grid-cols-2")
|
||||
)}>
|
||||
<div>
|
||||
<div className="flex place-content-center place-items-center gap-1">
|
||||
<h3 className="text-xl">{format("theme")}</h3>
|
||||
<ToolTip content={format("dark_mode_extension_warning")} placement="top">
|
||||
<Ico icon="info" />
|
||||
</ToolTip>
|
||||
</div>
|
||||
<ButtonGroup
|
||||
buttonsProps={[
|
||||
{
|
||||
onClick: () => {
|
||||
setThemeMode(ThemeMode.Light);
|
||||
sendAnalytics("Settings", "Change theme (light)");
|
||||
},
|
||||
active: themeMode === ThemeMode.Light,
|
||||
text: format("light"),
|
||||
},
|
||||
{
|
||||
onClick: () => {
|
||||
setThemeMode(ThemeMode.Auto);
|
||||
sendAnalytics("Settings", "Change theme (auto)");
|
||||
},
|
||||
active: themeMode === ThemeMode.Auto,
|
||||
text: format("auto"),
|
||||
},
|
||||
{
|
||||
onClick: () => {
|
||||
setThemeMode(ThemeMode.Dark);
|
||||
sendAnalytics("Settings", "Change theme (dark)");
|
||||
},
|
||||
active: themeMode === ThemeMode.Dark,
|
||||
text: format("dark"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl">{format("currency")}</h3>
|
||||
<div>
|
||||
<Select
|
||||
options={currencyOptions}
|
||||
value={currencySelect}
|
||||
onChange={(newCurrency) => {
|
||||
const newCurrencyName = currencyOptions[newCurrency];
|
||||
if (isDefined(newCurrencyName)) {
|
||||
setCurrency(newCurrencyName);
|
||||
sendAnalytics("Settings", `Change currency (${currencyOptions[newCurrency]})`);
|
||||
}
|
||||
}}
|
||||
className="w-28"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl">{format("font_size")}</h3>
|
||||
<ButtonGroup
|
||||
buttonsProps={[
|
||||
{
|
||||
onClick: () => {
|
||||
setFontSize((current) => current / 1.05);
|
||||
sendAnalytics(
|
||||
"Settings",
|
||||
`Change font size (${((fontSize / 1.05) * 100).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}%)`
|
||||
);
|
||||
},
|
||||
icon: "text_decrease",
|
||||
},
|
||||
{
|
||||
onClick: () => {
|
||||
setFontSize(1);
|
||||
sendAnalytics("Settings", "Change font size (100%)");
|
||||
},
|
||||
text: `${(fontSize * 100).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}%`,
|
||||
},
|
||||
{
|
||||
onClick: () => {
|
||||
setFontSize((current) => current * 1.05);
|
||||
sendAnalytics(
|
||||
"Settings",
|
||||
`Change font size (${(fontSize * 1.05 * 100).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}%)`
|
||||
);
|
||||
},
|
||||
icon: "text_increase",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl">{format("font")}</h3>
|
||||
<div className="grid gap-2">
|
||||
<ButtonGroup
|
||||
vertical
|
||||
buttonsProps={[
|
||||
{
|
||||
active: !isDyslexic,
|
||||
onClick: () => {
|
||||
setDyslexic(false);
|
||||
sendAnalytics("Settings", "Change font (Zen Maru Gothic)");
|
||||
},
|
||||
className: "font-zenMaruGothic",
|
||||
text: "Zen Maru Gothic",
|
||||
},
|
||||
{
|
||||
active: isDyslexic,
|
||||
onClick: () => {
|
||||
setDyslexic(true);
|
||||
sendAnalytics("Settings", "Change font (OpenDyslexic)");
|
||||
},
|
||||
className: "font-openDyslexic",
|
||||
text: "OpenDyslexic",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex place-content-center place-items-center gap-1">
|
||||
<h3 className="text-xl">{format("player_name")}</h3>
|
||||
<ToolTip content={format("player_name_tooltip")} placement="top">
|
||||
<Ico icon="info" />
|
||||
</ToolTip>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="(player)"
|
||||
className="w-48"
|
||||
value={playerName}
|
||||
onChange={(newName) => {
|
||||
setPlayerName(newName);
|
||||
sendAnalytics("Settings", "Change username");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid place-items-center">
|
||||
<div className="flex place-content-center place-items-center gap-1">
|
||||
<h3 className="text-xl">{format("performance_mode")}</h3>
|
||||
<ToolTip content={format("performance_mode_tooltip")} placement="top">
|
||||
<Ico icon="info" />
|
||||
</ToolTip>
|
||||
</div>
|
||||
<Switch
|
||||
value={perfModeEnabled}
|
||||
onClick={() => setPerfMode(perfModeEnabled ? PerfMode.Off : PerfMode.On)}
|
||||
disabled={!isPerfModeToggleable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
|
@ -1,13 +0,0 @@
|
|||
import { Immutable } from "helpers/types";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SubPanel(props: Immutable<Props>): JSX.Element {
|
||||
return (
|
||||
<div className="grid pt-10 pb-20 px-6 desktop:py-8 desktop:px-10 gap-y-2 text-center">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1,75 +0,0 @@
|
|||
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||
import { Immutable } from "helpers/types";
|
||||
import { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import Hotkeys from "react-hot-keys";
|
||||
|
||||
interface Props {
|
||||
setState:
|
||||
| Dispatch<SetStateAction<boolean | undefined>>
|
||||
| Dispatch<SetStateAction<boolean>>;
|
||||
state?: boolean;
|
||||
children: React.ReactNode;
|
||||
fillViewport?: boolean;
|
||||
hideBackground?: boolean;
|
||||
padding?: boolean;
|
||||
}
|
||||
|
||||
export function Popup(props: Immutable<Props>): JSX.Element {
|
||||
const {
|
||||
setState,
|
||||
state,
|
||||
children,
|
||||
fillViewport,
|
||||
hideBackground,
|
||||
padding = true,
|
||||
} = props;
|
||||
|
||||
const appLayout = useAppLayout();
|
||||
|
||||
useEffect(() => {
|
||||
appLayout.setMenuGestures(!state);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Hotkeys
|
||||
keyName="escape"
|
||||
allowRepeat
|
||||
onKeyDown={() => {
|
||||
setState(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`fixed inset-0 z-50 grid place-content-center
|
||||
transition-[backdrop-filter] duration-500 ${
|
||||
state ? "[backdrop-filter:blur(2px)]" : "pointer-events-none touch-none"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`fixed bg-shade inset-0 transition-all duration-500 ${
|
||||
state ? "bg-opacity-50" : "bg-opacity-0"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setState(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`${
|
||||
padding && "p-10 mobile:p-6"
|
||||
} grid gap-4 place-items-center transition-transform ${
|
||||
state ? "scale-100" : "scale-0"
|
||||
} ${
|
||||
fillViewport
|
||||
? "absolute inset-10"
|
||||
: "relative max-h-[80vh] overflow-y-auto mobile:w-[85vw]"
|
||||
} ${
|
||||
hideBackground ? "" : "bg-light rounded-lg shadow-2xl shadow-shade"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
|
@ -1,26 +1,28 @@
|
|||
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||
import { prettySlug } from "helpers/formatters";
|
||||
import { getStatusDescription } from "helpers/others";
|
||||
import { Immutable, PostWithTranslations } from "helpers/types";
|
||||
import { useSmartLanguage } from "hooks/useSmartLanguage";
|
||||
import { Fragment } from "react";
|
||||
import { AppLayout } from "./AppLayout";
|
||||
import { Chip } from "./Chip";
|
||||
import { HorizontalLine } from "./HorizontalLine";
|
||||
import { Markdawn } from "./Markdown/Markdawn";
|
||||
import { TOC } from "./Markdown/TOC";
|
||||
import { ReturnButton, ReturnButtonType } from "./PanelComponents/ReturnButton";
|
||||
import { ContentPanel } from "./Panels/ContentPanel";
|
||||
import { SubPanel } from "./Panels/SubPanel";
|
||||
import { RecorderChip } from "./RecorderChip";
|
||||
import { useCallback } from "react";
|
||||
import { AppLayout, AppLayoutRequired } from "./AppLayout";
|
||||
import { getTocFromMarkdawn, Markdawn, TableOfContents } from "./Markdown/Markdawn";
|
||||
import { ReturnButton } from "./PanelComponents/ReturnButton";
|
||||
import { ContentPanel } from "./Containers/ContentPanel";
|
||||
import { SubPanel } from "./Containers/SubPanel";
|
||||
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 { 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";
|
||||
|
||||
interface Props {
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props extends AppLayoutRequired {
|
||||
post: PostWithTranslations;
|
||||
langui: AppStaticProps["langui"];
|
||||
languages: AppStaticProps["languages"];
|
||||
currencies: AppStaticProps["currencies"];
|
||||
returnHref?: string;
|
||||
returnTitle?: string | null | undefined;
|
||||
displayCredits?: boolean;
|
||||
|
@ -32,11 +34,10 @@ interface Props {
|
|||
appendBody?: JSX.Element;
|
||||
}
|
||||
|
||||
export function PostPage(props: Immutable<Props>): JSX.Element {
|
||||
const {
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const PostPage = ({
|
||||
post,
|
||||
langui,
|
||||
languages,
|
||||
returnHref,
|
||||
returnTitle,
|
||||
displayCredits,
|
||||
|
@ -45,90 +46,53 @@ export function PostPage(props: Immutable<Props>): JSX.Element {
|
|||
displayLanguageSwitcher,
|
||||
appendBody,
|
||||
prependBody,
|
||||
} = props;
|
||||
const displayTitle = props.displayTitle ?? true;
|
||||
displayTitle = true,
|
||||
...otherProps
|
||||
}: Props): JSX.Element => {
|
||||
const { formatCategory } = useFormat();
|
||||
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
|
||||
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
|
||||
|
||||
const [selectedTranslation, LanguageSwitcher] = useSmartLanguage({
|
||||
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
|
||||
items: post.translations,
|
||||
languages: languages,
|
||||
languageExtractor: (item) => item.language?.data?.attributes?.code,
|
||||
languageExtractor: useCallback(
|
||||
(item: NonNullable<PostWithTranslations["translations"][number]>) =>
|
||||
item.language?.data?.attributes?.code,
|
||||
[]
|
||||
),
|
||||
});
|
||||
|
||||
const thumbnail =
|
||||
selectedTranslation?.thumbnail?.data?.attributes ??
|
||||
post.thumbnail?.data?.attributes;
|
||||
|
||||
selectedTranslation?.thumbnail?.data?.attributes ?? post.thumbnail?.data?.attributes;
|
||||
const body = selectedTranslation?.body ?? "";
|
||||
const title = selectedTranslation?.title ?? prettySlug(post.slug);
|
||||
const except = selectedTranslation?.excerpt ?? "";
|
||||
const excerpt = selectedTranslation?.excerpt ?? "";
|
||||
|
||||
const toc = getTocFromMarkdawn(body, title);
|
||||
|
||||
const subPanelElems = [
|
||||
returnHref && returnTitle && !is1ColumnLayout && (
|
||||
<ReturnButton href={returnHref} title={returnTitle} />
|
||||
),
|
||||
|
||||
displayCredits && <Credits status={selectedTranslation?.status} authors={post.authors?.data} />,
|
||||
|
||||
displayToc && isDefined(toc) && (
|
||||
<TableOfContents toc={toc} onContentClicked={() => setSubPanelOpened(false)} />
|
||||
),
|
||||
];
|
||||
|
||||
const subPanel =
|
||||
returnHref || returnTitle || displayCredits || displayToc ? (
|
||||
subPanelElems.filter(Boolean).length > 0 ? (
|
||||
<SubPanel>
|
||||
{returnHref && returnTitle && (
|
||||
<ReturnButton
|
||||
href={returnHref}
|
||||
title={returnTitle}
|
||||
langui={langui}
|
||||
displayOn={ReturnButtonType.desktop}
|
||||
horizontalLine
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayCredits && (
|
||||
<>
|
||||
{selectedTranslation && (
|
||||
<div className="grid grid-flow-col place-items-center place-content-center gap-2">
|
||||
<p className="font-headers">{langui.status}:</p>
|
||||
|
||||
<ToolTip
|
||||
content={getStatusDescription(
|
||||
selectedTranslation.status,
|
||||
langui
|
||||
)}
|
||||
maxWidth={"20rem"}
|
||||
>
|
||||
<Chip>{selectedTranslation.status}</Chip>
|
||||
</ToolTip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post.authors && post.authors.data.length > 0 && (
|
||||
<div>
|
||||
<p className="font-headers">{"Authors"}:</p>
|
||||
<div className="grid place-items-center place-content-center gap-2">
|
||||
{post.authors.data.map((author) => (
|
||||
<Fragment key={author.id}>
|
||||
{author.attributes && (
|
||||
<RecorderChip
|
||||
langui={langui}
|
||||
recorder={author.attributes}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HorizontalLine />
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayToc && <TOC text={body} title={title} />}
|
||||
<ElementsSeparator>{subPanelElems}</ElementsSeparator>
|
||||
</SubPanel>
|
||||
) : undefined;
|
||||
|
||||
const contentPanel = (
|
||||
<ContentPanel>
|
||||
{returnHref && returnTitle && (
|
||||
<ReturnButton
|
||||
href={returnHref}
|
||||
title={returnTitle}
|
||||
langui={langui}
|
||||
displayOn={ReturnButtonType.mobile}
|
||||
horizontalLine
|
||||
/>
|
||||
{is1ColumnLayout && returnHref && returnTitle && (
|
||||
<ReturnButton href={returnHref} title={returnTitle} className="mb-10" />
|
||||
)}
|
||||
|
||||
{displayThumbnailHeader ? (
|
||||
|
@ -136,42 +100,38 @@ export function PostPage(props: Immutable<Props>): JSX.Element {
|
|||
<ThumbnailHeader
|
||||
thumbnail={thumbnail}
|
||||
title={title}
|
||||
description={except}
|
||||
langui={langui}
|
||||
categories={post.categories}
|
||||
languageSwitcher={<LanguageSwitcher />}
|
||||
description={excerpt}
|
||||
categories={filterHasAttributes(post.categories?.data, ["attributes"]).map((category) =>
|
||||
formatCategory(category.attributes.slug)
|
||||
)}
|
||||
releaseDate={post.date}
|
||||
languageSwitcher={
|
||||
languageSwitcherProps.locales.size > 1 ? (
|
||||
<LanguageSwitcher {...languageSwitcherProps} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<HorizontalLine />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{displayLanguageSwitcher && (
|
||||
<div className="grid place-content-end place-items-start">
|
||||
<LanguageSwitcher />
|
||||
<LanguageSwitcher {...languageSwitcherProps} />
|
||||
</div>
|
||||
)}
|
||||
{displayTitle && (
|
||||
<h1 className="text-center flex gap-3 justify-center text-4xl my-16">
|
||||
{title}
|
||||
</h1>
|
||||
<h1 className="my-16 flex justify-center gap-3 text-center text-4xl">{title}</h1>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{prependBody}
|
||||
<Markdawn text={body} />
|
||||
{body && <Markdawn text={body} />}
|
||||
|
||||
{appendBody}
|
||||
</ContentPanel>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
navTitle={title}
|
||||
contentPanel={contentPanel}
|
||||
subPanel={subPanel}
|
||||
thumbnail={thumbnail ?? undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <AppLayout {...otherProps} contentPanel={contentPanel} subPanel={subPanel} />;
|
||||
};
|
||||
|
|
|
@ -1,25 +1,32 @@
|
|||
import { useAppLayout } from "contexts/AppLayoutContext";
|
||||
import {
|
||||
DatePickerFragment,
|
||||
PricePickerFragment,
|
||||
UploadImageFragment,
|
||||
} from "graphql/generated";
|
||||
import { AppStaticProps } from "graphql/getAppStaticProps";
|
||||
import {
|
||||
prettyDate,
|
||||
prettyDuration,
|
||||
prettyPrice,
|
||||
prettyShortenNumber,
|
||||
} from "helpers/formatters";
|
||||
import { MouseEventHandler, useCallback } from "react";
|
||||
import { Markdown } from "./Markdown/Markdown";
|
||||
import { Chip } from "components/Chip";
|
||||
import { Ico } from "components/Ico";
|
||||
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 { prettyDuration, prettyShortenNumber } from "helpers/formatters";
|
||||
import { ImageQuality } from "helpers/img";
|
||||
import { Immutable } from "helpers/types";
|
||||
import Link from "next/link";
|
||||
import { Chip } from "./Chip";
|
||||
import { Img } from "./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";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
thumbnail?: UploadImageFragment | string | null | undefined;
|
||||
thumbnailAspectRatio?: string;
|
||||
thumbnailForceAspectRatio?: boolean;
|
||||
thumbnailFitMethod?: "contain" | "cover";
|
||||
thumbnailRounded?: boolean;
|
||||
href: string;
|
||||
pre_title?: string | null | undefined;
|
||||
title: string | null | undefined;
|
||||
|
@ -28,164 +35,130 @@ interface Props {
|
|||
topChips?: string[];
|
||||
bottomChips?: string[];
|
||||
keepInfoVisible?: boolean;
|
||||
stackNumber?: number;
|
||||
metadata?: {
|
||||
currencies?: AppStaticProps["currencies"];
|
||||
release_date?: DatePickerFragment | null;
|
||||
releaseDate?: DatePickerFragment | null;
|
||||
releaseDateFormat?: Intl.DateTimeFormatOptions["dateStyle"];
|
||||
price?: PricePickerFragment | null;
|
||||
views?: number;
|
||||
author?: string;
|
||||
position: "Bottom" | "Top";
|
||||
};
|
||||
infoAppend?: React.ReactNode;
|
||||
hoverlay?:
|
||||
| {
|
||||
__typename: "Video";
|
||||
duration: number;
|
||||
}
|
||||
| { __typename: "anotherHoverlayName" };
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
export function PreviewCard(props: Immutable<Props>): JSX.Element {
|
||||
const {
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const PreviewCard = ({
|
||||
href,
|
||||
thumbnail,
|
||||
thumbnailAspectRatio = "4/3",
|
||||
thumbnailForceAspectRatio = false,
|
||||
thumbnailFitMethod = "cover",
|
||||
thumbnailRounded = true,
|
||||
pre_title,
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
stackNumber = 0,
|
||||
topChips,
|
||||
bottomChips,
|
||||
keepInfoVisible,
|
||||
thumbnailAspectRatio,
|
||||
metadata,
|
||||
hoverlay,
|
||||
} = props;
|
||||
infoAppend,
|
||||
className,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: Props): JSX.Element => {
|
||||
const { formatPrice, formatDate } = useFormat();
|
||||
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
|
||||
const preferredCurrency = useAtomGetter(atoms.settings.currency);
|
||||
const isHoverable = useDeviceSupportsHover();
|
||||
|
||||
const appLayout = useAppLayout();
|
||||
|
||||
const metadataJSX =
|
||||
metadata && (metadata.release_date || metadata.price) ? (
|
||||
<div className="flex flex-row flex-wrap gap-x-3 w-full">
|
||||
{metadata.release_date && (
|
||||
<p className="mobile:text-xs text-sm">
|
||||
<span className="material-icons !text-base translate-y-[.15em] mr-1">
|
||||
event
|
||||
</span>
|
||||
{prettyDate(metadata.release_date)}
|
||||
const metadataJSX = (
|
||||
<>
|
||||
{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" />
|
||||
{formatDate(metadata.releaseDate)}
|
||||
</p>
|
||||
)}
|
||||
{metadata.price && metadata.currencies && (
|
||||
<p className="mobile:text-xs text-sm justify-self-end">
|
||||
<span className="material-icons !text-base translate-y-[.15em] mr-1">
|
||||
shopping_cart
|
||||
</span>
|
||||
{prettyPrice(
|
||||
metadata.price,
|
||||
metadata.currencies,
|
||||
appLayout.currency
|
||||
)}
|
||||
{metadata.price && (
|
||||
<p className="justify-self-end text-sm">
|
||||
<Ico icon="shopping_cart" className="mr-1 translate-y-[.15em] !text-base" />
|
||||
{formatPrice(metadata.price, preferredCurrency)}
|
||||
</p>
|
||||
)}
|
||||
{metadata.views && (
|
||||
<p className="mobile:text-xs text-sm">
|
||||
<span className="material-icons !text-base translate-y-[.15em] mr-1">
|
||||
visibility
|
||||
</span>
|
||||
<p className="text-sm">
|
||||
<Ico icon="visibility" className="mr-1 translate-y-[.15em] !text-base" />
|
||||
{prettyShortenNumber(metadata.views)}
|
||||
</p>
|
||||
)}
|
||||
{metadata.author && (
|
||||
<p className="mobile:text-xs text-sm">
|
||||
<span className="material-icons !text-base translate-y-[.15em] mr-1">
|
||||
person
|
||||
</span>
|
||||
{metadata.author}
|
||||
<p className="text-sm">
|
||||
<Ico icon="person" className="mr-1 translate-y-[.15em] !text-base" />
|
||||
<Markdown text={metadata.author} className="inline-block" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Link href={href} passHref>
|
||||
<div
|
||||
className="drop-shadow-shade-xl cursor-pointer grid items-end
|
||||
fine:[--cover-opacity:0] hover:[--cover-opacity:1] hover:scale-[1.02]
|
||||
[--bg-opacity:0] hover:[--bg-opacity:0.5] [--play-opacity:0]
|
||||
hover:[--play-opacity:100] transition-transform
|
||||
[--stacked-top:0] hover:[--stacked-top:1]"
|
||||
>
|
||||
{stackNumber > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="bg-light rounded-md overflow-hidden absolute transition-[top_transform]
|
||||
inset-0 -top-[var(--stacked-top)*2.1rem] brightness-[0.8] sepia-[0.5]
|
||||
scale-[calc(1-0.15*var(--stacked-top))]"
|
||||
>
|
||||
{thumbnail && (
|
||||
<Img
|
||||
className="opacity-30 "
|
||||
image={thumbnail}
|
||||
quality={ImageQuality.Medium}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-light rounded-md overflow-hidden absolute transition-[top_transform]
|
||||
-top-[var(--stacked-top)*1rem] inset-0 brightness-[0.9] sepia-[0.2]
|
||||
scale-[calc(1-0.06*var(--stacked-top))]"
|
||||
>
|
||||
{thumbnail && (
|
||||
<Img
|
||||
className="opacity-70"
|
||||
image={thumbnail}
|
||||
quality={ImageQuality.Medium}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<UpPressable
|
||||
className={cJoin("relative grid items-end text-left", className)}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
noBackground
|
||||
disabled={disabled}>
|
||||
<div className={cJoin("group", cIf(disabled, "pointer-events-none touch-none select-none"))}>
|
||||
{thumbnail ? (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
aspectRatio: thumbnailForceAspectRatio ? thumbnailAspectRatio : "unset",
|
||||
}}>
|
||||
<Img
|
||||
className={
|
||||
keepInfoVisible
|
||||
? "rounded-t-md"
|
||||
: "rounded-md coarse:rounded-b-none"
|
||||
}
|
||||
image={thumbnail}
|
||||
className={cJoin(
|
||||
cIf(
|
||||
thumbnailRounded,
|
||||
cIf(keepInfoVisible, "rounded-t-md", "rounded-md notHoverable:rounded-b-none")
|
||||
),
|
||||
cIf(thumbnailForceAspectRatio, "h-full w-full"),
|
||||
cIf(
|
||||
thumbnailForceAspectRatio && thumbnailFitMethod === "contain",
|
||||
"object-contain",
|
||||
"object-cover"
|
||||
)
|
||||
)}
|
||||
src={thumbnail}
|
||||
quality={ImageQuality.Medium}
|
||||
/>
|
||||
{stackNumber > 0 && (
|
||||
<div
|
||||
className="absolute right-2 top-2 text-light bg-black
|
||||
bg-opacity-60 px-2 rounded-full"
|
||||
>
|
||||
{stackNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hoverlay && hoverlay.__typename === "Video" && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 text-light grid
|
||||
place-content-center drop-shadow-shade-lg bg-shade
|
||||
bg-opacity-[var(--bg-opacity)] transition-colors"
|
||||
>
|
||||
<span
|
||||
className="material-icons text-6xl
|
||||
opacity-[var(--play-opacity)] transition-opacity"
|
||||
>
|
||||
play_circle_outline
|
||||
</span>
|
||||
className="absolute inset-0 grid place-content-center rounded-t-md
|
||||
bg-shade/0 text-light transition-colors group-hover:bg-shade/50">
|
||||
<Ico
|
||||
icon="play_circle"
|
||||
className="!text-6xl text-light opacity-0 drop-shadow-lg transition-opacity
|
||||
shadow-shade group-hover:opacity-100 dark:text-black"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute right-2 bottom-2 text-light bg-black
|
||||
bg-opacity-60 px-2 rounded-full"
|
||||
>
|
||||
<div className="absolute bottom-2 right-2 rounded-full bg-black/60 px-2 text-light">
|
||||
{prettyDuration(hoverlay.duration)}
|
||||
</div>
|
||||
</>
|
||||
|
@ -194,62 +167,85 @@ export function PreviewCard(props: Immutable<Props>): JSX.Element {
|
|||
) : (
|
||||
<div
|
||||
style={{ aspectRatio: thumbnailAspectRatio }}
|
||||
className={`w-full bg-light relative ${
|
||||
keepInfoVisible
|
||||
? "rounded-t-md"
|
||||
: "rounded-md coarse:rounded-b-none"
|
||||
}`}
|
||||
>
|
||||
{stackNumber > 0 && (
|
||||
<div
|
||||
className="absolute right-2 top-2 text-light bg-black
|
||||
bg-opacity-60 px-2 rounded-full"
|
||||
>
|
||||
{stackNumber}
|
||||
</div>
|
||||
className={cJoin(
|
||||
"relative w-full bg-highlight",
|
||||
cIf(keepInfoVisible, "rounded-t-md", "rounded-md notHoverable:rounded-b-none")
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`linearbg-obi ${
|
||||
!keepInfoVisible &&
|
||||
`fine:drop-shadow-shade-lg fine:absolute coarse:rounded-b-md
|
||||
bottom-2 -inset-x-0.5 opacity-[var(--cover-opacity)]`
|
||||
} transition-opacity z-20 grid p-4 gap-2`}
|
||||
>
|
||||
className={cJoin(
|
||||
"z-20 grid gap-2 p-4 transition-opacity linearbg-obi",
|
||||
cIf(
|
||||
!keepInfoVisible && isHoverable,
|
||||
`-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:shadow-lg
|
||||
notHoverable:rounded-b-md notHoverable:opacity-100`,
|
||||
cIf(!isPerfModeEnabled, "[border-radius:0%_0%_10%_10%_/_0%_0%_3%_3%]")
|
||||
)
|
||||
)}>
|
||||
{metadata?.position === "Top" && metadataJSX}
|
||||
{topChips && topChips.length > 0 && (
|
||||
<div className="grid grid-flow-col gap-1 overflow-hidden place-content-start">
|
||||
<div
|
||||
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll
|
||||
scrollbar-none">
|
||||
{topChips.map((text, index) => (
|
||||
<Chip key={index}>{text}</Chip>
|
||||
<Chip key={index} text={text} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="my-1">
|
||||
{pre_title && (
|
||||
<p className="leading-none mb-1 break-words">{pre_title}</p>
|
||||
)}
|
||||
{pre_title && <Markdown text={pre_title} className="mb-1 leading-none break-words" />}
|
||||
{title && (
|
||||
<p className="font-headers text-lg leading-none break-words">
|
||||
{title}
|
||||
</p>
|
||||
<Markdown
|
||||
text={title}
|
||||
className="font-headers text-lg font-bold leading-none break-words"
|
||||
/>
|
||||
)}
|
||||
{subtitle && <p className="leading-none break-words">{subtitle}</p>}
|
||||
{subtitle && <Markdown text={subtitle} className="leading-none break-words" />}
|
||||
</div>
|
||||
{description && <p>{description}</p>}
|
||||
{description && <Markdown text={description} className="overflow-hidden break-words" />}
|
||||
{bottomChips && bottomChips.length > 0 && (
|
||||
<div className="grid grid-flow-col gap-1 overflow-hidden place-content-start">
|
||||
<div
|
||||
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll
|
||||
scrollbar-none">
|
||||
{bottomChips.map((text, index) => (
|
||||
<Chip key={index} className="text-sm">
|
||||
{text}
|
||||
</Chip>
|
||||
<Chip key={index} className="text-sm" text={text} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadata?.position === "Bottom" && metadataJSX}
|
||||
|
||||
{infoAppend}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</UpPressable>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭──────────────────────╮
|
||||
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const TranslatedPreviewCard = ({
|
||||
translations,
|
||||
fallback,
|
||||
...otherProps
|
||||
}: TranslatedProps<Props, "description" | "pre_title" | "subtitle" | "title">): JSX.Element => {
|
||||
const [selectedTranslation] = useSmartLanguage({
|
||||
items: translations,
|
||||
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
|
||||
});
|
||||
return (
|
||||
<PreviewCard
|
||||
pre_title={selectedTranslation?.pre_title ?? fallback.pre_title}
|
||||
title={selectedTranslation?.title ?? fallback.title}
|
||||
subtitle={selectedTranslation?.subtitle ?? fallback.subtitle}
|
||||
description={selectedTranslation?.description ?? fallback.description}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import { UploadImageFragment } from "graphql/generated";
|
||||
import { ImageQuality } from "helpers/img";
|
||||
import { Immutable } from "helpers/types";
|
||||
import Link from "next/link";
|
||||
import { Chip } from "./Chip";
|
||||
import { Img } from "./Img";
|
||||
|
||||
interface Props {
|
||||
thumbnail?: UploadImageFragment | string | null | undefined;
|
||||
thumbnailAspectRatio?: string;
|
||||
href: string;
|
||||
pre_title?: string | null | undefined;
|
||||
title: string | null | undefined;
|
||||
subtitle?: string | null | undefined;
|
||||
topChips?: string[];
|
||||
bottomChips?: string[];
|
||||
}
|
||||
|
||||
export function PreviewLine(props: Immutable<Props>): JSX.Element {
|
||||
const {
|
||||
href,
|
||||
thumbnail,
|
||||
pre_title,
|
||||
title,
|
||||
subtitle,
|
||||
topChips,
|
||||
bottomChips,
|
||||
thumbnailAspectRatio,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Link href={href} passHref>
|
||||
<div
|
||||
className="drop-shadow-shade-xl rounded-md bg-light cursor-pointer
|
||||
hover:scale-[1.02] transition-transform flex flex-row gap-4
|
||||
overflow-hidden place-items-center pr-4 w-full h-36"
|
||||
>
|
||||
{thumbnail ? (
|
||||
<div className="h-full aspect-[3/2]">
|
||||
<Img image={thumbnail} quality={ImageQuality.Medium} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ aspectRatio: thumbnailAspectRatio }}></div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
{topChips && topChips.length > 0 && (
|
||||
<div className="grid grid-flow-col gap-1 overflow-hidden place-content-start">
|
||||
{topChips.map((text, index) => (
|
||||
<Chip key={index}>{text}</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col my-1">
|
||||
{pre_title && <p className="leading-none mb-1">{pre_title}</p>}
|
||||
{title && (
|
||||
<p className="font-headers text-lg leading-none">{title}</p>
|
||||
)}
|
||||
{subtitle && <p className="leading-none">{subtitle}</p>}
|
||||
</div>
|
||||
{bottomChips && bottomChips.length > 0 && (
|
||||
<div className="grid grid-flow-col gap-1 overflow-hidden place-content-start">
|
||||
{bottomChips.map((text, index) => (
|
||||
<Chip key={index} className="text-sm">
|
||||
{text}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|