From 146c9b1eb2c1025574047549a56c712d27014395 Mon Sep 17 00:00:00 2001 From: James Keezer <125431058+Jamesllllllllll@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:26:35 -0400 Subject: [PATCH] feat: add website internationalization --- drizzle/0022_user_preferred_locale.sql | 1 + package-lock.json | 159 ++++- package.json | 5 + scripts/check-i18n-coverage.mjs | 174 ++++++ src/app.css | 24 +- src/components/channel-community-panel.tsx | 166 +++-- src/components/channel-rules-panel.tsx | 78 +-- src/components/language-picker.tsx | 40 ++ src/components/overlay-settings-panel.tsx | 127 ++-- .../playlist-management-surface.tsx | 437 +++++++------ src/components/public-played-history-card.tsx | 81 ++- src/components/song-search-panel.tsx | 241 ++++---- src/components/ui/select.tsx | 2 +- src/lib/auth/session.server.ts | 13 + src/lib/bot-status.ts | 32 +- src/lib/db/latest-migration.generated.ts | 2 +- src/lib/db/repositories.ts | 15 + src/lib/db/schema.ts | 1 + src/lib/i18n/client.tsx | 97 +++ src/lib/i18n/config.ts | 14 + src/lib/i18n/detect.ts | 63 ++ src/lib/i18n/format.ts | 17 + src/lib/i18n/get-initial-locale.server.ts | 7 + src/lib/i18n/get-initial-locale.ts | 13 + src/lib/i18n/init.ts | 53 ++ src/lib/i18n/locales.ts | 38 ++ src/lib/i18n/metadata.ts | 41 ++ src/lib/i18n/resources.ts | 59 ++ src/lib/i18n/resources/en/admin.json | 126 ++++ src/lib/i18n/resources/en/common.json | 19 + src/lib/i18n/resources/en/dashboard.json | 314 ++++++++++ src/lib/i18n/resources/en/home.json | 45 ++ src/lib/i18n/resources/en/playlist.json | 370 +++++++++++ src/lib/i18n/resources/en/search.json | 79 +++ src/lib/i18n/resources/es/admin.json | 126 ++++ src/lib/i18n/resources/es/common.json | 19 + src/lib/i18n/resources/es/dashboard.json | 314 ++++++++++ src/lib/i18n/resources/es/home.json | 45 ++ src/lib/i18n/resources/es/playlist.json | 370 +++++++++++ src/lib/i18n/resources/es/search.json | 79 +++ src/lib/i18n/resources/fr/admin.json | 126 ++++ src/lib/i18n/resources/fr/common.json | 19 + src/lib/i18n/resources/fr/dashboard.json | 314 ++++++++++ src/lib/i18n/resources/fr/home.json | 45 ++ src/lib/i18n/resources/fr/playlist.json | 370 +++++++++++ src/lib/i18n/resources/fr/search.json | 79 +++ src/lib/i18n/resources/pt-br/admin.json | 126 ++++ src/lib/i18n/resources/pt-br/common.json | 19 + src/lib/i18n/resources/pt-br/dashboard.json | 314 ++++++++++ src/lib/i18n/resources/pt-br/home.json | 45 ++ src/lib/i18n/resources/pt-br/playlist.json | 370 +++++++++++ src/lib/i18n/resources/pt-br/search.json | 79 +++ src/lib/i18n/server.ts | 14 + src/lib/playlist/management-display.ts | 8 +- src/lib/server/dashboard-settings.ts | 2 +- src/lib/server/search.ts | 2 +- src/lib/server/viewer-session-data.ts | 76 +++ src/lib/server/viewer.ts | 47 +- src/lib/viewer-session-query.ts | 5 + src/routes/$slug/index.tsx | 379 +++++++----- src/routes/__root.tsx | 72 ++- src/routes/api/session.ts | 38 +- src/routes/api/session/locale.ts | 42 ++ src/routes/channel/$slug.tsx | 17 +- src/routes/dashboard/admin.tsx | 530 ++++++++-------- src/routes/dashboard/index.tsx | 110 ++-- src/routes/dashboard/route.tsx | 40 +- src/routes/dashboard/settings.tsx | 582 +++++++++++------- src/routes/index.tsx | 192 ++++-- src/routes/search.tsx | 22 +- tests/i18n.locale.test.ts | 144 +++++ 71 files changed, 6790 insertions(+), 1344 deletions(-) create mode 100644 drizzle/0022_user_preferred_locale.sql create mode 100644 scripts/check-i18n-coverage.mjs create mode 100644 src/components/language-picker.tsx create mode 100644 src/lib/i18n/client.tsx create mode 100644 src/lib/i18n/config.ts create mode 100644 src/lib/i18n/detect.ts create mode 100644 src/lib/i18n/format.ts create mode 100644 src/lib/i18n/get-initial-locale.server.ts create mode 100644 src/lib/i18n/get-initial-locale.ts create mode 100644 src/lib/i18n/init.ts create mode 100644 src/lib/i18n/locales.ts create mode 100644 src/lib/i18n/metadata.ts create mode 100644 src/lib/i18n/resources.ts create mode 100644 src/lib/i18n/resources/en/admin.json create mode 100644 src/lib/i18n/resources/en/common.json create mode 100644 src/lib/i18n/resources/en/dashboard.json create mode 100644 src/lib/i18n/resources/en/home.json create mode 100644 src/lib/i18n/resources/en/playlist.json create mode 100644 src/lib/i18n/resources/en/search.json create mode 100644 src/lib/i18n/resources/es/admin.json create mode 100644 src/lib/i18n/resources/es/common.json create mode 100644 src/lib/i18n/resources/es/dashboard.json create mode 100644 src/lib/i18n/resources/es/home.json create mode 100644 src/lib/i18n/resources/es/playlist.json create mode 100644 src/lib/i18n/resources/es/search.json create mode 100644 src/lib/i18n/resources/fr/admin.json create mode 100644 src/lib/i18n/resources/fr/common.json create mode 100644 src/lib/i18n/resources/fr/dashboard.json create mode 100644 src/lib/i18n/resources/fr/home.json create mode 100644 src/lib/i18n/resources/fr/playlist.json create mode 100644 src/lib/i18n/resources/fr/search.json create mode 100644 src/lib/i18n/resources/pt-br/admin.json create mode 100644 src/lib/i18n/resources/pt-br/common.json create mode 100644 src/lib/i18n/resources/pt-br/dashboard.json create mode 100644 src/lib/i18n/resources/pt-br/home.json create mode 100644 src/lib/i18n/resources/pt-br/playlist.json create mode 100644 src/lib/i18n/resources/pt-br/search.json create mode 100644 src/lib/i18n/server.ts create mode 100644 src/lib/server/viewer-session-data.ts create mode 100644 src/lib/viewer-session-query.ts create mode 100644 src/routes/api/session/locale.ts create mode 100644 tests/i18n.locale.test.ts diff --git a/drizzle/0022_user_preferred_locale.sql b/drizzle/0022_user_preferred_locale.sql new file mode 100644 index 0000000..9732e5f --- /dev/null +++ b/drizzle/0022_user_preferred_locale.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD `preferred_locale` text; diff --git a/package-lock.json b/package-lock.json index 0eaf402..c8d1c3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,12 +29,15 @@ "clsx": "latest", "cmdk": "^1.1.1", "drizzle-orm": "latest", + "i18next": "^26.0.3", + "i18next-icu": "^2.4.3", "isbot": "latest", "lucide-react": "^0.577.0", "motion": "^12.37.0", "radix-ui": "^1.4.3", "react": "latest", "react-dom": "latest", + "react-i18next": "^17.0.2", "tailwind-merge": "latest", "zod": "latest" }, @@ -1638,6 +1641,63 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@formatjs/bigdecimal": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.0.tgz", + "integrity": "sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==", + "license": "MIT", + "peer": true + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.2.0.tgz", + "integrity": "sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@formatjs/bigdecimal": "0.2.0", + "@formatjs/fast-memoize": "3.1.1", + "@formatjs/intl-localematcher": "0.8.2" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.1.tgz", + "integrity": "sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==", + "license": "MIT", + "peer": true + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.3.tgz", + "integrity": "sha512-HJWZ9S6JWey6iY5+YXE3Kd0ofWU1sC2KTTp56e1168g/xxWvVvr8k9G4fexIgwYV9wbtjY7kGYK5FjoWB3B2OQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "3.2.0", + "@formatjs/icu-skeleton-parser": "2.1.3" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.3.tgz", + "integrity": "sha512-9mFp8TJ166ZM2pcjKwsBWXrDnOJGT7vMEScVgLygUODPOsE8S6f/FHoacvrlHK1B4dYZk8vSCNruyPU64AfgJQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "3.2.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.2.tgz", + "integrity": "sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@formatjs/fast-memoize": "3.1.1" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -8005,6 +8065,15 @@ } } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -8052,6 +8121,46 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "26.0.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz", + "integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-icu": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/i18next-icu/-/i18next-icu-2.4.3.tgz", + "integrity": "sha512-Clb5XCp416Z+BkJUTATCjmDcw2AFzSUDVLxLVK/KhtXP6TJQHrht+6MqoJU1hCpyoCBKe59wMO9pvCvYroNcKg==", + "license": "MIT", + "peerDependencies": { + "intl-messageformat": ">=10.3.3 <12.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -8064,6 +8173,18 @@ "node": ">=0.10.0" } }, + "node_modules/intl-messageformat": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.0.tgz", + "integrity": "sha512-IhghAA8n4KSlXuWKzYsWyWb82JoYTzShfyvdSF85oJPnNOjvv4kAo7S7Jtkm3/vJ53C7dQNRO+Gpnj3iWgTjBQ==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "3.2.0", + "@formatjs/fast-memoize": "3.1.1", + "@formatjs/icu-messageformat-parser": "3.5.3" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -9054,6 +9175,33 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz", + "integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -10104,7 +10252,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10874,6 +11022,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", diff --git a/package.json b/package.json index c4670d7..67d6b9d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "db:bootstrap:remote": "node scripts/run-remote-operation.mjs db:bootstrap:remote", "check:staged": "lint-staged", "check:generated": "node scripts/generate-route-tree.mjs --check && node scripts/write-latest-migration.mjs --check", + "check:i18n": "node scripts/check-i18n-coverage.mjs", "typecheck:ci": "tsc --noEmit", "test:ci": "vitest run --config vitest.config.ts", "check:prepush": "npm run check:generated && npm run typecheck:ci && npm run test:ci", @@ -62,6 +63,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@sentry/cloudflare": "^10.45.0", + "@sentry/core": "^10.45.0", "@tanstack/react-form": "latest", "@tanstack/react-query": "latest", "@tanstack/react-query-devtools": "latest", @@ -74,12 +76,15 @@ "clsx": "latest", "cmdk": "^1.1.1", "drizzle-orm": "latest", + "i18next": "^26.0.3", + "i18next-icu": "^2.4.3", "isbot": "latest", "lucide-react": "^0.577.0", "motion": "^12.37.0", "radix-ui": "^1.4.3", "react": "latest", "react-dom": "latest", + "react-i18next": "^17.0.2", "tailwind-merge": "latest", "zod": "latest" }, diff --git a/scripts/check-i18n-coverage.mjs b/scripts/check-i18n-coverage.mjs new file mode 100644 index 0000000..92a317f --- /dev/null +++ b/scripts/check-i18n-coverage.mjs @@ -0,0 +1,174 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +const resourcesRoot = path.resolve("src/lib/i18n/resources"); +const baseLocale = "en"; + +async function collectJsonFiles(directory, prefix = "") { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + const absolutePath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + files.push(...(await collectJsonFiles(absolutePath, relativePath))); + continue; + } + + if (entry.isFile() && entry.name.endsWith(".json")) { + files.push(relativePath); + } + } + + return files.sort(); +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function describeValueType(value) { + if (Array.isArray(value)) { + return "array"; + } + + return value === null ? "null" : typeof value; +} + +function compareTranslationTrees(input) { + const { + baseValue, + targetValue, + locale, + namespacePath, + failures, + } = input; + + if (isPlainObject(baseValue)) { + if (!isPlainObject(targetValue)) { + failures.push( + `[${locale}] ${namespacePath || ""} should be an object, got ${describeValueType(targetValue)}` + ); + return; + } + + const baseKeys = Object.keys(baseValue).sort(); + const targetKeys = new Set(Object.keys(targetValue)); + + for (const key of baseKeys) { + const childPath = namespacePath ? `${namespacePath}.${key}` : key; + if (!(key in targetValue)) { + failures.push(`[${locale}] Missing key: ${childPath}`); + continue; + } + + compareTranslationTrees({ + baseValue: baseValue[key], + targetValue: targetValue[key], + locale, + namespacePath: childPath, + failures, + }); + + targetKeys.delete(key); + } + + for (const extraKey of [...targetKeys].sort()) { + const childPath = namespacePath ? `${namespacePath}.${extraKey}` : extraKey; + failures.push(`[${locale}] Extra key: ${childPath}`); + } + + return; + } + + if (Array.isArray(baseValue)) { + if (!Array.isArray(targetValue)) { + failures.push( + `[${locale}] ${namespacePath || ""} should be an array, got ${describeValueType(targetValue)}` + ); + } + return; + } + + const baseType = describeValueType(baseValue); + const targetType = describeValueType(targetValue); + + if (baseType !== targetType) { + failures.push( + `[${locale}] ${namespacePath || ""} should be ${baseType}, got ${targetType}` + ); + } +} + +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, "utf8")); +} + +async function main() { + const localeEntries = await readdir(resourcesRoot, { withFileTypes: true }); + const localeDirectories = localeEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + const baseDirectory = path.join(resourcesRoot, baseLocale); + + if (!localeDirectories.includes(baseLocale)) { + throw new Error(`Base locale directory "${baseLocale}" was not found.`); + } + + const baseFiles = await collectJsonFiles(baseDirectory); + const failures = []; + + for (const locale of localeDirectories) { + if (locale === baseLocale) { + continue; + } + + const localeDirectory = path.join(resourcesRoot, locale); + const localeFiles = await collectJsonFiles(localeDirectory); + const localeFileSet = new Set(localeFiles); + + for (const baseFile of baseFiles) { + if (!localeFileSet.has(baseFile)) { + failures.push(`[${locale}] Missing file: ${baseFile}`); + continue; + } + + const [baseJson, localeJson] = await Promise.all([ + readJson(path.join(baseDirectory, baseFile)), + readJson(path.join(localeDirectory, baseFile)), + ]); + + compareTranslationTrees({ + baseValue: baseJson, + targetValue: localeJson, + locale, + namespacePath: path.basename(baseFile, ".json"), + failures, + }); + + localeFileSet.delete(baseFile); + } + + for (const extraFile of [...localeFileSet].sort()) { + failures.push(`[${locale}] Extra file: ${extraFile}`); + } + } + + if (failures.length > 0) { + console.error("i18n coverage check failed:\n"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exitCode = 1; + return; + } + + console.log( + `i18n coverage check passed for ${localeDirectories.length} locales across ${baseFiles.length} namespace files.` + ); +} + +await main(); diff --git a/src/app.css b/src/app.css index 7f45747..556e3ee 100644 --- a/src/app.css +++ b/src/app.css @@ -95,6 +95,25 @@ body.overlay-mode { background: transparent; } +/* + Radix Select uses react-remove-scroll, which adds a body margin-right when it + hides the scrollbar. The app already reserves scrollbar space with + `scrollbar-gutter: stable`, so that extra compensation shifts centered + content slightly left when simple selects open. +*/ +html body[data-scroll-locked] { + margin-right: 0 !important; + --removed-body-scroll-bar-size: 0px; +} + +html body[data-scroll-locked] .width-before-scroll-bar { + margin-right: 0 !important; +} + +html body[data-scroll-locked] .right-scroll-bar-position { + right: 0 !important; +} + body::before { content: ""; position: absolute; @@ -114,7 +133,10 @@ body::before { html::after { content: ""; position: fixed; - inset: 0; + top: 0; + right: var(--removed-body-scroll-bar-size, 0px); + bottom: 0; + left: 0; z-index: -1; pointer-events: none; opacity: 0.22; diff --git a/src/components/channel-community-panel.tsx b/src/components/channel-community-panel.tsx index 1d605b4..22a49ae 100644 --- a/src/components/channel-community-panel.tsx +++ b/src/components/channel-community-panel.tsx @@ -5,6 +5,8 @@ import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Input } from "~/components/ui/input"; +import { useAppLocale, useLocaleTranslation } from "~/lib/i18n/client"; +import { formatNumber } from "~/lib/i18n/format"; import { getErrorMessage } from "~/lib/utils"; import { clampVipTokenCount, @@ -49,6 +51,8 @@ export function ChannelCommunityPanel(props: { }>; vipTokens: VipTokenRowData[]; }) { + const { t } = useLocaleTranslation("playlist"); + const { locale } = useAppLocale(); const queryClient = useQueryClient(); const [blockedLookupQuery, setBlockedLookupQuery] = useState(""); const [debouncedBlockedLookupQuery, setDebouncedBlockedLookupQuery] = @@ -163,7 +167,7 @@ export function ChannelCommunityPanel(props: { await mutation.mutateAsync(payload); setPendingVipFocusLogin(normalizedLogin); - setVipTokenNotice("Added 1 token"); + setVipTokenNotice(t("community.vip.noticeAddedSingle")); } catch {} } @@ -199,9 +203,7 @@ export function ChannelCommunityPanel(props: { } | null; if (!response.ok) { - throw new Error( - payload?.error ?? "Unable to update channel community settings." - ); + throw new Error(payload?.error ?? t("community.states.updateFailed")); } return payload; @@ -260,7 +262,7 @@ export function ChannelCommunityPanel(props: { } catch (error) { if (!cancelled) { setBlockedSearchError( - getErrorMessage(error) || "User lookup failed." + getErrorMessage(error) || t("community.states.lookupFailed") ); } } @@ -314,7 +316,9 @@ export function ChannelCommunityPanel(props: { } } catch (error) { if (!cancelled) { - setVipSearchError(getErrorMessage(error) || "User lookup failed."); + setVipSearchError( + getErrorMessage(error) || t("community.states.lookupFailed") + ); } } } @@ -392,7 +396,7 @@ export function ChannelCommunityPanel(props: {

- Moderator controls + {t("community.title")}

@@ -401,7 +405,7 @@ export function ChannelCommunityPanel(props: { {props.canManageBlockedChatters ? ( - Blocked viewers + {t("community.blocks.title")}
@@ -411,7 +415,7 @@ export function ChannelCommunityPanel(props: { setBlockedLookupQuery(event.target.value); setSelectedBlockedUser(null); }} - placeholder="Search Twitch username to block" + placeholder={t("community.blocks.searchPlaceholder")} className="min-w-[16rem] flex-1" />
{blockedLookupQuery.trim().replace(/^@+/, "").length > 0 && blockedLookupQuery.trim().replace(/^@+/, "").length < 4 ? (

- Type at least 4 characters to search Twitch usernames. + {t("community.blocks.searchMin")}

) : null} {needsBlockedChatterScopeReconnect ? (

- Reconnect Twitch to prioritize viewers currently in chat. + {t("community.reconnectMessage")}

@@ -466,12 +470,13 @@ export function ChannelCommunityPanel(props: {

{blockedPreferredSource === "chatters" - ? "Current chatters first" - : "Twitch matches"} + ? t("community.currentChattersFirst") + : t("community.twitchMatches")}

- {blockedLookupResults.length} result - {blockedLookupResults.length === 1 ? "" : "s"} + {t("community.resultCount", { + count: blockedLookupResults.length, + })}
{blockedLookupResults.map((user, index) => { @@ -505,11 +510,13 @@ export function ChannelCommunityPanel(props: {
{user.isCurrentChatter ? ( - In chat + {t("community.inChat")} ) : null} {isBlockedSelected ? ( - Selected + + {t("community.selected")} + ) : null}
@@ -518,15 +525,13 @@ export function ChannelCommunityPanel(props: { ) : (

- No matching Twitch usernames. + {t("community.noMatchingUsers")}

)} ) : null}

- Blocked viewers can still talk in Twitch chat, but they cannot - add or edit requests from chat, the website, or the extension - panel. + {t("community.blocks.description")}

{props.blocks.length > 0 ? (
@@ -553,7 +558,7 @@ export function ChannelCommunityPanel(props: {

{block.reason ?? - "Blocked from making requests in this channel."} + t("community.blocks.defaultReason")}

))} ) : ( -

No blocked viewers.

+

+ {t("community.blocks.empty")} +

)}
@@ -582,7 +589,7 @@ export function ChannelCommunityPanel(props: { {props.canViewVipTokens ? ( - VIP tokens + {t("community.vip.title")} {props.canManageVipTokens ? ( @@ -594,7 +601,7 @@ export function ChannelCommunityPanel(props: { setVipLookupQuery(event.target.value); setSelectedVipUser(null); }} - placeholder="Search Twitch username to grant a token" + placeholder={t("community.vip.searchPlaceholder")} className="min-w-[16rem] flex-1" /> {hasShortVipLookupQuery ? (

{hasLocalVipLookupMatches - ? "Existing VIP token holders appear below. Type at least 4 characters to search all Twitch usernames." - : "Type at least 4 characters to search Twitch usernames."} + ? t("community.vip.searchMinExisting") + : t("community.vip.searchMin")}

) : null} {needsVipChatterScopeReconnect ? (

- Reconnect Twitch to prioritize viewers currently in - chat. + {t("community.reconnectMessage")}

@@ -642,16 +648,15 @@ export function ChannelCommunityPanel(props: {

{hasShortVipLookupQuery && hasLocalVipLookupMatches - ? "VIP token holders first" + ? t("community.vip.tokenHoldersFirst") : vipPreferredSource === "chatters" - ? "Current chatters first" - : "Twitch matches"} + ? t("community.currentChattersFirst") + : t("community.twitchMatches")}

- {prioritizedVipLookupResults.length} result - {prioritizedVipLookupResults.length === 1 - ? "" - : "s"} + {t("community.resultCount", { + count: prioritizedVipLookupResults.length, + })} {prioritizedVipLookupResults.map((user, index) => { @@ -691,22 +696,34 @@ export function ChannelCommunityPanel(props: {
{existingVipToken ? ( - {formatVipTokenCount( - existingVipToken.availableCount - )}{" "} - token {existingVipToken.availableCount === 1 - ? "" - : "s"} + ? t( + "community.vip.tokenCountSingle", + { + count: formatVipTokenCount( + existingVipToken.availableCount + ), + } + ) + : t( + "community.vip.tokenCountPlural", + { + count: formatVipTokenCount( + existingVipToken.availableCount + ), + } + )} ) : null} {user.isCurrentChatter ? ( - In chat + {t("community.inChat")} ) : null} {isVipSelected ? ( - Selected + + {t("community.selected")} + ) : null}
@@ -715,7 +732,7 @@ export function ChannelCommunityPanel(props: { ) : (

- No matching Twitch usernames. + {t("community.noMatchingUsers")}

)} @@ -723,8 +740,7 @@ export function ChannelCommunityPanel(props: { ) : (

- You can view VIP balances, but only the broadcaster or an - allowed moderator can change them. + {t("community.vip.viewOnly")}

)}
@@ -745,10 +761,10 @@ export function ChannelCommunityPanel(props: { - Username + {t("community.vip.username")} - Tokens + {t("community.vip.tokens")} @@ -778,8 +794,14 @@ export function ChannelCommunityPanel(props: { {props.vipTokens.length > VIP_TOKEN_PAGE_SIZE ? (

- Showing {vipTokenRangeStart}-{vipTokenRangeEnd} of{" "} - {props.vipTokens.length} + {t("community.vip.showingRange", { + start: formatNumber(locale, vipTokenRangeStart), + end: formatNumber(locale, vipTokenRangeEnd), + total: formatNumber( + locale, + props.vipTokens.length + ), + })}

- Page {vipTokenPage} of {totalVipTokenPages} + {t("community.vip.pageOf", { + page: formatNumber(locale, vipTokenPage), + total: formatNumber(locale, totalVipTokenPages), + })}
@@ -817,7 +842,7 @@ export function ChannelCommunityPanel(props: { ) : (

- No VIP tokens yet. + {t("community.vip.empty")}

)}
@@ -843,6 +868,7 @@ function VipTokenRow(props: { onShowNotice(message: string): void; onSave(input: { login: string; count: number }): Promise; }) { + const { t } = useLocaleTranslation("playlist"); const [draftCount, setDraftCount] = useState( formatVipTokenCount(props.token.availableCount) ); @@ -894,7 +920,7 @@ function VipTokenRow(props: { setHasLocalEdits(false); setSaveState("saved"); if (delta !== 0) { - props.onShowNotice(formatVipTokenDeltaNotice(delta)); + props.onShowNotice(formatVipTokenDeltaNotice(delta, t)); } window.setTimeout(() => { setSaveState((current) => (current === "saved" ? "idle" : current)); @@ -1109,18 +1135,24 @@ function buildPrioritizedVipLookupResults(input: { }); } -function formatVipTokenDeltaNotice(delta: number) { +function formatVipTokenDeltaNotice( + delta: number, + t: (key: string, options?: Record) => string +) { const normalizedDelta = normalizeVipTokenCount(delta); const formattedDelta = formatVipTokenCount(Math.abs(normalizedDelta)); - const tokenLabel = formattedDelta === "1" ? "VIP token" : "VIP tokens"; if (normalizedDelta > 0) { - return `Added ${formattedDelta} ${tokenLabel}`; + return normalizedDelta === 1 + ? t("community.vip.noticeAddedSingle") + : t("community.vip.noticeAddedMultiple", { count: formattedDelta }); } if (normalizedDelta < 0) { - return `Removed ${formattedDelta} ${tokenLabel}`; + return normalizedDelta === -1 + ? t("community.vip.noticeRemovedSingle") + : t("community.vip.noticeRemovedMultiple", { count: formattedDelta }); } - return "Saved VIP tokens"; + return t("community.vip.noticeSaved"); } diff --git a/src/components/channel-rules-panel.tsx b/src/components/channel-rules-panel.tsx index abf6589..8fc2f58 100644 --- a/src/components/channel-rules-panel.tsx +++ b/src/components/channel-rules-panel.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Input } from "~/components/ui/input"; +import { useLocaleTranslation } from "~/lib/i18n/client"; import { getErrorMessage } from "~/lib/utils"; type ArtistMatch = { @@ -54,6 +55,7 @@ export function ChannelRulesPanel(props: { }>; setlistArtists: Array<{ artistId: number; artistName: string }>; }) { + const { t } = useLocaleTranslation("playlist"); const queryClient = useQueryClient(); const [artistQuery, setArtistQuery] = useState(""); const [charterQuery, setCharterQuery] = useState(""); @@ -96,7 +98,7 @@ export function ChannelRulesPanel(props: { } | null; if (!response.ok) { - throw new Error(payload?.error ?? "Unable to update channel rules."); + throw new Error(payload?.error ?? t("rules.states.updateFailed")); } return payload; @@ -148,7 +150,9 @@ export function ChannelRulesPanel(props: { } } catch (error) { if (!cancelled) { - setSearchError(getErrorMessage(error) || "Search failed."); + setSearchError( + getErrorMessage(error) || t("rules.states.searchFailed") + ); } } } @@ -244,8 +248,10 @@ export function ChannelRulesPanel(props: {

{props.channelDisplayName - ? `${props.channelDisplayName}'s channel rules` - : "Channel rules"} + ? t("rules.sectionTitleWithChannel", { + channel: props.channelDisplayName, + }) + : t("rules.sectionTitle")}

@@ -261,14 +267,14 @@ export function ChannelRulesPanel(props: { <> {showBlacklistedArtistsCard ? ( ({ key: `artist-match-${artist.artistId}`, label: artist.artistName, - meta: `${artist.trackCount} tracks`, + meta: t("rules.trackCount", { count: artist.trackCount }), onAdd: () => mutateRules.mutate({ action: "addBlacklistedArtist", @@ -279,7 +285,7 @@ export function ChannelRulesPanel(props: { currentItems={props.artists.map((item) => ({ key: `artist-current-${item.artistId}`, label: item.artistName, - hoverDetail: `Artist ID ${item.artistId}`, + hoverDetail: t("rules.artistId", { id: item.artistId }), onRemove: () => mutateRules.mutate({ action: "removeBlacklistedArtist", @@ -287,7 +293,7 @@ export function ChannelRulesPanel(props: { }), }))} isPending={mutateRules.isPending} - emptyCurrentLabel="No blacklisted artists." + emptyCurrentLabel={t("rules.noBlacklistedArtists")} canManage={props.canManageBlacklist} hideReadOnlyRemoveAction /> @@ -295,14 +301,14 @@ export function ChannelRulesPanel(props: { {showBlacklistedChartersCard ? ( ({ key: `charter-match-${charter.charterId}`, label: charter.charterName, - meta: `${charter.trackCount} tracks`, + meta: t("rules.trackCount", { count: charter.trackCount }), onAdd: () => mutateRules.mutate({ action: "addBlacklistedCharter", @@ -313,7 +319,7 @@ export function ChannelRulesPanel(props: { currentItems={props.charters.map((item) => ({ key: `charter-current-${item.charterId}`, label: item.charterName, - hoverDetail: `Charter ID ${item.charterId}`, + hoverDetail: t("rules.charterId", { id: item.charterId }), onRemove: () => mutateRules.mutate({ action: "removeBlacklistedCharter", @@ -321,7 +327,7 @@ export function ChannelRulesPanel(props: { }), }))} isPending={mutateRules.isPending} - emptyCurrentLabel="No blacklisted charters." + emptyCurrentLabel={t("rules.noBlacklistedCharters")} canManage={props.canManageBlacklist} hideReadOnlyRemoveAction /> @@ -329,16 +335,19 @@ export function ChannelRulesPanel(props: { {showBlacklistedSongsCard ? ( ({ key: `song-group-match-${song.groupedProjectId}`, label: song.artistName ? `${song.songTitle} - ${song.artistName}` : song.songTitle, - meta: `${song.versionCount} version${song.versionCount === 1 ? "" : "s"} - Song group ${song.groupedProjectId}`, + meta: t("rules.versionCount", { + count: song.versionCount, + groupId: song.groupedProjectId, + }), onAdd: () => mutateRules.mutate({ action: "addBlacklistedSongGroup", @@ -353,7 +362,9 @@ export function ChannelRulesPanel(props: { label: item.artistName ? `${item.songTitle} - ${item.artistName}` : item.songTitle, - hoverDetail: `Song group ${item.groupedProjectId}`, + hoverDetail: t("rules.songGroupId", { + groupId: item.groupedProjectId, + }), onRemove: () => mutateRules.mutate({ action: "removeBlacklistedSongGroup", @@ -361,7 +372,7 @@ export function ChannelRulesPanel(props: { }), }))} isPending={mutateRules.isPending} - emptyCurrentLabel="No blacklisted songs." + emptyCurrentLabel={t("rules.noBlacklistedSongs")} canManage={props.canManageBlacklist} hideReadOnlyRemoveAction /> @@ -369,7 +380,7 @@ export function ChannelRulesPanel(props: { {showBlacklistedVersionsCard ? ( {}} placeholder="" @@ -379,7 +390,7 @@ export function ChannelRulesPanel(props: { label: item.artistName ? `${item.songTitle} - ${item.artistName}` : item.songTitle, - hoverDetail: `Version ID ${item.songId}`, + hoverDetail: t("rules.versionId", { id: item.songId }), onRemove: () => mutateRules.mutate({ action: "removeBlacklistedSong", @@ -387,7 +398,7 @@ export function ChannelRulesPanel(props: { }), }))} isPending={mutateRules.isPending} - emptyCurrentLabel="No blacklisted versions." + emptyCurrentLabel={t("rules.noBlacklistedVersions")} canManage={props.canManageBlacklist} showSearch={false} hideReadOnlyRemoveAction @@ -398,14 +409,14 @@ export function ChannelRulesPanel(props: { {showSetlistArtistsCard ? ( ({ key: `setlist-match-${artist.artistId}`, label: artist.artistName, - meta: `${artist.trackCount} tracks`, + meta: t("rules.trackCount", { count: artist.trackCount }), onAdd: () => mutateRules.mutate({ action: "addSetlistArtist", @@ -416,7 +427,7 @@ export function ChannelRulesPanel(props: { currentItems={props.setlistArtists.map((item) => ({ key: `setlist-current-${item.artistId}`, label: item.artistName, - hoverDetail: `Artist ID ${item.artistId}`, + hoverDetail: t("rules.artistId", { id: item.artistId }), onRemove: () => mutateRules.mutate({ action: "removeSetlistArtist", @@ -424,7 +435,7 @@ export function ChannelRulesPanel(props: { }), }))} isPending={mutateRules.isPending} - emptyCurrentLabel="No setlist artists." + emptyCurrentLabel={t("rules.noSetlistArtists")} canManage={props.canManageSetlist} /> ) : null} @@ -463,6 +474,7 @@ function SearchManageCard(props: { showSearch?: boolean; hideReadOnlyRemoveAction?: boolean; }) { + const { t } = useLocaleTranslation("playlist"); const normalizedLength = props.inputValue.trim().length; const canManage = props.canManage !== false; const showSearch = props.showSearch !== false; @@ -482,9 +494,7 @@ function SearchManageCard(props: { placeholder={props.placeholder} /> {normalizedLength > 0 && normalizedLength < 2 ? ( -

- Type at least 2 characters to search. -

+

{t("rules.searchMin")}

) : null} ) : null} @@ -509,14 +519,12 @@ function SearchManageCard(props: { onClick={match.onAdd} disabled={props.isPending} > - Add + {t("rules.add")} )) ) : ( -

- No matching entries to add. -

+

{t("rules.noMatches")}

)} ) : null} @@ -554,7 +562,7 @@ function SearchManageCard(props: { onClick={item.onRemove} disabled={props.isPending || !canManage} > - Remove + {t("rules.remove")} ) : null} diff --git a/src/components/language-picker.tsx b/src/components/language-picker.tsx new file mode 100644 index 0000000..ae828ff --- /dev/null +++ b/src/components/language-picker.tsx @@ -0,0 +1,40 @@ +import { Languages } from "lucide-react"; +import { useAppLocale, useLocaleTranslation } from "~/lib/i18n/client"; +import { type AppLocale, localeOptions } from "~/lib/i18n/locales"; +import { cn } from "~/lib/utils"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; + +export function LanguagePicker(props: { className?: string }) { + const { locale, setLocale, isSavingLocale } = useAppLocale(); + const { t } = useLocaleTranslation("common"); + const selectedLocaleLabel = + localeOptions.find((option) => option.value === locale)?.nativeLabel ?? + localeOptions[0].nativeLabel; + + return ( +
+ +
+ ); +} diff --git a/src/components/overlay-settings-panel.tsx b/src/components/overlay-settings-panel.tsx index 67b3341..2ae6711 100644 --- a/src/components/overlay-settings-panel.tsx +++ b/src/components/overlay-settings-panel.tsx @@ -15,6 +15,7 @@ import { } from "~/components/ui/card"; import { Checkbox } from "~/components/ui/checkbox"; import { Input } from "~/components/ui/input"; +import { useLocaleTranslation } from "~/lib/i18n/client"; import { getErrorMessage, hexToRgba } from "~/lib/utils"; import type { OverlaySettingsInputData } from "~/lib/validation"; @@ -71,9 +72,17 @@ const defaultOverlayForm: OverlaySettingsInputData = { }; export function OverlaySettingsPanel() { + const { t } = useLocaleTranslation("dashboard"); const queryClient = useQueryClient(); - const [form, setForm] = - useState(defaultOverlayForm); + const cachedOverlayData = queryClient.getQueryData([ + "dashboard-overlay", + ]); + const [form, setForm] = useState( + () => cachedOverlayData?.settings ?? defaultOverlayForm + ); + const [hasHydratedForm, setHasHydratedForm] = useState( + () => cachedOverlayData !== undefined + ); const [previewMode, setPreviewMode] = useState<"live" | "sample">("live"); const [previewModeTouched, setPreviewModeTouched] = useState(false); const [showRestoreDialog, setShowRestoreDialog] = useState(false); @@ -92,13 +101,17 @@ export function OverlaySettingsPanel() { if (!response.ok) { throw new Error( body && "message" in body - ? (body.message ?? "Failed to load overlay settings.") - : "Failed to load overlay settings." + ? (body.message ?? t("overlay.states.failedToLoad")) + : t("overlay.states.failedToLoad") ); } return body as OverlaySettingsResponse; }, + staleTime: 5 * 60_000, + gcTime: 10 * 60_000, + refetchOnWindowFocus: false, + placeholderData: (previousData) => previousData, }); const playlistQuery = useQuery({ @@ -119,7 +132,7 @@ export function OverlaySettingsPanel() { .catch(() => null)) as ChannelPlaylistPreviewResponse | null; if (!response.ok) { - throw new Error("Failed to load playlist preview."); + throw new Error(t("overlay.states.failedPreview")); } return { @@ -132,10 +145,16 @@ export function OverlaySettingsPanel() { }); useEffect(() => { - if (overlayQuery.data?.settings) { + if (hasHydratedForm || overlayQuery.data === undefined) { + return; + } + + if (overlayQuery.data.settings) { setForm(overlayQuery.data.settings); } - }, [overlayQuery.data]); + + setHasHydratedForm(true); + }, [hasHydratedForm, overlayQuery.data]); const saveMutation = useMutation({ mutationFn: async () => { @@ -151,9 +170,7 @@ export function OverlaySettingsPanel() { } | null; if (!response.ok) { - throw new Error( - body?.message ?? "Overlay settings could not be saved." - ); + throw new Error(body?.message ?? t("overlay.states.failedToSave")); } return body; @@ -163,7 +180,7 @@ export function OverlaySettingsPanel() { setErrorMessage(null); }, onSuccess: async (payload) => { - setMessage(payload?.message ?? "Overlay settings saved."); + setMessage(payload?.message ?? t("overlay.states.saved")); await queryClient.invalidateQueries({ queryKey: ["dashboard-overlay"] }); }, onError: (error) => { @@ -179,7 +196,8 @@ export function OverlaySettingsPanel() { const savedForm = overlayQuery.data?.settings ?? defaultOverlayForm; const hasUnsavedChanges = JSON.stringify(form) !== JSON.stringify(savedForm); const overlayUrl = overlayQuery.data?.overlayUrl ?? ""; - const channelName = overlayQuery.data?.channel.displayName ?? "Your channel"; + const channelName = + overlayQuery.data?.channel.displayName ?? t("overlay.channelFallback"); const liveItems = playlistQuery.data?.items ?? []; const sampleItems = useMemo( () => [ @@ -247,7 +265,7 @@ export function OverlaySettingsPanel() { } await navigator.clipboard.writeText(overlayUrl); - setMessage("Overlay URL copied."); + setMessage(t("overlay.states.copied")); setErrorMessage(null); } @@ -282,10 +300,8 @@ export function OverlaySettingsPanel() { return ( - Stream overlay - - Display your playlist on your stream using a browser source. - + {t("overlay.title")} + {t("overlay.description")} {message ? {message} : null} @@ -298,7 +314,7 @@ export function OverlaySettingsPanel() {
- Overlay URL + {t("overlay.url.title")} @@ -309,12 +325,12 @@ export function OverlaySettingsPanel() { disabled={!overlayUrl} > - Copy URL + {t("overlay.url.copy")} {overlayUrl ? ( ) : null} @@ -324,21 +340,21 @@ export function OverlaySettingsPanel() { - Layout and behavior + {t("overlay.layout.title")} setBoolean("overlayShowCreator", value)} /> setBoolean("overlayShowAlbum", value)} /> setBoolean("overlayAnimateNowPlaying", value) @@ -349,44 +365,44 @@ export function OverlaySettingsPanel() { - Theme + {t("overlay.theme.title")} setColor("overlayAccentColor", value)} /> setColor("overlayVipColor", value)} /> setColor("overlayTextColor", value)} /> setColor("overlayMutedTextColor", value)} /> setColor("overlayPanelColor", value)} /> setColor("overlayBackgroundColor", value) } /> setColor("overlayBorderColor", value)} /> @@ -395,12 +411,12 @@ export function OverlaySettingsPanel() { - Density and sizing + {t("overlay.sizing.title")} setNumber("overlayCornerRadius", value)} /> setNumber("overlayItemGap", value)} /> setNumber("overlayItemPadding", value)} /> setNumber("overlayTitleFontSize", value)} /> setShowRestoreDialog(true)} > - Restore defaults + {t("overlay.actions.restoreDefaults")}
@@ -460,13 +476,15 @@ export function OverlaySettingsPanel() {
- Preview + {t("overlay.preview.title")}
@@ -513,7 +531,9 @@ export function OverlaySettingsPanel() {
@@ -529,21 +549,20 @@ export function OverlaySettingsPanel() {

- Restore defaults? + {t("overlay.restoreDialog.title")}

- This resets the overlay editor to the default theme. Unsaved - changes will be lost. + {t("overlay.restoreDialog.description")}

@@ -620,13 +639,15 @@ function RangeField(props: { value: number; onChange: (value: number) => void; }) { + const { t } = useLocaleTranslation("dashboard"); + return (

{props.label}

- Value + {t("overlay.sizing.value")} {props.value}
diff --git a/src/components/playlist-management-surface.tsx b/src/components/playlist-management-surface.tsx index 659041c..b6db6f3 100644 --- a/src/components/playlist-management-surface.tsx +++ b/src/components/playlist-management-surface.tsx @@ -11,6 +11,7 @@ import { } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { TFunction } from "i18next"; import { AlertTriangle, ArrowDown, @@ -52,6 +53,11 @@ import { PopoverTitle, PopoverTrigger, } from "~/components/ui/popover"; +import { useAppLocale, useLocaleTranslation } from "~/lib/i18n/client"; +import { + formatDate as formatLocaleDate, + formatNumber, +} from "~/lib/i18n/format"; import { formatPlaylistItemSummaryLine, getResolvedPlaylistCandidates, @@ -61,10 +67,7 @@ import { getUpdatedPositionsAfterSetCurrent, getUpdatedQueuedPositionsAfterKindChange, } from "~/lib/playlist/order"; -import { - ADD_REQUESTS_WHEN_LIVE_MESSAGE, - areChannelRequestsOpen, -} from "~/lib/request-availability"; +import { areChannelRequestsOpen } from "~/lib/request-availability"; import { formatPathLabel } from "~/lib/request-policy"; import { getErrorMessage } from "~/lib/utils"; import { @@ -268,9 +271,31 @@ export type PlaylistManagementSurfaceProps = { currentPlaylistTitle?: string | null; }; +function formatTimeAgo(t: TFunction, timestamp: number) { + const deltaMs = Date.now() - timestamp; + const deltaMinutes = Math.max(0, Math.floor(deltaMs / 60000)); + + if (deltaMinutes < 1) { + return t("management.relative.now"); + } + + if (deltaMinutes < 60) { + return t("management.relative.minutesAgo", { count: deltaMinutes }); + } + + const deltaHours = Math.floor(deltaMinutes / 60); + if (deltaHours < 24) { + return t("management.relative.hoursAgo", { count: deltaHours }); + } + + const deltaDays = Math.floor(deltaHours / 24); + return t("management.relative.daysAgo", { count: deltaDays }); +} + export function PlaylistManagementSurface( props: PlaylistManagementSurfaceProps ) { + const { t } = useLocaleTranslation("playlist"); const queryClient = useQueryClient(); const [manualQuery, setManualQuery] = useState(""); const [manualRequesterLogin, setManualRequesterLogin] = useState(""); @@ -378,8 +403,8 @@ export function PlaylistManagementSurface( if (!response.ok) { throw new Error( body && "message" in body - ? (body.message ?? "Search failed.") - : "Search failed." + ? (body.message ?? t("management.states.searchFailed")) + : t("management.states.searchFailed") ); } @@ -405,7 +430,9 @@ export function PlaylistManagementSurface( } | null; throw new Error( - payload?.error ?? payload?.message ?? "Playlist update failed." + payload?.error ?? + payload?.message ?? + t("management.states.playlistUpdateFailed") ); } @@ -549,7 +576,10 @@ export function PlaylistManagementSurface( } const action = typeof body.action === "string" ? body.action : "unknown"; - const message = getErrorMessage(error, "Playlist update failed."); + const message = getErrorMessage( + error, + t("management.states.playlistUpdateFailed") + ); if (action === "manualAdd") { setManualAddError(message); @@ -576,7 +606,7 @@ export function PlaylistManagementSurface( const moderationMutation = useMutation({ mutationFn: async (body: Record) => { if (!moderationEndpoint) { - throw new Error("Moderation actions are unavailable for this channel."); + throw new Error(t("management.states.moderationUnavailable")); } const response = await fetch(moderationEndpoint, { @@ -593,7 +623,9 @@ export function PlaylistManagementSurface( if (!response.ok) { throw new Error( - payload?.error ?? payload?.message ?? "Blacklist update failed." + payload?.error ?? + payload?.message ?? + t("management.states.blacklistUpdateFailed") ); } @@ -604,7 +636,7 @@ export function PlaylistManagementSurface( }, onError: (error) => { setPlaylistActionError( - getErrorMessage(error, "Blacklist update failed.") + getErrorMessage(error, t("management.states.blacklistUpdateFailed")) ); }, onSuccess: () => { @@ -748,7 +780,13 @@ export function PlaylistManagementSurface( description={ props.headerDescription ?? (managedChannel - ? `${accessRole === "moderator" ? "Managing" : "Channel:"} ${managedChannel.displayName}` + ? accessRole === "moderator" + ? t("management.header.managing", { + channel: managedChannel.displayName, + }) + : t("management.header.channel", { + channel: managedChannel.displayName, + }) : undefined) } /> @@ -757,29 +795,29 @@ export function PlaylistManagementSurface( {props.showManualAdd !== false ? ( - Add a song + {t("management.manual.title")} setManualRequesterLogin(event.target.value)} - placeholder="Requester username (optional)" + placeholder={t("management.manual.requesterPlaceholder")} disabled={!requestsOpen} /> setManualQuery(event.target.value)} - placeholder="Search and add a song" + placeholder={t("management.manual.searchPlaceholder")} disabled={!requestsOpen} /> {!requestsOpen ? (
- {ADD_REQUESTS_WHEN_LIVE_MESSAGE} + {t("page.requestsLiveOnly")}
) : null} {manualQueryTooShort ? (

- Search terms must be at least 3 characters. + {t("management.manual.searchMin")}

) : null} {manualSearchQuery.error ? ( @@ -795,10 +833,10 @@ export function PlaylistManagementSurface( {manualQuery.trim().length >= 3 ? (
-
Track
-
Album / Creator
-
Tuning / Path
-
Add
+
{t("management.manual.table.track")}
+
{t("management.manual.table.albumCreator")}
+
{t("management.manual.table.tuningPath")}
+
{t("management.manual.table.add")}
{manualSearchQuery.data?.results?.map((song, index) => { const isBlacklistedCharter = @@ -819,37 +857,39 @@ export function PlaylistManagementSurface( {song.title}

- {song.artist ?? "Unknown artist"} + {song.artist ?? t("management.manual.unknownArtist")}

- {song.album ?? "Unknown album"} + {song.album ?? t("management.manual.unknownAlbum")}

{song.creator - ? `Charted by ${song.creator}` - : "Unknown creator"} + ? t("management.manual.chartedBy", { + creator: song.creator, + }) + : t("management.manual.unknownCreator")} {isBlacklistedCharter ? ( - Blacklisted + {t("management.manual.blacklisted")} ) : null}

- {song.tuning ?? "No tuning info"} + {song.tuning ?? t("management.manual.noTuningInfo")}

{song.parts?.length ? song.parts.join(", ") - : "No path info"} + : t("management.manual.noPathInfo")}

@@ -898,11 +938,11 @@ export function PlaylistManagementSurface( title={ requestsOpen ? undefined - : ADD_REQUESTS_WHEN_LIVE_MESSAGE + : t("page.requestsLiveOnly") } > - Add + {t("management.manual.addButton")}
@@ -928,31 +968,23 @@ export function PlaylistManagementSurface( onClick={() => mutation.mutate({ action: "shufflePlaylist" })} disabled={mutation.isPending || items.length < 2} > - Shuffle + {t("management.actions.shuffle")}
@@ -1035,7 +1067,7 @@ export function PlaylistManagementSurface( onBlacklistSong={(item) => { if (item.songCatalogSourceId == null) { setPlaylistActionError( - "This request does not have a version ID to blacklist." + t("management.blacklistErrors.missingRequestVersionId") ); return; } @@ -1050,7 +1082,7 @@ export function PlaylistManagementSurface( onBlacklistCandidateSong={(candidate) => { if (candidate.sourceId == null) { setPlaylistActionError( - "This version does not have a version ID to blacklist." + t("management.blacklistErrors.missingVersionVersionId") ); return; } @@ -1065,7 +1097,7 @@ export function PlaylistManagementSurface( onBlacklistSongGroup={(item) => { if (item.songGroupedProjectId == null) { setPlaylistActionError( - "This request does not have a song group ID to blacklist." + t("management.blacklistErrors.missingRequestSongGroupId") ); return; } @@ -1081,7 +1113,7 @@ export function PlaylistManagementSurface( onBlacklistArtist={(item) => { if (item.songArtistId == null) { setPlaylistActionError( - "This request does not have an artist ID to blacklist." + t("management.blacklistErrors.missingRequestArtistId") ); return; } @@ -1089,13 +1121,15 @@ export function PlaylistManagementSurface( moderationMutation.mutate({ action: "addBlacklistedArtist", artistId: item.songArtistId, - artistName: item.songArtist ?? "Unknown artist", + artistName: + item.songArtist ?? + t("management.blacklistErrors.unknownArtist"), }); }} onBlacklistCharter={(candidate) => { if (candidate.authorId == null) { setPlaylistActionError( - "This version does not have a charter ID to blacklist." + t("management.blacklistErrors.missingVersionCharterId") ); return; } @@ -1103,7 +1137,9 @@ export function PlaylistManagementSurface( moderationMutation.mutate({ action: "addBlacklistedCharter", charterId: candidate.authorId, - charterName: candidate.creator ?? "Unknown charter", + charterName: + candidate.creator ?? + t("management.blacklistErrors.unknownCharter"), }); }} isBlacklistArtistPending={moderationMutation.isPending} @@ -1117,7 +1153,7 @@ export function PlaylistManagementSurface(
- {props.currentPlaylistTitle ?? "Current playlist"} + {props.currentPlaylistTitle ?? t("management.currentTitle")}
@@ -1235,7 +1263,7 @@ export function PlaylistManagementSurface( onBlacklistSong={(item) => { if (item.songCatalogSourceId == null) { setPlaylistActionError( - "This request does not have a version ID to blacklist." + t("management.blacklistErrors.missingRequestVersionId") ); return; } @@ -1250,7 +1278,7 @@ export function PlaylistManagementSurface( onBlacklistCandidateSong={(candidate) => { if (candidate.sourceId == null) { setPlaylistActionError( - "This version does not have a version ID to blacklist." + t("management.blacklistErrors.missingVersionVersionId") ); return; } @@ -1265,7 +1293,7 @@ export function PlaylistManagementSurface( onBlacklistSongGroup={(item) => { if (item.songGroupedProjectId == null) { setPlaylistActionError( - "This request does not have a song group ID to blacklist." + t("management.blacklistErrors.missingRequestSongGroupId") ); return; } @@ -1281,7 +1309,7 @@ export function PlaylistManagementSurface( onBlacklistArtist={(item) => { if (item.songArtistId == null) { setPlaylistActionError( - "This request does not have an artist ID to blacklist." + t("management.blacklistErrors.missingRequestArtistId") ); return; } @@ -1289,13 +1317,15 @@ export function PlaylistManagementSurface( moderationMutation.mutate({ action: "addBlacklistedArtist", artistId: item.songArtistId, - artistName: item.songArtist ?? "Unknown artist", + artistName: + item.songArtist ?? + t("management.blacklistErrors.unknownArtist"), }); }} onBlacklistCharter={(candidate) => { if (candidate.authorId == null) { setPlaylistActionError( - "This version does not have a charter ID to blacklist." + t("management.blacklistErrors.missingVersionCharterId") ); return; } @@ -1303,7 +1333,9 @@ export function PlaylistManagementSurface( moderationMutation.mutate({ action: "addBlacklistedCharter", charterId: candidate.authorId, - charterName: candidate.creator ?? "Unknown charter", + charterName: + candidate.creator ?? + t("management.blacklistErrors.unknownCharter"), }); }} isBlacklistArtistPending={moderationMutation.isPending} @@ -1322,14 +1354,14 @@ export function PlaylistManagementSurface( charters={blacklistCharters} songs={blacklistSongs} songGroups={blacklistSongGroups} - description="Artists, charters, songs, and specific versions can be blocked for this channel." + description={t("management.blacklistPanelDescription")} collapsible defaultOpen={false} /> - Played history + {t("management.history.title")} {playedSongs.map((song, index) => ( @@ -1355,13 +1387,17 @@ export function PlaylistManagementSurface( {(song.requestedByDisplayName ?? song.requestedByLogin) ? (

- Requested by{" "} - {song.requestedByDisplayName ?? - song.requestedByLogin} + {t("management.history.requestedBy", { + requester: + song.requestedByDisplayName ?? + song.requestedByLogin, + })}

) : null}

- Played {formatTimeAgo(song.playedAt)} + {t("management.history.played", { + time: formatTimeAgo(t, song.playedAt), + })}

@@ -1382,7 +1418,9 @@ export function PlaylistManagementSurface( } disabled={isRestorePending(song.id)} > - {isRestorePending(song.id) ? "Restoring..." : "Restore"} + {isRestorePending(song.id) + ? t("management.history.restoring") + : t("management.history.restore")} @@ -1390,7 +1428,7 @@ export function PlaylistManagementSurface( ))} {playedSongs.length === 0 ? (

- Nothing has been marked played yet. + {t("management.history.empty")}

) : null}
@@ -1409,17 +1447,24 @@ export function PlaylistManagementSurface( - Remove this request from playlist? + {t("management.deleteDialog.title")} {deleteDialogItem - ? `This removes "${deleteDialogItem.songTitle}"${deleteDialogItem.songArtist ? ` by ${deleteDialogItem.songArtist}` : ""} from the playlist. This cannot be undone.` - : "This removes the selected request from the playlist. This cannot be undone."} + ? deleteDialogItem.songArtist + ? t("management.deleteDialog.descriptionWithArtist", { + title: deleteDialogItem.songTitle, + artist: deleteDialogItem.songArtist, + }) + : t("management.deleteDialog.descriptionWithoutArtist", { + title: deleteDialogItem.songTitle, + }) + : t("management.deleteDialog.descriptionFallback")} - Keep request + {t("management.deleteDialog.keep")} @@ -1483,6 +1528,7 @@ function CurrentPlaylistRows(props: { onBlacklistArtist: (item: PlaylistItem) => void; onBlacklistCharter: (candidate: PlaylistCandidate) => void; }) { + const { t } = useLocaleTranslation("playlist"); const reorderableItemIds = props.items .filter((item) => item.id !== props.currentItemId) .map((item) => item.id); @@ -1569,38 +1615,13 @@ function CurrentPlaylistRows(props: { {props.items.length === 0 ? (

- No songs in the playlist yet. + {t("management.queue.empty")}

) : null} ); } -function formatTimeAgo(timestamp: number) { - const deltaMs = Date.now() - timestamp; - const deltaMinutes = Math.max(0, Math.floor(deltaMs / 60000)); - - if (deltaMinutes < 1) { - return "just now"; - } - - if (deltaMinutes < 60) { - return `${deltaMinutes} minute${deltaMinutes === 1 ? "" : "s"} ago`; - } - - const deltaHours = Math.floor(deltaMinutes / 60); - if (deltaHours < 24) { - return `${deltaHours} hour${deltaHours === 1 ? "" : "s"} ago`; - } - - const deltaDays = Math.floor(deltaHours / 24); - return `${deltaDays} day${deltaDays === 1 ? "" : "s"} ago`; -} - -function formatDate(timestamp: number) { - return new Date(timestamp).toLocaleDateString(); -} - function getRequesterLabel(item: PlaylistItem) { return item.requestedByDisplayName ?? item.requestedByLogin ?? null; } @@ -1838,6 +1859,7 @@ function PlaylistQueueItem(props: { onBlacklistArtist: () => void; onBlacklistCharter: (candidate: PlaylistCandidate) => void; }) { + const { t } = useLocaleTranslation("playlist"); const itemRef = useRef(null); const dragHandleRef = useRef(null); const [showVersions, setShowVersions] = useState(false); @@ -1986,19 +2008,19 @@ function PlaylistQueueItem(props: {
props.onMoveItem("top")} /> props.onMoveItem("up")} /> props.onMoveItem("down")} /> {isVipRequest ? ( - VIP + {t("management.item.vipBadge")} ) : null} {isCurrentItem ? ( - Playing + {t("management.item.playingBadge")} ) : null} {props.item.warningMessage ? ( @@ -2069,7 +2093,7 @@ function PlaylistQueueItem(props: { icon={AlertTriangle} className="border-amber-400/40 bg-amber-500/15 text-amber-200" > - Warning + {t("management.item.warningBadge")} ) : null} {props.isBlacklistedSong ? ( @@ -2077,7 +2101,7 @@ function PlaylistQueueItem(props: { variant="outline" className="border-rose-400/40 bg-rose-500/10 text-rose-200" > - Version blacklisted + {t("management.item.versionBlacklisted")} ) : null} {props.isBlacklistedSongGroup ? ( @@ -2085,7 +2109,7 @@ function PlaylistQueueItem(props: { variant="outline" className="border-rose-400/40 bg-rose-500/10 text-rose-200" > - Song blacklisted + {t("management.item.songBlacklisted")} ) : null} {props.isBlacklistedArtist ? ( @@ -2093,7 +2117,7 @@ function PlaylistQueueItem(props: { variant="outline" className="border-rose-400/40 bg-rose-500/10 text-rose-200" > - Artist blacklisted + {t("management.item.artistBlacklisted")} ) : null}
@@ -2104,28 +2128,39 @@ function PlaylistQueueItem(props: {

{formatPlaylistItemSummaryLine(props.item, { hasMultipleVersions, + chartedByLabel: t("management.versionsTable.chartedBy"), + unknownArtistLabel: t("management.manual.unknownArtist"), })}

{getRequesterLabel(props.item) ? (

- Requested by {getRequesterLabel(props.item)} + {t("management.item.requestedBy", { + requester: getRequesterLabel(props.item), + })}

) : null} {showVipTokenBalance ? (

- {formatVipTokenCount(props.availableVipTokenCount)} VIP - tokens + {t("management.item.vipTokens", { + count: formatVipTokenCount(props.availableVipTokenCount), + })}

) : null}

- Added {formatTimeAgo(props.item.createdAt)} + + {t("management.item.added", { + time: formatTimeAgo(t, props.item.createdAt), + })} +

{props.item.requestedQuery ? (

- Requested text: {props.item.requestedQuery} + {t("management.item.requestedText", { + query: props.item.requestedQuery, + })}

) : null} {props.item.warningMessage ? ( @@ -2147,8 +2182,8 @@ function PlaylistQueueItem(props: { disabled={props.isChangeRequestKindPending} > {props.isChangeRequestKindPending - ? "Saving..." - : "Make regular"} + ? t("management.item.saving") + : t("management.item.makeRegular")} ) : null} {canUpgradeToVip && !isCurrentItem ? ( @@ -2159,7 +2194,9 @@ function PlaylistQueueItem(props: { onClick={() => props.onChangeRequestKind("vip")} disabled={props.isChangeRequestKindPending} > - {props.isChangeRequestKindPending ? "Saving..." : "Make VIP"} + {props.isChangeRequestKindPending + ? t("management.item.saving") + : t("management.item.makeVip")} ) : null} {isCurrentItem ? ( @@ -2172,8 +2209,8 @@ function PlaylistQueueItem(props: { > {props.isReturnToQueuePending - ? "Saving..." - : "Return to queue"} + ? t("management.item.saving") + : t("management.item.returnToQueue")} ) : ( )} {isCurrentItem ? ( @@ -2195,7 +2232,7 @@ function PlaylistQueueItem(props: { disabled={props.isMarkPlayedPending} > - Mark complete + {t("management.item.markComplete")} ) : null} {props.canManageBlacklist || !isCurrentItem ? ( @@ -2233,8 +2270,9 @@ function PlaylistQueueItem(props: { aria-expanded={showVersions} onClick={() => setShowVersions((current) => !current)} > - {resolvedCandidates.length} version - {resolvedCandidates.length === 1 ? "" : "s"} + {t("management.item.versionsCount", { + count: resolvedCandidates.length, + })} ) : singleVersionDownloadUrl ? ( ) : null} @@ -2351,12 +2389,15 @@ function PlaylistItemActionsPopover(props: { onBlacklistArtist: () => void; onBlacklistCharter: (candidate: PlaylistCandidate) => void; }) { + const { t } = useLocaleTranslation("playlist"); const [open, setOpen] = useState(false); const [view, setView] = useState<"menu" | "blacklist">("menu"); const queuedVersionLabel = props.item.songCatalogSourceId != null - ? `Queued version ID ${props.item.songCatalogSourceId}` - : "No queued version ID"; + ? t("management.actionsMenu.queuedVersionId", { + id: props.item.songCatalogSourceId, + }) + : t("management.actionsMenu.noQueuedVersionId"); const charterCandidates = Array.from( new Map( props.candidates @@ -2389,7 +2430,9 @@ function PlaylistItemActionsPopover(props: { type="button" size="icon" variant="outline" - aria-label={`Open actions for ${props.item.songTitle}`} + aria-label={t("management.actionsMenu.openActionsAria", { + title: props.item.songTitle, + })} className="h-8 w-8" > @@ -2413,8 +2456,8 @@ function PlaylistItemActionsPopover(props: { > {props.isDeletingItem - ? "Removing..." - : "Remove from playlist"} + ? t("management.actionsMenu.removing") + : t("management.actionsMenu.removeFromPlaylist")} ) : null} @@ -2425,7 +2468,7 @@ function PlaylistItemActionsPopover(props: { onClick={() => setView("blacklist")} > - Blacklist + {t("management.actionsMenu.blacklist")} ) : null}
@@ -2434,7 +2477,7 @@ function PlaylistItemActionsPopover(props: {
- Blacklist actions + {t("management.actionsMenu.blacklistTitle")}

- Choose whether to block the queued version, every version of this - song, the artist, or a charter. + {t("management.actionsMenu.description")}

{queuedVersionLabel} @@ -2473,13 +2515,17 @@ function PlaylistItemActionsPopover(props: {

{props.isBlacklistedSong - ? "Version blacklisted" - : "Blacklist queued version"} + ? t("management.actionsMenu.versionBlocked") + : t("management.actionsMenu.blacklistQueuedVersion")} {props.item.songCatalogSourceId != null - ? `Blocks only version ID ${props.item.songCatalogSourceId}.` - : "Blocks only the exact version attached to this request."} + ? t("management.actionsMenu.blockVersionDescription", { + id: props.item.songCatalogSourceId, + }) + : t( + "management.actionsMenu.blockVersionFallbackDescription" + )}
@@ -2501,11 +2547,11 @@ function PlaylistItemActionsPopover(props: {
{props.isBlacklistedSongGroup - ? "Song blacklisted" - : "Blacklist all versions of this song"} + ? t("management.actionsMenu.songBlocked") + : t("management.actionsMenu.blacklistSongGroup")} - Blocks every version grouped under this song. + {t("management.actionsMenu.blockSongDescription")}
@@ -2527,11 +2573,19 @@ function PlaylistItemActionsPopover(props: {
{props.isBlacklistedArtist - ? `Artist blacklisted: ${props.item.songArtist ?? "Unknown artist"}` - : `Blacklist artist: ${props.item.songArtist ?? "Unknown artist"}`} + ? t("management.actionsMenu.artistBlocked", { + artist: + props.item.songArtist ?? + t("management.blacklistErrors.unknownArtist"), + }) + : t("management.actionsMenu.blacklistArtist", { + artist: + props.item.songArtist ?? + t("management.blacklistErrors.unknownArtist"), + })} - Blocks every song by this artist ID. + {t("management.actionsMenu.blockArtistDescription")}
@@ -2558,11 +2612,19 @@ function PlaylistItemActionsPopover(props: {
{isBlacklistedCharter - ? `Charter blacklisted: ${candidate.creator ?? "Unknown"}` - : `Blacklist charter: ${candidate.creator ?? "Unknown"}`} + ? t("management.actionsMenu.charterBlocked", { + charter: + candidate.creator ?? + t("management.blacklistErrors.unknownCharter"), + }) + : t("management.actionsMenu.blacklistCharter", { + charter: + candidate.creator ?? + t("management.blacklistErrors.unknownCharter"), + })} - Blocks every song by this charter ID. + {t("management.actionsMenu.blockCharterDescription")}
@@ -2570,7 +2632,7 @@ function PlaylistItemActionsPopover(props: { })} {!charterCandidates.length ? (
- No charter IDs available for these versions. + {t("management.actionsMenu.noCharterIds")}
) : null} @@ -2590,28 +2652,31 @@ function PlaylistVersionsTable(props: { isBlacklistSongPending: boolean; onBlacklistCandidateSong: (candidate: PlaylistCandidate) => void; }) { + const { t } = useLocaleTranslation("playlist"); + const { locale } = useAppLocale(); + return (
@@ -2638,19 +2703,24 @@ function PlaylistVersionsTable(props: { {candidate.album ? ` · ${candidate.album}` : ""}

- {candidate.artist ?? "Unknown artist"} + {candidate.artist ?? + t("management.versionsTable.unknownArtist")}

{candidate.creator ? (

- Charted by{" "} + + {t("management.versionsTable.chartedBy")} + {" "} {candidate.creator} - {isBlacklistedCharter ? " · Charter blacklisted" : ""} + {isBlacklistedCharter + ? ` · ${t("management.versionsTable.charterBlacklisted")}` + : ""}

) : null}
- Song / album + {t("management.versionsTable.songAlbum")} - Tunings + {t("management.versionsTable.tunings")} - Paths + {t("management.versionsTable.paths")} - Updated + {t("management.versionsTable.updated")} - Downloads + {t("management.versionsTable.downloads")} - Actions + {t("management.versionsTable.actions")}
- {candidate.tuning ?? "Unknown"} + {candidate.tuning ?? t("management.versionsTable.unknown")}
@@ -2664,19 +2734,23 @@ function PlaylistVersionsTable(props: { ))} {(candidate.parts ?? []).length === 0 ? ( - Unknown + + {t("management.versionsTable.unknown")} + ) : null}
{candidate.sourceUpdatedAt - ? formatDate(candidate.sourceUpdatedAt) - : "Unknown"} + ? formatLocaleDate(locale, candidate.sourceUpdatedAt, { + dateStyle: "medium", + }) + : t("management.versionsTable.unknown")} {candidate.downloads != null - ? candidate.downloads.toLocaleString() - : "Unknown"} + ? formatNumber(locale, candidate.downloads) + : t("management.versionsTable.unknown")}
@@ -2695,7 +2769,7 @@ function PlaylistVersionsTable(props: { className="no-underline" > - Download + {t("management.versionsTable.download")} ) : null} @@ -2716,8 +2790,8 @@ function PlaylistVersionsTable(props: { > {isBlacklistedCandidateSong - ? "Blacklisted" - : "Blacklist"} + ? t("management.versionsTable.blacklisted") + : t("management.versionsTable.blacklist")} ) : null}
@@ -2766,6 +2840,7 @@ function getPlaylistPathBadgeClass(path: string) { } export function PlaylistQueueItemPreview() { + const { t } = useLocaleTranslation("playlist"); const [item, setItem] = useState(PLAYLIST_PREVIEW_ITEM); const [removed, setRemoved] = useState(false); const [isBlacklistedArtist, setIsBlacklistedArtist] = useState(false); @@ -2781,7 +2856,7 @@ export function PlaylistQueueItemPreview() { return (

- The demo row is removed from the playlist. + {t("management.preview.removed")}

diff --git a/src/components/public-played-history-card.tsx b/src/components/public-played-history-card.tsx index 508c2d1..60fd3f9 100644 --- a/src/components/public-played-history-card.tsx +++ b/src/components/public-played-history-card.tsx @@ -11,6 +11,8 @@ import { PaginationNext, PaginationPrevious, } from "~/components/ui/pagination"; +import { useAppLocale, useLocaleTranslation } from "~/lib/i18n/client"; +import { formatNumber } from "~/lib/i18n/format"; import { usePaginatedContentTransition } from "~/lib/paginated-content-transition"; import { decodeHtmlEntities } from "~/lib/utils"; @@ -45,6 +47,8 @@ export function PublicPlayedHistoryCard(props: { slug: string; channelDisplayName?: string | null; }) { + const { t } = useLocaleTranslation("playlist"); + const { locale } = useAppLocale(); const [historyOpen, setHistoryOpen] = useState(false); const [historyPage, setHistoryPage] = useState(1); const [searchQuery, setSearchQuery] = useState(""); @@ -158,8 +162,10 @@ export function PublicPlayedHistoryCard(props: {
{props.channelDisplayName - ? `${props.channelDisplayName}'s played history` - : "Played history"} + ? t("history.titleWithChannel", { + channel: props.channelDisplayName, + }) + : t("history.title")}
@@ -171,12 +177,12 @@ export function PublicPlayedHistoryCard(props: { {historyOpen ? ( <> - Hide history + {t("history.hide")} ) : ( <> - Show history + {t("history.show")} )} @@ -189,7 +195,7 @@ export function PublicPlayedHistoryCard(props: { className="text-xs font-semibold uppercase tracking-[0.2em] text-(--muted)" htmlFor={`played-history-search-${props.slug}`} > - Song Search + {t("history.songSearch")}
@@ -197,7 +203,7 @@ export function PublicPlayedHistoryCard(props: { id={`played-history-search-${props.slug}`} value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} - placeholder="Song, artist, album, or charter" + placeholder={t("history.searchPlaceholder")} className="pr-10 pl-10!" /> {searchQuery ? ( @@ -205,7 +211,7 @@ export function PublicPlayedHistoryCard(props: { type="button" onClick={() => setSearchQuery("")} className="absolute top-1/2 right-3 -translate-y-1/2 text-(--muted) transition-colors hover:text-(--text)" - aria-label="Clear search" + aria-label={t("history.clearSearch")} > @@ -218,7 +224,7 @@ export function PublicPlayedHistoryCard(props: { className="text-xs font-semibold uppercase tracking-[0.2em] text-(--muted)" htmlFor={`played-history-requester-${props.slug}`} > - Requester + {t("history.requester")}
{requesterInput ? ( @@ -247,9 +253,11 @@ export function PublicPlayedHistoryCard(props: { {selectedRequester ? (
- {formatRequesterLabel(selectedRequester)} + {formatRequesterLabel(selectedRequester, t)} {selectedRequester.requestCount - ? ` · ${selectedRequester.requestCount} request${selectedRequester.requestCount === 1 ? "" : "s"}` + ? ` · ${t("history.requestCount", { + count: selectedRequester.requestCount, + })}` : ""} @@ -273,25 +281,26 @@ export function PublicPlayedHistoryCard(props: { type="button" onClick={() => { setSelectedRequester(requester); - setRequesterInput(formatRequesterLabel(requester)); + setRequesterInput(formatRequesterLabel(requester, t)); }} className={`grid w-full gap-1 px-4 py-3 text-left transition-colors hover:bg-(--panel-strong) ${ index > 0 ? "border-t border-(--border)" : "" }`} > - {formatRequesterLabel(requester)} + {formatRequesterLabel(requester, t)} - {requester.requestCount} request - {requester.requestCount === 1 ? "" : "s"} + {t("history.requestCount", { + count: requester.requestCount, + })} ))}
) : requesterResultsQuery.isLoading ? (

- Searching requesters... + {t("history.searchingRequesters")}

) : null}
@@ -302,17 +311,13 @@ export function PublicPlayedHistoryCard(props: { className={`paginated-transition-frame ${historyTransitionClassName}`.trim()} > {playedHistoryQuery.isLoading ? ( -

- Loading played history... -

+

{t("history.loading")}

) : null} {!playedHistoryQuery.isLoading && (playedHistoryQuery.data?.results.length ?? 0) === 0 ? (

- {hasFilters - ? "No played songs matched those filters." - : "No songs have been marked played yet."} + {hasFilters ? t("history.emptyFiltered") : t("history.empty")}

) : null} @@ -330,14 +335,23 @@ export function PublicPlayedHistoryCard(props: {

{decodeHtmlEntities(song.songTitle)} {song.songArtist - ? ` by ${decodeHtmlEntities(song.songArtist)}` + ? t("history.byArtist", { + artist: decodeHtmlEntities(song.songArtist), + }) : ""}

{(song.requestedByDisplayName ?? song.requestedByLogin) - ? `Requested by ${song.requestedByDisplayName ?? song.requestedByLogin} · ` + ? `${t("history.requestedBy", { + requester: + song.requestedByDisplayName ?? + song.requestedByLogin, + })} · ` : ""} - {new Date(song.playedAt).toLocaleString()} + {new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(song.playedAt))}

))} @@ -351,7 +365,9 @@ export function PublicPlayedHistoryCard(props: { playedHistoryQuery.data.hasNextPage) ? (

- Page {playedHistoryQuery.data.page} + {t("history.page", { + page: formatNumber(locale, playedHistoryQuery.data.page), + })}

@@ -391,8 +407,13 @@ export function PublicPlayedHistoryCard(props: { ); } -function formatRequesterLabel(requester: PlayedHistoryRequester) { +function formatRequesterLabel( + requester: PlayedHistoryRequester, + t: (key: string, options?: Record) => string +) { return ( - requester.requesterDisplayName ?? requester.requesterLogin ?? "Unknown" + requester.requesterDisplayName ?? + requester.requesterLogin ?? + t("history.unknownRequester") ); } diff --git a/src/components/song-search-panel.tsx b/src/components/song-search-panel.tsx index 9962024..5e1675a 100644 --- a/src/components/song-search-panel.tsx +++ b/src/components/song-search-panel.tsx @@ -57,7 +57,7 @@ import { TooltipTrigger, } from "~/components/ui/tooltip"; import { pathOptions } from "~/lib/channel-options"; -import { formatPathLabel } from "~/lib/request-policy"; +import { useAppLocale, useLocaleTranslation } from "~/lib/i18n/client"; import { cn, getErrorMessage } from "~/lib/utils"; type SearchField = "any" | "title" | "artist" | "album" | "creator"; @@ -99,12 +99,6 @@ type SearchFilterOptionsResponse = { years: number[]; }; -const updatedDateFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", -}); - export function buildRequestCommand(song: SearchSong) { if (song.sourceId != null) { return `!sr song:${song.sourceId}`; @@ -181,6 +175,34 @@ export function SongSearchPanel(props: { actionsLabel?: string; renderActions?: (args: SearchSongActionRenderArgs) => ReactNode; }) { + const { locale } = useAppLocale(); + const { t } = useLocaleTranslation("search"); + const updatedDateFormatter = useMemo( + () => + new Intl.DateTimeFormat(locale, { + month: "short", + day: "numeric", + year: "numeric", + }), + [locale] + ); + const getPathLabel = (path: string) => { + switch (path.toLowerCase()) { + case "lead": + return t("paths.lead"); + case "rhythm": + return t("paths.rhythm"); + case "bass": + return t("paths.bass"); + case "voice": + case "vocals": + return t("paths.lyrics"); + default: + return path; + } + }; + const getPathShortLabel = (path: string) => + getPathLabel(path).slice(0, 1).toUpperCase(); const normalizedDefaultPathFilters = useMemo( () => [...new Set(props.defaultPathFilters ?? [])].filter((path) => @@ -333,8 +355,8 @@ export function SongSearchPanel(props: { if (!response.ok) { throw new Error( body && "message" in body - ? (body.message ?? "Filter options failed to load.") - : "Filter options failed to load." + ? (body.message ?? t("errors.filterOptionsFailed")) + : t("errors.filterOptionsFailed") ); } @@ -357,8 +379,8 @@ export function SongSearchPanel(props: { if (!response.ok) { throw new Error( body && "message" in body - ? (body.message ?? "Search failed.") - : "Search failed." + ? (body.message ?? t("errors.searchFailed")) + : t("errors.searchFailed") ); } @@ -383,8 +405,8 @@ export function SongSearchPanel(props: { if (!response.ok) { throw new Error( body && "message" in body - ? (body.message ?? "Search failed.") - : "Search failed." + ? (body.message ?? t("errors.searchFailed")) + : t("errors.searchFailed") ); } @@ -541,18 +563,20 @@ export function SongSearchPanel(props: { > {showTopFilterSummary ? (
- Filters + + {t("summary.filters")} + {activePathFilters.map((path) => ( ))} {activeNonPathFilterCount > 0 ? ( - +{activeNonPathFilterCount} more + {t("summary.moreCount", { count: activeNonPathFilterCount })} ) : null} {!showAdvanced ? ( @@ -561,7 +585,7 @@ export function SongSearchPanel(props: { className="text-(--brand) transition hover:opacity-80" onClick={() => setShowAdvanced(true)} > - Change filters + {t("summary.changeFilters")} ) : null}
@@ -645,8 +669,8 @@ export function SongSearchPanel(props: { {isDisabled ? disabledReason : copiedType === "sr" - ? "Copied !sr command" - : "Copy !sr command"} + ? t("commands.copiedSr") + : t("commands.copySr")} @@ -670,8 +694,8 @@ export function SongSearchPanel(props: { {isDisabled ? disabledReason : copiedType === "edit" - ? "Copied !edit command" - : "Copy !edit command"} + ? t("commands.copiedEdit") + : t("commands.copyEdit")} @@ -695,8 +719,8 @@ export function SongSearchPanel(props: { {isDisabled ? disabledReason : copiedType === "vip" - ? "Copied !vip command" - : "Copy !vip command"} + ? t("commands.copiedVip") + : t("commands.copyVip")}
@@ -729,7 +753,7 @@ export function SongSearchPanel(props: { {resolvedInfoNote ? (
- Note: + {t("summary.note")} {resolvedInfoNote}
@@ -737,7 +761,7 @@ export function SongSearchPanel(props: { {!queryTooShort && !error ? (

- Found {summaryCount} songs + {t("summary.foundCount", { count: summaryCount })}

) : null} @@ -755,9 +779,7 @@ export function SongSearchPanel(props: { autoCorrect="off" autoCapitalize="none" className="pr-4 pl-10!" - placeholder={ - props.placeholder ?? "Search by song title, artist or album" - } + placeholder={props.placeholder ?? t("page.placeholder")} /> @@ -790,7 +822,7 @@ export function SongSearchPanel(props: {
- + toggleAdvancedTuning(value)} @@ -859,19 +891,14 @@ export function SongSearchPanel(props: { />
- + formatPathLabel(part))} - selectedValues={advancedFilters.parts.map((part) => - formatPathLabel(part) - )} - onAdd={(value) => - toggleAdvancedPart(getPathTokenFromLabel(value)) - } - onRemove={(value) => - toggleAdvancedPart(getPathTokenFromLabel(value)) - } + label={t("controls.path")} + options={pathOptions} + selectedValues={advancedFilters.parts} + onAdd={(value) => toggleAdvancedPart(value)} + onRemove={(value) => toggleAdvancedPart(value)} + renderValue={getPathLabel} toneByValue={getPathToneByValue} /> {advancedFilters.parts.length > 1 ? ( @@ -889,7 +916,7 @@ export function SongSearchPanel(props: { updateAdvancedFilter("partsMatchMode", "any") } > - Match any + {t("controls.matchAny")}
) : null}
- + String(year) )} @@ -924,13 +951,13 @@ export function SongSearchPanel(props: { />
- +
@@ -961,17 +988,17 @@ export function SongSearchPanel(props: { resultsGridColumns )} > - Song - Paths - Stats + {t("columns.song")} + {t("columns.paths")} + {t("columns.stats")} - {props.actionsLabel ?? "Actions"} + {props.actionsLabel ?? t("columns.actions")}
{isLoading && results.length === 0 ? (
- Loading songs... + {t("states.loading")}
) : null} @@ -983,7 +1010,7 @@ export function SongSearchPanel(props: { )} > - Search terms must be at least 3 characters. + {t("states.queryTooShort")}
) : null} @@ -997,8 +1024,8 @@ export function SongSearchPanel(props: { {!isLoading && !queryTooShort && visibleResults.length === 0 ? (
{hasSearchInput - ? "No songs matched those filters yet. Try broadening the search field or clearing one of the advanced inputs." - : "No songs are available in the demo catalog yet."} + ? t("states.emptyFiltered") + : t("states.emptyCatalog")}
) : null} @@ -1025,8 +1052,10 @@ export function SongSearchPanel(props: { const isDisabled = resultState.disabled === true; const disabledReason = resultState.reasons && resultState.reasons.length > 0 - ? `Blacklisted - ${resultState.reasons.join(" · ")}` - : "Blacklisted"; + ? t("states.blacklistedWithReasons", { + reasons: resultState.reasons.join(" · "), + }) + : t("states.blacklisted"); return (

- {song.artist ?? "Unknown artist"} + {song.artist ?? t("states.unknownArtist")}

{song.album ? (

@@ -1070,30 +1099,30 @@ export function SongSearchPanel(props: {

{song.parts?.includes("lead") ? ( ) : null} {song.parts?.includes("rhythm") ? ( ) : null} {song.parts?.includes("bass") ? ( ) : null} {song.parts?.includes("voice") || song.parts?.includes("vocals") ? ( ) : null} @@ -1128,7 +1157,7 @@ export function SongSearchPanel(props: { ) : null}

- {song.artist ?? "Unknown artist"} + {song.artist ?? t("states.unknownArtist")}

{song.album ? (

@@ -1142,30 +1171,30 @@ export function SongSearchPanel(props: {

{song.parts?.includes("lead") ? ( ) : null} {song.parts?.includes("rhythm") ? ( ) : null} {song.parts?.includes("bass") ? ( ) : null} {song.parts?.includes("voice") || song.parts?.includes("vocals") ? ( ) : null} @@ -1188,15 +1217,16 @@ export function SongSearchPanel(props: { ) : null} {song.creator ? (

- Charted by {song.creator} + {t("states.chartedBy", { creator: song.creator })}

) : null} {song.sourceUpdatedAt ? (

- Updated{" "} - {updatedDateFormatter.format( - new Date(song.sourceUpdatedAt) - )} + {t("states.updated", { + date: updatedDateFormatter.format( + new Date(song.sourceUpdatedAt) + ), + })}

) : null}
@@ -1263,10 +1293,6 @@ function getPathToneByValue(value: string) { } } -function getPathTokenFromLabel(value: string) { - return value.toLowerCase() === "lyrics" ? "voice" : value.toLowerCase(); -} - function haveSameSelectedValues(left: string[], right: string[]) { if (left.length !== right.length) { return false; @@ -1282,17 +1308,20 @@ function haveSameSelectedValues(left: string[], right: string[]) { function MultiSelectSelect(props: { label: string; - options: string[]; - selectedValues: string[]; + options: readonly string[]; + selectedValues: readonly string[]; onAdd: (value: string) => void; onRemove: (value: string) => void; + renderValue?: (value: string) => string; toneByValue?: (value: string) => string; }) { + const { t } = useLocaleTranslation("search"); const [open, setOpen] = useState(false); + const renderValue = props.renderValue ?? ((value: string) => value); const summary = props.selectedValues.length > 0 - ? `${props.selectedValues.length} selected` - : `Select ${props.label.toLowerCase()}`; + ? t("multiSelect.selected", { count: props.selectedValues.length }) + : t("multiSelect.select", { label: props.label }); return (
@@ -1317,18 +1346,20 @@ function MultiSelectSelect(props: { > - No matches found. + {t("multiSelect.noMatches")} {props.options.map((option) => { const selected = props.selectedValues.includes(option); + const optionLabel = renderValue(option); return ( { if (selected) { props.onRemove(option); @@ -1347,7 +1378,7 @@ function MultiSelectSelect(props: { checked={selected} className="pointer-events-none" /> - {option} + {optionLabel} ); })} @@ -1370,7 +1401,7 @@ function MultiSelectSelect(props: { "border-(--brand) bg-(--brand)/15 text-(--text)" )} > - {value} + {renderValue(value)} ))} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 7431b6d..b4ec817 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -14,7 +14,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "flex h-12 w-full items-center justify-between rounded-none border border-(--border) bg-(--panel-soft) px-4 py-3 text-sm text-(--text) shadow-none transition-[border-color,background,box-shadow] focus-visible:border-(--brand) focus-visible:bg-(--bg-elevated) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--brand) disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className )} {...props} diff --git a/src/lib/auth/session.server.ts b/src/lib/auth/session.server.ts index 2c0370e..dd59d34 100644 --- a/src/lib/auth/session.server.ts +++ b/src/lib/auth/session.server.ts @@ -1,4 +1,5 @@ import type { AppEnv } from "~/lib/env"; +import { localeCookieMaxAgeSeconds, localeCookieName } from "~/lib/i18n/config"; import { createId, sha256 } from "~/lib/utils"; const sessionCookieName = "rb_session"; @@ -66,6 +67,18 @@ export function clearSessionCookie(env: AppEnv) { return `${sessionCookieName}=; Path=/; HttpOnly; SameSite=Lax;${secureFlag(env)} Max-Age=0`; } +export function getLocaleCookie(request: Request) { + return parseCookie(request, localeCookieName); +} + +export function buildLocaleCookie(locale: string, env: AppEnv) { + return `${localeCookieName}=${encodeURIComponent(locale)}; Path=/; SameSite=Lax;${secureFlag(env)} Max-Age=${localeCookieMaxAgeSeconds}`; +} + +export function clearLocaleCookie(env: AppEnv) { + return `${localeCookieName}=; Path=/; SameSite=Lax;${secureFlag(env)} Max-Age=0`; +} + export async function createOauthStateCookie(env: AppEnv) { const state = createId("oauth"); const signature = await sha256(`${state}:${env.SESSION_SECRET}`); diff --git a/src/lib/bot-status.ts b/src/lib/bot-status.ts index 75d7c17..f9c3d37 100644 --- a/src/lib/bot-status.ts +++ b/src/lib/bot-status.ts @@ -1,37 +1,37 @@ -export function getBotStatusLabel(status: string) { +export function getBotStatusKey(status: string) { switch (status) { case "active": - return "Active"; + return "active"; case "active_offline_testing": - return "Offline testing enabled"; + return "activeOfflineTesting"; case "waiting_for_live": - return "Waiting to go live"; + return "waitingForLive"; case "bot_auth_required": - return "Bot auth required"; + return "botAuthRequired"; case "broadcaster_auth_required": - return "Broadcaster auth required"; + return "broadcasterAuthRequired"; case "subscription_error": - return "Subscription error"; + return "subscriptionError"; default: - return "Disabled"; + return "disabled"; } } -export function getBotStatusMessage(status: string) { +export function getBotStatusMessageKey(status: string) { switch (status) { case "active": - return "Ready for chat requests."; + return "active"; case "active_offline_testing": - return "Offline testing is on."; + return "activeOfflineTesting"; case "waiting_for_live": - return "The bot starts when you go live."; + return "waitingForLive"; case "bot_auth_required": - return "An admin needs to connect the bot."; + return "botAuthRequired"; case "broadcaster_auth_required": - return "Reconnect Twitch."; + return "broadcasterAuthRequired"; case "subscription_error": - return "There was a Twitch subscription issue."; + return "subscriptionError"; default: - return "The bot is off."; + return "disabled"; } } diff --git a/src/lib/db/latest-migration.generated.ts b/src/lib/db/latest-migration.generated.ts index 0916409..8896d76 100644 --- a/src/lib/db/latest-migration.generated.ts +++ b/src/lib/db/latest-migration.generated.ts @@ -1 +1 @@ -export const LATEST_MIGRATION_NAME = "0021_max_queue_size_default_50.sql"; +export const LATEST_MIGRATION_NAME = "0022_user_preferred_locale.sql"; diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index 04c1b5d..4a77875 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -1,3 +1,4 @@ +import "@tanstack/react-start/server-only"; import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"; import { tuningOptions } from "~/lib/channel-options"; import type { AppEnv } from "~/lib/env"; @@ -339,6 +340,20 @@ export async function upsertUserProfile( return user; } +export async function updateUserPreferredLocale( + env: AppEnv, + userId: string, + preferredLocale: string +) { + await getDb(env) + .update(users) + .set({ + preferredLocale, + updatedAt: Date.now(), + }) + .where(eq(users.id, userId)); +} + export async function saveTwitchAuthorization( env: AppEnv, input: { diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 1404d3a..1997279 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -16,6 +16,7 @@ export const users = sqliteTable("users", { login: text("login").notNull(), displayName: text("display_name").notNull(), profileImageUrl: text("profile_image_url"), + preferredLocale: text("preferred_locale"), isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false), createdAt: integer("created_at").notNull().default(sql`(unixepoch() * 1000)`), updatedAt: integer("updated_at").notNull().default(sql`(unixepoch() * 1000)`), diff --git a/src/lib/i18n/client.tsx b/src/lib/i18n/client.tsx new file mode 100644 index 0000000..ddc838b --- /dev/null +++ b/src/lib/i18n/client.tsx @@ -0,0 +1,97 @@ +import { createInstance, type i18n as I18nInstance } from "i18next"; +import ICU from "i18next-icu"; +import { + createContext, + type PropsWithChildren, + startTransition, + useContext, + useEffect, + useState, +} from "react"; +import { + I18nextProvider, + initReactI18next, + useTranslation, +} from "react-i18next"; +import { + persistExplicitDeviceLocale, + persistExplicitLocaleCookie, +} from "./detect"; +import { getI18nInitOptions } from "./init"; +import type { AppLocale } from "./locales"; + +type AppLocaleContextValue = { + locale: AppLocale; + setLocale(locale: AppLocale): Promise; + isSavingLocale: boolean; +}; + +const AppLocaleContext = createContext(null); + +function createI18n(locale: AppLocale) { + const instance = createInstance(); + + void instance.use(ICU).use(initReactI18next).init(getI18nInitOptions(locale)); + + return instance; +} + +export function AppI18nProvider( + props: PropsWithChildren<{ initialLocale: AppLocale }> +) { + const [locale, setLocaleState] = useState(props.initialLocale); + const [isSavingLocale, setIsSavingLocale] = useState(false); + const [i18n] = useState(() => createI18n(props.initialLocale)); + + useEffect(() => { + document.documentElement.lang = locale; + void i18n.changeLanguage(locale); + }, [i18n, locale]); + + async function setLocale(nextLocale: AppLocale) { + if (nextLocale === locale) { + return; + } + + startTransition(() => { + setLocaleState(nextLocale); + }); + persistExplicitDeviceLocale(nextLocale); + persistExplicitLocaleCookie(nextLocale); + setIsSavingLocale(true); + + try { + await fetch("/api/session/locale", { + method: "POST", + credentials: "include", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ locale: nextLocale }), + }); + } catch (error) { + console.error("Failed to persist locale preference", error); + } finally { + setIsSavingLocale(false); + } + } + + return ( + + {props.children} + + ); +} + +export function useAppLocale() { + const context = useContext(AppLocaleContext); + if (!context) { + throw new Error("useAppLocale must be used within AppI18nProvider."); + } + + return context; +} + +export function useLocaleTranslation(namespace?: string | string[]) { + return useTranslation(namespace); +} diff --git a/src/lib/i18n/config.ts b/src/lib/i18n/config.ts new file mode 100644 index 0000000..3799d21 --- /dev/null +++ b/src/lib/i18n/config.ts @@ -0,0 +1,14 @@ +export const localeCookieName = "rb_locale"; +export const localeStorageKey = "request-bot:locale"; +export const localeCookieMaxAgeSeconds = 60 * 60 * 24 * 365; + +export const websiteNamespaces = [ + "common", + "home", + "search", + "dashboard", + "admin", + "playlist", +] as const; + +export type WebsiteNamespace = (typeof websiteNamespaces)[number]; diff --git a/src/lib/i18n/detect.ts b/src/lib/i18n/detect.ts new file mode 100644 index 0000000..885a85d --- /dev/null +++ b/src/lib/i18n/detect.ts @@ -0,0 +1,63 @@ +import { + localeCookieMaxAgeSeconds, + localeCookieName, + localeStorageKey, +} from "./config"; +import { type AppLocale, defaultLocale, normalizeLocale } from "./locales"; + +export function resolveExplicitLocale(input: { + userPreferredLocale?: string | null; + storedLocale?: string | null; +}) { + return ( + normalizeLocale(input.userPreferredLocale) ?? + normalizeLocale(input.storedLocale) ?? + defaultLocale + ); +} + +export function readExplicitDeviceLocale() { + if (typeof window === "undefined") { + return null; + } + + try { + return normalizeLocale(window.localStorage.getItem(localeStorageKey)); + } catch { + return null; + } +} + +export function readExplicitLocaleCookie() { + if (typeof document === "undefined") { + return null; + } + + const cookie = document.cookie + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${localeCookieName}=`)); + + return normalizeLocale(cookie?.split("=").slice(1).join("=") ?? null); +} + +export function persistExplicitDeviceLocale(locale: AppLocale) { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem(localeStorageKey, locale); + } catch { + // Ignore local storage failures in restricted browser contexts. + } +} + +export function persistExplicitLocaleCookie(locale: AppLocale) { + if (typeof document === "undefined") { + return; + } + + const secureFlag = window.location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `${localeCookieName}=${encodeURIComponent(locale)}; Path=/; SameSite=Lax; Max-Age=${localeCookieMaxAgeSeconds}${secureFlag}`; +} diff --git a/src/lib/i18n/format.ts b/src/lib/i18n/format.ts new file mode 100644 index 0000000..b010ad7 --- /dev/null +++ b/src/lib/i18n/format.ts @@ -0,0 +1,17 @@ +import type { AppLocale } from "./locales"; + +export function formatDate( + locale: AppLocale, + value: number | string | Date, + options: Intl.DateTimeFormatOptions +) { + return new Intl.DateTimeFormat(locale, options).format(new Date(value)); +} + +export function formatNumber( + locale: AppLocale, + value: number, + options?: Intl.NumberFormatOptions +) { + return new Intl.NumberFormat(locale, options).format(value); +} diff --git a/src/lib/i18n/get-initial-locale.server.ts b/src/lib/i18n/get-initial-locale.server.ts new file mode 100644 index 0000000..a0da962 --- /dev/null +++ b/src/lib/i18n/get-initial-locale.server.ts @@ -0,0 +1,7 @@ +import { getCookie } from "@tanstack/react-start/server"; +import { localeCookieName } from "~/lib/i18n/config"; +import { defaultLocale, normalizeLocale } from "~/lib/i18n/locales"; + +export function getInitialLocaleFromRequest() { + return normalizeLocale(getCookie(localeCookieName)) ?? defaultLocale; +} diff --git a/src/lib/i18n/get-initial-locale.ts b/src/lib/i18n/get-initial-locale.ts new file mode 100644 index 0000000..d35fe35 --- /dev/null +++ b/src/lib/i18n/get-initial-locale.ts @@ -0,0 +1,13 @@ +import { createIsomorphicFn } from "@tanstack/react-start"; +import { readExplicitLocaleCookie } from "~/lib/i18n/detect"; +import { defaultLocale } from "~/lib/i18n/locales"; + +export const getInitialLocale = createIsomorphicFn() + .server(async () => { + const { getInitialLocaleFromRequest } = await import( + "~/lib/i18n/get-initial-locale.server" + ); + + return getInitialLocaleFromRequest(); + }) + .client(() => readExplicitLocaleCookie() ?? defaultLocale); diff --git a/src/lib/i18n/init.ts b/src/lib/i18n/init.ts new file mode 100644 index 0000000..01f9a10 --- /dev/null +++ b/src/lib/i18n/init.ts @@ -0,0 +1,53 @@ +import type { InitOptions } from "i18next"; +import { websiteNamespaces } from "./config"; +import { type AppLocale, defaultLocale } from "./locales"; +import { i18nResources } from "./resources"; + +const warnedMissingKeys = new Set(); +const shouldWarnOnMissingKeys = + import.meta.env.DEV && import.meta.env.MODE !== "test"; + +function warnMissingKey( + languages: readonly string[] | string, + namespaces: readonly string[] | string, + key: string +) { + if (!shouldWarnOnMissingKeys) { + return; + } + + const requestedLanguage = Array.isArray(languages) + ? (languages[0] ?? defaultLocale) + : languages; + const requestedNamespace = Array.isArray(namespaces) + ? (namespaces[0] ?? "common") + : namespaces; + const warningKey = `${requestedLanguage}:${requestedNamespace}:${key}`; + + if (warnedMissingKeys.has(warningKey)) { + return; + } + + warnedMissingKeys.add(warningKey); + console.warn( + `[i18n] Missing translation key "${requestedNamespace}.${key}" for locale "${requestedLanguage}".` + ); +} + +export function getI18nInitOptions(locale: AppLocale): InitOptions { + return { + resources: i18nResources, + lng: locale, + fallbackLng: defaultLocale, + supportedLngs: Object.keys(i18nResources), + defaultNS: "common", + ns: [...websiteNamespaces], + interpolation: { + escapeValue: false, + }, + initAsync: false, + returnNull: false, + saveMissing: shouldWarnOnMissingKeys, + missingKeyHandler: shouldWarnOnMissingKeys ? warnMissingKey : undefined, + }; +} diff --git a/src/lib/i18n/locales.ts b/src/lib/i18n/locales.ts new file mode 100644 index 0000000..c8c3e19 --- /dev/null +++ b/src/lib/i18n/locales.ts @@ -0,0 +1,38 @@ +export const supportedLocales = ["en", "es", "fr", "pt-BR"] as const; + +export type AppLocale = (typeof supportedLocales)[number]; + +export const defaultLocale: AppLocale = "en"; + +export const localeOptions: Array<{ value: AppLocale; nativeLabel: string }> = [ + { value: "en", nativeLabel: "English" }, + { value: "es", nativeLabel: "Español" }, + { value: "fr", nativeLabel: "Français" }, + { value: "pt-BR", nativeLabel: "Português (Brasil)" }, +]; + +export function isSupportedLocale(value: string): value is AppLocale { + return supportedLocales.includes(value as AppLocale); +} + +export function normalizeLocale(value: string | null | undefined) { + const normalized = value?.trim().replace(/_/g, "-"); + if (!normalized) { + return null; + } + + if (isSupportedLocale(normalized)) { + return normalized; + } + + const lower = normalized.toLowerCase(); + + if (lower === "pt-br") { + return "pt-BR"; + } + + const [baseLocale] = lower.split("-"); + return baseLocale === "en" || baseLocale === "es" || baseLocale === "fr" + ? baseLocale + : null; +} diff --git a/src/lib/i18n/metadata.ts b/src/lib/i18n/metadata.ts new file mode 100644 index 0000000..144f1bb --- /dev/null +++ b/src/lib/i18n/metadata.ts @@ -0,0 +1,41 @@ +import { + createInstance, + type i18n as I18nInstance, + type TOptions, +} from "i18next"; +import ICU from "i18next-icu"; +import { pageTitle } from "~/lib/page-title"; +import type { WebsiteNamespace } from "./config"; +import { getInitialLocale } from "./get-initial-locale"; +import { getI18nInitOptions } from "./init"; +import type { AppLocale } from "./locales"; + +const metadataI18nByLocale = new Map(); + +function getMetadataI18n(locale: AppLocale) { + const existing = metadataI18nByLocale.get(locale); + if (existing) { + return existing; + } + + const instance = createInstance(); + + void instance.use(ICU).init(getI18nInitOptions(locale)); + + metadataI18nByLocale.set(locale, instance); + return instance; +} + +export async function getLocalizedPageTitle(input: { + namespace: WebsiteNamespace; + key: string; + options?: TOptions; +}) { + const locale = await getInitialLocale(); + const title = getMetadataI18n(locale).t(input.key, { + ns: input.namespace, + ...input.options, + }); + + return pageTitle(title); +} diff --git a/src/lib/i18n/resources.ts b/src/lib/i18n/resources.ts new file mode 100644 index 0000000..74a1b97 --- /dev/null +++ b/src/lib/i18n/resources.ts @@ -0,0 +1,59 @@ +import adminEn from "./resources/en/admin.json"; +import commonEn from "./resources/en/common.json"; +import dashboardEn from "./resources/en/dashboard.json"; +import homeEn from "./resources/en/home.json"; +import playlistEn from "./resources/en/playlist.json"; +import searchEn from "./resources/en/search.json"; +import adminEs from "./resources/es/admin.json"; +import commonEs from "./resources/es/common.json"; +import dashboardEs from "./resources/es/dashboard.json"; +import homeEs from "./resources/es/home.json"; +import playlistEs from "./resources/es/playlist.json"; +import searchEs from "./resources/es/search.json"; +import adminFr from "./resources/fr/admin.json"; +import commonFr from "./resources/fr/common.json"; +import dashboardFr from "./resources/fr/dashboard.json"; +import homeFr from "./resources/fr/home.json"; +import playlistFr from "./resources/fr/playlist.json"; +import searchFr from "./resources/fr/search.json"; +import adminPtBr from "./resources/pt-br/admin.json"; +import commonPtBr from "./resources/pt-br/common.json"; +import dashboardPtBr from "./resources/pt-br/dashboard.json"; +import homePtBr from "./resources/pt-br/home.json"; +import playlistPtBr from "./resources/pt-br/playlist.json"; +import searchPtBr from "./resources/pt-br/search.json"; + +export const i18nResources = { + en: { + admin: adminEn, + common: commonEn, + dashboard: dashboardEn, + home: homeEn, + playlist: playlistEn, + search: searchEn, + }, + es: { + admin: adminEs, + common: commonEs, + dashboard: dashboardEs, + home: homeEs, + playlist: playlistEs, + search: searchEs, + }, + fr: { + admin: adminFr, + common: commonFr, + dashboard: dashboardFr, + home: homeFr, + playlist: playlistFr, + search: searchFr, + }, + "pt-BR": { + admin: adminPtBr, + common: commonPtBr, + dashboard: dashboardPtBr, + home: homePtBr, + playlist: playlistPtBr, + search: searchPtBr, + }, +} as const; diff --git a/src/lib/i18n/resources/en/admin.json b/src/lib/i18n/resources/en/admin.json new file mode 100644 index 0000000..58e1383 --- /dev/null +++ b/src/lib/i18n/resources/en/admin.json @@ -0,0 +1,126 @@ +{ + "page": { + "title": "Admin", + "description": "Manage shared bot access and offline testing controls.", + "noAccess": "You do not have access to the admin dashboard.", + "authNotice": "Complete Twitch auth as", + "botAccount": "Bot account", + "botMismatch": "Connected as {connected}. Expected {expected}. Reconnect to switch accounts." + }, + "states": { + "offlineTestingFailed": "Offline testing setting could not be updated.", + "botUpdateFailed": "Bot account could not be updated.", + "updated": "Updated." + }, + "status": { + "connected": "Connected", + "needsAuth": "Needs auth", + "enabled": "Enabled", + "disabled": "Disabled" + }, + "actions": { + "connectBot": "Connect {username}", + "reconnectBot": "Reconnect bot", + "reconnecting": "Reconnecting...", + "enable": "Enable", + "enabling": "Enabling...", + "disable": "Disable", + "disabling": "Disabling..." + }, + "metrics": { + "requestIssues": { + "label": "Request issues total", + "description": "Blocked, rejected, and error results across all request logs." + }, + "auditRows": { + "label": "Audit rows total", + "description": "Recorded admin and channel-management actions." + } + }, + "offlineTesting": { + "title": "Offline bot testing" + }, + "prototype": { + "title": "Playlist row prototype", + "description": "Preview of a more condensed playlist item with always-visible song versions.", + "cardTitle": "Condensed multi-version playlist item", + "cardDescription": "This preview uses the same playlist-item component as the live playlist page, with a demo song that has two versions." + }, + "logs": { + "title": "Request logs", + "description": "Newest request attempts across chat and viewer request flows. Each row shows the request, who sent it, the result, and any recorded match or reason.", + "loading": "Loading request logs...", + "empty": "No request logs yet." + }, + "audits": { + "title": "Audit records", + "description": "Newest admin and channel-management actions recorded for this channel. Each row shows what changed, who triggered it, and the key saved values.", + "loading": "Loading audit records...", + "empty": "No audit records yet." + }, + "pagination": { + "show": "Show", + "previous": "Previous", + "next": "Next", + "updating": "Updating...", + "showingEmpty": "Showing 0 results", + "showingRange": "Showing {start}-{end} of {total}" + }, + "table": { + "loading": "Loading...", + "unknownTime": "Unknown time", + "noExtraValues": "No extra values were recorded.", + "none": "None", + "yes": "Yes", + "no": "No" + }, + "requestLog": { + "columns": { + "time": "Time", + "request": "Request", + "requester": "Requester", + "result": "Result", + "details": "Details" + }, + "query": "Query: {query}", + "unknownUser": "Unknown user", + "matched": "Matched: {title}{artist}", + "reason": "Reason: {reason}", + "noMatchOrReason": "No match or reason was recorded." + }, + "auditLog": { + "columns": { + "time": "Time", + "action": "Action", + "entity": "Entity", + "actor": "Actor", + "details": "Details" + } + }, + "labels": { + "accepted": "Accepted", + "blocked": "Blocked", + "error": "Error", + "system": "System", + "auto_grant_vip_tokens_cheer": "Auto-grant VIP tokens: cheer", + "auto_grant_vip_tokens_gift_recipient": "Auto-grant VIP tokens: gifted sub recipient", + "auto_grant_vip_tokens_new_subscriber": "Auto-grant VIP tokens: new paid sub", + "auto_grant_vip_tokens_raid": "Auto-grant VIP tokens: raid", + "auto_grant_vip_tokens_shared_sub_renewal_message": "Auto-grant VIP tokens: shared sub renewal message", + "auto_grant_vip_tokens_streamelements_tip": "Auto-grant VIP tokens: StreamElements tip", + "auto_grant_vip_tokens_sub_gifter": "Auto-grant VIP tokens: gifted sub gifter", + "grantedTokenCount": "Tokens granted", + "minimumRaidViewerCount": "Minimum raid size", + "totalGiftedSubs": "Gifted subs", + "twitchMessageId": "EventSub message ID", + "vip_token": "VIP token" + }, + "auditSources": { + "channelCheer": "Cheer", + "channelRaid": "Raid", + "channelSubscribe": "Channel subscribe", + "channelSubscriptionGift": "Gifted sub", + "channelSubscriptionMessage": "Shared sub renewal message", + "streamElementsTip": "StreamElements tip" + } +} diff --git a/src/lib/i18n/resources/en/common.json b/src/lib/i18n/resources/en/common.json new file mode 100644 index 0000000..13f99ef --- /dev/null +++ b/src/lib/i18n/resources/en/common.json @@ -0,0 +1,19 @@ +{ + "brand": { + "name": "RockList.Live" + }, + "nav": { + "search": "Search", + "account": "Account" + }, + "auth": { + "goToPlaylist": "Go to your playlist.", + "logOut": "Log out", + "signIn": "Sign in with Twitch", + "permissionsRefresh": "Twitch permissions need to be refreshed for this account.", + "reconnect": "Reconnect Twitch" + }, + "language": { + "label": "Language" + } +} diff --git a/src/lib/i18n/resources/en/dashboard.json b/src/lib/i18n/resources/en/dashboard.json new file mode 100644 index 0000000..aa26c46 --- /dev/null +++ b/src/lib/i18n/resources/en/dashboard.json @@ -0,0 +1,314 @@ +{ + "nav": { + "channels": "Channels", + "settings": "Settings", + "admin": "Admin" + }, + "botStatus": { + "active": "Active", + "activeOfflineTesting": "Offline testing enabled", + "waitingForLive": "Waiting to go live", + "botAuthRequired": "Bot auth required", + "broadcasterAuthRequired": "Broadcaster auth required", + "subscriptionError": "Subscription error", + "disabled": "Disabled" + }, + "overview": { + "title": "Account", + "description": "Channel access and owner settings.", + "cards": { + "openPlaylist": "Open your playlist", + "manageSettings": "Manage channel settings" + }, + "indicators": { + "channelStatus": "Channel Status", + "requests": "Requests", + "bot": "Bot" + }, + "status": { + "live": "Live", + "offline": "Offline", + "unavailable": "Unavailable", + "enabled": "Enabled", + "paused": "Paused" + }, + "moderatedChannels": { + "title": "Online channels you moderate", + "reconnect": "Reconnect Twitch to refresh your moderated channel access.", + "openChannel": "Open channel", + "empty": "No moderated channels are live right now." + }, + "notesTitle": "Notes", + "notes": { + "enableBot": { + "title": "Enable the bot", + "body": "Turn on bot control in Settings before using requests on your channel." + }, + "offlineTesting": { + "title": "Offline testing is on", + "body": "Requests stay available while you test the bot offline." + }, + "goLive": { + "title": "Go live to start taking requests", + "body": "Requests are meant to run while your channel is live." + }, + "requestsPaused": { + "title": "Requests are paused", + "body": "Re-enable requests in Settings when you want viewers to add songs again." + }, + "channelReady": { + "title": "Channel is ready", + "body": "Your stream is live and requests are available." + }, + "checkStatus": { + "title": "Check channel status", + "body": "Make sure the bot is connected before you start taking requests." + } + } + }, + "settings": { + "header": { + "title": "Settings", + "description": "Owner-only channel configuration for requests, moderators, and the stream overlay.", + "betaNote": "Beta testers can install the Twitch extension panel on Twitch.", + "previewPanel": "Preview mod panel", + "installExtension": "Install Twitch extension beta" + }, + "states": { + "failedToLoad": "Failed to load settings.", + "failedToSave": "Settings could not be saved.", + "loadingAccess": "Loading settings access...", + "loading": "Loading settings...", + "saveFailed": "Save failed. Review the message below and try again.", + "saving": "Saving settings...", + "unsavedChanges": "You have unsaved changes.", + "allChangesSaved": "All changes are saved.", + "saved": "Saved", + "savingButton": "Saving..." + }, + "actions": { + "saveSettings": "Save settings", + "reconnectTwitch": "Reconnect Twitch", + "copyUrl": "Copy URL", + "copied": "Copied", + "show": "Show", + "hide": "Hide" + }, + "ownerOnly": { + "title": "Owner settings only", + "description": "This area is only available for channels you own. Use the channel page for moderation work on channels you manage.", + "openChannel": "Open channel page", + "signInHint": "Sign in with a streamer account that owns a channel to manage owner settings here." + }, + "sections": { + "channelSetup": { + "title": "Channel setup", + "description": "Control the main playlist toggles, chat command, and viewer-facing playlist behavior.", + "reconnectNotice": "Twitch permissions need to be refreshed before the bot can run.", + "mainToggles": "Main toggles", + "enableBot": "Enable bot on this channel", + "enableBotHelp": "Turn this off when you do not want request-bot actions, including awarding VIP tokens for subscriptions, raids, etc.", + "enableRequests": "Enable requests", + "enableRequestsHelp": "Turning requests off keeps playlist management available to you and your moderators, but viewers cannot add songs.", + "playlistDisplay": "Playlist display", + "showPositions": "Show playlist positions", + "commandPrefix": "Command prefix", + "commandPrefixHelp": "Use the command viewers type in chat.", + "requestModifiers": "Request modifiers", + "requestModifiersHelp": "Let chat request bass arrangements with the *bass modifier.", + "allowBassModifier": "Allow the *bass modifier in chat commands", + "duplicateCooldown": "Duplicate cooldown (minutes)", + "duplicateCooldownHelp": "Set how long the same song waits before it can be requested again." + }, + "requestAccess": { + "title": "Who can request", + "description": "Choose which viewers can add songs to your playlist.", + "anyone": "Anyone can request", + "subscribers": "Subscribers can request", + "vips": "Channel VIPs can request" + }, + "filters": { + "title": "Search and request filters", + "description": "These rules limit what appears in search and what viewers can request on your channel page.", + "officialDlc": { + "title": "Official DLC", + "description": "Limit search results and requests to official DLC.", + "notice": "Official DLC filters are not available yet. This section previews settings that appear here in a future update.", + "onlyOfficial": "Only include official DLC", + "onlyOwned": "Only include official DLC that I own", + "ownedTitle": "Owned official DLC", + "import": "Import from CustomsForge Song Manager", + "owned": "Owned", + "notOwned": "Not owned" + }, + "allowedTunings": { + "title": "Allowed tunings", + "description": "Click any tuning to allow or block it.", + "allowAll": "Allow all", + "groups": { + "open": "Open", + "other": "Other" + } + }, + "requiredPaths": { + "title": "Required paths", + "description": "Choose the paths a song needs before it appears in search or can be requested.", + "matchAny": "Match any selected path", + "matchAll": "Match all selected paths", + "none": "No path filter is active. Songs can match with any path combination.", + "singlePrefix": "Songs must include", + "singleSuffix": "They can still include any other paths.", + "exampleLabel": "Example: a song with", + "singleExample": "still matches.", + "matchAnyPrefix": "Songs match if they include at least one of", + "matchAnySuffix": "They can still include any other paths.", + "matchAnyExample": "still matches because it includes one selected path.", + "matchAllPrefix": "Songs match only if they include all of", + "matchAllSuffix": "They can still include any other paths.", + "matchAllExample": "still matches because it includes every selected path.", + "paths": { + "lyrics": "Lyrics" + } + } + }, + "queueLimits": { + "title": "Queue and rate limits", + "description": "Set the playlist size and decide how often regular or VIP requests can be added.", + "queueLimits": "Queue limits", + "maxPlaylist": "Maximum playlist size", + "maxPerViewer": "Max requests per viewer", + "maxPerSubscriber": "Max requests per subscriber", + "maxVipPerViewer": "Max VIP requests per viewer", + "maxVipPerSubscriber": "Max VIP requests per subscriber", + "rateLimits": "Request rate limits", + "regular": "Regular", + "enableRegular": "Enable regular request rate limit", + "regularAllowed": "Regular requests allowed", + "regularPeriod": "Regular period (seconds)", + "vip": "VIP", + "enableVip": "Enable VIP request rate limit", + "vipAllowed": "VIP requests allowed", + "vipPeriod": "VIP period (seconds)" + }, + "vipAutomation": { + "title": "VIP token automation", + "description": "Automatically reward VIP tokens for new subs, gifted subs, raids, cheers, and StreamElements tips.", + "subscribes": "Subscribes", + "newSub": "Give 1 VIP token for a new paid sub", + "sharedRenewal": "Give 1 VIP token for a shared sub renewal message", + "sharedRenewalHelp": "Quiet sub renewals do not award automatically. Twitch only sends a renewal event here when the viewer shares the resub message in chat.", + "giftedSubs": "Gifted subs", + "subGifter": "Give 1 VIP token to the gifter for each gifted sub", + "subRecipient": "Give 1 VIP token to each gifted sub recipient", + "raids": "Raids", + "raidReward": "Give 1 VIP token to the streamer who raids this channel", + "minimumRaid": "Minimum raid size", + "minimumRaidHelp": "Set 1 to reward every raid.", + "raidNotice": "Twitch only sends raid rewards here when the raid shows up in chat.", + "cheers": "Cheers", + "cheersToggle": "Give VIP tokens for cheers", + "cheerConversion": "Cheer conversion", + "bitsPerToken": "bits per 1 VIP token", + "minimumCheer": "Minimum cheer to earn a partial token", + "liveExample": "Live example", + "minimumCheerExample": "Minimum cheer: {bits} bits grants {tokenCount} of a VIP token at the {percent}% threshold.", + "bitsExample": "{oneTokenBits} bits grants 1 VIP token. {fiveTokenBits} bits grants 5 VIP tokens.", + "bitsExampleEmpty": "Set the bits per VIP token above 0 to preview the minimum cheer threshold.", + "tips": "Tips", + "tipsToggle": "Give VIP tokens for StreamElements tips", + "tipAmount": "Tip amount per 1 VIP token", + "tipAmountHelp": "A $25 tip grants 5 VIP tokens when this is set to 5.", + "relayUrl": "Relay URL", + "relayUrlHelp": "Use this URL in the Streamer.bot step that forwards your StreamElements tip event.", + "setup": "Setup", + "setupHelp": "StreamElements can keep showing tip alerts in OBS the way you already use them. VIP token rewards need Streamer.bot to forward the tip event here.", + "setupSteps": { + "connect": "Connect Streamer.bot to StreamElements.", + "trigger": "Use the StreamElements Tip trigger in Streamer.bot.", + "send": "Send that tip event to the Relay URL shown here.", + "note": "Without Streamer.bot, tips still show in OBS, but they do not add VIP tokens here." + } + }, + "rules": { + "title": "Blacklist and setlist rules", + "description": "Blacklist and setlist entries are managed on the channel page. These toggles control how those rules are enforced.", + "enableBlacklist": "Enable blacklist", + "bypassBlacklist": "Let setlist bypass blacklist", + "enableSetlist": "Enable setlist", + "subscribersFollowSetlist": "Subscribers must follow setlist" + }, + "moderatorPermissions": { + "title": "Moderator permissions", + "description": "Moderators always see VIP tokens. Turn other channel-management actions on or off here.", + "manageRequests": "Manage requests", + "manageBlacklist": "Manage blacklist", + "manageSetlist": "Manage setlist", + "manageBlockedViewers": "Manage blocked viewers", + "manageVipTokens": "Manage VIP tokens", + "manageTags": "Manage tags" + } + } + }, + "overlay": { + "title": "Stream overlay", + "description": "Display your playlist on your stream using a browser source.", + "channelFallback": "Your channel", + "states": { + "failedToLoad": "Failed to load overlay settings.", + "failedPreview": "Failed to load playlist preview.", + "failedToSave": "Overlay settings could not be saved.", + "saved": "Overlay settings saved.", + "copied": "Overlay URL copied." + }, + "url": { + "title": "Overlay URL", + "copy": "Copy URL", + "open": "Open overlay" + }, + "layout": { + "title": "Layout and behavior", + "showCreator": "Show creator", + "showAlbum": "Show album", + "animateNowPlaying": "Animate now playing" + }, + "theme": { + "title": "Theme", + "accent": "Accent", + "vipBadge": "VIP badge", + "text": "Text", + "mutedText": "Muted text", + "requestBackground": "Request item background", + "backgroundColor": "Background color", + "backgroundColorHelp": "For a transparent background, set background opacity to 0.", + "border": "Border" + }, + "sizing": { + "title": "Density and sizing", + "backgroundOpacity": "Overlay background opacity", + "backgroundOpacityHelp": "Set this to 0 for a fully transparent background behind the playlist items.", + "cornerRadius": "Corner radius", + "itemGap": "Item gap", + "itemPadding": "Item padding", + "titleFontSize": "Title font size", + "metaFontSize": "Meta font size", + "value": "Value" + }, + "actions": { + "restoreDefaults": "Restore defaults", + "saveChanges": "Save changes", + "saving": "Saving...", + "cancel": "Cancel" + }, + "preview": { + "title": "Preview", + "live": "Live", + "sample": "Sample", + "channelTitle": "Playlist for {channel}" + }, + "restoreDialog": { + "title": "Restore defaults?", + "description": "This resets the overlay editor to the default theme. Unsaved changes will be lost.", + "confirm": "Restore defaults" + } + } +} diff --git a/src/lib/i18n/resources/en/home.json b/src/lib/i18n/resources/en/home.json new file mode 100644 index 0000000..b963d13 --- /dev/null +++ b/src/lib/i18n/resources/en/home.json @@ -0,0 +1,45 @@ +{ + "meta": { + "title": "Home" + }, + "hero": { + "eyebrow": "Playlist management for Rocksmith streamers", + "title": "Search songs or manage your channel." + }, + "actions": { + "findSong": "Find a song to request", + "searchSongs": "Search songs", + "manageChannel": "Manage your channel", + "openSettings": "Open settings" + }, + "about": { + "eyebrow": "What is RockList.Live?", + "body": "RockList.Live helps Rocksmith streamers take requests, manage the playlist, and keep the show moving." + }, + "features": { + "surfaceRequestsTitle": "Requests from every surface", + "surfaceRequestsBody": "Viewers add songs on the playlist page, in chat, or from the Twitch panel.", + "moderationTitle": "Moderator support", + "moderationBody": "Moderators manage requests and handle VIP bumps while the stream is live.", + "queueTitle": "Keep the queue moving", + "queueBody": "Edit, sort, and track requests without losing the next song.", + "rulesTitle": "Set the rules", + "rulesBody": "Control blacklists, setlists, moderation, and request settings for your channel." + }, + "live": { + "eyebrow": "Live now", + "title": "Current streamers", + "showLive": "Show live", + "showDemo": "Show demo", + "demoOnly": "The streamers shown here are for demo purposes only.", + "activeCount": "{count} active", + "empty": "No streamers are live with the bot active yet.", + "previewAlt": "{displayName} live stream preview", + "status": "Live", + "nowPlaying": "Now playing", + "upNext": "Up next", + "nextRequest": "Next request", + "openPlaylist": "Open playlist", + "watchOnTwitch": "Watch on Twitch" + } +} diff --git a/src/lib/i18n/resources/en/playlist.json b/src/lib/i18n/resources/en/playlist.json new file mode 100644 index 0000000..01328cb --- /dev/null +++ b/src/lib/i18n/resources/en/playlist.json @@ -0,0 +1,370 @@ +{ + "page": { + "title": "{channel}'s Playlist", + "loading": "Loading playlist...", + "empty": "This playlist is empty right now.", + "viewerStateFailed": "Viewer request state failed to load.", + "viewerToolsFailed": "Viewer request tools failed to load.", + "requestsLiveOnly": "You can add requests when the stream goes live." + }, + "states": { + "updateRequestToggleFailed": "Unable to update the request toggle right now.", + "unableToAddSong": "Unable to add the song.", + "unableToUpdateRequest": "Unable to update your request.", + "requestUpdated": "Request updated.", + "unableToRemoveRequests": "Unable to remove your requests.", + "requestsRemoved": "Requests removed." + }, + "search": { + "title": "Search to add a song", + "placeholder": "Search songs for {channel}", + "actions": { + "add": "Add", + "request": "Request", + "actions": "Actions" + }, + "showBlacklisted": "Show blacklisted songs", + "pathWarning": "Doesn't match the channel default {count, plural, one {path} other {paths}}: {paths}." + }, + "viewerSummary": { + "vipTokensLabel": "{count} VIP tokens", + "vipBalanceLoading": "VIP balance...", + "vipTokensShort": "VIP tokens", + "requestsWithLimit": "{count}/{limit} reqs", + "requestsNoLimit": "{count} reqs", + "vipBalanceSummary": "{count} VIP tokens available", + "vipBalanceChecking": "Checking your VIP token balance...", + "vipBalanceUnavailable": "Your VIP token balance is unavailable right now.", + "replaceQueued": "New adds replace your queued requests.", + "vipHelp": "VIP token help", + "open": "Open", + "noRequests": "No requests in the playlist.", + "removeQueued": "Remove queued requests", + "removing": "Removing..." + }, + "badges": { + "vip": "VIP", + "regular": "Regular", + "nowPlaying": "Now playing", + "pick": "Pick {count}", + "requestsOn": "Requests are on", + "requestsOff": "Requests are off", + "turnRequestsOn": "Turn requests on", + "turnRequestsOff": "Turn requests off", + "online": "Online", + "offline": "Offline", + "vipBalance": "You have {count} VIP tokens", + "vipTokens": "VIP tokens" + }, + "vipInfo": { + "earn": "Earn VIP tokens", + "manualOnly": "This channel grants VIP tokens manually right now.", + "use": "Use VIP tokens" + }, + "history": { + "title": "Played history", + "titleWithChannel": "{channel}'s played history", + "show": "Show history", + "hide": "Hide history", + "songSearch": "Song search", + "searchPlaceholder": "Song, artist, album, or charter", + "clearSearch": "Clear search", + "requester": "Requester", + "requesterPlaceholder": "Search a requester", + "clearRequester": "Clear requester", + "clearRequesterFilter": "Clear requester filter", + "searchingRequesters": "Searching requesters...", + "loading": "Loading played history...", + "emptyFiltered": "No played songs matched those filters.", + "empty": "No songs have been marked played yet.", + "requestCount": "{count, plural, one {# request} other {# requests}}", + "byArtist": " by {artist}", + "requestedBy": "Requested by {requester}", + "page": "Page {page}", + "unknownRequester": "Unknown" + }, + "rules": { + "sectionTitle": "Channel rules", + "sectionTitleWithChannel": "{channel}'s channel rules", + "states": { + "updateFailed": "Unable to update channel rules.", + "searchFailed": "Search failed." + }, + "blacklistedArtists": "Blacklisted artists", + "blacklistedCharters": "Blacklisted charters", + "blacklistedSongs": "Blacklisted songs", + "blacklistedVersions": "Blacklisted versions", + "setlistArtists": "Setlist artists", + "searchArtists": "Search artists by name", + "searchCharters": "Search charters by name", + "searchSongs": "Search songs by title", + "searchMin": "Type at least 2 characters to search.", + "noMatches": "No matching entries to add.", + "add": "Add", + "remove": "Remove", + "trackCount": "{count, plural, one {# track} other {# tracks}}", + "versionCount": "{count, plural, one {# version} other {# versions}} - Song group {groupId}", + "artistId": "Artist ID {id}", + "charterId": "Charter ID {id}", + "songGroupId": "Song group {groupId}", + "versionId": "Version ID {id}", + "noBlacklistedArtists": "No blacklisted artists.", + "noBlacklistedCharters": "No blacklisted charters.", + "noBlacklistedSongs": "No blacklisted songs.", + "noBlacklistedVersions": "No blacklisted versions.", + "noSetlistArtists": "No setlist artists." + }, + "community": { + "title": "Moderator controls", + "reconnect": "Reconnect Twitch", + "reconnectMessage": "Reconnect Twitch to prioritize viewers currently in chat.", + "currentChattersFirst": "Current chatters first", + "twitchMatches": "Twitch matches", + "resultCount": "{count, plural, one {# result} other {# results}}", + "inChat": "In chat", + "selected": "Selected", + "noMatchingUsers": "No matching Twitch usernames.", + "states": { + "updateFailed": "Unable to update channel community settings.", + "lookupFailed": "User lookup failed." + }, + "blocks": { + "title": "Blocked viewers", + "searchPlaceholder": "Search Twitch username to block", + "blockViewer": "Block viewer", + "searchMin": "Type at least 4 characters to search Twitch usernames.", + "description": "Blocked viewers can still talk in Twitch chat, but they cannot add or edit requests from chat, the website, or the extension panel.", + "defaultReason": "Blocked from making requests in this channel.", + "unblock": "Unblock", + "empty": "No blocked viewers." + }, + "vip": { + "title": "VIP tokens", + "searchPlaceholder": "Search Twitch username to grant a token", + "grant": "Grant token", + "searchMin": "Type at least 4 characters to search Twitch usernames.", + "searchMinExisting": "Existing VIP token holders appear below. Type at least 4 characters to search all Twitch usernames.", + "tokenHoldersFirst": "VIP token holders first", + "tokenCountSingle": "{count} token", + "tokenCountPlural": "{count} tokens", + "viewOnly": "You can view VIP balances, but only the broadcaster or an allowed moderator can change them.", + "username": "Username", + "tokens": "Tokens", + "showingRange": "Showing {start}-{end} of {total}", + "previous": "Previous", + "next": "Next", + "pageOf": "Page {page} of {total}", + "empty": "No VIP tokens yet.", + "noticeAddedSingle": "Added 1 token", + "noticeAddedMultiple": "Added {count} VIP tokens", + "noticeRemovedSingle": "Removed 1 token", + "noticeRemovedMultiple": "Removed {count} VIP tokens", + "noticeSaved": "Saved VIP tokens" + } + }, + "viewerActions": { + "add": "Add", + "addVip": "Add VIP", + "adding": "Adding...", + "alreadyVip": "Already in your queue as a VIP request.", + "alreadyRegular": "Already in your queue as a regular request.", + "blacklistedPrefix": "Blacklisted", + "songUnavailable": "That song is unavailable here.", + "checkingAccess": "Checking your request access...", + "cannotRequest": "You cannot request songs here.", + "alreadyActive": "This song is already in your active requests.", + "activeLimitReached": "You already have {count, plural, one {# active request} other {# active requests}}.", + "insufficientVipTokens": "You do not have enough VIP tokens." + }, + "specialRequest": { + "titleManage": "Add a custom request", + "titleViewer": "Request by artist", + "artist": "Artist", + "artistPlaceholder": "Artist name", + "artistMin": "Type at least 2 characters from an artist name.", + "chooseMode": "Choose mode", + "chooseType": "Choose type", + "random": "Random", + "choice": "Choice", + "regular": "Regular", + "add": "Add", + "addVip": "Add VIP", + "adding": "Adding...", + "randomHelp": "Adds a random song from the matching songs for this artist.", + "choiceHelp": "Adds a streamer choice request for this artist." + }, + "manageActions": { + "add": "Add", + "adding": "Adding...", + "addForUser": "Add for user", + "searchViewers": "Search current viewers", + "searchMin": "Type at least 2 characters to search current viewers.", + "reconnectMessage": "Reconnect Twitch to search viewers currently in chat.", + "reconnect": "Reconnect", + "searching": "Searching current viewers...", + "noMatches": "No current viewers matched that username." + }, + "row": { + "viewer": "viewer", + "added": "Added {time}", + "edited": "Edited {time}", + "streamerChoiceTitle": "Streamer choice: {query}", + "streamerChoiceSubtitle": "Streamer picks the exact song.", + "relative": { + "now": "now", + "minutes": "{count}m", + "hours": "{count}h", + "days": "{count}d" + }, + "picks": { + "first": "1st pick", + "second": "2nd pick", + "third": "3rd pick" + } + }, + "management": { + "states": { + "searchFailed": "Search failed.", + "playlistUpdateFailed": "Playlist update failed.", + "moderationUnavailable": "Moderation actions are unavailable for this channel.", + "blacklistUpdateFailed": "Blacklist update failed." + }, + "header": { + "managing": "Managing {channel}", + "channel": "Channel: {channel}" + }, + "manual": { + "title": "Add a song", + "requesterPlaceholder": "Requester username (optional)", + "searchPlaceholder": "Search and add a song", + "searchMin": "Search terms must be at least 3 characters.", + "table": { + "track": "Track", + "albumCreator": "Album / Creator", + "tuningPath": "Tuning / Path", + "add": "Add" + }, + "unknownArtist": "Unknown artist", + "unknownAlbum": "Unknown album", + "chartedBy": "Charted by {creator}", + "unknownCreator": "Unknown creator", + "blacklisted": "Blacklisted", + "noTuningInfo": "No tuning info", + "noPathInfo": "No path info", + "addButton": "Add" + }, + "actions": { + "shuffle": "Shuffle", + "clearPlaylist": "Clear playlist", + "resetSession": "Reset session", + "confirmClear": "Empty the entire playlist? This cannot be undone.", + "confirmReset": "Reset the session? This will clear the current playlist." + }, + "currentTitle": "Current playlist", + "blacklistErrors": { + "missingRequestVersionId": "This request does not have a version ID to blacklist.", + "missingVersionVersionId": "This version does not have a version ID to blacklist.", + "missingRequestSongGroupId": "This request does not have a song group ID to blacklist.", + "missingRequestArtistId": "This request does not have an artist ID to blacklist.", + "missingVersionCharterId": "This version does not have a charter ID to blacklist.", + "unknownArtist": "Unknown artist", + "unknownCharter": "Unknown charter" + }, + "blacklistPanelDescription": "Artists, charters, songs, and specific versions can be blocked for this channel.", + "history": { + "title": "Played history", + "requestedBy": "Requested by {requester}", + "played": "Played {time}", + "restore": "Restore", + "restoring": "Restoring...", + "empty": "Nothing has been marked played yet." + }, + "deleteDialog": { + "title": "Remove this request from playlist?", + "descriptionWithArtist": "This removes \"{title}\" by {artist} from the playlist. This cannot be undone.", + "descriptionWithoutArtist": "This removes \"{title}\" from the playlist. This cannot be undone.", + "descriptionFallback": "This removes the selected request from the playlist. This cannot be undone.", + "keep": "Keep request", + "removing": "Removing...", + "remove": "Remove request" + }, + "queue": { + "empty": "No songs in the playlist yet." + }, + "relative": { + "now": "just now", + "minutesAgo": "{count, plural, one {# minute ago} other {# minutes ago}}", + "hoursAgo": "{count, plural, one {# hour ago} other {# hours ago}}", + "daysAgo": "{count, plural, one {# day ago} other {# days ago}}" + }, + "item": { + "moveTop": "Move to top", + "moveUp": "Move up", + "moveDown": "Move down", + "moveBottom": "Move to bottom", + "reorderAria": "Reorder {title}", + "vipBadge": "VIP", + "playingBadge": "Playing", + "warningBadge": "Warning", + "versionBlacklisted": "Version blacklisted", + "songBlacklisted": "Song blacklisted", + "artistBlacklisted": "Artist blacklisted", + "requestedBy": "Requested by {requester}", + "vipTokens": "{count} VIP tokens", + "added": "Added {time}", + "requestedText": "Requested text: {query}", + "saving": "Saving...", + "makeRegular": "Make regular", + "makeVip": "Make VIP", + "returnToQueue": "Return to queue", + "playNow": "Play now", + "markComplete": "Mark complete", + "versionsCount": "{count, plural, one {# version} other {# versions}}", + "downloadFromCf": "Download from CF" + }, + "actionsMenu": { + "openActionsAria": "Open actions for {title}", + "removing": "Removing...", + "removeFromPlaylist": "Remove from playlist", + "blacklist": "Blacklist", + "blacklistTitle": "Blacklist actions", + "back": "Back", + "description": "Choose whether to block the queued version, every version of this song, the artist, or a charter.", + "queuedVersionId": "Queued version ID {id}", + "noQueuedVersionId": "No queued version ID", + "versionBlocked": "Version blacklisted", + "blacklistQueuedVersion": "Blacklist queued version", + "blockVersionDescription": "Blocks only version ID {id}.", + "blockVersionFallbackDescription": "Blocks only the exact version attached to this request.", + "songBlocked": "Song blacklisted", + "blacklistSongGroup": "Blacklist all versions of this song", + "blockSongDescription": "Blocks every version grouped under this song.", + "artistBlocked": "Artist blacklisted: {artist}", + "blacklistArtist": "Blacklist artist: {artist}", + "blockArtistDescription": "Blocks every song by this artist ID.", + "charterBlocked": "Charter blacklisted: {charter}", + "blacklistCharter": "Blacklist charter: {charter}", + "blockCharterDescription": "Blocks every song by this charter ID.", + "noCharterIds": "No charter IDs available for these versions." + }, + "versionsTable": { + "songAlbum": "Song / album", + "tunings": "Tunings", + "paths": "Paths", + "updated": "Updated", + "downloads": "Downloads", + "actions": "Actions", + "unknownArtist": "Unknown artist", + "chartedBy": "Charted by", + "charterBlacklisted": "Charter blacklisted", + "unknown": "Unknown", + "download": "Download", + "blacklisted": "Blacklisted", + "blacklist": "Blacklist" + }, + "preview": { + "removed": "The demo row is removed from the playlist.", + "restore": "Restore demo row" + } + } +} diff --git a/src/lib/i18n/resources/en/search.json b/src/lib/i18n/resources/en/search.json new file mode 100644 index 0000000..39858f4 --- /dev/null +++ b/src/lib/i18n/resources/en/search.json @@ -0,0 +1,79 @@ +{ + "meta": { + "title": "Search Songs" + }, + "page": { + "title": "Search", + "infoNote": "This demo only contains {count} songs.", + "placeholder": "Search by song title, artist or album" + }, + "summary": { + "note": "Note:", + "foundCount": "Found {count} songs", + "filters": "Filters", + "moreCount": "+{count} more", + "changeFilters": "Change filters" + }, + "controls": { + "searchField": "Search field", + "allFields": "All fields", + "titleOnly": "Title only", + "artistOnly": "Artist only", + "albumOnly": "Album only", + "creatorOnly": "Creator only", + "showFilters": "Show filters", + "hideFilters": "Hide filters", + "title": "Title", + "artist": "Artist", + "album": "Album", + "creator": "Creator", + "tuning": "Tuning", + "path": "Path", + "year": "Year", + "actions": "Actions", + "clearAdvancedFilters": "Clear filters", + "matchAny": "Match any", + "matchAll": "Match all" + }, + "columns": { + "song": "Song", + "paths": "Paths", + "stats": "Stats", + "actions": "Actions" + }, + "states": { + "loading": "Loading songs...", + "queryTooShort": "Search terms must be at least 3 characters.", + "emptyFiltered": "No songs matched those filters yet. Try broadening the search field or clearing one of the advanced inputs.", + "emptyCatalog": "No songs are available in the demo catalog yet.", + "blacklisted": "Blacklisted", + "blacklistedWithReasons": "Blacklisted - {reasons}", + "unknownArtist": "Unknown artist", + "chartedBy": "Charted by {creator}", + "updated": "Updated {date}" + }, + "errors": { + "filterOptionsFailed": "Filter options failed to load.", + "searchFailed": "Search failed." + }, + "commands": { + "copySr": "Copy !sr command", + "copiedSr": "Copied !sr command", + "copyEdit": "Copy !edit command", + "copiedEdit": "Copied !edit command", + "copyVip": "Copy !vip command", + "copiedVip": "Copied !vip command" + }, + "paths": { + "lead": "Lead", + "rhythm": "Rhythm", + "bass": "Bass", + "lyrics": "Lyrics" + }, + "multiSelect": { + "selected": "{count} selected", + "select": "Select {label}", + "filter": "Filter {label}...", + "noMatches": "No matches found." + } +} diff --git a/src/lib/i18n/resources/es/admin.json b/src/lib/i18n/resources/es/admin.json new file mode 100644 index 0000000..6b4a424 --- /dev/null +++ b/src/lib/i18n/resources/es/admin.json @@ -0,0 +1,126 @@ +{ + "page": { + "title": "Admin", + "description": "Administra el acceso compartido del bot y los controles de prueba sin conexión.", + "noAccess": "No tienes acceso al panel de administración.", + "authNotice": "Completa la autenticación de Twitch como", + "botAccount": "Cuenta del bot", + "botMismatch": "Conectado como {connected}. Se esperaba {expected}. Reconecta para cambiar de cuenta." + }, + "states": { + "offlineTestingFailed": "No se pudo actualizar la prueba sin conexión.", + "botUpdateFailed": "No se pudo actualizar la cuenta del bot.", + "updated": "Actualizado." + }, + "status": { + "connected": "Conectado", + "needsAuth": "Falta autenticación", + "enabled": "Activado", + "disabled": "Desactivado" + }, + "actions": { + "connectBot": "Conectar {username}", + "reconnectBot": "Reconectar bot", + "reconnecting": "Reconectando...", + "enable": "Activar", + "enabling": "Activando...", + "disable": "Desactivar", + "disabling": "Desactivando..." + }, + "metrics": { + "requestIssues": { + "label": "Total de incidencias de solicitudes", + "description": "Resultados bloqueados, rechazados y con error en todos los registros de solicitudes." + }, + "auditRows": { + "label": "Total de filas de auditoría", + "description": "Acciones registradas de administración y gestión del canal." + } + }, + "offlineTesting": { + "title": "Prueba del bot sin conexión" + }, + "prototype": { + "title": "Prototipo de fila de playlist", + "description": "Vista previa de un elemento de playlist más compacto con versiones siempre visibles.", + "cardTitle": "Elemento compacto de playlist con varias versiones", + "cardDescription": "Esta vista previa usa el mismo componente de elemento de playlist que la página en vivo, con una canción de ejemplo que tiene dos versiones." + }, + "logs": { + "title": "Registros de solicitudes", + "description": "Intentos de solicitud más recientes desde chat y desde el flujo de solicitudes del espectador. Cada fila muestra la solicitud, quién la envió, el resultado y cualquier coincidencia o motivo registrado.", + "loading": "Cargando registros de solicitudes...", + "empty": "Todavía no hay registros de solicitudes." + }, + "audits": { + "title": "Registros de auditoría", + "description": "Acciones administrativas y de gestión del canal registradas más recientemente para este canal. Cada fila muestra qué cambió, quién lo hizo y los valores clave guardados.", + "loading": "Cargando registros de auditoría...", + "empty": "Todavía no hay registros de auditoría." + }, + "pagination": { + "show": "Mostrar", + "previous": "Anterior", + "next": "Siguiente", + "updating": "Actualizando...", + "showingEmpty": "Mostrando 0 resultados", + "showingRange": "Mostrando {start}-{end} de {total}" + }, + "table": { + "loading": "Cargando...", + "unknownTime": "Hora desconocida", + "noExtraValues": "No se registraron valores extra.", + "none": "Ninguno", + "yes": "Sí", + "no": "No" + }, + "requestLog": { + "columns": { + "time": "Hora", + "request": "Solicitud", + "requester": "Solicitante", + "result": "Resultado", + "details": "Detalles" + }, + "query": "Consulta: {query}", + "unknownUser": "Usuario desconocido", + "matched": "Coincidencia: {title}{artist}", + "reason": "Motivo: {reason}", + "noMatchOrReason": "No se registró ninguna coincidencia ni motivo." + }, + "auditLog": { + "columns": { + "time": "Hora", + "action": "Acción", + "entity": "Entidad", + "actor": "Actor", + "details": "Detalles" + } + }, + "labels": { + "accepted": "Aceptado", + "blocked": "Bloqueado", + "error": "Error", + "system": "Sistema", + "auto_grant_vip_tokens_cheer": "Otorgar tokens VIP automáticamente: cheers", + "auto_grant_vip_tokens_gift_recipient": "Otorgar tokens VIP automáticamente: receptor de sub regalada", + "auto_grant_vip_tokens_new_subscriber": "Otorgar tokens VIP automáticamente: nueva sub de pago", + "auto_grant_vip_tokens_raid": "Otorgar tokens VIP automáticamente: raid", + "auto_grant_vip_tokens_shared_sub_renewal_message": "Otorgar tokens VIP automáticamente: mensaje compartido de renovación", + "auto_grant_vip_tokens_streamelements_tip": "Otorgar tokens VIP automáticamente: propina de StreamElements", + "auto_grant_vip_tokens_sub_gifter": "Otorgar tokens VIP automáticamente: quien regaló la sub", + "grantedTokenCount": "Tokens otorgados", + "minimumRaidViewerCount": "Tamaño mínimo de raid", + "totalGiftedSubs": "Subs regaladas", + "twitchMessageId": "ID de mensaje de EventSub", + "vip_token": "Token VIP" + }, + "auditSources": { + "channelCheer": "Cheer", + "channelRaid": "Raid", + "channelSubscribe": "Suscripción al canal", + "channelSubscriptionGift": "Sub regalada", + "channelSubscriptionMessage": "Mensaje compartido de renovación", + "streamElementsTip": "Propina de StreamElements" + } +} diff --git a/src/lib/i18n/resources/es/common.json b/src/lib/i18n/resources/es/common.json new file mode 100644 index 0000000..296c8f7 --- /dev/null +++ b/src/lib/i18n/resources/es/common.json @@ -0,0 +1,19 @@ +{ + "brand": { + "name": "RockList.Live" + }, + "nav": { + "search": "Buscar", + "account": "Cuenta" + }, + "auth": { + "goToPlaylist": "Ir a tu lista.", + "logOut": "Cerrar sesión", + "signIn": "Iniciar sesión con Twitch", + "permissionsRefresh": "Es necesario actualizar los permisos de Twitch para esta cuenta.", + "reconnect": "Reconectar Twitch" + }, + "language": { + "label": "Idioma" + } +} diff --git a/src/lib/i18n/resources/es/dashboard.json b/src/lib/i18n/resources/es/dashboard.json new file mode 100644 index 0000000..ace3706 --- /dev/null +++ b/src/lib/i18n/resources/es/dashboard.json @@ -0,0 +1,314 @@ +{ + "nav": { + "channels": "Canales", + "settings": "Configuración", + "admin": "Admin" + }, + "botStatus": { + "active": "Activo", + "activeOfflineTesting": "Pruebas sin conexión activadas", + "waitingForLive": "Esperando a que empieces directo", + "botAuthRequired": "Falta autenticar el bot", + "broadcasterAuthRequired": "Falta autenticar la cuenta del canal", + "subscriptionError": "Error de suscripción", + "disabled": "Desactivado" + }, + "overview": { + "title": "Cuenta", + "description": "Acceso al canal y configuración del propietario.", + "cards": { + "openPlaylist": "Abrir tu playlist", + "manageSettings": "Administrar configuración del canal" + }, + "indicators": { + "channelStatus": "Estado del canal", + "requests": "Solicitudes", + "bot": "Bot" + }, + "status": { + "live": "En vivo", + "offline": "Sin conexión", + "unavailable": "No disponible", + "enabled": "Activado", + "paused": "Pausado" + }, + "moderatedChannels": { + "title": "Canales en vivo que moderas", + "reconnect": "Vuelve a conectar Twitch para actualizar tu acceso a canales moderados.", + "openChannel": "Abrir canal", + "empty": "Ahora mismo no hay canales moderados en vivo." + }, + "notesTitle": "Notas", + "notes": { + "enableBot": { + "title": "Activa el bot", + "body": "Activa el control del bot en Configuración antes de usar solicitudes en tu canal." + }, + "offlineTesting": { + "title": "Las pruebas sin conexión están activas", + "body": "Las solicitudes siguen disponibles mientras pruebas el bot sin conexión." + }, + "goLive": { + "title": "Empieza directo para aceptar solicitudes", + "body": "Las solicitudes están pensadas para funcionar cuando tu canal está en vivo." + }, + "requestsPaused": { + "title": "Las solicitudes están en pausa", + "body": "Vuelve a activar las solicitudes en Configuración cuando quieras que los espectadores vuelvan a añadir canciones." + }, + "channelReady": { + "title": "El canal está listo", + "body": "Tu stream está en vivo y las solicitudes están disponibles." + }, + "checkStatus": { + "title": "Revisa el estado del canal", + "body": "Asegúrate de que el bot esté conectado antes de empezar a aceptar solicitudes." + } + } + }, + "settings": { + "header": { + "title": "Configuración", + "description": "Configuración del canal solo para el propietario: solicitudes, moderadores y overlay del stream.", + "betaNote": "Los beta testers pueden instalar el panel de la extensión de Twitch.", + "previewPanel": "Vista previa del panel de moderación", + "installExtension": "Instalar beta de la extensión de Twitch" + }, + "states": { + "failedToLoad": "No se pudo cargar la configuración.", + "failedToSave": "No se pudo guardar la configuración.", + "loadingAccess": "Cargando acceso a la configuración...", + "loading": "Cargando configuración...", + "saveFailed": "El guardado falló. Revisa el mensaje de abajo y vuelve a intentarlo.", + "saving": "Guardando configuración...", + "unsavedChanges": "Tienes cambios sin guardar.", + "allChangesSaved": "Todos los cambios están guardados.", + "saved": "Guardado", + "savingButton": "Guardando..." + }, + "actions": { + "saveSettings": "Guardar configuración", + "reconnectTwitch": "Volver a conectar Twitch", + "copyUrl": "Copiar URL", + "copied": "Copiado", + "show": "Mostrar", + "hide": "Ocultar" + }, + "ownerOnly": { + "title": "Solo configuración del propietario", + "description": "Esta área solo está disponible para los canales que te pertenecen. Usa la página del canal para moderar canales que administras.", + "openChannel": "Abrir página del canal", + "signInHint": "Inicia sesión con una cuenta de streamer que sea propietaria de un canal para administrarlo aquí." + }, + "sections": { + "channelSetup": { + "title": "Configuración del canal", + "description": "Controla los interruptores principales de la playlist, el comando del chat y el comportamiento de la playlist para los espectadores.", + "reconnectNotice": "Hay que actualizar los permisos de Twitch antes de que el bot pueda funcionar.", + "mainToggles": "Interruptores principales", + "enableBot": "Activar el bot en este canal", + "enableBotHelp": "Desactívalo cuando no quieras acciones de request-bot, incluido otorgar tokens VIP por suscripciones, raids, etc.", + "enableRequests": "Activar solicitudes", + "enableRequestsHelp": "Si desactivas las solicitudes, tú y tus moderadores seguís pudiendo gestionar la playlist, pero los espectadores no podrán añadir canciones.", + "playlistDisplay": "Visualización de la playlist", + "showPositions": "Mostrar posiciones de la playlist", + "commandPrefix": "Prefijo de comando", + "commandPrefixHelp": "Usa el comando que los espectadores escriben en el chat.", + "requestModifiers": "Modificadores de solicitud", + "requestModifiersHelp": "Permite pedir arreglos de bajo en el chat con el modificador *bass.", + "allowBassModifier": "Permitir el modificador *bass en los comandos del chat", + "duplicateCooldown": "Enfriamiento de duplicados (minutos)", + "duplicateCooldownHelp": "Define cuánto tiempo debe esperar una canción antes de poder volver a solicitarse." + }, + "requestAccess": { + "title": "Quién puede pedir", + "description": "Elige qué espectadores pueden añadir canciones a tu playlist.", + "anyone": "Cualquiera puede pedir", + "subscribers": "Los suscriptores pueden pedir", + "vips": "Los VIP del canal pueden pedir" + }, + "filters": { + "title": "Filtros de búsqueda y solicitudes", + "description": "Estas reglas limitan lo que aparece en la búsqueda y lo que los espectadores pueden pedir en la página del canal.", + "officialDlc": { + "title": "DLC oficial", + "description": "Limita los resultados y las solicitudes al DLC oficial.", + "notice": "Los filtros de DLC oficial todavía no están disponibles. Esta sección muestra una vista previa de la configuración que aparecerá aquí en una futura actualización.", + "onlyOfficial": "Incluir solo DLC oficial", + "onlyOwned": "Incluir solo DLC oficial que yo tenga", + "ownedTitle": "DLC oficial en propiedad", + "import": "Importar desde CustomsForge Song Manager", + "owned": "En propiedad", + "notOwned": "Sin propiedad" + }, + "allowedTunings": { + "title": "Afinaciones permitidas", + "description": "Haz clic en cualquier afinación para permitirla o bloquearla.", + "allowAll": "Permitir todas", + "groups": { + "open": "Abiertas", + "other": "Otras" + } + }, + "requiredPaths": { + "title": "Rutas requeridas", + "description": "Elige las rutas que una canción necesita antes de aparecer en la búsqueda o poder solicitarse.", + "matchAny": "Coincidir con cualquier ruta seleccionada", + "matchAll": "Coincidir con todas las rutas seleccionadas", + "none": "No hay ningún filtro de rutas activo. Las canciones pueden coincidir con cualquier combinación de rutas.", + "singlePrefix": "Las canciones deben incluir", + "singleSuffix": "Aun así pueden incluir cualquier otra ruta.", + "exampleLabel": "Ejemplo: una canción con", + "singleExample": "sigue coincidiendo.", + "matchAnyPrefix": "Las canciones coinciden si incluyen al menos una de", + "matchAnySuffix": "Aun así pueden incluir cualquier otra ruta.", + "matchAnyExample": "sigue coincidiendo porque incluye una ruta seleccionada.", + "matchAllPrefix": "Las canciones solo coinciden si incluyen todas estas rutas", + "matchAllSuffix": "Aun así pueden incluir cualquier otra ruta.", + "matchAllExample": "sigue coincidiendo porque incluye todas las rutas seleccionadas.", + "paths": { + "lyrics": "Letras" + } + } + }, + "queueLimits": { + "title": "Límites de cola y ritmo", + "description": "Define el tamaño de la playlist y con qué frecuencia se pueden añadir solicitudes normales o VIP.", + "queueLimits": "Límites de cola", + "maxPlaylist": "Tamaño máximo de la playlist", + "maxPerViewer": "Máximo de solicitudes por espectador", + "maxPerSubscriber": "Máximo de solicitudes por suscriptor", + "maxVipPerViewer": "Máximo de solicitudes VIP por espectador", + "maxVipPerSubscriber": "Máximo de solicitudes VIP por suscriptor", + "rateLimits": "Límites de ritmo", + "regular": "Normal", + "enableRegular": "Activar límite de ritmo para solicitudes normales", + "regularAllowed": "Solicitudes normales permitidas", + "regularPeriod": "Periodo normal (segundos)", + "vip": "VIP", + "enableVip": "Activar límite de ritmo para solicitudes VIP", + "vipAllowed": "Solicitudes VIP permitidas", + "vipPeriod": "Periodo VIP (segundos)" + }, + "vipAutomation": { + "title": "Automatización de tokens VIP", + "description": "Entrega automáticamente tokens VIP por nuevas suscripciones, subs regaladas, raids, cheers y tips de StreamElements.", + "subscribes": "Suscripciones", + "newSub": "Dar 1 token VIP por una nueva sub pagada", + "sharedRenewal": "Dar 1 token VIP por un mensaje compartido de renovación", + "sharedRenewalHelp": "Las renovaciones silenciosas no otorgan premio automáticamente. Twitch solo envía este evento cuando el espectador comparte el mensaje de resub en el chat.", + "giftedSubs": "Subs regaladas", + "subGifter": "Dar 1 token VIP al regalador por cada sub regalada", + "subRecipient": "Dar 1 token VIP a cada receptor de una sub regalada", + "raids": "Raids", + "raidReward": "Dar 1 token VIP al streamer que hace raid a este canal", + "minimumRaid": "Tamaño mínimo del raid", + "minimumRaidHelp": "Pon 1 para premiar todos los raids.", + "raidNotice": "Twitch solo envía recompensas por raid aquí cuando el raid aparece en el chat.", + "cheers": "Cheers", + "cheersToggle": "Dar tokens VIP por cheers", + "cheerConversion": "Conversión de cheers", + "bitsPerToken": "bits por 1 token VIP", + "minimumCheer": "Cheer mínimo para ganar un token parcial", + "liveExample": "Ejemplo en vivo", + "minimumCheerExample": "Cheer mínimo: {bits} bits otorgan {tokenCount} de un token VIP con el umbral del {percent}%.", + "bitsExample": "{oneTokenBits} bits otorgan 1 token VIP. {fiveTokenBits} bits otorgan 5 tokens VIP.", + "bitsExampleEmpty": "Define los bits por token VIP por encima de 0 para ver el umbral mínimo del cheer.", + "tips": "Tips", + "tipsToggle": "Dar tokens VIP por tips de StreamElements", + "tipAmount": "Importe por 1 token VIP", + "tipAmountHelp": "Un tip de $25 otorga 5 tokens VIP cuando esto está configurado en 5.", + "relayUrl": "URL del relay", + "relayUrlHelp": "Usa esta URL en el paso de Streamer.bot que reenvía el evento de tip de StreamElements.", + "setup": "Configuración", + "setupHelp": "StreamElements puede seguir mostrando alertas de tip en OBS como ya lo haces. Para los premios de tokens VIP hace falta que Streamer.bot reenvíe el evento aquí.", + "setupSteps": { + "connect": "Conecta Streamer.bot con StreamElements.", + "trigger": "Usa el trigger de StreamElements Tip en Streamer.bot.", + "send": "Envía ese evento de tip a la URL de relay que se muestra aquí.", + "note": "Sin Streamer.bot, los tips siguen apareciendo en OBS, pero no añaden tokens VIP aquí." + } + }, + "rules": { + "title": "Reglas de blacklist y setlist", + "description": "Las entradas de blacklist y setlist se gestionan en la página del canal. Estos interruptores controlan cómo se aplican esas reglas.", + "enableBlacklist": "Activar blacklist", + "bypassBlacklist": "Permitir que la setlist ignore la blacklist", + "enableSetlist": "Activar setlist", + "subscribersFollowSetlist": "Los suscriptores deben seguir la setlist" + }, + "moderatorPermissions": { + "title": "Permisos de moderación", + "description": "Los moderadores siempre ven los tokens VIP. Activa o desactiva aquí el resto de acciones de gestión del canal.", + "manageRequests": "Gestionar solicitudes", + "manageBlacklist": "Gestionar blacklist", + "manageSetlist": "Gestionar setlist", + "manageBlockedViewers": "Gestionar espectadores bloqueados", + "manageVipTokens": "Gestionar tokens VIP", + "manageTags": "Gestionar tags" + } + } + }, + "overlay": { + "title": "Overlay del stream", + "description": "Muestra tu playlist en el stream usando una fuente de navegador.", + "channelFallback": "Tu canal", + "states": { + "failedToLoad": "No se pudo cargar la configuración del overlay.", + "failedPreview": "No se pudo cargar la vista previa de la playlist.", + "failedToSave": "No se pudo guardar la configuración del overlay.", + "saved": "Configuración del overlay guardada.", + "copied": "URL del overlay copiada." + }, + "url": { + "title": "URL del overlay", + "copy": "Copiar URL", + "open": "Abrir overlay" + }, + "layout": { + "title": "Diseño y comportamiento", + "showCreator": "Mostrar charter", + "showAlbum": "Mostrar álbum", + "animateNowPlaying": "Animar la canción actual" + }, + "theme": { + "title": "Tema", + "accent": "Acento", + "vipBadge": "Insignia VIP", + "text": "Texto", + "mutedText": "Texto secundario", + "requestBackground": "Fondo del elemento de solicitud", + "backgroundColor": "Color de fondo", + "backgroundColorHelp": "Para un fondo transparente, ajusta la opacidad del fondo a 0.", + "border": "Borde" + }, + "sizing": { + "title": "Densidad y tamaño", + "backgroundOpacity": "Opacidad del fondo del overlay", + "backgroundOpacityHelp": "Ponlo en 0 para un fondo totalmente transparente detrás de los elementos de la playlist.", + "cornerRadius": "Radio de esquinas", + "itemGap": "Separación entre elementos", + "itemPadding": "Padding del elemento", + "titleFontSize": "Tamaño de fuente del título", + "metaFontSize": "Tamaño de fuente de metadatos", + "value": "Valor" + }, + "actions": { + "restoreDefaults": "Restaurar valores por defecto", + "saveChanges": "Guardar cambios", + "saving": "Guardando...", + "cancel": "Cancelar" + }, + "preview": { + "title": "Vista previa", + "live": "En vivo", + "sample": "Ejemplo", + "channelTitle": "Playlist de {channel}" + }, + "restoreDialog": { + "title": "¿Restaurar valores por defecto?", + "description": "Esto restablece el editor del overlay al tema por defecto. Se perderán los cambios sin guardar.", + "confirm": "Restaurar valores por defecto" + } + } +} diff --git a/src/lib/i18n/resources/es/home.json b/src/lib/i18n/resources/es/home.json new file mode 100644 index 0000000..2c78125 --- /dev/null +++ b/src/lib/i18n/resources/es/home.json @@ -0,0 +1,45 @@ +{ + "meta": { + "title": "Inicio" + }, + "hero": { + "eyebrow": "Gestión de listas para streamers de Rocksmith", + "title": "Busca canciones o administra tu canal." + }, + "actions": { + "findSong": "Encontrar una canción para pedir", + "searchSongs": "Buscar canciones", + "manageChannel": "Administrar tu canal", + "openSettings": "Abrir ajustes" + }, + "about": { + "eyebrow": "¿Qué es RockList.Live?", + "body": "RockList.Live ayuda a los streamers de Rocksmith a recibir pedidos, administrar la lista y mantener el stream en marcha." + }, + "features": { + "surfaceRequestsTitle": "Pedidos desde cualquier superficie", + "surfaceRequestsBody": "Los espectadores agregan canciones desde la página de la lista, el chat o el panel de Twitch.", + "moderationTitle": "Soporte para moderadores", + "moderationBody": "Los moderadores administran pedidos y priorizan solicitudes VIP mientras el stream está en vivo.", + "queueTitle": "Mantén la cola en movimiento", + "queueBody": "Edita, ordena y sigue los pedidos sin perder la próxima canción.", + "rulesTitle": "Define las reglas", + "rulesBody": "Controla listas negras, setlists, moderación y ajustes de pedidos para tu canal." + }, + "live": { + "eyebrow": "En vivo", + "title": "Streamers actuales", + "showLive": "Mostrar en vivo", + "showDemo": "Mostrar demo", + "demoOnly": "Los streamers que aparecen aquí son solo para demostración.", + "activeCount": "{count} activos", + "empty": "Todavía no hay streamers en vivo con el bot activo.", + "previewAlt": "Vista previa del stream en vivo de {displayName}", + "status": "En vivo", + "nowPlaying": "Sonando ahora", + "upNext": "Sigue", + "nextRequest": "Próximo pedido", + "openPlaylist": "Abrir lista", + "watchOnTwitch": "Ver en Twitch" + } +} diff --git a/src/lib/i18n/resources/es/playlist.json b/src/lib/i18n/resources/es/playlist.json new file mode 100644 index 0000000..a8d351c --- /dev/null +++ b/src/lib/i18n/resources/es/playlist.json @@ -0,0 +1,370 @@ +{ + "page": { + "title": "Playlist de {channel}", + "loading": "Cargando playlist...", + "empty": "Esta playlist está vacía ahora mismo.", + "viewerStateFailed": "No se pudo cargar el estado de solicitudes del espectador.", + "viewerToolsFailed": "No se pudieron cargar las herramientas de solicitud del espectador.", + "requestsLiveOnly": "Podrás añadir solicitudes cuando el stream esté en vivo." + }, + "states": { + "updateRequestToggleFailed": "No se pudo actualizar el interruptor de solicitudes ahora mismo.", + "unableToAddSong": "No se pudo añadir la canción.", + "unableToUpdateRequest": "No se pudo actualizar tu solicitud.", + "requestUpdated": "Solicitud actualizada.", + "unableToRemoveRequests": "No se pudieron eliminar tus solicitudes.", + "requestsRemoved": "Solicitudes eliminadas." + }, + "search": { + "title": "Busca para añadir una canción", + "placeholder": "Buscar canciones para {channel}", + "actions": { + "add": "Añadir", + "request": "Pedir", + "actions": "Acciones" + }, + "showBlacklisted": "Mostrar canciones bloqueadas", + "pathWarning": "No coincide con {count, plural, one {la ruta predeterminada del canal} other {las rutas predeterminadas del canal}}: {paths}." + }, + "viewerSummary": { + "vipTokensLabel": "{count} tokens VIP", + "vipBalanceLoading": "Saldo VIP...", + "vipTokensShort": "Tokens VIP", + "requestsWithLimit": "{count}/{limit} solicitudes", + "requestsNoLimit": "{count} solicitudes", + "vipBalanceSummary": "{count} tokens VIP disponibles", + "vipBalanceChecking": "Comprobando tu saldo de tokens VIP...", + "vipBalanceUnavailable": "Tu saldo de tokens VIP no está disponible ahora mismo.", + "replaceQueued": "Las nuevas canciones sustituyen tus solicitudes en cola.", + "vipHelp": "Ayuda de tokens VIP", + "open": "Abrir", + "noRequests": "No hay solicitudes en la playlist.", + "removeQueued": "Quitar solicitudes en cola", + "removing": "Quitando..." + }, + "badges": { + "vip": "VIP", + "regular": "Normal", + "nowPlaying": "Sonando ahora", + "pick": "Pick {count}", + "requestsOn": "Las solicitudes están activas", + "requestsOff": "Las solicitudes están desactivadas", + "turnRequestsOn": "Activar solicitudes", + "turnRequestsOff": "Desactivar solicitudes", + "online": "En vivo", + "offline": "Sin conexión", + "vipBalance": "Tienes {count} tokens VIP", + "vipTokens": "Tokens VIP" + }, + "vipInfo": { + "earn": "Gana tokens VIP", + "manualOnly": "Este canal otorga tokens VIP manualmente por ahora.", + "use": "Usa tokens VIP" + }, + "history": { + "title": "Historial reproducido", + "titleWithChannel": "Historial reproducido de {channel}", + "show": "Mostrar historial", + "hide": "Ocultar historial", + "songSearch": "Búsqueda de canciones", + "searchPlaceholder": "Canción, artista, álbum o charter", + "clearSearch": "Borrar búsqueda", + "requester": "Solicitante", + "requesterPlaceholder": "Buscar un solicitante", + "clearRequester": "Borrar solicitante", + "clearRequesterFilter": "Borrar filtro de solicitante", + "searchingRequesters": "Buscando solicitantes...", + "loading": "Cargando historial reproducido...", + "emptyFiltered": "Ninguna canción reproducida coincide con esos filtros.", + "empty": "Todavía no se ha marcado ninguna canción como reproducida.", + "requestCount": "{count, plural, one {# solicitud} other {# solicitudes}}", + "byArtist": " de {artist}", + "requestedBy": "Solicitada por {requester}", + "page": "Página {page}", + "unknownRequester": "Desconocido" + }, + "rules": { + "sectionTitle": "Reglas del canal", + "sectionTitleWithChannel": "Reglas del canal de {channel}", + "states": { + "updateFailed": "No se pudieron actualizar las reglas del canal.", + "searchFailed": "La búsqueda falló." + }, + "blacklistedArtists": "Artistas bloqueados", + "blacklistedCharters": "Charters bloqueados", + "blacklistedSongs": "Canciones bloqueadas", + "blacklistedVersions": "Versiones bloqueadas", + "setlistArtists": "Artistas del setlist", + "searchArtists": "Buscar artistas por nombre", + "searchCharters": "Buscar charters por nombre", + "searchSongs": "Buscar canciones por título", + "searchMin": "Escribe al menos 2 caracteres para buscar.", + "noMatches": "No hay coincidencias para añadir.", + "add": "Añadir", + "remove": "Quitar", + "trackCount": "{count, plural, one {# tema} other {# temas}}", + "versionCount": "{count, plural, one {# versión} other {# versiones}} - Grupo de canción {groupId}", + "artistId": "ID de artista {id}", + "charterId": "ID de charter {id}", + "songGroupId": "Grupo de canción {groupId}", + "versionId": "ID de versión {id}", + "noBlacklistedArtists": "No hay artistas bloqueados.", + "noBlacklistedCharters": "No hay charters bloqueados.", + "noBlacklistedSongs": "No hay canciones bloqueadas.", + "noBlacklistedVersions": "No hay versiones bloqueadas.", + "noSetlistArtists": "No hay artistas en el setlist." + }, + "community": { + "title": "Controles de moderación", + "reconnect": "Volver a conectar Twitch", + "reconnectMessage": "Vuelve a conectar Twitch para priorizar a los espectadores que están en el chat.", + "currentChattersFirst": "Primero quienes están en el chat", + "twitchMatches": "Coincidencias de Twitch", + "resultCount": "{count, plural, one {# resultado} other {# resultados}}", + "inChat": "En el chat", + "selected": "Seleccionado", + "noMatchingUsers": "No hay nombres de usuario de Twitch coincidentes.", + "states": { + "updateFailed": "No se pudo actualizar la configuración de comunidad del canal.", + "lookupFailed": "La búsqueda de usuarios falló." + }, + "blocks": { + "title": "Espectadores bloqueados", + "searchPlaceholder": "Buscar usuario de Twitch para bloquear", + "blockViewer": "Bloquear espectador", + "searchMin": "Escribe al menos 4 caracteres para buscar usuarios de Twitch.", + "description": "Los espectadores bloqueados pueden seguir hablando en el chat de Twitch, pero no pueden añadir ni editar solicitudes desde el chat, la web o el panel de la extensión.", + "defaultReason": "Bloqueado para hacer solicitudes en este canal.", + "unblock": "Desbloquear", + "empty": "No hay espectadores bloqueados." + }, + "vip": { + "title": "Tokens VIP", + "searchPlaceholder": "Buscar usuario de Twitch para otorgar un token", + "grant": "Otorgar token", + "searchMin": "Escribe al menos 4 caracteres para buscar usuarios de Twitch.", + "searchMinExisting": "Los usuarios con tokens VIP existentes aparecen abajo. Escribe al menos 4 caracteres para buscar entre todos los usuarios de Twitch.", + "tokenHoldersFirst": "Primero quienes ya tienen tokens VIP", + "tokenCountSingle": "{count} token", + "tokenCountPlural": "{count} tokens", + "viewOnly": "Puedes ver los saldos VIP, pero solo el broadcaster o un moderador permitido pueden cambiarlos.", + "username": "Usuario", + "tokens": "Tokens", + "showingRange": "Mostrando {start}-{end} de {total}", + "previous": "Anterior", + "next": "Siguiente", + "pageOf": "Página {page} de {total}", + "empty": "Todavía no hay tokens VIP.", + "noticeAddedSingle": "Se añadió 1 token", + "noticeAddedMultiple": "Se añadieron {count} tokens VIP", + "noticeRemovedSingle": "Se quitó 1 token", + "noticeRemovedMultiple": "Se quitaron {count} tokens VIP", + "noticeSaved": "Tokens VIP guardados" + } + }, + "viewerActions": { + "add": "Añadir", + "addVip": "Añadir VIP", + "adding": "Añadiendo...", + "alreadyVip": "Ya está en tu cola como solicitud VIP.", + "alreadyRegular": "Ya está en tu cola como solicitud normal.", + "blacklistedPrefix": "Bloqueada", + "songUnavailable": "Esa canción no está disponible aquí.", + "checkingAccess": "Comprobando tu acceso a solicitudes...", + "cannotRequest": "No puedes pedir canciones aquí.", + "alreadyActive": "Esa canción ya está en tus solicitudes activas.", + "activeLimitReached": "Ya tienes {count, plural, one {# solicitud activa} other {# solicitudes activas}}.", + "insufficientVipTokens": "No tienes suficientes tokens VIP." + }, + "specialRequest": { + "titleManage": "Añadir una solicitud personalizada", + "titleViewer": "Pedir por artista", + "artist": "Artista", + "artistPlaceholder": "Nombre del artista", + "artistMin": "Escribe al menos 2 caracteres del nombre de un artista.", + "chooseMode": "Elegir modo", + "chooseType": "Elegir tipo", + "random": "Aleatoria", + "choice": "Elección", + "regular": "Normal", + "add": "Añadir", + "addVip": "Añadir VIP", + "adding": "Añadiendo...", + "randomHelp": "Añade una canción aleatoria entre las canciones coincidentes de ese artista.", + "choiceHelp": "Añade una solicitud de elección del streamer para ese artista." + }, + "manageActions": { + "add": "Añadir", + "adding": "Añadiendo...", + "addForUser": "Añadir para usuario", + "searchViewers": "Buscar espectadores actuales", + "searchMin": "Escribe al menos 2 caracteres para buscar espectadores actuales.", + "reconnectMessage": "Vuelve a conectar Twitch para buscar espectadores que están ahora mismo en el chat.", + "reconnect": "Reconectar", + "searching": "Buscando espectadores actuales...", + "noMatches": "Ningún espectador actual coincide con ese usuario." + }, + "row": { + "viewer": "espectador", + "added": "Añadida {time}", + "edited": "Editada {time}", + "streamerChoiceTitle": "Elección del streamer: {query}", + "streamerChoiceSubtitle": "El streamer elige la canción exacta.", + "relative": { + "now": "ahora", + "minutes": "{count} min", + "hours": "{count} h", + "days": "{count} d" + }, + "picks": { + "first": "1.ª elección", + "second": "2.ª elección", + "third": "3.ª elección" + } + }, + "management": { + "states": { + "searchFailed": "La búsqueda falló.", + "playlistUpdateFailed": "La playlist no se pudo actualizar.", + "moderationUnavailable": "Las acciones de moderación no están disponibles para este canal.", + "blacklistUpdateFailed": "La blacklist no se pudo actualizar." + }, + "header": { + "managing": "Gestionando {channel}", + "channel": "Canal: {channel}" + }, + "manual": { + "title": "Añadir una canción", + "requesterPlaceholder": "Usuario solicitante (opcional)", + "searchPlaceholder": "Buscar y añadir una canción", + "searchMin": "La búsqueda debe tener al menos 3 caracteres.", + "table": { + "track": "Canción", + "albumCreator": "Álbum / Charter", + "tuningPath": "Afinación / Ruta", + "add": "Añadir" + }, + "unknownArtist": "Artista desconocido", + "unknownAlbum": "Álbum desconocido", + "chartedBy": "Creada por {creator}", + "unknownCreator": "Charter desconocido", + "blacklisted": "En blacklist", + "noTuningInfo": "Sin afinación", + "noPathInfo": "Sin ruta", + "addButton": "Añadir" + }, + "actions": { + "shuffle": "Mezclar", + "clearPlaylist": "Vaciar playlist", + "resetSession": "Reiniciar sesión", + "confirmClear": "¿Vaciar toda la playlist? Esta acción no se puede deshacer.", + "confirmReset": "¿Reiniciar la sesión? Esto vaciará la playlist actual." + }, + "currentTitle": "Playlist actual", + "blacklistErrors": { + "missingRequestVersionId": "Esta solicitud no tiene un ID de versión para bloquear.", + "missingVersionVersionId": "Esta versión no tiene un ID de versión para bloquear.", + "missingRequestSongGroupId": "Esta solicitud no tiene un ID de grupo de canción para bloquear.", + "missingRequestArtistId": "Esta solicitud no tiene un ID de artista para bloquear.", + "missingVersionCharterId": "Esta versión no tiene un ID de charter para bloquear.", + "unknownArtist": "Artista desconocido", + "unknownCharter": "Charter desconocido" + }, + "blacklistPanelDescription": "Puedes bloquear artistas, charters, canciones y versiones concretas en este canal.", + "history": { + "title": "Historial reproducido", + "requestedBy": "Pedido por {requester}", + "played": "Reproducida {time}", + "restore": "Restaurar", + "restoring": "Restaurando...", + "empty": "Todavía no se ha marcado ninguna canción como reproducida." + }, + "deleteDialog": { + "title": "¿Quitar esta solicitud de la playlist?", + "descriptionWithArtist": "Esto quita \"{title}\" de {artist} de la playlist. Esta acción no se puede deshacer.", + "descriptionWithoutArtist": "Esto quita \"{title}\" de la playlist. Esta acción no se puede deshacer.", + "descriptionFallback": "Esto quita la solicitud seleccionada de la playlist. Esta acción no se puede deshacer.", + "keep": "Mantener solicitud", + "removing": "Quitando...", + "remove": "Quitar solicitud" + }, + "queue": { + "empty": "Todavía no hay canciones en la playlist." + }, + "relative": { + "now": "justo ahora", + "minutesAgo": "{count, plural, one {hace # minuto} other {hace # minutos}}", + "hoursAgo": "{count, plural, one {hace # hora} other {hace # horas}}", + "daysAgo": "{count, plural, one {hace # día} other {hace # días}}" + }, + "item": { + "moveTop": "Mover al inicio", + "moveUp": "Subir", + "moveDown": "Bajar", + "moveBottom": "Mover al final", + "reorderAria": "Reordenar {title}", + "vipBadge": "VIP", + "playingBadge": "Reproduciendo", + "warningBadge": "Aviso", + "versionBlacklisted": "Versión en blacklist", + "songBlacklisted": "Canción en blacklist", + "artistBlacklisted": "Artista en blacklist", + "requestedBy": "Pedido por {requester}", + "vipTokens": "{count} tokens VIP", + "added": "Añadida {time}", + "requestedText": "Texto pedido: {query}", + "saving": "Guardando...", + "makeRegular": "Hacer normal", + "makeVip": "Hacer VIP", + "returnToQueue": "Volver a la cola", + "playNow": "Reproducir ahora", + "markComplete": "Marcar completada", + "versionsCount": "{count, plural, one {# versión} other {# versiones}}", + "downloadFromCf": "Descargar de CF" + }, + "actionsMenu": { + "openActionsAria": "Abrir acciones para {title}", + "removing": "Quitando...", + "removeFromPlaylist": "Quitar de la playlist", + "blacklist": "Blacklist", + "blacklistTitle": "Acciones de blacklist", + "back": "Volver", + "description": "Elige si quieres bloquear la versión en cola, todas las versiones de esta canción, el artista o un charter.", + "queuedVersionId": "ID de versión en cola {id}", + "noQueuedVersionId": "Sin ID de versión en cola", + "versionBlocked": "Versión en blacklist", + "blacklistQueuedVersion": "Bloquear versión en cola", + "blockVersionDescription": "Bloquea solo el ID de versión {id}.", + "blockVersionFallbackDescription": "Bloquea solo la versión exacta asociada a esta solicitud.", + "songBlocked": "Canción en blacklist", + "blacklistSongGroup": "Bloquear todas las versiones de esta canción", + "blockSongDescription": "Bloquea todas las versiones agrupadas bajo esta canción.", + "artistBlocked": "Artista en blacklist: {artist}", + "blacklistArtist": "Bloquear artista: {artist}", + "blockArtistDescription": "Bloquea todas las canciones de este ID de artista.", + "charterBlocked": "Charter en blacklist: {charter}", + "blacklistCharter": "Bloquear charter: {charter}", + "blockCharterDescription": "Bloquea todas las canciones de este ID de charter.", + "noCharterIds": "No hay IDs de charter disponibles para estas versiones." + }, + "versionsTable": { + "songAlbum": "Canción / álbum", + "tunings": "Afinaciones", + "paths": "Rutas", + "updated": "Actualizada", + "downloads": "Descargas", + "actions": "Acciones", + "unknownArtist": "Artista desconocido", + "chartedBy": "Creada por", + "charterBlacklisted": "Charter en blacklist", + "unknown": "Desconocido", + "download": "Descargar", + "blacklisted": "En blacklist", + "blacklist": "Bloquear" + }, + "preview": { + "removed": "La fila de demo se ha quitado de la playlist.", + "restore": "Restaurar fila de demo" + } + } +} diff --git a/src/lib/i18n/resources/es/search.json b/src/lib/i18n/resources/es/search.json new file mode 100644 index 0000000..150983c --- /dev/null +++ b/src/lib/i18n/resources/es/search.json @@ -0,0 +1,79 @@ +{ + "meta": { + "title": "Buscar canciones" + }, + "page": { + "title": "Buscar", + "infoNote": "Esta demo solo contiene {count} canciones.", + "placeholder": "Busca por título, artista o álbum" + }, + "summary": { + "note": "Nota:", + "foundCount": "{count} canciones encontradas", + "filters": "Filtros", + "moreCount": "+{count} más", + "changeFilters": "Cambiar filtros" + }, + "controls": { + "searchField": "Campo de búsqueda", + "allFields": "Todos los campos", + "titleOnly": "Solo título", + "artistOnly": "Solo artista", + "albumOnly": "Solo álbum", + "creatorOnly": "Solo creador", + "showFilters": "Mostrar filtros", + "hideFilters": "Ocultar filtros", + "title": "Título", + "artist": "Artista", + "album": "Álbum", + "creator": "Creador", + "tuning": "Afinación", + "path": "Parte", + "year": "Año", + "actions": "Acciones", + "clearAdvancedFilters": "Borrar filtros", + "matchAny": "Coincide con cualquiera", + "matchAll": "Coincide con todas" + }, + "columns": { + "song": "Canción", + "paths": "Partes", + "stats": "Detalles", + "actions": "Acciones" + }, + "states": { + "loading": "Cargando canciones...", + "queryTooShort": "La búsqueda debe tener al menos 3 caracteres.", + "emptyFiltered": "Todavía no hay canciones que coincidan con esos filtros. Amplía la búsqueda o borra alguno de los filtros avanzados.", + "emptyCatalog": "Todavía no hay canciones disponibles en el catálogo de demostración.", + "blacklisted": "Bloqueada", + "blacklistedWithReasons": "Bloqueada - {reasons}", + "unknownArtist": "Artista desconocido", + "chartedBy": "Creada por {creator}", + "updated": "Actualizada {date}" + }, + "errors": { + "filterOptionsFailed": "No se pudieron cargar las opciones de filtro.", + "searchFailed": "La búsqueda falló." + }, + "commands": { + "copySr": "Copiar comando !sr", + "copiedSr": "Comando !sr copiado", + "copyEdit": "Copiar comando !edit", + "copiedEdit": "Comando !edit copiado", + "copyVip": "Copiar comando !vip", + "copiedVip": "Comando !vip copiado" + }, + "paths": { + "lead": "Principal", + "rhythm": "Ritmo", + "bass": "Bajo", + "lyrics": "Letras" + }, + "multiSelect": { + "selected": "{count} seleccionadas", + "select": "Seleccionar {label}", + "filter": "Filtrar {label}...", + "noMatches": "No se encontraron coincidencias." + } +} diff --git a/src/lib/i18n/resources/fr/admin.json b/src/lib/i18n/resources/fr/admin.json new file mode 100644 index 0000000..0455eb1 --- /dev/null +++ b/src/lib/i18n/resources/fr/admin.json @@ -0,0 +1,126 @@ +{ + "page": { + "title": "Admin", + "description": "Gérez l’accès partagé du bot et les contrôles de test hors ligne.", + "noAccess": "Vous n’avez pas accès au tableau de bord admin.", + "authNotice": "Terminez l’authentification Twitch avec", + "botAccount": "Compte du bot", + "botMismatch": "Connecté en tant que {connected}. Compte attendu : {expected}. Reconnectez-vous pour changer de compte." + }, + "states": { + "offlineTestingFailed": "Le réglage du test hors ligne n’a pas pu être mis à jour.", + "botUpdateFailed": "Le compte du bot n’a pas pu être mis à jour.", + "updated": "Mis à jour." + }, + "status": { + "connected": "Connecté", + "needsAuth": "Auth requise", + "enabled": "Activé", + "disabled": "Désactivé" + }, + "actions": { + "connectBot": "Connecter {username}", + "reconnectBot": "Reconnecter le bot", + "reconnecting": "Reconnexion...", + "enable": "Activer", + "enabling": "Activation...", + "disable": "Désactiver", + "disabling": "Désactivation..." + }, + "metrics": { + "requestIssues": { + "label": "Total des problèmes de demande", + "description": "Résultats bloqués, refusés et en erreur sur l’ensemble des journaux de demandes." + }, + "auditRows": { + "label": "Total des lignes d’audit", + "description": "Actions admin et de gestion de chaîne enregistrées." + } + }, + "offlineTesting": { + "title": "Test du bot hors ligne" + }, + "prototype": { + "title": "Prototype de ligne de playlist", + "description": "Aperçu d’un élément de playlist plus compact avec les versions de morceau toujours visibles.", + "cardTitle": "Élément de playlist compact à plusieurs versions", + "cardDescription": "Cet aperçu utilise le même composant d’élément de playlist que la page en direct, avec un morceau de démonstration qui possède deux versions." + }, + "logs": { + "title": "Journaux de demandes", + "description": "Dernières tentatives de demande depuis le chat et le flux de demandes spectateur. Chaque ligne montre la demande, son auteur, le résultat et toute correspondance ou raison enregistrée.", + "loading": "Chargement des journaux de demandes...", + "empty": "Aucun journal de demandes pour le moment." + }, + "audits": { + "title": "Enregistrements d’audit", + "description": "Dernières actions admin et de gestion de chaîne enregistrées pour cette chaîne. Chaque ligne montre ce qui a changé, qui l’a déclenché et les valeurs clés enregistrées.", + "loading": "Chargement des enregistrements d’audit...", + "empty": "Aucun enregistrement d’audit pour le moment." + }, + "pagination": { + "show": "Afficher", + "previous": "Précédent", + "next": "Suivant", + "updating": "Mise à jour...", + "showingEmpty": "0 résultat affiché", + "showingRange": "{start}-{end} sur {total}" + }, + "table": { + "loading": "Chargement...", + "unknownTime": "Heure inconnue", + "noExtraValues": "Aucune valeur supplémentaire n’a été enregistrée.", + "none": "Aucun", + "yes": "Oui", + "no": "Non" + }, + "requestLog": { + "columns": { + "time": "Heure", + "request": "Demande", + "requester": "Demandeur", + "result": "Résultat", + "details": "Détails" + }, + "query": "Requête : {query}", + "unknownUser": "Utilisateur inconnu", + "matched": "Correspondance : {title}{artist}", + "reason": "Raison : {reason}", + "noMatchOrReason": "Aucune correspondance ni raison n’a été enregistrée." + }, + "auditLog": { + "columns": { + "time": "Heure", + "action": "Action", + "entity": "Entité", + "actor": "Acteur", + "details": "Détails" + } + }, + "labels": { + "accepted": "Accepté", + "blocked": "Bloqué", + "error": "Erreur", + "system": "Système", + "auto_grant_vip_tokens_cheer": "Attribution auto de tokens VIP : cheer", + "auto_grant_vip_tokens_gift_recipient": "Attribution auto de tokens VIP : destinataire d’abonnement offert", + "auto_grant_vip_tokens_new_subscriber": "Attribution auto de tokens VIP : nouvel abonnement payant", + "auto_grant_vip_tokens_raid": "Attribution auto de tokens VIP : raid", + "auto_grant_vip_tokens_shared_sub_renewal_message": "Attribution auto de tokens VIP : message partagé de renouvellement", + "auto_grant_vip_tokens_streamelements_tip": "Attribution auto de tokens VIP : tip StreamElements", + "auto_grant_vip_tokens_sub_gifter": "Attribution auto de tokens VIP : offreur d’abonnement", + "grantedTokenCount": "Tokens attribués", + "minimumRaidViewerCount": "Taille minimale du raid", + "totalGiftedSubs": "Abonnements offerts", + "twitchMessageId": "ID de message EventSub", + "vip_token": "Token VIP" + }, + "auditSources": { + "channelCheer": "Cheer", + "channelRaid": "Raid", + "channelSubscribe": "Abonnement à la chaîne", + "channelSubscriptionGift": "Abonnement offert", + "channelSubscriptionMessage": "Message partagé de renouvellement", + "streamElementsTip": "Tip StreamElements" + } +} diff --git a/src/lib/i18n/resources/fr/common.json b/src/lib/i18n/resources/fr/common.json new file mode 100644 index 0000000..7c9b300 --- /dev/null +++ b/src/lib/i18n/resources/fr/common.json @@ -0,0 +1,19 @@ +{ + "brand": { + "name": "RockList.Live" + }, + "nav": { + "search": "Recherche", + "account": "Compte" + }, + "auth": { + "goToPlaylist": "Aller a votre playlist.", + "logOut": "Se deconnecter", + "signIn": "Se connecter avec Twitch", + "permissionsRefresh": "Les autorisations Twitch doivent etre actualisees pour ce compte.", + "reconnect": "Reconnecter Twitch" + }, + "language": { + "label": "Langue" + } +} diff --git a/src/lib/i18n/resources/fr/dashboard.json b/src/lib/i18n/resources/fr/dashboard.json new file mode 100644 index 0000000..464de48 --- /dev/null +++ b/src/lib/i18n/resources/fr/dashboard.json @@ -0,0 +1,314 @@ +{ + "nav": { + "channels": "Chaines", + "settings": "Reglages", + "admin": "Admin" + }, + "botStatus": { + "active": "Actif", + "activeOfflineTesting": "Test hors ligne active", + "waitingForLive": "En attente du direct", + "botAuthRequired": "Auth du bot requise", + "broadcasterAuthRequired": "Auth du diffuseur requise", + "subscriptionError": "Erreur d'abonnement", + "disabled": "Desactive" + }, + "overview": { + "title": "Compte", + "description": "Acces a la chaine et reglages du proprietaire.", + "cards": { + "openPlaylist": "Ouvrir votre playlist", + "manageSettings": "Gerer les reglages de la chaine" + }, + "indicators": { + "channelStatus": "Etat de la chaine", + "requests": "Demandes", + "bot": "Bot" + }, + "status": { + "live": "En direct", + "offline": "Hors ligne", + "unavailable": "Indisponible", + "enabled": "Active", + "paused": "En pause" + }, + "moderatedChannels": { + "title": "Chaines en ligne que vous moderez", + "reconnect": "Reconnectez Twitch pour actualiser votre acces aux chaines moderees.", + "openChannel": "Ouvrir la chaine", + "empty": "Aucune chaine moderee n'est en direct pour le moment." + }, + "notesTitle": "Notes", + "notes": { + "enableBot": { + "title": "Activer le bot", + "body": "Activez le controle du bot dans Reglages avant d'utiliser les demandes sur votre chaine." + }, + "offlineTesting": { + "title": "Le test hors ligne est actif", + "body": "Les demandes restent disponibles pendant vos tests hors ligne." + }, + "goLive": { + "title": "Passez en direct pour accepter des demandes", + "body": "Les demandes sont prevues pour fonctionner quand votre chaine est en direct." + }, + "requestsPaused": { + "title": "Les demandes sont en pause", + "body": "Reactivez les demandes dans Reglages quand vous voulez que les viewers ajoutent a nouveau des morceaux." + }, + "channelReady": { + "title": "La chaine est prete", + "body": "Votre stream est en direct et les demandes sont disponibles." + }, + "checkStatus": { + "title": "Verifier l'etat de la chaine", + "body": "Assurez-vous que le bot est connecte avant d'accepter des demandes." + } + } + }, + "settings": { + "header": { + "title": "Reglages", + "description": "Configuration de la chaine reservee au proprietaire pour les demandes, les moderateurs et l'overlay du stream.", + "betaNote": "Les beta testeurs peuvent installer le panneau d'extension Twitch.", + "previewPanel": "Apercu du panneau modo", + "installExtension": "Installer la beta de l'extension Twitch" + }, + "states": { + "failedToLoad": "Impossible de charger les reglages.", + "failedToSave": "Les reglages n'ont pas pu etre enregistres.", + "loadingAccess": "Chargement de l'acces aux reglages...", + "loading": "Chargement des reglages...", + "saveFailed": "L'enregistrement a echoue. Verifiez le message ci-dessous puis reessayez.", + "saving": "Enregistrement des reglages...", + "unsavedChanges": "Vous avez des modifications non enregistrees.", + "allChangesSaved": "Toutes les modifications sont enregistrees.", + "saved": "Enregistre", + "savingButton": "Enregistrement..." + }, + "actions": { + "saveSettings": "Enregistrer les reglages", + "reconnectTwitch": "Reconnecter Twitch", + "copyUrl": "Copier l'URL", + "copied": "Copie", + "show": "Afficher", + "hide": "Masquer" + }, + "ownerOnly": { + "title": "Reglages du proprietaire uniquement", + "description": "Cette zone est reservee aux chaines que vous possedez. Utilisez la page de chaine pour la moderation des chaines que vous gererez.", + "openChannel": "Ouvrir la page de chaine", + "signInHint": "Connectez-vous avec un compte streamer proprietaire d'une chaine pour gerer ces reglages ici." + }, + "sections": { + "channelSetup": { + "title": "Configuration de la chaine", + "description": "Controlez les interrupteurs principaux de la playlist, la commande de chat et le comportement de playlist visible par les viewers.", + "reconnectNotice": "Les permissions Twitch doivent etre actualisees avant que le bot puisse fonctionner.", + "mainToggles": "Interrupteurs principaux", + "enableBot": "Activer le bot sur cette chaine", + "enableBotHelp": "Desactivez ceci quand vous ne voulez pas d'actions request-bot, y compris l'attribution de tokens VIP pour les abonnements, raids, etc.", + "enableRequests": "Activer les demandes", + "enableRequestsHelp": "Desactiver les demandes vous laisse, vous et vos moderateurs, gerer la playlist, mais les viewers ne peuvent plus ajouter de morceaux.", + "playlistDisplay": "Affichage de la playlist", + "showPositions": "Afficher les positions de la playlist", + "commandPrefix": "Prefixe de commande", + "commandPrefixHelp": "Utilisez la commande que les viewers tapent dans le chat.", + "requestModifiers": "Modificateurs de demande", + "requestModifiersHelp": "Permet au chat de demander des arrangements basse avec le modificateur *bass.", + "allowBassModifier": "Autoriser le modificateur *bass dans les commandes de chat", + "duplicateCooldown": "Delai des doublons (minutes)", + "duplicateCooldownHelp": "Definissez combien de temps un meme morceau doit attendre avant de pouvoir etre redemande." + }, + "requestAccess": { + "title": "Qui peut demander", + "description": "Choisissez quels viewers peuvent ajouter des morceaux a votre playlist.", + "anyone": "Tout le monde peut demander", + "subscribers": "Les abonnes peuvent demander", + "vips": "Les VIP de la chaine peuvent demander" + }, + "filters": { + "title": "Filtres de recherche et de demande", + "description": "Ces regles limitent ce qui apparait dans la recherche et ce que les viewers peuvent demander sur votre page de chaine.", + "officialDlc": { + "title": "DLC officiel", + "description": "Limitez les resultats de recherche et les demandes au DLC officiel.", + "notice": "Les filtres de DLC officiel ne sont pas encore disponibles. Cette section montre un apercu des reglages qui apparaitront ici dans une future mise a jour.", + "onlyOfficial": "Inclure uniquement le DLC officiel", + "onlyOwned": "Inclure uniquement le DLC officiel que je possede", + "ownedTitle": "DLC officiel possede", + "import": "Importer depuis CustomsForge Song Manager", + "owned": "Possede", + "notOwned": "Non possede" + }, + "allowedTunings": { + "title": "Accordages autorises", + "description": "Cliquez sur n'importe quel accordage pour l'autoriser ou le bloquer.", + "allowAll": "Tout autoriser", + "groups": { + "open": "Ouverts", + "other": "Autres" + } + }, + "requiredPaths": { + "title": "Chemins requis", + "description": "Choisissez les chemins dont un morceau a besoin avant d'apparaitre dans la recherche ou d'etre demande.", + "matchAny": "Correspondre a n'importe quel chemin selectionne", + "matchAll": "Correspondre a tous les chemins selectionnes", + "none": "Aucun filtre de chemin n'est actif. Les morceaux peuvent correspondre avec n'importe quelle combinaison de chemins.", + "singlePrefix": "Les morceaux doivent inclure", + "singleSuffix": "Ils peuvent tout de meme inclure d'autres chemins.", + "exampleLabel": "Exemple : un morceau avec", + "singleExample": "correspond toujours.", + "matchAnyPrefix": "Les morceaux correspondent s'ils incluent au moins un de", + "matchAnySuffix": "Ils peuvent tout de meme inclure d'autres chemins.", + "matchAnyExample": "correspond toujours car il inclut un chemin selectionne.", + "matchAllPrefix": "Les morceaux correspondent seulement s'ils incluent tous ces chemins", + "matchAllSuffix": "Ils peuvent tout de meme inclure d'autres chemins.", + "matchAllExample": "correspond toujours car il inclut tous les chemins selectionnes.", + "paths": { + "lyrics": "Paroles" + } + } + }, + "queueLimits": { + "title": "Limites de file et de rythme", + "description": "Definissez la taille de la playlist et la frequence a laquelle des demandes normales ou VIP peuvent etre ajoutees.", + "queueLimits": "Limites de file", + "maxPlaylist": "Taille maximale de la playlist", + "maxPerViewer": "Max de demandes par viewer", + "maxPerSubscriber": "Max de demandes par abonne", + "maxVipPerViewer": "Max de demandes VIP par viewer", + "maxVipPerSubscriber": "Max de demandes VIP par abonne", + "rateLimits": "Limites de rythme", + "regular": "Normal", + "enableRegular": "Activer la limite de rythme pour les demandes normales", + "regularAllowed": "Demandes normales autorisees", + "regularPeriod": "Periode normale (secondes)", + "vip": "VIP", + "enableVip": "Activer la limite de rythme pour les demandes VIP", + "vipAllowed": "Demandes VIP autorisees", + "vipPeriod": "Periode VIP (secondes)" + }, + "vipAutomation": { + "title": "Automatisation des tokens VIP", + "description": "Attribuez automatiquement des tokens VIP pour les nouveaux abonnements, les abonnements offerts, les raids, les cheers et les tips StreamElements.", + "subscribes": "Abonnements", + "newSub": "Donner 1 token VIP pour un nouvel abonnement payant", + "sharedRenewal": "Donner 1 token VIP pour un message partage de renouvellement", + "sharedRenewalHelp": "Les renouvellements silencieux ne donnent rien automatiquement. Twitch envoie cet evenement ici uniquement quand le viewer partage le message de resub dans le chat.", + "giftedSubs": "Abonnements offerts", + "subGifter": "Donner 1 token VIP au gifter pour chaque abonnement offert", + "subRecipient": "Donner 1 token VIP a chaque destinataire d'abonnement offert", + "raids": "Raids", + "raidReward": "Donner 1 token VIP au streamer qui raid cette chaine", + "minimumRaid": "Taille minimale du raid", + "minimumRaidHelp": "Mettez 1 pour recompenser tous les raids.", + "raidNotice": "Twitch envoie les recompenses de raid ici uniquement quand le raid apparait dans le chat.", + "cheers": "Cheers", + "cheersToggle": "Donner des tokens VIP pour les cheers", + "cheerConversion": "Conversion des cheers", + "bitsPerToken": "bits pour 1 token VIP", + "minimumCheer": "Cheer minimum pour gagner une fraction de token", + "liveExample": "Exemple en direct", + "minimumCheerExample": "Cheer minimum : {bits} bits donnent {tokenCount} d'un token VIP avec le seuil de {percent}%.", + "bitsExample": "{oneTokenBits} bits donnent 1 token VIP. {fiveTokenBits} bits donnent 5 tokens VIP.", + "bitsExampleEmpty": "Definissez un nombre de bits par token VIP superieur a 0 pour voir le seuil minimum du cheer.", + "tips": "Tips", + "tipsToggle": "Donner des tokens VIP pour les tips StreamElements", + "tipAmount": "Montant du tip pour 1 token VIP", + "tipAmountHelp": "Un tip de 25 $ donne 5 tokens VIP quand ceci est regle sur 5.", + "relayUrl": "URL du relais", + "relayUrlHelp": "Utilisez cette URL dans l'etape Streamer.bot qui transfere votre evenement de tip StreamElements ici.", + "setup": "Configuration", + "setupHelp": "StreamElements peut continuer a afficher les alertes de tip dans OBS comme aujourd'hui. Les recompenses de tokens VIP ont besoin que Streamer.bot transfere l'evenement ici.", + "setupSteps": { + "connect": "Connectez Streamer.bot a StreamElements.", + "trigger": "Utilisez le declencheur StreamElements Tip dans Streamer.bot.", + "send": "Envoyez cet evenement de tip a l'URL du relais affichee ici.", + "note": "Sans Streamer.bot, les tips apparaissent toujours dans OBS, mais ils n'ajoutent pas de tokens VIP ici." + } + }, + "rules": { + "title": "Regles de blacklist et de setlist", + "description": "Les entrees de blacklist et de setlist sont gerees sur la page de chaine. Ces interrupteurs controlent la facon dont ces regles sont appliquees.", + "enableBlacklist": "Activer la blacklist", + "bypassBlacklist": "Autoriser la setlist a contourner la blacklist", + "enableSetlist": "Activer la setlist", + "subscribersFollowSetlist": "Les abonnes doivent suivre la setlist" + }, + "moderatorPermissions": { + "title": "Permissions des moderateurs", + "description": "Les moderateurs voient toujours les tokens VIP. Activez ou desactivez ici les autres actions de gestion de chaine.", + "manageRequests": "Gerer les demandes", + "manageBlacklist": "Gerer la blacklist", + "manageSetlist": "Gerer la setlist", + "manageBlockedViewers": "Gerer les viewers bloques", + "manageVipTokens": "Gerer les tokens VIP", + "manageTags": "Gerer les tags" + } + } + }, + "overlay": { + "title": "Overlay du stream", + "description": "Affichez votre playlist sur le stream avec une source navigateur.", + "channelFallback": "Votre chaine", + "states": { + "failedToLoad": "Impossible de charger les reglages de l'overlay.", + "failedPreview": "Impossible de charger l'apercu de la playlist.", + "failedToSave": "Les reglages de l'overlay n'ont pas pu etre enregistres.", + "saved": "Reglages de l'overlay enregistres.", + "copied": "URL de l'overlay copiee." + }, + "url": { + "title": "URL de l'overlay", + "copy": "Copier l'URL", + "open": "Ouvrir l'overlay" + }, + "layout": { + "title": "Disposition et comportement", + "showCreator": "Afficher le charter", + "showAlbum": "Afficher l'album", + "animateNowPlaying": "Animer le morceau en cours" + }, + "theme": { + "title": "Theme", + "accent": "Accent", + "vipBadge": "Badge VIP", + "text": "Texte", + "mutedText": "Texte secondaire", + "requestBackground": "Fond de l'element de demande", + "backgroundColor": "Couleur de fond", + "backgroundColorHelp": "Pour un fond transparent, reglez l'opacite du fond a 0.", + "border": "Bordure" + }, + "sizing": { + "title": "Densite et tailles", + "backgroundOpacity": "Opacite du fond de l'overlay", + "backgroundOpacityHelp": "Reglez ceci sur 0 pour un fond totalement transparent derriere les elements de la playlist.", + "cornerRadius": "Rayon des coins", + "itemGap": "Ecart entre les elements", + "itemPadding": "Padding des elements", + "titleFontSize": "Taille de police du titre", + "metaFontSize": "Taille de police des metas", + "value": "Valeur" + }, + "actions": { + "restoreDefaults": "Restaurer les valeurs par defaut", + "saveChanges": "Enregistrer les modifications", + "saving": "Enregistrement...", + "cancel": "Annuler" + }, + "preview": { + "title": "Apercu", + "live": "Direct", + "sample": "Exemple", + "channelTitle": "Playlist de {channel}" + }, + "restoreDialog": { + "title": "Restaurer les valeurs par defaut ?", + "description": "Cela reinitialise l'editeur d'overlay au theme par defaut. Les modifications non enregistrees seront perdues.", + "confirm": "Restaurer les valeurs par defaut" + } + } +} diff --git a/src/lib/i18n/resources/fr/home.json b/src/lib/i18n/resources/fr/home.json new file mode 100644 index 0000000..57ad4e3 --- /dev/null +++ b/src/lib/i18n/resources/fr/home.json @@ -0,0 +1,45 @@ +{ + "meta": { + "title": "Accueil" + }, + "hero": { + "eyebrow": "Gestion de playlist pour les streamers Rocksmith", + "title": "Recherchez des morceaux ou gerez votre chaine." + }, + "actions": { + "findSong": "Trouver un morceau a demander", + "searchSongs": "Rechercher des morceaux", + "manageChannel": "Gerer votre chaine", + "openSettings": "Ouvrir les parametres" + }, + "about": { + "eyebrow": "Qu'est-ce que RockList.Live ?", + "body": "RockList.Live aide les streamers Rocksmith a prendre des demandes, gerer la playlist et garder le stream fluide." + }, + "features": { + "surfaceRequestsTitle": "Des demandes depuis chaque surface", + "surfaceRequestsBody": "Les viewers ajoutent des morceaux sur la page de playlist, dans le chat ou depuis le panneau Twitch.", + "moderationTitle": "Support des moderateurs", + "moderationBody": "Les moderateurs gerent les demandes et les priorites VIP pendant le live.", + "queueTitle": "Gardez la file en mouvement", + "queueBody": "Modifiez, triez et suivez les demandes sans perdre le prochain morceau.", + "rulesTitle": "Definissez les regles", + "rulesBody": "Controlez les listes noires, les setlists, la moderation et les parametres de demande pour votre chaine." + }, + "live": { + "eyebrow": "En direct", + "title": "Streamers actuels", + "showLive": "Afficher le direct", + "showDemo": "Afficher la demo", + "demoOnly": "Les streamers affiches ici le sont uniquement a des fins de demonstration.", + "activeCount": "{count} actifs", + "empty": "Aucun streamer n'est encore en direct avec le bot actif.", + "previewAlt": "Apercu du live de {displayName}", + "status": "En direct", + "nowPlaying": "En cours", + "upNext": "Ensuite", + "nextRequest": "Prochaine demande", + "openPlaylist": "Ouvrir la playlist", + "watchOnTwitch": "Regarder sur Twitch" + } +} diff --git a/src/lib/i18n/resources/fr/playlist.json b/src/lib/i18n/resources/fr/playlist.json new file mode 100644 index 0000000..b442f71 --- /dev/null +++ b/src/lib/i18n/resources/fr/playlist.json @@ -0,0 +1,370 @@ +{ + "page": { + "title": "Playlist de {channel}", + "loading": "Chargement de la playlist...", + "empty": "Cette playlist est vide pour le moment.", + "viewerStateFailed": "Le chargement de l'etat des demandes spectateur a echoue.", + "viewerToolsFailed": "Le chargement des outils de demande spectateur a echoue.", + "requestsLiveOnly": "Vous pourrez ajouter des demandes quand le stream sera en direct." + }, + "states": { + "updateRequestToggleFailed": "Impossible de mettre a jour l'interrupteur des demandes pour le moment.", + "unableToAddSong": "Impossible d'ajouter le morceau.", + "unableToUpdateRequest": "Impossible de mettre a jour votre demande.", + "requestUpdated": "Demande mise a jour.", + "unableToRemoveRequests": "Impossible de retirer vos demandes.", + "requestsRemoved": "Demandes retirees." + }, + "search": { + "title": "Rechercher pour ajouter un morceau", + "placeholder": "Rechercher des morceaux pour {channel}", + "actions": { + "add": "Ajouter", + "request": "Demander", + "actions": "Actions" + }, + "showBlacklisted": "Afficher les morceaux blacklistes", + "pathWarning": "Ne correspond pas {count, plural, one {au chemin par defaut de la chaine} other {aux chemins par defaut de la chaine}} : {paths}." + }, + "viewerSummary": { + "vipTokensLabel": "{count} tokens VIP", + "vipBalanceLoading": "Solde VIP...", + "vipTokensShort": "Tokens VIP", + "requestsWithLimit": "{count}/{limit} demandes", + "requestsNoLimit": "{count} demandes", + "vipBalanceSummary": "{count} tokens VIP disponibles", + "vipBalanceChecking": "Verification de votre solde de tokens VIP...", + "vipBalanceUnavailable": "Votre solde de tokens VIP est indisponible pour le moment.", + "replaceQueued": "Les nouveaux ajouts remplacent vos demandes en file.", + "vipHelp": "Aide des tokens VIP", + "open": "Ouvrir", + "noRequests": "Aucune demande dans la playlist.", + "removeQueued": "Retirer les demandes en file", + "removing": "Retrait..." + }, + "badges": { + "vip": "VIP", + "regular": "Normal", + "nowPlaying": "En lecture", + "pick": "Pick {count}", + "requestsOn": "Les demandes sont actives", + "requestsOff": "Les demandes sont desactivees", + "turnRequestsOn": "Activer les demandes", + "turnRequestsOff": "Desactiver les demandes", + "online": "En ligne", + "offline": "Hors ligne", + "vipBalance": "Vous avez {count} tokens VIP", + "vipTokens": "Tokens VIP" + }, + "vipInfo": { + "earn": "Gagner des tokens VIP", + "manualOnly": "Cette chaine attribue les tokens VIP manuellement pour le moment.", + "use": "Utiliser les tokens VIP" + }, + "history": { + "title": "Historique joue", + "titleWithChannel": "Historique joue de {channel}", + "show": "Afficher l'historique", + "hide": "Masquer l'historique", + "songSearch": "Recherche de morceau", + "searchPlaceholder": "Morceau, artiste, album ou charter", + "clearSearch": "Effacer la recherche", + "requester": "Demandeur", + "requesterPlaceholder": "Rechercher un demandeur", + "clearRequester": "Effacer le demandeur", + "clearRequesterFilter": "Effacer le filtre de demandeur", + "searchingRequesters": "Recherche des demandeurs...", + "loading": "Chargement de l'historique joue...", + "emptyFiltered": "Aucun morceau joue ne correspond a ces filtres.", + "empty": "Aucun morceau n'a encore ete marque comme joue.", + "requestCount": "{count, plural, one {# demande} other {# demandes}}", + "byArtist": " par {artist}", + "requestedBy": "Demande par {requester}", + "page": "Page {page}", + "unknownRequester": "Inconnu" + }, + "rules": { + "sectionTitle": "Regles de la chaine", + "sectionTitleWithChannel": "Regles de la chaine de {channel}", + "states": { + "updateFailed": "Impossible de mettre a jour les regles de la chaine.", + "searchFailed": "La recherche a echoue." + }, + "blacklistedArtists": "Artistes blacklistes", + "blacklistedCharters": "Charters blacklistes", + "blacklistedSongs": "Morceaux blacklistes", + "blacklistedVersions": "Versions blacklistees", + "setlistArtists": "Artistes de la setlist", + "searchArtists": "Rechercher des artistes par nom", + "searchCharters": "Rechercher des charters par nom", + "searchSongs": "Rechercher des morceaux par titre", + "searchMin": "Saisissez au moins 2 caracteres pour rechercher.", + "noMatches": "Aucune entree correspondante a ajouter.", + "add": "Ajouter", + "remove": "Retirer", + "trackCount": "{count, plural, one {# morceau} other {# morceaux}}", + "versionCount": "{count, plural, one {# version} other {# versions}} - Groupe de morceau {groupId}", + "artistId": "ID artiste {id}", + "charterId": "ID charter {id}", + "songGroupId": "Groupe de morceau {groupId}", + "versionId": "ID version {id}", + "noBlacklistedArtists": "Aucun artiste blackliste.", + "noBlacklistedCharters": "Aucun charter blackliste.", + "noBlacklistedSongs": "Aucun morceau blackliste.", + "noBlacklistedVersions": "Aucune version blacklistee.", + "noSetlistArtists": "Aucun artiste de setlist." + }, + "community": { + "title": "Controles de moderation", + "reconnect": "Reconnecter Twitch", + "reconnectMessage": "Reconnectez Twitch pour prioriser les viewers actuellement dans le chat.", + "currentChattersFirst": "Viewers du chat en premier", + "twitchMatches": "Correspondances Twitch", + "resultCount": "{count, plural, one {# resultat} other {# resultats}}", + "inChat": "Dans le chat", + "selected": "Selectionne", + "noMatchingUsers": "Aucun nom d'utilisateur Twitch correspondant.", + "states": { + "updateFailed": "Impossible de mettre a jour les reglages de communaute de la chaine.", + "lookupFailed": "La recherche d'utilisateur a echoue." + }, + "blocks": { + "title": "Viewers bloques", + "searchPlaceholder": "Rechercher un nom Twitch a bloquer", + "blockViewer": "Bloquer le viewer", + "searchMin": "Saisissez au moins 4 caracteres pour rechercher des noms Twitch.", + "description": "Les viewers bloques peuvent toujours parler dans le chat Twitch, mais ils ne peuvent pas ajouter ni modifier des demandes depuis le chat, le site web ou le panneau d'extension.", + "defaultReason": "Bloque pour faire des demandes sur cette chaine.", + "unblock": "Debloquer", + "empty": "Aucun viewer bloque." + }, + "vip": { + "title": "Tokens VIP", + "searchPlaceholder": "Rechercher un nom Twitch pour attribuer un token", + "grant": "Attribuer un token", + "searchMin": "Saisissez au moins 4 caracteres pour rechercher des noms Twitch.", + "searchMinExisting": "Les detenteurs de tokens VIP existants apparaissent ci-dessous. Saisissez au moins 4 caracteres pour rechercher parmi tous les noms Twitch.", + "tokenHoldersFirst": "Detenteurs de tokens VIP en premier", + "tokenCountSingle": "{count} token", + "tokenCountPlural": "{count} tokens", + "viewOnly": "Vous pouvez voir les soldes VIP, mais seul le streamer ou un moderateur autorise peut les modifier.", + "username": "Nom d'utilisateur", + "tokens": "Tokens", + "showingRange": "{start}-{end} sur {total}", + "previous": "Precedent", + "next": "Suivant", + "pageOf": "Page {page} sur {total}", + "empty": "Aucun token VIP pour le moment.", + "noticeAddedSingle": "1 token ajoute", + "noticeAddedMultiple": "{count} tokens VIP ajoutes", + "noticeRemovedSingle": "1 token retire", + "noticeRemovedMultiple": "{count} tokens VIP retires", + "noticeSaved": "Tokens VIP enregistres" + } + }, + "viewerActions": { + "add": "Ajouter", + "addVip": "Ajouter VIP", + "adding": "Ajout...", + "alreadyVip": "Deja dans votre file comme demande VIP.", + "alreadyRegular": "Deja dans votre file comme demande normale.", + "blacklistedPrefix": "Blackliste", + "songUnavailable": "Ce morceau n'est pas disponible ici.", + "checkingAccess": "Verification de votre acces aux demandes...", + "cannotRequest": "Vous ne pouvez pas demander de morceaux ici.", + "alreadyActive": "Ce morceau est deja dans vos demandes actives.", + "activeLimitReached": "Vous avez deja {count, plural, one {# demande active} other {# demandes actives}}.", + "insufficientVipTokens": "Vous n'avez pas assez de tokens VIP." + }, + "specialRequest": { + "titleManage": "Ajouter une demande personnalisee", + "titleViewer": "Demander par artiste", + "artist": "Artiste", + "artistPlaceholder": "Nom de l'artiste", + "artistMin": "Saisissez au moins 2 caracteres du nom d'un artiste.", + "chooseMode": "Choisir le mode", + "chooseType": "Choisir le type", + "random": "Aleatoire", + "choice": "Choix", + "regular": "Normal", + "add": "Ajouter", + "addVip": "Ajouter VIP", + "adding": "Ajout...", + "randomHelp": "Ajoute un morceau aleatoire parmi les morceaux correspondants pour cet artiste.", + "choiceHelp": "Ajoute une demande de choix du streamer pour cet artiste." + }, + "manageActions": { + "add": "Ajouter", + "adding": "Ajout...", + "addForUser": "Ajouter pour un utilisateur", + "searchViewers": "Rechercher les viewers actuels", + "searchMin": "Saisissez au moins 2 caracteres pour rechercher les viewers actuels.", + "reconnectMessage": "Reconnectez Twitch pour rechercher les viewers actuellement dans le chat.", + "reconnect": "Reconnecter", + "searching": "Recherche des viewers actuels...", + "noMatches": "Aucun viewer actuel ne correspond a ce nom." + }, + "row": { + "viewer": "viewer", + "added": "Ajoute {time}", + "edited": "Modifie {time}", + "streamerChoiceTitle": "Choix du streamer : {query}", + "streamerChoiceSubtitle": "Le streamer choisit le morceau exact.", + "relative": { + "now": "maintenant", + "minutes": "{count} min", + "hours": "{count} h", + "days": "{count} j" + }, + "picks": { + "first": "1er pick", + "second": "2e pick", + "third": "3e pick" + } + }, + "management": { + "states": { + "searchFailed": "La recherche a echoue.", + "playlistUpdateFailed": "La playlist n'a pas pu etre mise a jour.", + "moderationUnavailable": "Les actions de moderation ne sont pas disponibles pour cette chaine.", + "blacklistUpdateFailed": "La blacklist n'a pas pu etre mise a jour." + }, + "header": { + "managing": "Gestion de {channel}", + "channel": "Chaine : {channel}" + }, + "manual": { + "title": "Ajouter un morceau", + "requesterPlaceholder": "Nom du demandeur (optionnel)", + "searchPlaceholder": "Rechercher et ajouter un morceau", + "searchMin": "La recherche doit contenir au moins 3 caracteres.", + "table": { + "track": "Morceau", + "albumCreator": "Album / Charter", + "tuningPath": "Accordage / Chemin", + "add": "Ajouter" + }, + "unknownArtist": "Artiste inconnu", + "unknownAlbum": "Album inconnu", + "chartedBy": "Charte par {creator}", + "unknownCreator": "Charter inconnu", + "blacklisted": "Blackliste", + "noTuningInfo": "Aucun accordage", + "noPathInfo": "Aucun chemin", + "addButton": "Ajouter" + }, + "actions": { + "shuffle": "Melanger", + "clearPlaylist": "Vider la playlist", + "resetSession": "Reinitialiser la session", + "confirmClear": "Vider toute la playlist ? Cette action est definitive.", + "confirmReset": "Reinitialiser la session ? Cela videra la playlist actuelle." + }, + "currentTitle": "Playlist actuelle", + "blacklistErrors": { + "missingRequestVersionId": "Cette demande n'a pas d'ID de version a blacklister.", + "missingVersionVersionId": "Cette version n'a pas d'ID de version a blacklister.", + "missingRequestSongGroupId": "Cette demande n'a pas d'ID de groupe de morceau a blacklister.", + "missingRequestArtistId": "Cette demande n'a pas d'ID d'artiste a blacklister.", + "missingVersionCharterId": "Cette version n'a pas d'ID de charter a blacklister.", + "unknownArtist": "Artiste inconnu", + "unknownCharter": "Charter inconnu" + }, + "blacklistPanelDescription": "Les artistes, charters, morceaux et versions precises peuvent etre bloques pour cette chaine.", + "history": { + "title": "Historique joue", + "requestedBy": "Demande par {requester}", + "played": "Joue {time}", + "restore": "Restaurer", + "restoring": "Restauration...", + "empty": "Rien n'a encore ete marque comme joue." + }, + "deleteDialog": { + "title": "Retirer cette demande de la playlist ?", + "descriptionWithArtist": "Cela retire \"{title}\" de {artist} de la playlist. Cette action est definitive.", + "descriptionWithoutArtist": "Cela retire \"{title}\" de la playlist. Cette action est definitive.", + "descriptionFallback": "Cela retire la demande selectionnee de la playlist. Cette action est definitive.", + "keep": "Garder la demande", + "removing": "Suppression...", + "remove": "Retirer la demande" + }, + "queue": { + "empty": "Aucun morceau dans la playlist pour le moment." + }, + "relative": { + "now": "a l'instant", + "minutesAgo": "{count, plural, one {il y a # minute} other {il y a # minutes}}", + "hoursAgo": "{count, plural, one {il y a # heure} other {il y a # heures}}", + "daysAgo": "{count, plural, one {il y a # jour} other {il y a # jours}}" + }, + "item": { + "moveTop": "Deplacer en haut", + "moveUp": "Monter", + "moveDown": "Descendre", + "moveBottom": "Deplacer en bas", + "reorderAria": "Reordonner {title}", + "vipBadge": "VIP", + "playingBadge": "En lecture", + "warningBadge": "Alerte", + "versionBlacklisted": "Version blacklistee", + "songBlacklisted": "Morceau blackliste", + "artistBlacklisted": "Artiste blackliste", + "requestedBy": "Demande par {requester}", + "vipTokens": "{count} tokens VIP", + "added": "Ajoute {time}", + "requestedText": "Texte demande : {query}", + "saving": "Enregistrement...", + "makeRegular": "Passer en normal", + "makeVip": "Passer en VIP", + "returnToQueue": "Remettre en file", + "playNow": "Jouer maintenant", + "markComplete": "Marquer termine", + "versionsCount": "{count, plural, one {# version} other {# versions}}", + "downloadFromCf": "Telecharger depuis CF" + }, + "actionsMenu": { + "openActionsAria": "Ouvrir les actions pour {title}", + "removing": "Suppression...", + "removeFromPlaylist": "Retirer de la playlist", + "blacklist": "Blacklist", + "blacklistTitle": "Actions de blacklist", + "back": "Retour", + "description": "Choisissez si vous voulez bloquer la version en file, toutes les versions de ce morceau, l'artiste ou un charter.", + "queuedVersionId": "ID de version en file {id}", + "noQueuedVersionId": "Aucun ID de version en file", + "versionBlocked": "Version blacklistee", + "blacklistQueuedVersion": "Blacklister la version en file", + "blockVersionDescription": "Bloque uniquement l'ID de version {id}.", + "blockVersionFallbackDescription": "Bloque uniquement la version exacte associee a cette demande.", + "songBlocked": "Morceau blackliste", + "blacklistSongGroup": "Blacklister toutes les versions de ce morceau", + "blockSongDescription": "Bloque toutes les versions groupees sous ce morceau.", + "artistBlocked": "Artiste blackliste : {artist}", + "blacklistArtist": "Blacklister l'artiste : {artist}", + "blockArtistDescription": "Bloque tous les morceaux de cet ID artiste.", + "charterBlocked": "Charter blackliste : {charter}", + "blacklistCharter": "Blacklister le charter : {charter}", + "blockCharterDescription": "Bloque tous les morceaux de cet ID charter.", + "noCharterIds": "Aucun ID de charter disponible pour ces versions." + }, + "versionsTable": { + "songAlbum": "Morceau / album", + "tunings": "Accordages", + "paths": "Chemins", + "updated": "Mis a jour", + "downloads": "Telechargements", + "actions": "Actions", + "unknownArtist": "Artiste inconnu", + "chartedBy": "Charte par", + "charterBlacklisted": "Charter blackliste", + "unknown": "Inconnu", + "download": "Telecharger", + "blacklisted": "Blackliste", + "blacklist": "Blacklister" + }, + "preview": { + "removed": "La ligne de demo a ete retiree de la playlist.", + "restore": "Restaurer la ligne de demo" + } + } +} diff --git a/src/lib/i18n/resources/fr/search.json b/src/lib/i18n/resources/fr/search.json new file mode 100644 index 0000000..31d6be8 --- /dev/null +++ b/src/lib/i18n/resources/fr/search.json @@ -0,0 +1,79 @@ +{ + "meta": { + "title": "Recherche de morceaux" + }, + "page": { + "title": "Recherche", + "infoNote": "Cette demo contient seulement {count} morceaux.", + "placeholder": "Rechercher par titre, artiste ou album" + }, + "summary": { + "note": "Note :", + "foundCount": "{count} morceaux trouves", + "filters": "Filtres", + "moreCount": "+{count} de plus", + "changeFilters": "Modifier les filtres" + }, + "controls": { + "searchField": "Champ de recherche", + "allFields": "Tous les champs", + "titleOnly": "Titre uniquement", + "artistOnly": "Artiste uniquement", + "albumOnly": "Album uniquement", + "creatorOnly": "Createur uniquement", + "showFilters": "Afficher les filtres", + "hideFilters": "Masquer les filtres", + "title": "Titre", + "artist": "Artiste", + "album": "Album", + "creator": "Createur", + "tuning": "Accordage", + "path": "Partie", + "year": "Annee", + "actions": "Actions", + "clearAdvancedFilters": "Effacer les filtres", + "matchAny": "Correspond a l'un", + "matchAll": "Correspond a tous" + }, + "columns": { + "song": "Morceau", + "paths": "Parties", + "stats": "Infos", + "actions": "Actions" + }, + "states": { + "loading": "Chargement des morceaux...", + "queryTooShort": "Les termes de recherche doivent contenir au moins 3 caracteres.", + "emptyFiltered": "Aucun morceau ne correspond encore a ces filtres. Essayez d'elargir la recherche ou de supprimer un filtre avance.", + "emptyCatalog": "Aucun morceau n'est encore disponible dans le catalogue de demo.", + "blacklisted": "Liste noire", + "blacklistedWithReasons": "Liste noire - {reasons}", + "unknownArtist": "Artiste inconnu", + "chartedBy": "Chart par {creator}", + "updated": "Mis a jour {date}" + }, + "errors": { + "filterOptionsFailed": "Le chargement des options de filtre a echoue.", + "searchFailed": "La recherche a echoue." + }, + "commands": { + "copySr": "Copier la commande !sr", + "copiedSr": "Commande !sr copiee", + "copyEdit": "Copier la commande !edit", + "copiedEdit": "Commande !edit copiee", + "copyVip": "Copier la commande !vip", + "copiedVip": "Commande !vip copiee" + }, + "paths": { + "lead": "Lead", + "rhythm": "Rythmique", + "bass": "Basse", + "lyrics": "Paroles" + }, + "multiSelect": { + "selected": "{count} selectionnes", + "select": "Selectionner {label}", + "filter": "Filtrer {label}...", + "noMatches": "Aucun resultat." + } +} diff --git a/src/lib/i18n/resources/pt-br/admin.json b/src/lib/i18n/resources/pt-br/admin.json new file mode 100644 index 0000000..88a88ef --- /dev/null +++ b/src/lib/i18n/resources/pt-br/admin.json @@ -0,0 +1,126 @@ +{ + "page": { + "title": "Admin", + "description": "Gerencie o acesso compartilhado do bot e os controles de teste offline.", + "noAccess": "Você não tem acesso ao painel administrativo.", + "authNotice": "Conclua a autenticação da Twitch como", + "botAccount": "Conta do bot", + "botMismatch": "Conectado como {connected}. Era esperado {expected}. Reconecte para trocar de conta." + }, + "states": { + "offlineTestingFailed": "Não foi possível atualizar o teste offline.", + "botUpdateFailed": "Não foi possível atualizar a conta do bot.", + "updated": "Atualizado." + }, + "status": { + "connected": "Conectado", + "needsAuth": "Precisa de autenticação", + "enabled": "Ativado", + "disabled": "Desativado" + }, + "actions": { + "connectBot": "Conectar {username}", + "reconnectBot": "Reconectar bot", + "reconnecting": "Reconectando...", + "enable": "Ativar", + "enabling": "Ativando...", + "disable": "Desativar", + "disabling": "Desativando..." + }, + "metrics": { + "requestIssues": { + "label": "Total de problemas de pedidos", + "description": "Resultados bloqueados, rejeitados e com erro em todos os registros de pedidos." + }, + "auditRows": { + "label": "Total de linhas de auditoria", + "description": "Ações administrativas e de gerenciamento do canal registradas." + } + }, + "offlineTesting": { + "title": "Teste offline do bot" + }, + "prototype": { + "title": "Protótipo de linha da playlist", + "description": "Prévia de um item de playlist mais compacto com as versões da música sempre visíveis.", + "cardTitle": "Item compacto de playlist com várias versões", + "cardDescription": "Esta prévia usa o mesmo componente de item da playlist da página ao vivo, com uma música de demonstração que tem duas versões." + }, + "logs": { + "title": "Registros de pedidos", + "description": "Tentativas de pedido mais recentes no chat e no fluxo de pedidos do espectador. Cada linha mostra o pedido, quem enviou, o resultado e qualquer correspondência ou motivo registrado.", + "loading": "Carregando registros de pedidos...", + "empty": "Ainda não há registros de pedidos." + }, + "audits": { + "title": "Registros de auditoria", + "description": "Ações administrativas e de gerenciamento do canal registradas mais recentemente para este canal. Cada linha mostra o que mudou, quem acionou e os principais valores salvos.", + "loading": "Carregando registros de auditoria...", + "empty": "Ainda não há registros de auditoria." + }, + "pagination": { + "show": "Mostrar", + "previous": "Anterior", + "next": "Próximo", + "updating": "Atualizando...", + "showingEmpty": "Mostrando 0 resultados", + "showingRange": "Mostrando {start}-{end} de {total}" + }, + "table": { + "loading": "Carregando...", + "unknownTime": "Hora desconhecida", + "noExtraValues": "Nenhum valor extra foi registrado.", + "none": "Nenhum", + "yes": "Sim", + "no": "Não" + }, + "requestLog": { + "columns": { + "time": "Hora", + "request": "Pedido", + "requester": "Solicitante", + "result": "Resultado", + "details": "Detalhes" + }, + "query": "Consulta: {query}", + "unknownUser": "Usuário desconhecido", + "matched": "Correspondência: {title}{artist}", + "reason": "Motivo: {reason}", + "noMatchOrReason": "Nenhuma correspondência ou motivo foi registrado." + }, + "auditLog": { + "columns": { + "time": "Hora", + "action": "Ação", + "entity": "Entidade", + "actor": "Autor", + "details": "Detalhes" + } + }, + "labels": { + "accepted": "Aceito", + "blocked": "Bloqueado", + "error": "Erro", + "system": "Sistema", + "auto_grant_vip_tokens_cheer": "Conceder tokens VIP automaticamente: cheer", + "auto_grant_vip_tokens_gift_recipient": "Conceder tokens VIP automaticamente: destinatário do sub presenteado", + "auto_grant_vip_tokens_new_subscriber": "Conceder tokens VIP automaticamente: novo sub pago", + "auto_grant_vip_tokens_raid": "Conceder tokens VIP automaticamente: raid", + "auto_grant_vip_tokens_shared_sub_renewal_message": "Conceder tokens VIP automaticamente: mensagem compartilhada de renovação", + "auto_grant_vip_tokens_streamelements_tip": "Conceder tokens VIP automaticamente: gorjeta do StreamElements", + "auto_grant_vip_tokens_sub_gifter": "Conceder tokens VIP automaticamente: quem presenteou o sub", + "grantedTokenCount": "Tokens concedidos", + "minimumRaidViewerCount": "Tamanho mínimo do raid", + "totalGiftedSubs": "Subs presenteados", + "twitchMessageId": "ID da mensagem do EventSub", + "vip_token": "Token VIP" + }, + "auditSources": { + "channelCheer": "Cheer", + "channelRaid": "Raid", + "channelSubscribe": "Inscrição do canal", + "channelSubscriptionGift": "Sub presenteado", + "channelSubscriptionMessage": "Mensagem compartilhada de renovação", + "streamElementsTip": "Gorjeta do StreamElements" + } +} diff --git a/src/lib/i18n/resources/pt-br/common.json b/src/lib/i18n/resources/pt-br/common.json new file mode 100644 index 0000000..477f8f2 --- /dev/null +++ b/src/lib/i18n/resources/pt-br/common.json @@ -0,0 +1,19 @@ +{ + "brand": { + "name": "RockList.Live" + }, + "nav": { + "search": "Buscar", + "account": "Conta" + }, + "auth": { + "goToPlaylist": "Ir para sua playlist.", + "logOut": "Sair", + "signIn": "Entrar com a Twitch", + "permissionsRefresh": "As permissoes da Twitch precisam ser atualizadas para esta conta.", + "reconnect": "Reconectar Twitch" + }, + "language": { + "label": "Idioma" + } +} diff --git a/src/lib/i18n/resources/pt-br/dashboard.json b/src/lib/i18n/resources/pt-br/dashboard.json new file mode 100644 index 0000000..3e3c9e2 --- /dev/null +++ b/src/lib/i18n/resources/pt-br/dashboard.json @@ -0,0 +1,314 @@ +{ + "nav": { + "channels": "Canais", + "settings": "Configuracao", + "admin": "Admin" + }, + "botStatus": { + "active": "Ativo", + "activeOfflineTesting": "Teste offline ativado", + "waitingForLive": "Aguardando entrar ao vivo", + "botAuthRequired": "Autenticacao do bot necessaria", + "broadcasterAuthRequired": "Autenticacao do broadcaster necessaria", + "subscriptionError": "Erro de inscricao", + "disabled": "Desativado" + }, + "overview": { + "title": "Conta", + "description": "Acesso ao canal e configuracoes do proprietario.", + "cards": { + "openPlaylist": "Abrir sua playlist", + "manageSettings": "Gerenciar configuracoes do canal" + }, + "indicators": { + "channelStatus": "Status do canal", + "requests": "Pedidos", + "bot": "Bot" + }, + "status": { + "live": "Ao vivo", + "offline": "Offline", + "unavailable": "Indisponivel", + "enabled": "Ativado", + "paused": "Pausado" + }, + "moderatedChannels": { + "title": "Canais online que voce modera", + "reconnect": "Reconecte a Twitch para atualizar seu acesso aos canais moderados.", + "openChannel": "Abrir canal", + "empty": "Nenhum canal moderado esta ao vivo agora." + }, + "notesTitle": "Notas", + "notes": { + "enableBot": { + "title": "Ative o bot", + "body": "Ative o controle do bot em Configuracao antes de usar pedidos no seu canal." + }, + "offlineTesting": { + "title": "O teste offline esta ativo", + "body": "Os pedidos continuam disponiveis enquanto voce testa o bot offline." + }, + "goLive": { + "title": "Entre ao vivo para aceitar pedidos", + "body": "Os pedidos foram feitos para funcionar quando seu canal estiver ao vivo." + }, + "requestsPaused": { + "title": "Os pedidos estao em pausa", + "body": "Ative os pedidos novamente em Configuracao quando quiser que os espectadores voltem a adicionar musicas." + }, + "channelReady": { + "title": "O canal esta pronto", + "body": "Sua live esta ao vivo e os pedidos estao disponiveis." + }, + "checkStatus": { + "title": "Verifique o status do canal", + "body": "Confirme que o bot esta conectado antes de comecar a aceitar pedidos." + } + } + }, + "settings": { + "header": { + "title": "Configuracao", + "description": "Configuracao do canal apenas para o proprietario: pedidos, moderadores e overlay da live.", + "betaNote": "Os beta testers podem instalar o painel da extensao da Twitch.", + "previewPanel": "Prever painel de moderacao", + "installExtension": "Instalar beta da extensao da Twitch" + }, + "states": { + "failedToLoad": "Nao foi possivel carregar as configuracoes.", + "failedToSave": "Nao foi possivel salvar as configuracoes.", + "loadingAccess": "Carregando acesso as configuracoes...", + "loading": "Carregando configuracoes...", + "saveFailed": "Falha ao salvar. Revise a mensagem abaixo e tente novamente.", + "saving": "Salvando configuracoes...", + "unsavedChanges": "Voce tem alteracoes nao salvas.", + "allChangesSaved": "Todas as alteracoes foram salvas.", + "saved": "Salvo", + "savingButton": "Salvando..." + }, + "actions": { + "saveSettings": "Salvar configuracoes", + "reconnectTwitch": "Reconectar Twitch", + "copyUrl": "Copiar URL", + "copied": "Copiado", + "show": "Mostrar", + "hide": "Ocultar" + }, + "ownerOnly": { + "title": "Apenas configuracoes do proprietario", + "description": "Esta area so esta disponivel para canais que pertencem a voce. Use a pagina do canal para moderar canais que voce gerencia.", + "openChannel": "Abrir pagina do canal", + "signInHint": "Entre com uma conta de streamer que seja proprietaria de um canal para gerenciar essas configuracoes aqui." + }, + "sections": { + "channelSetup": { + "title": "Configuracao do canal", + "description": "Controle os principais ajustes da playlist, o comando do chat e o comportamento da playlist para os espectadores.", + "reconnectNotice": "As permissoes da Twitch precisam ser atualizadas antes que o bot possa funcionar.", + "mainToggles": "Controles principais", + "enableBot": "Ativar bot neste canal", + "enableBotHelp": "Desative isto quando nao quiser acoes do request-bot, inclusive conceder tokens VIP por inscricoes, raids etc.", + "enableRequests": "Ativar pedidos", + "enableRequestsHelp": "Desativar pedidos ainda permite que voce e seus moderadores gerenciem a playlist, mas os espectadores nao poderao adicionar musicas.", + "playlistDisplay": "Exibicao da playlist", + "showPositions": "Mostrar posicoes da playlist", + "commandPrefix": "Prefixo do comando", + "commandPrefixHelp": "Use o comando que os espectadores digitam no chat.", + "requestModifiers": "Modificadores de pedido", + "requestModifiersHelp": "Permite pedir arranjos de baixo com o modificador *bass no chat.", + "allowBassModifier": "Permitir o modificador *bass nos comandos do chat", + "duplicateCooldown": "Tempo de espera para duplicados (minutos)", + "duplicateCooldownHelp": "Defina quanto tempo a mesma musica precisa esperar antes de poder ser pedida de novo." + }, + "requestAccess": { + "title": "Quem pode pedir", + "description": "Escolha quais espectadores podem adicionar musicas a sua playlist.", + "anyone": "Qualquer pessoa pode pedir", + "subscribers": "Inscritos podem pedir", + "vips": "VIPs do canal podem pedir" + }, + "filters": { + "title": "Filtros de busca e pedido", + "description": "Essas regras limitam o que aparece na busca e o que os espectadores podem pedir na pagina do canal.", + "officialDlc": { + "title": "DLC oficial", + "description": "Limite os resultados da busca e os pedidos ao DLC oficial.", + "notice": "Os filtros de DLC oficial ainda nao estao disponiveis. Esta secao mostra uma previa das configuracoes que aparecerao aqui em uma atualizacao futura.", + "onlyOfficial": "Incluir apenas DLC oficial", + "onlyOwned": "Incluir apenas DLC oficial que eu possuo", + "ownedTitle": "DLC oficial possuido", + "import": "Importar do CustomsForge Song Manager", + "owned": "Possuido", + "notOwned": "Nao possuido" + }, + "allowedTunings": { + "title": "Afinacoes permitidas", + "description": "Clique em qualquer afinacao para permitir ou bloquear.", + "allowAll": "Permitir todas", + "groups": { + "open": "Abertas", + "other": "Outras" + } + }, + "requiredPaths": { + "title": "Caminhos obrigatorios", + "description": "Escolha os caminhos que uma musica precisa ter antes de aparecer na busca ou poder ser pedida.", + "matchAny": "Corresponder a qualquer caminho selecionado", + "matchAll": "Corresponder a todos os caminhos selecionados", + "none": "Nenhum filtro de caminho esta ativo. As musicas podem corresponder com qualquer combinacao de caminhos.", + "singlePrefix": "As musicas precisam incluir", + "singleSuffix": "Elas ainda podem incluir quaisquer outros caminhos.", + "exampleLabel": "Exemplo: uma musica com", + "singleExample": "ainda corresponde.", + "matchAnyPrefix": "As musicas correspondem se incluirem pelo menos um destes caminhos", + "matchAnySuffix": "Elas ainda podem incluir quaisquer outros caminhos.", + "matchAnyExample": "ainda corresponde porque inclui um caminho selecionado.", + "matchAllPrefix": "As musicas so correspondem se incluirem todos estes caminhos", + "matchAllSuffix": "Elas ainda podem incluir quaisquer outros caminhos.", + "matchAllExample": "ainda corresponde porque inclui todos os caminhos selecionados.", + "paths": { + "lyrics": "Letras" + } + } + }, + "queueLimits": { + "title": "Limites de fila e ritmo", + "description": "Defina o tamanho da playlist e com que frequencia pedidos normais ou VIP podem ser adicionados.", + "queueLimits": "Limites da fila", + "maxPlaylist": "Tamanho maximo da playlist", + "maxPerViewer": "Maximo de pedidos por espectador", + "maxPerSubscriber": "Maximo de pedidos por inscrito", + "maxVipPerViewer": "Maximo de pedidos VIP por espectador", + "maxVipPerSubscriber": "Maximo de pedidos VIP por inscrito", + "rateLimits": "Limites de ritmo", + "regular": "Normal", + "enableRegular": "Ativar limite de ritmo para pedidos normais", + "regularAllowed": "Pedidos normais permitidos", + "regularPeriod": "Periodo normal (segundos)", + "vip": "VIP", + "enableVip": "Ativar limite de ritmo para pedidos VIP", + "vipAllowed": "Pedidos VIP permitidos", + "vipPeriod": "Periodo VIP (segundos)" + }, + "vipAutomation": { + "title": "Automacao de tokens VIP", + "description": "Conceda automaticamente tokens VIP para novas inscricoes, inscricoes de presente, raids, cheers e tips do StreamElements.", + "subscribes": "Inscricoes", + "newSub": "Dar 1 token VIP para uma nova inscricao paga", + "sharedRenewal": "Dar 1 token VIP por uma mensagem compartilhada de renovacao", + "sharedRenewalHelp": "Renovacoes silenciosas nao concedem premio automaticamente. A Twitch so envia esse evento aqui quando o espectador compartilha a mensagem de resub no chat.", + "giftedSubs": "Inscricoes presenteadas", + "subGifter": "Dar 1 token VIP para quem presenteou por cada inscricao dada", + "subRecipient": "Dar 1 token VIP para cada destinatario de inscricao presenteada", + "raids": "Raids", + "raidReward": "Dar 1 token VIP ao streamer que fizer raid neste canal", + "minimumRaid": "Tamanho minimo do raid", + "minimumRaidHelp": "Defina 1 para recompensar todos os raids.", + "raidNotice": "A Twitch so envia recompensas de raid aqui quando o raid aparece no chat.", + "cheers": "Cheers", + "cheersToggle": "Dar tokens VIP por cheers", + "cheerConversion": "Conversao de cheers", + "bitsPerToken": "bits por 1 token VIP", + "minimumCheer": "Cheer minimo para ganhar parte de um token", + "liveExample": "Exemplo ao vivo", + "minimumCheerExample": "Cheer minimo: {bits} bits concede {tokenCount} de um token VIP com o limite de {percent}%.", + "bitsExample": "{oneTokenBits} bits concede 1 token VIP. {fiveTokenBits} bits concede 5 tokens VIP.", + "bitsExampleEmpty": "Defina bits por token VIP acima de 0 para ver o limite minimo do cheer.", + "tips": "Tips", + "tipsToggle": "Dar tokens VIP por tips do StreamElements", + "tipAmount": "Valor da gorjeta por 1 token VIP", + "tipAmountHelp": "Uma gorjeta de $25 concede 5 tokens VIP quando isto esta definido como 5.", + "relayUrl": "URL do relay", + "relayUrlHelp": "Use esta URL na etapa do Streamer.bot que encaminha o evento de tip do StreamElements.", + "setup": "Configuracao", + "setupHelp": "O StreamElements pode continuar mostrando alertas de tip no OBS como voce ja usa hoje. As recompensas de tokens VIP precisam que o Streamer.bot encaminhe o evento para ca.", + "setupSteps": { + "connect": "Conecte o Streamer.bot ao StreamElements.", + "trigger": "Use o gatilho StreamElements Tip no Streamer.bot.", + "send": "Envie esse evento de tip para a URL do relay mostrada aqui.", + "note": "Sem Streamer.bot, as tips continuam aparecendo no OBS, mas nao adicionam tokens VIP aqui." + } + }, + "rules": { + "title": "Regras de blacklist e setlist", + "description": "As entradas de blacklist e setlist sao gerenciadas na pagina do canal. Estes controles definem como essas regras sao aplicadas.", + "enableBlacklist": "Ativar blacklist", + "bypassBlacklist": "Permitir que a setlist ignore a blacklist", + "enableSetlist": "Ativar setlist", + "subscribersFollowSetlist": "Inscritos devem seguir a setlist" + }, + "moderatorPermissions": { + "title": "Permissoes de moderacao", + "description": "Moderadores sempre veem os tokens VIP. Ative ou desative aqui as outras acoes de gerenciamento do canal.", + "manageRequests": "Gerenciar pedidos", + "manageBlacklist": "Gerenciar blacklist", + "manageSetlist": "Gerenciar setlist", + "manageBlockedViewers": "Gerenciar espectadores bloqueados", + "manageVipTokens": "Gerenciar tokens VIP", + "manageTags": "Gerenciar tags" + } + } + }, + "overlay": { + "title": "Overlay da live", + "description": "Mostre sua playlist na live usando uma fonte de navegador.", + "channelFallback": "Seu canal", + "states": { + "failedToLoad": "Nao foi possivel carregar as configuracoes do overlay.", + "failedPreview": "Nao foi possivel carregar a previa da playlist.", + "failedToSave": "Nao foi possivel salvar as configuracoes do overlay.", + "saved": "Configuracoes do overlay salvas.", + "copied": "URL do overlay copiada." + }, + "url": { + "title": "URL do overlay", + "copy": "Copiar URL", + "open": "Abrir overlay" + }, + "layout": { + "title": "Layout e comportamento", + "showCreator": "Mostrar charter", + "showAlbum": "Mostrar album", + "animateNowPlaying": "Animar musica atual" + }, + "theme": { + "title": "Tema", + "accent": "Acento", + "vipBadge": "Badge VIP", + "text": "Texto", + "mutedText": "Texto secundario", + "requestBackground": "Fundo do item de pedido", + "backgroundColor": "Cor de fundo", + "backgroundColorHelp": "Para um fundo transparente, defina a opacidade do fundo como 0.", + "border": "Borda" + }, + "sizing": { + "title": "Densidade e tamanho", + "backgroundOpacity": "Opacidade do fundo do overlay", + "backgroundOpacityHelp": "Defina isto como 0 para um fundo totalmente transparente atras dos itens da playlist.", + "cornerRadius": "Raio dos cantos", + "itemGap": "Espaco entre itens", + "itemPadding": "Padding do item", + "titleFontSize": "Tamanho da fonte do titulo", + "metaFontSize": "Tamanho da fonte dos metadados", + "value": "Valor" + }, + "actions": { + "restoreDefaults": "Restaurar padrao", + "saveChanges": "Salvar alteracoes", + "saving": "Salvando...", + "cancel": "Cancelar" + }, + "preview": { + "title": "Previa", + "live": "Ao vivo", + "sample": "Exemplo", + "channelTitle": "Playlist de {channel}" + }, + "restoreDialog": { + "title": "Restaurar padrao?", + "description": "Isto redefine o editor do overlay para o tema padrao. Alteracoes nao salvas serao perdidas.", + "confirm": "Restaurar padrao" + } + } +} diff --git a/src/lib/i18n/resources/pt-br/home.json b/src/lib/i18n/resources/pt-br/home.json new file mode 100644 index 0000000..a7f6b4c --- /dev/null +++ b/src/lib/i18n/resources/pt-br/home.json @@ -0,0 +1,45 @@ +{ + "meta": { + "title": "Inicio" + }, + "hero": { + "eyebrow": "Gerenciamento de playlist para streamers de Rocksmith", + "title": "Busque musicas ou gerencie seu canal." + }, + "actions": { + "findSong": "Encontrar uma musica para pedir", + "searchSongs": "Buscar musicas", + "manageChannel": "Gerenciar seu canal", + "openSettings": "Abrir configuracoes" + }, + "about": { + "eyebrow": "O que e RockList.Live?", + "body": "RockList.Live ajuda streamers de Rocksmith a receber pedidos, gerenciar a playlist e manter a live fluindo." + }, + "features": { + "surfaceRequestsTitle": "Pedidos de todas as superficies", + "surfaceRequestsBody": "Os viewers adicionam musicas na pagina da playlist, no chat ou pelo painel da Twitch.", + "moderationTitle": "Suporte para moderadores", + "moderationBody": "Os moderadores gerenciam pedidos e prioridades VIP enquanto a live esta no ar.", + "queueTitle": "Mantenha a fila andando", + "queueBody": "Edite, organize e acompanhe pedidos sem perder a proxima musica.", + "rulesTitle": "Defina as regras", + "rulesBody": "Controle listas de bloqueio, setlists, moderacao e configuracoes de pedido do seu canal." + }, + "live": { + "eyebrow": "Ao vivo agora", + "title": "Streamers atuais", + "showLive": "Mostrar ao vivo", + "showDemo": "Mostrar demo", + "demoOnly": "Os streamers mostrados aqui sao apenas para demonstracao.", + "activeCount": "{count} ativos", + "empty": "Ainda nao ha streamers ao vivo com o bot ativo.", + "previewAlt": "Previa da live de {displayName}", + "status": "Ao vivo", + "nowPlaying": "Tocando agora", + "upNext": "Em seguida", + "nextRequest": "Proximo pedido", + "openPlaylist": "Abrir playlist", + "watchOnTwitch": "Assistir na Twitch" + } +} diff --git a/src/lib/i18n/resources/pt-br/playlist.json b/src/lib/i18n/resources/pt-br/playlist.json new file mode 100644 index 0000000..c6b66b1 --- /dev/null +++ b/src/lib/i18n/resources/pt-br/playlist.json @@ -0,0 +1,370 @@ +{ + "page": { + "title": "Playlist de {channel}", + "loading": "Carregando playlist...", + "empty": "Esta playlist esta vazia agora.", + "viewerStateFailed": "Nao foi possivel carregar o estado de pedidos do espectador.", + "viewerToolsFailed": "Nao foi possivel carregar as ferramentas de pedido do espectador.", + "requestsLiveOnly": "Voce podera adicionar pedidos quando a live estiver online." + }, + "states": { + "updateRequestToggleFailed": "Nao foi possivel atualizar o controle de pedidos agora.", + "unableToAddSong": "Nao foi possivel adicionar a musica.", + "unableToUpdateRequest": "Nao foi possivel atualizar o seu pedido.", + "requestUpdated": "Pedido atualizado.", + "unableToRemoveRequests": "Nao foi possivel remover os seus pedidos.", + "requestsRemoved": "Pedidos removidos." + }, + "search": { + "title": "Pesquise para adicionar uma musica", + "placeholder": "Buscar musicas para {channel}", + "actions": { + "add": "Adicionar", + "request": "Pedir", + "actions": "Acoes" + }, + "showBlacklisted": "Mostrar musicas bloqueadas", + "pathWarning": "Nao corresponde {count, plural, one {ao caminho padrao do canal} other {aos caminhos padrao do canal}}: {paths}." + }, + "viewerSummary": { + "vipTokensLabel": "{count} tokens VIP", + "vipBalanceLoading": "Saldo VIP...", + "vipTokensShort": "Tokens VIP", + "requestsWithLimit": "{count}/{limit} pedidos", + "requestsNoLimit": "{count} pedidos", + "vipBalanceSummary": "{count} tokens VIP disponiveis", + "vipBalanceChecking": "Verificando o seu saldo de tokens VIP...", + "vipBalanceUnavailable": "Seu saldo de tokens VIP nao esta disponivel agora.", + "replaceQueued": "Novos itens substituem os seus pedidos na fila.", + "vipHelp": "Ajuda de tokens VIP", + "open": "Abrir", + "noRequests": "Nao ha pedidos na playlist.", + "removeQueued": "Remover pedidos em fila", + "removing": "Removendo..." + }, + "badges": { + "vip": "VIP", + "regular": "Normal", + "nowPlaying": "Tocando agora", + "pick": "Pick {count}", + "requestsOn": "Os pedidos estao ativos", + "requestsOff": "Os pedidos estao desativados", + "turnRequestsOn": "Ativar pedidos", + "turnRequestsOff": "Desativar pedidos", + "online": "Online", + "offline": "Offline", + "vipBalance": "Voce tem {count} tokens VIP", + "vipTokens": "Tokens VIP" + }, + "vipInfo": { + "earn": "Ganhe tokens VIP", + "manualOnly": "Este canal concede tokens VIP manualmente por enquanto.", + "use": "Use tokens VIP" + }, + "history": { + "title": "Historico tocado", + "titleWithChannel": "Historico tocado de {channel}", + "show": "Mostrar historico", + "hide": "Ocultar historico", + "songSearch": "Busca de musica", + "searchPlaceholder": "Musica, artista, album ou charter", + "clearSearch": "Limpar busca", + "requester": "Solicitante", + "requesterPlaceholder": "Buscar um solicitante", + "clearRequester": "Limpar solicitante", + "clearRequesterFilter": "Limpar filtro de solicitante", + "searchingRequesters": "Buscando solicitantes...", + "loading": "Carregando historico tocado...", + "emptyFiltered": "Nenhuma musica tocada corresponde a esses filtros.", + "empty": "Nenhuma musica foi marcada como tocada ainda.", + "requestCount": "{count, plural, one {# pedido} other {# pedidos}}", + "byArtist": " por {artist}", + "requestedBy": "Pedido por {requester}", + "page": "Pagina {page}", + "unknownRequester": "Desconhecido" + }, + "rules": { + "sectionTitle": "Regras do canal", + "sectionTitleWithChannel": "Regras do canal de {channel}", + "states": { + "updateFailed": "Nao foi possivel atualizar as regras do canal.", + "searchFailed": "A busca falhou." + }, + "blacklistedArtists": "Artistas bloqueados", + "blacklistedCharters": "Charters bloqueados", + "blacklistedSongs": "Musicas bloqueadas", + "blacklistedVersions": "Versoes bloqueadas", + "setlistArtists": "Artistas da setlist", + "searchArtists": "Buscar artistas por nome", + "searchCharters": "Buscar charters por nome", + "searchSongs": "Buscar musicas por titulo", + "searchMin": "Digite pelo menos 2 caracteres para buscar.", + "noMatches": "Nenhuma entrada correspondente para adicionar.", + "add": "Adicionar", + "remove": "Remover", + "trackCount": "{count, plural, one {# faixa} other {# faixas}}", + "versionCount": "{count, plural, one {# versao} other {# versoes}} - Grupo da musica {groupId}", + "artistId": "ID do artista {id}", + "charterId": "ID do charter {id}", + "songGroupId": "Grupo da musica {groupId}", + "versionId": "ID da versao {id}", + "noBlacklistedArtists": "Nenhum artista bloqueado.", + "noBlacklistedCharters": "Nenhum charter bloqueado.", + "noBlacklistedSongs": "Nenhuma musica bloqueada.", + "noBlacklistedVersions": "Nenhuma versao bloqueada.", + "noSetlistArtists": "Nenhum artista na setlist." + }, + "community": { + "title": "Controles de moderacao", + "reconnect": "Reconectar Twitch", + "reconnectMessage": "Reconecte a Twitch para priorizar espectadores que estao no chat agora.", + "currentChattersFirst": "Pessoas do chat primeiro", + "twitchMatches": "Correspondencias da Twitch", + "resultCount": "{count, plural, one {# resultado} other {# resultados}}", + "inChat": "No chat", + "selected": "Selecionado", + "noMatchingUsers": "Nenhum usuario da Twitch corresponde a busca.", + "states": { + "updateFailed": "Nao foi possivel atualizar as configuracoes da comunidade do canal.", + "lookupFailed": "A busca de usuario falhou." + }, + "blocks": { + "title": "Espectadores bloqueados", + "searchPlaceholder": "Buscar usuario da Twitch para bloquear", + "blockViewer": "Bloquear espectador", + "searchMin": "Digite pelo menos 4 caracteres para buscar usuarios da Twitch.", + "description": "Espectadores bloqueados ainda podem falar no chat da Twitch, mas nao podem adicionar nem editar pedidos pelo chat, pelo site ou pelo painel da extensao.", + "defaultReason": "Bloqueado para fazer pedidos neste canal.", + "unblock": "Desbloquear", + "empty": "Nenhum espectador bloqueado." + }, + "vip": { + "title": "Tokens VIP", + "searchPlaceholder": "Buscar usuario da Twitch para conceder um token", + "grant": "Conceder token", + "searchMin": "Digite pelo menos 4 caracteres para buscar usuarios da Twitch.", + "searchMinExisting": "Usuarios que ja possuem tokens VIP aparecem abaixo. Digite pelo menos 4 caracteres para buscar em todos os usuarios da Twitch.", + "tokenHoldersFirst": "Quem ja tem token VIP primeiro", + "tokenCountSingle": "{count} token", + "tokenCountPlural": "{count} tokens", + "viewOnly": "Voce pode ver os saldos VIP, mas apenas o broadcaster ou um moderador autorizado pode altera-los.", + "username": "Usuario", + "tokens": "Tokens", + "showingRange": "Mostrando {start}-{end} de {total}", + "previous": "Anterior", + "next": "Proximo", + "pageOf": "Pagina {page} de {total}", + "empty": "Ainda nao ha tokens VIP.", + "noticeAddedSingle": "1 token adicionado", + "noticeAddedMultiple": "{count} tokens VIP adicionados", + "noticeRemovedSingle": "1 token removido", + "noticeRemovedMultiple": "{count} tokens VIP removidos", + "noticeSaved": "Tokens VIP salvos" + } + }, + "viewerActions": { + "add": "Adicionar", + "addVip": "Adicionar VIP", + "adding": "Adicionando...", + "alreadyVip": "Ja esta na sua fila como pedido VIP.", + "alreadyRegular": "Ja esta na sua fila como pedido normal.", + "blacklistedPrefix": "Bloqueada", + "songUnavailable": "Essa musica nao esta disponivel aqui.", + "checkingAccess": "Verificando seu acesso a pedidos...", + "cannotRequest": "Voce nao pode pedir musicas aqui.", + "alreadyActive": "Essa musica ja esta nos seus pedidos ativos.", + "activeLimitReached": "Voce ja tem {count, plural, one {# pedido ativo} other {# pedidos ativos}}.", + "insufficientVipTokens": "Voce nao tem tokens VIP suficientes." + }, + "specialRequest": { + "titleManage": "Adicionar pedido personalizado", + "titleViewer": "Pedir por artista", + "artist": "Artista", + "artistPlaceholder": "Nome do artista", + "artistMin": "Digite pelo menos 2 caracteres do nome de um artista.", + "chooseMode": "Escolher modo", + "chooseType": "Escolher tipo", + "random": "Aleatorio", + "choice": "Escolha", + "regular": "Normal", + "add": "Adicionar", + "addVip": "Adicionar VIP", + "adding": "Adicionando...", + "randomHelp": "Adiciona uma musica aleatoria entre as musicas correspondentes desse artista.", + "choiceHelp": "Adiciona um pedido de escolha do streamer para esse artista." + }, + "manageActions": { + "add": "Adicionar", + "adding": "Adicionando...", + "addForUser": "Adicionar para usuario", + "searchViewers": "Buscar espectadores atuais", + "searchMin": "Digite pelo menos 2 caracteres para buscar espectadores atuais.", + "reconnectMessage": "Reconecte a Twitch para buscar espectadores que estao no chat agora.", + "reconnect": "Reconectar", + "searching": "Buscando espectadores atuais...", + "noMatches": "Nenhum espectador atual corresponde a esse usuario." + }, + "row": { + "viewer": "espectador", + "added": "Adicionada {time}", + "edited": "Editada {time}", + "streamerChoiceTitle": "Escolha do streamer: {query}", + "streamerChoiceSubtitle": "O streamer escolhe a musica exata.", + "relative": { + "now": "agora", + "minutes": "{count} min", + "hours": "{count} h", + "days": "{count} d" + }, + "picks": { + "first": "1a escolha", + "second": "2a escolha", + "third": "3a escolha" + } + }, + "management": { + "states": { + "searchFailed": "A busca falhou.", + "playlistUpdateFailed": "Nao foi possivel atualizar a playlist.", + "moderationUnavailable": "As acoes de moderacao nao estao disponiveis para este canal.", + "blacklistUpdateFailed": "Nao foi possivel atualizar a blacklist." + }, + "header": { + "managing": "Gerenciando {channel}", + "channel": "Canal: {channel}" + }, + "manual": { + "title": "Adicionar uma musica", + "requesterPlaceholder": "Usuario solicitante (opcional)", + "searchPlaceholder": "Buscar e adicionar uma musica", + "searchMin": "A busca precisa ter pelo menos 3 caracteres.", + "table": { + "track": "Musica", + "albumCreator": "Album / Charter", + "tuningPath": "Afinacao / Caminho", + "add": "Adicionar" + }, + "unknownArtist": "Artista desconhecido", + "unknownAlbum": "Album desconhecido", + "chartedBy": "Criada por {creator}", + "unknownCreator": "Charter desconhecido", + "blacklisted": "Bloqueada", + "noTuningInfo": "Sem afinacao", + "noPathInfo": "Sem caminho", + "addButton": "Adicionar" + }, + "actions": { + "shuffle": "Embaralhar", + "clearPlaylist": "Limpar playlist", + "resetSession": "Reiniciar sessao", + "confirmClear": "Limpar toda a playlist? Esta acao nao pode ser desfeita.", + "confirmReset": "Reiniciar a sessao? Isso vai limpar a playlist atual." + }, + "currentTitle": "Playlist atual", + "blacklistErrors": { + "missingRequestVersionId": "Este pedido nao tem um ID de versao para bloquear.", + "missingVersionVersionId": "Esta versao nao tem um ID de versao para bloquear.", + "missingRequestSongGroupId": "Este pedido nao tem um ID de grupo da musica para bloquear.", + "missingRequestArtistId": "Este pedido nao tem um ID de artista para bloquear.", + "missingVersionCharterId": "Esta versao nao tem um ID de charter para bloquear.", + "unknownArtist": "Artista desconhecido", + "unknownCharter": "Charter desconhecido" + }, + "blacklistPanelDescription": "Artistas, charters, musicas e versoes especificas podem ser bloqueados neste canal.", + "history": { + "title": "Historico tocado", + "requestedBy": "Pedido por {requester}", + "played": "Tocada {time}", + "restore": "Restaurar", + "restoring": "Restaurando...", + "empty": "Nada foi marcado como tocado ainda." + }, + "deleteDialog": { + "title": "Remover este pedido da playlist?", + "descriptionWithArtist": "Isso remove \"{title}\" de {artist} da playlist. Esta acao nao pode ser desfeita.", + "descriptionWithoutArtist": "Isso remove \"{title}\" da playlist. Esta acao nao pode ser desfeita.", + "descriptionFallback": "Isso remove o pedido selecionado da playlist. Esta acao nao pode ser desfeita.", + "keep": "Manter pedido", + "removing": "Removendo...", + "remove": "Remover pedido" + }, + "queue": { + "empty": "Ainda nao ha musicas na playlist." + }, + "relative": { + "now": "agora mesmo", + "minutesAgo": "{count, plural, one {ha # minuto} other {ha # minutos}}", + "hoursAgo": "{count, plural, one {ha # hora} other {ha # horas}}", + "daysAgo": "{count, plural, one {ha # dia} other {ha # dias}}" + }, + "item": { + "moveTop": "Mover para o topo", + "moveUp": "Mover para cima", + "moveDown": "Mover para baixo", + "moveBottom": "Mover para o fim", + "reorderAria": "Reordenar {title}", + "vipBadge": "VIP", + "playingBadge": "Tocando", + "warningBadge": "Aviso", + "versionBlacklisted": "Versao bloqueada", + "songBlacklisted": "Musica bloqueada", + "artistBlacklisted": "Artista bloqueado", + "requestedBy": "Pedido por {requester}", + "vipTokens": "{count} tokens VIP", + "added": "Adicionada {time}", + "requestedText": "Texto pedido: {query}", + "saving": "Salvando...", + "makeRegular": "Tornar normal", + "makeVip": "Tornar VIP", + "returnToQueue": "Voltar para a fila", + "playNow": "Tocar agora", + "markComplete": "Marcar como concluida", + "versionsCount": "{count, plural, one {# versao} other {# versoes}}", + "downloadFromCf": "Baixar do CF" + }, + "actionsMenu": { + "openActionsAria": "Abrir acoes para {title}", + "removing": "Removendo...", + "removeFromPlaylist": "Remover da playlist", + "blacklist": "Blacklist", + "blacklistTitle": "Acoes de blacklist", + "back": "Voltar", + "description": "Escolha se quer bloquear a versao na fila, todas as versoes desta musica, o artista ou um charter.", + "queuedVersionId": "ID da versao na fila {id}", + "noQueuedVersionId": "Sem ID de versao na fila", + "versionBlocked": "Versao bloqueada", + "blacklistQueuedVersion": "Bloquear versao na fila", + "blockVersionDescription": "Bloqueia apenas o ID de versao {id}.", + "blockVersionFallbackDescription": "Bloqueia apenas a versao exata vinculada a este pedido.", + "songBlocked": "Musica bloqueada", + "blacklistSongGroup": "Bloquear todas as versoes desta musica", + "blockSongDescription": "Bloqueia todas as versoes agrupadas nessa musica.", + "artistBlocked": "Artista bloqueado: {artist}", + "blacklistArtist": "Bloquear artista: {artist}", + "blockArtistDescription": "Bloqueia todas as musicas desse ID de artista.", + "charterBlocked": "Charter bloqueado: {charter}", + "blacklistCharter": "Bloquear charter: {charter}", + "blockCharterDescription": "Bloqueia todas as musicas desse ID de charter.", + "noCharterIds": "Nenhum ID de charter disponivel para estas versoes." + }, + "versionsTable": { + "songAlbum": "Musica / album", + "tunings": "Afinacoes", + "paths": "Caminhos", + "updated": "Atualizada", + "downloads": "Downloads", + "actions": "Acoes", + "unknownArtist": "Artista desconhecido", + "chartedBy": "Criada por", + "charterBlacklisted": "Charter bloqueado", + "unknown": "Desconhecido", + "download": "Baixar", + "blacklisted": "Bloqueada", + "blacklist": "Bloquear" + }, + "preview": { + "removed": "A linha de demo foi removida da playlist.", + "restore": "Restaurar linha de demo" + } + } +} diff --git a/src/lib/i18n/resources/pt-br/search.json b/src/lib/i18n/resources/pt-br/search.json new file mode 100644 index 0000000..057d244 --- /dev/null +++ b/src/lib/i18n/resources/pt-br/search.json @@ -0,0 +1,79 @@ +{ + "meta": { + "title": "Buscar musicas" + }, + "page": { + "title": "Buscar", + "infoNote": "Esta demo contem apenas {count} musicas.", + "placeholder": "Busque por titulo, artista ou album" + }, + "summary": { + "note": "Nota:", + "foundCount": "{count} musicas encontradas", + "filters": "Filtros", + "moreCount": "+{count} a mais", + "changeFilters": "Alterar filtros" + }, + "controls": { + "searchField": "Campo de busca", + "allFields": "Todos os campos", + "titleOnly": "Apenas titulo", + "artistOnly": "Apenas artista", + "albumOnly": "Apenas album", + "creatorOnly": "Apenas criador", + "showFilters": "Mostrar filtros", + "hideFilters": "Ocultar filtros", + "title": "Titulo", + "artist": "Artista", + "album": "Album", + "creator": "Criador", + "tuning": "Afinacao", + "path": "Parte", + "year": "Ano", + "actions": "Acoes", + "clearAdvancedFilters": "Limpar filtros", + "matchAny": "Corresponde a qualquer um", + "matchAll": "Corresponde a todos" + }, + "columns": { + "song": "Musica", + "paths": "Partes", + "stats": "Estatisticas", + "actions": "Acoes" + }, + "states": { + "loading": "Carregando musicas...", + "queryTooShort": "Os termos de busca precisam ter pelo menos 3 caracteres.", + "emptyFiltered": "Nenhuma musica corresponde a esses filtros ainda. Tente ampliar a busca ou limpar um dos filtros avancados.", + "emptyCatalog": "Ainda nao ha musicas disponiveis no catalogo de demo.", + "blacklisted": "Bloqueada", + "blacklistedWithReasons": "Bloqueada - {reasons}", + "unknownArtist": "Artista desconhecido", + "chartedBy": "Chart por {creator}", + "updated": "Atualizado {date}" + }, + "errors": { + "filterOptionsFailed": "Nao foi possivel carregar as opcoes de filtro.", + "searchFailed": "A busca falhou." + }, + "commands": { + "copySr": "Copiar comando !sr", + "copiedSr": "Comando !sr copiado", + "copyEdit": "Copiar comando !edit", + "copiedEdit": "Comando !edit copiado", + "copyVip": "Copiar comando !vip", + "copiedVip": "Comando !vip copiado" + }, + "paths": { + "lead": "Lead", + "rhythm": "Rhythm", + "bass": "Baixo", + "lyrics": "Letra" + }, + "multiSelect": { + "selected": "{count} selecionados", + "select": "Selecionar {label}", + "filter": "Filtrar {label}...", + "noMatches": "Nenhum resultado encontrado." + } +} diff --git a/src/lib/i18n/server.ts b/src/lib/i18n/server.ts new file mode 100644 index 0000000..5180975 --- /dev/null +++ b/src/lib/i18n/server.ts @@ -0,0 +1,14 @@ +import { getLocaleCookie } from "~/lib/auth/session.server"; +import type { AppEnv } from "~/lib/env"; +import { resolveExplicitLocale } from "./detect"; + +export function resolveRequestLocale( + request: Request, + _env: AppEnv, + userPreferredLocale?: string | null +) { + return resolveExplicitLocale({ + userPreferredLocale, + storedLocale: getLocaleCookie(request), + }); +} diff --git a/src/lib/playlist/management-display.ts b/src/lib/playlist/management-display.ts index b26516c..96fb768 100644 --- a/src/lib/playlist/management-display.ts +++ b/src/lib/playlist/management-display.ts @@ -117,6 +117,8 @@ export function formatPlaylistItemSummaryLine( >, options?: { hasMultipleVersions?: boolean; + chartedByLabel?: string; + unknownArtistLabel?: string; } ) { return ( @@ -124,10 +126,12 @@ export function formatPlaylistItemSummaryLine( item.songArtist, item.songAlbum, !options?.hasMultipleVersions && item.songCreator - ? `Charted by ${item.songCreator}` + ? `${options?.chartedByLabel ?? "Charted by"} ${item.songCreator}` : null, ] .filter(Boolean) - .join(" · ") || "Unknown artist" + .join(" · ") || + options?.unknownArtistLabel || + "Unknown artist" ); } diff --git a/src/lib/server/dashboard-settings.ts b/src/lib/server/dashboard-settings.ts index 8cd6e07..c4a6dbd 100644 --- a/src/lib/server/dashboard-settings.ts +++ b/src/lib/server/dashboard-settings.ts @@ -1,6 +1,6 @@ import { env } from "cloudflare:workers"; import { createServerFn } from "@tanstack/react-start"; -import { getRequest } from "@tanstack/start-server-core"; +import { getRequest } from "@tanstack/react-start/server"; import { getSessionUserId } from "~/lib/auth/session.server"; import { callBackend } from "~/lib/backend"; import { diff --git a/src/lib/server/search.ts b/src/lib/server/search.ts index 5d8cc2b..0f75b90 100644 --- a/src/lib/server/search.ts +++ b/src/lib/server/search.ts @@ -1,6 +1,6 @@ import { env } from "cloudflare:workers"; import { createServerFn } from "@tanstack/react-start"; -import { getRequest, getRequestIP } from "@tanstack/start-server-core"; +import { getRequest, getRequestIP } from "@tanstack/react-start/server"; import type { z } from "zod"; import { getSessionUserId } from "~/lib/auth/session.server"; import { diff --git a/src/lib/server/viewer-session-data.ts b/src/lib/server/viewer-session-data.ts new file mode 100644 index 0000000..f2bfa49 --- /dev/null +++ b/src/lib/server/viewer-session-data.ts @@ -0,0 +1,76 @@ +import { getSessionUserId } from "~/lib/auth/session.server"; +import { getViewerState } from "~/lib/db/repositories"; +import type { AppEnv } from "~/lib/env"; +import type { AppLocale } from "~/lib/i18n/locales"; +import { resolveRequestLocale } from "~/lib/i18n/server"; + +export type ViewerSessionData = { + locale: AppLocale; + viewer: null | { + user: { + twitchUserId: string; + displayName: string; + login: string; + profileImageUrl?: string | null; + isAdmin?: boolean; + preferredLocale?: string | null; + }; + channel: { + slug: string; + } | null; + manageableChannels?: Array<{ + slug: string; + displayName: string; + login: string; + isLive: boolean; + }>; + needsBroadcasterScopeReconnect?: boolean; + needsModeratorScopeReconnect?: boolean; + }; +}; + +export async function getViewerSessionData( + request: Request, + runtimeEnv: AppEnv +) { + const userId = await getSessionUserId(request, runtimeEnv); + + if (!userId) { + return { + locale: resolveRequestLocale(request, runtimeEnv), + viewer: null, + } satisfies ViewerSessionData; + } + + const viewer = await getViewerState(runtimeEnv, userId); + const locale = resolveRequestLocale( + request, + runtimeEnv, + viewer?.user.preferredLocale + ); + + return { + locale, + viewer: viewer + ? { + user: { + twitchUserId: viewer.user.twitchUserId, + displayName: viewer.user.displayName, + login: viewer.user.login, + profileImageUrl: viewer.user.profileImageUrl, + isAdmin: viewer.user.isAdmin, + preferredLocale: viewer.user.preferredLocale, + }, + channel: viewer.channel ? { slug: viewer.channel.slug } : null, + manageableChannels: viewer.manageableChannels.map((channel) => ({ + slug: channel.slug, + displayName: channel.displayName, + login: channel.login, + isLive: channel.isLive, + })), + needsBroadcasterScopeReconnect: viewer.needsBroadcasterScopeReconnect, + needsModeratorScopeReconnect: viewer.needsModeratorScopeReconnect, + } + : null, + } satisfies ViewerSessionData; +} diff --git a/src/lib/server/viewer.ts b/src/lib/server/viewer.ts index 175f880..2f7ea67 100644 --- a/src/lib/server/viewer.ts +++ b/src/lib/server/viewer.ts @@ -1,49 +1,18 @@ import { env } from "cloudflare:workers"; import { createServerFn } from "@tanstack/react-start"; -import { getRequest } from "@tanstack/start-server-core"; -import { getSessionUserId } from "~/lib/auth/session.server"; -import { getViewerState } from "~/lib/db/repositories"; +import { getRequest } from "@tanstack/react-start/server"; import type { AppEnv } from "~/lib/env"; +import { + getViewerSessionData, + type ViewerSessionData, +} from "~/lib/server/viewer-session-data"; -export type ViewerSessionData = { - viewer: null | { - user: { - twitchUserId: string; - displayName: string; - login: string; - profileImageUrl?: string | null; - isAdmin?: boolean; - }; - channel: { - slug: string; - } | null; - }; -}; +export type { ViewerSessionData } from "~/lib/server/viewer-session-data"; export const getViewerSession = createServerFn({ method: "GET" }).handler( - async () => { + async (): Promise => { const runtimeEnv = env as AppEnv; const request = getRequest(); - const userId = await getSessionUserId(request, runtimeEnv); - - if (!userId) { - return { viewer: null } satisfies ViewerSessionData; - } - - const viewer = await getViewerState(runtimeEnv, userId); - return { - viewer: viewer - ? { - user: { - twitchUserId: viewer.user.twitchUserId, - displayName: viewer.user.displayName, - login: viewer.user.login, - profileImageUrl: viewer.user.profileImageUrl, - isAdmin: viewer.user.isAdmin, - }, - channel: viewer.channel ? { slug: viewer.channel.slug } : null, - } - : null, - } satisfies ViewerSessionData; + return getViewerSessionData(request, runtimeEnv); } ); diff --git a/src/lib/viewer-session-query.ts b/src/lib/viewer-session-query.ts new file mode 100644 index 0000000..f3370c8 --- /dev/null +++ b/src/lib/viewer-session-query.ts @@ -0,0 +1,5 @@ +export const viewerSessionQueryOptions = { + staleTime: 5 * 60_000, + gcTime: 10 * 60_000, + refetchOnWindowFocus: false, +} as const; diff --git a/src/routes/$slug/index.tsx b/src/routes/$slug/index.tsx index b03f7ff..b98e07e 100644 --- a/src/routes/$slug/index.tsx +++ b/src/routes/$slug/index.tsx @@ -32,7 +32,9 @@ import { formatBlacklistReasonLabel, getBlacklistReasonCodes, } from "~/lib/channel-blacklist"; -import { formatSlugTitle, pageTitle } from "~/lib/page-title"; +import { useLocaleTranslation } from "~/lib/i18n/client"; +import { getLocalizedPageTitle } from "~/lib/i18n/metadata"; +import { formatSlugTitle } from "~/lib/page-title"; import { getPickNumbersForQueuedItems } from "~/lib/pick-order"; import { ADD_REQUESTS_WHEN_LIVE_MESSAGE, @@ -41,6 +43,7 @@ import { import { STREAMER_CHOICE_WARNING_CODE } from "~/lib/request-modes"; import { formatPathLabel, getArraySetting } from "~/lib/request-policy"; import { cn, decodeHtmlEntities, getErrorMessage } from "~/lib/utils"; +import { viewerSessionQueryOptions } from "~/lib/viewer-session-query"; import { getVipTokenAutomationDetails, getVipTokenRedemptionDetails, @@ -194,8 +197,18 @@ const publicPlaylistItemTransition = { }; export const Route = createFileRoute("/$slug/")({ - head: ({ params }) => ({ - meta: [{ title: pageTitle(`${formatSlugTitle(params.slug)} Playlist`) }], + head: async ({ params }) => ({ + meta: [ + { + title: await getLocalizedPageTitle({ + namespace: "playlist", + key: "page.title", + options: { + channel: formatSlugTitle(params.slug), + }, + }), + }, + ], }), component: PublicChannelPage, }); @@ -225,15 +238,18 @@ function isStreamerChoicePlaylistItem(item: { return item.warningCode === STREAMER_CHOICE_WARNING_CODE; } -function formatPublicPlaylistTitle(item: { - songTitle: string; - songArtist?: string | null; - requestedQuery?: string | null; - warningCode?: string | null; -}) { +function formatPublicPlaylistTitle( + item: { + songTitle: string; + songArtist?: string | null; + requestedQuery?: string | null; + warningCode?: string | null; + }, + t: (key: string, options?: Record) => string +) { if (isStreamerChoicePlaylistItem(item)) { return item.requestedQuery?.trim() - ? `Streamer choice: ${item.requestedQuery.trim()}` + ? t("row.streamerChoiceTitle", { query: item.requestedQuery.trim() }) : item.songTitle; } @@ -245,14 +261,17 @@ function formatPublicPlaylistTitle(item: { .join(" - "); } -function formatPublicPlaylistSecondaryLine(item: { - songArtist?: string | null; - songAlbum?: string | null; - requestedQuery?: string | null; - warningCode?: string | null; -}) { +function formatPublicPlaylistSecondaryLine( + item: { + songArtist?: string | null; + songAlbum?: string | null; + requestedQuery?: string | null; + warningCode?: string | null; + }, + t: (key: string, options?: Record) => string +) { if (isStreamerChoicePlaylistItem(item)) { - return "Streamer picks the exact song."; + return t("row.streamerChoiceSubtitle"); } if (item.songAlbum) { @@ -263,6 +282,7 @@ function formatPublicPlaylistSecondaryLine(item: { } function PublicChannelPage() { + const { t } = useLocaleTranslation(["common", "playlist"]); const { slug } = Route.useParams(); const queryClient = useQueryClient(); const [showBlacklisted, setShowBlacklisted] = useState(false); @@ -318,6 +338,7 @@ function PublicChannelPage() { }); return response.json() as Promise; }, + ...viewerSessionQueryOptions, }); const signedInViewer = sessionData?.viewer ?? null; const viewerRequestStateQuery = useQuery({ @@ -334,8 +355,8 @@ function PublicChannelPage() { if (!response.ok) { throw new Error( body && "error" in body - ? (body.error ?? "Viewer request state failed to load.") - : "Viewer request state failed to load." + ? (body.error ?? t("page.viewerStateFailed", { ns: "playlist" })) + : t("page.viewerStateFailed", { ns: "playlist" }) ); } @@ -405,10 +426,14 @@ function PublicChannelPage() { context.defaultPathFilters, context.defaultPathFilterMatchMode ) - ? `Doesn't match channel default path${context.defaultPathFilters.length === 1 ? "" : "s"}: ${formatPathFilterSummary( - context.defaultPathFilters, - context.defaultPathFilterMatchMode - )}.` + ? t("search.pathWarning", { + ns: "playlist", + count: context.defaultPathFilters.length, + paths: formatPathFilterSummary( + context.defaultPathFilters, + context.defaultPathFilterMatchMode + ), + }) : undefined; return { @@ -449,7 +474,8 @@ function PublicChannelPage() { if (!response.ok) { throw new Error( - body?.error ?? "Unable to update the request toggle right now." + body?.error ?? + t("states.updateRequestToggleFailed", { ns: "playlist" }) ); } @@ -489,7 +515,7 @@ function PublicChannelPage() { } setViewerRequestError( getErrorMessage(error) || - "Unable to update the request toggle right now." + t("states.updateRequestToggleFailed", { ns: "playlist" }) ); }, onSuccess: async () => { @@ -562,7 +588,9 @@ function PublicChannelPage() { } | null; if (!response.ok) { throw new Error( - body?.error ?? body?.message ?? "Unable to add the song." + body?.error ?? + body?.message ?? + t("states.unableToAddSong", { ns: "playlist" }) ); } return body; @@ -624,7 +652,9 @@ function PublicChannelPage() { if (!response.ok) { throw new Error( - body?.error ?? body?.message ?? "Unable to update your request." + body?.error ?? + body?.message ?? + t("states.unableToUpdateRequest", { ns: "playlist" }) ); } @@ -642,7 +672,9 @@ function PublicChannelPage() { }); }, onSuccess: async (payload) => { - setViewerRequestFeedback(payload?.message ?? "Request updated."); + setViewerRequestFeedback( + payload?.message ?? t("states.requestUpdated", { ns: "playlist" }) + ); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["channel-playlist", slug], @@ -654,7 +686,8 @@ function PublicChannelPage() { }, onError: (error) => { setViewerRequestError( - getErrorMessage(error) || "Unable to update your request." + getErrorMessage(error) || + t("states.unableToUpdateRequest", { ns: "playlist" }) ); }, onSettled: () => { @@ -680,7 +713,9 @@ function PublicChannelPage() { if (!response.ok) { throw new Error( - body?.error ?? body?.message ?? "Unable to remove your requests." + body?.error ?? + body?.message ?? + t("states.unableToRemoveRequests", { ns: "playlist" }) ); } @@ -694,7 +729,9 @@ function PublicChannelPage() { }); }, onSuccess: async (payload) => { - setViewerRequestFeedback(payload?.message ?? "Requests removed."); + setViewerRequestFeedback( + payload?.message ?? t("states.requestsRemoved", { ns: "playlist" }) + ); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["channel-playlist", slug], @@ -706,7 +743,8 @@ function PublicChannelPage() { }, onError: (error) => { setViewerRequestError( - getErrorMessage(error) || "Unable to remove your requests." + getErrorMessage(error) || + t("states.unableToRemoveRequests", { ns: "playlist" }) ); }, onSettled: () => { @@ -780,7 +818,10 @@ function PublicChannelPage() {

- {`${channelDisplayName}'s Playlist`} + {t("page.title", { + ns: "playlist", + channel: channelDisplayName, + })}

{!isLoading && data?.settings ? ( @@ -816,7 +857,9 @@ function PublicChannelPage() {
{isLoading ? ( -

Loading playlist...

+

+ {t("page.loading", { ns: "playlist" })} +

) : null} {canManagePlaylist ? (
@@ -848,7 +891,7 @@ function PublicChannelPage() { {!isLoading && !filteredItems.length ? (

- This playlist is empty right now. + {t("page.empty", { ns: "playlist" })}

) : null}
@@ -862,7 +905,7 @@ function PublicChannelPage() { ) : null} {!channelRequestsOpen ? ( - {ADD_REQUESTS_WHEN_LIVE_MESSAGE} + {t("page.requestsLiveOnly", { ns: "playlist" })} ) : null} {!canManagePlaylist && viewerRequestError ? ( @@ -876,7 +919,7 @@ function PublicChannelPage() { {getErrorMessage( viewerRequestStateQuery.error, - "Viewer request tools failed to load." + t("page.viewerToolsFailed", { ns: "playlist" }) )} ) : null} @@ -884,11 +927,14 @@ function PublicChannelPage() {
- Show blacklisted songs{" "} + {t("search.showBlacklisted", { ns: "playlist" })}{" "} ({searchData?.hiddenBlacklistedCount ?? 0}) @@ -1067,13 +1117,14 @@ function ViewerRequestSummaryWidget(props: { removePending: boolean; onRemoveRequests: () => void; }) { + const { t } = useLocaleTranslation(["common", "playlist"]); if (!props.signedInViewer) { return ( ); @@ -1085,20 +1136,33 @@ function ViewerRequestSummaryWidget(props: { activeLimit != null && props.activeRequests.length >= activeLimit; const vipTokensLabel = props.viewerState != null - ? `${formatVipTokenCount(props.viewerState.vipTokensAvailable)} VIP tokens` + ? t("viewerSummary.vipTokensLabel", { + ns: "playlist", + count: formatVipTokenCount(props.viewerState.vipTokensAvailable), + }) : props.viewerStateLoading - ? "VIP balance..." - : "VIP tokens"; + ? t("viewerSummary.vipBalanceLoading", { ns: "playlist" }) + : t("viewerSummary.vipTokensShort", { ns: "playlist" }); const requestsLabel = activeLimit != null - ? `${props.activeRequests.length}/${activeLimit} reqs` - : `${props.activeRequests.length} reqs`; + ? t("viewerSummary.requestsWithLimit", { + ns: "playlist", + count: props.activeRequests.length, + limit: activeLimit, + }) + : t("viewerSummary.requestsNoLimit", { + ns: "playlist", + count: props.activeRequests.length, + }); const vipBalanceSummary = props.viewerState != null - ? `${formatVipTokenCount(props.viewerState.vipTokensAvailable)} VIP token${props.viewerState.vipTokensAvailable === 1 ? "" : "s"} available` + ? t("viewerSummary.vipBalanceSummary", { + ns: "playlist", + count: formatVipTokenCount(props.viewerState.vipTokensAvailable), + }) : props.viewerStateLoading - ? "Checking your VIP token balance..." - : "Your VIP token balance is unavailable right now."; + ? t("viewerSummary.vipBalanceChecking", { ns: "playlist" }) + : t("viewerSummary.vipBalanceUnavailable", { ns: "playlist" }); return ( @@ -1159,13 +1223,13 @@ function ViewerRequestSummaryWidget(props: {

{getErrorMessage( props.viewerStateError, - "Viewer request tools failed to load." + t("page.viewerToolsFailed", { ns: "playlist" }) )}

) : null} {limitReached && props.queuedRequests.length > 0 ? (

- New adds replace your queued requests. + {t("viewerSummary.replaceQueued", { ns: "playlist" })}

) : null} @@ -1175,9 +1239,9 @@ function ViewerRequestSummaryWidget(props: { type="button" className="flex w-full items-center justify-between gap-3 bg-(--panel-soft) px-4 py-3 text-left text-sm font-medium text-(--text) transition-colors hover:bg-(--panel)" > - VIP token help + {t("viewerSummary.vipHelp", { ns: "playlist" })} - Open + {t("viewerSummary.open", { ns: "playlist" })} @@ -1200,11 +1264,11 @@ function ViewerRequestSummaryWidget(props: { >

- {formatPublicPlaylistTitle(item)} + {formatPublicPlaylistTitle(item, t)}

- {formatPublicPlaylistSecondaryLine(item) ? ( + {formatPublicPlaylistSecondaryLine(item, t) ? (

- {formatPublicPlaylistSecondaryLine(item)} + {formatPublicPlaylistSecondaryLine(item, t)}

) : null}
@@ -1214,12 +1278,21 @@ function ViewerRequestSummaryWidget(props: { item.requestKind === "vip" ? "default" : "secondary" } > - {item.requestKind === "vip" ? "VIP" : "Regular"} + {item.requestKind === "vip" + ? t("badges.vip", { ns: "playlist" }) + : t("badges.regular", { ns: "playlist" })} {item.status === "current" ? ( - Now playing + + {t("badges.nowPlaying", { ns: "playlist" })} + ) : item.pickNumber != null ? ( - Pick {item.pickNumber} + + {t("badges.pick", { + ns: "playlist", + count: item.pickNumber, + })} + ) : null}
@@ -1227,7 +1300,7 @@ function ViewerRequestSummaryWidget(props: { ) : (

- No requests in the playlist. + {t("viewerSummary.noRequests", { ns: "playlist" })}

)} {props.queuedRequests.length > 0 ? ( @@ -1237,7 +1310,9 @@ function ViewerRequestSummaryWidget(props: { onClick={props.onRemoveRequests} disabled={props.removePending} > - {props.removePending ? "Removing..." : "Remove queued requests"} + {props.removePending + ? t("viewerSummary.removing", { ns: "playlist" }) + : t("viewerSummary.removeQueued", { ns: "playlist" })} ) : null} @@ -1254,6 +1329,7 @@ function RequestsStatusBadge(props: { onScrollToSearch: () => void; onToggle: () => void; }) { + const { t } = useLocaleTranslation("playlist"); if (!props.canManageRequests) { return ( ); } @@ -1281,14 +1359,18 @@ function RequestsStatusBadge(props: { className="transition-opacity hover:opacity-85" onClick={props.onScrollToSearch} > - {props.requestsEnabled ? "Requests are on" : "Requests are off"} + {props.requestsEnabled + ? t("badges.requestsOn") + : t("badges.requestsOff")} {helperText ? ( @@ -1535,8 +1623,8 @@ function ViewerSearchSongActions(props: { ) : matchingRequest ? (

{matchingRequest.requestKind === "vip" - ? "Already in your queue as a VIP request." - : "Already in your queue as a regular request."} + ? t("viewerActions.alreadyVip") + : t("viewerActions.alreadyRegular")}

) : null} @@ -1563,6 +1651,7 @@ function ViewerSpecialRequestControls(props: { requestKind: "regular" | "vip" ) => void; }) { + const { t } = useLocaleTranslation("playlist"); const [artistQuery, setArtistQuery] = useState(""); const [requestMode, setRequestMode] = useState<"random" | "choice">("random"); const [requestKind, setRequestKind] = useState<"regular" | "vip">("regular"); @@ -1584,13 +1673,14 @@ function ViewerSpecialRequestControls(props: { viewerState: props.viewerState, viewerStateLoading: props.viewerStateLoading, viewerStateError: props.viewerStateError, + t, }); const helperText = selectedDisabledReason || (normalizedQuery.length >= 2 ? requestMode === "random" - ? "Adds a random song from the matching songs for this artist." - : "Adds a streamer choice request for this artist." + ? t("specialRequest.randomHelp") + : t("specialRequest.choiceHelp") : null); const submitPending = props.mutationIsPending && @@ -1606,8 +1696,8 @@ function ViewerSpecialRequestControls(props: {

{props.canManagePlaylist - ? "Add a custom request" - : "Request by artist"} + ? t("specialRequest.titleManage") + : t("specialRequest.titleViewer")}

@@ -1617,20 +1707,20 @@ function ViewerSpecialRequestControls(props: { className="text-[11px] font-semibold uppercase tracking-[0.12em] text-(--muted)" htmlFor="viewer-special-request-artist" > - Artist + {t("specialRequest.artist")} setArtistQuery(event.target.value)} - placeholder="Artist name" + placeholder={t("specialRequest.artistPlaceholder")} className="h-9 px-3" />

- Choose mode + {t("specialRequest.chooseMode")}

- Choose type + {t("specialRequest.chooseType")}

@@ -1706,7 +1796,7 @@ function ViewerSpecialRequestControls(props: {

{helperText}

) : props.replaceExisting ? (

- New adds replace your queued requests. + {t("viewerSummary.replaceQueued")}

) : null}
@@ -1724,27 +1814,30 @@ function getViewerSongActionDisabledReason(input: { matchingRequest: EnrichedPublicPlaylistItem | null; atActiveLimit: boolean; replaceExisting: boolean; + t: (key: string, options?: Record) => string; }) { if (input.resultState.disabled) { return input.resultState.reasons?.length - ? `Blacklisted - ${input.resultState.reasons.join(" · ")}` - : "That song is unavailable here."; + ? `${input.t("viewerActions.blacklistedPrefix")} - ${input.resultState.reasons.join(" · ")}` + : input.t("viewerActions.songUnavailable"); } if (input.viewerStateLoading) { - return "Checking your request access..."; + return input.t("viewerActions.checkingAccess"); } if (input.requestsOpen === false) { - return ADD_REQUESTS_WHEN_LIVE_MESSAGE; + return input.t("page.requestsLiveOnly"); } if (!input.viewerState) { - return input.viewerStateError || "Viewer request tools are unavailable."; + return input.viewerStateError || input.t("page.viewerToolsFailed"); } if (!input.viewerState.access.allowed) { - return input.viewerState.access.reason ?? "You cannot request songs here."; + return ( + input.viewerState.access.reason ?? input.t("viewerActions.cannotRequest") + ); } if ( @@ -1752,7 +1845,7 @@ function getViewerSongActionDisabledReason(input: { input.matchingRequest.requestKind === input.requestKind && !input.replaceExisting ) { - return "This song is already in your active requests."; + return input.t("viewerActions.alreadyActive"); } if ( @@ -1765,14 +1858,14 @@ function getViewerSongActionDisabledReason(input: { ) { const activeLimit = input.viewerState.activeRequestLimit ?? input.activeRequests.length; - return `You already have ${activeLimit} active request${activeLimit === 1 ? "" : "s"}.`; + return input.t("viewerActions.activeLimitReached", { count: activeLimit }); } if ( input.requestKind === "vip" && !hasRedeemableVipToken(input.viewerState.vipTokensAvailable) ) { - return "You do not have enough VIP tokens."; + return input.t("viewerActions.insufficientVipTokens"); } return ""; @@ -1808,6 +1901,7 @@ function ManageSearchSongActions(props: { mutationIsPending: boolean; onAdd: (requester: ViewerMatch) => void; }) { + const { t } = useLocaleTranslation("playlist"); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); @@ -1862,13 +1956,11 @@ function ManageSearchSongActions(props: { props.onAdd(props.currentViewer); }} disabled={addDisabled} - title={ - props.requestsOpen ? undefined : ADD_REQUESTS_WHEN_LIVE_MESSAGE - } + title={props.requestsOpen ? undefined : t("page.requestsLiveOnly")} > {props.mutationIsPending && props.pendingAddSongId === props.song.id - ? "Adding..." - : "Add"} + ? t("manageActions.adding") + : t("manageActions.add")} @@ -1882,10 +1974,10 @@ function ManageSearchSongActions(props: { props.mutationIsPending } title={ - props.requestsOpen ? undefined : ADD_REQUESTS_WHEN_LIVE_MESSAGE + props.requestsOpen ? undefined : t("page.requestsLiveOnly") } > - Add for user + {t("manageActions.addForUser")} setQuery(event.target.value)} - placeholder="Search current viewers" + placeholder={t("manageActions.searchViewers")} /> {normalizedQuery.length > 0 && normalizedQuery.length < 2 ? (

- Type at least 2 characters to search current viewers. + {t("manageActions.searchMin")}

) : null} {lookupQuery.data?.needsChatterScopeReconnect ? (

- Reconnect Twitch to search viewers currently in chat. + {t("manageActions.reconnectMessage")}

@@ -1920,7 +2012,7 @@ function ManageSearchSongActions(props: { {normalizedQuery.length >= 2 ? ( lookupQuery.isFetching ? (

- Searching current viewers... + {t("manageActions.searching")}

) : (lookupQuery.data?.users?.length ?? 0) > 0 ? (
@@ -1952,14 +2044,14 @@ function ManageSearchSongActions(props: {

- Add + {t("manageActions.add")} ))} ) : (

- No current viewers matched that username. + {t("manageActions.noMatches")}

) ) : null} @@ -1979,32 +2071,35 @@ function getViewerSpecialActionDisabledReason(input: { viewerState: ViewerRequestStateData["viewer"]; viewerStateLoading: boolean; viewerStateError: string; + t: (key: string, options?: Record) => string; }) { if (input.query.length < 2) { - return "Type at least 2 characters from an artist name."; + return input.t("specialRequest.artistMin"); } if (input.viewerStateLoading) { - return "Checking your request access..."; + return input.t("viewerActions.checkingAccess"); } if (input.requestsOpen === false) { - return ADD_REQUESTS_WHEN_LIVE_MESSAGE; + return input.t("page.requestsLiveOnly"); } if (!input.viewerState) { - return input.viewerStateError || "Viewer request tools are unavailable."; + return input.viewerStateError || input.t("page.viewerToolsFailed"); } if (!input.viewerState.access.allowed) { - return input.viewerState.access.reason ?? "You cannot request songs here."; + return ( + input.viewerState.access.reason ?? input.t("viewerActions.cannotRequest") + ); } if ( input.requestKind === "vip" && !hasRedeemableVipToken(input.viewerState.vipTokensAvailable) ) { - return "You do not have enough VIP tokens."; + return input.t("viewerActions.insufficientVipTokens"); } return null; @@ -2016,18 +2111,19 @@ function PublicPlaylistRow(props: { showPlaylistPositions: boolean; isViewerRequest: boolean; }) { + const { t } = useLocaleTranslation("playlist"); const requesterName = props.item.requestedByDisplayName ?? props.item.requestedByLogin ?? - "viewer"; - const titleLine = formatPublicPlaylistTitle(props.item); - const secondaryLine = formatPublicPlaylistSecondaryLine(props.item); + t("row.viewer"); + const titleLine = formatPublicPlaylistTitle(props.item, t); + const secondaryLine = formatPublicPlaylistSecondaryLine(props.item, t); const addedLabel = props.item.createdAt - ? formatCompactPlaylistRelativeTime(props.item.createdAt) + ? formatCompactPlaylistRelativeTime(props.item.createdAt, t) : null; const editedTimestamp = props.item.editedAt ?? null; const editedLabel = editedTimestamp - ? formatCompactPlaylistRelativeTime(editedTimestamp) + ? formatCompactPlaylistRelativeTime(editedTimestamp, t) : null; const showEditedLabel = editedTimestamp != null && @@ -2035,8 +2131,10 @@ function PublicPlaylistRow(props: { (props.item.createdAt == null || editedTimestamp > props.item.createdAt); const metadataLine = [ requesterName, - addedLabel ? `Added ${addedLabel}` : null, - showEditedLabel && editedLabel ? `Edited ${editedLabel}` : null, + addedLabel ? t("row.added", { time: addedLabel }) : null, + showEditedLabel && editedLabel + ? t("row.edited", { time: editedLabel }) + : null, ] .filter((value): value is string => Boolean(value)) .join(" - "); @@ -2091,25 +2189,28 @@ function PublicPlaylistRow(props: { ); } -function formatCompactPlaylistRelativeTime(timestamp: number) { +function formatCompactPlaylistRelativeTime( + timestamp: number, + t: (key: string, options?: Record) => string +) { const elapsedMs = Math.max(0, Date.now() - timestamp); if (elapsedMs < 60_000) { - return "now"; + return t("row.relative.now"); } const minutes = Math.floor(elapsedMs / 60_000); if (minutes < 60) { - return `${minutes}m`; + return t("row.relative.minutes", { count: minutes }); } const hours = Math.floor(elapsedMs / 3_600_000); if (hours < 24) { - return `${hours}h`; + return t("row.relative.hours", { count: hours }); } const days = Math.floor(elapsedMs / 86_400_000); - return `${days}d`; + return t("row.relative.days", { count: days }); } function StatusColumn(props: { @@ -2143,6 +2244,7 @@ function PlaylistPositionBadge(props: { position: number }) { } function RecordBadge(props: { spinning: boolean; active: boolean }) { + const { t } = useLocaleTranslation("playlist"); const activeColor = "#a855f7"; return ( @@ -2154,7 +2256,7 @@ function RecordBadge(props: { spinning: boolean; active: boolean }) { ? "drop-shadow(0 0 16px rgba(168, 85, 247, 0.28))" : "none", }} - title={props.active ? "Now playing" : undefined} + title={props.active ? t("badges.nowPlaying") : undefined} > ()({ + loader: async () => { + const [locale, initialViewerSession] = await Promise.all([ + getInitialLocale(), + getViewerSession(), + ]); + + return { + locale, + initialViewerSession, + }; + }, + staleTime: Number.POSITIVE_INFINITY, component: RootComponent, }); function RootComponent() { + const { locale } = Route.useLoaderData(); const router = useRouter(); const queryClient = router.options.context.queryClient; const pathname = useRouterState({ @@ -40,7 +59,7 @@ function RootComponent() { const isExtensionRoute = pathname.startsWith("/extension/"); return ( - + {isExtensionRoute ? (