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)}>
+
+ {ref && (
+
+ )}
+
+ );
+};
+
+// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
+
+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 (
+
+
+
+ {isDefinedAndNotEmpty(title) && (
+
{title}
+ )}
+
+
+ {prettyDuration(currentTime)}
+
+
{
+ const newTime = (value as number) / STEP_MULTIPLIER;
+ mediaRef.currentTime = newTime;
+ setCurrentTime(newTime);
+ }}
+ onAfterChange={async () => await mediaRef.play()}
+ max={duration * STEP_MULTIPLIER}
+ />
+ {!isContentPanelAtLeastXl && type === "video" && /
}
+
+ {prettyDuration(duration)}
+
+
+
+ {isContentPanelAtLeastXl ? (
+ buttonGroup
+ ) : (
+
+
+
+ )}
+
+ );
+};
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 ? (
-
+
) : (
@@ -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,
diff --git a/src/pages/contents/[slug].tsx b/src/pages/contents/[slug].tsx
index 94390ce..bee3856 100644
--- a/src/pages/contents/[slug].tsx
+++ b/src/pages/contents/[slug].tsx
@@ -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
();
+
+ 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 = (
{[
!is1ColumnLayout && ,
- selectedTranslation?.text_set?.source_language?.data?.attributes?.code !== undefined && (
-
-
- {selectedTranslation.text_set.source_language.data.attributes.code ===
- selectedTranslation.language?.data?.attributes?.code
- ? format("transcript_notice")
- : format("translation_notice")}
-
-
- {selectedTranslation.text_set.source_language.data.attributes.code !==
- selectedTranslation.language?.data?.attributes?.code && (
-
-
{format("source_language")}:
-
-
- )}
-
-
-
{format("status")}:
-
-
-
-
-
-
- {selectedTranslation.text_set.transcribers &&
- selectedTranslation.text_set.transcribers.data.length > 0 && (
-
-
{format("transcribers")}:
-
- {filterHasAttributes(selectedTranslation.text_set.transcribers.data, [
- "attributes",
- "id",
- ]).map((recorder) => (
-
-
-
- ))}
-
-
- )}
-
- {selectedTranslation.text_set.translators &&
- selectedTranslation.text_set.translators.data.length > 0 && (
-
-
{format("translators")}:
-
- {filterHasAttributes(selectedTranslation.text_set.translators.data, [
- "attributes",
- "id",
- ]).map((recorder) => (
-
-
-
- ))}
-
-
- )}
-
- {selectedTranslation.text_set.proofreaders &&
- selectedTranslation.text_set.proofreaders.data.length > 0 && (
-
-
{format("proofreaders")}:
-
- {filterHasAttributes(selectedTranslation.text_set.proofreaders.data, [
- "attributes",
- "id",
- ]).map((recorder) => (
-
-
-
- ))}
-
-
- )}
-
- {isDefinedAndNotEmpty(selectedTranslation.text_set.notes) && (
-
-
{format("notes")}:
-
-
-
-
- )}
-
+ selectedSetType === "text_set" ? (
+
+ ) : selectedSetType === "audio_set" ? (
+
+ ) : (
+ selectedSetType === "video_set" && (
+
+ )
),
toc && setSubPanelOpened(false)} />,
@@ -254,24 +233,35 @@ const Content = ({ content, ...otherProps }: Props): JSX.Element => {
/>
-
+
+ ) : (
+
+ )
+ }>
{[
- 1 ? (
-
- ) : undefined
- }
- />,
+
+ 1 ? (
+
+ ) : undefined
+ }
+ />
+ {setTypeSelectorProps.filter((button) => button.visible).length > 1 && (
+
+ )}
+
,
content.previous_contents?.data && content.previous_contents.data.length > 0 && (
{
/>
),
- selectedTranslation?.text_set?.text && (
+ selectedSetType === "text_set" && selectedTranslation?.text_set?.text ? (
+ ) : selectedSetType === "audio_set" && selectedTranslation?.audio_set ? (
+
+ ) : (
+ selectedSetType === "video_set" &&
+ selectedTranslation?.video_set && (
+
+ )
),
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,
],
diff --git a/src/pages/dev/showcase/design-system.tsx b/src/pages/dev/showcase/design-system.tsx
index 8837648..d8147bd 100644
--- a/src/pages/dev/showcase/design-system.tsx
+++ b/src/pages/dev/showcase/design-system.tsx
@@ -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 = (
{
- Small sized
+ Small sized
Normal
@@ -215,12 +217,22 @@ const DesignSystem = (props: Props): JSX.Element => {
- Groups
-
+
Groups
+
+
Normal sized
+
Small sized
+
+
{
{ icon: "cable" },
]}
/>
+
+ 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),
+ },
+ ]}
+ />
{
onClick: () => setButtonGroupState(3),
},
]}
+ size="small"
+ />
+
+
+
+
+
Vertical groups
+
+
Normal sized
+
Small sized
+
+
+
+
+
+
+
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
+ />
+ 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"
/>
@@ -814,6 +956,28 @@ const DesignSystem = (props: Props): JSX.Element => {
+
+
+ Audio players
+
+
+
+
+
+ Video players
+
+
+
);
return ;
@@ -844,10 +1008,15 @@ export const getStaticProps: GetStaticProps = (context) => {
interface ThemedSectionProps {
className?: string;
children?: ReactNode;
+ fullWidth?: boolean;
}
-const TwoThemedSection = ({ children, className }: ThemedSectionProps) => (
-
+const TwoThemedSection = ({ children, className, fullWidth }: ThemedSectionProps) => (
+
{children}
diff --git a/src/pages/library/[slug]/index.tsx b/src/pages/library/[slug]/index.tsx
index c09e8bc..b097174 100644
--- a/src/pages/library/[slug]/index.tsx
+++ b/src/pages/library/[slug]/index.tsx
@@ -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["data"][number]["attributes"]>;
itemId: NonNullable["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 = (
@@ -157,7 +158,7 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
/>
)}
- {item.contents && item.contents.data.length > 0 && (
+ {hasContentSection && (
{
)}
+ {tracks.map(({ id, title, src }) => (
+
+ ))}
{
}
);
+ 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,
diff --git a/src/pages/news/[slug].tsx b/src/pages/news/[slug].tsx
index 7d6f6c9..89df0a7 100644
--- a/src/pages/news/[slug].tsx
+++ b/src/pages/news/[slug].tsx
@@ -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;
}
}
}
diff --git a/src/styles/others.css b/src/styles/others.css
index 4e12216..4e61698 100644
--- a/src/styles/others.css
+++ b/src/styles/others.css
@@ -67,3 +67,9 @@ textarea:disabled {
textarea {
@apply scrollbar-none;
}
+
+/* VIDEO CC */
+
+video::cue {
+ @apply bg-[black]/70 font-body;
+}
diff --git a/src/styles/rc-slider.css b/src/styles/rc-slider.css
index 9c1e5ce..729d651 100644
--- a/src/styles/rc-slider.css
+++ b/src/styles/rc-slider.css
@@ -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%;
}