Added audio and video player

This commit is contained in:
DrMint 2023-05-19 01:35:47 +02:00
parent 3e979c4005
commit 895fee1bae
21 changed files with 1307 additions and 286 deletions

254
package-lock.json generated
View File

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

View File

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

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

@ -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 (
<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={prettyLanguage(sourceLanguageCode, languages)} />
</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?: RecorderChipFragment | 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.anonymous_code} recorder={recorder.attributes} />
))}
</div>
);

View File

@ -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) && <p className="-translate-y-[0.05em] text-center">{text}</p>}
{isDefinedAndNotEmpty(text) && (
<p className="line-clamp-1 -translate-y-[0.05em] text-center leading-5">{text}</p>
)}
</button>
</div>
</Link>

View File

@ -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<typeof Button>[0];
export interface ButtonGroupProps {
className?: string;
buttonsProps: (Parameters<typeof Button>[0] & {
vertical?: boolean;
size?: ButtonProps["size"];
buttonsProps: (Omit<ButtonProps, "size"> & {
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 => (
<FilteredButtonGroup
buttonsProps={buttonsProps.filter((button) => button.visible !== false)}
className={className}
vertical={vertical}
size={size}
/>
);
const FilteredButtonGroup = ({ buttonsProps, className }: ButtonGroupProps) => (
<div className={cJoin("grid 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}
className={
index === 0
? "rounded-r-none border-r-0"
: index === buttonsProps.length - 1
? "rounded-l-none"
: "rounded-none border-r-0"
}
/>
</ConditionalWrapper>
))}
</div>
);
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>
);
};
/*
*

View File

@ -1,6 +1,5 @@
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Button } from "components/Inputs/Button";
import { ButtonGroup } from "components/Inputs/ButtonGroup";
import { OrderableList } from "components/Inputs/OrderableList";
import { Select } from "components/Inputs/Select";
@ -203,23 +202,28 @@ export const SettingsPopup = (): JSX.Element => {
<div>
<h3 className="text-xl">{format("font")}</h3>
<div className="grid gap-2">
<Button
active={!isDyslexic}
onClick={() => {
setDyslexic(false);
sendAnalytics("Settings", "Change font (Zen Maru Gothic)");
}}
className="font-zenMaruGothic"
text="Zen Maru Gothic"
/>
<Button
active={isDyslexic}
onClick={() => {
setDyslexic(true);
sendAnalytics("Settings", "Change font (OpenDyslexic)");
}}
className="font-openDyslexic"
text="OpenDyslexic"
<ButtonGroup
vertical
buttonsProps={[
{
active: !isDyslexic,
onClick: () => {
setDyslexic(false);
sendAnalytics("Settings", "Change font (Zen Maru Gothic)");
},
className: "font-zenMaruGothic",
text: "Zen Maru Gothic",
},
{
active: isDyslexic,
onClick: () => {
setDyslexic(true);
sendAnalytics("Settings", "Change font (OpenDyslexic)");
},
className: "font-openDyslexic",
text: "OpenDyslexic",
},
]}
/>
</div>
</div>

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

@ -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<HTMLAudioElement | null>(null);
const [isFocused, setFocus] = useState(false);
return (
<div
className={cJoin("w-full", className)}
tabIndex={0}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}>
<audio ref={setRef} src={src} />
{ref && (
<PlayerControls
className={className}
mediaRef={ref}
type="audio"
src={src}
title={title}
isFocused={isFocused}
/>
)}
</div>
);
};
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface VideoPlayerProps {
src?: string;
className?: string;
title?: string;
rounded?: boolean;
subSrc?: string;
}
export const VideoPlayer = ({
src,
className,
title,
subSrc,
rounded = true,
}: VideoPlayerProps): JSX.Element => {
const [ref, setRef] = useState<HTMLVideoElement | null>(null);
const videoId = useId();
const { isFullscreen, toggleFullscreen } = useFullscreen(videoId);
const [isPlaying, setPlaying] = useState(false);
const [isFocused, setFocus] = useState(false);
const togglePlayback = useCallback(
async () => (isPlaying ? ref?.pause() : await ref?.play()),
[isPlaying, ref]
);
return (
<div
className={cJoin("grid w-full", className)}
id={videoId}
tabIndex={0}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}>
<video
ref={setRef}
className={cJoin("h-full w-full", cIf(!isFullscreen && rounded, "rounded-t-4xl"))}
crossOrigin="anonymous"
onClick={togglePlayback}
onDoubleClick={toggleFullscreen}>
<source type="video/mp4" src={src} />
{subSrc && <track label="English" kind="subtitles" srcLang="en" src={subSrc} default />}
</video>
{ref && (
<PlayerControls
title={title}
mediaRef={ref}
src={src}
type="video"
className={cIf(isFullscreen || !rounded, "rounded-none", "rounded-b-4xl rounded-t-none")}
fullscreen={{ isFullscreen, toggleFullscreen }}
onPlaybackChanged={setPlaying}
isFocused={isFocused}
hasCC={isDefined(subSrc)}
/>
)}
</div>
);
};
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface PlayerControls {
mediaRef: HTMLMediaElement;
src?: string;
title?: string;
className?: string;
isFocused?: boolean;
type: "audio" | "video";
fullscreen?: {
isFullscreen: boolean;
toggleFullscreen: () => void;
};
onPlaybackChanged?: (isPlaying: boolean) => void;
hasCC?: boolean;
}
const PlayerControls = ({
mediaRef,
className,
src,
title,
fullscreen,
isFocused = false,
hasCC = false,
type,
onPlaybackChanged,
}: PlayerControls) => {
const [isPlaying, setPlaying] = useState(false);
const [duration, setDuration] = useState(mediaRef.duration);
const [currentTime, setCurrentTime] = useState(mediaRef.currentTime);
const [isMuted, setMuted] = useState(mediaRef.volume === 0);
const [hasEnded, setEnded] = useState(false);
const [ccVisible, setCCVisible] = useState(hasCC);
const isContentPanelAtLeastXl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastXl);
const isContentPanelAtLeastMd = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastMd);
const togglePlayback = useCallback(
async () => (isPlaying ? mediaRef.pause() : await mediaRef.play()),
[isPlaying, mediaRef]
);
useHotkeys(
"left",
() => {
mediaRef.currentTime -= 5;
},
{ enabled: isFocused }
);
useHotkeys(
"right",
() => {
mediaRef.currentTime += 5;
},
{ enabled: isFocused }
);
useEffect(() => {
const audio = mediaRef;
audio.addEventListener("loadedmetadata", () => {
setDuration(audio.duration);
});
audio.addEventListener("play", () => {
setPlaying(true);
onPlaybackChanged?.(true);
setEnded(false);
});
audio.addEventListener("pause", () => {
setPlaying(false);
onPlaybackChanged?.(false);
});
audio.addEventListener("ended", () => setEnded(true));
audio.addEventListener("timeupdate", () => {
setCurrentTime(audio.currentTime);
});
return () => audio.pause();
}, [mediaRef, onPlaybackChanged]);
useEffect(() => {
const textTrack = mediaRef.textTracks[0];
if (isUndefined(textTrack)) return;
textTrack.mode = ccVisible ? "showing" : "hidden";
}, [ccVisible, mediaRef.textTracks]);
const buttonGroup = (
<ButtonGroup
vertical={!isContentPanelAtLeastXl && type === "video"}
buttonsProps={[
{
icon: isMuted ? "volume_off" : "volume_up",
active: isMuted,
onClick: () => {
setMuted((oldMutedValue) => {
const newMutedValue = !oldMutedValue;
mediaRef.volume = newMutedValue ? 0 : 1;
return newMutedValue;
});
},
},
{
icon: "closed_caption",
active: ccVisible,
onClick: () => setCCVisible((value) => !value),
visible: hasCC,
},
{
icon: fullscreen?.isFullscreen ? "fullscreen_exit" : "fullscreen",
active: fullscreen?.isFullscreen,
onClick: fullscreen?.toggleFullscreen,
visible: isDefined(fullscreen),
},
{ icon: "download", href: src, alwaysNewTab: true },
]}
/>
);
return (
<div
className={cJoin(
`relative flex w-full place-items-center rounded-full
bg-highlight p-3 shadow-md shadow-shade/50`,
cIf(isContentPanelAtLeastMd, "gap-5", "gap-3"),
className
)}>
<Button
icon={hasEnded ? "replay" : isPlaying ? "pause" : "play_arrow"}
active={isPlaying}
onClick={togglePlayback}
/>
<div className="grid w-full place-items-start">
{isDefinedAndNotEmpty(title) && (
<p className="line-clamp-1 text-left text-xs text-dark">{title}</p>
)}
<div
className={cJoin(
"flex w-full place-content-between place-items-center",
cIf(isContentPanelAtLeastMd, "gap-5", "gap-3")
)}>
<p className={cJoin("font-mono", cIf(isContentPanelAtLeastMd, "h-5", "h-3 text-xs"))}>
{prettyDuration(currentTime)}
</p>
<Slider
className={cIf(
!isContentPanelAtLeastXl && type === "video",
"!absolute left-0 right-0 top-[-5px]"
)}
value={currentTime * STEP_MULTIPLIER}
onChange={(value) => {
const newTime = (value as number) / STEP_MULTIPLIER;
mediaRef.currentTime = newTime;
setCurrentTime(newTime);
}}
onAfterChange={async () => await mediaRef.play()}
max={duration * STEP_MULTIPLIER}
/>
{!isContentPanelAtLeastXl && type === "video" && <p>/</p>}
<p className={cJoin("font-mono", cIf(isContentPanelAtLeastMd, "h-5", "h-3 text-xs"))}>
{prettyDuration(duration)}
</p>
</div>
</div>
{isContentPanelAtLeastXl ? (
buttonGroup
) : (
<ToolTip content={buttonGroup}>
<Button icon="more_vert" />
</ToolTip>
)}
</div>
);
};

View File

@ -1,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 = ({
<ReturnButton href={returnHref} title={returnTitle} />
),
displayCredits && (
<>
{selectedTranslation && (
<div className="grid grid-flow-col place-content-center place-items-center gap-2">
<p className="font-headers font-bold">{format("status")}:</p>
<ToolTip
content={formatStatusDescription(selectedTranslation.status)}
maxWidth={"20rem"}>
<Chip text={selectedTranslation.status} />
</ToolTip>
</div>
)}
{post.authors && post.authors.data.length > 0 && (
<div>
<p className="font-headers font-bold">{"Authors"}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(post.authors.data, ["id", "attributes"]).map((author) => (
<Fragment key={author.id}>
<RecorderChip recorder={author.attributes} />
</Fragment>
))}
</div>
</div>
)}
</>
),
displayCredits && <Credits status={selectedTranslation?.status} authors={post.authors?.data} />,
displayToc && isDefined(toc) && (
<TableOfContents toc={toc} onContentClicked={() => setSubPanelOpened(false)} />

View File

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

View File

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

View File

@ -133,6 +133,11 @@ query getLibraryItem($slug: String, $language_code: String) {
}
}
}
tracks(pagination: { limit: -1 }) {
id
slug
title
}
}
... on ComponentMetadataGroup {
subtype {

View File

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

View File

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

View File

@ -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<typeof getFormat>["format"] | ReturnType<typeof useFormat>["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),

View File

@ -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 => {
<div className="grid place-items-center gap-12">
<div id="video" className="w-full overflow-hidden rounded-xl shadow-xl shadow-shade/80">
{video.gone ? (
<video className="w-full" src={getVideoFile(video.uid)} controls />
<VideoPlayer className="w-full" src={getVideoFile(video.uid)} rounded={false} />
) : (
<iframe
src={`https://www.youtube-nocookie.com/embed/${video.uid}`}
@ -132,7 +135,7 @@ const Video = ({ video, ...otherProps }: Props): JSX.Element => {
<InsetBox id="description" className="grid place-items-center">
<div className="grid w-[clamp(0px,100%,42rem)] place-items-center gap-8">
<h2 className="text-2xl">{format("description")}</h2>
<p className="whitespace-pre-line">{video.description}</p>
<Markdown className="whitespace-pre-line" text={video.description} />
</div>
</InsetBox>
</div>
@ -158,7 +161,14 @@ export const getStaticProps: GetStaticProps = async (context) => {
const props: Props = {
video: videos.videos.data[0].attributes,
openGraph: getOpenGraph(format, videos.videos.data[0].attributes.title),
openGraph: getOpenGraph(
format,
videos.videos.data[0].attributes.title,
getDescription(videos.videos.data[0].attributes.description, {
[format("channel")]: [videos.videos.data[0].attributes.channel?.data?.attributes?.title],
}),
getVideoThumbnailURL(videos.videos.data[0].attributes.uid)
),
};
return {
props: props,

View File

@ -1,26 +1,18 @@
import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { Fragment, useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import Collapsible from "react-collapsible";
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Chip } from "components/Chip";
import { PreviewCardCTAs } from "components/Library/PreviewCardCTAs";
import { getTocFromMarkdawn, Markdawn, TableOfContents } from "components/Markdown/Markdawn";
import { TranslatedReturnButton } from "components/PanelComponents/ReturnButton";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { SubPanel } from "components/Containers/SubPanel";
import { PreviewCard, TranslatedPreviewCard } from "components/PreviewCard";
import { RecorderChip } from "components/RecorderChip";
import { ThumbnailHeader } from "components/ThumbnailHeader";
import { ToolTip } from "components/ToolTip";
import { getReadySdk } from "graphql/sdk";
import {
prettyInlineTitle,
prettyLanguage,
prettyItemSubType,
prettySlug,
} from "helpers/formatters";
import { prettyInlineTitle, prettyItemSubType, prettySlug } from "helpers/formatters";
import { isUntangibleGroupItem } from "helpers/libraryItem";
import { filterHasAttributes, isDefinedAndNotEmpty } from "helpers/asserts";
import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { ContentWithTranslations } from "types/types";
import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { useSmartLanguage } from "hooks/useSmartLanguage";
@ -36,12 +28,18 @@ import { getFormat } from "helpers/i18n";
import { ElementsSeparator } from "helpers/component";
import { RelatedContentPreviewFragment } from "graphql/generated";
import { Button } from "components/Inputs/Button";
import { ButtonGroup, ButtonGroupProps } from "components/Inputs/ButtonGroup";
import { AudioPlayer, VideoPlayer } from "components/Player";
import { HorizontalLine } from "components/HorizontalLine";
import { Credits } from "components/Credits";
/*
*
* PAGE
*/
type SetType = "audio_set" | "text_set" | "video_set";
interface Props extends AppLayoutRequired {
content: ContentWithTranslations;
}
@ -49,9 +47,7 @@ interface Props extends AppLayoutRequired {
const Content = ({ content, ...otherProps }: Props): JSX.Element => {
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const { format, formatStatusDescription } = useFormat();
const languages = useAtomGetter(atoms.localData.languages);
const { format } = useFormat();
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
items: content.translations,
@ -63,6 +59,20 @@ const Content = ({ content, ...otherProps }: Props): JSX.Element => {
});
useScrollTopOnChange(Ids.ContentPanel, [selectedTranslation]);
const [selectedSetType, setSelectedSetType] = useState<SetType>();
useEffect(() => {
if (isDefined(selectedSetType) && selectedTranslation?.[selectedSetType]) return;
if (selectedTranslation?.text_set) {
setSelectedSetType("text_set");
} else if (selectedTranslation?.audio_set) {
setSelectedSetType("audio_set");
} else if (selectedTranslation?.video_set) {
setSelectedSetType("video_set");
} else {
setSelectedSetType(undefined);
}
}, [selectedSetType, selectedTranslation]);
const returnButtonProps = {
href: content.folder?.data?.attributes
@ -91,104 +101,73 @@ const Content = ({ content, ...otherProps }: Props): JSX.Element => {
)
);
const setTypeSelectorProps: ButtonGroupProps["buttonsProps"] = [
{
text: "Text",
icon: "subject",
visible: isDefined(selectedTranslation?.text_set),
onClick: () => setSelectedSetType("text_set"),
active: selectedSetType === "text_set",
},
{
text: "Audio",
icon: "headphones",
visible: isDefined(selectedTranslation?.audio_set),
onClick: () => setSelectedSetType("audio_set"),
active: selectedSetType === "audio_set",
},
{
text: "Video",
icon: "movie",
visible: isDefined(selectedTranslation?.video_set),
onClick: () => setSelectedSetType("video_set"),
active: selectedSetType === "video_set",
},
];
const subPanel = (
<SubPanel>
<ElementsSeparator>
{[
!is1ColumnLayout && <TranslatedReturnButton {...returnButtonProps} />,
selectedTranslation?.text_set?.source_language?.data?.attributes?.code !== undefined && (
<div className="grid gap-5">
<h2 className="text-xl">
{selectedTranslation.text_set.source_language.data.attributes.code ===
selectedTranslation.language?.data?.attributes?.code
? format("transcript_notice")
: format("translation_notice")}
</h2>
{selectedTranslation.text_set.source_language.data.attributes.code !==
selectedTranslation.language?.data?.attributes?.code && (
<div className="grid place-items-center gap-2">
<p className="font-headers font-bold">{format("source_language")}:</p>
<Chip
text={prettyLanguage(
selectedTranslation.text_set.source_language.data.attributes.code,
languages
)}
/>
</div>
)}
<div className="grid grid-flow-col place-content-center place-items-center gap-2">
<p className="font-headers font-bold">{format("status")}:</p>
<ToolTip
content={formatStatusDescription(selectedTranslation.text_set.status)}
maxWidth={"20rem"}>
<Chip text={selectedTranslation.text_set.status} />
</ToolTip>
</div>
{selectedTranslation.text_set.transcribers &&
selectedTranslation.text_set.transcribers.data.length > 0 && (
<div>
<p className="font-headers font-bold">{format("transcribers")}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedTranslation.text_set.transcribers.data, [
"attributes",
"id",
]).map((recorder) => (
<Fragment key={recorder.id}>
<RecorderChip recorder={recorder.attributes} />
</Fragment>
))}
</div>
</div>
)}
{selectedTranslation.text_set.translators &&
selectedTranslation.text_set.translators.data.length > 0 && (
<div>
<p className="font-headers font-bold">{format("translators")}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedTranslation.text_set.translators.data, [
"attributes",
"id",
]).map((recorder) => (
<Fragment key={recorder.id}>
<RecorderChip recorder={recorder.attributes} />
</Fragment>
))}
</div>
</div>
)}
{selectedTranslation.text_set.proofreaders &&
selectedTranslation.text_set.proofreaders.data.length > 0 && (
<div>
<p className="font-headers font-bold">{format("proofreaders")}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedTranslation.text_set.proofreaders.data, [
"attributes",
"id",
]).map((recorder) => (
<Fragment key={recorder.id}>
<RecorderChip recorder={recorder.attributes} />
</Fragment>
))}
</div>
</div>
)}
{isDefinedAndNotEmpty(selectedTranslation.text_set.notes) && (
<div>
<p className="font-headers font-bold">{format("notes")}:</p>
<div className="grid place-content-center place-items-center gap-2">
<Markdawn text={selectedTranslation.text_set.notes} />
</div>
</div>
)}
</div>
selectedSetType === "text_set" ? (
<Credits
key="credits"
languageCode={selectedTranslation?.language?.data?.attributes?.code}
sourceLanguageCode={
selectedTranslation?.text_set?.source_language?.data?.attributes?.code
}
status={selectedTranslation?.text_set?.status}
transcribers={selectedTranslation?.text_set?.transcribers?.data}
translators={selectedTranslation?.text_set?.translators?.data}
proofreaders={selectedTranslation?.text_set?.proofreaders?.data}
notes={selectedTranslation?.text_set?.notes}
/>
) : selectedSetType === "audio_set" ? (
<Credits
key="credits"
languageCode={selectedTranslation?.language?.data?.attributes?.code}
sourceLanguageCode={
selectedTranslation?.audio_set?.source_language?.data?.attributes?.code
}
status={selectedTranslation?.audio_set?.status}
dubbers={selectedTranslation?.audio_set?.dubbers?.data}
notes={selectedTranslation?.audio_set?.notes}
/>
) : (
selectedSetType === "video_set" && (
<Credits
key="credits"
languageCode={selectedTranslation?.language?.data?.attributes?.code}
sourceLanguageCode={
selectedTranslation?.video_set?.source_language?.data?.attributes?.code
}
status={selectedTranslation?.video_set?.status}
subbers={selectedTranslation?.video_set?.subbers?.data}
notes={selectedTranslation?.video_set?.notes}
/>
)
),
toc && <TableOfContents toc={toc} onContentClicked={() => setSubPanelOpened(false)} />,
@ -254,24 +233,35 @@ const Content = ({ content, ...otherProps }: Props): JSX.Element => {
/>
<div className="grid place-items-center">
<ElementsSeparator className="max-w-2xl">
<ElementsSeparator
separator={
selectedSetType === "text_set" ? (
<HorizontalLine className="max-w-2xl" />
) : (
<div className="py-8" />
)
}>
{[
<ThumbnailHeader
key="thumbnailHeader"
className="max-w-2xl"
thumbnail={content.thumbnail?.data?.attributes}
pre_title={selectedTranslation?.pre_title}
title={selectedTranslation?.title}
subtitle={selectedTranslation?.subtitle}
description={selectedTranslation?.description}
type={content.type}
categories={content.categories}
languageSwitcher={
languageSwitcherProps.locales.size > 1 ? (
<LanguageSwitcher {...languageSwitcherProps} />
) : undefined
}
/>,
<div key="thumbnailHeader" className="grid place-items-center gap-6">
<ThumbnailHeader
className="max-w-2xl"
thumbnail={content.thumbnail?.data?.attributes}
pre_title={selectedTranslation?.pre_title}
title={selectedTranslation?.title}
subtitle={selectedTranslation?.subtitle}
description={selectedTranslation?.description}
type={content.type}
categories={content.categories}
languageSwitcher={
languageSwitcherProps.locales.size > 1 ? (
<LanguageSwitcher {...languageSwitcherProps} />
) : undefined
}
/>
{setTypeSelectorProps.filter((button) => button.visible).length > 1 && (
<ButtonGroup buttonsProps={setTypeSelectorProps} />
)}
</div>,
content.previous_contents?.data && content.previous_contents.data.length > 0 && (
<RelatedContentsSection
@ -283,8 +273,35 @@ const Content = ({ content, ...otherProps }: Props): JSX.Element => {
/>
),
selectedTranslation?.text_set?.text && (
selectedSetType === "text_set" && selectedTranslation?.text_set?.text ? (
<Markdawn className="max-w-2xl" text={selectedTranslation.text_set.text} />
) : selectedSetType === "audio_set" && selectedTranslation?.audio_set ? (
<AudioPlayer
title={prettyInlineTitle(
selectedTranslation.pre_title,
selectedTranslation.title,
selectedTranslation.subtitle
)}
src={`${process.env.NEXT_PUBLIC_URL_ASSETS}/contents/audios/\
${content.slug}_${selectedTranslation.language?.data?.attributes?.code}.mp3`}
className="max-w-2xl"
/>
) : (
selectedSetType === "video_set" &&
selectedTranslation?.video_set && (
<VideoPlayer
title={prettyInlineTitle(
selectedTranslation.pre_title,
selectedTranslation.title,
selectedTranslation.subtitle
)}
src={`${process.env.NEXT_PUBLIC_URL_ASSETS}/contents/videos/\
${content.slug}_${selectedTranslation.language?.data?.attributes?.code}.mp4`}
subSrc={`${process.env.NEXT_PUBLIC_URL_ASSETS}/contents/videos/\
${content.slug}_${selectedTranslation.language?.data?.attributes?.code}.vtt`}
className="max-w-[90vh]"
/>
)
),
content.next_contents?.data && content.next_contents.data.length > 0 && (
@ -331,13 +348,16 @@ export const getStaticProps: GetStaticProps = async (context) => {
preferredLanguages: getDefaultPreferredLanguages(context.locale, context.locales),
});
if (selectedTranslation) {
const rawDescription = isDefinedAndNotEmpty(selectedTranslation.description)
? selectedTranslation.description
: selectedTranslation.text_set?.text;
return {
title: prettyInlineTitle(
selectedTranslation.pre_title,
selectedTranslation.title,
selectedTranslation.subtitle
),
description: getDescription(selectedTranslation.description, {
description: getDescription(rawDescription, {
[format("type", { count: Infinity })]: [
content.contents.data[0].attributes.type?.data?.attributes?.titles?.[0]?.title,
],

View File

@ -5,7 +5,7 @@ import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { getOpenGraph } from "helpers/openGraph";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { Button } from "components/Inputs/Button";
import { cJoin } from "helpers/className";
import { cIf, cJoin } from "helpers/className";
import { HorizontalLine } from "components/HorizontalLine";
import { Switch } from "components/Inputs/Switch";
import { TextInput } from "components/Inputs/TextInput";
@ -17,6 +17,7 @@ import { PreviewCard } from "components/PreviewCard";
import { ChroniclePreview } from "components/Chronicles/ChroniclePreview";
import { PreviewFolder } from "components/Contents/PreviewFolder";
import { getFormat } from "helpers/i18n";
import { AudioPlayer, VideoPlayer } from "components/Player";
/*
*
@ -32,6 +33,7 @@ const DesignSystem = (props: Props): JSX.Element => {
const [textInputState, setTextInputState] = useState("");
const [textAreaState, setTextAreaState] = useState("");
const [buttonGroupState, setButtonGroupState] = useState(0);
const [verticalButtonGroupState, setVerticalButtonGroupState] = useState(0);
const contentPanel = (
<ContentPanel
@ -190,7 +192,7 @@ const DesignSystem = (props: Props): JSX.Element => {
</div>
<HorizontalLine />
<h3 className="text-xl">Small sized</h3>
<h3 className="-mt-6 text-xl">Small sized</h3>
<div className="grid grid-cols-[repeat(4,auto)] place-content-center gap-4">
<p className="self-center justify-self-start">Normal</p>
@ -215,12 +217,22 @@ const DesignSystem = (props: Props): JSX.Element => {
</div>
<HorizontalLine />
<h3 className="text-xl">Groups</h3>
<div className="grid place-items-center gap-4">
<h3 className="-mt-6 text-xl">Groups</h3>
<div className="grid grid-cols-2 place-items-center gap-4">
<p>Normal sized</p>
<p>Small sized</p>
<ButtonGroup buttonsProps={[{ icon: "call_end" }, { icon: "zoom_in_map" }]} />
<ButtonGroup
buttonsProps={[{ icon: "call_end" }, { icon: "zoom_in_map" }]}
size="small"
/>
<ButtonGroup
buttonsProps={[{ icon: "car_crash" }, { icon: "timelapse" }, { icon: "leak_add" }]}
/>
<ButtonGroup
buttonsProps={[{ icon: "car_crash" }, { icon: "timelapse" }, { icon: "leak_add" }]}
size="small"
/>
<ButtonGroup
buttonsProps={[
{ icon: "car_crash" },
@ -229,6 +241,40 @@ const DesignSystem = (props: Props): JSX.Element => {
{ icon: "cable" },
]}
/>
<ButtonGroup
buttonsProps={[
{ icon: "car_crash" },
{ icon: "timelapse", text: "Label", active: true },
{ text: "Another Label" },
{ icon: "cable" },
]}
size="small"
/>
<ButtonGroup
buttonsProps={[
{
text: "Try me!",
active: buttonGroupState === 0,
onClick: () => setButtonGroupState(0),
},
{
icon: "ad_units",
text: "Label",
active: buttonGroupState === 1,
onClick: () => setButtonGroupState(1),
},
{
text: "Yet another label",
active: buttonGroupState === 2,
onClick: () => setButtonGroupState(2),
},
{
icon: "security",
active: buttonGroupState === 3,
onClick: () => setButtonGroupState(3),
},
]}
/>
<ButtonGroup
buttonsProps={[
{
@ -253,6 +299,102 @@ const DesignSystem = (props: Props): JSX.Element => {
onClick: () => setButtonGroupState(3),
},
]}
size="small"
/>
</div>
<HorizontalLine />
<h3 className="-mt-6 text-xl">Vertical groups</h3>
<div className="grid grid-cols-2 place-items-center gap-4">
<p>Normal sized</p>
<p>Small sized</p>
<ButtonGroup buttonsProps={[{ icon: "call_end" }, { icon: "zoom_in_map" }]} vertical />
<ButtonGroup
buttonsProps={[{ icon: "call_end" }, { icon: "zoom_in_map" }]}
vertical
size="small"
/>
<ButtonGroup
buttonsProps={[{ icon: "car_crash" }, { icon: "timelapse" }, { icon: "leak_add" }]}
vertical
/>
<ButtonGroup
buttonsProps={[{ icon: "car_crash" }, { icon: "timelapse" }, { icon: "leak_add" }]}
vertical
size="small"
/>
<ButtonGroup
buttonsProps={[
{ icon: "car_crash" },
{ icon: "timelapse", text: "Label", active: true },
{ text: "Another Label" },
{ icon: "cable" },
]}
vertical
/>
<ButtonGroup
buttonsProps={[
{ icon: "car_crash" },
{ icon: "timelapse", text: "Label", active: true },
{ text: "Another Label" },
{ icon: "cable" },
]}
vertical
size="small"
/>
<ButtonGroup
buttonsProps={[
{
text: "Try me!",
active: verticalButtonGroupState === 0,
onClick: () => setVerticalButtonGroupState(0),
},
{
icon: "ad_units",
text: "Label",
active: verticalButtonGroupState === 1,
onClick: () => setVerticalButtonGroupState(1),
},
{
text: "Yet another label",
active: verticalButtonGroupState === 2,
onClick: () => setVerticalButtonGroupState(2),
},
{
icon: "security",
active: verticalButtonGroupState === 3,
onClick: () => setVerticalButtonGroupState(3),
},
]}
vertical
/>
<ButtonGroup
buttonsProps={[
{
text: "Try me!",
active: verticalButtonGroupState === 0,
onClick: () => setVerticalButtonGroupState(0),
},
{
icon: "ad_units",
text: "Label",
active: verticalButtonGroupState === 1,
onClick: () => setVerticalButtonGroupState(1),
},
{
text: "Yet another label",
active: verticalButtonGroupState === 2,
onClick: () => setVerticalButtonGroupState(2),
},
{
icon: "security",
active: verticalButtonGroupState === 3,
onClick: () => setVerticalButtonGroupState(3),
},
]}
vertical
size="small"
/>
</div>
</TwoThemedSection>
@ -814,6 +956,28 @@ const DesignSystem = (props: Props): JSX.Element => {
<PreviewFolder href="#" title="Disabled, with a longer title" disabled />
</div>
</TwoThemedSection>
<TwoThemedSection className="grid gap-4" fullWidth>
<h3 className="mb-2 text-xl">Audio players</h3>
<AudioPlayer src="https://resha.re/public-domain/Prelude-No.15-in-G-major-BWV-860.mp3" />
<AudioPlayer
title="A longer audio track, with a title"
src="https://resha.re/public-domain/Muriel-Nguyen-Xuan-Brahms-rhapsody-opus79-1.ogg"
/>
<AudioPlayer
title={`The same audio tack, but this time, an obnoxiously long title that frankly at\
this point should stop because who in their right mind would read that much text for a title.`}
src="https://resha.re/public-domain/Muriel-Nguyen-Xuan-Brahms-rhapsody-opus79-1.ogg"
/>
<HorizontalLine />
<h3 className="mb-2 text-xl">Video players</h3>
<VideoPlayer src={`https://resha.re/public-domain/the_whistler_1944.mp4`} />
<VideoPlayer
src={`https://resha.re/public-domain/big_buck_bunny_720p_surround.mp4`}
title="Big Buck Bunny - Blender Foundation"
/>
</TwoThemedSection>
</ContentPanel>
);
return <AppLayout {...props} contentPanel={contentPanel} />;
@ -844,10 +1008,15 @@ export const getStaticProps: GetStaticProps = (context) => {
interface ThemedSectionProps {
className?: string;
children?: ReactNode;
fullWidth?: boolean;
}
const TwoThemedSection = ({ children, className }: ThemedSectionProps) => (
<div className="mb-12 grid grid-flow-col drop-shadow-lg shadow-shade">
const TwoThemedSection = ({ children, className, fullWidth }: ThemedSectionProps) => (
<div
className={cJoin(
"mb-12 grid grid-flow-col drop-shadow-lg shadow-shade",
cIf(fullWidth, "w-full")
)}>
<LightThemeSection className={cJoin("rounded-l-xl text-black", className)}>
{children}
</LightThemeSection>

View File

@ -38,7 +38,7 @@ import {
isDefinedAndNotEmpty,
} from "helpers/asserts";
import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { getScanArchiveURL, isUntangibleGroupItem } from "helpers/libraryItem";
import { getScanArchiveURL, getTrackURL, isUntangibleGroupItem } from "helpers/libraryItem";
import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { WithLabel } from "components/Inputs/WithLabel";
import { cJoin, cIf } from "helpers/className";
@ -53,6 +53,7 @@ import { useFormat } from "hooks/useFormat";
import { getFormat } from "helpers/i18n";
import { ElementsSeparator } from "helpers/component";
import { ToolTip } from "components/ToolTip";
import { AudioPlayer } from "components/Player";
/*
*
@ -69,9 +70,21 @@ const intersectionIds = ["summary", "gallery", "details", "subitems", "contents"
interface Props extends AppLayoutRequired {
item: NonNullable<NonNullable<GetLibraryItemQuery["libraryItems"]>["data"][number]["attributes"]>;
itemId: NonNullable<GetLibraryItemQuery["libraryItems"]>["data"][number]["id"];
tracks: { id: string; title: string; src: string }[];
hasContentScans: boolean;
isVariantSet: boolean;
hasContentSection: boolean;
}
const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
const LibrarySlug = ({
item,
itemId,
tracks,
hasContentScans,
isVariantSet,
hasContentSection,
...otherProps
}: Props): JSX.Element => {
const currency = useAtomGetter(atoms.settings.currency);
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
const { format, formatLibraryItemType } = useFormat();
@ -79,6 +92,7 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
const isContentPanelAtLeast3xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast3xl);
const isContentPanelAtLeastSm = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastSm);
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
const hoverable = useDeviceSupportsHover();
const router = useRouter();
@ -91,19 +105,6 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
useScrollTopOnChange(Ids.ContentPanel, [itemId]);
const currentIntersection = useIntersectionList(intersectionIds);
const isVariantSet =
item.metadata?.[0]?.__typename === "ComponentMetadataGroup" &&
item.metadata[0].subtype?.data?.attributes?.slug === "variant-set";
const hasContentScans = item.contents?.data.some(
(content) => content.attributes?.scan_set && content.attributes.scan_set.length > 0
);
const hasContentSection =
(item.contents && item.contents.data.length > 0) || item.download_available;
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
const subPanel = (
<SubPanel>
<ElementsSeparator>
@ -157,7 +158,7 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
/>
)}
{item.contents && item.contents.data.length > 0 && (
{hasContentSection && (
<NavOption
title={format("contents")}
url={`#${intersectionIds[4]}`}
@ -546,6 +547,9 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
)}
</div>
<div className="grid w-full gap-4">
{tracks.map(({ id, title, src }) => (
<AudioPlayer key={id} src={src} title={title} />
))}
<div
className={cJoin(
"grid items-center",
@ -641,10 +645,36 @@ export const getStaticProps: GetStaticProps = async (context) => {
}
);
const tracks: Props["tracks"] = ((attributes) => {
const metadata = attributes.metadata?.[0];
if (metadata?.__typename !== "ComponentMetadataAudio" || !metadata.tracks) {
return [];
}
return filterDefined(metadata.tracks).map((track, index) => ({
id: track.slug,
src: getTrackURL(attributes.slug, track.slug),
title: `${index + 1}. ${track.title}`,
}));
})(item.libraryItems.data[0].attributes);
const props: Props = {
item: item.libraryItems.data[0].attributes,
itemId: item.libraryItems.data[0].id,
tracks,
openGraph: getOpenGraph(format, title, description, thumbnail?.data?.attributes),
isVariantSet:
item.libraryItems.data[0].attributes.metadata?.[0]?.__typename === "ComponentMetadataGroup" &&
item.libraryItems.data[0].attributes.metadata[0].subtype?.data?.attributes?.slug ===
"variant-set",
hasContentScans:
item.libraryItems.data[0].attributes.contents?.data.some(
(content) => content.attributes?.scan_set && content.attributes.scan_set.length > 0
) ?? false,
hasContentSection:
(item.libraryItems.data[0].attributes.contents &&
item.libraryItems.data[0].attributes.contents.data.length > 0) ||
item.libraryItems.data[0].attributes.download_available ||
tracks.length > 0,
};
return {
props: props,

View File

@ -8,7 +8,6 @@ import { Terminal } from "components/Cli/Terminal";
import { PostWithTranslations } from "types/types";
import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales";
import { prettyTerminalBoxedTitle } from "helpers/terminal";
import { prettyMarkdown } from "helpers/description";
import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms";
import { useFormat } from "hooks/useFormat";
@ -89,11 +88,11 @@ const terminalPostPage = (post: PostWithTranslations, router: NextRouter): strin
result += prettyTerminalBoxedTitle(selectedTranslation.title);
if (isDefinedAndNotEmpty(selectedTranslation.excerpt)) {
result += "\n\n";
result += prettyMarkdown(selectedTranslation.excerpt);
result += selectedTranslation.excerpt;
}
if (isDefinedAndNotEmpty(selectedTranslation.body)) {
result += "\n\n";
result += prettyMarkdown(selectedTranslation.body);
result += selectedTranslation.body;
}
}
}

View File

@ -67,3 +67,9 @@ textarea:disabled {
textarea {
@apply scrollbar-none;
}
/* VIDEO CC */
video::cue {
@apply bg-[black]/70 font-body;
}

View File

@ -11,7 +11,7 @@
box-sizing: border-box;
}
.rc-slider-rail {
@apply h-2 rounded-full bg-mid/80;
@apply h-2 rounded-full bg-mid;
position: absolute;
width: 100%;
}