Compare commits

..

374 Commits

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

* Changed material icons to symbols and added wikipage search

* Updated deps

* Changed color of the play button on previewcard overlay

* Updated search params

* Updated deps

* Audit fix

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

* Finish converting the remaining contexts into Jotai

* Changed the readme

* Fixed build

* Provider hell be gone

* Fixed build
2022-11-04 02:30:20 +01:00
DrMint f4ff30e279 Fixed pointer issue with Link elements 2022-11-01 03:43:36 +01:00
DrMint 16db6a9a39 Small improvements 2022-11-01 02:39:48 +01:00
DrMint 9fa3848456 Fixed build (missing dep + faulty dep) 2022-10-29 22:01:38 +02:00
DrMint b1b08e299a Updated deps 2022-10-29 21:34:20 +02:00
DrMint 42821a7490 Precommit, updated README 2022-10-29 21:26:11 +02:00
DrMint fe24a77d6e Improved CSS and design system 2022-10-29 16:22:49 +02:00
DrMint cffe26a29a Small improvements 2022-10-23 06:31:26 +02:00
DrMint 922a6af4c5 Removed useless dep 2022-10-23 06:23:13 +02:00
DrMint 0d9bf73f9d Removed useless dep 2022-10-23 06:22:02 +02:00
DrMint 230df12c22 Refacto context+styles and improved lightbox 2022-10-23 06:19:53 +02:00
DrMint 8aeae06432 Precommit 2022-10-22 01:45:30 +02:00
DrMint 155e7246d2 Updated deps 2022-10-22 01:42:35 +02:00
DrMint 25d99ee294 Reader settings are now saved in localStorage 2022-10-22 01:16:47 +02:00
DrMint 89ad4620d6 Splitted context into a bunch of contexts 2022-10-21 23:50:32 +02:00
DrMint c95e142ca0 Updated deps 2022-10-17 00:44:25 +02:00
DrMint e4b39a4c38 Fixed build 2022-10-17 00:14:26 +02:00
DrMint 0328e730e1 Added reader 2022-09-25 22:36:52 +02:00
DrMint 2dacf190d2 Fixed darkmode not keeping the user preference 2022-09-21 18:12:47 +02:00
DrMint ee9a9a67fc Some tailwind simplification 2022-09-21 18:12:04 +02:00
DrMint 06c61d0222 Improved 404 and 500 pages 2022-09-15 00:04:16 +02:00
DrMint 7db5578b3c Improved revalidation api 2022-09-14 08:39:05 +02:00
DrMint 8862be4118 Updated deps 2022-09-14 08:17:17 +02:00
DrMint 296dd194a4 Safari only has to disgard warning once per session 2022-09-05 17:29:44 +02:00
DrMint 9abd9f03f2 Updated deps 2022-09-05 17:29:24 +02:00
DrMint 1b347ad357 Added terminal stuff 2022-09-05 17:02:22 +02:00
DrMint 7b303f81ad Fixed unabled to click on the preview ctas 2022-08-29 01:58:19 +02:00
DrMint 5fc1d26243 Hopefully fixed the scrool to anchor when changing page 2022-08-29 00:11:49 +02:00
DrMint 73c25fd924 Content previous/next content is now following the same order as the folder 2022-08-28 22:25:44 +02:00
DrMint 0453a83d2f Analytics now works even if blocked by tracker blockers 2022-08-28 20:57:40 +02:00
DrMint c73e6a0bb4 Merge branch 'main' of github.com:Accords-Library/accords-library.com 2022-08-28 17:58:29 +02:00
DrMint 51c20a57eb Fixed umami category for an event 2022-08-28 17:58:22 +02:00
DrMint a13e916cae
Update node.js.yml 2022-08-28 17:42:18 +02:00
DrMint 77d96a3dc3 Fixed eslint warnings + configure prettier 2022-08-28 17:40:41 +02:00
DrMint 669d4358e7 Moved types into subfolder in src 2022-08-28 14:01:17 +02:00
DrMint 40d893eba8 Umami events + natural sort 2022-08-28 13:42:31 +02:00
DrMint bd0185358c Fixed the anchor link problems, maybe 2022-08-28 02:35:45 +02:00
DrMint c464cb1402
Merge pull request #39 from Accords-Library/localdata
Trying to make localdata works
2022-08-27 22:43:02 +02:00
DrMint a4467a6ee4 Updated deps 2022-08-27 22:22:16 +02:00
DrMint 119794a236 Fixed the problems 2022-08-27 21:55:46 +02:00
DrMint 43994ade36 Trying to make localdata words 2022-08-27 17:03:05 +02:00
DrMint 75d18e4c1c Fixed this mfking dark mode selector 2022-08-22 22:59:31 +02:00
DrMint 3afaea7027 Added intersection + added container query 2022-08-22 21:56:00 +02:00
DrMint acd2d7d482 Removed horizontal line from Return button 2022-08-21 10:20:12 +02:00
DrMint e947fd7a0e Added intersection for improved UX on page navigation 2022-08-21 02:46:44 +02:00
DrMint b7ebda4f4f Updated deps 2022-08-21 02:37:22 +02:00
DrMint 19e1c7784b Fixed recorder chip thumbnail aspect ratio 2022-08-16 22:08:23 +02:00
DrMint 1bbf3b164a Use localStorage hook 2022-08-16 01:25:01 +02:00
DrMint 625f436163 Lots of bs 2022-08-16 00:17:26 +02:00
DrMint 5a963294b7 Updated deps 2022-08-16 00:16:25 +02:00
DrMint eaef34a766 Img extends img 2022-08-14 02:04:54 +02:00
DrMint 93645a2f53 Contents only have one folder now 2022-08-13 13:38:57 +02:00
DrMint 57ba34aae3 Added content folder revalidated 2022-08-13 01:37:09 +02:00
DrMint 43a1b5c24b Updated deps 2022-08-13 00:52:18 +02:00
DrMint 6a3410d251 Convert content group to folders 2022-08-13 00:33:24 +02:00
DrMint 82c605086b Allow middle click to open new tab 2022-08-08 22:12:18 +02:00
DrMint c2c434eb80 Reordered imports 2022-08-08 20:56:08 +02:00
DrMint 918b9b8502 Black listed some pages from indexing 2022-08-08 20:55:26 +02:00
DrMint 922d54f41e Fixed bug where macos chrome would get flagged as safari 2022-08-07 20:46:18 +02:00
DrMint 4df187436a Fixed precommit 2022-08-07 19:59:35 +02:00
DrMint 18ad9eedb5 Display warning on webkit browser 2022-08-07 03:55:06 +02:00
DrMint 692e9ab1b4 Fixed bug where og description could have keys without values 2022-08-06 11:42:46 +02:00
DrMint a87d886785 Added analytics 2022-08-06 11:31:06 +02:00
DrMint 70a5cbae91 Fixed height of img in lightbox on chrome 2022-07-24 15:56:47 +02:00
DrMint 45b670de4e Anchor link langui 2022-07-23 23:11:52 +02:00
DrMint ac38f1dae0 Chronology v2 2022-07-23 22:56:48 +02:00
DrMint a04f1b50c3 Updated deps 2022-07-23 22:27:47 +02:00
DrMint 7832b71f5c OpenGraph support 2022-07-23 10:24:13 +02:00
DrMint 74b77431a9 Renamed prettyinline 2022-07-19 20:06:31 +02:00
DrMint ceaacc8242 Updated deps 2022-07-18 02:11:03 +02:00
DrMint 2aab536a94 Fixed a bug 2022-07-18 02:09:29 +02:00
DrMint 5edc2d7f27 Lots of things including Chronicles 2022-07-18 02:04:13 +02:00
DrMint 9fe7a777ff Minor deps updates 2022-07-16 00:16:09 +02:00
DrMint 89f4168e72 Fixed a few things 2022-07-16 00:14:08 +02:00
DrMint 9a3d76a356 Fixed combine related contents 2022-07-15 14:34:25 +02:00
DrMint c6ee213903 Improved wiki for mobile + removed old hook 2022-07-15 01:51:06 +02:00
DrMint 0df66815c8 Fixed bugs + translated components 2022-07-14 23:12:22 +02:00
DrMint 930da37d64 Fixed problems with preferredLanguages 2022-07-14 01:20:14 +02:00
DrMint 260bdd5577 useBoolean + many fixes 2022-07-13 03:46:58 +02:00
DrMint b6c2363093
Create DATA_TESTING.md 2022-07-11 02:44:46 +02:00
DrMint ae169e62e7 Merge branch 'main' of github.com:Accords-Library/accords-library.com 2022-07-11 02:35:16 +02:00
DrMint d1d055de29 Improved the wiki section 2022-07-11 02:35:09 +02:00
DrMint a91c5bf6ba
Create dependabot.yml 2022-07-10 04:27:04 +02:00
DrMint bb42e2a56f Fixed some lint 2022-07-10 04:12:38 +02:00
DrMint 1a790a597d
Update node.js.yml 2022-07-10 03:54:47 +02:00
DrMint 9436c266cc
Update node.js.yml 2022-07-10 03:54:13 +02:00
DrMint d3ac283c0f
Update node.js.yml 2022-07-10 03:53:01 +02:00
DrMint c350ecc3e3
Create codeql-analysis.yml 2022-07-10 03:35:56 +02:00
DrMint 4cf3158790 Updated deps 2022-07-10 03:32:33 +02:00
DrMint de3f385458 Improved the filterHasAttributes + Chip 2022-07-10 02:47:32 +02:00
DrMint ae25df8d72 There's a lot to unpack here 2022-07-08 01:42:38 +02:00
DrMint ba13c736b0 Fixed stacked preview card 2022-07-04 05:32:43 +02:00
DrMint 18186f2014 Added wiki pages to revalidate 2022-07-03 15:02:09 +02:00
DrMint be1a32181e Now using expression function style + some sections 2022-07-03 14:34:00 +02:00
DrMint 8b80ec4ca3 Fixed bugs 2022-07-02 05:10:40 +02:00
DrMint 8a9d354503 Fixed huge bug with link in library 2022-07-02 05:06:58 +02:00
DrMint 5c539a0b4b Updated deps 2022-07-02 05:06:44 +02:00
DrMint f62602f922 Use ref instead of document.querySelector 2022-07-02 04:40:50 +02:00
DrMint b9570e903e Added transcript tool 2022-07-02 04:34:21 +02:00
DrMint df92d97bfa Fixed bug 2022-06-30 00:09:36 +02:00
DrMint 3e1ebf74fd Fixed stuff probably 2022-06-30 00:06:15 +02:00
DrMint 520c4e3e35 Reverted audit fix because code-gen no longer works 2022-06-25 02:47:38 +02:00
DrMint 63c5dc0dd3 Fixed some vulnerabilities with npm audit fix 2022-06-25 02:40:50 +02:00
DrMint 93c079ec9f Updated deps 2022-06-25 01:33:13 +02:00
DrMint 2443dee83f Continue to improve code with hooks 2022-06-25 01:25:45 +02:00
DrMint d0b91f9db6 Continued using hooks 2022-06-23 00:39:59 +02:00
DrMint efcf01e8a0 Updated deps 2022-06-18 22:39:32 +02:00
DrMint bc0764c0d0 Improved stuff 2022-06-18 21:53:23 +02:00
DrMint 6ae54c39d4 Removed troublesome Immutable type 2022-06-18 04:39:18 +02:00
DrMint 24a8b43701 Fixed the hooks problems plus other things 2022-06-18 04:02:20 +02:00
DrMint c076ec06ad Use of cJoin and cIf 2022-06-15 07:33:20 +02:00
DrMint 1510366bc8 Prettier 2022-06-12 13:54:57 +02:00
DrMint 1ee5ff1292 Improvements here and there 2022-06-12 13:54:17 +02:00
DrMint b0fb445518 Added basic wiki 2022-06-12 13:52:32 +02:00
DrMint 4b1a9d570f Prettier 2022-06-11 01:30:02 +02:00
DrMint fe5c99ee8f Improved tailwind config and removed duplicated values in code 2022-06-11 01:19:19 +02:00
DrMint 31165f966c Updated deps + removed pointer/coarse for hover instead 2022-06-10 22:50:51 +02:00
DrMint bd7330489f Prettier 2022-06-09 21:01:40 +02:00
DrMint ede23194de Fixed default description 2022-06-09 20:53:37 +02:00
DrMint 97c8670924 Added better description 2022-06-08 23:04:27 +02:00
DrMint 46c4fece41 Added missing translations 2022-06-06 22:51:45 +02:00
DrMint 670b2b8469 prettier 2022-06-06 22:31:27 +02:00
DrMint 2073199971 Added possibility to remove roundness on preview cards 2022-06-06 22:28:44 +02:00
DrMint e1cd5424f7 Added link from content to corresponding library items 2022-06-02 22:00:27 +02:00
DrMint 7446ad3f42 Fixed bug where footnotes wouldn't work 2022-06-02 19:41:51 +02:00
DrMint f2c572e576 Smart language for the preview cards and preview line 2022-06-02 00:41:54 +02:00
DrMint 59283fa465 Added ability to mark library item as 'Want' or 'have' 2022-05-28 19:33:10 +02:00
DrMint 8b6abd6379 Prettier 2022-05-28 19:31:41 +02:00
DrMint ae1d1d735e Centered element on button + removed not allowed cursor when active 2022-05-28 19:30:53 +02:00
DrMint 3f7cad9053 Fixed 500 errors throughout the app 2022-05-27 16:50:10 +02:00
DrMint 2dee361f20 Disabled search for now 2022-05-27 16:22:05 +02:00
DrMint 622493a869 Added clear search and reset filters to relevent pages 2022-05-27 16:16:45 +02:00
DrMint 89bfc7ea89 TSC/Lint/Prettier 2022-05-27 13:17:37 +02:00
DrMint 2a799cf9e0 Improved editor 2022-05-27 13:10:19 +02:00
DrMint cedc25862d Fixed bug, TOC was no longer working 2022-05-27 13:09:56 +02:00
DrMint 2775d446d8 Fixed bug with TOC in case the header tree is incorrect 2022-05-27 13:08:08 +02:00
DrMint 56e89dbbe4 View tweaks and fixes 2022-05-22 21:23:18 +02:00
DrMint 16c540181d Improved the input for the pages 2022-05-22 20:43:17 +02:00
DrMint 6adae3fb3f Changed enum naming convention 2022-05-22 16:55:39 +02:00
DrMint 3a379f98a1 Added properly typed icons 2022-05-22 16:24:16 +02:00
DrMint a7c5ca61fd Added basic search 2022-05-22 14:43:36 +02:00
DrMint 88b60077df Added tailwindcss prettier 2022-05-21 13:18:57 +02:00
DrMint 435785f31b Dep cleanup 2022-05-21 12:31:48 +02:00
DrMint 9a5f4e5576 Added highlight color 2022-05-21 06:42:07 +02:00
DrMint 58bd46f25d Changed some icons + use langui for search placeholder 2022-05-17 23:54:51 +02:00
DrMint fa96469ebf Fixed key problems + search function 2022-05-17 23:16:22 +02:00
DrMint 50e988f64f Fixed font size wasn't apply to icons which make things look werid 2022-05-17 21:42:45 +02:00
DrMint aadc5f48e7 Fix wrong href for return to contents button 2022-05-17 21:42:24 +02:00
DrMint 53a4b2d2c0 Fix when switch label is too long 2022-05-17 21:42:03 +02:00
DrMint 2b3ecc665c Given better default for how to close opened menus when url or hash change 2022-05-17 20:09:38 +02:00
DrMint 311d91c370 Somewhat fixed problem with pages not going back up when changing dynamic slug 2022-05-16 23:54:16 +02:00
DrMint bec0c9a640 Fixed HUGE bug that was crashing the navigation 2022-05-16 00:36:38 +02:00
DrMint 262d464734 Fixed the way the number of content in a group is calculated 2022-05-15 18:17:01 +02:00
DrMint d3a349ef17 Fixed bug when there is more than 10 elements in a content group 2022-05-15 18:16:47 +02:00
DrMint 1f01ae3d2b Fixed the revalidate 2022-05-15 13:12:30 +02:00
DrMint 5de174d63e
Merge pull request #23 from Accords-Library/react18
React18
2022-05-15 10:21:37 +02:00
DrMint 237f496b6b Prettier 2022-05-15 10:15:55 +02:00
DrMint b2b2b00735 Replace raw string by translated strings 2022-05-15 10:14:45 +02:00
DrMint 0b97d9f73d Added stacked previewCard effect even to cards without thumbnails 2022-05-15 03:07:15 +02:00
DrMint 16df351741 Fixed visual bug + grouping bug 2022-05-15 01:27:17 +02:00
DrMint 88b9fa4477 Moved content functions to separate helper file 2022-05-15 01:19:53 +02:00
DrMint ed7198807f Replaced Exclude with NonNullable 2022-05-15 01:15:57 +02:00
DrMint 2e62f25fb5 Use new content groups instead of previous/next 2022-05-15 01:15:29 +02:00
DrMint 461bd08a3d Switched to new content data model 2022-05-14 22:47:00 +02:00
DrMint 2e0af56aa8 Updated deps 2022-05-14 14:52:14 +02:00
DrMint 5c7689c3f0 Removed the default in the exports whenever possible 2022-05-14 14:45:29 +02:00
DrMint be7c508aaa Allowed to combine contents 2022-05-14 14:21:13 +02:00
DrMint fc0512fc91 Made useSmartLanguage not default export and created useLightBox 2022-05-14 14:21:00 +02:00
DrMint 067f66b146 Added onClick to Img component 2022-05-14 14:18:06 +02:00
DrMint f51f7322fe Increased size of config button 2022-05-13 21:15:34 +02:00
DrMint 3469b5bfe9 Improved layout on mobile and avoid overflow 2022-05-13 21:06:46 +02:00
DrMint a82bc74baa Renamed ThumbnailPreview to PreviewCard + removed Next/Image 2022-05-13 19:24:25 +02:00
DrMint 16891dfad1 Fixed a bug, subitems were always showing 2022-05-13 19:23:34 +02:00
DrMint 81c8cf08fb Prettier 2022-05-10 22:37:13 +02:00
DrMint e17dbc14a0 Wrapped lines that are too long 2022-05-10 22:24:31 +02:00
DrMint 0591fa22d4 Mass use of Immutable 2022-05-08 21:37:41 +02:00
DrMint 02e03071a0 Moved getAppStaticProps to Graphql 2022-05-08 12:26:36 +02:00
DrMint aafa278384 Splitted the helpers into multiple files 2022-05-08 12:21:29 +02:00
DrMint b043e453c7 Renammed the queries folder to helpers 2022-05-08 11:34:14 +02:00
DrMint 08170887b9 Fixed missing playername in settings 2022-05-08 11:26:22 +02:00
DrMint badbd4b956 Updated REAME 2022-05-08 00:30:18 +02:00
DrMint a41b4a5ea1 Updated README 2022-05-08 00:28:34 +02:00
DrMint d4574af2cd Removed dep 2022-05-07 19:57:27 +02:00
DrMint 6d2240fb55 Lightbox gestures and keyboard navigation 2022-05-07 19:55:08 +02:00
DrMint d648509311 Updated many dependences included React 18 2022-05-07 14:20:41 +02:00
DrMint 7c6b074e6d Replaced faulty lightbox with custom basic lightbox 2022-05-07 14:00:00 +02:00
DrMint f65bdcc72c Added prebuild 2022-05-07 11:51:00 +02:00
DrMint fe3757f56f Added npm run-scripts 2022-05-07 11:35:53 +02:00
DrMint fcd4ba9385 Added tsc check and other small things 2022-05-07 11:19:46 +02:00
DrMint 349f53ea27 Post lack of attribute now send a 404 2022-05-07 11:19:28 +02:00
DrMint 85106d1735 Extracted language selector logic into a custom hook 2022-05-07 11:17:58 +02:00
DrMint 6f69aaf236 Fixed bug when switching between content pages not changing the content 2022-05-05 23:41:24 +02:00
DrMint e38f059c69 Run prettrier 2022-05-04 04:13:17 +02:00
DrMint c49094c843 Removed currently useless View profile button on Recorder chip 2022-05-04 04:11:29 +02:00
DrMint 379ed4ce20 Added back the data checkups in their own separate page 2022-05-04 04:11:03 +02:00
DrMint 0e130da9ea Moved input components to another subfolder 2022-04-24 15:12:32 +02:00
DrMint 15f44a009a removed horizontal line when unecessary 2022-04-24 15:07:06 +02:00
DrMint 9b162b0d49 Fixed bug where 404 would freak out 2022-04-24 15:06:39 +02:00
DrMint fcd303fe6a Renameed component 2022-04-24 14:32:55 +02:00
DrMint 1c2653ad07 Added previous/next content recommendation 2022-04-24 14:28:21 +02:00
DrMint 2fe1ffb273 Added autoprefixer, because?? 2022-04-24 00:14:43 +02:00
DrMint 5f1dd5ec31 Updated dependencies 2022-04-23 23:40:24 +02:00
DrMint a780db9b0f Fixed some potential problem with the uselocalstorage 2022-04-23 23:28:52 +02:00
DrMint 80f78d9940 Hotfix: was using a variable in generated css classes which isn't possible 2022-04-23 22:25:49 +02:00
DrMint cb4787ea42 Removed useless source language button 2022-04-23 22:15:04 +02:00
DrMint cf76a49219 Prettier 2022-04-23 22:08:48 +02:00
DrMint 1251bf19bc Fixed all lint alert 2022-04-23 22:08:22 +02:00
DrMint a87f8abdf0 Moved db testing to a seperate page 2022-04-23 21:47:59 +02:00
DrMint 427e44cdf4 Prettier 2022-04-23 20:33:41 +02:00
DrMint 81ac3fb23a Pages now use the new ThumbnailPreview component 2022-04-23 20:32:55 +02:00
DrMint 351c526dbe Fixed bug where post would unwantendly display the return button on mobile 2022-04-23 20:31:49 +02:00
DrMint 7dc8ab3a28 Simplified how the images are loaded from markdown 2022-04-23 20:30:51 +02:00
DrMint ae7dc03821 Renammed the import name for the compoentn 2022-04-23 20:30:02 +02:00
DrMint a1644ab47f Img component now support raw url 2022-04-23 20:29:38 +02:00
DrMint add0522095 Fused all the preview cards to one all-encompassing component 2022-04-23 20:16:23 +02:00
DrMint 51f050819e Content descriptions can now be parsed as markdawn 2022-04-22 22:42:38 +02:00
DrMint 025e4c4d31 Added notes to content 2022-04-16 21:28:46 +02:00
DrMint 741a10ef29 Order scan pages automatically 2022-04-16 20:13:08 +02:00
DrMint 648446d267 More pages updated when content are updated 2022-04-16 20:12:49 +02:00
DrMint 1ddcd68286 Scans pages are also refreshed when the library item is updated 2022-04-15 17:58:34 +02:00
DrMint 1c023e6a01 Hotfix 2022-04-15 17:42:31 +02:00
DrMint 8e734aa347 Select the primary language according to routing 2022-04-15 17:40:57 +02:00
DrMint 18aef1c8eb update README 2022-04-13 03:07:39 +02:00
DrMint 3b0514fc6b Added more info for scans of covers 2022-04-13 03:06:51 +02:00
DrMint 822adbec48 Added covers in the scanset 2022-04-12 18:10:26 +02:00
DrMint b3f3ddf60e Added smart translation to scan 2022-04-12 14:30:15 +02:00
DrMint a84560d86e Added smart language switch for posts ad hoc page and content 2022-04-12 12:25:40 +02:00
DrMint 049a2e2044 OrderableList had the wrong function name 2022-04-12 12:00:10 +02:00
DrMint 2a784fd0ec Added arrows for browser that does'nt support drag&drop 2022-04-11 01:27:30 +02:00
DrMint a61ccea317 Popup had a problem when they need to be scroolable 2022-04-11 01:27:04 +02:00
DrMint fb88e97825 Added language preference system 2022-04-10 23:18:52 +02:00
DrMint 3cf890d70d Buttons' content can no longer be selected + draggability 2022-04-10 23:16:59 +02:00
DrMint 7c18c4f634 PageSelector buttons could only be clicked in the middle 2022-04-10 23:16:19 +02:00
DrMint 31d68dc6ae Library items updates also update the parent items 2022-04-09 13:47:44 +02:00
DrMint c98537c85e Added library item buying links 2022-04-04 16:19:17 +02:00
DrMint 7bb0404fd7 Prettier 2022-04-04 11:09:30 +02:00
DrMint 2cfc14dfcb Proofreading 2022-04-04 11:08:54 +02:00
DrMint 286187135f Editor now has buttons to easily add custom content 2022-04-04 11:08:41 +02:00
DrMint 3f452c529a Made props types more consistent 2022-04-03 10:34:21 +02:00
DrMint 572cbb57d5 Pagination for videos 2022-04-02 23:29:57 +02:00
DrMint 1db24083cb Added videos page and all 2022-04-02 22:46:37 +02:00
DrMint 762df56aea prettier 2022-04-01 20:48:32 +02:00
DrMint e3c2e4da1d Added basic video functionnality 2022-04-01 20:47:42 +02:00
DrMint 73ad43fe7d Prettier 2022-03-31 15:01:44 +02:00
DrMint 125421de0f Use graphql-codegen and replace all types from queries 2022-03-31 14:59:31 +02:00
DrMint acf92d6fc1 Added content to the list of revalidatable pages 2022-03-30 00:45:04 +02:00
DrMint 324eb7c366 Fixed bug with lenght of title 2022-03-30 00:15:23 +02:00
DrMint c8a2104dae Removed some static text and use textui 2022-03-29 23:45:23 +02:00
DrMint 935c364a71 Added fallback and avoid 404 2022-03-29 22:36:13 +02:00
DrMint 4dd1782389 Paths can now be added after site is built 2022-03-29 21:51:50 +02:00
DrMint 61d5363242 Updated README 2022-03-29 21:43:10 +02:00
DrMint 4c9cf9f7c4 Added more pages to be revalidated 2022-03-28 23:51:41 +02:00
DrMint 4fba3e19de Updated dependencies 2022-03-28 22:27:56 +02:00
DrMint afbcbe7a3c Merge branch 'main' of github.com:Accords-Library/accords-library.com 2022-03-28 18:04:55 +02:00
DrMint 70a3e1895b Added On-demand revalidation 2022-03-28 18:04:47 +02:00
DrMint 051ac0434e
Merge pull request #22 from Accords-Library/develop
Order news country-chronologically
2022-03-28 16:27:27 +02:00
DrMint a77b7aa796
Merge pull request #21 from Accords-Library/develop
Fixed bug with the sluggify
2022-03-28 13:18:08 +02:00
DrMint 2614ca1ac9
Update node.js.yml 2022-03-28 12:59:25 +02:00
DrMint 0049e53ce2
Merge pull request #20 from Accords-Library/develop
Fixed button not working if no href
2022-03-28 12:58:07 +02:00
DrMint 6d4fab38ab
Update node.js.yml 2022-03-28 12:34:56 +02:00
DrMint 0bf7b6bce4
Merge pull request #19 from Accords-Library/develop
Sitemap!
2022-03-28 11:58:20 +02:00
DrMint e591426f79
Merge pull request #18 from Accords-Library/develop
Added scans + removed useless router as props + use graphql aliases f…
2022-03-28 10:44:03 +02:00
DrMint 151a07e8ad
Merge pull request #17 from Accords-Library/develop
Linting!
2022-03-27 18:43:59 +02:00
DrMint 1778c07e54
Merge pull request #16 from Accords-Library/develop
Added contact form
2022-03-23 16:49:16 +01:00
DrMint 00ff7be736
Merge pull request #15 from Accords-Library/develop
Improved About us section + message when content not available in a language
2022-03-20 14:51:52 +01:00
DrMint cd12fba4c6
Merge pull request #14 from Accords-Library/develop
Fixed bug with thumbnail
2022-03-18 14:10:59 +01:00
DrMint 21ad01cf3b
Merge pull request #13 from Accords-Library/develop
Better TOC and Lightbox
2022-03-18 14:01:41 +01:00
DrMint ccaea20b7b
Merge pull request #12 from Accords-Library/develop
Small improvements to the RecorderChip and the Transcript lines
2022-03-13 18:25:35 +01:00
DrMint 55c267490a
Merge pull request #11 from Accords-Library/develop
Now using Tippy for Tooltips + prettier integration
2022-03-13 16:26:56 +01:00
DrMint f2d99f5dcd
Update README.md 2022-03-13 16:26:21 +01:00
DrMint 56c07b715d
Merge pull request #10 from Accords-Library/develop
Better Markdawn and auto-generated tables of content
2022-03-12 14:04:12 +01:00
DrMint 00d9cc8b9d
Merge pull request #9 from Accords-Library/develop
Added more grouping options for Library and Contents
2022-03-11 01:43:42 +01:00
DrMint aba8d373d5
Update node.js.yml 2022-03-11 01:08:33 +01:00
DrMint d5a39aa5f4
Merge pull request #8 from Accords-Library/develop
Develop
2022-03-07 21:59:58 +01:00
DrMint a70246344a
Merge pull request #7 from Accords-Library/develop
Added a config menu with multiple options for the visitors + globally loaded props
2022-03-07 16:06:54 +01:00
DrMint e1dfdbd87e
Merge pull request #6 from Accords-Library/develop
Fixed all UX problems pointed out by groof
2022-03-06 04:50:16 +01:00
DrMint 46dab30ebe
Update node.js.yml 2022-03-06 04:49:51 +01:00
DrMint e6214600bc
Merge pull request #5 from Accords-Library/develop
Disable testing log on npm build
2022-03-06 02:40:37 +01:00
DrMint b95278ddbe
Update README.md 2022-03-06 02:23:52 +01:00
DrMint 9ecabfc2f3
Update node.js.yml 2022-03-06 02:08:16 +01:00
DrMint 29faaf8051
Update node.js.yml 2022-03-06 02:04:36 +01:00
DrMint d502655dff
Update node.js.yml 2022-03-06 02:01:49 +01:00
DrMint e4def9268a
Update node.js.yml 2022-03-06 01:37:16 +01:00
DrMint 5e18039edc
Merge pull request #4 from Accords-Library/develop
Quick fix for the last commit
2022-03-06 01:27:17 +01:00
DrMint ae5e37c002
Create node.js.yml 2022-03-06 01:21:43 +01:00
DrMint 4c2ca5e1b2
Merge pull request #2 from Accords-Library/develop
Library Items can now be of Group type instead of relying on the Other type
2022-03-06 00:46:31 +01:00
DrMint 30a2b8ceda
Merge pull request #1 from Accords-Library/develop
New translation system and filtering/group for Library Items
2022-03-05 16:37:58 +01:00
267 changed files with 45400 additions and 16407 deletions

44
.env.example Executable file
View File

@ -0,0 +1,44 @@
# /!\ For URLs, don't include the trailing '/'
# ┌─────────────────────┐
# │ PRIVATE VARIABLES │
# └─────────────────────┘
## STRAPI
URL_GRAPHQL=https://url-to.strapi-accords-library.com/graphql
ACCESS_TOKEN=abcdef0123456789
REVALIDATION_TOKEN=abcdef0123456789
## MAILING
SMTP_HOST=email.provider.com
SMTP_USER=email@example.com
SMTP_PASSWORD=mypassword123
# ┌────────────────────┐
# │ PUBLIC VARIABLES │
# └────────────────────┘
## ASSETS
NEXT_PUBLIC_URL_CMS=https://url-to.strapi-accords-library.com
NEXT_PUBLIC_URL_IMG=https://url-to.img-accords-library.com
NEXT_PUBLIC_URL_SELF=https://url-to.front-accords-library.com
NEXT_PUBLIC_URL_ASSETS=https://url-to.assets-accords-library.com
## MEILISEARCH
NEXT_PUBLIC_URL_MEILISEARCH=https://url-to.search-accords-library.com
NEXT_PUBLIC_MEILISEARCH_KEY=abcdef0123456789
## UMAMI
NEXT_PUBLIC_UMAMI_URL=https://url-to.umami-accords-library.com
NEXT_PUBLIC_UMAMI_ID=abcdef0123456789
## OCR.SPACE
NEXT_PUBLIC_API_OCR_KEY=abcdef0123456789

View File

@ -1,2 +1,12 @@
*.js src/graphql/generated.ts
*.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

View File

@ -7,6 +7,8 @@ module.exports = {
extends: [ extends: [
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"next/core-web-vitals", "next/core-web-vitals",
], ],
rules: { rules: {
@ -41,15 +43,16 @@ module.exports = {
eqeqeq: "error", eqeqeq: "error",
"func-name-matching": "warn", "func-name-matching": "warn",
"func-names": "warn", "func-names": "warn",
"func-style": ["warn", "declaration"], "func-style": ["warn", "expression"],
"grouped-accessor-pairs": "warn", "grouped-accessor-pairs": "warn",
"guard-for-in": "warn", "guard-for-in": "warn",
"id-denylist": ["error", "data", "err", "e", "cb", "callback", "i"], "id-denylist": ["error", "err", "e", "cb", "callback", "i"],
// "id-length": "warn", // "id-length": "warn",
"id-match": "warn", "id-match": "warn",
"max-classes-per-file": ["error", 1], "max-classes-per-file": ["error", 1],
// "max-depth": ["warn", 4], // "max-depth": ["warn", 4],
// "max-lines": "warn", // "max-lines": "warn",
"max-len": ["warn", { code: 100 }],
// "max-lines-per-function": "warn", // "max-lines-per-function": "warn",
// "max-nested-callbacks": "warn", // "max-nested-callbacks": "warn",
// "max-params": "warn", // "max-params": "warn",
@ -59,7 +62,7 @@ module.exports = {
"no-alert": "warn", "no-alert": "warn",
"no-bitwise": "warn", "no-bitwise": "warn",
"no-caller": "warn", "no-caller": "warn",
"no-confusing-arrow": "warn", // "no-confusing-arrow": "warn",
"no-continue": "warn", "no-continue": "warn",
"no-else-return": "warn", "no-else-return": "warn",
"no-eq-null": "warn", "no-eq-null": "warn",
@ -76,9 +79,9 @@ module.exports = {
"no-lone-blocks": "warn", "no-lone-blocks": "warn",
"no-lonely-if": "warn", "no-lonely-if": "warn",
// "no-magic-numbers": "warn", // "no-magic-numbers": "warn",
"no-mixed-operators": "warn", // "no-mixed-operators": "warn",
"no-multi-assign": "warn", "no-multi-assign": "warn",
"no-multi-str": "warn", // "no-multi-str": "warn",
"no-negated-condition": "warn", "no-negated-condition": "warn",
// "no-nested-ternary": "warn", // "no-nested-ternary": "warn",
"no-new": "warn", "no-new": "warn",
@ -87,7 +90,7 @@ module.exports = {
"no-new-wrappers": "warn", "no-new-wrappers": "warn",
"no-octal-escape": "warn", "no-octal-escape": "warn",
"no-param-reassign": "warn", "no-param-reassign": "warn",
"no-plusplus": "warn", // "no-plusplus": "warn",
"no-proto": "warn", "no-proto": "warn",
"no-restricted-exports": "warn", "no-restricted-exports": "warn",
"no-restricted-globals": "warn", "no-restricted-globals": "warn",
@ -121,7 +124,7 @@ module.exports = {
"prefer-exponentiation-operator": "warn", "prefer-exponentiation-operator": "warn",
"prefer-named-capture-group": "warn", "prefer-named-capture-group": "warn",
"prefer-numeric-literals": "warn", "prefer-numeric-literals": "warn",
// "prefer-object-has-own": "warn", "prefer-object-has-own": "warn",
"prefer-object-spread": "warn", "prefer-object-spread": "warn",
"prefer-promise-reject-errors": "warn", "prefer-promise-reject-errors": "warn",
"prefer-regex-literals": "warn", "prefer-regex-literals": "warn",
@ -146,32 +149,22 @@ module.exports = {
"@typescript-eslint/ban-tslint-comment": "warn", "@typescript-eslint/ban-tslint-comment": "warn",
"@typescript-eslint/class-literal-property-style": "warn", "@typescript-eslint/class-literal-property-style": "warn",
"@typescript-eslint/consistent-indexed-object-style": "warn", "@typescript-eslint/consistent-indexed-object-style": "warn",
"@typescript-eslint/consistent-type-assertions": [ "@typescript-eslint/consistent-type-assertions": ["warn", { assertionStyle: "as" }],
"warn",
{ assertionStyle: "as" },
],
"@typescript-eslint/consistent-type-exports": "error", "@typescript-eslint/consistent-type-exports": "error",
"@typescript-eslint/explicit-module-boundary-types": "warn", "@typescript-eslint/explicit-module-boundary-types": "warn",
"@typescript-eslint/method-signature-style": ["error", "property"], "@typescript-eslint/method-signature-style": ["error", "property"],
"@typescript-eslint/no-base-to-string": "warn", "@typescript-eslint/no-base-to-string": "warn",
"@typescript-eslint/no-confusing-non-null-assertion": "warn", "@typescript-eslint/no-confusing-non-null-assertion": "warn",
"@typescript-eslint/no-confusing-void-expression": [ "@typescript-eslint/no-confusing-void-expression": ["error", { ignoreArrowShorthand: true }],
"error",
{ ignoreArrowShorthand: true },
],
"@typescript-eslint/no-dynamic-delete": "error", "@typescript-eslint/no-dynamic-delete": "error",
"@typescript-eslint/no-empty-interface": [ "@typescript-eslint/no-empty-interface": ["error", { allowSingleExtends: true }],
"error",
{ allowSingleExtends: true },
],
"@typescript-eslint/no-invalid-void-type": "error", "@typescript-eslint/no-invalid-void-type": "error",
"@typescript-eslint/no-meaningless-void-operator": "error", "@typescript-eslint/no-meaningless-void-operator": "error",
"@typescript-eslint/no-non-null-asserted-nullish-coalescing": "error", "@typescript-eslint/no-non-null-asserted-nullish-coalescing": "error",
"@typescript-eslint/no-parameter-properties": "error",
"@typescript-eslint/no-require-imports": "error", "@typescript-eslint/no-require-imports": "error",
// "@typescript-eslint/no-type-alias": "warn", // "@typescript-eslint/no-type-alias": "warn",
"@typescript-eslint/no-unnecessary-boolean-literal-compare": "warn", "@typescript-eslint/no-unnecessary-boolean-literal-compare": "warn",
// "@typescript-eslint/no-unnecessary-condition": "warn", "@typescript-eslint/no-unnecessary-condition": "warn",
"@typescript-eslint/no-unnecessary-qualifier": "warn", "@typescript-eslint/no-unnecessary-qualifier": "warn",
"@typescript-eslint/no-unnecessary-type-arguments": "warn", "@typescript-eslint/no-unnecessary-type-arguments": "warn",
"@typescript-eslint/prefer-enum-initializers": "error", "@typescript-eslint/prefer-enum-initializers": "error",
@ -188,14 +181,15 @@ module.exports = {
"@typescript-eslint/prefer-string-starts-ends-with": "error", "@typescript-eslint/prefer-string-starts-ends-with": "error",
"@typescript-eslint/promise-function-async": "error", "@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/require-array-sort-compare": "error", "@typescript-eslint/require-array-sort-compare": "error",
"@typescript-eslint/sort-type-union-intersection-members": "warn", // "@typescript-eslint/strict-boolean-expressions": [
// "@typescript-eslint/strict-boolean-expressions": "error", // "error",
// { allowAny: true },
// ],
"@typescript-eslint/switch-exhaustiveness-check": "error", "@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/typedef": "error", "@typescript-eslint/typedef": "error",
"@typescript-eslint/unified-signatures": "error", "@typescript-eslint/unified-signatures": "error",
/* EXTENSION OF ESLINT */ /* EXTENSION OF ESLINT */
"@typescript-eslint/no-duplicate-imports": "error",
"@typescript-eslint/default-param-last": "warn", "@typescript-eslint/default-param-last": "warn",
"@typescript-eslint/dot-notation": "warn", "@typescript-eslint/dot-notation": "warn",
"@typescript-eslint/init-declarations": "warn", "@typescript-eslint/init-declarations": "warn",
@ -210,5 +204,52 @@ module.exports = {
/* NEXTJS */ /* NEXTJS */
"@next/next/no-img-element": "off", "@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",
}, },
}; };

11
.github/dependabot.yml vendored Normal file
View File

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

71
.github/workflows/codeql-analysis.yml vendored Normal file
View File

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

32
.github/workflows/node.js.yml vendored Normal file
View File

@ -0,0 +1,32 @@
# 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: Preflight checks
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci --force
- run: npm run precommit
env:
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
URL_GRAPHQL: ${{ secrets.URL_GRAPHQL }}

5
.gitignore vendored
View File

@ -1,6 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Generated content
src/graphql/generated.ts
/testing_logs/* public/robots.txt
# dependencies # dependencies
/node_modules /node_modules

5
.ncurc.yml Normal file
View File

@ -0,0 +1,5 @@
upgrade: true
interactive: true
format: "group"
reject:
- "react-hotkeys-hook" # we are stuck at version 3.4.7 because 4.X is not working well. Need more experimenting.

View File

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

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

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

85
CONTRIBUTING.md Normal file
View File

@ -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 ╰──────────────────────────────────────
*/
```

102
DATA_TESTING.md Normal file
View File

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

181
README.md
View File

@ -1,53 +1,144 @@
# Accords-library.com # Accords-library.com
[![Node.js CI](https://github.com/Accords-Library/accords-library.com/actions/workflows/node.js.yml/badge.svg?branch=main)](https://github.com/Accords-Library/accords-library.com/actions/workflows/node.js.yml)
[![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
Accords Library is a fan-site that aims at gathering and archiving all of Yoko Taros work.
Yoko Taro is a Japanese video game director and scenario writer. He is best-known for his work on the NieR and Drakengard (Drag-on Dragoon) franchises.
## Technologies ## Technologies
#### [Back](https://github.com/Accords-Library/strapi.accords-library.com) ### Overview
- CMS: Stapi ![](docs/project-mind-map.png)
- GraphQL endpoint
- Multilanguage support
- Markdown format for the rich text fields
#### [Image Processor](https://github.com/Accords-Library/img.accords-library.com) _Purple connections are actions done at build-time only. Grey connections can be at build-time or run-time._
- Convert the images from the CMS to 4 formats ### [strapi.accords-library.com](https://github.com/Accords-Library/strapi.accords-library.com)
- 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) 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, search engine, and image processor when new content/media has been created/modified/deleted
### [img.accords-library.com](https://github.com/Accords-Library/img.accords-library.com)
A custom made image processor to overcome the lack of customization offered by Strapi build-in image processor. There is a python script to bulk process all images uploaded to Strapi. Subsequent changes to Strapi's media library can be handled using webhooks. The repo includes a server that listen to these webhook calls, and another to serve the images.
Each image in Strapi's media library is converted to four different formats:
- Small: 512x512, quality 60, .webp
- Medium: 1024x1024, quality 75, .webp
- Large: 2048x2048, quality 80, .webp
- Og: 512x512, quality 60, .jpg
### [search.accords-library.com](https://github.com/Accords-Library/search.accords-library.com)
A search engine that uses [Meilisearch](https://www.meilisearch.com/).
The repo includes a docker-compose file to run an instance of Meilisearch. There is also a server that populates Meilisearch's documents at startup, then listen to webhooks sent by Strapi for subsequent changes.
### [gallery.accords-library.com](https://github.com/Accords-Library/gallery.accords-library.com)
An image board engine, uses [Szurubooru](https://github.com/rr-/szurubooru), a lighweight engine inspired by Danbooru (and Booru-type galleries in general). Unlike the other subdomains, this repo is completely separated from the rest of the stack.
### [watch.accords-library.com](https://github.com/Accords-Library/watch.accords-library.com)
A set of tools to archive videos on multiple platforms. The repo contains a CLI tool to archive YouTube videos. There is also a Python script which import the videos metadata to Strapi using GraphQL mutations. Finally, there's a server to serve the video files and thumbnail.
### [umami.accords-library.com](https://umami.is/)
An open-source self-hosted alternative to Google Analytics which doesn't require a cookie notice to be GDPR compliant.
### [accords-library.com](https://github.com/Accords-Library/accords-library.com) (this repository)
A detailled look at the technologies used in this repository:
- Language: [TypeScript](https://www.typescriptlang.org/) - Language: [TypeScript](https://www.typescriptlang.org/)
- Queries: [GraphQL](https://graphql.org/)
- [GraphQL Code Generator](https://www.graphql-code-generator.com/) to automatically generated types for the operations variables and responses - Framework: [Next.js 13](https://nextjs.org/) (React 18)
- The operations are stored in a graphql file and then retrieved and wrap as an actual TypeScript function
- Markdown: [markdown-to-jsx](https://www.npmjs.com/package/markdown-to-jsx) - SSG + ISR (Static Site Generation + Incremental Static Regeneration)
- Support for Arbitrary React Components and Component Props!
- Styling: [Tailwind CSS](https://tailwindcss.com/)
- Good typographic defaults using [Tailwind/Typography](https://tailwindcss.com/docs/typography-plugin)
- Beside the theme declaration no CSS outside of Tailwind CSS
- Manually added support for scrollbar styling
- Support for [Material Icons](https://fonts.google.com/icons)
- Support for light and dark mode with a manual switch and system's selected theme by default
- Support for creating any arbitrary theming mode by swapping CSS variables
- Framework: [Next.js](https://nextjs.org/) (React)
- Multilanguage support
- State Management: [React Context](https://reactjs.org/docs/context.html)
- Persistent app state using LocalStorage
- Support for many screen sizes and resolutions
- SSG (Static Site Generation):
- The website is built before running in production - The website is built before running in production
- Performances are great, and possibility to deploy the app using a CDN - Performances are great, and it's possible to deploy the app on a CDN
- OpenGraph and Metadata - On-Demand ISR to continuously update the website when new content is added or existing content is modified/deleted
- Good defaults for the metadate and OpenGraph properties - 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.
- Each page can provide the thumbnail, title, description to be used
- Data quality testing - 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
- Styling: [Tailwind CSS](https://tailwindcss.com/)
- 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-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
- 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 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 from the CMS is subject to a battery of tests (about 20 warning types and 40 error types) at build time - Data from the CMS is subject to a battery of tests (about 20 warning types and 40 error types) at build time
- Each warning/error comes with a front-end link to the incriminating element, as well as a link to the CMS to fix it. - Each warning/error comes with a front-end link to the incriminating element, as well as a link to the CMS to fix it
- Check for completeness, conformity, and integrity - Check for completeness, conformity, and integrity
- Code Quality and Style
- React Strict Mode
- [Eslint](https://www.npmjs.com/package/eslint) with [eslint-plugin-import](https://www.npmjs.com/package/eslint-plugin-import), [typescript-eslint](https://www.npmjs.com/package/@typescript-eslint/eslint-plugin)
- [Prettier](https://www.npmjs.com/package/prettier) with [prettier-plugin-tailwindcss](https://www.npmjs.com/package/prettier-plugin-tailwindcss)
- [ts-unused-exports](https://www.npmjs.com/package/ts-unused-exports) to find unused exported functions/constants...
- Other
- Custom book reader based on [Okuma-Reader](https://github.com/DrMint/Okuma-Reader)
- Support for [Material Symbols](https://fonts.google.com/icons)
- Custom lightbox using [react-zoom-pan-pinch](https://www.npmjs.com/package/react-zoom-pan-pinch)
- Handle query params type-validation using [Zod](https://zod.dev/)
- A secret "Terminal" mode. Can you find it?
## Installation ## Installation
```bash ```bash
@ -56,24 +147,14 @@ cd accords-library.com
npm install npm install
``` ```
Create a env file: Create a env file based on the example one:
```bash ```bash
cp .env.example .env.local
nano .env.local nano .env.local
``` ```
Enter the followind information: Change the variables
```txt
URL_GRAPHQL=https://url-to.strapi-accords-library.com/graphql
ACCESS_TOKEN=genatedcode-by-strapi-api
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
```
Run in dev mode: Run in dev mode:
@ -84,6 +165,6 @@ Run in dev mode:
OR build and run in production mode OR build and run in production mode
```bash ```bash
./run_accords_build.sh npm run build
./run_accords_prod.sh ./run_accords_prod.sh
``` ```

53
design.config.js Normal file
View File

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

View File

@ -0,0 +1,134 @@
<?xml version="1.0"?>
<minder version="1.14.0" parent-etag="2844169042" etag="3777682473">
<theme name="dark" label="Dark" index="-1"/>
<styles>
<style level="0" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="rounded" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="10" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<style level="1" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<style level="2" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<style level="3" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<style level="4" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<style level="5" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<style level="6" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<style level="7" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<style level="8" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<style level="9" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<style level="10" isset="true" branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true" connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
</styles>
<drawarea x="-736.36765543619742" y="32.05864461263036" scale="1.5"/>
<images/>
<nodes>
<node id="0" posx="648.76816813151015" posy="501.13512166341127" width="181" height="56" side="left" fold="false" treesize="56" layout="Horizontal" group="false">
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
<nodename posx="667.76816813151015" posy="520.13512166341127" maxwidth="200">
<text data="accords-library.com"/>
</nodename>
<nodenote></nodenote>
</node>
<node id="1" posx="603.1911417643222" posy="198.59891764322904" width="228" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
<nodename posx="622.1911417643222" posy="217.59891764322904" maxwidth="200">
<text data="strapi.accords-library.com"/>
</nodename>
<nodenote></nodenote>
</node>
<node id="2" posx="508.77593994140574" posy="358.0493469238279" width="230" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
<nodename posx="527.7759399414058" posy="377.0493469238279" maxwidth="200">
<text data="watch.accords-library.com"/>
</nodename>
<nodenote></nodenote>
</node>
<node id="3" posx="959.78491210937489" posy="490.81981404622388" width="235" height="56" side="left" fold="false" treesize="56" layout="Horizontal" group="false">
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
<nodename posx="978.78491210937489" posy="509.81981404622388" maxwidth="200">
<text data="search.accords-library.com"/>
</nodename>
<nodenote></nodenote>
</node>
<node id="4" posx="300.46666463216098" posy="474.56320190429688" width="213" height="56" side="left" fold="false" treesize="56" layout="Horizontal" group="false">
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
<nodename posx="319.46666463216098" posy="493.56320190429688" maxwidth="200">
<text data="img.accords-library.com"/>
</nodename>
<nodenote></nodenote>
</node>
<node id="5" posx="753.05198160807242" posy="709.79446411132812" width="236" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
<nodename posx="772.05198160807242" posy="728.79446411132812" maxwidth="200">
<text data="umami.accords-library.com"/>
</nodename>
<nodenote></nodenote>
</node>
<node id="6" posx="468.00632731119703" posy="709.85610961914062" width="234" height="56" side="right" fold="false" treesize="56" layout="Horizontal" group="false">
<style branchmargin="100" branchradius="25" linktype="straight" linkwidth="4" linkarrow="false" linkdash="solid" nodeborder="underlined" nodewidth="200" nodeborderwidth="4" nodefill="false" nodemargin="8" nodepadding="11" nodefont="Sans 11" nodemarkup="true"/>
<nodename posx="487.00632731119703" posy="728.85610961914062" maxwidth="200">
<text data="gallery.accords-library.com"/>
</nodename>
<nodenote></nodenote>
</node>
</nodes>
<groups/>
<connections>
<connection from_id="3" to_id="1" drag_x="1038.6269124348951" drag_y="296.62844848632801" color="#813d9c">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<title>GraphQL queries</title>
<note></note>
</connection>
<connection from_id="1" to_id="3" drag_x="982.0184326171875" drag_y="347.55404663085926">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<title>Webhook</title>
<note></note>
</connection>
<connection from_id="2" to_id="1" drag_x="640.25118001302098" drag_y="308.37991333007801" color="#813d9c">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<title>GraphQL mutations</title>
<note></note>
</connection>
<connection from_id="0" to_id="5" drag_x="801.02655029296898" drag_y="644.59735107421943">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<title>Sends events</title>
<note></note>
</connection>
<connection from_id="4" to_id="0" drag_x="531.34985351562477" drag_y="582.42803955078148">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="150"/>
<title>Provides the images</title>
<note></note>
</connection>
<connection from_id="3" to_id="0" drag_x="917.41172281901027" drag_y="585.41107177734443">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="150"/>
<title>Provides search results</title>
<note></note>
</connection>
<connection from_id="0" to_id="1" drag_x="872.998291015625" drag_y="408.14896647135413">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<title>GraphQL queries</title>
<note></note>
</connection>
<connection from_id="0" to_id="6" drag_x="664.23213704427053" drag_y="645.67006429036542">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<title>Links to</title>
<note></note>
</connection>
<connection from_id="4" to_id="1" drag_x="400.83854166666663" drag_y="295.4715677897135" color="#813d9c">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<title>Python script</title>
<note></note>
</connection>
<connection from_id="2" to_id="0" drag_x="645.96777343749955" drag_y="448.91068522135413">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="150"/>
<title>Provides the videos</title>
<note></note>
</connection>
<connection from_id="1" to_id="4" drag_x="441.14660644531227" drag_y="342.38249715169252">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<title>Webhook</title>
<note></note>
</connection>
<connection from_id="1" to_id="0" drag_x="790.7373046875" drag_y="369.4803466796875">
<style connectiondash="dotted" connectionlwidth="2" connectionarrow="fromto" connectionpadding="3" connectionfont="Sans 9" connectiontwidth="100"/>
<title>Webhook</title>
<note></note>
</connection>
</connections>
<stickers/>
</minder>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

17
graphql-codegen.config.js Normal file
View File

@ -0,0 +1,17 @@
const { loadEnvConfig } = require("@next/env");
loadEnvConfig(process.cwd());
module.exports = {
overwrite: true,
schema: {
[process.env.URL_GRAPHQL]: {
headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` },
},
},
documents: ["src/graphql/operations/**/*.graphql", "src/graphql/fragments/*.graphql"],
generates: {
"src/graphql/generated.ts": {
plugins: ["typescript", "typescript-operations", "typescript-graphql-request"],
},
},
};

View File

@ -7,22 +7,22 @@ module.exports = {
href: `${process.env.NEXT_PUBLIC_URL_SELF}/en/`, href: `${process.env.NEXT_PUBLIC_URL_SELF}/en/`,
hreflang: "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/`, href: `${process.env.NEXT_PUBLIC_URL_SELF}/es/`,
hreflang: "es", hreflang: "es",
}, },
{
href: `${process.env.NEXT_PUBLIC_URL_SELF}/fr/`,
hreflang: "fr",
},
{ {
href: `${process.env.NEXT_PUBLIC_URL_SELF}/pt-br/`, href: `${process.env.NEXT_PUBLIC_URL_SELF}/pt-br/`,
hreflang: "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/*"],
}; };

View File

@ -1,13 +1,19 @@
/** @type {import('next').NextConfig} */ /* CONFIG */
const locales = ["en", "es", "fr", "pt-br", "ja", "zh"];
/* END CONFIG */
/* @type {import('next').NextConfig} */
module.exports = { module.exports = {
swcMinify: true,
reactStrictMode: true, reactStrictMode: true,
poweredByHeader: false,
i18n: { i18n: {
locales: ["en", "fr", "ja", "es", "pt-br"], locales: locales,
defaultLocale: "en", defaultLocale: "en",
}, },
images: { images: {
domains: ["img.accords-library.com"], domains: ["img.accords-library.com", "watch.accords-library.com"],
}, },
async redirects() { async redirects() {
return [ return [
@ -16,6 +22,16 @@ module.exports = {
destination: "https://discord.com/invite/5mcXcRAczj", destination: "https://discord.com/invite/5mcXcRAczj",
permanent: false, permanent: false,
}, },
{
source: "/gallery",
destination: "https://gallery.accords-library.com/posts",
permanent: false,
},
{
source: "/contents/folder",
destination: "/contents",
permanent: false,
},
]; ];
}, },
}; };

15868
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,41 +2,90 @@
"name": "accords-library.com", "name": "accords-library.com",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "postinstall": "patch-package",
"dev": "next dev -p 12499",
"precommit": "npm run fetch-local-data && npm run icu-to-ts && npm run unused-wording-keys && npm run unused-exports && npm run prettier && npm run eslint && npm run tsc && echo ALL PRECOMMIT CHECKS PASSED SUCCESSFULLY, LET\\'S FUCKING GO!",
"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", "build": "next build",
"postbuild": "next-sitemap", "postbuild": "next-sitemap --config next-sitemap.config.js",
"start": "next start", "start": "next start -p 12500",
"lint": "next lint" "lint": "next lint",
"eslint": "npx eslint .",
"generate": "graphql-codegen --config graphql-codegen.config.js",
"tsc": "tsc",
"prettier": "prettier --list-different --end-of-line auto --write .",
"upgrade": "ncu"
}, },
"dependencies": { "dependencies": {
"@fontsource/material-icons": "^4.5.2", "@fontsource/noto-serif-jp": "^5.0.7",
"@fontsource/material-icons-rounded": "^4.5.2", "@fontsource/opendyslexic": "^5.0.7",
"@fontsource/opendyslexic": "^4.5.2", "@fontsource/share-tech-mono": "^5.0.8",
"@fontsource/vollkorn": "^4.5.4", "@fontsource/vollkorn": "^5.0.9",
"@fontsource/zen-maru-gothic": "^4.5.5", "@fontsource/zen-maru-gothic": "^5.0.7",
"@formatjs/icu-messageformat-parser": "^2.6.0",
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"markdown-to-jsx": "^7.1.7", "autoprefixer": "^10.4.15",
"next": "^12.1.0", "cuid": "^2.1.8",
"nodemailer": "^6.7.3", "html-to-text": "^9.0.5",
"react": "17.0.2", "intl-messageformat": "^10.5.0",
"react-dom": "17.0.2", "isomorphic-dompurify": "^1.8.0",
"react-image-lightbox": "^5.1.4", "jotai": "^2.3.1",
"react-swipeable": "^6.2.0", "markdown-to-jsx": "^7.3.2",
"turndown": "^7.1.1" "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": { "devDependencies": {
"@types/node": "17.0.21", "@digitak/esrun": "3.2.24",
"@types/nodemailer": "^6.4.4", "@graphql-codegen/cli": "5.0.0",
"@types/react": "17.0.40", "@graphql-codegen/typescript": "4.0.1",
"@types/react-dom": "^17.0.13", "@graphql-codegen/typescript-graphql-request": "5.0.0",
"@graphql-codegen/typescript-operations": "4.0.1",
"@types/html-to-text": "^9.0.1",
"@types/marked": "^5.0.1",
"@types/node": "20.5.0",
"@types/nodemailer": "^6.4.9",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/string-natural-compare": "^3.0.2",
"@types/throttle-debounce": "^5.0.0",
"@types/turndown": "^5.0.1", "@types/turndown": "^5.0.1",
"@typescript-eslint/eslint-plugin": "^5.16.0", "@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/parser": "^5.16.0", "@typescript-eslint/eslint-plugin": "^6.4.0",
"eslint": "^8.10.0", "@typescript-eslint/parser": "^6.4.0",
"eslint-config-next": "12.1.0", "chalk": "^5.3.0",
"next-sitemap": "^2.5.14", "dotenv": "^16.3.1",
"prettier-plugin-organize-imports": "^2.3.4", "eslint": "^8.47.0",
"tailwindcss": "^3.0.23", "eslint-config-next": "13.4.17",
"typescript": "^4.6.2" "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"
} }
} }

21
prettier.config.js Normal file
View File

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

Binary file not shown.

BIN
public/gameover_cards.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/reader/paper.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1 +0,0 @@
npx next build

View File

@ -1 +1 @@
npx next dev -p 12499 npm run dev

View File

@ -1 +0,0 @@
npx prettier --end-of-line auto --write .

View File

@ -1 +1 @@
npx next start -p 12500 npm run start

View File

@ -1,2 +0,0 @@
export ENABLE_TESTING_LOG=true
npx next build 2> >(tee ./testing_logs/$(date +"%Y-%m-%d---%H-%M-%S").stderr.tsv) 1> >(tee ./testing_logs/$(date +"%Y-%m-%d---%H-%M-%S").stdout.tsv)

View File

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

View File

@ -1,392 +1,284 @@
import Button from "components/Button";
import { useAppLayout } from "contexts/AppLayoutContext";
import { StrapiImage } from "graphql/operations-types";
import { useMediaMobile } from "hooks/useMediaQuery";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router";
import { AppStaticProps } from "queries/getAppStaticProps";
import { getOgImage, OgImage, prettyLanguage } from "queries/helpers";
import { useEffect, useState } from "react";
import { useSwipeable } from "react-swipeable"; import { useSwipeable } from "react-swipeable";
import { ImageQuality } from "./Img"; import { MaterialSymbol } from "material-symbols";
import MainPanel from "./Panels/MainPanel"; import { atom } from "jotai";
import Popup from "./Popup"; import { useRouter } from "next/router";
import Select from "./Select"; import { layout } from "../../design.config";
import { Ico } from "./Ico";
import { MainPanel } from "./Panels/MainPanel";
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 AppLayoutProps extends AppStaticProps { /*
subPanel?: React.ReactNode; *
subPanelIcon?: string; * CONSTANTS
contentPanel?: React.ReactNode; */
title?: string;
navTitle: string; const SENSIBILITY_SWIPE = 1.1;
thumbnail?: StrapiImage; const isIOSAtom = atom((get) => get(atoms.userAgent.os) === "iOS");
description?: string;
/*
*
* COMPONENT
*/
export interface AppLayoutRequired {
openGraph: OpenGraph;
} }
export default function AppLayout(props: AppLayoutProps): JSX.Element { interface Props extends AppLayoutRequired {
const { langui, currencies, languages, subPanel, contentPanel } = props; subPanel?: React.ReactNode;
const router = useRouter(); subPanelIcon?: MaterialSymbol;
const isMobile = useMediaMobile(); contentPanel?: React.ReactNode;
const appLayout = useAppLayout(); contentPanelScroolbar?: boolean;
}
const sensibilitySwipe = 1.1; // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const AppLayout = ({
subPanel,
contentPanel,
openGraph,
subPanelIcon = "tune",
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 { format } = useFormat();
const handlers = useSwipeable({ const handlers = useSwipeable({
onSwipedLeft: (SwipeEventData) => { onSwipedLeft: (SwipeEventData) => {
if (SwipeEventData.velocity < sensibilitySwipe) return; if (isMenuGesturesEnabled) {
if (appLayout.mainPanelOpen) { if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
appLayout.setMainPanelOpen(false); if (isMainPanelOpened) {
} else if (subPanel && contentPanel) { setMainPanelOpened(false);
appLayout.setSubPanelOpen(true); } else if (isDefined(subPanel) && isDefined(contentPanel)) {
setSubPanelOpened(true);
}
} }
}, },
onSwipedRight: (SwipeEventData) => { onSwipedRight: (SwipeEventData) => {
if (SwipeEventData.velocity < sensibilitySwipe) return; if (isMenuGesturesEnabled) {
if (appLayout.subPanelOpen) { if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
appLayout.setSubPanelOpen(false); if (isSubPanelOpened) {
} else { setSubPanelOpened(false);
appLayout.setMainPanelOpen(true); } else {
setMainPanelOpened(true);
}
} }
}, },
}); });
const turnSubIntoContent = subPanel && !contentPanel; const turnSubIntoContent = isDefined(subPanel) && isUndefined(contentPanel) && is1ColumnLayout;
const titlePrefix = "Accords Library";
const metaImage: OgImage = props.thumbnail
? getOgImage(ImageQuality.Og, props.thumbnail)
: {
image: "/default_og.jpg",
width: 1200,
height: 630,
alt: "Accord's Library Logo",
};
const ogTitle = props.title ? props.title : props.navTitle;
const metaDescription = props.description
? props.description
: langui.default_description;
useEffect(() => {
document.getElementsByTagName("html")[0].style.fontSize = `${
(appLayout.fontSize ?? 1) * 100
}%`;
}, [appLayout.fontSize]);
const currencyOptions = currencies.map(
(currency) => currency.attributes.code
);
const [currencySelect, setCurrencySelect] = useState<number>(-1);
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 (props.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]";
}
return ( return (
<div <div
id="MyAppLayout" {...handlers}
className={`${ id={Ids.Body}
appLayout.darkMode ? "set-theme-dark" : "set-theme-light" className={cJoin(
} ${ "fixed inset-0 m-0 grid touch-pan-y bg-light p-0",
appLayout.dyslexic cIf(
? "set-theme-font-dyslexic" is1ColumnLayout,
: "set-theme-font-standard" "grid-rows-[1fr_5rem] [grid-template-areas:'content''navbar']",
}`} "[grid-template-areas:'main_sub_content']"
> )
<div )}
{...handlers} style={{
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']`} gridTemplateColumns: is1ColumnLayout
> ? "1fr"
<Head> : `${isMainPanelReduced ? layout.mainMenuReduced : layout.mainMenu}rem ${
<title>{`${titlePrefix} - ${ogTitle}`}</title> isDefined(subPanel) ? layout.subMenu : 0
}rem 1fr`,
}}>
<Head>
<title>{openGraph.title}</title>
<meta name="description" content={openGraph.description} />
<meta <meta name="twitter:site" content="@AccordsLibrary" />
name="twitter:title" <meta name="twitter:title" content={openGraph.title} />
content={`${titlePrefix} - ${ogTitle}`} <meta name="twitter:description" content={openGraph.description} />
></meta> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={openGraph.thumbnail.image} />
<meta name="description" content={metaDescription} /> <meta
<meta name="twitter:description" content={metaDescription}></meta> property="og:type"
content={openGraph.video ? "video.movie" : openGraph.audio ? "music.song" : "website"}
/>
<meta property="og:locale" content={router.locale} />
<meta property="og:site_name" content="Accords Library" />
<meta property="og:image" content={metaImage.image}></meta> <meta property="og:title" content={openGraph.title} />
<meta property="og:image:secure_url" content={metaImage.image}></meta> <meta property="og:description" content={openGraph.description} />
<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 name="twitter:image" content={metaImage.image}></meta> <meta property="og:image" content={openGraph.thumbnail.image} />
</Head> <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" />
{/* Background when navbar is opened */} {openGraph.audio && (
<div <>
className={`[grid-area:content] mobile:z-10 absolute inset-0 transition-[backdrop-filter] duration-500 ${ <meta property="og:audio" content={openGraph.audio} />
(appLayout.mainPanelOpen || appLayout.subPanelOpen) && isMobile <meta property="og:audio:type" content="audio/mpeg" />
? "[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
className={`[grid-area:content] overflow-y-scroll bg-light texture-paper-dots`}
>
{contentPanel ? (
contentPanel
) : (
<div className="grid place-content-center h-full">
<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>
</div>
)}
</div>
{/* Sub panel */}
{subPanel && (
<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]"
}`}
>
{subPanel}
</div>
)} )}
{openGraph.video && (
<>
<meta property="og:video" content={openGraph.video} />{" "}
<meta property="og:video:type" content="video/mp4" />
</>
)}
</Head>
{/* Main panel */} {/* Content panel */}
<div
id={Ids.ContentPanel}
className={cJoin(
"bg-light [grid-area:content]",
cIf(!isIOS, "texture-paper-dots"),
cIf(contentPanelScroolbar, "overflow-y-scroll")
)}>
{isDefined(contentPanel) ? (
contentPanel
) : turnSubIntoContent ? (
subPanel
) : (
<ContentPlaceholder message={format("select_option_sidebar")} icon={"chevron_left"} />
)}
</div>
{/* Background when navbar is opened */}
<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 <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 className={cJoin(
${appLayout.mainPanelOpen ? "" : "mobile:-translate-x-full"}`} "absolute inset-0 bg-shade transition-opacity duration-500",
> cIf(
<MainPanel langui={langui} /> (isMainPanelOpened || isSubPanelOpened) && is1ColumnLayout,
</div> "opacity-60",
"opacity-0"
)
)}
onClick={() => {
setMainPanelOpened(false);
setSubPanelOpened(false);
}}
/>
</div>
{/* Navbar */} {/* 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"> <div
<span className={cJoin(
className="material-icons mt-[.1em] cursor-pointer" `z-40 grid grid-cols-[5rem_1fr_5rem] place-items-center border-t
border-dotted border-black bg-light [grid-area:navbar]`,
cIf(!isIOS, "texture-paper-dots"),
cIf(!is1ColumnLayout, "hidden")
)}>
<Ico
icon={isMainPanelOpened ? "close" : "menu"}
className="cursor-pointer !text-2xl"
onClick={() => {
setMainPanelOpened((current) => !current);
setSubPanelOpened(false);
}}
/>
<p
className={cJoin(
"overflow-hidden text-center font-headers font-black",
cIf(openGraph.title.length > 30, "max-h-14 text-xl", "max-h-16 text-2xl")
)}>
{openGraph.title.substring(TITLE_PREFIX.length + TITLE_SEPARATOR.length)
? openGraph.title.substring(TITLE_PREFIX.length + TITLE_SEPARATOR.length)
: "Accords Library"}
</p>
{isDefined(subPanel) && !turnSubIntoContent && (
<Ico
icon={isSubPanelOpened ? "close" : subPanelIcon}
className="cursor-pointer !text-2xl"
onClick={() => { onClick={() => {
appLayout.setMainPanelOpen(!appLayout.mainPanelOpen); setSubPanelOpened((current) => !current);
appLayout.setSubPanelOpen(false); setMainPanelOpened(false);
}} }}
> />
{appLayout.mainPanelOpen ? "close" : "menu"} )}
</span> </div>
<p className="text-2xl font-black font-headers">{props.navTitle}</p>
<span {/* Sub panel */}
className="material-icons mt-[.1em] cursor-pointer" {isDefined(subPanel) && !turnSubIntoContent && (
onClick={() => { <div
appLayout.setSubPanelOpen(!appLayout.subPanelOpen); id={Ids.SubPanel}
appLayout.setMainPanelOpen(false); className={cJoin(
}} `overflow-y-scroll border-r border-dark/50 bg-light
> transition-transform duration-300 scrollbar-none`,
{subPanel && !turnSubIntoContent cIf(!isIOS, "texture-paper-dots"),
? appLayout.subPanelOpen cIf(
? "close" is1ColumnLayout,
: props.subPanelIcon "z-40 justify-self-end border-r-0 [grid-area:content]",
? props.subPanelIcon "[grid-area:sub]"
: "tune" ),
: ""} cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)] border-l"),
</span> cIf(is1ColumnLayout && !isSubPanelOpened, "translate-x-[100vw]")
)}>
{subPanel}
</div> </div>
)}
<Popup {/* Main panel */}
state={appLayout.languagePanelOpen} <div
setState={appLayout.setLanguagePanelOpen} className={cJoin(
> `overflow-y-scroll border-r border-dark/50 bg-light
<h2 className="text-2xl">{langui.select_language}</h2> transition-transform duration-300 scrollbar-none`,
<div className="flex flex-wrap flex-row gap-2 mobile:flex-col"> cIf(!isIOS, "texture-paper-dots"),
{router.locales?.map((locale) => ( cIf(is1ColumnLayout, "z-40 justify-self-start [grid-area:content]", "[grid-area:main]"),
<Button cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)]"),
key={locale} cIf(!isMainPanelOpened && is1ColumnLayout, "-translate-x-full")
active={locale === router.locale} )}>
href={router.asPath} <MainPanel />
locale={locale}
onClick={() => appLayout.setLanguagePanelOpen(false)}
>
{prettyLanguage(locale, languages)}
</Button>
))}
</div>
</Popup>
<Popup
state={appLayout.configPanelOpen}
setState={appLayout.setConfigPanelOpen}
>
<h2 className="text-2xl">{langui.settings}</h2>
<div className="mt-4 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_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_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
)
}
/>
</div>
</div>
</Popup>
</div> </div>
</div> </div>
); );
};
/*
*
* PRIVATE COMPONENTS
*/
interface ContentPlaceholderProps {
message: string;
icon?: MaterialSymbol;
} }
const ContentPlaceholder = ({ message, icon }: ContentPlaceholderProps): JSX.Element => (
<div className="grid h-full place-content-center">
<div
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
border-dark p-8 text-dark opacity-40">
{isDefined(icon) && <Ico icon={icon} className="!text-[300%]" />}
<p className={cJoin("w-64 text-2xl", cIf(isUndefined(icon), "text-center"))}>{message}</p>
</div>
</div>
);

View File

@ -1,45 +0,0 @@
import { useRouter } from "next/router";
import { MouseEventHandler } from "react";
type ButtonProps = {
id?: string;
className?: string;
href?: string;
children: React.ReactNode;
active?: boolean;
locale?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
};
export default function Button(props: ButtonProps): JSX.Element {
const router = useRouter();
const button = (
<div
id={props.id}
onClick={props.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 ${
props.className
} ${
props.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"
}`}
>
{props.children}
</div>
);
return (
<div
onClick={() => {
if (props.href || props.locale)
router.push(props.href ?? router.asPath, props.href, {
locale: props.locale,
});
}}
>
{button}
</div>
);
}

View File

@ -1,14 +1,24 @@
type ChipProps = { import { cJoin } from "helpers/className";
className?: string;
children: React.ReactNode;
};
export default function Chip(props: ChipProps): JSX.Element { /*
return ( *
<div * COMPONENT
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} interface Props {
</div> className?: string;
); text: string;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Chip = ({ className, text }: Props): JSX.Element => (
<div
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>
);

View File

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

View File

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

View File

@ -0,0 +1,54 @@
import { useState } from "react";
import { GetChroniclesChaptersQuery } from "graphql/generated";
import { filterHasAttributes } from "helpers/asserts";
import { TranslatedChroniclesList } from "components/Chronicles/ChroniclesList";
import { prettySlug } from "helpers/formatters";
/*
*
* COMPONENT
*/
interface Props {
chapters: NonNullable<GetChroniclesChaptersQuery["chroniclesChapters"]>["data"];
currentChronicleSlug?: string;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const ChroniclesLists = ({ chapters, currentChronicleSlug }: Props): JSX.Element => {
const [openedIndex, setOpenedIndex] = useState(
currentChronicleSlug
? chapters.findIndex(
(chapter) =>
chapter.attributes?.chronicles?.data.some(
(chronicle) => chronicle.attributes?.slug === currentChronicleSlug
)
)
: -1
);
return (
<div className="grid gap-16">
{filterHasAttributes(chapters, ["attributes.chronicles", "id"]).map(
(chapter, chapterIndex) => (
<TranslatedChroniclesList
currentSlug={currentChronicleSlug}
open={openedIndex === chapterIndex}
onOpening={() => setOpenedIndex(chapterIndex)}
onTriggerClosing={() => setOpenedIndex(-1)}
key={chapter.id}
chronicles={chapter.attributes.chronicles.data}
translations={filterHasAttributes(chapter.attributes.titles, [
"language.data.attributes.code",
]).map((translation) => ({
title: translation.title,
language: translation.language.data.attributes.code,
}))}
fallback={{ title: prettySlug(chapter.attributes.slug) }}
/>
)
)}
</div>
);
};

View File

@ -1,137 +0,0 @@
import Chip from "components/Chip";
import ToolTip from "components/ToolTip";
import {
Enum_Componenttranslationschronologyitem_Status,
GetChronologyItemsQuery,
GetWebsiteInterfaceQuery,
} from "graphql/operations-types";
import { getStatusDescription } from "queries/helpers";
export type ChronologyItemComponentProps = {
item: GetChronologyItemsQuery["chronologyItems"]["data"][number];
displayYear: boolean;
langui: GetWebsiteInterfaceQuery["websiteInterfaces"]["data"][number]["attributes"];
};
export default function ChronologyItemComponent(
props: ChronologyItemComponentProps
): JSX.Element {
const { langui } = props;
function generateAnchor(year: number, month: number, day: number): string {
let result = "";
result += year;
if (month) result += `- ${month.toString().padStart(2, "0")}`;
if (day) result += `- ${day.toString().padStart(2, "0")}`;
return result;
}
function generateYear(displayed_date: string, year: number): string {
if (displayed_date) {
return displayed_date;
}
return year.toString();
}
function generateDate(month: number, day: number): string {
const lut = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
let result = "";
if (month) {
result += lut[month - 1];
if (day) {
result += ` ${day}`;
}
}
return result;
}
return (
<div
className="grid place-content-start grid-rows-[auto_1fr] grid-cols-[4em] py-4 px-8 rounded-2xl target:bg-mid target:py-8 target:my-4"
id={generateAnchor(
props.item.attributes.year,
props.item.attributes.month,
props.item.attributes.day
)}
>
{props.displayYear && (
<p className="text-lg mt-[-.2em] font-bold">
{generateYear(
props.item.attributes.displayed_date,
props.item.attributes.year
)}
</p>
)}
<p className="col-start-1 text-dark text-sm">
{generateDate(props.item.attributes.month, props.item.attributes.day)}
</p>
<div className="col-start-2 row-start-1 row-span-2 grid gap-4">
{props.item.attributes.events.map((event) => (
<div className="m-0" key={event.id}>
{event.translations.map((translation) => (
<>
<div className="place-items-start place-content-start grid grid-flow-col gap-2">
{translation.status !==
Enum_Componenttranslationschronologyitem_Status.Done && (
<ToolTip
content={getStatusDescription(translation.status, langui)}
maxWidth={"20rem"}
>
<Chip>{translation.status}</Chip>
</ToolTip>
)}
{translation.title ? <h3>{translation.title}</h3> : ""}
</div>
{translation.description && (
<p
className={
event.translations.length > 1
? "before:content-['-'] before:text-dark before:inline-block before:w-4 before:ml-[-1em] mt-2 whitespace-pre-line"
: "whitespace-pre-line"
}
>
{translation.description}
</p>
)}
{translation.note ? (
<em>{`Notes: ${translation.note}`}</em>
) : (
""
)}
</>
))}
<p className="text-dark text-xs grid place-self-start grid-flow-col gap-1 mt-1">
{event.source.data ? (
`(${event.source.data.attributes.name})`
) : (
<>
<span className="material-icons !text-sm">warning</span>No
sources!
</>
)}
</p>
</div>
))}
</div>
</div>
);
}

View File

@ -1,33 +0,0 @@
import ChronologyItemComponent from "components/Chronology/ChronologyItemComponent";
import {
GetChronologyItemsQuery,
GetWebsiteInterfaceQuery,
} from "graphql/operations-types";
type ChronologyYearComponentProps = {
year: number;
items: GetChronologyItemsQuery["chronologyItems"]["data"][number][];
langui: GetWebsiteInterfaceQuery["websiteInterfaces"]["data"][number]["attributes"];
};
export default function ChronologyYearComponent(
props: ChronologyYearComponentProps
): JSX.Element {
const { langui } = props;
return (
<div
className="target:bg-mid rounded-2xl target:py-4 target:my-4"
id={props.items.length > 1 ? props.year.toString() : undefined}
>
{props.items.map((item, index) => (
<ChronologyItemComponent
key={index}
item={item}
displayYear={index === 0}
langui={langui}
/>
))}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,79 @@
import { useHotkeys } from "react-hotkeys-hook";
import { Ico } from "components/Ico";
import { PageSelector } from "components/Inputs/PageSelector";
import { atoms } from "contexts/atoms";
import { isUndefined } from "helpers/asserts";
import { useAtomGetter } from "helpers/atoms";
import { useFormat } from "hooks/useFormat";
import { useScrollTopOnChange } from "hooks/useScrollOnChange";
import { Ids } from "types/ids";
/*
*
* COMPONENT
*/
interface Props {
page: number;
onPageChange: (newPage: number) => void;
totalNumberOfPages: number | null | undefined;
children: React.ReactNode;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Paginator = ({
page,
onPageChange,
totalNumberOfPages,
children,
}: Props): JSX.Element => {
useScrollTopOnChange(Ids.ContentPanel, [page]);
useHotkeys("left", () => onPageChange(page - 1), { enabled: page > 1 }, [page]);
useHotkeys("right", () => onPageChange(page + 1), { enabled: page < (totalNumberOfPages ?? 0) }, [
page,
]);
if (totalNumberOfPages === 0) return <DefaultRenderWhenEmpty />;
if (isUndefined(totalNumberOfPages) || totalNumberOfPages < 2) return <>{children}</>;
return (
<>
<PageSelector
page={page}
onChange={onPageChange}
pagesCount={totalNumberOfPages}
className="mb-12"
/>
{children}
<PageSelector
page={page}
onChange={onPageChange}
pagesCount={totalNumberOfPages}
className="mt-12"
/>
</>
);
};
/*
*
* PRIVATE COMPONENTS
*/
const DefaultRenderWhenEmpty = () => {
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
const { format } = useFormat();
return (
<div className="grid h-full place-content-center">
<div
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
border-dark p-8 text-dark opacity-40">
{is3ColumnsLayout && <Ico icon="chevron_left" className="!text-[300%]" />}
<p className="max-w-xs text-2xl">{format("no_results_message")}</p>
{!is3ColumnsLayout && <Ico icon="chevron_right" className="!text-[300%]" />}
</div>
</div>
);
};

View File

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

View File

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

View File

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

View File

@ -1,90 +0,0 @@
import Chip from "components/Chip";
import Img, { ImageQuality } from "components/Img";
import InsetBox from "components/InsetBox";
import {
GetContentQuery,
GetWebsiteInterfaceQuery,
} from "graphql/operations-types";
import { prettyinlineTitle, prettySlug, slugify } from "queries/helpers";
export type ThumbnailHeaderProps = {
pre_title?: string;
title: string;
subtitle?: string;
description?: string;
type?: GetContentQuery["contents"]["data"][number]["attributes"]["type"];
categories?: GetContentQuery["contents"]["data"][number]["attributes"]["categories"];
thumbnail?: GetContentQuery["contents"]["data"][number]["attributes"]["thumbnail"]["data"]["attributes"];
langui: GetWebsiteInterfaceQuery["websiteInterfaces"]["data"][number]["attributes"];
};
export default function ThumbnailHeader(
props: ThumbnailHeaderProps
): JSX.Element {
const {
langui,
pre_title,
title,
subtitle,
thumbnail,
type,
categories,
description,
} = props;
return (
<>
<div className="grid place-items-center gap-12 mb-12">
<div className="drop-shadow-shade-lg">
{thumbnail ? (
<Img
className=" rounded-xl"
image={thumbnail}
quality={ImageQuality.Medium}
priority
/>
) : (
<div className="w-96 aspect-[4/3] bg-light rounded-xl"></div>
)}
</div>
<div
id={slugify(
prettyinlineTitle(pre_title ?? "", title, subtitle ?? "")
)}
className="grid place-items-center text-center"
>
<p className="text-2xl">{pre_title}</p>
<h1 className="text-3xl">{title}</h1>
<h2 className="text-2xl">{subtitle}</h2>
</div>
</div>
<div className="grid grid-flow-col gap-8">
{type?.data && (
<div className="flex flex-col place-items-center gap-2">
<h3 className="text-xl">{langui.type}</h3>
<div className="flex flex-row flex-wrap">
<Chip>
{type.data.attributes.titles.length > 0
? type.data.attributes.titles[0].title
: prettySlug(type.data.attributes.slug)}
</Chip>
</div>
</div>
)}
{categories && categories.data.length > 0 && (
<div className="flex flex-col place-items-center gap-2">
<h3 className="text-xl">{langui.categories}</h3>
<div className="flex flex-row flex-wrap place-content-center gap-2">
{categories.data.map((category) => (
<Chip key={category.id}>{category.attributes.name}</Chip>
))}
</div>
</div>
)}
</div>
{description && <InsetBox className="mt-8">{description}</InsetBox>}
</>
);
}

View File

@ -0,0 +1,60 @@
import { useRef } from "react";
import { Button, TranslatedButton } from "components/Inputs/Button";
import { atoms } from "contexts/atoms";
import { ParentFolderPreviewFragment } from "graphql/generated";
import { useAtomSetter } from "helpers/atoms";
import { useScrollRightOnChange } from "hooks/useScrollOnChange";
import { Ids } from "types/ids";
import { filterHasAttributes } from "helpers/asserts";
import { prettySlug } from "helpers/formatters";
import { Ico } from "components/Ico";
interface Props {
path: ParentFolderPreviewFragment[];
}
export const FolderPath = ({ path }: Props): JSX.Element => {
useScrollRightOnChange(Ids.ContentsFolderPath, [path]);
const setMenuGesturesEnabled = useAtomSetter(atoms.layout.menuGesturesEnabled);
const gestureReenableTimeout = useRef<NodeJS.Timeout>();
return (
<div className="grid">
<div
id={Ids.ContentsFolderPath}
onPointerEnter={() => {
if (gestureReenableTimeout.current) clearTimeout(gestureReenableTimeout.current);
setMenuGesturesEnabled(false);
}}
onPointerLeave={() => {
gestureReenableTimeout.current = setTimeout(() => setMenuGesturesEnabled(true), 500);
}}
className={`-mx-4 flex place-items-center justify-start gap-x-1 gap-y-4
overflow-x-auto px-4 pb-10 scrollbar-none`}>
{path.map((pathFolder, index) => (
<>
{pathFolder.slug === "root" ? (
<Button href="/contents" icon="home" active={index === path.length - 1} />
) : (
<TranslatedButton
className="w-max"
href={`/contents/folder/${pathFolder.slug}`}
translations={filterHasAttributes(pathFolder.titles, [
"language.data.attributes.code",
]).map((title) => ({
language: title.language.data.attributes.code,
text: title.title,
}))}
fallback={{
text: prettySlug(pathFolder.slug),
}}
active={index === path.length - 1}
/>
)}
{index < path.length - 1 && <Ico icon="chevron_right" />}
</>
))}
</div>
</div>
);
};

View File

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

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

@ -0,0 +1,126 @@
import { Chip } from "components/Chip";
import { Markdawn } from "components/Markdown/Markdawn";
import { RecorderChip } from "components/RecorderChip";
import { ToolTip } from "components/ToolTip";
import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { ContentStatus, useFormat } from "hooks/useFormat";
/*
*
* COMPONENT
*/
interface Props {
languageCode?: string;
sourceLanguageCode?: string;
status?: ContentStatus | null;
transcribers?: RecorderChipsProps["recorders"];
translators?: RecorderChipsProps["recorders"];
proofreaders?: RecorderChipsProps["recorders"];
dubbers?: RecorderChipsProps["recorders"];
subbers?: RecorderChipsProps["recorders"];
authors?: RecorderChipsProps["recorders"];
notes?: string | null;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Credits = ({
languageCode,
sourceLanguageCode,
status,
transcribers = [],
translators = [],
dubbers = [],
proofreaders = [],
subbers = [],
authors = [],
notes,
}: Props): JSX.Element => {
const { format, formatStatusDescription, formatStatusLabel, formatLanguage } = useFormat();
return (
<div className="grid place-items-center gap-5">
{isDefined(languageCode) && isDefined(sourceLanguageCode) && (
<>
{languageCode === sourceLanguageCode ? (
<h2 className="text-xl">{format("transcript_notice")}</h2>
) : (
<>
<h2 className="text-xl">{format("translation_notice")}</h2>
<div className="flex flex-wrap place-content-center place-items-center gap-2">
<p className="font-headers font-bold">{format("source_language")}:</p>
<Chip text={formatLanguage(sourceLanguageCode)} />
</div>
</>
)}
</>
)}
{status && (
<div className="flex flex-wrap place-content-center place-items-center gap-2">
<p className="font-headers font-bold">{format("status")}:</p>
<ToolTip content={formatStatusDescription(status)} maxWidth={"20rem"}>
<Chip text={formatStatusLabel(status)} />
</ToolTip>
</div>
)}
{transcribers.length > 0 && (
<RecorderChips
title={format("transcriber", { count: transcribers.length })}
recorders={transcribers}
/>
)}
{translators.length > 0 && (
<RecorderChips
title={format("translator", { count: translators.length })}
recorders={translators}
/>
)}
{proofreaders.length > 0 && (
<RecorderChips
title={format("proofreader", { count: proofreaders.length })}
recorders={proofreaders}
/>
)}
{dubbers.length > 0 && (
<RecorderChips title={format("dubber", { count: dubbers.length })} recorders={dubbers} />
)}
{subbers.length > 0 && (
<RecorderChips title={format("subber", { count: subbers.length })} recorders={subbers} />
)}
{authors.length > 0 && (
<RecorderChips title={format("author", { count: authors.length })} recorders={authors} />
)}
{isDefinedAndNotEmpty(notes) && (
<div>
<p className="font-headers font-bold">{format("notes")}:</p>
<div className="grid place-content-center place-items-center gap-2">
<Markdawn text={notes} />
</div>
</div>
)}
</div>
);
};
interface RecorderChipsProps {
title: string;
recorders: { attributes?: { username: string } | null }[];
}
const RecorderChips = ({ title, recorders }: RecorderChipsProps) => (
<div className="flex flex-wrap place-content-center place-items-center gap-1">
<p className="pr-1 font-headers font-bold">{title}:</p>
{filterHasAttributes(recorders, ["attributes"]).map((recorder) => (
<RecorderChip key={recorder.attributes.username} username={recorder.attributes.username} />
))}
</div>
);

View File

@ -1,13 +1,16 @@
type HorizontalLineProps = { import { cJoin } from "helpers/className";
className?: string;
};
export default function HorizontalLine( /*
props: HorizontalLineProps *
): JSX.Element { * COMPONENT
return ( */
<div
className={`h-0 w-full my-8 border-t-[3px] border-dotted border-black ${props.className}`} interface Props {
></div> className?: string;
);
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const HorizontalLine = ({ className }: Props): JSX.Element => (
<div className={cJoin("my-8 h-0 w-full border-t-2 border-dotted border-black", className)} />
);

48
src/components/Ico.tsx Normal file
View File

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

View File

@ -1,104 +1,46 @@
import { StrapiImage } from "graphql/operations-types"; import { DetailedHTMLProps, ImgHTMLAttributes } from "react";
import Image, { ImageProps } from "next/image"; import { UploadImageFragment } from "graphql/generated";
import { getAssetURL, getImgSizesByQuality, ImageQuality } from "helpers/img";
export enum ImageQuality { /*
Small = "small", *
Medium = "medium", * COMPONENT
Large = "large", */
Og = "og",
}
export function getAssetURL(url: string, quality: ImageQuality): string { interface Props
let newUrl = url; extends Omit<DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, "src"> {
newUrl = newUrl.replace(/^\/uploads/u, `/${quality}`); src: UploadImageFragment | string;
newUrl = newUrl.replace(/.jpg$/u, ".webp");
newUrl = newUrl.replace(/.png$/u, ".webp");
if (quality === ImageQuality.Og) newUrl = newUrl.replace(/.webp$/u, ".jpg");
return process.env.NEXT_PUBLIC_URL_IMG + newUrl;
}
export function getImgSizesByMaxSize(
width: number,
height: number,
maxSize: number
): { width: number; height: number } {
if (width > height) {
if (width < maxSize) return { width: width, height: height };
return { width: maxSize, height: (height / width) * maxSize };
}
if (height < maxSize) return { width: width, height: height };
return { width: (width / height) * maxSize, height: maxSize };
}
export function getImgSizesByQuality(
width: number,
height: number,
quality: ImageQuality
): { width: number; height: number } {
switch (quality) {
case ImageQuality.Og:
return getImgSizesByMaxSize(width, height, 512);
case ImageQuality.Small:
return getImgSizesByMaxSize(width, height, 512);
case ImageQuality.Medium:
return getImgSizesByMaxSize(width, height, 1024);
case ImageQuality.Large:
return getImgSizesByMaxSize(width, height, 2048);
default:
return { width: 0, height: 0 };
}
}
type ImgProps = {
className?: string;
image?: StrapiImage;
quality?: ImageQuality; quality?: ImageQuality;
alt?: ImageProps["alt"]; sizeMultiplicator?: number;
layout?: ImageProps["layout"];
objectFit?: ImageProps["objectFit"];
priority?: ImageProps["priority"];
rawImg?: boolean;
};
export default function Img(props: ImgProps): JSX.Element {
if (props.image) {
const imgSize = getImgSizesByQuality(
props.image.width,
props.image.height,
props.quality ? props.quality : ImageQuality.Small
);
if (props.rawImg) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
className={props.className}
src={getAssetURL(
props.image.url,
props.quality ? props.quality : ImageQuality.Small
)}
alt={props.alt ? props.alt : props.image.alternativeText}
width={imgSize.width}
height={imgSize.height}
/>
);
}
return (
<Image
className={props.className}
src={getAssetURL(
props.image.url,
props.quality ? props.quality : ImageQuality.Small
)}
alt={props.alt ? props.alt : props.image.alternativeText}
width={props.layout === "fill" ? undefined : imgSize.width}
height={props.layout === "fill" ? undefined : imgSize.height}
layout={props.layout}
objectFit={props.objectFit}
priority={props.priority}
unoptimized
/>
);
}
return <></>;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Img = ({
className,
src: propsSrc,
quality = ImageQuality.Small,
alt,
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);
return (
<img
className={className}
src={src}
alt={alt}
loading={loading}
height={size.height}
width={size.width}
{...otherProps}
/>
);
};

View File

@ -0,0 +1,118 @@
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;
active?: boolean;
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 const Button = ({
draggable,
id,
onClick,
onMouseUp,
active = false,
className,
icon,
text,
href,
alwaysNewTab = false,
badgeNumber,
disabled,
type,
size = "normal",
}: Props): JSX.Element => (
<Link href={href} alwaysNewTab={alwaysNewTab} disabled={disabled}>
<div className="relative">
<button
type={type}
draggable={draggable}
id={id}
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={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>
)}
{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>
);
/*
*
* TRANSLATED VARIANT
*/
export const TranslatedButton = ({
translations,
fallback,
...otherProps
}: TranslatedProps<Props, "text">): JSX.Element => {
const [selectedTranslation] = useSmartLanguage({
items: translations,
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
});
return <Button text={selectedTranslation?.text ?? fallback.text} {...otherProps} />;
};

View File

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

View File

@ -0,0 +1,59 @@
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;
locales: Map<string, number>;
localesIndex: number | undefined;
onLanguageChanged: (index: number) => void;
size?: Parameters<typeof Button>[0]["size"];
showBadge?: boolean;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const LanguageSwitcher = ({
className,
locales,
localesIndex,
size,
onLanguageChanged,
showBadge = true,
}: Props): JSX.Element => {
const { formatLanguage } = useFormat();
return (
<ToolTip
content={
<div className={cJoin("flex flex-col gap-2", className)}>
{iterateMap(locales, (locale, value, index) => (
<Fragment key={index}>
<Button
active={value === localesIndex}
onClick={() => {
onLanguageChanged(value);
sendAnalytics("Language Switcher", `Switch language (${locale})`);
}}
text={formatLanguage(locale)}
/>
</Fragment>
))}
</div>
}>
<Button
badgeNumber={showBadge && locales.size > 1 ? locales.size : undefined}
icon="translate"
size={size}
/>
</ToolTip>
);
};

View File

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

View File

@ -0,0 +1,96 @@
import { Fragment, useCallback } from "react";
import { Ico } from "components/Ico";
import { arrayMove } from "helpers/others";
import { isDefinedAndNotEmpty } from "helpers/asserts";
/*
*
* COMPONENT
*/
interface Props {
className?: string;
items: { code: string; name: string }[];
insertLabels?: { insertAt: number; name: string }[];
onChange?: (props: Props["items"]) => void;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
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((item, index) => (
<Fragment key={index}>
<InsertedLabel label={insertLabels?.[index]?.name} />
<div
onDragStart={(event) => {
const source = event.target as HTMLElement;
const sourceIndex = source.parentElement
? Array.from(source.parentElement.children)
.filter((element) => element.tagName === "DIV")
.indexOf(source)
: -1;
event.dataTransfer.setData("text", sourceIndex.toString());
}}
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={(event) => {
event.preventDefault();
const target = event.target as HTMLElement;
const targetIndex = target.parentElement
? Array.from(target.parentElement.children)
.filter((element) => element.tagName === "DIV")
.indexOf(target)
: -1;
const sourceIndex = parseInt(event.dataTransfer.getData("text"), 10);
updateOrder(sourceIndex, targetIndex);
}}
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 && (
<Ico
icon="arrow_drop_up"
className="row-start-1 cursor-pointer"
onClick={() => {
updateOrder(index, index - 1);
}}
/>
)}
{index < items.length - 1 && (
<Ico
icon="arrow_drop_down"
className="row-start-2 cursor-pointer"
onClick={() => {
updateOrder(index, index + 1);
}}
/>
)}
</div>
{item.name}
</div>
</Fragment>
))}
</div>
);
};

View File

@ -0,0 +1,45 @@
import { ButtonGroup } from "./ButtonGroup";
import { cJoin } from "helpers/className";
/*
*
* COMPONENT
*/
interface Props {
className?: string;
page: number;
pagesCount: number;
onChange: (value: number) => void;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
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",
},
]}
/>
);

View File

@ -0,0 +1,100 @@
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 {
value: number;
options: string[];
selected?: number;
allowEmpty?: boolean;
className?: string;
onChange: (value: number) => void;
disabled?: boolean;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
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
ref={ref}
className={cJoin(
"relative text-center transition-filter",
cIf(isOpened, "z-20 drop-shadow-lg shadow-shade"),
className
)}>
<div
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>
{value >= 0 && allowEmpty && (
<Ico
icon="close"
className="!text-xs"
onClick={() => !disabled && onSelectionChanged(-1)}
/>
)}
<Ico onClick={tryToggling} icon={isOpened ? "arrow_drop_up" : "arrow_drop_down"} />
</div>
<div className={cJoin("left-0 right-0 rounded-b-[1em]", cIf(isOpened, "absolute", "hidden"))}>
{options.map((option, index) => (
<Fragment key={index}>
{index !== value && (
<div
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={() => onSelectionChanged(index)}>
{option}
</div>
)}
</Fragment>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,50 @@
import { useState } from "react";
import { cIf, cJoin } from "helpers/className";
/*
*
* COMPONENT
*/
interface Props {
onClick: () => void;
value: boolean;
className?: string;
disabled?: boolean;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Switch = ({ value, onClick, className, disabled = false }: Props): JSX.Element => {
const [isFocused, setIsFocused] = useState(false);
return (
<div
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) onClick();
}}
onPointerDown={() => !disabled && setIsFocused(true)}
onPointerOut={() => setIsFocused(false)}
onPointerLeave={() => setIsFocused(false)}
onPointerUp={() => setIsFocused(false)}>
<div
className={cJoin(
"pointer-events-none ml-1 h-4 w-4 touch-none rounded-full bg-dark transition-transform",
cIf(value, "translate-x-6"),
cIf(isFocused, cIf(value, "translate-x-5", "translate-x-1"))
)}
/>
</div>
);
};

View File

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

View File

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

View File

@ -1,16 +0,0 @@
type InsetBoxProps = {
className?: string;
children: React.ReactNode;
id?: string;
};
export default function InsetBox(props: InsetBoxProps): 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>
);
}

View File

@ -1,42 +0,0 @@
import {
GetLanguagesQuery,
GetWebsiteInterfaceQuery,
} from "graphql/operations-types";
import { useRouter } from "next/router";
import { prettyLanguage } from "queries/helpers";
import Button from "./Button";
type HorizontalLineProps = {
className?: string;
locales: string[];
languages: GetLanguagesQuery["languages"]["data"];
langui: GetWebsiteInterfaceQuery["websiteInterfaces"]["data"][number]["attributes"];
href?: string;
};
export default function HorizontalLine(
props: HorizontalLineProps
): JSX.Element {
const { locales, langui, href } = props;
const router = useRouter();
return (
<div className="w-full grid place-content-center">
<div className="flex flex-col place-items-center text-center gap-4 my-12 border-2 border-mid rounded-xl p-8 max-w-lg">
<p>{langui.language_switch_message}</p>
<div className="flex flex-wrap flex-row gap-2">
{locales.map((locale, index) => (
<Button
key={index}
active={locale === router.locale}
href={href}
locale={locale}
>
{prettyLanguage(locale, props.languages)}
</Button>
))}
</div>
</div>
</div>
);
}

View File

@ -1,101 +0,0 @@
import Button from "components/Button";
import Chip from "components/Chip";
import {
GetLibraryItemQuery,
GetWebsiteInterfaceQuery,
} from "graphql/operations-types";
import { prettyinlineTitle, prettySlug } from "queries/helpers";
import { useState } from "react";
type ContentTOCLineProps = {
content: GetLibraryItemQuery["libraryItems"]["data"][number]["attributes"]["contents"]["data"][number];
parentSlug: string;
langui: GetWebsiteInterfaceQuery["websiteInterfaces"]["data"][number]["attributes"];
};
export default function ContentTOCLine(
props: ContentTOCLineProps
): JSX.Element {
const { content, langui, parentSlug } = props;
const [opened, setOpened] = useState(false);
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 &&
content.attributes.content.data.attributes.titles.length > 0
? prettyinlineTitle(
content.attributes.content.data.attributes.titles[0]
.pre_title,
content.attributes.content.data.attributes.titles[0].title,
content.attributes.content.data.attributes.titles[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 && (
<Chip className="justify-self-end thin:hidden">
{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.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.length === 0 &&
!content.attributes.content.data
? "The content is not available"
: ""}
</div>
</div>
);
}

View File

@ -1,66 +0,0 @@
import Chip from "components/Chip";
import Img, { ImageQuality } from "components/Img";
import { GetContentsQuery } from "graphql/operations-types";
import Link from "next/link";
import { prettySlug } from "queries/helpers";
export type LibraryContentPreviewProps = {
item: {
slug: GetContentsQuery["contents"]["data"][number]["attributes"]["slug"];
thumbnail: GetContentsQuery["contents"]["data"][number]["attributes"]["thumbnail"];
titles: GetContentsQuery["contents"]["data"][number]["attributes"]["titles"];
categories: GetContentsQuery["contents"]["data"][number]["attributes"]["categories"];
type: GetContentsQuery["contents"]["data"][number]["attributes"]["type"];
};
};
export default function LibraryContentPreview(
props: LibraryContentPreviewProps
): JSX.Element {
const { item } = props;
return (
<Link href={`/contents/${item.slug}`} passHref>
<div className="drop-shadow-shade-xl cursor-pointer grid items-end fine:[--cover-opacity:0] hover:[--cover-opacity:1] hover:scale-[1.02] transition-transform">
{item.thumbnail.data ? (
<Img
className="rounded-md coarse:rounded-b-none"
image={item.thumbnail.data.attributes}
quality={ImageQuality.Medium}
/>
) : (
<div className="w-full aspect-[3/2] bg-light rounded-lg"></div>
)}
<div className="linearbg-obi 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">
<div className="grid grid-flow-col gap-1 overflow-hidden place-content-start">
{item.type.data && (
<Chip>
{item.type.data.attributes.titles.length > 0
? item.type.data.attributes.titles[0].title
: prettySlug(item.type.data.attributes.slug)}
</Chip>
)}
</div>
<div>
{item.titles.length > 0 ? (
<>
<p>{item.titles[0].pre_title}</p>
<h1 className="text-lg">{item.titles[0].title}</h1>
<h2>{item.titles[0].subtitle}</h2>
</>
) : (
<h1 className="text-lg">{prettySlug(item.slug)}</h1>
)}
</div>
<div className="grid grid-flow-col gap-1 overflow-x-scroll webkit-scrollbar:w-0 [scrollbar-width:none] place-content-start">
{item.categories.data.map((category) => (
<Chip key={category.id} className="text-sm">
{category.attributes.short}
</Chip>
))}
</div>
</div>
</div>
</Link>
);
}

View File

@ -1,94 +0,0 @@
import Chip from "components/Chip";
import Img, { ImageQuality } from "components/Img";
import { useAppLayout } from "contexts/AppLayoutContext";
import {
GetCurrenciesQuery,
GetLibraryItemsPreviewQuery,
} from "graphql/operations-types";
import Link from "next/link";
import { prettyDate, prettyItemSubType, prettyPrice } from "queries/helpers";
export type LibraryItemsPreviewProps = {
className?: string;
item: {
slug: GetLibraryItemsPreviewQuery["libraryItems"]["data"][number]["attributes"]["slug"];
thumbnail: GetLibraryItemsPreviewQuery["libraryItems"]["data"][number]["attributes"]["thumbnail"];
title: GetLibraryItemsPreviewQuery["libraryItems"]["data"][number]["attributes"]["title"];
subtitle: GetLibraryItemsPreviewQuery["libraryItems"]["data"][number]["attributes"]["subtitle"];
price?: GetLibraryItemsPreviewQuery["libraryItems"]["data"][number]["attributes"]["price"];
categories: GetLibraryItemsPreviewQuery["libraryItems"]["data"][number]["attributes"]["categories"];
release_date?: GetLibraryItemsPreviewQuery["libraryItems"]["data"][number]["attributes"]["release_date"];
metadata?: GetLibraryItemsPreviewQuery["libraryItems"]["data"][number]["attributes"]["metadata"];
};
currencies?: GetCurrenciesQuery["currencies"]["data"];
};
export default function LibraryItemsPreview(
props: LibraryItemsPreviewProps
): JSX.Element {
const { item } = props;
const appLayout = useAppLayout();
return (
<Link href={`/library/${item.slug}`} passHref>
<div
className={`drop-shadow-shade-xl cursor-pointer grid items-end hover:rounded-3xl fine:[--cover-opacity:0] hover:[--cover-opacity:1] hover:scale-[1.02] transition-transform ${props.className}`}
>
{item.thumbnail.data ? (
<Img
image={item.thumbnail.data.attributes}
quality={ImageQuality.Small}
/>
) : (
<div className="w-full aspect-[21/29.7] bg-light rounded-lg"></div>
)}
<div className="linearbg-obi fine:drop-shadow-shade-lg fine:absolute place-items-start bottom-2 -inset-x-0.5 opacity-[var(--cover-opacity)] transition-opacity z-20 grid p-4 gap-2">
{item.metadata && item.metadata.length > 0 && (
<div className="flex flex-row gap-1">
<Chip>{prettyItemSubType(item.metadata[0])}</Chip>
</div>
)}
<div>
<h2 className="mobile:text-sm text-lg leading-5">{item.title}</h2>
<h3 className="mobile:text-xs leading-3">{item.subtitle}</h3>
</div>
<div className="w-full grid grid-flow-col gap-1 overflow-x-scroll webkit-scrollbar:h-0 [scrollbar-width:none] place-content-start">
{item.categories.data.map((category) => (
<Chip key={category.id} className="text-sm">
{category.attributes.short}
</Chip>
))}
</div>
{(item.release_date || item.price) && (
<div className="grid grid-flow-col w-full">
{item.release_date && (
<p className="mobile:text-xs text-sm">
<span className="material-icons !text-base translate-y-[.15em] mr-1">
event
</span>
{prettyDate(item.release_date)}
</p>
)}
{item.price && props.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(
item.price,
props.currencies,
appLayout.currency
)}
</p>
)}
</div>
)}
</div>
</div>
</Link>
);
}

View File

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

View File

@ -1,39 +1,206 @@
import { useMediaMobile } from "hooks/useMediaQuery"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { Dispatch, SetStateAction } from "react"; import { useState } from "react";
import Lightbox from "react-image-lightbox"; import { useHotkeys } from "react-hotkeys-hook";
import { Img } from "./Img";
import { Button } from "./Inputs/Button";
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";
export type LightBoxProps = { /*
setState: *
| Dispatch<SetStateAction<boolean | undefined>> * COMPONENT
| Dispatch<SetStateAction<boolean>>; */
state: boolean;
images: string[];
index: number;
setIndex: Dispatch<SetStateAction<number>>;
};
export default function LightBox(props: LightBoxProps): JSX.Element { interface Props {
const { state, setState, images, index, setIndex } = props; onCloseRequest: () => void;
const mobile = useMediaMobile(); isVisible: boolean;
image?: UploadImageFragment | string;
isNextImageAvailable: boolean;
isPreviousImageAvailable: boolean;
onPressNext: () => void;
onPressPrevious: () => void;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
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
);
useHotkeys("left", () => onPressPrevious(), { enabled: isVisible && isPreviousImageAvailable }, [
onPressPrevious,
]);
useHotkeys("f", () => requestFullscreen(), { enabled: isVisible && !isFullscreen }, [
requestFullscreen,
]);
useHotkeys("right", () => onPressNext(), { enabled: isVisible && isNextImageAvailable }, [
onPressNext,
]);
useHotkeys("escape", onCloseRequest, { enabled: isVisible }, [onCloseRequest]);
return ( return (
<div
id={Ids.LightBox}
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
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>
);
};
/*
*
* 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 ? (
<> <>
{state && ( <div className="absolute bottom-2 left-0 right-0 grid place-content-center">
<Lightbox <div className="grid grid-flow-col gap-4 rounded-4xl p-4 backdrop-blur-lg">
reactModalProps={{ <PreviousButton />
parentSelector: () => document.getElementById("MyAppLayout"), <FullscreenButton />
}} <NextButton />
mainSrc={images[index]} </div>
prevSrc={index > 0 ? images[index - 1] : undefined} </div>
nextSrc={index < images.length ? images[index + 1] : undefined} <div className="absolute right-2 top-2 grid gap-4 rounded-4xl p-4 backdrop-blur-lg">
onMovePrevRequest={() => setIndex(index - 1)} <CloseButton />
onMoveNextRequest={() => setIndex(index + 1)} </div>
imageCaption="" </>
imageTitle="" ) : (
onCloseRequest={() => setState(false)} <>
imagePadding={mobile ? 0 : 70} {isPreviousImageAvailable && (
/> <div
className={`absolute left-8 top-1/2 grid gap-4 rounded-4xl p-4
backdrop-blur-lg`}>
<PreviousButton />
</div>
)} )}
{isNextImageAvailable && (
<div
className={`absolute right-8 top-1/2 grid gap-4 rounded-4xl p-4
backdrop-blur-lg`}>
<NextButton />
</div>
)}
<div
className={`absolute right-8 top-4 grid gap-4 rounded-4xl p-4
backdrop-blur-lg`}>
<CloseButton />
<FullscreenButton />
</div>
</> </>
); );
} };

View File

@ -1,388 +1,575 @@
import HorizontalLine from "components/HorizontalLine";
import Img, { getAssetURL, ImageQuality } from "components/Img";
import InsetBox from "components/InsetBox";
import LightBox from "components/LightBox";
import ToolTip from "components/ToolTip";
import { useAppLayout } from "contexts/AppLayoutContext";
import Markdown from "markdown-to-jsx"; import Markdown from "markdown-to-jsx";
import { useRouter } from "next/router"; import React, { Fragment, MouseEventHandler, useMemo } from "react";
import { slugify } from "queries/helpers";
import React, { useState } from "react";
import ReactDOMServer from "react-dom/server"; import ReactDOMServer from "react-dom/server";
import { z } from "zod";
import { HorizontalLine } from "components/HorizontalLine";
import { Img } from "components/Img";
import { InsetBox } from "components/Containers/InsetBox";
import { cIf, cJoin } from "helpers/className";
import { slugify } from "helpers/formatters";
import { getAssetURL, ImageQuality } from "helpers/img";
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";
type MarkdawnProps = { /*
*
* COMPONENT
*/
interface MarkdawnProps {
className?: string; className?: string;
text: string; text: string;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
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);
/* eslint-disable no-irregular-whitespace */
const text = `${preprocessMarkDawn(rawText, playerName)}
`;
/* eslint-enable no-irregular-whitespace */
if (isUndefined(text) || text === "") {
return <></>;
}
return (
<Markdown
className={cJoin("formatted", className)}
options={{
slugify: slugify,
overrides: {
a: {
component: (compProps: { href: string; children: React.ReactNode }) => {
if (compProps.href.startsWith("/") || compProps.href.startsWith("#")) {
return (
<Link href={compProps.href} linkStyled>
{compProps.children}
</Link>
);
}
return (
<Link href={compProps.href} alwaysNewTab linkStyled>
{compProps.children}
</Link>
);
},
},
Header: {
component: (compProps: {
id: string;
style: React.CSSProperties;
children: string;
level: string;
}) => (
<Header
title={compProps.children}
level={parseInt(compProps.level, 10)}
slug={compProps.id}
/>
),
},
SceneBreak: {
component: (compProps: { id: string }) => (
<Header title={"* * *"} level={6} slug={compProps.id} />
),
},
IntraLink: {
component: (compProps: {
children: React.ReactNode;
target?: string;
page?: string;
}) => {
const slug = isDefinedAndNotEmpty(compProps.target)
? slugify(compProps.target)
: slugify(compProps.children?.toString());
return (
<Link href={`${compProps.page ?? ""}#${slug}`} linkStyled>
{compProps.children}
</Link>
);
},
},
Transcript: {
component: (compProps) => (
<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) => {
const schema = z.object({ name: z.string(), children: z.any() });
if (!schema.safeParse(compProps).success) {
return (
<MarkdawnError
message={`Error while parsing a <Line/> tag. Here is the correct usage:
<Line name="John">Hello!</Line>`}
/>
);
}
const safeProps: z.infer<typeof schema> = compProps;
return (
<>
<strong
className={cJoin(
"!my-0 text-dark/60",
cIf(!isContentPanelAtLeastLg, "!-mb-4")
)}>
<Markdawn text={safeProps.name} />
</strong>
<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>,
},
li: {
component: (compProps: { children: React.ReactNode }) => (
<li
className={
isDefined(compProps.children) &&
ReactDOMServer.renderToStaticMarkup(<>{compProps.children}</>).length > 100
? "my-4"
: ""
}>
{compProps.children}
</li>
),
},
Highlight: {
component: (compProps: { children: React.ReactNode }) => (
<mark>{compProps.children}</mark>
),
},
footer: {
component: (compProps: { children: React.ReactNode }) => (
<>
<HorizontalLine />
<div className="grid gap-8">{compProps.children}</div>
</>
),
},
blockquote: {
component: (compProps: { children: React.ReactNode; cite?: string }) => (
<blockquote>
{isDefinedAndNotEmpty(compProps.cite) ? (
<>
&ldquo;{compProps.children}&rdquo;
<cite> {compProps.cite}</cite>
</>
) : (
compProps.children
)}
</blockquote>
),
},
img: {
component: (compProps: {
alt: string;
src: string;
width?: number;
height?: number;
caption?: string;
name?: string;
}) => (
<div
className="mb-12 mt-8 grid cursor-pointer place-content-center"
onClick={() => {
showLightBox([
compProps.src.startsWith("/uploads/")
? getAssetURL(compProps.src, ImageQuality.Large)
: compProps.src,
]);
}}>
<Img
src={
compProps.src.startsWith("/uploads/")
? getAssetURL(compProps.src, ImageQuality.Small)
: compProps.src
}
quality={ImageQuality.Medium}
className="drop-shadow-lg shadow-shade"
/>
</div>
),
},
},
}}>
{text}
</Markdown>
);
}; };
export default function Markdawn(props: MarkdawnProps): JSX.Element { // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
const appLayout = useAppLayout();
const text = preprocessMarkDawn(props.text);
const router = useRouter(); interface MarkdawnErrorProps {
message: string;
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxImages, setLightboxImages] = useState([""]);
const [lightboxIndex, setLightboxIndex] = useState(0);
if (text) {
return (
<>
<LightBox
state={lightboxOpen}
setState={setLightboxOpen}
images={lightboxImages}
index={lightboxIndex}
setIndex={setLightboxIndex}
/>
<Markdown
className={`formatted ${props.className}`}
options={{
slugify: slugify,
overrides: {
a: {
component: (compProps: {
href: string;
children: React.ReactNode;
}) => {
if (compProps.href.startsWith("/")) {
return (
<a onClick={async () => router.push(compProps.href)}>
{compProps.children}
</a>
);
}
return <a href={compProps.href}>{compProps.children}</a>;
},
},
h1: {
component: (compProps: {
id: string;
style: React.CSSProperties;
children: React.ReactNode;
}) => (
<h1 id={compProps.id} style={compProps.style}>
{compProps.children}
<HeaderToolTip id={compProps.id} />
</h1>
),
},
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-24"></div>,
},
SceneBreak: {
component: (compProps: { id: string }) => (
<div
id={compProps.id}
className={"h-0 text-center text-3xl text-dark mt-16 mb-20"}
>
* * *
</div>
),
},
IntraLink: {
component: (compProps: {
children: React.ReactNode;
target?: string;
page?: string;
}) => {
const slug = compProps.target
? slugify(compProps.target)
: slugify(compProps.children?.toString());
return (
<a
onClick={async () =>
router.replace(
`${compProps.page ? compProps.page : ""}#${slug}`
)
}
>
{compProps.children}
</a>
);
},
},
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">
{compProps.children}
</div>
),
},
Line: {
component: (compProps) => (
<>
<strong className="text-dark opacity-60 mobile:!-mb-4">
{compProps.name}
</strong>
<p className="whitespace-pre-line">{compProps.children}</p>
</>
),
},
InsetBox: {
component: (compProps) => (
<InsetBox className="my-12">{compProps.children}</InsetBox>
),
},
li: {
component: (compProps: { children: React.ReactNode }) => (
<li
className={
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>
</>
),
},
blockquote: {
component: (compProps: {
children: React.ReactNode;
cite?: string;
}) => (
<blockquote>
{compProps.cite ? (
<>
&ldquo;{compProps.children}&rdquo;
<cite> {compProps.cite}</cite>
</>
) : (
compProps.children
)}
</blockquote>
),
},
img: {
component: (compProps: {
alt: string;
src: string;
width?: number;
height?: number;
caption?: string;
name?: string;
}) => (
<div
className="my-8 cursor-pointer"
onClick={() => {
setLightboxOpen(true);
setLightboxImages([
compProps.src.startsWith("/uploads/")
? getAssetURL(compProps.src, ImageQuality.Large)
: compProps.src,
]);
setLightboxIndex(0);
}}
>
{compProps.src.startsWith("/uploads/") ? (
<div className="relative w-full aspect-video">
<Img
image={{
__typename: "UploadFile",
alternativeText: compProps.alt,
url: compProps.src,
width: compProps.width ?? 1500,
height: compProps.height ?? 1000,
caption: compProps.caption ?? "",
name: compProps.name ?? "",
}}
layout="fill"
objectFit="contain"
quality={ImageQuality.Medium}
></Img>
</div>
) : (
<div className="grid place-content-center">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img {...compProps} className="max-h-[50vh] " />
</div>
)}
</div>
),
},
},
}}
>
{text}
</Markdown>
</>
);
}
return <></>;
} }
function HeaderToolTip(props: { id: 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 ( return (
<ToolTip <>
content={"Copy anchor link"} {toc.children.length > 0 && (
trigger="mouseenter" <>
className="text-sm" <h3 className="text-xl">{format("table_of_contents")}</h3>
> <div className="max-w-[14.5rem] text-left">
<ToolTip content={"Copied! 👍"} trigger="click" className="text-sm"> <p
<span className="relative my-2 overflow-x-hidden text-ellipsis whitespace-nowrap
className="material-icons transition-color hover:text-dark cursor-pointer" text-left">
onClick={() => { <Link href={`#${toc.slug}`} linkStyled onClick={onContentClicked}>
navigator.clipboard.writeText( {<abbr title={toc.title}>{toc.title}</abbr>}
`${process.env.NEXT_PUBLIC_URL_SELF + window.location.pathname}#${ </Link>
props.id </p>
}` <TocLevel
); tocchildren={toc.children}
}} parentNumbering=""
> onContentClicked={onContentClicked}
link />
</span> </div>
</ToolTip> </>
</ToolTip> )}
</>
); );
};
/*
*
* PRIVATE COMPONENTS
*/
interface HeaderProps {
level: number;
title: string;
slug: string;
} }
export function preprocessMarkDawn(text: string): string { const Header = ({ level, title, slug }: HeaderProps): JSX.Element => {
const isHoverable = useDeviceSupportsHover();
const innerComponent = (
<>
<div className="ml-10 flex place-items-center gap-4">
{title === "* * *" ? (
<div className="mb-12 mt-8 space-x-3 text-dark">
<Ico icon="emergency" />
<Ico icon="emergency" />
<Ico icon="emergency" />
</div>
) : (
<div className="font-headers">{title}</div>
)}
<AnchorShare
className={cIf(isHoverable, "opacity-0 transition-opacity group-hover:opacity-100")}
id={slug}
/>
</div>
</>
);
switch (level) {
case 1:
return (
<h1 id={slug} className="group">
{innerComponent}
</h1>
);
case 2:
return (
<h2 id={slug} className="group">
{innerComponent}
</h2>
);
case 3:
return (
<h3 id={slug} className="group">
{innerComponent}
</h3>
);
case 4:
return (
<h4 id={slug} className="group">
{innerComponent}
</h4>
);
case 5:
return (
<h5 id={slug} className="group">
{innerComponent}
</h5>
);
default:
return (
<h6 id={slug} className="group">
{innerComponent}
</h6>
);
}
};
interface TocInterface {
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 ""; if (!text) return "";
const processedPlayerName = playerName.replaceAll("_", "\\_").replaceAll("*", "\\*");
let preprocessed = text
.replaceAll("--", "—")
.replaceAll(
"@player",
isDefinedAndNotEmpty(processedPlayerName) ? processedPlayerName : "(player)"
);
let scenebreakIndex = 0; let scenebreakIndex = 0;
const visitedSlugs: string[] = []; const visitedSlugs: string[] = [];
const result = text.split("\n").map((line) => { preprocessed = preprocessed
if (line === "* * *" || line === "---") { .split("\n")
scenebreakIndex += 1; .map((line) => {
return `<SceneBreak id="scene-break-${scenebreakIndex}">`; if (line === "* * *" || line === "---") {
} scenebreakIndex++;
return `<SceneBreak id="scene-break-${scenebreakIndex}">`;
}
if (line.startsWith("# ")) { if (/^[#]+ /u.test(line)) {
return markdawnHeadersParser(headerLevels.h1, line, visitedSlugs); return markdawnHeadersParser(line.indexOf(" "), line, visitedSlugs);
} }
if (line.startsWith("## ")) { return line;
return markdawnHeadersParser(headerLevels.h2, line, visitedSlugs); })
} .join("\n");
if (line.startsWith("### ")) { return preprocessed;
return markdawnHeadersParser(headerLevels.h3, line, visitedSlugs); };
}
if (line.startsWith("#### ")) { // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
return markdawnHeadersParser(headerLevels.h4, line, visitedSlugs);
}
if (line.startsWith("##### ")) { const markdawnHeadersParser = (
return markdawnHeadersParser(headerLevels.h5, line, visitedSlugs); headerLevel: number,
}
if (line.startsWith("###### ")) {
return markdawnHeadersParser(headerLevels.h6, line, visitedSlugs);
}
return line;
});
return result.join("\n");
}
enum headerLevels {
h1 = 1,
h2 = 2,
h3 = 3,
h4 = 4,
h5 = 5,
h6 = 6,
}
function markdawnHeadersParser(
headerLevel: headerLevels,
line: string, line: string,
visitedSlugs: string[] visitedSlugs: string[]
): string { ): string => {
const lineText = line.slice(headerLevel + 1); const lineText = line.slice(headerLevel + 1);
const slug = slugify(lineText); const slug = slugify(lineText);
let newSlug = slug; let newSlug = slug;
let index = 2; let index = 2;
while (visitedSlugs.includes(newSlug)) { while (visitedSlugs.includes(newSlug)) {
newSlug = `${slug}-${index}`; newSlug = `${slug}-${index}`;
index += 1; index++;
} }
visitedSlugs.push(newSlug); 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;
};

View File

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

View File

@ -1,165 +0,0 @@
import { useRouter } from "next/router";
import { slugify } from "queries/helpers";
import { preprocessMarkDawn } from "./Markdawn";
type TOCProps = {
text: string;
title?: string;
};
export default function TOCComponent(props: TOCProps): 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>
</>
);
}
type TOCLevelProps = {
tocchildren: TOC[];
parentNumbering: string;
};
function TOCLevel(props: TOCLevelProps): JSX.Element {
const router = useRouter();
const { tocchildren, parentNumbering } = props;
return (
<ol className="pl-4 text-left">
{tocchildren.map((child, childIndex) => (
<>
<li
key={child.slug}
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}.`}
/>
</>
))}
</ol>
);
}
export type TOC = {
title: string;
slug: string;
children: TOC[];
};
export function getTocFromMarkdawn(text: string, title?: string): TOC {
const toc: TOC = {
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;
}

View File

@ -1,64 +0,0 @@
import Chip from "components/Chip";
import Img, { ImageQuality } from "components/Img";
import { GetPostsPreviewQuery } from "graphql/operations-types";
import Link from "next/link";
import { prettyDate, prettySlug } from "queries/helpers";
export type PostPreviewProps = {
post: {
slug: GetPostsPreviewQuery["posts"]["data"][number]["attributes"]["slug"];
thumbnail: GetPostsPreviewQuery["posts"]["data"][number]["attributes"]["thumbnail"];
translations: GetPostsPreviewQuery["posts"]["data"][number]["attributes"]["translations"];
categories: GetPostsPreviewQuery["posts"]["data"][number]["attributes"]["categories"];
date: GetPostsPreviewQuery["posts"]["data"][number]["attributes"]["date"];
};
};
export default function PostPreview(props: PostPreviewProps): JSX.Element {
const { post } = props;
return (
<Link href={`/news/${post.slug}`} passHref>
<div className="drop-shadow-shade-xl cursor-pointer grid items-end hover:scale-[1.02] transition-transform">
{post.thumbnail.data ? (
<Img
className="rounded-md rounded-b-none"
image={post.thumbnail.data.attributes}
quality={ImageQuality.Medium}
/>
) : (
<div className="w-full aspect-[3/2] bg-light rounded-lg"></div>
)}
<div className="linearbg-obi fine:drop-shadow-shade-lg rounded-b-md top-full transition-opacity z-20 grid p-4 gap-2">
<div className="grid grid-flow-col w-full">
{post.date && (
<p className="mobile:text-xs text-sm">
<span className="material-icons !text-base translate-y-[.15em] mr-1">
event
</span>
{prettyDate(post.date)}
</p>
)}
</div>
<div>
{post.translations.length > 0 ? (
<>
<h1 className="text-xl">{post.translations[0].title}</h1>
<p>{post.translations[0].excerpt}</p>
</>
) : (
<h1 className="text-lg">{prettySlug(post.slug)}</h1>
)}
</div>
<div className="grid grid-flow-col gap-1 overflow-x-scroll webkit-scrollbar:w-0 [scrollbar-width:none] place-content-start">
{post.categories.data.map((category) => (
<Chip key={category.id} className="text-sm">
{category.attributes.short}
</Chip>
))}
</div>
</div>
</div>
</Link>
);
}

View File

@ -1,65 +1,99 @@
import ToolTip from "components/ToolTip";
import { useRouter } from "next/router"; 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";
type NavOptionProps = { /*
*
* COMPONENT
*/
interface Props {
url: string; url: string;
icon?: string; icon?: MaterialSymbol;
title: string; title: string | null | undefined;
subtitle?: string; subtitle?: string | null | undefined;
border?: boolean; border?: boolean;
reduced?: boolean; reduced?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>; active?: boolean;
}; disabled?: boolean;
onClick?: MouseEventHandler<HTMLAnchorElement>;
}
export default function NavOption(props: NavOptionProps): 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 router = useRouter();
const isActive = router.asPath.startsWith(props.url); const isActive = active || router.asPath.startsWith(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 : ""}`;
return ( return (
<ToolTip <ToolTip
content={ content={
<div> <div>
<h3 className="text-2xl">{props.title}</h3> <h3 className="text-2xl">{title}</h3>
{props.subtitle && <p className="col-start-2">{props.subtitle}</p>} {isDefinedAndNotEmpty(subtitle) && <p className="col-start-2">{subtitle}</p>}
</div> </div>
} }
placement="right" placement="right"
className="text-left" className="text-left"
disabled={!props.reduced} disabled={!reduced || disabled}>
> <DownPressable
<div className={cJoin(
onClick={(event) => { "grid w-full auto-cols-fr grid-flow-col grid-cols-[auto] justify-center gap-x-5",
if (props.onClick) props.onClick(event); cIf(icon, "text-left", "text-center")
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>
)} )}
href={url}
{!props.reduced && ( border={border}
onClick={onClick}
active={isActive}
disabled={disabled}>
{icon && <Ico icon={icon} className="mt-[-.1em] !text-2xl" isFilled={isActive} />}
{!reduced && (
<div> <div>
<h3 className="text-2xl">{props.title}</h3> <h3 className="text-2xl">{title}</h3>
{props.subtitle && <p className="col-start-2">{props.subtitle}</p>} {isDefinedAndNotEmpty(subtitle) && <p className="col-start-2">{subtitle}</p>}
</div> </div>
)} )}
</div> </DownPressable>
</ToolTip> </ToolTip>
); );
} };
/*
*
* TRANSLATED VARIANT
*/
export const TranslatedNavOption = ({
translations,
fallback,
...otherProps
}: TranslatedProps<Props, "subtitle" | "title">): JSX.Element => {
const [selectedTranslation] = useSmartLanguage({
items: translations,
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
});
return (
<NavOption
title={selectedTranslation?.title ?? fallback.title}
subtitle={selectedTranslation?.subtitle ?? fallback.subtitle}
{...otherProps}
/>
);
};

View File

@ -1,22 +1,26 @@
import HorizontalLine from "components/HorizontalLine"; import { MaterialSymbol } from "material-symbols";
import { Ico } from "components/Ico";
import { isDefinedAndNotEmpty } from "helpers/asserts";
type PanelHeaderProps = { /*
icon?: string; *
title: string; * COMPONENT
description?: string; */
};
export default function PanelHeader(props: PanelHeaderProps): JSX.Element { interface Props {
return ( icon?: MaterialSymbol;
<> title: string | null | undefined;
<div className="w-full grid place-items-center"> description?: string | null | undefined;
{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>
<HorizontalLine />
</>
);
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const PanelHeader = ({ icon, description, title }: Props): JSX.Element => (
<>
<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>
</>
);

View File

@ -1,45 +1,47 @@
import Button from "components/Button"; import { useCallback } from "react";
import HorizontalLine from "components/HorizontalLine"; import { Button } from "components/Inputs/Button";
import { useAppLayout } from "contexts/AppLayoutContext"; import { TranslatedProps } from "types/TranslatedProps";
import { GetWebsiteInterfaceQuery } from "graphql/operations-types"; import { useSmartLanguage } from "hooks/useSmartLanguage";
import { useFormat } from "hooks/useFormat";
import { cJoin } from "helpers/className";
type ReturnButtonProps = { /*
*
* COMPONENT
*/
interface Props {
href: string; href: string;
title: string; title: string | null | undefined;
langui: GetWebsiteInterfaceQuery["websiteInterfaces"]["data"][number]["attributes"];
displayOn: ReturnButtonType;
horizontalLine?: boolean;
className?: string; className?: string;
};
export enum ReturnButtonType {
mobile = "mobile",
desktop = "desktop",
both = "both",
} }
export default function ReturnButton(props: ReturnButtonProps): JSX.Element { // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
const appLayout = useAppLayout();
export const ReturnButton = ({ href, title, className }: Props): JSX.Element => {
const { format } = useFormat();
return ( return (
<div <div className={cJoin("mx-auto w-full max-w-lg place-self-center", className)}>
className={`${ <Button href={href} text={format("return_to_x", { x: title })} icon="navigate_before" />
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> </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} />;
};

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