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", "@tippyjs/react": "^4.2.6",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"cuid": "^2.1.8", "cuid": "^2.1.8",
"html-to-text": "^9.0.5",
"intl-messageformat": "^10.3.5", "intl-messageformat": "^10.3.5",
"isomorphic-dompurify": "^1.5.0", "isomorphic-dompurify": "^1.5.0",
"jotai": "^2.1.0", "jotai": "^2.1.0",
@ -47,6 +48,7 @@
"@graphql-codegen/typescript": "3.0.4", "@graphql-codegen/typescript": "3.0.4",
"@graphql-codegen/typescript-graphql-request": "^4.5.9", "@graphql-codegen/typescript-graphql-request": "^4.5.9",
"@graphql-codegen/typescript-operations": "^3.0.4", "@graphql-codegen/typescript-operations": "^3.0.4",
"@types/html-to-text": "^9.0.0",
"@types/marked": "^4.3.0", "@types/marked": "^4.3.0",
"@types/node": "20.1.3", "@types/node": "20.1.3",
"@types/nodemailer": "^6.4.7", "@types/nodemailer": "^6.4.7",
@ -3642,6 +3644,18 @@
"integrity": "sha512-WiBSI6JBIhC6LRIsB2Kwh8DsGTlbBU+mLRxJmAe3LjHTdkDpwIbEOZgoXBbZilk/vlfjK8i6nKRAvIRn1XaIMw==", "integrity": "sha512-WiBSI6JBIhC6LRIsB2Kwh8DsGTlbBU+mLRxJmAe3LjHTdkDpwIbEOZgoXBbZilk/vlfjK8i6nKRAvIRn1XaIMw==",
"dev": true "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": { "node_modules/@swc/helpers": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz",
@ -3683,6 +3697,12 @@
"@types/trusted-types": "*" "@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": { "node_modules/@types/js-yaml": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", "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==", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true "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": { "node_modules/defaults": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
@ -5243,6 +5271,30 @@
"node": ">=6.0.0" "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": { "node_modules/domexception": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
@ -5262,6 +5314,20 @@
"node": ">=12" "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": { "node_modules/domino": {
"version": "2.1.6", "version": "2.1.6",
"resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", "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", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.3.tgz",
"integrity": "sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ==" "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": { "node_modules/dot-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
@ -6865,6 +6944,39 @@
"node": ">=12" "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": { "node_modules/http-proxy-agent": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "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" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -8668,6 +8788,18 @@
"url": "https://github.com/inikulin/parse5?sponsor=1" "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": { "node_modules/pascal-case": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
@ -8845,6 +8977,14 @@
"node": ">=8" "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": { "node_modules/picocolors": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@ -9565,6 +9705,17 @@
"integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==", "integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==",
"dev": true "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": { "node_modules/semver": {
"version": "7.3.8", "version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
@ -13631,6 +13782,15 @@
"integrity": "sha512-WiBSI6JBIhC6LRIsB2Kwh8DsGTlbBU+mLRxJmAe3LjHTdkDpwIbEOZgoXBbZilk/vlfjK8i6nKRAvIRn1XaIMw==", "integrity": "sha512-WiBSI6JBIhC6LRIsB2Kwh8DsGTlbBU+mLRxJmAe3LjHTdkDpwIbEOZgoXBbZilk/vlfjK8i6nKRAvIRn1XaIMw==",
"dev": true "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": { "@swc/helpers": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz",
@ -13667,6 +13827,12 @@
"@types/trusted-types": "*" "@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": { "@types/js-yaml": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", "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==", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true "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": { "defaults": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
@ -14839,6 +15010,21 @@
"esutils": "^2.0.2" "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": { "domexception": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", "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": { "domino": {
"version": "2.1.6", "version": "2.1.6",
"resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", "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", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.3.tgz",
"integrity": "sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ==" "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": { "dot-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
@ -16083,6 +16287,29 @@
"whatwg-encoding": "^2.0.0" "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": { "http-proxy-agent": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "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" "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": { "levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -17374,6 +17606,15 @@
"entities": "^4.4.0" "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": { "pascal-case": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
@ -17511,6 +17752,11 @@
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true "dev": true
}, },
"peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="
},
"picocolors": { "picocolors": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@ -17966,6 +18212,14 @@
"integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==", "integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==",
"dev": true "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": { "semver": {
"version": "7.3.8", "version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",

View File

@ -30,6 +30,7 @@
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"cuid": "^2.1.8", "cuid": "^2.1.8",
"html-to-text": "^9.0.5",
"intl-messageformat": "^10.3.5", "intl-messageformat": "^10.3.5",
"isomorphic-dompurify": "^1.5.0", "isomorphic-dompurify": "^1.5.0",
"jotai": "^2.1.0", "jotai": "^2.1.0",
@ -61,6 +62,7 @@
"@graphql-codegen/typescript": "3.0.4", "@graphql-codegen/typescript": "3.0.4",
"@graphql-codegen/typescript-graphql-request": "^4.5.9", "@graphql-codegen/typescript-graphql-request": "^4.5.9",
"@graphql-codegen/typescript-operations": "^3.0.4", "@graphql-codegen/typescript-operations": "^3.0.4",
"@types/html-to-text": "^9.0.0",
"@types/marked": "^4.3.0", "@types/marked": "^4.3.0",
"@types/node": "20.1.3", "@types/node": "20.1.3",
"@types/nodemailer": "^6.4.7", "@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} onMouseUp={onMouseUp}
onFocus={(event) => event.target.blur()} onFocus={(event) => event.target.blur()}
className={cJoin( className={cJoin(
`group grid w-full grid-flow-col place-content-center `group grid w-full grid-flow-col
place-items-center gap-2 rounded-full border border-dark place-content-center place-items-center gap-2 rounded-full border
leading-none text-dark transition-all disabled:cursor-not-allowed border-dark leading-none text-dark transition-all
disabled:opacity-50 disabled:grayscale`, disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale`,
cIf(size === "small", "px-3 py-1 text-xs", "px-4 py-3"), 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(active, "!border-black bg-black !text-light shadow-lg shadow-black"),
cIf( cIf(
!disabled && !active, !disabled && !active,
@ -91,7 +91,9 @@ export const Button = ({
weight={size === "normal" ? 500 : 800} 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> </button>
</div> </div>
</Link> </Link>

View File

@ -1,7 +1,7 @@
import type { Placement } from "tippy.js"; import type { Placement } from "tippy.js";
import { Button } from "./Button"; import { Button } from "./Button";
import { ToolTip } from "components/ToolTip"; import { ToolTip } from "components/ToolTip";
import { cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { ConditionalWrapper, Wrapper } from "helpers/component"; import { ConditionalWrapper, Wrapper } from "helpers/component";
import { isDefined } from "helpers/asserts"; import { isDefined } from "helpers/asserts";
@ -10,9 +10,13 @@ import { isDefined } from "helpers/asserts";
* COMPONENT * COMPONENT
*/ */
type ButtonProps = Parameters<typeof Button>[0];
export interface ButtonGroupProps { export interface ButtonGroupProps {
className?: string; className?: string;
buttonsProps: (Parameters<typeof Button>[0] & { vertical?: boolean;
size?: ButtonProps["size"];
buttonsProps: (Omit<ButtonProps, "size"> & {
visible?: boolean; visible?: boolean;
tooltip?: React.ReactNode | null | undefined; tooltip?: React.ReactNode | null | undefined;
tooltipPlacement?: Placement; 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 <FilteredButtonGroup
buttonsProps={buttonsProps.filter((button) => button.visible !== false)} buttonsProps={buttonsProps.filter((button) => button.visible !== false)}
className={className} className={className}
vertical={vertical}
size={size}
/> />
); );
const FilteredButtonGroup = ({ buttonsProps, className }: ButtonGroupProps) => ( const FilteredButtonGroup = ({
<div className={cJoin("grid grid-flow-col", className)}> buttonsProps,
{buttonsProps.map((buttonProps, index) => ( className,
<ConditionalWrapper vertical = false,
key={index} size = "normal",
isWrapping={isDefined(buttonProps.tooltip)} }: ButtonGroupProps) => {
wrapper={ToolTipWrapper} const firstClassName = cIf(
wrapperProps={{ text: buttonProps.tooltip ?? "", placement: buttonProps.tooltipPlacement }}> vertical,
<Button cJoin("rounded-b-none border-b-0", cIf(size === "normal", "rounded-t-3xl", "rounded-t-xl")),
{...buttonProps} "rounded-r-none border-r-0"
className={ );
index === 0
? "rounded-r-none border-r-0" const lastClassName = cIf(
: index === buttonsProps.length - 1 vertical,
? "rounded-l-none" cJoin("rounded-t-none border-t-0", cIf(size === "normal", "rounded-b-3xl", "rounded-b-xl")),
: "rounded-none border-r-0" "rounded-l-none border-l-0"
} );
/>
</ConditionalWrapper> const middleClassName = cIf(vertical, "rounded-none border-y-0", "rounded-none border-x-0");
))}
</div> 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 { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "components/Inputs/Button";
import { ButtonGroup } from "components/Inputs/ButtonGroup"; import { ButtonGroup } from "components/Inputs/ButtonGroup";
import { OrderableList } from "components/Inputs/OrderableList"; import { OrderableList } from "components/Inputs/OrderableList";
import { Select } from "components/Inputs/Select"; import { Select } from "components/Inputs/Select";
@ -203,23 +202,28 @@ export const SettingsPopup = (): JSX.Element => {
<div> <div>
<h3 className="text-xl">{format("font")}</h3> <h3 className="text-xl">{format("font")}</h3>
<div className="grid gap-2"> <div className="grid gap-2">
<Button <ButtonGroup
active={!isDyslexic} vertical
onClick={() => { buttonsProps={[
setDyslexic(false); {
sendAnalytics("Settings", "Change font (Zen Maru Gothic)"); active: !isDyslexic,
}} onClick: () => {
className="font-zenMaruGothic" setDyslexic(false);
text="Zen Maru Gothic" sendAnalytics("Settings", "Change font (Zen Maru Gothic)");
/> },
<Button className: "font-zenMaruGothic",
active={isDyslexic} text: "Zen Maru Gothic",
onClick={() => { },
setDyslexic(true); {
sendAnalytics("Settings", "Change font (OpenDyslexic)"); active: isDyslexic,
}} onClick: () => {
className="font-openDyslexic" setDyslexic(true);
text="OpenDyslexic" sendAnalytics("Settings", "Change font (OpenDyslexic)");
},
className: "font-openDyslexic",
text: "OpenDyslexic",
},
]}
/> />
</div> </div>
</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 { Fragment, useCallback } from "react";
import { AppLayout, AppLayoutRequired } from "./AppLayout"; import { AppLayout, AppLayoutRequired } from "./AppLayout";
import { Chip } from "./Chip";
import { getTocFromMarkdawn, Markdawn, TableOfContents } from "./Markdown/Markdawn"; import { getTocFromMarkdawn, Markdawn, TableOfContents } from "./Markdown/Markdawn";
import { ReturnButton } from "./PanelComponents/ReturnButton"; import { ReturnButton } from "./PanelComponents/ReturnButton";
import { ContentPanel } from "./Containers/ContentPanel"; import { ContentPanel } from "./Containers/ContentPanel";
import { SubPanel } from "./Containers/SubPanel"; import { SubPanel } from "./Containers/SubPanel";
import { RecorderChip } from "./RecorderChip";
import { ThumbnailHeader } from "./ThumbnailHeader"; import { ThumbnailHeader } from "./ThumbnailHeader";
import { ToolTip } from "./ToolTip";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
import { PostWithTranslations } from "types/types"; import { PostWithTranslations } from "types/types";
import { filterHasAttributes, isDefined } from "helpers/asserts"; import { isDefined } from "helpers/asserts";
import { prettySlug } from "helpers/formatters"; import { prettySlug } from "helpers/formatters";
import { useFormat } from "hooks/useFormat";
import { useAtomGetter, useAtomSetter } from "helpers/atoms"; import { useAtomGetter, useAtomSetter } from "helpers/atoms";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { ElementsSeparator } from "helpers/component"; import { ElementsSeparator } from "helpers/component";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { Credits } from "components/Credits";
/* /*
* *
@ -51,7 +48,6 @@ export const PostPage = ({
displayTitle = true, displayTitle = true,
...otherProps ...otherProps
}: Props): JSX.Element => { }: Props): JSX.Element => {
const { format, formatStatusDescription } = useFormat();
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened); const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout); const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
@ -77,34 +73,7 @@ export const PostPage = ({
<ReturnButton href={returnHref} title={returnTitle} /> <ReturnButton href={returnHref} title={returnTitle} />
), ),
displayCredits && ( displayCredits && <Credits status={selectedTranslation?.status} authors={post.authors?.data} />,
<>
{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>
)}
</>
),
displayToc && isDefined(toc) && ( displayToc && isDefined(toc) && (
<TableOfContents toc={toc} onContentClicked={() => setSubPanelOpened(false)} /> <TableOfContents toc={toc} onContentClicked={() => setSubPanelOpened(false)} />

View File

@ -4,7 +4,7 @@ import { PostWithTranslations } from "types/types";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { prettyDate, prettySlug } from "helpers/formatters"; import { prettyDate, prettySlug } from "helpers/formatters";
import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales"; import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales";
import { filterHasAttributes, isDefined } from "helpers/asserts"; import { filterHasAttributes } from "helpers/asserts";
import { getDescription } from "helpers/description"; import { getDescription } from "helpers/description";
import { AppLayoutRequired } from "components/AppLayout"; import { AppLayoutRequired } from "components/AppLayout";
import { getFormat } from "helpers/i18n"; import { getFormat } from "helpers/i18n";
@ -22,40 +22,36 @@ export const getPostStaticProps =
slug: slug, slug: slug,
language_code: context.locale ?? "en", 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); if (!post.posts?.data[0]?.attributes?.translations || !context.locale || !context.locales) {
return { notFound: true };
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,
};
} }
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 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 { thumbnail {

View File

@ -133,6 +133,11 @@ query getLibraryItem($slug: String, $language_code: String) {
} }
} }
} }
tracks(pagination: { limit: -1 }) {
id
slug
title
}
} }
... on ComponentMetadataGroup { ... on ComponentMetadataGroup {
subtype { 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"; import { filterDefined, isDefined, isDefinedAndNotEmpty } from "./asserts";
export const getDescription = ( export const getDescription = (
@ -6,13 +9,6 @@ export const getDescription = (
): string => { ): string => {
let result = ""; let result = "";
if (isDefinedAndNotEmpty(description)) {
result += prettyMarkdown(description);
if (isDefined(chipsGroups)) {
result += "\n\n";
}
}
for (const key in chipsGroups) { for (const key in chipsGroups) {
if (Object.hasOwn(chipsGroups, key)) { if (Object.hasOwn(chipsGroups, key)) {
const chipsGroup = filterDefined(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; return result;
}; };
export const prettyMarkdown = (markdown: string): string => const block = (text: string) => `${text}\n\n`;
markdown.replace(/[*]/gu, "").replace(/[_]/gu, ""); 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 => const prettyChip = (items: (string | undefined)[]): string =>
items items

View File

@ -16,3 +16,6 @@ export const isUntangibleGroupItem = (
export const getScanArchiveURL = (slug: string): string => export const getScanArchiveURL = (slug: string): string =>
`${process.env.NEXT_PUBLIC_URL_ASSETS}/library/scans/${slug}.zip`; `${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 { UploadImageFragment } from "graphql/generated";
import { useFormat } from "hooks/useFormat"; import { useFormat } from "hooks/useFormat";
const DEFAULT_OG_THUMBNAIL = { const DEFAULT_OG_THUMBNAIL: OgImage = {
image: `${process.env.NEXT_PUBLIC_URL_SELF}/default_og.jpg`, image: `${process.env.NEXT_PUBLIC_URL_SELF}/default_og.jpg`,
width: 1200, width: 1200,
height: 630, height: 630,
@ -24,14 +24,26 @@ export const getOpenGraph = (
format: ReturnType<typeof getFormat>["format"] | ReturnType<typeof useFormat>["format"], format: ReturnType<typeof getFormat>["format"] | ReturnType<typeof useFormat>["format"],
title?: string | null | undefined, title?: string | null | undefined,
description?: string | null | undefined, description?: string | null | undefined,
thumbnail?: UploadImageFragment | null | undefined thumbnail?: UploadImageFragment | string | null | undefined
): OpenGraph => ({ ): OpenGraph => ({
title: `${TITLE_PREFIX}${isDefinedAndNotEmpty(title) ? `${TITLE_SEPARATOR}${title}` : ""}`, 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, 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); const imgSize = getImgSizesByQuality(image.width ?? 0, image.height ?? 0, ImageQuality.Og);
return { return {
image: getAssetURL(image.url, ImageQuality.Og), image: getAssetURL(image.url, ImageQuality.Og),

View File

@ -14,12 +14,15 @@ import { GetVideoQuery } from "graphql/generated";
import { getReadySdk } from "graphql/sdk"; import { getReadySdk } from "graphql/sdk";
import { prettyDate, prettyShortenNumber } from "helpers/formatters"; import { prettyDate, prettyShortenNumber } from "helpers/formatters";
import { filterHasAttributes, isDefined } from "helpers/asserts"; import { filterHasAttributes, isDefined } from "helpers/asserts";
import { getVideoFile } from "helpers/videos"; import { getVideoFile, getVideoThumbnailURL } from "helpers/videos";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter, useAtomSetter } from "helpers/atoms"; import { useAtomGetter, useAtomSetter } from "helpers/atoms";
import { useFormat } from "hooks/useFormat"; import { useFormat } from "hooks/useFormat";
import { getFormat } from "helpers/i18n"; 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 className="grid place-items-center gap-12">
<div id="video" className="w-full overflow-hidden rounded-xl shadow-xl shadow-shade/80"> <div id="video" className="w-full overflow-hidden rounded-xl shadow-xl shadow-shade/80">
{video.gone ? ( {video.gone ? (
<video className="w-full" src={getVideoFile(video.uid)} controls /> <VideoPlayer className="w-full" src={getVideoFile(video.uid)} rounded={false} />
) : ( ) : (
<iframe <iframe
src={`https://www.youtube-nocookie.com/embed/${video.uid}`} 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"> <InsetBox id="description" className="grid place-items-center">
<div className="grid w-[clamp(0px,100%,42rem)] place-items-center gap-8"> <div className="grid w-[clamp(0px,100%,42rem)] place-items-center gap-8">
<h2 className="text-2xl">{format("description")}</h2> <h2 className="text-2xl">{format("description")}</h2>
<p className="whitespace-pre-line">{video.description}</p> <Markdown className="whitespace-pre-line" text={video.description} />
</div> </div>
</InsetBox> </InsetBox>
</div> </div>
@ -158,7 +161,14 @@ export const getStaticProps: GetStaticProps = async (context) => {
const props: Props = { const props: Props = {
video: videos.videos.data[0].attributes, 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 { return {
props: props, props: props,

View File

@ -1,26 +1,18 @@
import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next"; import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { Fragment, useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Collapsible from "react-collapsible"; import Collapsible from "react-collapsible";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Chip } from "components/Chip";
import { PreviewCardCTAs } from "components/Library/PreviewCardCTAs"; import { PreviewCardCTAs } from "components/Library/PreviewCardCTAs";
import { getTocFromMarkdawn, Markdawn, TableOfContents } from "components/Markdown/Markdawn"; import { getTocFromMarkdawn, Markdawn, TableOfContents } from "components/Markdown/Markdawn";
import { TranslatedReturnButton } from "components/PanelComponents/ReturnButton"; import { TranslatedReturnButton } from "components/PanelComponents/ReturnButton";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { PreviewCard, TranslatedPreviewCard } from "components/PreviewCard"; import { PreviewCard, TranslatedPreviewCard } from "components/PreviewCard";
import { RecorderChip } from "components/RecorderChip";
import { ThumbnailHeader } from "components/ThumbnailHeader"; import { ThumbnailHeader } from "components/ThumbnailHeader";
import { ToolTip } from "components/ToolTip";
import { getReadySdk } from "graphql/sdk"; import { getReadySdk } from "graphql/sdk";
import { import { prettyInlineTitle, prettyItemSubType, prettySlug } from "helpers/formatters";
prettyInlineTitle,
prettyLanguage,
prettyItemSubType,
prettySlug,
} from "helpers/formatters";
import { isUntangibleGroupItem } from "helpers/libraryItem"; import { isUntangibleGroupItem } from "helpers/libraryItem";
import { filterHasAttributes, isDefinedAndNotEmpty } from "helpers/asserts"; import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { ContentWithTranslations } from "types/types"; import { ContentWithTranslations } from "types/types";
import { useScrollTopOnChange } from "hooks/useScrollTopOnChange"; import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
@ -36,12 +28,18 @@ import { getFormat } from "helpers/i18n";
import { ElementsSeparator } from "helpers/component"; import { ElementsSeparator } from "helpers/component";
import { RelatedContentPreviewFragment } from "graphql/generated"; import { RelatedContentPreviewFragment } from "graphql/generated";
import { Button } from "components/Inputs/Button"; 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 * PAGE
*/ */
type SetType = "audio_set" | "text_set" | "video_set";
interface Props extends AppLayoutRequired { interface Props extends AppLayoutRequired {
content: ContentWithTranslations; content: ContentWithTranslations;
} }
@ -49,9 +47,7 @@ interface Props extends AppLayoutRequired {
const Content = ({ content, ...otherProps }: Props): JSX.Element => { const Content = ({ content, ...otherProps }: Props): JSX.Element => {
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened); const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout); const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const { format } = useFormat();
const { format, formatStatusDescription } = useFormat();
const languages = useAtomGetter(atoms.localData.languages);
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({ const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
items: content.translations, items: content.translations,
@ -63,6 +59,20 @@ const Content = ({ content, ...otherProps }: Props): JSX.Element => {
}); });
useScrollTopOnChange(Ids.ContentPanel, [selectedTranslation]); 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 = { const returnButtonProps = {
href: content.folder?.data?.attributes 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 = ( const subPanel = (
<SubPanel> <SubPanel>
<ElementsSeparator> <ElementsSeparator>
{[ {[
!is1ColumnLayout && <TranslatedReturnButton {...returnButtonProps} />, !is1ColumnLayout && <TranslatedReturnButton {...returnButtonProps} />,
selectedTranslation?.text_set?.source_language?.data?.attributes?.code !== undefined && ( selectedSetType === "text_set" ? (
<div className="grid gap-5"> <Credits
<h2 className="text-xl"> key="credits"
{selectedTranslation.text_set.source_language.data.attributes.code === languageCode={selectedTranslation?.language?.data?.attributes?.code}
selectedTranslation.language?.data?.attributes?.code sourceLanguageCode={
? format("transcript_notice") selectedTranslation?.text_set?.source_language?.data?.attributes?.code
: format("translation_notice")} }
</h2> status={selectedTranslation?.text_set?.status}
transcribers={selectedTranslation?.text_set?.transcribers?.data}
{selectedTranslation.text_set.source_language.data.attributes.code !== translators={selectedTranslation?.text_set?.translators?.data}
selectedTranslation.language?.data?.attributes?.code && ( proofreaders={selectedTranslation?.text_set?.proofreaders?.data}
<div className="grid place-items-center gap-2"> notes={selectedTranslation?.text_set?.notes}
<p className="font-headers font-bold">{format("source_language")}:</p> />
<Chip ) : selectedSetType === "audio_set" ? (
text={prettyLanguage( <Credits
selectedTranslation.text_set.source_language.data.attributes.code, key="credits"
languages languageCode={selectedTranslation?.language?.data?.attributes?.code}
)} sourceLanguageCode={
/> selectedTranslation?.audio_set?.source_language?.data?.attributes?.code
</div> }
)} status={selectedTranslation?.audio_set?.status}
dubbers={selectedTranslation?.audio_set?.dubbers?.data}
<div className="grid grid-flow-col place-content-center place-items-center gap-2"> notes={selectedTranslation?.audio_set?.notes}
<p className="font-headers font-bold">{format("status")}:</p> />
) : (
<ToolTip selectedSetType === "video_set" && (
content={formatStatusDescription(selectedTranslation.text_set.status)} <Credits
maxWidth={"20rem"}> key="credits"
<Chip text={selectedTranslation.text_set.status} /> languageCode={selectedTranslation?.language?.data?.attributes?.code}
</ToolTip> sourceLanguageCode={
</div> selectedTranslation?.video_set?.source_language?.data?.attributes?.code
}
{selectedTranslation.text_set.transcribers && status={selectedTranslation?.video_set?.status}
selectedTranslation.text_set.transcribers.data.length > 0 && ( subbers={selectedTranslation?.video_set?.subbers?.data}
<div> notes={selectedTranslation?.video_set?.notes}
<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>
), ),
toc && <TableOfContents toc={toc} onContentClicked={() => setSubPanelOpened(false)} />, toc && <TableOfContents toc={toc} onContentClicked={() => setSubPanelOpened(false)} />,
@ -254,24 +233,35 @@ const Content = ({ content, ...otherProps }: Props): JSX.Element => {
/> />
<div className="grid place-items-center"> <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 <div key="thumbnailHeader" className="grid place-items-center gap-6">
key="thumbnailHeader" <ThumbnailHeader
className="max-w-2xl" className="max-w-2xl"
thumbnail={content.thumbnail?.data?.attributes} thumbnail={content.thumbnail?.data?.attributes}
pre_title={selectedTranslation?.pre_title} pre_title={selectedTranslation?.pre_title}
title={selectedTranslation?.title} title={selectedTranslation?.title}
subtitle={selectedTranslation?.subtitle} subtitle={selectedTranslation?.subtitle}
description={selectedTranslation?.description} description={selectedTranslation?.description}
type={content.type} type={content.type}
categories={content.categories} categories={content.categories}
languageSwitcher={ languageSwitcher={
languageSwitcherProps.locales.size > 1 ? ( languageSwitcherProps.locales.size > 1 ? (
<LanguageSwitcher {...languageSwitcherProps} /> <LanguageSwitcher {...languageSwitcherProps} />
) : undefined ) : undefined
} }
/>, />
{setTypeSelectorProps.filter((button) => button.visible).length > 1 && (
<ButtonGroup buttonsProps={setTypeSelectorProps} />
)}
</div>,
content.previous_contents?.data && content.previous_contents.data.length > 0 && ( content.previous_contents?.data && content.previous_contents.data.length > 0 && (
<RelatedContentsSection <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} /> <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 && ( 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), preferredLanguages: getDefaultPreferredLanguages(context.locale, context.locales),
}); });
if (selectedTranslation) { if (selectedTranslation) {
const rawDescription = isDefinedAndNotEmpty(selectedTranslation.description)
? selectedTranslation.description
: selectedTranslation.text_set?.text;
return { return {
title: prettyInlineTitle( title: prettyInlineTitle(
selectedTranslation.pre_title, selectedTranslation.pre_title,
selectedTranslation.title, selectedTranslation.title,
selectedTranslation.subtitle selectedTranslation.subtitle
), ),
description: getDescription(selectedTranslation.description, { description: getDescription(rawDescription, {
[format("type", { count: Infinity })]: [ [format("type", { count: Infinity })]: [
content.contents.data[0].attributes.type?.data?.attributes?.titles?.[0]?.title, 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 { getOpenGraph } from "helpers/openGraph";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { Switch } from "components/Inputs/Switch"; import { Switch } from "components/Inputs/Switch";
import { TextInput } from "components/Inputs/TextInput"; import { TextInput } from "components/Inputs/TextInput";
@ -17,6 +17,7 @@ import { PreviewCard } from "components/PreviewCard";
import { ChroniclePreview } from "components/Chronicles/ChroniclePreview"; import { ChroniclePreview } from "components/Chronicles/ChroniclePreview";
import { PreviewFolder } from "components/Contents/PreviewFolder"; import { PreviewFolder } from "components/Contents/PreviewFolder";
import { getFormat } from "helpers/i18n"; 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 [textInputState, setTextInputState] = useState("");
const [textAreaState, setTextAreaState] = useState(""); const [textAreaState, setTextAreaState] = useState("");
const [buttonGroupState, setButtonGroupState] = useState(0); const [buttonGroupState, setButtonGroupState] = useState(0);
const [verticalButtonGroupState, setVerticalButtonGroupState] = useState(0);
const contentPanel = ( const contentPanel = (
<ContentPanel <ContentPanel
@ -190,7 +192,7 @@ const DesignSystem = (props: Props): JSX.Element => {
</div> </div>
<HorizontalLine /> <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"> <div className="grid grid-cols-[repeat(4,auto)] place-content-center gap-4">
<p className="self-center justify-self-start">Normal</p> <p className="self-center justify-self-start">Normal</p>
@ -215,12 +217,22 @@ const DesignSystem = (props: Props): JSX.Element => {
</div> </div>
<HorizontalLine /> <HorizontalLine />
<h3 className="text-xl">Groups</h3> <h3 className="-mt-6 text-xl">Groups</h3>
<div className="grid place-items-center gap-4"> <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" }]} />
<ButtonGroup
buttonsProps={[{ icon: "call_end" }, { icon: "zoom_in_map" }]}
size="small"
/>
<ButtonGroup <ButtonGroup
buttonsProps={[{ icon: "car_crash" }, { icon: "timelapse" }, { icon: "leak_add" }]} buttonsProps={[{ icon: "car_crash" }, { icon: "timelapse" }, { icon: "leak_add" }]}
/> />
<ButtonGroup
buttonsProps={[{ icon: "car_crash" }, { icon: "timelapse" }, { icon: "leak_add" }]}
size="small"
/>
<ButtonGroup <ButtonGroup
buttonsProps={[ buttonsProps={[
{ icon: "car_crash" }, { icon: "car_crash" },
@ -229,6 +241,40 @@ const DesignSystem = (props: Props): JSX.Element => {
{ icon: "cable" }, { 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 <ButtonGroup
buttonsProps={[ buttonsProps={[
{ {
@ -253,6 +299,102 @@ const DesignSystem = (props: Props): JSX.Element => {
onClick: () => setButtonGroupState(3), 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> </div>
</TwoThemedSection> </TwoThemedSection>
@ -814,6 +956,28 @@ const DesignSystem = (props: Props): JSX.Element => {
<PreviewFolder href="#" title="Disabled, with a longer title" disabled /> <PreviewFolder href="#" title="Disabled, with a longer title" disabled />
</div> </div>
</TwoThemedSection> </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> </ContentPanel>
); );
return <AppLayout {...props} contentPanel={contentPanel} />; return <AppLayout {...props} contentPanel={contentPanel} />;
@ -844,10 +1008,15 @@ export const getStaticProps: GetStaticProps = (context) => {
interface ThemedSectionProps { interface ThemedSectionProps {
className?: string; className?: string;
children?: ReactNode; children?: ReactNode;
fullWidth?: boolean;
} }
const TwoThemedSection = ({ children, className }: ThemedSectionProps) => ( const TwoThemedSection = ({ children, className, fullWidth }: ThemedSectionProps) => (
<div className="mb-12 grid grid-flow-col drop-shadow-lg shadow-shade"> <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)}> <LightThemeSection className={cJoin("rounded-l-xl text-black", className)}>
{children} {children}
</LightThemeSection> </LightThemeSection>

View File

@ -38,7 +38,7 @@ import {
isDefinedAndNotEmpty, isDefinedAndNotEmpty,
} from "helpers/asserts"; } from "helpers/asserts";
import { useScrollTopOnChange } from "hooks/useScrollTopOnChange"; import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { getScanArchiveURL, isUntangibleGroupItem } from "helpers/libraryItem"; import { getScanArchiveURL, getTrackURL, isUntangibleGroupItem } from "helpers/libraryItem";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { WithLabel } from "components/Inputs/WithLabel"; import { WithLabel } from "components/Inputs/WithLabel";
import { cJoin, cIf } from "helpers/className"; import { cJoin, cIf } from "helpers/className";
@ -53,6 +53,7 @@ import { useFormat } from "hooks/useFormat";
import { getFormat } from "helpers/i18n"; import { getFormat } from "helpers/i18n";
import { ElementsSeparator } from "helpers/component"; import { ElementsSeparator } from "helpers/component";
import { ToolTip } from "components/ToolTip"; 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 { interface Props extends AppLayoutRequired {
item: NonNullable<NonNullable<GetLibraryItemQuery["libraryItems"]>["data"][number]["attributes"]>; item: NonNullable<NonNullable<GetLibraryItemQuery["libraryItems"]>["data"][number]["attributes"]>;
itemId: NonNullable<GetLibraryItemQuery["libraryItems"]>["data"][number]["id"]; 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 currency = useAtomGetter(atoms.settings.currency);
const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled); const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled);
const { format, formatLibraryItemType } = useFormat(); const { format, formatLibraryItemType } = useFormat();
@ -79,6 +92,7 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
const isContentPanelAtLeast3xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast3xl); const isContentPanelAtLeast3xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast3xl);
const isContentPanelAtLeastSm = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastSm); const isContentPanelAtLeastSm = useAtomGetter(atoms.containerQueries.isContentPanelAtLeastSm);
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
const hoverable = useDeviceSupportsHover(); const hoverable = useDeviceSupportsHover();
const router = useRouter(); const router = useRouter();
@ -91,19 +105,6 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
useScrollTopOnChange(Ids.ContentPanel, [itemId]); useScrollTopOnChange(Ids.ContentPanel, [itemId]);
const currentIntersection = useIntersectionList(intersectionIds); 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 = ( const subPanel = (
<SubPanel> <SubPanel>
<ElementsSeparator> <ElementsSeparator>
@ -157,7 +158,7 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
/> />
)} )}
{item.contents && item.contents.data.length > 0 && ( {hasContentSection && (
<NavOption <NavOption
title={format("contents")} title={format("contents")}
url={`#${intersectionIds[4]}`} url={`#${intersectionIds[4]}`}
@ -546,6 +547,9 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
)} )}
</div> </div>
<div className="grid w-full gap-4"> <div className="grid w-full gap-4">
{tracks.map(({ id, title, src }) => (
<AudioPlayer key={id} src={src} title={title} />
))}
<div <div
className={cJoin( className={cJoin(
"grid items-center", "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 = { const props: Props = {
item: item.libraryItems.data[0].attributes, item: item.libraryItems.data[0].attributes,
itemId: item.libraryItems.data[0].id, itemId: item.libraryItems.data[0].id,
tracks,
openGraph: getOpenGraph(format, title, description, thumbnail?.data?.attributes), 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 { return {
props: props, props: props,

View File

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

View File

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

View File

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