diff --git a/package-lock.json b/package-lock.json index 51c8ff6..212645f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@tippyjs/react": "^4.2.6", "autoprefixer": "^10.4.14", "cuid": "^2.1.8", + "html-to-text": "^9.0.5", "intl-messageformat": "^10.3.5", "isomorphic-dompurify": "^1.5.0", "jotai": "^2.1.0", @@ -47,6 +48,7 @@ "@graphql-codegen/typescript": "3.0.4", "@graphql-codegen/typescript-graphql-request": "^4.5.9", "@graphql-codegen/typescript-operations": "^3.0.4", + "@types/html-to-text": "^9.0.0", "@types/marked": "^4.3.0", "@types/node": "20.1.3", "@types/nodemailer": "^6.4.7", @@ -3642,6 +3644,18 @@ "integrity": "sha512-WiBSI6JBIhC6LRIsB2Kwh8DsGTlbBU+mLRxJmAe3LjHTdkDpwIbEOZgoXBbZilk/vlfjK8i6nKRAvIRn1XaIMw==", "dev": true }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@swc/helpers": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz", @@ -3683,6 +3697,12 @@ "@types/trusted-types": "*" } }, + "node_modules/@types/html-to-text": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-9.0.0.tgz", + "integrity": "sha512-FnF3p2FJZ1kJT/0C/lmBzw7HSlH3RhtACVYyrwUsJoCmFNuiLpusWT2FWWB7P9A48CaYpvD6Q2fprn7sZeffpw==", + "dev": true + }, "node_modules/@types/js-yaml": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", @@ -5156,6 +5176,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", @@ -5243,6 +5271,30 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -5262,6 +5314,20 @@ "node": ">=12" } }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/domino": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", @@ -5272,6 +5338,19 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.3.tgz", "integrity": "sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ==" }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -6865,6 +6944,39 @@ "node": ">=12" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -7792,6 +7904,14 @@ "language-subtag-registry": "~0.3.2" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8668,6 +8788,18 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/pascal-case": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", @@ -8845,6 +8977,14 @@ "node": ">=8" } }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -9565,6 +9705,17 @@ "integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==", "dev": true }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -13631,6 +13782,15 @@ "integrity": "sha512-WiBSI6JBIhC6LRIsB2Kwh8DsGTlbBU+mLRxJmAe3LjHTdkDpwIbEOZgoXBbZilk/vlfjK8i6nKRAvIRn1XaIMw==", "dev": true }, + "@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "requires": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + } + }, "@swc/helpers": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz", @@ -13667,6 +13827,12 @@ "@types/trusted-types": "*" } }, + "@types/html-to-text": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-9.0.0.tgz", + "integrity": "sha512-FnF3p2FJZ1kJT/0C/lmBzw7HSlH3RhtACVYyrwUsJoCmFNuiLpusWT2FWWB7P9A48CaYpvD6Q2fprn7sZeffpw==", + "dev": true + }, "@types/js-yaml": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", @@ -14773,6 +14939,11 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + }, "defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", @@ -14839,6 +15010,21 @@ "esutils": "^2.0.2" } }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, "domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -14854,6 +15040,14 @@ } } }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, "domino": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", @@ -14864,6 +15058,16 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.3.tgz", "integrity": "sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ==" }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, "dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -16083,6 +16287,29 @@ "whatwg-encoding": "^2.0.0" } }, + "html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "requires": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + } + }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -16764,6 +16991,11 @@ "language-subtag-registry": "~0.3.2" } }, + "leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==" + }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -17374,6 +17606,15 @@ "entities": "^4.4.0" } }, + "parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "requires": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + } + }, "pascal-case": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", @@ -17511,6 +17752,11 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==" + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -17966,6 +18212,14 @@ "integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==", "dev": true }, + "selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "requires": { + "parseley": "^0.12.0" + } + }, "semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", diff --git a/package.json b/package.json index f8ae2f2..4199bfa 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@tippyjs/react": "^4.2.6", "autoprefixer": "^10.4.14", "cuid": "^2.1.8", + "html-to-text": "^9.0.5", "intl-messageformat": "^10.3.5", "isomorphic-dompurify": "^1.5.0", "jotai": "^2.1.0", @@ -61,6 +62,7 @@ "@graphql-codegen/typescript": "3.0.4", "@graphql-codegen/typescript-graphql-request": "^4.5.9", "@graphql-codegen/typescript-operations": "^3.0.4", + "@types/html-to-text": "^9.0.0", "@types/marked": "^4.3.0", "@types/node": "20.1.3", "@types/nodemailer": "^6.4.7", diff --git a/src/components/Credits.tsx b/src/components/Credits.tsx new file mode 100644 index 0000000..ea0ca3d --- /dev/null +++ b/src/components/Credits.tsx @@ -0,0 +1,131 @@ +import { Chip } from "components/Chip"; +import { Markdawn } from "components/Markdown/Markdawn"; +import { RecorderChip } from "components/RecorderChip"; +import { ToolTip } from "components/ToolTip"; +import { atoms } from "contexts/atoms"; +import { RecorderChipFragment } from "graphql/generated"; +import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/asserts"; +import { useAtomGetter } from "helpers/atoms"; +import { prettyLanguage } from "helpers/formatters"; +import { ContentStatus, useFormat } from "hooks/useFormat"; + +/* + * ╭─────────────╮ + * ───────────────────────────────────────╯ COMPONENT ╰─────────────────────────────────────────── + */ + +interface Props { + languageCode?: string; + sourceLanguageCode?: string; + status?: ContentStatus | null; + transcribers?: { attributes?: RecorderChipFragment | null }[]; + translators?: { attributes?: RecorderChipFragment | null }[]; + proofreaders?: { attributes?: RecorderChipFragment | null }[]; + dubbers?: { attributes?: RecorderChipFragment | null }[]; + subbers?: { attributes?: RecorderChipFragment | null }[]; + authors?: { attributes?: RecorderChipFragment | null }[]; + notes?: string | null; +} + +// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +export const Credits = ({ + languageCode, + sourceLanguageCode, + status, + transcribers = [], + translators = [], + dubbers = [], + proofreaders = [], + subbers = [], + authors = [], + notes, +}: Props): JSX.Element => { + const { format, formatStatusDescription, formatStatusLabel } = useFormat(); + const languages = useAtomGetter(atoms.localData.languages); + + return ( +
+ {isDefined(languageCode) && isDefined(sourceLanguageCode) && ( + <> + {languageCode === sourceLanguageCode ? ( +

{format("transcript_notice")}

+ ) : ( + <> +

{format("translation_notice")}

+
+

{format("source_language")}:

+ +
+ + )} + + )} + + {status && ( +
+

{format("status")}:

+ + + +
+ )} + + {transcribers.length > 0 && ( + + )} + + {translators.length > 0 && ( + + )} + + {proofreaders.length > 0 && ( + + )} + + {dubbers.length > 0 && ( + + )} + + {subbers.length > 0 && ( + + )} + + {authors.length > 0 && ( + + )} + + {isDefinedAndNotEmpty(notes) && ( +
+

{format("notes")}:

+
+ +
+
+ )} +
+ ); +}; + +interface RecorderChipsProps { + title: string; + recorders: { attributes?: RecorderChipFragment | null }[]; +} + +const RecorderChips = ({ title, recorders }: RecorderChipsProps) => ( +
+

{title}:

+ {filterHasAttributes(recorders, ["attributes"]).map((recorder) => ( + + ))} +
+); diff --git a/src/components/Inputs/Button.tsx b/src/components/Inputs/Button.tsx index 15bafb1..9099a2e 100644 --- a/src/components/Inputs/Button.tsx +++ b/src/components/Inputs/Button.tsx @@ -58,11 +58,11 @@ export const Button = ({ 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", "px-3 py-1 text-xs", "px-4 py-3"), + `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, @@ -91,7 +91,9 @@ export const Button = ({ weight={size === "normal" ? 500 : 800} /> )} - {isDefinedAndNotEmpty(text) &&

{text}

} + {isDefinedAndNotEmpty(text) && ( +

{text}

+ )} diff --git a/src/components/Inputs/ButtonGroup.tsx b/src/components/Inputs/ButtonGroup.tsx index d6c691b..50ec6ab 100644 --- a/src/components/Inputs/ButtonGroup.tsx +++ b/src/components/Inputs/ButtonGroup.tsx @@ -1,7 +1,7 @@ import type { Placement } from "tippy.js"; import { Button } from "./Button"; import { ToolTip } from "components/ToolTip"; -import { cJoin } from "helpers/className"; +import { cIf, cJoin } from "helpers/className"; import { ConditionalWrapper, Wrapper } from "helpers/component"; import { isDefined } from "helpers/asserts"; @@ -10,9 +10,13 @@ import { isDefined } from "helpers/asserts"; * ───────────────────────────────────────╯ COMPONENT ╰─────────────────────────────────────────── */ +type ButtonProps = Parameters[0]; + export interface ButtonGroupProps { className?: string; - buttonsProps: (Parameters[0] & { + vertical?: boolean; + size?: ButtonProps["size"]; + buttonsProps: (Omit & { visible?: boolean; tooltip?: React.ReactNode | null | undefined; tooltipPlacement?: Placement; @@ -21,35 +25,73 @@ export interface ButtonGroupProps { // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ -export const ButtonGroup = ({ buttonsProps, className }: ButtonGroupProps): JSX.Element => ( +export const ButtonGroup = ({ + buttonsProps, + className, + vertical, + size, +}: ButtonGroupProps): JSX.Element => ( button.visible !== false)} className={className} + vertical={vertical} + size={size} /> ); -const FilteredButtonGroup = ({ buttonsProps, className }: ButtonGroupProps) => ( -
- {buttonsProps.map((buttonProps, index) => ( - -
-); +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 ( +
+ {buttonsProps.map((buttonProps, index) => ( + +
+ ); +}; /* * ╭──────────────────────╮ diff --git a/src/components/Panels/SettingsPopup.tsx b/src/components/Panels/SettingsPopup.tsx index dc64053..b371e34 100644 --- a/src/components/Panels/SettingsPopup.tsx +++ b/src/components/Panels/SettingsPopup.tsx @@ -1,6 +1,5 @@ import { useRouter } from "next/router"; import { useEffect, useState } from "react"; -import { Button } from "components/Inputs/Button"; import { ButtonGroup } from "components/Inputs/ButtonGroup"; import { OrderableList } from "components/Inputs/OrderableList"; import { Select } from "components/Inputs/Select"; @@ -203,23 +202,28 @@ export const SettingsPopup = (): JSX.Element => {

{format("font")}

-
diff --git a/src/components/Player.tsx b/src/components/Player.tsx new file mode 100644 index 0000000..6fc6a7e --- /dev/null +++ b/src/components/Player.tsx @@ -0,0 +1,293 @@ +import { useCallback, useEffect, useId, useState } from "react"; +import Slider from "rc-slider"; +import { useHotkeys } from "react-hotkeys-hook"; +import { Button } from "components/Inputs/Button"; +import { prettyDuration } from "helpers/formatters"; +import { ButtonGroup } from "components/Inputs/ButtonGroup"; +import { cIf, cJoin } from "helpers/className"; +import { useFullscreen } from "hooks/useFullscreen"; +import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts"; +import { useAtomGetter } from "helpers/atoms"; +import { atoms } from "contexts/atoms"; +import { ToolTip } from "components/ToolTip"; + +/* + * ╭─────────────╮ + * ────────────────────────────────────────╯ CONSTANTS ╰────────────────────────────────────────── + */ + +const STEP_MULTIPLIER = 100; + +/* + * ╭─────────────╮ + * ───────────────────────────────────────╯ COMPONENT ╰─────────────────────────────────────────── + */ + +interface AudioPlayerProps { + src?: string; + className?: string; + title?: string; +} + +export const AudioPlayer = ({ src, className, title }: AudioPlayerProps): JSX.Element => { + const [ref, setRef] = useState(null); + const [isFocused, setFocus] = useState(false); + + return ( +
setFocus(true)} + onBlur={() => setFocus(false)}> +
+ ); +}; + +// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +interface VideoPlayerProps { + src?: string; + className?: string; + title?: string; + rounded?: boolean; + subSrc?: string; +} + +export const VideoPlayer = ({ + src, + className, + title, + subSrc, + rounded = true, +}: VideoPlayerProps): JSX.Element => { + const [ref, setRef] = useState(null); + const videoId = useId(); + const { isFullscreen, toggleFullscreen } = useFullscreen(videoId); + const [isPlaying, setPlaying] = useState(false); + const [isFocused, setFocus] = useState(false); + + const togglePlayback = useCallback( + async () => (isPlaying ? ref?.pause() : await ref?.play()), + [isPlaying, ref] + ); + + return ( +
setFocus(true)} + onBlur={() => setFocus(false)}> + + {ref && ( + + )} +
+ ); +}; + +// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +interface PlayerControls { + mediaRef: HTMLMediaElement; + src?: string; + title?: string; + className?: string; + isFocused?: boolean; + type: "audio" | "video"; + fullscreen?: { + isFullscreen: boolean; + toggleFullscreen: () => void; + }; + onPlaybackChanged?: (isPlaying: boolean) => void; + hasCC?: boolean; +} + +const PlayerControls = ({ + mediaRef, + className, + src, + title, + fullscreen, + isFocused = false, + hasCC = false, + type, + onPlaybackChanged, +}: PlayerControls) => { + const [isPlaying, setPlaying] = useState(false); + const [duration, setDuration] = useState(mediaRef.duration); + const [currentTime, setCurrentTime] = useState(mediaRef.currentTime); + const [isMuted, setMuted] = useState(mediaRef.volume === 0); + const [hasEnded, setEnded] = useState(false); + const [ccVisible, setCCVisible] = useState(hasCC); + const isContentPanelAtLeastXl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastXl); + const isContentPanelAtLeastMd = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastMd); + + const togglePlayback = useCallback( + async () => (isPlaying ? mediaRef.pause() : await mediaRef.play()), + [isPlaying, mediaRef] + ); + + useHotkeys( + "left", + () => { + mediaRef.currentTime -= 5; + }, + { enabled: isFocused } + ); + + useHotkeys( + "right", + () => { + mediaRef.currentTime += 5; + }, + { enabled: isFocused } + ); + + useEffect(() => { + const audio = mediaRef; + audio.addEventListener("loadedmetadata", () => { + setDuration(audio.duration); + }); + + audio.addEventListener("play", () => { + setPlaying(true); + onPlaybackChanged?.(true); + setEnded(false); + }); + audio.addEventListener("pause", () => { + setPlaying(false); + onPlaybackChanged?.(false); + }); + + audio.addEventListener("ended", () => setEnded(true)); + + audio.addEventListener("timeupdate", () => { + setCurrentTime(audio.currentTime); + }); + + return () => audio.pause(); + }, [mediaRef, onPlaybackChanged]); + + useEffect(() => { + const textTrack = mediaRef.textTracks[0]; + if (isUndefined(textTrack)) return; + textTrack.mode = ccVisible ? "showing" : "hidden"; + }, [ccVisible, mediaRef.textTracks]); + + const buttonGroup = ( + { + setMuted((oldMutedValue) => { + const newMutedValue = !oldMutedValue; + mediaRef.volume = newMutedValue ? 0 : 1; + return newMutedValue; + }); + }, + }, + { + icon: "closed_caption", + active: ccVisible, + onClick: () => setCCVisible((value) => !value), + visible: hasCC, + }, + { + icon: fullscreen?.isFullscreen ? "fullscreen_exit" : "fullscreen", + active: fullscreen?.isFullscreen, + onClick: fullscreen?.toggleFullscreen, + visible: isDefined(fullscreen), + }, + { icon: "download", href: src, alwaysNewTab: true }, + ]} + /> + ); + + return ( +
+
+ ); +}; diff --git a/src/components/PostPage.tsx b/src/components/PostPage.tsx index 1936a54..d329fd9 100644 --- a/src/components/PostPage.tsx +++ b/src/components/PostPage.tsx @@ -1,22 +1,19 @@ import { Fragment, useCallback } from "react"; import { AppLayout, AppLayoutRequired } from "./AppLayout"; -import { Chip } from "./Chip"; import { getTocFromMarkdawn, Markdawn, TableOfContents } from "./Markdown/Markdawn"; import { ReturnButton } from "./PanelComponents/ReturnButton"; import { ContentPanel } from "./Containers/ContentPanel"; import { SubPanel } from "./Containers/SubPanel"; -import { RecorderChip } from "./RecorderChip"; import { ThumbnailHeader } from "./ThumbnailHeader"; -import { ToolTip } from "./ToolTip"; import { useSmartLanguage } from "hooks/useSmartLanguage"; import { PostWithTranslations } from "types/types"; -import { filterHasAttributes, isDefined } from "helpers/asserts"; +import { isDefined } from "helpers/asserts"; import { prettySlug } from "helpers/formatters"; -import { useFormat } from "hooks/useFormat"; import { useAtomGetter, useAtomSetter } from "helpers/atoms"; import { atoms } from "contexts/atoms"; import { ElementsSeparator } from "helpers/component"; import { HorizontalLine } from "components/HorizontalLine"; +import { Credits } from "components/Credits"; /* * ╭─────────────╮ @@ -51,7 +48,6 @@ export const PostPage = ({ displayTitle = true, ...otherProps }: Props): JSX.Element => { - const { format, formatStatusDescription } = useFormat(); const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened); const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout); @@ -77,34 +73,7 @@ export const PostPage = ({ ), - displayCredits && ( - <> - {selectedTranslation && ( -
-

{format("status")}:

- - - - -
- )} - - {post.authors && post.authors.data.length > 0 && ( -
-

{"Authors"}:

-
- {filterHasAttributes(post.authors.data, ["id", "attributes"]).map((author) => ( - - - - ))} -
-
- )} - - ), + displayCredits && , displayToc && isDefined(toc) && ( setSubPanelOpened(false)} /> diff --git a/src/graphql/getPostStaticProps.ts b/src/graphql/getPostStaticProps.ts index 4a6162f..14e462a 100644 --- a/src/graphql/getPostStaticProps.ts +++ b/src/graphql/getPostStaticProps.ts @@ -4,7 +4,7 @@ import { PostWithTranslations } from "types/types"; import { getOpenGraph } from "helpers/openGraph"; import { prettyDate, prettySlug } from "helpers/formatters"; import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales"; -import { filterHasAttributes, isDefined } from "helpers/asserts"; +import { filterHasAttributes } from "helpers/asserts"; import { getDescription } from "helpers/description"; import { AppLayoutRequired } from "components/AppLayout"; import { getFormat } from "helpers/i18n"; @@ -22,40 +22,36 @@ export const getPostStaticProps = slug: slug, language_code: context.locale ?? "en", }); - if ( - post.posts?.data && - post.posts.data.length > 0 && - post.posts.data[0]?.attributes?.translations && - isDefined(context.locale) && - isDefined(context.locales) - ) { - const selectedTranslation = staticSmartLanguage({ - items: post.posts.data[0].attributes.translations, - languageExtractor: (item) => item.language?.data?.attributes?.code, - preferredLanguages: getDefaultPreferredLanguages(context.locale, context.locales), - }); - const title = selectedTranslation?.title ?? prettySlug(slug); - - const description = getDescription(selectedTranslation?.excerpt, { - [format("release_date")]: [prettyDate(post.posts.data[0].attributes.date, context.locale)], - [format("category", { count: Infinity })]: filterHasAttributes( - post.posts.data[0].attributes.categories?.data, - ["attributes"] - ).map((category) => category.attributes.short), - }); - - const thumbnail = - selectedTranslation?.thumbnail?.data?.attributes ?? - post.posts.data[0].attributes.thumbnail?.data?.attributes; - - const props: PostStaticProps = { - post: post.posts.data[0].attributes as PostWithTranslations, - openGraph: getOpenGraph(format, title, description, thumbnail), - }; - return { - props: props, - }; + if (!post.posts?.data[0]?.attributes?.translations || !context.locale || !context.locales) { + return { notFound: true }; } - return { notFound: true }; + + const selectedTranslation = staticSmartLanguage({ + items: post.posts.data[0].attributes.translations, + languageExtractor: (item) => item.language?.data?.attributes?.code, + preferredLanguages: getDefaultPreferredLanguages(context.locale, context.locales), + }); + + const title = selectedTranslation?.title ?? prettySlug(slug); + + const description = getDescription(selectedTranslation?.excerpt ?? selectedTranslation?.body, { + [format("release_date")]: [prettyDate(post.posts.data[0].attributes.date, context.locale)], + [format("category", { count: Infinity })]: filterHasAttributes( + post.posts.data[0].attributes.categories?.data, + ["attributes"] + ).map((category) => category.attributes.short), + }); + + const thumbnail = + selectedTranslation?.thumbnail?.data?.attributes ?? + post.posts.data[0].attributes.thumbnail?.data?.attributes; + + const props: PostStaticProps = { + post: post.posts.data[0].attributes as PostWithTranslations, + openGraph: getOpenGraph(format, title, description, thumbnail), + }; + return { + props: props, + }; }; diff --git a/src/graphql/operations/getContentText.graphql b/src/graphql/operations/getContentText.graphql index e80ccf8..868136b 100644 --- a/src/graphql/operations/getContentText.graphql +++ b/src/graphql/operations/getContentText.graphql @@ -185,6 +185,43 @@ query getContentText($slug: String, $language_code: String) { } notes } + video_set { + status + source_language { + data { + attributes { + code + } + } + } + has_subfile + subbers(pagination: { limit: -1 }) { + data { + attributes { + ...recorderChip + } + } + } + notes + } + audio_set { + status + source_language { + data { + attributes { + code + } + } + } + dubbers(pagination: { limit: -1 }) { + data { + attributes { + ...recorderChip + } + } + } + notes + } } thumbnail { diff --git a/src/graphql/operations/getLibraryItem.graphql b/src/graphql/operations/getLibraryItem.graphql index 613af5d..69b8e4b 100644 --- a/src/graphql/operations/getLibraryItem.graphql +++ b/src/graphql/operations/getLibraryItem.graphql @@ -133,6 +133,11 @@ query getLibraryItem($slug: String, $language_code: String) { } } } + tracks(pagination: { limit: -1 }) { + id + slug + title + } } ... on ComponentMetadataGroup { subtype { diff --git a/src/helpers/description.ts b/src/helpers/description.ts index cd3347b..5607dfd 100644 --- a/src/helpers/description.ts +++ b/src/helpers/description.ts @@ -1,3 +1,6 @@ +import { convert } from "html-to-text"; +import { sanitize } from "isomorphic-dompurify"; +import { marked } from "marked"; import { filterDefined, isDefined, isDefinedAndNotEmpty } from "./asserts"; export const getDescription = ( @@ -6,13 +9,6 @@ export const getDescription = ( ): string => { let result = ""; - if (isDefinedAndNotEmpty(description)) { - result += prettyMarkdown(description); - if (isDefined(chipsGroups)) { - result += "\n\n"; - } - } - for (const key in chipsGroups) { if (Object.hasOwn(chipsGroups, key)) { const chipsGroup = filterDefined(chipsGroups[key]); @@ -22,11 +18,52 @@ export const getDescription = ( } } + if (isDefinedAndNotEmpty(description)) { + if (result !== "") { + result += "\n"; + } + result += prettyMarkdown(description); + } + return result; }; -export const prettyMarkdown = (markdown: string): string => - markdown.replace(/[*]/gu, "").replace(/[_]/gu, ""); +const block = (text: string) => `${text}\n\n`; +const escapeBlock = (text: string) => `${escape(text)}\n\n`; +const line = (text: string) => `${text}\n`; +const inline = (text: string) => text; +const newline = () => "\n"; +const empty = () => ""; + +const TxtRenderer: marked.Renderer = { + // Block elements + code: escapeBlock, + blockquote: block, + html: empty, + heading: block, + hr: newline, + list: (text) => block(text.trim()), + listitem: line, + checkbox: empty, + paragraph: block, + table: (header, body) => line(header + body), + tablerow: (text) => line(text.trim()), + tablecell: (text) => `${text} `, + // Inline elements + strong: inline, + em: inline, + codespan: inline, + br: newline, + del: inline, + link: (_0, _1, text) => text, + image: (_0, _1, text) => text, + text: inline, + // etc. + options: {}, +}; + +const prettyMarkdown = (markdown: string): string => + convert(sanitize(marked(markdown, { renderer: TxtRenderer }))).trim(); const prettyChip = (items: (string | undefined)[]): string => items diff --git a/src/helpers/libraryItem.ts b/src/helpers/libraryItem.ts index 323eb42..c3281fa 100644 --- a/src/helpers/libraryItem.ts +++ b/src/helpers/libraryItem.ts @@ -16,3 +16,6 @@ export const isUntangibleGroupItem = ( export const getScanArchiveURL = (slug: string): string => `${process.env.NEXT_PUBLIC_URL_ASSETS}/library/scans/${slug}.zip`; + +export const getTrackURL = (itemSlug: string, trackSlug: string): string => + `${process.env.NEXT_PUBLIC_URL_ASSETS}/library/tracks/${itemSlug}/${trackSlug}.mp3`; diff --git a/src/helpers/openGraph.ts b/src/helpers/openGraph.ts index 142868e..eab5e7f 100644 --- a/src/helpers/openGraph.ts +++ b/src/helpers/openGraph.ts @@ -4,7 +4,7 @@ import { getFormat } from "./i18n"; import { UploadImageFragment } from "graphql/generated"; import { useFormat } from "hooks/useFormat"; -const DEFAULT_OG_THUMBNAIL = { +const DEFAULT_OG_THUMBNAIL: OgImage = { image: `${process.env.NEXT_PUBLIC_URL_SELF}/default_og.jpg`, width: 1200, height: 630, @@ -24,14 +24,26 @@ export const getOpenGraph = ( format: ReturnType["format"] | ReturnType["format"], title?: string | null | undefined, description?: string | null | undefined, - thumbnail?: UploadImageFragment | null | undefined + thumbnail?: UploadImageFragment | string | null | undefined ): OpenGraph => ({ title: `${TITLE_PREFIX}${isDefinedAndNotEmpty(title) ? `${TITLE_SEPARATOR}${title}` : ""}`, - description: isDefinedAndNotEmpty(description) ? description : format("default_description"), + description: isDefinedAndNotEmpty(description) + ? description.length > 350 + ? `${description.slice(0, 349)}…` + : description + : format("default_description"), thumbnail: thumbnail ? getOgImage(thumbnail) : DEFAULT_OG_THUMBNAIL, }); -const getOgImage = (image: UploadImageFragment): OgImage => { +const getOgImage = (image: UploadImageFragment | string): OgImage => { + if (typeof image === "string") { + return { + image, + width: 0, + height: 0, + alt: "", + }; + } const imgSize = getImgSizesByQuality(image.width ?? 0, image.height ?? 0, ImageQuality.Og); return { image: getAssetURL(image.url, ImageQuality.Og), diff --git a/src/pages/archives/videos/v/[uid].tsx b/src/pages/archives/videos/v/[uid].tsx index d3dd69e..c075f28 100644 --- a/src/pages/archives/videos/v/[uid].tsx +++ b/src/pages/archives/videos/v/[uid].tsx @@ -14,12 +14,15 @@ import { GetVideoQuery } from "graphql/generated"; import { getReadySdk } from "graphql/sdk"; import { prettyDate, prettyShortenNumber } from "helpers/formatters"; import { filterHasAttributes, isDefined } from "helpers/asserts"; -import { getVideoFile } from "helpers/videos"; +import { getVideoFile, getVideoThumbnailURL } from "helpers/videos"; import { getOpenGraph } from "helpers/openGraph"; import { atoms } from "contexts/atoms"; import { useAtomGetter, useAtomSetter } from "helpers/atoms"; import { useFormat } from "hooks/useFormat"; import { getFormat } from "helpers/i18n"; +import { VideoPlayer } from "components/Player"; +import { getDescription } from "helpers/description"; +import { Markdown } from "components/Markdown/Markdown"; /* * ╭────────╮ @@ -65,7 +68,7 @@ const Video = ({ video, ...otherProps }: Props): JSX.Element => {
{video.gone ? ( -