diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb89b6..fee9447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.3.1] - 2026-04-01 + +### Added +- Owner-controlled channel language for bot replies, with Twitch panel fallback to the channel default when a viewer has no linked or local language preference. +- A non-English translation feedback prompt in the website header and account settings. + +### Changed +- Twitch panel and public VIP-token help now share localized VIP automation copy and locale-aware amount formatting. +- More support-event and StreamElements bot replies now respect the channel's configured bot language instead of always replying in English. + +### Fixed +- Website language changes now apply immediately again instead of briefly reverting or waiting for the background locale save to finish. + ## [0.3.0] - 2026-04-01 ### Added diff --git a/README.md b/README.md index 7f6ce8b..c8f3ebb 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ It runs on TanStack Start, Cloudflare Workers, D1, Durable Objects, Queues, KV, ### Platform And Quality -- Internationalization scaffolding for localized website copy +- Internationalization support for localized website and Twitch panel UI, plus owner-controlled bot reply locales with English as the default - Durable Object playlist serialization, Queue-based reply delivery, and Cloudflare-backed persistence - Vitest, Playwright, and GitHub Actions verification diff --git a/drizzle/0023_channel_default_locale.sql b/drizzle/0023_channel_default_locale.sql new file mode 100644 index 0000000..972db71 --- /dev/null +++ b/drizzle/0023_channel_default_locale.sql @@ -0,0 +1,2 @@ +ALTER TABLE `channel_settings` +ADD `default_locale` text NOT NULL DEFAULT 'en'; diff --git a/package.json b/package.json index 3b9e002..c68d082 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "request-bot", "private": true, "type": "module", - "version": "0.3.0", + "version": "0.3.1", "engines": { "node": ">=22" }, diff --git a/src/app.css b/src/app.css index 556e3ee..5ebaeae 100644 --- a/src/app.css +++ b/src/app.css @@ -102,15 +102,18 @@ body.overlay-mode { content slightly left when simple selects open. */ html body[data-scroll-locked] { + /* biome-ignore lint/complexity/noImportantStyles: react-remove-scroll injects inline compensation styles we need to neutralize. */ margin-right: 0 !important; --removed-body-scroll-bar-size: 0px; } html body[data-scroll-locked] .width-before-scroll-bar { + /* biome-ignore lint/complexity/noImportantStyles: react-remove-scroll injects inline compensation styles we need to neutralize. */ margin-right: 0 !important; } html body[data-scroll-locked] .right-scroll-bar-position { + /* biome-ignore lint/complexity/noImportantStyles: react-remove-scroll injects inline compensation styles we need to neutralize. */ right: 0 !important; } diff --git a/src/components/translation-help-button.tsx b/src/components/translation-help-button.tsx new file mode 100644 index 0000000..264e8e9 --- /dev/null +++ b/src/components/translation-help-button.tsx @@ -0,0 +1,58 @@ +import { useAppLocale, useLocaleTranslation } from "~/lib/i18n/client"; +import { cn } from "~/lib/utils"; +import { Button } from "./ui/button"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "./ui/popover"; + +export function TranslationHelpButton(props: { + className?: string; + align?: "start" | "center" | "end"; +}) { + const { locale } = useAppLocale(); + const { t } = useLocaleTranslation("common"); + + if (locale === "en") { + return null; + } + + return ( + + + + + + + + {t("translationHelp.title")} + + + {t("translationHelp.messageLead")}{" "} + + support@rocklist.live + + . + + + + + ); +} diff --git a/src/extension/panel/app.tsx b/src/extension/panel/app.tsx index 4f1e3ee..918b82f 100644 --- a/src/extension/panel/app.tsx +++ b/src/extension/panel/app.tsx @@ -9,6 +9,7 @@ import { extractClosestEdge, } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index"; +import type { TFunction } from "i18next"; import { Check, CircleAlert, @@ -45,6 +46,13 @@ import { CollapsibleTrigger, } from "~/components/ui/collapsible"; import { Input } from "~/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; import { Skeleton } from "~/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { @@ -53,19 +61,19 @@ import { TooltipProvider, TooltipTrigger, } from "~/components/ui/tooltip"; +import { + AppI18nProvider, + useAppLocale, + useLocaleTranslation, +} from "~/lib/i18n/client"; +import { type AppLocale, localeOptions } from "~/lib/i18n/locales"; import { getQueuedPositionsFromRegularOrder, getUpdatedPositionsAfterSetCurrent, getUpdatedQueuedPositionsAfterKindChange, } from "~/lib/playlist/order"; -import { - ADD_REQUESTS_WHEN_LIVE_MESSAGE, - areChannelRequestsOpen, -} from "~/lib/request-availability"; -import { - getVipTokenAutomationDetails, - getVipTokenRedemptionDescription, -} from "~/lib/vip-token-automation"; +import { areChannelRequestsOpen } from "~/lib/request-availability"; +import { getVipTokenAutomationDetails } from "~/lib/vip-token-automation"; import { toExtensionApiUrl, toExtensionAppUrl } from "./config"; import { applyDemoViewerRequestMutation, @@ -74,6 +82,7 @@ import { mockModeratorViewerProfile, type PanelDemoPlaylist, } from "./demo"; +import { readPanelStoredLocale, resolveExtensionPanelLocale } from "./locale"; import { getTwitchExtensionHelper, loadTwitchExtensionHelper, @@ -92,6 +101,7 @@ type PanelBootstrapResponse = { botReadyState?: string | null; }; settings: { + defaultLocale: string; requestsEnabled: boolean; showPlaylistPositions: boolean; autoGrantVipTokenToSubscribers: boolean; @@ -118,6 +128,7 @@ type PanelBootstrapResponse = { login: string; displayName: string; profileImageUrl?: string | null; + preferredLocale?: string | null; isSubscriber: boolean; subscriptionVerified: boolean; vipTokensAvailable: number; @@ -256,6 +267,33 @@ type TransientPanelNotice = { const PANEL_VISIBLE_REFRESH_INTERVAL_MS = 5000; export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { + const [initialLocale, setInitialLocale] = useState(() => + resolveExtensionPanelLocale({ + search: typeof window !== "undefined" ? window.location.search : null, + storedLocale: readPanelStoredLocale(), + documentLanguage: + typeof document !== "undefined" ? document.documentElement.lang : null, + navigatorLanguage: + typeof navigator !== "undefined" ? navigator.language : null, + }) + ); + + return ( + + + + ); +} + +function ExtensionPanelAppContent(props: { + apiBaseUrl?: string; + onResolvedLocaleChange?: (locale: AppLocale) => void; +}) { + const { t } = useLocaleTranslation("extension"); + const { locale, setLocale, isSavingLocale } = useAppLocale(); const [helperState, setHelperState] = useState<"loading" | "ready" | "error">( "loading" ); @@ -304,8 +342,13 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { const showPlaylistPositions = bootstrap?.settings.showPlaylistPositions ?? false; const vipTokenAutomationDetails = getVipTokenAutomationDetails( - bootstrap?.settings ?? {} + bootstrap?.settings ?? {}, + { + locale, + translate: (key, options) => t(key, options), + } ); + const addRequestsWhenLiveMessage = t("requests.addWhenLive"); const managementPermissions = bootstrap?.management.permissions; const canManagePlaylist = managementPermissions?.canManageRequests ?? false; const canManageVipRequests = @@ -323,14 +366,14 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { : viewerRequestsAvailable && !!bootstrap?.viewer.canVipRequest; const quickVipDisabledReason = canManagePlaylist ? !channelRequestsOpen - ? ADD_REQUESTS_WHEN_LIVE_MESSAGE + ? addRequestsWhenLiveMessage : viewerProfile && viewerProfile.vipTokensAvailable < 1 - ? "Not enough VIP tokens." + ? t("vip.notEnough") : null : !viewerRequestsAvailable - ? ADD_REQUESTS_WHEN_LIVE_MESSAGE + ? addRequestsWhenLiveMessage : viewerProfile && viewerProfile.vipTokensAvailable < 1 - ? "Not enough VIP tokens." + ? t("vip.notEnough") : null; const showViewerSearchActions = !canManagePlaylist && (canQuickRequest || canQuickVipRequest); @@ -338,9 +381,9 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { const showManagerSearchActions = canManagePlaylist; const searchTabBlockedByRequestsOff = !canManagePlaylist && !requestsEnabled; const vipSearchDisabledReason = !viewerRequestsAvailable - ? ADD_REQUESTS_WHEN_LIVE_MESSAGE + ? addRequestsWhenLiveMessage : viewerProfile && viewerProfile.vipTokensAvailable < 1 - ? "Not enough VIP tokens." + ? t("vip.notEnough") : null; const playlistItems = bootstrap?.playlist.items ?? []; const currentPlaylistItemId = bootstrap?.playlist.currentItemId ?? null; @@ -352,8 +395,8 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { const canShufflePlaylist = showShufflePlaylistControl && currentPlaylistItemId == null; const shufflePlaylistTooltip = currentPlaylistItemId - ? "Mark the current song played before shuffling." - : "Shuffle the queue."; + ? t("queue.shuffleBlocked") + : t("queue.shuffle"); const canReorderPlaylist = canManagePlaylist && queuedPlaylistItems.length > 1; const waitingForAuthorization = @@ -566,6 +609,27 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { }; }, [auth?.token, props.apiBaseUrl]); + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + props.onResolvedLocaleChange?.( + resolveExtensionPanelLocale({ + search: window.location.search, + storedLocale: readPanelStoredLocale(), + documentLanguage: document.documentElement.lang, + navigatorLanguage: navigator.language, + viewerPreferredLocale: bootstrap?.viewer.profile?.preferredLocale, + channelDefaultLocale: bootstrap?.settings.defaultLocale, + }) + ); + }, [ + bootstrap?.settings.defaultLocale, + bootstrap?.viewer.profile?.preferredLocale, + props.onResolvedLocaleChange, + ]); + function showTransientNotice( tone: TransientPanelNotice["tone"], message: string @@ -848,7 +912,11 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { } ); - showTransientNotice("success", result.message ?? "Request updated."); + showTransientNotice( + "success", + result.message ?? + (isEditingRequest ? "Request updated." : "Request added.") + ); await refreshPanelState({ token: auth.token, }); @@ -974,7 +1042,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { if (mutation.action !== "reorderItems") { showTransientNotice( "success", - getPlaylistMutationSuccessMessage(mutation, response) + getPlaylistMutationSuccessMessage(mutation, response, t) ); } } catch (error) { @@ -1083,13 +1151,16 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { } const channelTitle = bootstrap?.channel?.displayName - ? `${bootstrap.channel.displayName}'s Request Playlist` - : "Request Playlist"; + ? t("panel.titleWithChannel", { + displayName: bootstrap.channel.displayName, + }) + : t("panel.titleDefault"); const footerPlaylistHref = bootstrap?.channel?.slug ? toExtensionAppUrl(`/${bootstrap.channel.slug}`, props.apiBaseUrl) : null; const footerPlaylistLabel = getPanelPlaylistFooterLabel( - bootstrap?.channel?.displayName + bootstrap?.channel?.displayName, + t ); const showStandaloneDemo = !auth && @@ -1114,51 +1185,62 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { onOpenChange={setVipHelpOpen} className="min-w-0" > -
-

- {channelTitle} -

- {viewerProfile ? ( -
- You have - - - - - - {formatRequestLimitCompact( - activeRequestCount, - activeRequestLimit - )} - -
- ) : ( -

- {bootstrap?.viewer.isLinked - ? (bootstrap.viewer.access.reason ?? - "Viewer state is still loading.") - : "Share Twitch identity to request."} -

- )} +
+
+

+ {channelTitle} +

+ {viewerProfile ? ( +
+ + + + + + {formatRequestLimitCompact( + activeRequestCount, + activeRequestLimit, + t + )} + +
+ ) : ( +

+ {bootstrap?.viewer.isLinked + ? translateExtensionMessage( + bootstrap.viewer.access.reason ?? + t("panel.viewerStateLoading"), + t + ) + : t("panel.shareIdentityToRequest")} +

+ )} +
+

- {getVipTokenRedemptionDescription()} + {t("vip.redemptionDescription")}

- How to earn VIP tokens + {t("vip.howToEarn")}

{vipTokenAutomationDetails.earningRules.length ? (
@@ -1169,9 +1251,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { )}
) : ( -

- This channel grants VIP tokens manually right now. -

+

{t("vip.manualOnly")}

)} {vipTokenAutomationDetails.notes.map((note) => (

{note}

@@ -1189,7 +1269,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { onClick={handleRequestIdentityShare} disabled={helperState !== "ready"} > - Share Twitch Identity + {t("panel.shareIdentityButton")} ) : null}
@@ -1199,20 +1279,22 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { {helperState === "error" ? ( } tone="danger"> - {helperError ?? "Unable to load the Twitch extension helper."} + {translateExtensionMessage( + helperError ?? t("notices.helperLoadFailed"), + t + )} ) : null} {helperTimedOut && !auth ? ( }> - Open this page from Twitch Local Test or Hosted Test to receive - panel authorization. + {t("notices.authorizationHint")} ) : null} {bootstrapError ? ( } tone="danger"> - {bootstrapError} + {translateExtensionMessage(bootstrapError, t)} ) : null} @@ -1221,7 +1303,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { icon={} tone="default" > - {connectionMessage} + {translateExtensionMessage(connectionMessage, t)} ) : null} @@ -1231,19 +1313,19 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { key={transientNotice.id} tone={transientNotice.tone} > - {transientNotice.message} + {translateExtensionMessage(transientNotice.message, t)} ) : null} {bootstrap?.setup ? ( }> - {bootstrap.setup.message} + {translateExtensionMessage(bootstrap.setup.message, t)} ) : null} {bootstrap?.channel && !channelRequestsOpen ? ( }> - {ADD_REQUESTS_WHEN_LIVE_MESSAGE} + {addRequestsWhenLiveMessage} ) : null} @@ -1270,7 +1352,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { style={{ fontFamily: '"IBM Plex Sans", sans-serif' }} > - Playlist + {t("queue.tab")} ({queueCount}) @@ -1281,7 +1363,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { className="h-auto rounded-none border-0 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-(--muted) shadow-none after:bottom-0 after:h-px after:bg-(--brand-deep) data-[state=active]:text-(--brand-deep)" style={{ fontFamily: '"IBM Plex Sans", sans-serif' }} > - Search + {t("search.tab")} @@ -1296,7 +1378,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { - Queue tools + {t("queue.tools")} {showShufflePlaylistControl ? ( @@ -1374,7 +1456,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { }) ) : (
- Queue is empty. + {t("queue.empty")}
)}
@@ -1411,8 +1493,8 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { autoCapitalize="none" placeholder={ editingRequest - ? "Search for a song to edit your request" - : "Search title, artist, or album" + ? t("search.placeholderEdit") + : t("search.placeholder") } className="h-8 rounded-none border-(--border-strong) px-2 py-1 text-[12px] shadow-none focus-visible:ring-1 focus-visible:ring-(--brand) focus-visible:ring-offset-0" /> @@ -1436,7 +1518,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { {searchError ? (

- {searchError} + {translateExtensionMessage(searchError, t)}

) : null} {showSpecialRequestControls ? ( @@ -1463,7 +1545,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) {
{searchTabBlockedByRequestsOff ? (
- Requests are off right now. + {t("requests.offRightNow")}
) : searchResults?.items?.length ? (
@@ -1481,7 +1563,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) {

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

{formatSearchSongMeta(item)} @@ -1502,10 +1584,10 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { }} > {pendingAction === managerActionKey - ? "Adding..." + ? t("buttons.adding") : isEditingRequest - ? "Edit" - : "Add"} + ? t("buttons.edit") + : t("buttons.add")}

) : showViewerSearchActions ? ( @@ -1522,7 +1604,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { } title={ !viewerRequestsAvailable - ? ADD_REQUESTS_WHEN_LIVE_MESSAGE + ? addRequestsWhenLiveMessage : undefined } onClick={() => { @@ -1538,11 +1620,11 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { > {pendingAction === actionKey ? isEditingRequest - ? "Editing..." - : "Adding..." + ? t("buttons.editing") + : t("buttons.adding") : isEditingRequest - ? "Edit" - : "Add"} + ? t("buttons.edit") + : t("buttons.add")} ) : debouncedSearchQuery.trim().length >= 3 && !searching ? (
- No songs matched that search. + {t("search.noResults")}
) : null}
@@ -1594,6 +1676,7 @@ export function ExtensionPanelApp(props: { apiBaseUrl?: string }) { } export function ExtensionPanelModeratorPreview() { + const { t } = useLocaleTranslation("extension"); const [playlist, setPlaylist] = useState({ currentItemId: "preview-current", items: createMockModeratorPlaylistItems(), @@ -1634,7 +1717,7 @@ export function ExtensionPanelModeratorPreview() { const queueCount = playlist.items.length; const showPlaylistPositions = false; const footerPlaylistHref = toExtensionAppUrl("/jimmy-pants"); - const footerPlaylistLabel = getPanelPlaylistFooterLabel("Jimmy Pants_"); + const footerPlaylistLabel = getPanelPlaylistFooterLabel("Jimmy Pants_", t); const queuedPlaylistItems = playlist.items.filter( (item) => getString(item, "id") !== currentPlaylistItemId ); @@ -1643,11 +1726,11 @@ export function ExtensionPanelModeratorPreview() { const canShufflePlaylist = showShufflePlaylistControl && currentPlaylistItemId == null; const shufflePlaylistTooltip = currentPlaylistItemId - ? "Mark the current song played before shuffling." - : "Shuffle the queue."; + ? t("queue.shuffleBlocked") + : t("queue.shuffle"); const vipSearchDisabledReason = mockModeratorViewerProfile.vipTokensAvailable < 1 - ? "Not enough VIP tokens." + ? t("vip.notEnough") : null; const editingRequest = getViewerEditablePanelItem( playlist.items, @@ -2029,7 +2112,9 @@ export function ExtensionPanelModeratorPreview() { ) { showTransientMessage( "danger", - `You already have ${activeRequestLimit} active request${activeRequestLimit === 1 ? "" : "s"} in this playlist.` + t("requests.limitReached", { + count: activeRequestLimit, + }) ); return; } @@ -2102,9 +2187,13 @@ export function ExtensionPanelModeratorPreview() { if (mutation.action !== "reorderItems") { showTransientMessage( "success", - getPlaylistMutationSuccessMessage(mutation, { - ok: true, - }) + getPlaylistMutationSuccessMessage( + mutation, + { + ok: true, + }, + t + ) ); } @@ -2163,21 +2252,27 @@ export function ExtensionPanelModeratorPreview() {
-
-

- Jimmy Pants_'s Request Playlist -

-

- You have{" "} - {formatVipTokensCompact( - mockModeratorViewerProfile.vipTokensAvailable - )}{" "} - ·{" "} - {formatRequestLimitCompact( - activeRequestCount, - activeRequestLimit - )} -

+
+
+

+ {t("panel.titleWithChannel", { + displayName: "Jimmy Pants_", + })} +

+

+ {formatVipTokensCompact( + mockModeratorViewerProfile.vipTokensAvailable, + t + )}{" "} + ·{" "} + {formatRequestLimitCompact( + activeRequestCount, + activeRequestLimit, + t + )} +

+
+
@@ -2187,7 +2282,7 @@ export function ExtensionPanelModeratorPreview() { key={transientNotice.id} tone={transientNotice.tone} > - {transientNotice.message} + {translateExtensionMessage(transientNotice.message, t)} ) : null} @@ -2211,7 +2306,7 @@ export function ExtensionPanelModeratorPreview() { style={{ fontFamily: '"IBM Plex Sans", sans-serif' }} > - Playlist + {t("queue.tab")} ({queueCount}) @@ -2222,7 +2317,7 @@ export function ExtensionPanelModeratorPreview() { className="h-auto rounded-none border-0 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-(--muted) shadow-none after:bottom-0 after:h-px after:bg-(--brand-deep) data-[state=active]:text-(--brand-deep)" style={{ fontFamily: '"IBM Plex Sans", sans-serif' }} > - Search + {t("search.tab")} @@ -2237,7 +2332,7 @@ export function ExtensionPanelModeratorPreview() { - Queue tools + {t("queue.tools")} @@ -2335,8 +2430,8 @@ export function ExtensionPanelModeratorPreview() { autoCapitalize="none" placeholder={ editingRequest - ? "Search for a song to edit your request" - : "Search title, artist, or album" + ? t("search.placeholderEdit") + : t("search.placeholder") } className="h-8 rounded-none border-(--border-strong) px-2 py-1 text-[12px] shadow-none focus-visible:ring-1 focus-visible:ring-(--brand) focus-visible:ring-offset-0" /> @@ -2356,7 +2451,7 @@ export function ExtensionPanelModeratorPreview() { {searchError ? (

- {searchError} + {translateExtensionMessage(searchError, t)}

) : null}

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

{formatSearchSongMeta(item)} @@ -2421,11 +2516,11 @@ export function ExtensionPanelModeratorPreview() { > {pendingAction === actionKey ? isEditingRequest - ? "Editing..." - : "Adding..." + ? t("buttons.editing") + : t("buttons.adding") : isEditingRequest - ? "Edit" - : "Add"} + ? t("buttons.edit") + : t("buttons.add")} ) : !searching ? (

- No songs matched that search. + {t("search.noResults")}
) : null}
@@ -2499,6 +2594,7 @@ function PanelPlaylistRow(props: { onRemoveRequest: (itemId: string) => Promise; onPlaylistMutation: (mutation: PanelPlaylistMutation) => Promise; }) { + const { t } = useLocaleTranslation("extension"); const itemRef = useRef(null); const dragHandleRef = useRef(null); const isCurrent = props.itemId === props.currentItemId; @@ -2714,7 +2810,9 @@ function PanelPlaylistRow(props: {
@@ -2780,10 +2878,10 @@ function PanelPlaylistRow(props: {

- {formatSongLabel(props.item)} + {formatSongLabel(props.item, t)}

- {formatRequesterLine(props.item)} + {formatRequesterLine(props.item, t)}

@@ -2794,8 +2892,8 @@ function PanelPlaylistRow(props: { size="sm" variant="ghost" className="h-6 w-6 rounded-none px-0 text-(--muted) shadow-none hover:bg-(--panel-soft) hover:text-(--text)" - title="Request actions" - aria-label="Request actions" + title={t("panel.requestActions")} + aria-label={t("panel.requestActions")} > @@ -2836,7 +2934,7 @@ function PanelPlaylistRow(props: { > {canEditOwnRequest ? ( props.onEditRequest(props.itemId)} > @@ -2846,7 +2944,11 @@ function PanelPlaylistRow(props: { {canToggleVipRequest ? ( { void props.onPlaylistMutation({ @@ -2870,7 +2972,7 @@ function PanelPlaylistRow(props: { {canShowSetCurrent ? ( { void props.onPlaylistMutation({ action: "setCurrent", @@ -2889,7 +2991,7 @@ function PanelPlaylistRow(props: { {canReturnToQueue ? ( { void props.onPlaylistMutation({ action: "returnToQueue", @@ -2910,7 +3012,7 @@ function PanelPlaylistRow(props: { {canMarkPlayed ? ( { void props.onPlaylistMutation({ action: "markPlayed", @@ -2935,8 +3037,8 @@ function PanelPlaylistRow(props: { > {props.canManagePlaylist - ? "Remove from playlist?" - : "Remove request?"} + ? t("requests.removeFromPlaylistConfirm") + : t("requests.removeConfirm")}
@@ -3044,6 +3148,8 @@ function PanelSearchVipButton(props: { isEditingRequest: boolean; onClick: () => void; }) { + const { t } = useLocaleTranslation("extension"); + const button = ( ); @@ -3087,26 +3193,28 @@ function PanelSpecialRequestControls(props: { requestKind: "regular" | "vip" ) => void; }) { + const { t } = useLocaleTranslation("extension"); const normalizedQuery = props.query.trim(); const regularDisabledReason = getPanelSpecialRequestDisabledReason({ query: normalizedQuery, canRequest: props.canRequest, + t, }); const vipDisabledReason = getPanelSpecialRequestDisabledReason({ query: normalizedQuery, canRequest: props.canVipRequest, - fallbackReason: - props.vipDisabledReason ?? "You do not have enough VIP tokens.", + fallbackReason: props.vipDisabledReason ?? t("vip.insufficient"), + t, }); return (

- Quick request + {t("requests.quick")}

props.onSubmit("random", "vip")} /> void; onVipClick: () => void; }) { + const { t } = useLocaleTranslation("extension"); const regularDisabled = props.busy || props.disabledReason != null; const vipDisabled = props.busy || props.vipDisabledReason != null; @@ -3189,11 +3298,11 @@ function PanelSpecialRequestRow(props: { > {props.regularPending ? props.isEditingRequest - ? "Editing..." - : "Adding..." + ? t("buttons.editing") + : t("buttons.adding") : props.isEditingRequest - ? "Edit" - : "Add"} + ? t("buttons.edit") + : t("buttons.add")} , key: string) { : null; } -function formatSongLabel(item: Record) { +function formatSongLabel(item: Record, t: TFunction) { const warningCode = getString(item, "warningCode"); const requestedQuery = getString(item, "requestedQuery"); if (warningCode === "streamer_choice") { return requestedQuery - ? `Streamer choice: ${requestedQuery}` - : "Streamer choice"; + ? t("playlistItem.streamerChoiceWithQuery", { + query: requestedQuery, + }) + : t("playlistItem.streamerChoice"); } const artist = getString(item, "songArtist"); - const title = getString(item, "songTitle") ?? "Unknown song"; + const title = getString(item, "songTitle") ?? t("playlistItem.unknownSong"); return artist ? `${artist} - ${title}` : title; } -function formatRequesterLine(item: Record) { +function formatRequesterLine(item: Record, t: TFunction) { const requester = getString(item, "requestedByDisplayName") ?? getString(item, "requestedByLogin") ?? - "Unknown requester"; - const addedAt = formatCompactRelativeTimestamp(getNumber(item, "createdAt")); - const editedAt = formatEditedTimestamp(item); + t("playlistItem.unknownRequester"); + const addedAt = t("playlistItem.metaAdded", { + time: formatCompactRelativeTimestamp(getNumber(item, "createdAt"), t), + }); + const editedAt = formatEditedTimestamp(item, t); return editedAt - ? `${requester} · Added ${addedAt} · Edited ${editedAt}` - : `${requester} · Added ${addedAt}`; + ? t("playlistItem.metaLineEdited", { + requester, + added: addedAt, + edited: editedAt, + }) + : t("playlistItem.metaLine", { + requester, + added: addedAt, + }); } -function formatEditedTimestamp(item: Record) { +function formatEditedTimestamp(item: Record, t: TFunction) { const updatedAt = getNumber(item, "editedAt"); const createdAt = getNumber(item, "createdAt"); @@ -3641,17 +3858,22 @@ function formatEditedTimestamp(item: Record) { return null; } - return formatCompactRelativeTimestamp(updatedAt); + return t("playlistItem.metaEdited", { + time: formatCompactRelativeTimestamp(updatedAt, t), + }); } -function formatCompactRelativeTimestamp(timestamp: number | null) { +function formatCompactRelativeTimestamp( + timestamp: number | null, + t: TFunction +) { if (timestamp == null) { - return "recent"; + return t("playlistItem.recent"); } const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); if (deltaSeconds < 10) { - return "now"; + return t("playlistItem.now"); } if (deltaSeconds < 60) { return `${deltaSeconds}s`; @@ -3671,9 +3893,9 @@ function formatCompactRelativeTimestamp(timestamp: number | null) { return `${deltaDays}d`; } -function formatSearchSongLabel(item: Record) { +function formatSearchSongLabel(item: Record, t: TFunction) { const artist = getString(item, "artist"); - const title = getString(item, "title") ?? "Unknown song"; + const title = getString(item, "title") ?? t("playlistItem.unknownSong"); return artist ? `${artist} - ${title}` : title; } @@ -3699,13 +3921,14 @@ function getPanelSpecialRequestDisabledReason(input: { query: string; canRequest: boolean; fallbackReason?: string; + t: TFunction; }) { if (input.query.length < 2) { - return "Type at least 2 characters first."; + return input.t("search.typeAtLeastTwo"); } if (!input.canRequest) { - return input.fallbackReason ?? "You cannot request songs right now."; + return input.fallbackReason ?? input.t("requests.noPermission"); } return null; @@ -3725,6 +3948,8 @@ function PanelRequestsStatusBar({ }: { requestsEnabled: boolean; }) { + const { t } = useLocaleTranslation("extension"); + return (
- Requests are {requestsEnabled ? "ON" : "OFF"} + {t("requests.status", { + state: requestsEnabled + ? t("requests.statusOn") + : t("requests.statusOff"), + })}
); } -function formatVipTokensCompact(count: number) { - return count === 1 ? "1 VIP token" : `${count} VIP tokens`; +function formatVipTokensCompact(count: number, t: TFunction) { + return t("vip.balance", { + count, + }); } -function formatRequestLimitCompact(count: number, limit: number | null) { +function formatRequestLimitCompact( + count: number, + limit: number | null, + t: TFunction +) { if (limit == null) { - return `${count} request${count === 1 ? "" : "s"}`; + return t("requests.countUnlimited", { + count, + }); } - return `${count}/${limit} requests`; + return t("requests.countLimited", { + count, + limit, + }); } -function getPanelPlaylistFooterLabel(displayName: string | null | undefined) { +function getPanelPlaylistFooterLabel( + displayName: string | null | undefined, + t: TFunction +) { return displayName?.trim() - ? `Open ${displayName}'s playlist on RockList.Live` - : "Open playlist on RockList.Live"; + ? t("footer.openPlaylistForChannel", { + displayName, + }) + : t("footer.openPlaylist"); +} + +function PanelLanguageSelect(props: { + locale: AppLocale; + onLocaleChange: (locale: AppLocale) => Promise; + isSavingLocale: boolean; +}) { + const { t } = useLocaleTranslation("common"); + + return ( + + ); +} + +function PreviewPanelLanguageSelect() { + const { locale, setLocale, isSavingLocale } = useAppLocale(); + + return ( + + ); } diff --git a/src/extension/panel/locale.ts b/src/extension/panel/locale.ts new file mode 100644 index 0000000..c617d8c --- /dev/null +++ b/src/extension/panel/locale.ts @@ -0,0 +1,33 @@ +import { + readExplicitDeviceLocale, + readExplicitLocaleCookie, +} from "~/lib/i18n/detect"; +import { defaultLocale, normalizeLocale } from "~/lib/i18n/locales"; + +export function resolveExtensionPanelLocale(input?: { + search?: string | null; + storedLocale?: string | null; + cookieLocale?: string | null; + documentLanguage?: string | null; + navigatorLanguage?: string | null; + viewerPreferredLocale?: string | null; + channelDefaultLocale?: string | null; +}) { + const params = new URLSearchParams(input?.search ?? ""); + + return ( + normalizeLocale(input?.viewerPreferredLocale) ?? + normalizeLocale(input?.storedLocale) ?? + normalizeLocale(input?.cookieLocale) ?? + normalizeLocale(params.get("locale")) ?? + normalizeLocale(params.get("language")) ?? + normalizeLocale(input?.documentLanguage) ?? + normalizeLocale(input?.navigatorLanguage) ?? + normalizeLocale(input?.channelDefaultLocale) ?? + defaultLocale + ); +} + +export function readPanelStoredLocale() { + return readExplicitDeviceLocale() ?? readExplicitLocaleCookie(); +} diff --git a/src/lib/db/latest-migration.generated.ts b/src/lib/db/latest-migration.generated.ts index 8896d76..7942d7b 100644 --- a/src/lib/db/latest-migration.generated.ts +++ b/src/lib/db/latest-migration.generated.ts @@ -1 +1 @@ -export const LATEST_MIGRATION_NAME = "0022_user_preferred_locale.sql"; +export const LATEST_MIGRATION_NAME = "0023_channel_default_locale.sql"; diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index 312b1cf..ee96764 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -2711,6 +2711,7 @@ export async function updateSettings( env: AppEnv, channelId: string, input: { + defaultLocale: string; botChannelEnabled: boolean; moderatorCanManageRequests: boolean; moderatorCanManageBlacklist: boolean; @@ -2764,6 +2765,7 @@ export async function updateSettings( await getDb(env) .update(channelSettings) .set({ + defaultLocale: input.defaultLocale, botChannelEnabled: input.botChannelEnabled, moderatorCanManageRequests: input.moderatorCanManageRequests, moderatorCanManageBlacklist: input.moderatorCanManageBlacklist, diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 1997279..e865a3e 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -52,6 +52,7 @@ export const channelSettings = sqliteTable("channel_settings", { channelId: text("channel_id") .primaryKey() .references(() => channels.id), + defaultLocale: text("default_locale").notNull().default("en"), botChannelEnabled: integer("bot_channel_enabled", { mode: "boolean" }) .notNull() .default(false), diff --git a/src/lib/eventsub/chat-message.ts b/src/lib/eventsub/chat-message.ts index 625d5b5..e0a59b3 100644 --- a/src/lib/eventsub/chat-message.ts +++ b/src/lib/eventsub/chat-message.ts @@ -16,6 +16,7 @@ import { searchCatalogSongs, } from "~/lib/db/repositories"; import type { AppEnv } from "~/lib/env"; +import { getServerTranslation } from "~/lib/i18n/server"; import type { PlaylistMutationResult } from "~/lib/playlist/types"; import { parseRequestModifiers, @@ -31,6 +32,7 @@ import { formatPathList, getActiveRequestLimit, getArraySetting, + getMissingRequiredPaths, getRateLimitWindow, getRequiredPathsMatchMode, getRequiredPathsWarning, @@ -51,6 +53,7 @@ export interface EventSubChatChannel { } export interface EventSubChatSettings { + defaultLocale: string; requestsEnabled: boolean; moderatorCanManageVipTokens: boolean; autoGrantVipTokenToSubscribers: boolean; @@ -232,6 +235,8 @@ export interface ProcessEventSubChatMessageResult { status: number; } +type Translate = (key: string, options?: Record) => string; + function mention(login: string) { return `@${login}`; } @@ -312,23 +317,68 @@ function getRejectedSongMessage(input: { login: string; reason?: string; reasonCode?: string; + requestedPaths?: string[]; + translate: Translate; }) { if ( input.reasonCode === "charter_blacklist" || input.reasonCode === "song_blacklist" || input.reasonCode === "version_blacklist" ) { - return `${mention(input.login)} I cannot add that song to the playlist.`; + return input.translate("replies.rejectedSongBlocked", { + mention: mention(input.login), + }); } - return `${mention(input.login)} ${ - input.reason ?? "that song is not allowed in this channel." - }`; + return input.translate("replies.rejectedSongReason", { + mention: mention(input.login), + reason: getLocalizedReasonText({ + reason: input.reason, + reasonCode: input.reasonCode, + requestedPaths: input.requestedPaths, + translate: input.translate, + }), + }); } -function getRequestedPathMismatchMessage(requestedPaths: string[]) { - const formattedPaths = formatPathList(requestedPaths); - return `That song does not include the requested path${requestedPaths.length === 1 ? "" : "s"}: ${formattedPaths}.`; +function getLocalizedReasonText(input: { + reason?: string; + reasonCode?: string; + requestedPaths?: string[]; + translate: Translate; +}) { + switch (input.reasonCode) { + case "requests_disabled": + case "vip_requests_disabled": + case "subscriber_requests_disabled": + case "subscriber_or_vip_only": + case "only_official_dlc": + case "disallowed_tuning": + case "artist_not_in_setlist": + return input.translate(`reasons.${input.reasonCode}`); + case "requested_paths_not_matched": + return input.translate("reasons.requested_paths_not_matched", { + count: input.requestedPaths?.length ?? 0, + paths: formatPathList(input.requestedPaths ?? [], input.translate), + }); + default: + return input.reason ?? input.translate("replies.requestDeniedFallback"); + } +} + +function getRequestedPathMismatchMessage( + requestedPaths: string[], + translate?: Translate +) { + if (!translate) { + const formattedPaths = formatPathList(requestedPaths); + return `That song does not include the requested path${requestedPaths.length === 1 ? "" : "s"}: ${formattedPaths}.`; + } + + return translate("reasons.requested_paths_not_matched", { + count: requestedPaths.length, + paths: formatPathList(requestedPaths, translate), + }); } function extractRequestedSourceSongId(query: string | undefined) { @@ -416,6 +466,7 @@ function getSongAllowance(input: { requesterContext: Parameters[1]; allowBlacklistOverride: boolean; requestedPaths: string[]; + translate?: Translate; }) { const policyAllowance = isSongAllowed({ song: input.song, @@ -442,7 +493,10 @@ function getSongAllowance(input: { ) { return { allowed: false, - reason: getRequestedPathMismatchMessage(input.requestedPaths), + reason: getRequestedPathMismatchMessage( + input.requestedPaths, + input.translate + ), reasonCode: "requested_paths_not_matched", }; } @@ -661,18 +715,29 @@ function formatSpecialRequestReply(input: { requestKind: "regular" | "vip"; status?: string; existing?: boolean; + translate: Translate; }) { if (input.requestKind === "vip") { const nextPositionSuffix = input.status === "current" ? "." : " and will play next."; return input.existing - ? `your existing streamer choice request for "${input.requestedText}" is now a VIP request${nextPositionSuffix} 1 VIP token was used.` - : `your streamer choice request for "${input.requestedText}" has been added as a VIP request${nextPositionSuffix}`; + ? input.translate("replies.choiceExistingVip", { + query: input.requestedText, + suffix: nextPositionSuffix, + }) + : input.translate("replies.choiceNewVip", { + query: input.requestedText, + suffix: nextPositionSuffix, + }); } return input.existing - ? `your existing VIP streamer choice request for "${input.requestedText}" is now a regular request again. 1 VIP token was refunded.` - : `your streamer choice request for "${input.requestedText}" has been added to the playlist.`; + ? input.translate("replies.choiceExistingRegular", { + query: input.requestedText, + }) + : input.translate("replies.choiceNewRegular", { + query: input.requestedText, + }); } function buildStreamerChoiceSong(requestedText: string): PlaylistAddSong { @@ -689,6 +754,7 @@ function getPositionReplyMessage(input: { login: string; items: EventSubChatState["items"]; requesterTwitchUserId: string; + translate: Translate; }) { const activeRequests = input.items .filter( @@ -699,7 +765,9 @@ function getPositionReplyMessage(input: { .sort((left, right) => (left.position ?? 0) - (right.position ?? 0)); if (activeRequests.length === 0) { - return `${mention(input.login)} you do not have any active requests in this playlist.`; + return input.translate("replies.positionNone", { + mention: mention(input.login), + }); } const currentRequests = activeRequests.filter( @@ -716,7 +784,11 @@ function getPositionReplyMessage(input: { .filter((title): title is string => Boolean(title)) .join(", "); parts.push( - currentTitles.length > 0 ? `playing now: ${currentTitles}` : "playing now" + currentTitles.length > 0 + ? input.translate("replies.positionPlayingNow", { + titles: currentTitles, + }) + : input.translate("replies.positionPlayingNowFallback") ); } @@ -727,12 +799,46 @@ function getPositionReplyMessage(input: { .map((position) => `#${position}`); parts.push( positions.length > 0 - ? `queued at ${positions.join(", ")}` - : "queued in the playlist" + ? input.translate("replies.positionQueuedAt", { + positions: positions.join(", "), + }) + : input.translate("replies.positionQueuedFallback") ); } - return `${mention(input.login)} your request${activeRequests.length === 1 ? " is" : "s are"} ${parts.join(" and ")}.`; + return input.translate("replies.positionSummary", { + mention: mention(input.login), + count: activeRequests.length, + parts: parts.join(" and "), + }); +} + +function getKindLabel(kind: "regular" | "vip" | "all", translate: Translate) { + switch (kind) { + case "regular": + return translate("labels.regularRequest"); + case "vip": + return translate("labels.vipRequest"); + default: + return translate("labels.request"); + } +} + +function getMissingRequiredPathsText(input: { + song: SongSearchResult; + settings: Pick< + EventSubChatState["settings"], + "requiredPathsJson" | "requiredPathsMatchMode" + >; + translate: Translate; +}) { + return formatPathList( + getMissingRequiredPaths({ + song: input.song, + settings: input.settings, + }), + input.translate + ); } export async function processEventSubChatMessage(input: { @@ -775,6 +881,7 @@ export async function processEventSubChatMessage(input: { if (!state?.settings) { return { body: "Ignored", status: 202 }; } + const { t } = getServerTranslation(state.settings.defaultLocale, "bot"); if (await deps.isBlockedUser(env, channel.id, event.chatterTwitchUserId)) { await deps.createRequestLog(env, { @@ -812,8 +919,7 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: - "Only the broadcaster or an allowed moderator can grant VIP tokens.", + message: t("replies.vipPermissionDenied"), }); return { body: "Rejected", status: 202 }; } @@ -833,8 +939,7 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: - "Use !addvip or !addvip to grant VIP tokens.", + message: t("replies.addVipUsage"), }); return { body: "Rejected", status: 202 }; } @@ -854,7 +959,7 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: "Use a VIP token amount greater than 0.", + message: t("replies.invalidVipAmount"), }); return { body: "Rejected", status: 202 }; } @@ -897,7 +1002,11 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `Granted ${formatVipTokenCount(grantAmount)} VIP token${grantAmount === 1 ? "" : "s"} to ${resolvedTarget?.login ?? targetLogin}.`, + message: t("replies.grantedVipTokens", { + count: grantAmount, + countText: formatVipTokenCount(grantAmount), + login: resolvedTarget?.login ?? targetLogin, + }), }); return { body: "Accepted", status: 202 }; } @@ -905,6 +1014,7 @@ export async function processEventSubChatMessage(input: { if (parsed.command === "remove") { const effectiveRequester = await getEffectiveRequester(env, deps, event, { targetLogin: parsed.targetLogin, + translate: t, }); if (!effectiveRequester.allowed) { await deps.sendChatReply(env, { @@ -920,7 +1030,9 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `Use ${state.settings.commandPrefix}remove reg, ${state.settings.commandPrefix}remove vip, or ${state.settings.commandPrefix}remove all.`, + message: t("replies.removeUsage", { + commandPrefix: state.settings.commandPrefix, + }), }); return { body: "Rejected", status: 202 }; } @@ -937,18 +1049,23 @@ export async function processEventSubChatMessage(input: { result.message.match(/\d+/)?.[0] ?? "0", 10 ); - const kindLabel = - kind === "all" - ? "request" - : `${kind === "regular" ? "regular" : "VIP"} request`; + const kindLabel = getKindLabel(kind, t); await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, message: removedCount > 0 - ? `${mention(effectiveRequester.requester.login)} removed ${removedCount} ${kindLabel}${removedCount === 1 ? "" : "s"} from this playlist.` - : `${mention(effectiveRequester.requester.login)} you do not have any ${kindLabel}${kind === "all" ? "s" : ""} in this playlist.`, + ? t("replies.removeSuccess", { + mention: mention(effectiveRequester.requester.login), + count: removedCount, + kindLabel, + }) + : t("replies.removeEmpty", { + mention: mention(effectiveRequester.requester.login), + kind, + kindLabel, + }), }); return { body: "Accepted", status: 202 }; } catch (error) { @@ -962,7 +1079,9 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `${mention(event.chatterLogin)} I couldn't remove your requests right now. Please try again.`, + message: t("replies.removeFailed", { + mention: mention(event.chatterLogin), + }), }); return { body: "Remove failed", status: 202 }; } @@ -970,6 +1089,7 @@ export async function processEventSubChatMessage(input: { const effectiveRequester = await getEffectiveRequester(env, deps, event, { targetLogin: parsed.targetLogin, + translate: t, }); if (!effectiveRequester.allowed) { await deps.sendChatReply(env, { @@ -992,6 +1112,7 @@ export async function processEventSubChatMessage(input: { appUrl: env.APP_URL, channelSlug: channel.slug, allowRequestPathModifiers: state.settings.allowRequestPathModifiers, + translate: t, }), }); return { body: "Accepted", status: 202 }; @@ -1001,7 +1122,7 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: buildSearchMessage(env.APP_URL), + message: buildSearchMessage(env.APP_URL, t), }); return { body: "Accepted", status: 202 }; } @@ -1014,7 +1135,8 @@ export async function processEventSubChatMessage(input: { state.blacklistArtists, state.blacklistCharters, state.blacklistSongs, - state.blacklistSongGroups + state.blacklistSongGroups, + t ), }); return { body: "Accepted", status: 202 }; @@ -1024,7 +1146,7 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: buildSetlistMessage(state.setlistArtists), + message: buildSetlistMessage(state.setlistArtists, t), }); return { body: "Accepted", status: 202 }; } @@ -1037,6 +1159,7 @@ export async function processEventSubChatMessage(input: { login: requesterIdentity.login, items: state.items, requesterTwitchUserId: requesterIdentity.twitchUserId, + translate: t, }), }); return { body: "Accepted", status: 202 }; @@ -1046,7 +1169,7 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: "Requests are disabled for this channel right now.", + message: t("replies.requestsDisabledNow"), }); return { body: "Ignored", status: 202 }; } @@ -1064,13 +1187,19 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `${mention(requesterIdentity.login)} you have ${formatVipTokenCount(availableCount)} VIP token${availableCount === 1 ? "" : "s"} available.`, + message: t("replies.vipBalanceAvailable", { + mention: mention(requesterIdentity.login), + count: availableCount, + countText: formatVipTokenCount(availableCount), + }), }); return { body: "Accepted", status: 202 }; } const requesterAccess = isRequesterAllowed(state.settings, requesterContext); if (!requesterAccess.allowed) { + const requesterAccessReasonCode = + "reasonCode" in requesterAccess ? requesterAccess.reasonCode : undefined; await deps.createRequestLog(env, { channelId: channel.id, twitchMessageId: event.messageId, @@ -1080,14 +1209,16 @@ export async function processEventSubChatMessage(input: { rawMessage: event.rawMessage, normalizedQuery: parsed.query, outcome: "rejected", - outcomeReason: requesterAccess.reason, + outcomeReason: requesterAccessReasonCode ?? requesterAccess.reason, }); await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: - requesterAccess.reason ?? - "You cannot request songs in this channel right now.", + message: getLocalizedReasonText({ + reason: requesterAccess.reason, + reasonCode: requesterAccessReasonCode, + translate: t, + }), }); return { body: "Rejected", status: 202 }; } @@ -1112,7 +1243,9 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `${mention(requesterIdentity.login)} there is no active request to edit in this playlist.`, + message: t("replies.noActiveRequestToEdit", { + mention: mention(requesterIdentity.login), + }), }); return { body: "Rejected", status: 202 }; } @@ -1129,7 +1262,9 @@ export async function processEventSubChatMessage(input: { }); if (acceptedInPeriod >= timeWindow.limit) { - const message = `You have reached the request limit for the next ${timeWindow.periodSeconds} seconds.`; + const message = t("replies.timeWindowLimit", { + seconds: timeWindow.periodSeconds, + }); await deps.createRequestLog(env, { channelId: channel.id, twitchMessageId: event.messageId, @@ -1166,8 +1301,11 @@ export async function processEventSubChatMessage(input: { !hasRedeemableVipToken(vipTokenBalance?.availableCount ?? 0) && !canAutoGrantVipToken ) { - const balanceText = vipTokenBalance - ? ` You have ${formatVipTokenCount(vipTokenBalance.availableCount)}.` + const balanceSuffix = vipTokenBalance + ? t("replies.vipBalanceSuffix", { + count: vipTokenBalance.availableCount, + countText: formatVipTokenCount(vipTokenBalance.availableCount), + }) : ""; await deps.createRequestLog(env, { channelId: channel.id, @@ -1183,7 +1321,9 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `You do not have enough VIP tokens for this channel.${balanceText}`, + message: t("replies.notEnoughVipTokens", { + balanceSuffix, + }), }); return { body: "Rejected", status: 202 }; } @@ -1211,7 +1351,9 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `${mention(requesterIdentity.login)} include an artist or song before using request modifiers.`, + message: t("replies.requestQueryMissing", { + mention: mention(requesterIdentity.login), + }), }); return { body: "Rejected", status: 202 }; } @@ -1318,7 +1460,9 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `${mention(requesterIdentity.login)} I ran into a problem searching for that song. Please try again.`, + message: t("replies.songLookupFailed", { + mention: mention(requesterIdentity.login), + }), }); return { body: "Lookup failed", status: 202 }; } @@ -1371,6 +1515,8 @@ export async function processEventSubChatMessage(input: { login: requesterIdentity.login, reason: firstRejectedMatch.reason, reasonCode: firstRejectedMatch.reasonCode, + requestedPaths, + translate: t, }), }); return { body: "Rejected", status: 202 }; @@ -1400,6 +1546,8 @@ export async function processEventSubChatMessage(input: { login: requesterIdentity.login, reason: firstRejectedMatch.reason, reasonCode: firstRejectedMatch.reasonCode, + requestedPaths, + translate: t, }), }); return { body: "Rejected", status: 202 }; @@ -1420,7 +1568,10 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `${mention(requesterIdentity.login)} I couldn't find an allowed random match for "${unmatchedQuery}".`, + message: t("replies.randomNotFound", { + mention: mention(requesterIdentity.login), + query: unmatchedQuery, + }), }); return { body: "Rejected", status: 202 }; } @@ -1461,6 +1612,8 @@ export async function processEventSubChatMessage(input: { reason: songAllowed.reason, reasonCode: "reasonCode" in songAllowed ? songAllowed.reasonCode : undefined, + requestedPaths, + translate: t, }), }); return { body: "Rejected", status: 202 }; @@ -1516,8 +1669,12 @@ export async function processEventSubChatMessage(input: { broadcasterUserId: channel.twitchChannelId, message: requestMode === "choice" - ? `${mention(requesterIdentity.login)} that streamer choice request is already in your active requests.` - : `${mention(requesterIdentity.login)} that song is already in your active requests.`, + ? t("replies.choiceAlreadyActive", { + mention: mention(requesterIdentity.login), + }) + : t("replies.songAlreadyActive", { + mention: mention(requesterIdentity.login), + }), }); return { body: "Rejected", status: 202 }; } @@ -1527,7 +1684,9 @@ export async function processEventSubChatMessage(input: { effectiveActiveCount >= activeLimit && !existingMatchingRequest ) { - const message = `You already have ${activeLimit} active request${activeLimit === 1 ? "" : "s"} in the playlist.`; + const message = t("replies.activeRequestLimit", { + count: activeLimit, + }); await deps.createRequestLog(env, { channelId: channel.id, twitchMessageId: event.messageId, @@ -1576,7 +1735,9 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `${mention(requesterIdentity.login)} that song was requested too recently. Please wait before requesting it again.`, + message: t("replies.duplicateWindow", { + mention: mention(requesterIdentity.login), + }), }); return { body: "Rejected", status: 202 }; } @@ -1608,7 +1769,9 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `${mention(requesterIdentity.login)} the playlist is full right now.`, + message: t("replies.playlistFull", { + mention: mention(requesterIdentity.login), + }), }); return { body: "Rejected", status: 202 }; } @@ -1678,10 +1841,25 @@ export async function processEventSubChatMessage(input: { requestKind: isVipCommand ? "vip" : "regular", status: existingMatchingRequest.status, existing: true, + translate: t, })}` : isVipCommand - ? `${mention(requesterIdentity.login)} your existing request "${firstMatch ? formatSongForReply(firstMatch) : unmatchedQuery}" is now a VIP request${existingMatchingRequest.status === "current" ? "." : " and will play next."} 1 VIP token was used.` - : `${mention(requesterIdentity.login)} your existing VIP request "${firstMatch ? formatSongForReply(firstMatch) : unmatchedQuery}" is now a regular request again. 1 VIP token was refunded.`, + ? t("replies.existingSongVip", { + mention: mention(requesterIdentity.login), + song: firstMatch + ? formatSongForReply(firstMatch) + : unmatchedQuery, + suffix: + existingMatchingRequest.status === "current" + ? "." + : " and will play next.", + }) + : t("replies.existingSongRegular", { + mention: mention(requesterIdentity.login), + song: firstMatch + ? formatSongForReply(firstMatch) + : unmatchedQuery, + }), }); return { body: "Accepted", status: 202 }; } @@ -1771,15 +1949,38 @@ export async function processEventSubChatMessage(input: { { requestedText: unmatchedQuery, requestKind: isVipCommand ? "vip" : "regular", + translate: t, } )}` : !firstMatch - ? `${mention(requesterIdentity.login)} there was no matching track found for "${unmatchedQuery}", but I added it anyway. ${buildChannelPlaylistMessage(env.APP_URL, channel.slug)}` + ? t("replies.unmatchedAdded", { + mention: mention(requesterIdentity.login), + query: unmatchedQuery, + playlistUrlMessage: buildChannelPlaylistMessage( + env.APP_URL, + channel.slug, + t + ), + }) : warningCode === "missing_required_paths" - ? `${mention(requesterIdentity.login)} your song "${formatSongForReply(firstMatch)}" has been added to the playlist, but it is missing required paths: ${warningMessage?.replace("Missing required paths: ", "").replace(/\.$/, "")}.` + ? t("replies.missingRequiredPathsAdded", { + mention: mention(requesterIdentity.login), + song: formatSongForReply(firstMatch), + paths: getMissingRequiredPathsText({ + song: firstMatch, + settings: state.settings, + translate: t, + }), + }) : isVipCommand - ? `${mention(requesterIdentity.login)} your VIP song "${formatSongForReply(firstMatch)}" will play next.` - : `${mention(requesterIdentity.login)} your song "${formatSongForReply(firstMatch)}" has been added to the playlist.`, + ? t("replies.vipSongAdded", { + mention: mention(requesterIdentity.login), + song: formatSongForReply(firstMatch), + }) + : t("replies.songAdded", { + mention: mention(requesterIdentity.login), + song: formatSongForReply(firstMatch), + }), }); } @@ -1828,14 +2029,37 @@ export async function processEventSubChatMessage(input: { ? `${mention(requesterIdentity.login)} ${formatSpecialRequestReply({ requestedText: unmatchedQuery, requestKind: isVipCommand ? "vip" : "regular", + translate: t, })}` : !firstMatch - ? `${mention(requesterIdentity.login)} there was no matching track found for "${unmatchedQuery}", but I added it anyway. ${buildChannelPlaylistMessage(env.APP_URL, channel.slug)}` + ? t("replies.unmatchedAdded", { + mention: mention(requesterIdentity.login), + query: unmatchedQuery, + playlistUrlMessage: buildChannelPlaylistMessage( + env.APP_URL, + channel.slug, + t + ), + }) : warningCode === "missing_required_paths" - ? `${mention(requesterIdentity.login)} your song "${formatSongForReply(firstMatch)}" has been added to the playlist, but it is missing required paths: ${warningMessage?.replace("Missing required paths: ", "").replace(/\.$/, "")}.` + ? t("replies.missingRequiredPathsAdded", { + mention: mention(requesterIdentity.login), + song: formatSongForReply(firstMatch), + paths: getMissingRequiredPathsText({ + song: firstMatch, + settings: state.settings, + translate: t, + }), + }) : isVipCommand - ? `${mention(requesterIdentity.login)} your VIP song "${formatSongForReply(firstMatch)}" will play next.` - : `${mention(requesterIdentity.login)} your song "${formatSongForReply(firstMatch)}" has been added to the playlist.`, + ? t("replies.vipSongAdded", { + mention: mention(requesterIdentity.login), + song: formatSongForReply(firstMatch), + }) + : t("replies.songAdded", { + mention: mention(requesterIdentity.login), + song: formatSongForReply(firstMatch), + }), }); } catch (error) { console.error("EventSub failed to add request to playlist", { @@ -1862,10 +2086,16 @@ export async function processEventSubChatMessage(input: { broadcasterUserId: channel.twitchChannelId, message: requestMode === "choice" - ? `${mention(requesterIdentity.login)} I couldn't add your streamer choice request right now. Please try again.` + ? t("replies.choiceAddFailed", { + mention: mention(requesterIdentity.login), + }) : firstMatch - ? `${mention(requesterIdentity.login)} I found a song match, but I couldn't add it to the playlist. Please try again.` - : `${mention(requesterIdentity.login)} I couldn't find a song match, and I couldn't add the warning request to the playlist. Please try again.`, + ? t("replies.songAddFailedMatched", { + mention: mention(requesterIdentity.login), + }) + : t("replies.songAddFailedUnmatched", { + mention: mention(requesterIdentity.login), + }), }); return { body: "Playlist add failed", status: 202 }; } @@ -1967,6 +2197,7 @@ async function getEffectiveRequester( event: NormalizedChatEvent, input: { targetLogin?: string; + translate: Translate; } ) { const targetLogin = normalizeRequestedLogin(input.targetLogin); @@ -1990,8 +2221,7 @@ async function getEffectiveRequester( if (!event.isBroadcaster && !event.isModerator) { return { allowed: false as const, - message: - "Only the broadcaster or a moderator can request for someone else.", + message: input.translate("replies.targetPermissionDenied"), }; } @@ -1999,7 +2229,9 @@ async function getEffectiveRequester( if (!resolved) { return { allowed: false as const, - message: `I couldn't find Twitch user @${targetLogin}.`, + message: input.translate("replies.twitchUserNotFound", { + login: targetLogin, + }), }; } diff --git a/src/lib/eventsub/support-events.ts b/src/lib/eventsub/support-events.ts index 75a1e92..bbadfd8 100644 --- a/src/lib/eventsub/support-events.ts +++ b/src/lib/eventsub/support-events.ts @@ -6,6 +6,9 @@ import { grantVipToken, } from "~/lib/db/repositories"; import type { AppEnv } from "~/lib/env"; +import { formatNumber } from "~/lib/i18n/format"; +import type { AppLocale } from "~/lib/i18n/locales"; +import { getServerTranslation } from "~/lib/i18n/server"; import type { EventSubCheerEvent, EventSubRaidEvent, @@ -13,7 +16,7 @@ import type { EventSubSubscriptionGiftEvent, EventSubSubscriptionMessageEvent, } from "~/lib/twitch/types"; -import { formatVipTokenCount, normalizeVipTokenCount } from "~/lib/vip-tokens"; +import { normalizeVipTokenCount } from "~/lib/vip-tokens"; export interface EventSubSupportChannel { id: string; @@ -22,6 +25,7 @@ export interface EventSubSupportChannel { } export interface EventSubSupportSettings { + defaultLocale: string; autoGrantVipTokenToSubscribers: boolean; autoGrantVipTokensForSharedSubRenewalMessage: boolean; autoGrantVipTokensToSubGifters: boolean; @@ -73,9 +77,14 @@ type EventSubSupportResult = | { body: "Duplicate"; status: 202 } | { body: "Channel not found"; status: 202 }; -function formatPluralizedTokens(count: number) { - const formatted = formatVipTokenCount(count); - return `${formatted} VIP token${count === 1 ? "" : "s"}`; +function mention(login: string) { + return `@${login}`; +} + +function formatTokenCount(locale: AppLocale, count: number) { + return formatNumber(locale, normalizeVipTokenCount(count), { + maximumFractionDigits: 2, + }); } function getCheerMinimumBits(settings: EventSubSupportSettings) { @@ -123,6 +132,7 @@ export async function processEventSubSubscriptionGift(input: { if (!settings?.autoGrantVipTokensToSubGifters) { return { body: "Ignored", status: 202 }; } + const { locale, t } = getServerTranslation(settings.defaultLocale, "bot"); const claimed = await claimDeliveryIfNeeded({ env: input.env, @@ -171,7 +181,15 @@ export async function processEventSubSubscriptionGift(input: { await input.deps.sendChatReply(input.env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `Added ${formatPluralizedTokens(tokenCount)} to @${input.event.user_login} for gifting ${input.event.total} sub${input.event.total === 1 ? "" : "s"}.`, + message: t("replies.autoGrantGiftSubGifter", { + mention: mention(input.event.user_login), + count: tokenCount, + countText: formatTokenCount(locale, tokenCount), + subCount: input.event.total, + subCountText: formatNumber(locale, input.event.total, { + maximumFractionDigits: 2, + }), + }), }); return { body: "Accepted", status: 202 }; @@ -201,6 +219,7 @@ export async function processEventSubChannelSubscribe(input: { if (!shouldGrant) { return { body: "Ignored", status: 202 }; } + const { t } = getServerTranslation(settings?.defaultLocale, "bot"); const claimed = await claimDeliveryIfNeeded({ env: input.env, @@ -242,8 +261,12 @@ export async function processEventSubChannelSubscribe(input: { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, message: input.event.is_gift - ? `Added 1 VIP token to @${input.event.user_login} for receiving a gifted sub.` - : `Added 1 VIP token to @${input.event.user_login} for a new sub.`, + ? t("replies.autoGrantGiftRecipient", { + mention: mention(input.event.user_login), + }) + : t("replies.autoGrantNewSubscriber", { + mention: mention(input.event.user_login), + }), }); return { body: "Accepted", status: 202 }; @@ -270,6 +293,7 @@ export async function processEventSubSubscriptionMessage(input: { if (!settings?.autoGrantVipTokensForSharedSubRenewalMessage) { return { body: "Ignored", status: 202 }; } + const { t } = getServerTranslation(settings.defaultLocale, "bot"); const claimed = await claimDeliveryIfNeeded({ env: input.env, @@ -310,7 +334,9 @@ export async function processEventSubSubscriptionMessage(input: { await input.deps.sendChatReply(input.env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `Added 1 VIP token to @${input.event.user_login} for sharing a sub renewal message.`, + message: t("replies.autoGrantSharedSubRenewal", { + mention: mention(input.event.user_login), + }), }); return { body: "Accepted", status: 202 }; @@ -337,6 +363,7 @@ export async function processEventSubChannelCheer(input: { if (!settings?.autoGrantVipTokensForCheers) { return { body: "Ignored", status: 202 }; } + const { locale, t } = getServerTranslation(settings.defaultLocale, "bot"); const claimed = await claimDeliveryIfNeeded({ env: input.env, @@ -397,7 +424,15 @@ export async function processEventSubChannelCheer(input: { await input.deps.sendChatReply(input.env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `Added ${formatPluralizedTokens(tokenCount)} to @${input.event.user_login} for cheering ${input.event.bits} bit${input.event.bits === 1 ? "" : "s"}.`, + message: t("replies.autoGrantCheer", { + mention: mention(input.event.user_login), + count: tokenCount, + countText: formatTokenCount(locale, tokenCount), + bits: input.event.bits, + bitsText: formatNumber(locale, input.event.bits, { + maximumFractionDigits: 2, + }), + }), }); return { body: "Accepted", status: 202 }; @@ -424,6 +459,7 @@ export async function processEventSubChannelRaid(input: { if (!settings?.autoGrantVipTokensForRaiders) { return { body: "Ignored", status: 202 }; } + const { locale, t } = getServerTranslation(settings.defaultLocale, "bot"); if (input.event.viewers < settings.raidMinimumViewerCount) { return { body: "Ignored", status: 202 }; @@ -469,7 +505,13 @@ export async function processEventSubChannelRaid(input: { await input.deps.sendChatReply(input.env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `Added 1 VIP token to @${input.event.from_broadcaster_user_login} for raiding with ${input.event.viewers} viewer${input.event.viewers === 1 ? "" : "s"}.`, + message: t("replies.autoGrantRaid", { + mention: mention(input.event.from_broadcaster_user_login), + viewers: input.event.viewers, + viewersText: formatNumber(locale, input.event.viewers, { + maximumFractionDigits: 2, + }), + }), }); return { body: "Accepted", status: 202 }; @@ -486,6 +528,7 @@ export function createEventSubSupportDependencies(): EventSubSupportDependencies } return { + defaultLocale: settings.defaultLocale, autoGrantVipTokenToSubscribers: settings.autoGrantVipTokenToSubscribers, autoGrantVipTokensForSharedSubRenewalMessage: settings.autoGrantVipTokensForSharedSubRenewalMessage, diff --git a/src/lib/i18n/client.tsx b/src/lib/i18n/client.tsx index ddc838b..68dce51 100644 --- a/src/lib/i18n/client.tsx +++ b/src/lib/i18n/client.tsx @@ -3,9 +3,9 @@ import ICU from "i18next-icu"; import { createContext, type PropsWithChildren, - startTransition, useContext, useEffect, + useRef, useState, } from "react"; import { @@ -13,10 +13,7 @@ import { initReactI18next, useTranslation, } from "react-i18next"; -import { - persistExplicitDeviceLocale, - persistExplicitLocaleCookie, -} from "./detect"; +import { persistExplicitDeviceLocale } from "./detect"; import { getI18nInitOptions } from "./init"; import type { AppLocale } from "./locales"; @@ -36,28 +33,60 @@ function createI18n(locale: AppLocale) { return instance; } +function syncLocale(i18n: I18nInstance, locale: AppLocale) { + if (typeof document !== "undefined") { + document.documentElement.lang = locale; + } + + void i18n.changeLanguage(locale); +} + +export function getSyncedLocaleFromInitial(input: { + currentLocale: AppLocale; + previousInitialLocale: AppLocale; + nextInitialLocale: AppLocale; +}) { + return input.previousInitialLocale !== input.nextInitialLocale + ? input.nextInitialLocale + : input.currentLocale; +} + export function AppI18nProvider( props: PropsWithChildren<{ initialLocale: AppLocale }> ) { const [locale, setLocaleState] = useState(props.initialLocale); const [isSavingLocale, setIsSavingLocale] = useState(false); const [i18n] = useState(() => createI18n(props.initialLocale)); + const previousInitialLocaleRef = useRef(props.initialLocale); useEffect(() => { - document.documentElement.lang = locale; - void i18n.changeLanguage(locale); + syncLocale(i18n, locale); }, [i18n, locale]); + useEffect(() => { + const nextLocale = getSyncedLocaleFromInitial({ + currentLocale: locale, + previousInitialLocale: previousInitialLocaleRef.current, + nextInitialLocale: props.initialLocale, + }); + previousInitialLocaleRef.current = props.initialLocale; + + if (nextLocale === locale) { + return; + } + + syncLocale(i18n, nextLocale); + setLocaleState(nextLocale); + }, [locale, props.initialLocale]); + async function setLocale(nextLocale: AppLocale) { if (nextLocale === locale) { return; } - startTransition(() => { - setLocaleState(nextLocale); - }); + syncLocale(i18n, nextLocale); + setLocaleState(nextLocale); persistExplicitDeviceLocale(nextLocale); - persistExplicitLocaleCookie(nextLocale); setIsSavingLocale(true); try { diff --git a/src/lib/i18n/config.ts b/src/lib/i18n/config.ts index 3799d21..53fc9da 100644 --- a/src/lib/i18n/config.ts +++ b/src/lib/i18n/config.ts @@ -4,11 +4,13 @@ export const localeCookieMaxAgeSeconds = 60 * 60 * 24 * 365; export const websiteNamespaces = [ "common", + "bot", "home", "search", "dashboard", "admin", "playlist", + "extension", ] as const; export type WebsiteNamespace = (typeof websiteNamespaces)[number]; diff --git a/src/lib/i18n/detect.ts b/src/lib/i18n/detect.ts index 885a85d..64f9733 100644 --- a/src/lib/i18n/detect.ts +++ b/src/lib/i18n/detect.ts @@ -1,8 +1,4 @@ -import { - localeCookieMaxAgeSeconds, - localeCookieName, - localeStorageKey, -} from "./config"; +import { localeCookieName, localeStorageKey } from "./config"; import { type AppLocale, defaultLocale, normalizeLocale } from "./locales"; export function resolveExplicitLocale(input: { @@ -52,12 +48,3 @@ export function persistExplicitDeviceLocale(locale: AppLocale) { // 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 index b010ad7..00824ce 100644 --- a/src/lib/i18n/format.ts +++ b/src/lib/i18n/format.ts @@ -15,3 +15,16 @@ export function formatNumber( ) { return new Intl.NumberFormat(locale, options).format(value); } + +export function formatCurrency( + locale: AppLocale, + value: number, + currency: string, + options?: Omit +) { + return new Intl.NumberFormat(locale, { + ...options, + style: "currency", + currency, + }).format(value); +} diff --git a/src/lib/i18n/resources.ts b/src/lib/i18n/resources.ts index 74a1b97..50bbacf 100644 --- a/src/lib/i18n/resources.ts +++ b/src/lib/i18n/resources.ts @@ -1,24 +1,32 @@ import adminEn from "./resources/en/admin.json"; +import botEn from "./resources/en/bot.json"; import commonEn from "./resources/en/common.json"; import dashboardEn from "./resources/en/dashboard.json"; +import extensionEn from "./resources/en/extension.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 botEs from "./resources/es/bot.json"; import commonEs from "./resources/es/common.json"; import dashboardEs from "./resources/es/dashboard.json"; +import extensionEs from "./resources/es/extension.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 botFr from "./resources/fr/bot.json"; import commonFr from "./resources/fr/common.json"; import dashboardFr from "./resources/fr/dashboard.json"; +import extensionFr from "./resources/fr/extension.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 botPtBr from "./resources/pt-br/bot.json"; import commonPtBr from "./resources/pt-br/common.json"; import dashboardPtBr from "./resources/pt-br/dashboard.json"; +import extensionPtBr from "./resources/pt-br/extension.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"; @@ -26,32 +34,40 @@ import searchPtBr from "./resources/pt-br/search.json"; export const i18nResources = { en: { admin: adminEn, + bot: botEn, common: commonEn, dashboard: dashboardEn, + extension: extensionEn, home: homeEn, playlist: playlistEn, search: searchEn, }, es: { admin: adminEs, + bot: botEs, common: commonEs, dashboard: dashboardEs, + extension: extensionEs, home: homeEs, playlist: playlistEs, search: searchEs, }, fr: { admin: adminFr, + bot: botFr, common: commonFr, dashboard: dashboardFr, + extension: extensionFr, home: homeFr, playlist: playlistFr, search: searchFr, }, "pt-BR": { admin: adminPtBr, + bot: botPtBr, common: commonPtBr, dashboard: dashboardPtBr, + extension: extensionPtBr, home: homePtBr, playlist: playlistPtBr, search: searchPtBr, diff --git a/src/lib/i18n/resources/en/bot.json b/src/lib/i18n/resources/en/bot.json new file mode 100644 index 0000000..f0f1991 --- /dev/null +++ b/src/lib/i18n/resources/en/bot.json @@ -0,0 +1,99 @@ +{ + "dashboard": { + "botLanguage": "Bot reply language", + "botLanguageHelp": "Choose the language used for chat replies and command help. The Twitch panel also falls back to this when a viewer does not have their own language preference." + }, + "paths": { + "lead": "Lead", + "rhythm": "Rhythm", + "bass": "Bass", + "lyrics": "Lyrics" + }, + "labels": { + "request": "request", + "regularRequest": "regular request", + "vipRequest": "VIP request" + }, + "commands": { + "how": { + "commands": "Commands: {commandPrefix}sr artist - song; {commandPrefix}sr artist *random; {commandPrefix}sr artist *choice; {commandPrefix}vip; {commandPrefix}vip artist - song; {commandPrefix}edit artist - song; {commandPrefix}remove reg|vip|all; {commandPrefix}position.", + "bassRequests": "Bass requests: add *bass to {commandPrefix}sr, {commandPrefix}vip, or {commandPrefix}edit.", + "browse": "Browse the track list and request songs here: {url}" + }, + "search": "Search the song database here: {url}", + "channelPlaylist": "You can edit or search the song database here: {url}", + "blacklist": { + "empty": "No blacklisted artists, charters, songs, or versions.", + "summary": "Artists: {artists}. Charters: {charters}. Songs: {songs}. Versions: {versions}." + }, + "setlist": { + "empty": "No setlist artists.", + "summary": "Artists: {artists}." + } + }, + "reasons": { + "requests_disabled": "Requests are disabled for this channel.", + "vip_requests_disabled": "VIP requests are disabled in this channel.", + "subscriber_requests_disabled": "Subscriber requests are disabled in this channel.", + "subscriber_or_vip_only": "Only subscribers or VIPs can request songs right now.", + "only_official_dlc": "Only official DLC requests are allowed.", + "disallowed_tuning": "That song's tuning is not allowed in this channel.", + "artist_not_in_setlist": "That artist is not in the current setlist.", + "requested_paths_not_matched": "That song does not include the requested path{count, plural, one {} other {s}}: {paths}." + }, + "replies": { + "vipPermissionDenied": "Only the broadcaster or an allowed moderator can grant VIP tokens.", + "addVipUsage": "Use !addvip or !addvip to grant VIP tokens.", + "invalidVipAmount": "Use a VIP token amount greater than 0.", + "grantedVipTokens": "Granted {countText} VIP token{count, plural, one {} other {s}} to {login}.", + "autoGrantGiftSubGifter": "Added {countText} VIP token{count, plural, one {} other {s}} to {mention} for gifting {subCountText} sub{subCount, plural, one {} other {s}}.", + "autoGrantGiftRecipient": "Added 1 VIP token to {mention} for receiving a gifted sub.", + "autoGrantNewSubscriber": "Added 1 VIP token to {mention} for a new sub.", + "autoGrantSharedSubRenewal": "Added 1 VIP token to {mention} for sharing a sub renewal message.", + "autoGrantCheer": "Added {countText} VIP token{count, plural, one {} other {s}} to {mention} for cheering {bitsText} bit{bits, plural, one {} other {s}}.", + "autoGrantRaid": "Added 1 VIP token to {mention} for raiding with {viewersText} viewer{viewers, plural, one {} other {s}}.", + "autoGrantStreamElementsTip": "Added {countText} VIP token{count, plural, one {} other {s}} to {mention} for a {amount} StreamElements tip.", + "targetPermissionDenied": "Only the broadcaster or a moderator can request for someone else.", + "twitchUserNotFound": "I couldn't find Twitch user @{login}.", + "removeUsage": "Use {commandPrefix}remove reg, {commandPrefix}remove vip, or {commandPrefix}remove all.", + "removeSuccess": "{mention} removed {count} {kindLabel}{count, plural, one {} other {s}} from this playlist.", + "removeEmpty": "{mention} you do not have any {kindLabel}{kind, select, all {s} other {}} in this playlist.", + "removeFailed": "{mention} I couldn't remove your requests right now. Please try again.", + "positionNone": "{mention} you do not have any active requests in this playlist.", + "positionPlayingNow": "playing now: {titles}", + "positionPlayingNowFallback": "playing now", + "positionQueuedAt": "queued at {positions}", + "positionQueuedFallback": "queued in the playlist", + "positionSummary": "{mention} your request{count, plural, one {} other {s}} {count, plural, one {is} other {are}} {parts}.", + "requestsDisabledNow": "Requests are disabled for this channel right now.", + "vipBalanceAvailable": "{mention} you have {countText} VIP token{count, plural, one {} other {s}} available.", + "vipBalanceSuffix": " You have {countText} VIP token{count, plural, one {} other {s}}.", + "requestDeniedFallback": "You cannot request songs in this channel right now.", + "noActiveRequestToEdit": "{mention} there is no active request to edit in this playlist.", + "timeWindowLimit": "You have reached the request limit for the next {seconds} seconds.", + "notEnoughVipTokens": "You do not have enough VIP tokens for this channel.{balanceSuffix}", + "requestQueryMissing": "{mention} include an artist or song before using request modifiers.", + "songLookupFailed": "{mention} I ran into a problem searching for that song. Please try again.", + "randomNotFound": "{mention} I couldn't find an allowed random match for \"{query}\".", + "rejectedSongBlocked": "{mention} I cannot add that song to the playlist.", + "rejectedSongReason": "{mention} {reason}", + "choiceAlreadyActive": "{mention} that streamer choice request is already in your active requests.", + "songAlreadyActive": "{mention} that song is already in your active requests.", + "activeRequestLimit": "You already have {count} active request{count, plural, one {} other {s}} in the playlist.", + "duplicateWindow": "{mention} that song was requested too recently. Please wait before requesting it again.", + "playlistFull": "{mention} the playlist is full right now.", + "choiceExistingVip": "your existing streamer choice request for \"{query}\" is now a VIP request{suffix} 1 VIP token was used.", + "choiceNewVip": "your streamer choice request for \"{query}\" has been added as a VIP request{suffix}", + "choiceExistingRegular": "your existing VIP streamer choice request for \"{query}\" is now a regular request again. 1 VIP token was refunded.", + "choiceNewRegular": "your streamer choice request for \"{query}\" has been added to the playlist.", + "existingSongVip": "{mention} your existing request \"{song}\" is now a VIP request{suffix} 1 VIP token was used.", + "existingSongRegular": "{mention} your existing VIP request \"{song}\" is now a regular request again. 1 VIP token was refunded.", + "unmatchedAdded": "{mention} there was no matching track found for \"{query}\", but I added it anyway. {playlistUrlMessage}", + "missingRequiredPathsAdded": "{mention} your song \"{song}\" has been added to the playlist, but it is missing required paths: {paths}.", + "vipSongAdded": "{mention} your VIP song \"{song}\" will play next.", + "songAdded": "{mention} your song \"{song}\" has been added to the playlist.", + "choiceAddFailed": "{mention} I couldn't add your streamer choice request right now. Please try again.", + "songAddFailedMatched": "{mention} I found a song match, but I couldn't add it to the playlist. Please try again.", + "songAddFailedUnmatched": "{mention} I couldn't find a song match, and I couldn't add the warning request to the playlist. Please try again." + } +} diff --git a/src/lib/i18n/resources/en/common.json b/src/lib/i18n/resources/en/common.json index 13f99ef..96a2c42 100644 --- a/src/lib/i18n/resources/en/common.json +++ b/src/lib/i18n/resources/en/common.json @@ -15,5 +15,10 @@ }, "language": { "label": "Language" + }, + "translationHelp": { + "button": "Help us translate!", + "title": "Help us translate!", + "messageLead": "This website was translated with AI. If you notice anything wrong, please reach out to" } } diff --git a/src/lib/i18n/resources/en/extension.json b/src/lib/i18n/resources/en/extension.json new file mode 100644 index 0000000..27601f8 --- /dev/null +++ b/src/lib/i18n/resources/en/extension.json @@ -0,0 +1,131 @@ +{ + "panel": { + "titleDefault": "Request Playlist", + "titleWithChannel": "{displayName}'s Request Playlist", + "shareIdentityButton": "Share Twitch Identity", + "requestActions": "Request actions", + "viewerStateLoading": "Viewer state is still loading.", + "shareIdentityToRequest": "Share Twitch identity to request." + }, + "vip": { + "balance": "{count, plural, one {# VIP token} other {# VIP tokens}}", + "notEnough": "Not enough VIP tokens.", + "insufficient": "You do not have enough VIP tokens.", + "redemptionDescription": "Spend 1 VIP token for your song to be placed at the top of the playlist.", + "howToEarn": "How to earn VIP tokens", + "manualOnly": "This channel grants VIP tokens manually right now.", + "ruleNewPaidSub": "New paid sub = 1 VIP token", + "ruleSharedSubRenewal": "Shared sub renewal message = 1 VIP token", + "ruleGiftSub": "Gift 1 sub = 1 VIP token to the gifter", + "ruleGiftRecipient": "Receive a gifted sub = 1 VIP token", + "ruleCheer": "Cheer {bits} bits = 1 VIP token", + "ruleMinimumCheer": "Minimum cheer: {bits} bits = {tokens} {count, plural, one {VIP token} other {VIP tokens}}.", + "ruleRaidSingle": "Raid this channel = 1 VIP token", + "ruleRaidMultiple": "Raid with {viewers}+ viewers = 1 VIP token", + "ruleTip": "StreamElements tip {amount} = 1 VIP token" + }, + "requests": { + "status": "Requests are {state}", + "statusOn": "ON", + "statusOff": "OFF", + "countUnlimited": "{count, plural, one {# request} other {# requests}}", + "countLimited": "{count}/{limit} requests", + "addWhenLive": "You can add requests when the stream goes live.", + "offRightNow": "Requests are off right now.", + "quick": "Quick request", + "randomSong": "Random song", + "streamerChoice": "Streamer choice", + "limitReached": "You already have {count} active {count, plural, one {request} other {requests}} in this playlist.", + "noPermission": "You cannot request songs right now.", + "updated": "Request updated.", + "added": "Request added.", + "removed": "Request removed.", + "changedVip": "Request changed to VIP.", + "changedRegular": "Request changed to regular.", + "editBannerLabel": "Editing request", + "editBannerDescription": "Search for a song or use the request buttons below.", + "removeConfirm": "Remove request?", + "removeFromPlaylistConfirm": "Remove from playlist?" + }, + "queue": { + "tab": "Playlist", + "tools": "Queue tools", + "empty": "Queue is empty.", + "shuffle": "Shuffle the queue.", + "shuffleBlocked": "Mark the current song played before shuffling.", + "reorderAria": "Reorder {song}", + "dragToReorder": "Drag to reorder", + "currentStays": "Current song stays in place", + "playNow": "Play now", + "returnToQueue": "Return to queue", + "markComplete": "Mark complete", + "removeFromPlaylist": "Remove from playlist", + "removeRequest": "Remove request", + "editRequest": "Edit request", + "makeRegular": "Make regular", + "makeVip": "Make VIP", + "setCurrentSuccess": "Song is now playing.", + "returnToQueueSuccess": "Song returned to queue.", + "markPlayedSuccess": "Song marked played.", + "deleteItemSuccess": "Playlist item removed.", + "shuffleSuccess": "Playlist shuffled.", + "reorderSuccess": "Playlist order updated.", + "updated": "Playlist updated." + }, + "search": { + "tab": "Search", + "placeholder": "Search title, artist, or album", + "placeholderEdit": "Search for a song to edit your request", + "noResults": "No songs matched that search.", + "unavailable": "That song is unavailable right now.", + "typeArtistOrSong": "Type an artist or song first.", + "typeAtLeastTwo": "Type at least 2 characters first.", + "searchFailed": "Unable to search songs.", + "rateLimited": "Please wait before performing another search." + }, + "buttons": { + "add": "Add", + "adding": "Adding...", + "edit": "Edit", + "editing": "Editing...", + "vip": "VIP", + "editVip": "Edit VIP", + "cancel": "Cancel" + }, + "playlistItem": { + "unknownSong": "Unknown song", + "unknownRequester": "Unknown requester", + "streamerChoice": "Streamer choice", + "streamerChoiceWithQuery": "Streamer choice: {query}", + "metaAdded": "Added {time}", + "metaEdited": "Edited {time}", + "metaLine": "{requester} · {added}", + "metaLineEdited": "{requester} · {added} · {edited}", + "now": "now", + "recent": "recent" + }, + "notices": { + "helperLoadFailed": "Unable to load the Twitch extension helper.", + "helperUnavailable": "The Twitch extension helper is unavailable.", + "authorizationHint": "Open this page from Twitch Local Test or Hosted Test to receive panel authorization.", + "panelStateLoadFailed": "Unable to load panel state.", + "panelStateRefreshFailed": "Unable to refresh panel state.", + "updateRequestFailed": "Unable to update request.", + "removeRequestFailed": "Unable to remove request.", + "updatePlaylistFailed": "Unable to update the playlist.", + "extensionRequestFailed": "Extension request failed.", + "connectingRetry": "Panel is still connecting. Retrying in {delay}.", + "connectionInterruptedRetry": "Connection interrupted. Retrying in {delay}." + }, + "access": { + "channelNotConnected": "This Twitch channel has not connected RockList.Live yet.", + "viewerProfileUnavailable": "Viewer profile could not be resolved right now.", + "shareIdentityToRequestSongs": "Share Twitch identity to request songs.", + "shareIdentityToManage": "Share Twitch identity to manage requests from the panel.", + "blocked": "You are blocked from requesting songs in this channel." + }, + "footer": { + "openPlaylist": "Open playlist on RockList.Live", + "openPlaylistForChannel": "Open {displayName}'s playlist on RockList.Live" + } +} diff --git a/src/lib/i18n/resources/es/bot.json b/src/lib/i18n/resources/es/bot.json new file mode 100644 index 0000000..d0eff8c --- /dev/null +++ b/src/lib/i18n/resources/es/bot.json @@ -0,0 +1,99 @@ +{ + "dashboard": { + "botLanguage": "Idioma de respuestas del bot", + "botLanguageHelp": "Elige el idioma que usa el bot para responder en el chat y mostrar la ayuda de comandos. El panel de Twitch también usa este idioma cuando un espectador no tiene su propia preferencia." + }, + "paths": { + "lead": "Lead", + "rhythm": "Rhythm", + "bass": "Bass", + "lyrics": "Lyrics" + }, + "labels": { + "request": "request", + "regularRequest": "regular request", + "vipRequest": "VIP request" + }, + "commands": { + "how": { + "commands": "Commands: {commandPrefix}sr artist - song; {commandPrefix}sr artist *random; {commandPrefix}sr artist *choice; {commandPrefix}vip; {commandPrefix}vip artist - song; {commandPrefix}edit artist - song; {commandPrefix}remove reg|vip|all; {commandPrefix}position.", + "bassRequests": "Bass requests: add *bass to {commandPrefix}sr, {commandPrefix}vip, or {commandPrefix}edit.", + "browse": "Browse the track list and request songs here: {url}" + }, + "search": "Busca la base de datos de canciones aquí: {url}", + "channelPlaylist": "You can edit or search the song database here: {url}", + "blacklist": { + "empty": "No blacklisted artists, charters, songs, or versions.", + "summary": "Artists: {artists}. Charters: {charters}. Songs: {songs}. Versions: {versions}." + }, + "setlist": { + "empty": "No setlist artists.", + "summary": "Artists: {artists}." + } + }, + "reasons": { + "requests_disabled": "Requests are disabled for this channel.", + "vip_requests_disabled": "VIP requests are disabled in this channel.", + "subscriber_requests_disabled": "Subscriber requests are disabled in this channel.", + "subscriber_or_vip_only": "Only subscribers or VIPs can request songs right now.", + "only_official_dlc": "Only official DLC requests are allowed.", + "disallowed_tuning": "That song's tuning is not allowed in this channel.", + "artist_not_in_setlist": "That artist is not in the current setlist.", + "requested_paths_not_matched": "That song does not include the requested path{count, plural, one {} other {s}}: {paths}." + }, + "replies": { + "vipPermissionDenied": "Only the broadcaster or an allowed moderator can grant VIP tokens.", + "addVipUsage": "Use !addvip or !addvip to grant VIP tokens.", + "invalidVipAmount": "Use a VIP token amount greater than 0.", + "grantedVipTokens": "Granted {countText} VIP token{count, plural, one {} other {s}} to {login}.", + "autoGrantGiftSubGifter": "{count, plural, one {Se agregó} other {Se agregaron}} {countText} {count, plural, one {token VIP} other {tokens VIP}} a {mention} por regalar {subCountText} {subCount, plural, one {suscripción} other {suscripciones}}.", + "autoGrantGiftRecipient": "Se agregó 1 token VIP a {mention} por recibir una suscripción de regalo.", + "autoGrantNewSubscriber": "Se agregó 1 token VIP a {mention} por una nueva suscripción.", + "autoGrantSharedSubRenewal": "Se agregó 1 token VIP a {mention} por compartir un mensaje de renovación de suscripción.", + "autoGrantCheer": "{count, plural, one {Se agregó} other {Se agregaron}} {countText} {count, plural, one {token VIP} other {tokens VIP}} a {mention} por enviar {bitsText} {bits, plural, one {bit} other {bits}}.", + "autoGrantRaid": "Se agregó 1 token VIP a {mention} por hacer raid con {viewersText} {viewers, plural, one {espectador} other {espectadores}}.", + "autoGrantStreamElementsTip": "{count, plural, one {Se agregó} other {Se agregaron}} {countText} {count, plural, one {token VIP} other {tokens VIP}} a {mention} por una propina de StreamElements de {amount}.", + "targetPermissionDenied": "Only the broadcaster or a moderator can request for someone else.", + "twitchUserNotFound": "I couldn't find Twitch user @{login}.", + "removeUsage": "Use {commandPrefix}remove reg, {commandPrefix}remove vip, or {commandPrefix}remove all.", + "removeSuccess": "{mention} removed {count} {kindLabel}{count, plural, one {} other {s}} from this playlist.", + "removeEmpty": "{mention} you do not have any {kindLabel}{kind, select, all {s} other {}} in this playlist.", + "removeFailed": "{mention} I couldn't remove your requests right now. Please try again.", + "positionNone": "{mention} you do not have any active requests in this playlist.", + "positionPlayingNow": "playing now: {titles}", + "positionPlayingNowFallback": "playing now", + "positionQueuedAt": "queued at {positions}", + "positionQueuedFallback": "queued in the playlist", + "positionSummary": "{mention} your request{count, plural, one {} other {s}} {count, plural, one {is} other {are}} {parts}.", + "requestsDisabledNow": "Las solicitudes están desactivadas para este canal en este momento.", + "vipBalanceAvailable": "{mention} tienes {countText} {count, plural, one {token VIP disponible} other {tokens VIP disponibles}}.", + "vipBalanceSuffix": " You have {countText} VIP token{count, plural, one {} other {s}}.", + "requestDeniedFallback": "You cannot request songs in this channel right now.", + "noActiveRequestToEdit": "{mention} there is no active request to edit in this playlist.", + "timeWindowLimit": "You have reached the request limit for the next {seconds} seconds.", + "notEnoughVipTokens": "You do not have enough VIP tokens for this channel.{balanceSuffix}", + "requestQueryMissing": "{mention} incluye un artista o una canción antes de usar modificadores de solicitud.", + "songLookupFailed": "{mention} I ran into a problem searching for that song. Please try again.", + "randomNotFound": "{mention} no pude encontrar una coincidencia aleatoria permitida para \"{query}\".", + "rejectedSongBlocked": "{mention} no puedo agregar esa canción a la lista.", + "rejectedSongReason": "{mention} {reason}", + "choiceAlreadyActive": "{mention} that streamer choice request is already in your active requests.", + "songAlreadyActive": "{mention} that song is already in your active requests.", + "activeRequestLimit": "You already have {count} active request{count, plural, one {} other {s}} in the playlist.", + "duplicateWindow": "{mention} that song was requested too recently. Please wait before requesting it again.", + "playlistFull": "{mention} the playlist is full right now.", + "choiceExistingVip": "your existing streamer choice request for \"{query}\" is now a VIP request{suffix} 1 VIP token was used.", + "choiceNewVip": "your streamer choice request for \"{query}\" has been added as a VIP request{suffix}", + "choiceExistingRegular": "your existing VIP streamer choice request for \"{query}\" is now a regular request again. 1 VIP token was refunded.", + "choiceNewRegular": "your streamer choice request for \"{query}\" has been added to the playlist.", + "existingSongVip": "{mention} your existing request \"{song}\" is now a VIP request{suffix} 1 VIP token was used.", + "existingSongRegular": "{mention} your existing VIP request \"{song}\" is now a regular request again. 1 VIP token was refunded.", + "unmatchedAdded": "{mention} there was no matching track found for \"{query}\", but I added it anyway. {playlistUrlMessage}", + "missingRequiredPathsAdded": "{mention} your song \"{song}\" has been added to the playlist, but it is missing required paths: {paths}.", + "vipSongAdded": "{mention} tu canción VIP \"{song}\" sonará a continuación.", + "songAdded": "{mention} tu canción \"{song}\" se agregó a la lista.", + "choiceAddFailed": "{mention} I couldn't add your streamer choice request right now. Please try again.", + "songAddFailedMatched": "{mention} I found a song match, but I couldn't add it to the playlist. Please try again.", + "songAddFailedUnmatched": "{mention} I couldn't find a song match, and I couldn't add the warning request to the playlist. Please try again." + } +} diff --git a/src/lib/i18n/resources/es/common.json b/src/lib/i18n/resources/es/common.json index 296c8f7..bbb705a 100644 --- a/src/lib/i18n/resources/es/common.json +++ b/src/lib/i18n/resources/es/common.json @@ -15,5 +15,10 @@ }, "language": { "label": "Idioma" + }, + "translationHelp": { + "button": "Ayudanos a traducir", + "title": "Ayudanos a traducir", + "messageLead": "Este sitio web fue traducido con IA. Si notas algo incorrecto, escribenos a" } } diff --git a/src/lib/i18n/resources/es/extension.json b/src/lib/i18n/resources/es/extension.json new file mode 100644 index 0000000..1738895 --- /dev/null +++ b/src/lib/i18n/resources/es/extension.json @@ -0,0 +1,131 @@ +{ + "panel": { + "titleDefault": "Lista de solicitudes", + "titleWithChannel": "Lista de solicitudes de {displayName}", + "shareIdentityButton": "Compartir identidad de Twitch", + "requestActions": "Acciones de la solicitud", + "viewerStateLoading": "El estado del espectador todavía se está cargando.", + "shareIdentityToRequest": "Comparte tu identidad de Twitch para pedir canciones." + }, + "vip": { + "balance": "{count, plural, one {# token VIP} other {# tokens VIP}}", + "notEnough": "No tienes suficientes tokens VIP.", + "insufficient": "No tienes suficientes tokens VIP.", + "redemptionDescription": "Gasta 1 token VIP para que tu canción se coloque al principio de la lista.", + "howToEarn": "Cómo ganar tokens VIP", + "manualOnly": "Este canal otorga tokens VIP manualmente por ahora.", + "ruleNewPaidSub": "Nueva suscripción de pago = 1 token VIP", + "ruleSharedSubRenewal": "Mensaje compartido de renovación de suscripción = 1 token VIP", + "ruleGiftSub": "Regalar 1 suscripción = 1 token VIP para quien la regaló", + "ruleGiftRecipient": "Recibir una suscripción de regalo = 1 token VIP", + "ruleCheer": "Cheer de {bits} bits = 1 token VIP", + "ruleMinimumCheer": "Cheer mínimo: {bits} bits = {tokens} {count, plural, one {token VIP} other {tokens VIP}}.", + "ruleRaidSingle": "Hacer raid a este canal = 1 token VIP", + "ruleRaidMultiple": "Raid con {viewers}+ espectadores = 1 token VIP", + "ruleTip": "Propina de StreamElements de {amount} = 1 token VIP" + }, + "requests": { + "status": "Las solicitudes están {state}", + "statusOn": "ACTIVAS", + "statusOff": "DESACTIVADAS", + "countUnlimited": "{count, plural, one {# solicitud} other {# solicitudes}}", + "countLimited": "{count}/{limit} solicitudes", + "addWhenLive": "Podrás agregar solicitudes cuando la transmisión esté en vivo.", + "offRightNow": "Las solicitudes están desactivadas en este momento.", + "quick": "Solicitud rápida", + "randomSong": "Canción aleatoria", + "streamerChoice": "Elección del streamer", + "limitReached": "Ya tienes {count} {count, plural, one {solicitud activa} other {solicitudes activas}} en esta lista.", + "noPermission": "No puedes pedir canciones en este momento.", + "updated": "Solicitud actualizada.", + "added": "Solicitud agregada.", + "removed": "Solicitud eliminada.", + "changedVip": "La solicitud se cambió a VIP.", + "changedRegular": "La solicitud se cambió a normal.", + "editBannerLabel": "Editando solicitud", + "editBannerDescription": "Busca una canción o usa los botones de solicitud de abajo.", + "removeConfirm": "¿Eliminar solicitud?", + "removeFromPlaylistConfirm": "¿Quitar de la lista?" + }, + "queue": { + "tab": "Lista", + "tools": "Herramientas de cola", + "empty": "La cola está vacía.", + "shuffle": "Mezclar la cola.", + "shuffleBlocked": "Marca la canción actual como reproducida antes de mezclar.", + "reorderAria": "Reordenar {song}", + "dragToReorder": "Arrastra para reordenar", + "currentStays": "La canción actual se queda en su lugar", + "playNow": "Reproducir ahora", + "returnToQueue": "Devolver a la cola", + "markComplete": "Marcar como completada", + "removeFromPlaylist": "Quitar de la lista", + "removeRequest": "Eliminar solicitud", + "editRequest": "Editar solicitud", + "makeRegular": "Cambiar a normal", + "makeVip": "Cambiar a VIP", + "setCurrentSuccess": "La canción ya se está reproduciendo.", + "returnToQueueSuccess": "La canción volvió a la cola.", + "markPlayedSuccess": "La canción se marcó como reproducida.", + "deleteItemSuccess": "El elemento se quitó de la lista.", + "shuffleSuccess": "La lista se mezcló.", + "reorderSuccess": "El orden de la lista se actualizó.", + "updated": "La lista se actualizó." + }, + "search": { + "tab": "Buscar", + "placeholder": "Busca por título, artista o álbum", + "placeholderEdit": "Busca una canción para editar tu solicitud", + "noResults": "No se encontraron canciones con esa búsqueda.", + "unavailable": "Esa canción no está disponible en este momento.", + "typeArtistOrSong": "Escribe primero un artista o una canción.", + "typeAtLeastTwo": "Escribe al menos 2 caracteres primero.", + "searchFailed": "No se pudieron buscar canciones.", + "rateLimited": "Espera un momento antes de hacer otra búsqueda." + }, + "buttons": { + "add": "Agregar", + "adding": "Agregando...", + "edit": "Editar", + "editing": "Editando...", + "vip": "VIP", + "editVip": "Editar VIP", + "cancel": "Cancelar" + }, + "playlistItem": { + "unknownSong": "Canción desconocida", + "unknownRequester": "Solicitante desconocido", + "streamerChoice": "Elección del streamer", + "streamerChoiceWithQuery": "Elección del streamer: {query}", + "metaAdded": "Agregada {time}", + "metaEdited": "Editada {time}", + "metaLine": "{requester} · {added}", + "metaLineEdited": "{requester} · {added} · {edited}", + "now": "ahora", + "recent": "reciente" + }, + "notices": { + "helperLoadFailed": "No se pudo cargar el helper de la extensión de Twitch.", + "helperUnavailable": "El helper de la extensión de Twitch no está disponible.", + "authorizationHint": "Abre esta página desde Twitch Local Test o Hosted Test para recibir la autorización del panel.", + "panelStateLoadFailed": "No se pudo cargar el estado del panel.", + "panelStateRefreshFailed": "No se pudo actualizar el estado del panel.", + "updateRequestFailed": "No se pudo actualizar la solicitud.", + "removeRequestFailed": "No se pudo eliminar la solicitud.", + "updatePlaylistFailed": "No se pudo actualizar la lista.", + "extensionRequestFailed": "La solicitud de la extensión falló.", + "connectingRetry": "El panel todavía se está conectando. Reintentando en {delay}.", + "connectionInterruptedRetry": "La conexión se interrumpió. Reintentando en {delay}." + }, + "access": { + "channelNotConnected": "Este canal de Twitch todavía no ha conectado RockList.Live.", + "viewerProfileUnavailable": "El perfil del espectador no se pudo resolver en este momento.", + "shareIdentityToRequestSongs": "Comparte tu identidad de Twitch para pedir canciones.", + "shareIdentityToManage": "Comparte tu identidad de Twitch para administrar solicitudes desde el panel.", + "blocked": "No puedes pedir canciones en este canal." + }, + "footer": { + "openPlaylist": "Abrir lista en RockList.Live", + "openPlaylistForChannel": "Abrir la lista de {displayName} en RockList.Live" + } +} diff --git a/src/lib/i18n/resources/fr/bot.json b/src/lib/i18n/resources/fr/bot.json new file mode 100644 index 0000000..c13a42d --- /dev/null +++ b/src/lib/i18n/resources/fr/bot.json @@ -0,0 +1,99 @@ +{ + "dashboard": { + "botLanguage": "Langue des réponses du bot", + "botLanguageHelp": "Choisissez la langue utilisée pour les réponses dans le chat et l’aide des commandes. Le panneau Twitch utilise aussi cette langue par défaut quand un spectateur n’a pas sa propre préférence." + }, + "paths": { + "lead": "Lead", + "rhythm": "Rhythm", + "bass": "Bass", + "lyrics": "Lyrics" + }, + "labels": { + "request": "request", + "regularRequest": "regular request", + "vipRequest": "VIP request" + }, + "commands": { + "how": { + "commands": "Commands: {commandPrefix}sr artist - song; {commandPrefix}sr artist *random; {commandPrefix}sr artist *choice; {commandPrefix}vip; {commandPrefix}vip artist - song; {commandPrefix}edit artist - song; {commandPrefix}remove reg|vip|all; {commandPrefix}position.", + "bassRequests": "Bass requests: add *bass to {commandPrefix}sr, {commandPrefix}vip, or {commandPrefix}edit.", + "browse": "Browse the track list and request songs here: {url}" + }, + "search": "Recherchez dans la base de chansons ici : {url}", + "channelPlaylist": "You can edit or search the song database here: {url}", + "blacklist": { + "empty": "No blacklisted artists, charters, songs, or versions.", + "summary": "Artists: {artists}. Charters: {charters}. Songs: {songs}. Versions: {versions}." + }, + "setlist": { + "empty": "No setlist artists.", + "summary": "Artists: {artists}." + } + }, + "reasons": { + "requests_disabled": "Requests are disabled for this channel.", + "vip_requests_disabled": "VIP requests are disabled in this channel.", + "subscriber_requests_disabled": "Subscriber requests are disabled in this channel.", + "subscriber_or_vip_only": "Only subscribers or VIPs can request songs right now.", + "only_official_dlc": "Only official DLC requests are allowed.", + "disallowed_tuning": "That song's tuning is not allowed in this channel.", + "artist_not_in_setlist": "That artist is not in the current setlist.", + "requested_paths_not_matched": "That song does not include the requested path{count, plural, one {} other {s}}: {paths}." + }, + "replies": { + "vipPermissionDenied": "Only the broadcaster or an allowed moderator can grant VIP tokens.", + "addVipUsage": "Use !addvip or !addvip to grant VIP tokens.", + "invalidVipAmount": "Use a VIP token amount greater than 0.", + "grantedVipTokens": "Granted {countText} VIP token{count, plural, one {} other {s}} to {login}.", + "autoGrantGiftSubGifter": "{mention} reçoit {countText} {count, plural, one {jeton VIP} other {jetons VIP}} pour avoir offert {subCountText} {subCount, plural, one {abonnement} other {abonnements}}.", + "autoGrantGiftRecipient": "{mention} reçoit 1 jeton VIP pour avoir reçu un abonnement offert.", + "autoGrantNewSubscriber": "{mention} reçoit 1 jeton VIP pour un nouvel abonnement.", + "autoGrantSharedSubRenewal": "{mention} reçoit 1 jeton VIP pour avoir partagé un message de renouvellement d'abonnement.", + "autoGrantCheer": "{mention} reçoit {countText} {count, plural, one {jeton VIP} other {jetons VIP}} pour {bitsText} {bits, plural, one {bit envoyé} other {bits envoyés}}.", + "autoGrantRaid": "{mention} reçoit 1 jeton VIP pour un raid de {viewersText} {viewers, plural, one {spectateur} other {spectateurs}}.", + "autoGrantStreamElementsTip": "{mention} reçoit {countText} {count, plural, one {jeton VIP} other {jetons VIP}} pour un tip StreamElements de {amount}.", + "targetPermissionDenied": "Only the broadcaster or a moderator can request for someone else.", + "twitchUserNotFound": "I couldn't find Twitch user @{login}.", + "removeUsage": "Use {commandPrefix}remove reg, {commandPrefix}remove vip, or {commandPrefix}remove all.", + "removeSuccess": "{mention} removed {count} {kindLabel}{count, plural, one {} other {s}} from this playlist.", + "removeEmpty": "{mention} you do not have any {kindLabel}{kind, select, all {s} other {}} in this playlist.", + "removeFailed": "{mention} I couldn't remove your requests right now. Please try again.", + "positionNone": "{mention} you do not have any active requests in this playlist.", + "positionPlayingNow": "playing now: {titles}", + "positionPlayingNowFallback": "playing now", + "positionQueuedAt": "queued at {positions}", + "positionQueuedFallback": "queued in the playlist", + "positionSummary": "{mention} your request{count, plural, one {} other {s}} {count, plural, one {is} other {are}} {parts}.", + "requestsDisabledNow": "Les demandes sont désactivées pour cette chaîne pour le moment.", + "vipBalanceAvailable": "{mention} vous avez {countText} {count, plural, one {jeton VIP disponible} other {jetons VIP disponibles}}.", + "vipBalanceSuffix": " You have {countText} VIP token{count, plural, one {} other {s}}.", + "requestDeniedFallback": "You cannot request songs in this channel right now.", + "noActiveRequestToEdit": "{mention} there is no active request to edit in this playlist.", + "timeWindowLimit": "You have reached the request limit for the next {seconds} seconds.", + "notEnoughVipTokens": "You do not have enough VIP tokens for this channel.{balanceSuffix}", + "requestQueryMissing": "{mention} ajoutez un artiste ou une chanson avant d'utiliser les modificateurs de demande.", + "songLookupFailed": "{mention} I ran into a problem searching for that song. Please try again.", + "randomNotFound": "{mention} je n'ai pas trouvé de correspondance aléatoire autorisée pour \"{query}\".", + "rejectedSongBlocked": "{mention} je ne peux pas ajouter cette chanson à la playlist.", + "rejectedSongReason": "{mention} {reason}", + "choiceAlreadyActive": "{mention} that streamer choice request is already in your active requests.", + "songAlreadyActive": "{mention} that song is already in your active requests.", + "activeRequestLimit": "You already have {count} active request{count, plural, one {} other {s}} in the playlist.", + "duplicateWindow": "{mention} that song was requested too recently. Please wait before requesting it again.", + "playlistFull": "{mention} the playlist is full right now.", + "choiceExistingVip": "your existing streamer choice request for \"{query}\" is now a VIP request{suffix} 1 VIP token was used.", + "choiceNewVip": "your streamer choice request for \"{query}\" has been added as a VIP request{suffix}", + "choiceExistingRegular": "your existing VIP streamer choice request for \"{query}\" is now a regular request again. 1 VIP token was refunded.", + "choiceNewRegular": "your streamer choice request for \"{query}\" has been added to the playlist.", + "existingSongVip": "{mention} your existing request \"{song}\" is now a VIP request{suffix} 1 VIP token was used.", + "existingSongRegular": "{mention} your existing VIP request \"{song}\" is now a regular request again. 1 VIP token was refunded.", + "unmatchedAdded": "{mention} there was no matching track found for \"{query}\", but I added it anyway. {playlistUrlMessage}", + "missingRequiredPathsAdded": "{mention} your song \"{song}\" has been added to the playlist, but it is missing required paths: {paths}.", + "vipSongAdded": "{mention} votre chanson VIP \"{song}\" sera jouée ensuite.", + "songAdded": "{mention} votre chanson \"{song}\" a été ajoutée à la playlist.", + "choiceAddFailed": "{mention} I couldn't add your streamer choice request right now. Please try again.", + "songAddFailedMatched": "{mention} I found a song match, but I couldn't add it to the playlist. Please try again.", + "songAddFailedUnmatched": "{mention} I couldn't find a song match, and I couldn't add the warning request to the playlist. Please try again." + } +} diff --git a/src/lib/i18n/resources/fr/common.json b/src/lib/i18n/resources/fr/common.json index 7c9b300..2d8f4f2 100644 --- a/src/lib/i18n/resources/fr/common.json +++ b/src/lib/i18n/resources/fr/common.json @@ -15,5 +15,10 @@ }, "language": { "label": "Langue" + }, + "translationHelp": { + "button": "Aidez-nous a traduire", + "title": "Aidez-nous a traduire", + "messageLead": "Ce site a ete traduit avec l'IA. Si vous remarquez quelque chose d'incorrect, ecrivez a" } } diff --git a/src/lib/i18n/resources/fr/extension.json b/src/lib/i18n/resources/fr/extension.json new file mode 100644 index 0000000..918f114 --- /dev/null +++ b/src/lib/i18n/resources/fr/extension.json @@ -0,0 +1,131 @@ +{ + "panel": { + "titleDefault": "Playlist des demandes", + "titleWithChannel": "Playlist des demandes de {displayName}", + "shareIdentityButton": "Partager l'identite Twitch", + "requestActions": "Actions de la demande", + "viewerStateLoading": "L'etat du spectateur est encore en cours de chargement.", + "shareIdentityToRequest": "Partagez votre identite Twitch pour demander une chanson." + }, + "vip": { + "balance": "{count, plural, one {# jeton VIP} other {# jetons VIP}}", + "notEnough": "Vous n'avez pas assez de jetons VIP.", + "insufficient": "Vous n'avez pas assez de jetons VIP.", + "redemptionDescription": "Depensez 1 jeton VIP pour placer votre chanson en tete de playlist.", + "howToEarn": "Comment gagner des jetons VIP", + "manualOnly": "Cette chaine attribue actuellement les jetons VIP manuellement.", + "ruleNewPaidSub": "Nouvel abonnement payant = 1 jeton VIP", + "ruleSharedSubRenewal": "Message partage de renouvellement d'abonnement = 1 jeton VIP", + "ruleGiftSub": "Offrir 1 abonnement = 1 jeton VIP pour la personne qui l'offre", + "ruleGiftRecipient": "Recevoir un abonnement offert = 1 jeton VIP", + "ruleCheer": "Cheer de {bits} bits = 1 jeton VIP", + "ruleMinimumCheer": "Cheer minimum : {bits} bits = {tokens} {count, plural, one {jeton VIP} other {jetons VIP}}.", + "ruleRaidSingle": "Raider cette chaine = 1 jeton VIP", + "ruleRaidMultiple": "Raid avec {viewers}+ spectateurs = 1 jeton VIP", + "ruleTip": "Tip StreamElements de {amount} = 1 jeton VIP" + }, + "requests": { + "status": "Les demandes sont {state}", + "statusOn": "ACTIVES", + "statusOff": "DESACTIVEES", + "countUnlimited": "{count, plural, one {# demande} other {# demandes}}", + "countLimited": "{count}/{limit} demandes", + "addWhenLive": "Vous pourrez ajouter des demandes quand le stream sera en direct.", + "offRightNow": "Les demandes sont desactivees pour le moment.", + "quick": "Demande rapide", + "randomSong": "Chanson aleatoire", + "streamerChoice": "Choix du streamer", + "limitReached": "Vous avez deja {count} {count, plural, one {demande active} other {demandes actives}} dans cette playlist.", + "noPermission": "Vous ne pouvez pas demander de chansons pour le moment.", + "updated": "Demande mise a jour.", + "added": "Demande ajoutee.", + "removed": "Demande supprimee.", + "changedVip": "La demande est devenue VIP.", + "changedRegular": "La demande est redevenue normale.", + "editBannerLabel": "Modification de la demande", + "editBannerDescription": "Recherchez une chanson ou utilisez les boutons de demande ci-dessous.", + "removeConfirm": "Supprimer la demande ?", + "removeFromPlaylistConfirm": "Retirer de la playlist ?" + }, + "queue": { + "tab": "Playlist", + "tools": "Outils de file", + "empty": "La file est vide.", + "shuffle": "Melanger la file.", + "shuffleBlocked": "Marquez d'abord la chanson en cours comme jouee avant de melanger.", + "reorderAria": "Reordonner {song}", + "dragToReorder": "Glisser pour reordonner", + "currentStays": "La chanson en cours reste en place", + "playNow": "Lire maintenant", + "returnToQueue": "Remettre dans la file", + "markComplete": "Marquer comme terminee", + "removeFromPlaylist": "Retirer de la playlist", + "removeRequest": "Supprimer la demande", + "editRequest": "Modifier la demande", + "makeRegular": "Passer en normal", + "makeVip": "Passer en VIP", + "setCurrentSuccess": "La chanson est en lecture.", + "returnToQueueSuccess": "La chanson est retournee dans la file.", + "markPlayedSuccess": "La chanson a ete marquee comme jouee.", + "deleteItemSuccess": "L'element a ete retire de la playlist.", + "shuffleSuccess": "La playlist a ete melangee.", + "reorderSuccess": "L'ordre de la playlist a ete mis a jour.", + "updated": "La playlist a ete mise a jour." + }, + "search": { + "tab": "Recherche", + "placeholder": "Rechercher par titre, artiste ou album", + "placeholderEdit": "Rechercher une chanson pour modifier votre demande", + "noResults": "Aucune chanson ne correspond a cette recherche.", + "unavailable": "Cette chanson n'est pas disponible pour le moment.", + "typeArtistOrSong": "Saisissez d'abord un artiste ou une chanson.", + "typeAtLeastTwo": "Saisissez au moins 2 caracteres.", + "searchFailed": "Impossible de rechercher des chansons.", + "rateLimited": "Veuillez patienter avant d'effectuer une nouvelle recherche." + }, + "buttons": { + "add": "Ajouter", + "adding": "Ajout...", + "edit": "Modifier", + "editing": "Modification...", + "vip": "VIP", + "editVip": "Modifier VIP", + "cancel": "Annuler" + }, + "playlistItem": { + "unknownSong": "Chanson inconnue", + "unknownRequester": "Demandeur inconnu", + "streamerChoice": "Choix du streamer", + "streamerChoiceWithQuery": "Choix du streamer : {query}", + "metaAdded": "Ajoutee {time}", + "metaEdited": "Modifiee {time}", + "metaLine": "{requester} · {added}", + "metaLineEdited": "{requester} · {added} · {edited}", + "now": "maintenant", + "recent": "recent" + }, + "notices": { + "helperLoadFailed": "Impossible de charger le helper d'extension Twitch.", + "helperUnavailable": "Le helper d'extension Twitch est indisponible.", + "authorizationHint": "Ouvrez cette page depuis Twitch Local Test ou Hosted Test pour recevoir l'autorisation du panneau.", + "panelStateLoadFailed": "Impossible de charger l'etat du panneau.", + "panelStateRefreshFailed": "Impossible d'actualiser l'etat du panneau.", + "updateRequestFailed": "Impossible de mettre a jour la demande.", + "removeRequestFailed": "Impossible de supprimer la demande.", + "updatePlaylistFailed": "Impossible de mettre a jour la playlist.", + "extensionRequestFailed": "La requete de l'extension a echoue.", + "connectingRetry": "Le panneau est encore en cours de connexion. Nouvelle tentative dans {delay}.", + "connectionInterruptedRetry": "La connexion a ete interrompue. Nouvelle tentative dans {delay}." + }, + "access": { + "channelNotConnected": "Cette chaine Twitch n'a pas encore connecte RockList.Live.", + "viewerProfileUnavailable": "Le profil du spectateur n'a pas pu etre resolu pour le moment.", + "shareIdentityToRequestSongs": "Partagez votre identite Twitch pour demander des chansons.", + "shareIdentityToManage": "Partagez votre identite Twitch pour gerer les demandes depuis le panneau.", + "blocked": "Vous ne pouvez pas demander de chansons sur cette chaine." + }, + "footer": { + "openPlaylist": "Ouvrir la playlist sur RockList.Live", + "openPlaylistForChannel": "Ouvrir la playlist de {displayName} sur RockList.Live" + } +} diff --git a/src/lib/i18n/resources/pt-br/bot.json b/src/lib/i18n/resources/pt-br/bot.json new file mode 100644 index 0000000..1548336 --- /dev/null +++ b/src/lib/i18n/resources/pt-br/bot.json @@ -0,0 +1,99 @@ +{ + "dashboard": { + "botLanguage": "Idioma das respostas do bot", + "botLanguageHelp": "Escolha o idioma usado nas respostas do chat e na ajuda dos comandos. O painel da Twitch também usa esse idioma como padrão quando o espectador não tem a propria preferencia." + }, + "paths": { + "lead": "Lead", + "rhythm": "Rhythm", + "bass": "Bass", + "lyrics": "Lyrics" + }, + "labels": { + "request": "request", + "regularRequest": "regular request", + "vipRequest": "VIP request" + }, + "commands": { + "how": { + "commands": "Commands: {commandPrefix}sr artist - song; {commandPrefix}sr artist *random; {commandPrefix}sr artist *choice; {commandPrefix}vip; {commandPrefix}vip artist - song; {commandPrefix}edit artist - song; {commandPrefix}remove reg|vip|all; {commandPrefix}position.", + "bassRequests": "Bass requests: add *bass to {commandPrefix}sr, {commandPrefix}vip, or {commandPrefix}edit.", + "browse": "Browse the track list and request songs here: {url}" + }, + "search": "Pesquise no banco de dados de músicas aqui: {url}", + "channelPlaylist": "You can edit or search the song database here: {url}", + "blacklist": { + "empty": "No blacklisted artists, charters, songs, or versions.", + "summary": "Artists: {artists}. Charters: {charters}. Songs: {songs}. Versions: {versions}." + }, + "setlist": { + "empty": "No setlist artists.", + "summary": "Artists: {artists}." + } + }, + "reasons": { + "requests_disabled": "Requests are disabled for this channel.", + "vip_requests_disabled": "VIP requests are disabled in this channel.", + "subscriber_requests_disabled": "Subscriber requests are disabled in this channel.", + "subscriber_or_vip_only": "Only subscribers or VIPs can request songs right now.", + "only_official_dlc": "Only official DLC requests are allowed.", + "disallowed_tuning": "That song's tuning is not allowed in this channel.", + "artist_not_in_setlist": "That artist is not in the current setlist.", + "requested_paths_not_matched": "That song does not include the requested path{count, plural, one {} other {s}}: {paths}." + }, + "replies": { + "vipPermissionDenied": "Only the broadcaster or an allowed moderator can grant VIP tokens.", + "addVipUsage": "Use !addvip or !addvip to grant VIP tokens.", + "invalidVipAmount": "Use a VIP token amount greater than 0.", + "grantedVipTokens": "Granted {countText} VIP token{count, plural, one {} other {s}} to {login}.", + "autoGrantGiftSubGifter": "{mention} recebeu {countText} {count, plural, one {token VIP} other {tokens VIP}} por presentear {subCountText} {subCount, plural, one {inscrição} other {inscrições}}.", + "autoGrantGiftRecipient": "{mention} recebeu 1 token VIP por receber uma inscrição presenteada.", + "autoGrantNewSubscriber": "{mention} recebeu 1 token VIP por uma nova inscrição.", + "autoGrantSharedSubRenewal": "{mention} recebeu 1 token VIP por compartilhar uma mensagem de renovação de inscrição.", + "autoGrantCheer": "{mention} recebeu {countText} {count, plural, one {token VIP} other {tokens VIP}} por enviar {bitsText} {bits, plural, one {bit} other {bits}}.", + "autoGrantRaid": "{mention} recebeu 1 token VIP por fazer raid com {viewersText} {viewers, plural, one {espectador} other {espectadores}}.", + "autoGrantStreamElementsTip": "{mention} recebeu {countText} {count, plural, one {token VIP} other {tokens VIP}} por uma gorjeta do StreamElements de {amount}.", + "targetPermissionDenied": "Only the broadcaster or a moderator can request for someone else.", + "twitchUserNotFound": "I couldn't find Twitch user @{login}.", + "removeUsage": "Use {commandPrefix}remove reg, {commandPrefix}remove vip, or {commandPrefix}remove all.", + "removeSuccess": "{mention} removed {count} {kindLabel}{count, plural, one {} other {s}} from this playlist.", + "removeEmpty": "{mention} you do not have any {kindLabel}{kind, select, all {s} other {}} in this playlist.", + "removeFailed": "{mention} I couldn't remove your requests right now. Please try again.", + "positionNone": "{mention} you do not have any active requests in this playlist.", + "positionPlayingNow": "playing now: {titles}", + "positionPlayingNowFallback": "playing now", + "positionQueuedAt": "queued at {positions}", + "positionQueuedFallback": "queued in the playlist", + "positionSummary": "{mention} your request{count, plural, one {} other {s}} {count, plural, one {is} other {are}} {parts}.", + "requestsDisabledNow": "Os pedidos estão desativados para este canal no momento.", + "vipBalanceAvailable": "{mention} você tem {countText} {count, plural, one {token VIP disponível} other {tokens VIP disponíveis}}.", + "vipBalanceSuffix": " You have {countText} VIP token{count, plural, one {} other {s}}.", + "requestDeniedFallback": "You cannot request songs in this channel right now.", + "noActiveRequestToEdit": "{mention} there is no active request to edit in this playlist.", + "timeWindowLimit": "You have reached the request limit for the next {seconds} seconds.", + "notEnoughVipTokens": "You do not have enough VIP tokens for this channel.{balanceSuffix}", + "requestQueryMissing": "{mention} inclua um artista ou música antes de usar modificadores de pedido.", + "songLookupFailed": "{mention} I ran into a problem searching for that song. Please try again.", + "randomNotFound": "{mention} não encontrei uma combinação aleatória permitida para \"{query}\".", + "rejectedSongBlocked": "{mention} não consigo adicionar essa música à lista.", + "rejectedSongReason": "{mention} {reason}", + "choiceAlreadyActive": "{mention} that streamer choice request is already in your active requests.", + "songAlreadyActive": "{mention} that song is already in your active requests.", + "activeRequestLimit": "You already have {count} active request{count, plural, one {} other {s}} in the playlist.", + "duplicateWindow": "{mention} that song was requested too recently. Please wait before requesting it again.", + "playlistFull": "{mention} the playlist is full right now.", + "choiceExistingVip": "your existing streamer choice request for \"{query}\" is now a VIP request{suffix} 1 VIP token was used.", + "choiceNewVip": "your streamer choice request for \"{query}\" has been added as a VIP request{suffix}", + "choiceExistingRegular": "your existing VIP streamer choice request for \"{query}\" is now a regular request again. 1 VIP token was refunded.", + "choiceNewRegular": "your streamer choice request for \"{query}\" has been added to the playlist.", + "existingSongVip": "{mention} your existing request \"{song}\" is now a VIP request{suffix} 1 VIP token was used.", + "existingSongRegular": "{mention} your existing VIP request \"{song}\" is now a regular request again. 1 VIP token was refunded.", + "unmatchedAdded": "{mention} there was no matching track found for \"{query}\", but I added it anyway. {playlistUrlMessage}", + "missingRequiredPathsAdded": "{mention} your song \"{song}\" has been added to the playlist, but it is missing required paths: {paths}.", + "vipSongAdded": "{mention} sua música VIP \"{song}\" vai tocar em seguida.", + "songAdded": "{mention} sua música \"{song}\" foi adicionada à lista.", + "choiceAddFailed": "{mention} I couldn't add your streamer choice request right now. Please try again.", + "songAddFailedMatched": "{mention} I found a song match, but I couldn't add it to the playlist. Please try again.", + "songAddFailedUnmatched": "{mention} I couldn't find a song match, and I couldn't add the warning request to the playlist. Please try again." + } +} diff --git a/src/lib/i18n/resources/pt-br/common.json b/src/lib/i18n/resources/pt-br/common.json index 477f8f2..7faff7f 100644 --- a/src/lib/i18n/resources/pt-br/common.json +++ b/src/lib/i18n/resources/pt-br/common.json @@ -15,5 +15,10 @@ }, "language": { "label": "Idioma" + }, + "translationHelp": { + "button": "Ajude a traduzir", + "title": "Ajude a traduzir", + "messageLead": "Este site foi traduzido com IA. Se voce notar algo errado, escreva para" } } diff --git a/src/lib/i18n/resources/pt-br/extension.json b/src/lib/i18n/resources/pt-br/extension.json new file mode 100644 index 0000000..a6ed6db --- /dev/null +++ b/src/lib/i18n/resources/pt-br/extension.json @@ -0,0 +1,131 @@ +{ + "panel": { + "titleDefault": "Playlist de pedidos", + "titleWithChannel": "Playlist de pedidos de {displayName}", + "shareIdentityButton": "Compartilhar identidade da Twitch", + "requestActions": "Acoes do pedido", + "viewerStateLoading": "O estado do espectador ainda esta carregando.", + "shareIdentityToRequest": "Compartilhe sua identidade da Twitch para pedir musicas." + }, + "vip": { + "balance": "{count, plural, one {# token VIP} other {# tokens VIP}}", + "notEnough": "Voce nao tem tokens VIP suficientes.", + "insufficient": "Voce nao tem tokens VIP suficientes.", + "redemptionDescription": "Gaste 1 token VIP para colocar sua musica no topo da playlist.", + "howToEarn": "Como ganhar tokens VIP", + "manualOnly": "Este canal concede tokens VIP manualmente no momento.", + "ruleNewPaidSub": "Nova inscricao paga = 1 token VIP", + "ruleSharedSubRenewal": "Mensagem compartilhada de renovacao de inscricao = 1 token VIP", + "ruleGiftSub": "Presentear 1 inscricao = 1 token VIP para quem presenteou", + "ruleGiftRecipient": "Receber uma inscricao presenteada = 1 token VIP", + "ruleCheer": "Cheer de {bits} bits = 1 token VIP", + "ruleMinimumCheer": "Cheer minimo: {bits} bits = {tokens} {count, plural, one {token VIP} other {tokens VIP}}.", + "ruleRaidSingle": "Raid neste canal = 1 token VIP", + "ruleRaidMultiple": "Raid com {viewers}+ espectadores = 1 token VIP", + "ruleTip": "Gorjeta do StreamElements de {amount} = 1 token VIP" + }, + "requests": { + "status": "Os pedidos estao {state}", + "statusOn": "ATIVOS", + "statusOff": "DESATIVADOS", + "countUnlimited": "{count, plural, one {# pedido} other {# pedidos}}", + "countLimited": "{count}/{limit} pedidos", + "addWhenLive": "Voce podera adicionar pedidos quando a transmissao estiver ao vivo.", + "offRightNow": "Os pedidos estao desativados agora.", + "quick": "Pedido rapido", + "randomSong": "Musica aleatoria", + "streamerChoice": "Escolha do streamer", + "limitReached": "Voce ja tem {count} {count, plural, one {pedido ativo} other {pedidos ativos}} nesta playlist.", + "noPermission": "Voce nao pode pedir musicas agora.", + "updated": "Pedido atualizado.", + "added": "Pedido adicionado.", + "removed": "Pedido removido.", + "changedVip": "O pedido foi alterado para VIP.", + "changedRegular": "O pedido foi alterado para normal.", + "editBannerLabel": "Editando pedido", + "editBannerDescription": "Procure uma musica ou use os botoes de pedido abaixo.", + "removeConfirm": "Remover pedido?", + "removeFromPlaylistConfirm": "Remover da playlist?" + }, + "queue": { + "tab": "Playlist", + "tools": "Ferramentas da fila", + "empty": "A fila esta vazia.", + "shuffle": "Embaralhar a fila.", + "shuffleBlocked": "Marque a musica atual como tocada antes de embaralhar.", + "reorderAria": "Reordenar {song}", + "dragToReorder": "Arraste para reordenar", + "currentStays": "A musica atual permanece no lugar", + "playNow": "Tocar agora", + "returnToQueue": "Voltar para a fila", + "markComplete": "Marcar como concluida", + "removeFromPlaylist": "Remover da playlist", + "removeRequest": "Remover pedido", + "editRequest": "Editar pedido", + "makeRegular": "Tornar normal", + "makeVip": "Tornar VIP", + "setCurrentSuccess": "A musica esta tocando agora.", + "returnToQueueSuccess": "A musica voltou para a fila.", + "markPlayedSuccess": "A musica foi marcada como tocada.", + "deleteItemSuccess": "O item foi removido da playlist.", + "shuffleSuccess": "A playlist foi embaralhada.", + "reorderSuccess": "A ordem da playlist foi atualizada.", + "updated": "A playlist foi atualizada." + }, + "search": { + "tab": "Buscar", + "placeholder": "Buscar por titulo, artista ou album", + "placeholderEdit": "Buscar uma musica para editar seu pedido", + "noResults": "Nenhuma musica correspondeu a essa busca.", + "unavailable": "Essa musica nao esta disponivel agora.", + "typeArtistOrSong": "Digite primeiro um artista ou uma musica.", + "typeAtLeastTwo": "Digite pelo menos 2 caracteres primeiro.", + "searchFailed": "Nao foi possivel buscar musicas.", + "rateLimited": "Aguarde um momento antes de fazer outra busca." + }, + "buttons": { + "add": "Adicionar", + "adding": "Adicionando...", + "edit": "Editar", + "editing": "Editando...", + "vip": "VIP", + "editVip": "Editar VIP", + "cancel": "Cancelar" + }, + "playlistItem": { + "unknownSong": "Musica desconhecida", + "unknownRequester": "Solicitante desconhecido", + "streamerChoice": "Escolha do streamer", + "streamerChoiceWithQuery": "Escolha do streamer: {query}", + "metaAdded": "Adicionado {time}", + "metaEdited": "Editado {time}", + "metaLine": "{requester} · {added}", + "metaLineEdited": "{requester} · {added} · {edited}", + "now": "agora", + "recent": "recente" + }, + "notices": { + "helperLoadFailed": "Nao foi possivel carregar o helper da extensao da Twitch.", + "helperUnavailable": "O helper da extensao da Twitch nao esta disponivel.", + "authorizationHint": "Abra esta pagina pelo Twitch Local Test ou Hosted Test para receber a autorizacao do painel.", + "panelStateLoadFailed": "Nao foi possivel carregar o estado do painel.", + "panelStateRefreshFailed": "Nao foi possivel atualizar o estado do painel.", + "updateRequestFailed": "Nao foi possivel atualizar o pedido.", + "removeRequestFailed": "Nao foi possivel remover o pedido.", + "updatePlaylistFailed": "Nao foi possivel atualizar a playlist.", + "extensionRequestFailed": "A requisicao da extensao falhou.", + "connectingRetry": "O painel ainda esta se conectando. Tentando novamente em {delay}.", + "connectionInterruptedRetry": "A conexao foi interrompida. Tentando novamente em {delay}." + }, + "access": { + "channelNotConnected": "Este canal da Twitch ainda nao conectou o RockList.Live.", + "viewerProfileUnavailable": "Nao foi possivel resolver o perfil do espectador agora.", + "shareIdentityToRequestSongs": "Compartilhe sua identidade da Twitch para pedir musicas.", + "shareIdentityToManage": "Compartilhe sua identidade da Twitch para gerenciar pedidos pelo painel.", + "blocked": "Voce nao pode pedir musicas neste canal." + }, + "footer": { + "openPlaylist": "Abrir playlist no RockList.Live", + "openPlaylistForChannel": "Abrir a playlist de {displayName} no RockList.Live" + } +} diff --git a/src/lib/i18n/server.ts b/src/lib/i18n/server.ts index 5180975..bf94d22 100644 --- a/src/lib/i18n/server.ts +++ b/src/lib/i18n/server.ts @@ -1,6 +1,10 @@ +import { createInstance } from "i18next"; +import ICU from "i18next-icu"; import { getLocaleCookie } from "~/lib/auth/session.server"; import type { AppEnv } from "~/lib/env"; import { resolveExplicitLocale } from "./detect"; +import { getI18nInitOptions } from "./init"; +import { defaultLocale, normalizeLocale } from "./locales"; export function resolveRequestLocale( request: Request, @@ -12,3 +16,18 @@ export function resolveRequestLocale( storedLocale: getLocaleCookie(request), }); } + +export function getServerTranslation( + locale: string | null | undefined, + namespace?: string | string[] +) { + const resolvedLocale = normalizeLocale(locale) ?? defaultLocale; + const instance = createInstance(); + + void instance.use(ICU).init(getI18nInitOptions(resolvedLocale)); + + return { + locale: resolvedLocale, + t: instance.getFixedT(resolvedLocale, namespace), + }; +} diff --git a/src/lib/request-policy.ts b/src/lib/request-policy.ts index 6b89b56..ddd16aa 100644 --- a/src/lib/request-policy.ts +++ b/src/lib/request-policy.ts @@ -7,6 +7,8 @@ import { } from "./channel-blacklist"; import type { SongSearchResult } from "./song-search/types"; +type Translate = (key: string, options?: Record) => string; + function normalize(value: string | undefined | null) { return (value ?? "").trim().toLowerCase(); } @@ -79,6 +81,7 @@ export function isRequesterAllowed( return { allowed: false, reason: "Requests are disabled for this channel.", + reasonCode: "requests_disabled", }; } @@ -91,6 +94,7 @@ export function isRequesterAllowed( return { allowed: false, reason: "VIP requests are disabled in this channel.", + reasonCode: "vip_requests_disabled", }; } return { allowed: true }; @@ -101,6 +105,7 @@ export function isRequesterAllowed( return { allowed: false, reason: "Subscriber requests are disabled in this channel.", + reasonCode: "subscriber_requests_disabled", }; } return { allowed: true }; @@ -110,6 +115,7 @@ export function isRequesterAllowed( return { allowed: false, reason: "Only subscribers or VIPs can request songs right now.", + reasonCode: "subscriber_or_vip_only", }; } @@ -198,6 +204,7 @@ export function isSongAllowed(input: { return { allowed: false, reason: "Only official DLC requests are allowed.", + reasonCode: "only_official_dlc", }; } @@ -357,23 +364,29 @@ export function songMatchesRequestedPaths(input: { } export function formatPathLabel(path: string) { + return formatLocalizedPathLabel(path); +} + +export function formatLocalizedPathLabel(path: string, translate?: Translate) { switch (normalize(path)) { case "lead": - return "Lead"; + return translate?.("paths.lead") ?? "Lead"; case "rhythm": - return "Rhythm"; + return translate?.("paths.rhythm") ?? "Rhythm"; case "bass": - return "Bass"; + return translate?.("paths.bass") ?? "Bass"; case "voice": case "vocals": - return "Lyrics"; + return translate?.("paths.lyrics") ?? "Lyrics"; default: return path.trim(); } } -export function formatPathList(paths: string[]) { - return paths.map((path) => formatPathLabel(path)).join(", "); +export function formatPathList(paths: string[], translate?: Translate) { + return paths + .map((path) => formatLocalizedPathLabel(path, translate)) + .join(", "); } export function buildHowMessage(input: { @@ -381,37 +394,56 @@ export function buildHowMessage(input: { appUrl: string; channelSlug?: string; allowRequestPathModifiers?: boolean; + translate?: Translate; }) { const normalized = normalizeCommandPrefix(input.commandPrefix); const parts = [ - `Commands: ${normalized}sr artist - song; ${normalized}sr artist *random; ${normalized}sr artist *choice; ${normalized}vip; ${normalized}vip artist - song; ${normalized}edit artist - song; ${normalized}remove reg|vip|all; ${normalized}position.`, + input.translate?.("commands.how.commands", { + commandPrefix: normalized, + }) ?? + `Commands: ${normalized}sr artist - song; ${normalized}sr artist *random; ${normalized}sr artist *choice; ${normalized}vip; ${normalized}vip artist - song; ${normalized}edit artist - song; ${normalized}remove reg|vip|all; ${normalized}position.`, ]; if (input.allowRequestPathModifiers) { parts.push( - `Bass requests: add *bass to ${normalized}sr, ${normalized}vip, or ${normalized}edit.` + input.translate?.("commands.how.bassRequests", { + commandPrefix: normalized, + }) ?? + `Bass requests: add *bass to ${normalized}sr, ${normalized}vip, or ${normalized}edit.` ); } const root = input.appUrl.replace(/\/+$/, ""); const slug = input.channelSlug?.replace(/^\/+|\/+$/g, "") ?? ""; parts.push( - `Browse the track list and request songs here: ${slug ? `${root}/${slug}` : `${root}/search`}` + input.translate?.("commands.how.browse", { + url: slug ? `${root}/${slug}` : `${root}/search`, + }) ?? + `Browse the track list and request songs here: ${slug ? `${root}/${slug}` : `${root}/search`}` ); return parts.join(" "); } -export function buildSearchMessage(appUrl: string) { +export function buildSearchMessage(appUrl: string, translate?: Translate) { const root = appUrl.replace(/\/+$/, ""); - return `Search the song database here: ${root}/search`; + return ( + translate?.("commands.search", { + url: `${root}/search`, + }) ?? `Search the song database here: ${root}/search` + ); } export function buildChannelPlaylistMessage( appUrl: string, - channelSlug: string + channelSlug: string, + translate?: Translate ) { const root = appUrl.replace(/\/+$/, ""); const slug = channelSlug.replace(/^\/+|\/+$/g, ""); - return `You can edit or search the song database here: ${root}/${slug}`; + return ( + translate?.("commands.channelPlaylist", { + url: `${root}/${slug}`, + }) ?? `You can edit or search the song database here: ${root}/${slug}` + ); } export function buildBlacklistMessage( @@ -426,7 +458,8 @@ export function buildBlacklistMessage( groupedProjectId?: number; songTitle: string; artistName?: string | null; - }> = [] + }> = [], + translate?: Translate ) { if ( artists.length === 0 && @@ -434,7 +467,10 @@ export function buildBlacklistMessage( songs.length === 0 && songGroups.length === 0 ) { - return "No blacklisted artists, charters, songs, or versions."; + return ( + translate?.("commands.blacklist.empty") ?? + "No blacklisted artists, charters, songs, or versions." + ); } const artistText = artists.length @@ -469,14 +505,23 @@ export function buildBlacklistMessage( ) .join(", ") : "none"; - return `Artists: ${artistText}. Charters: ${charterText}. Songs: ${songGroupText}. Versions: ${songText}.`; + return ( + translate?.("commands.blacklist.summary", { + artists: artistText, + charters: charterText, + songs: songGroupText, + versions: songText, + }) ?? + `Artists: ${artistText}. Charters: ${charterText}. Songs: ${songGroupText}. Versions: ${songText}.` + ); } export function buildSetlistMessage( - artists: Array<{ artistId?: number | null; artistName: string }> + artists: Array<{ artistId?: number | null; artistName: string }>, + translate?: Translate ) { if (artists.length === 0) { - return "No setlist artists."; + return translate?.("commands.setlist.empty") ?? "No setlist artists."; } const artistText = artists.length @@ -485,7 +530,11 @@ export function buildSetlistMessage( .map((entry) => entry.artistName) .join(", ") : "none"; - return `Artists: ${artistText}.`; + return ( + translate?.("commands.setlist.summary", { + artists: artistText, + }) ?? `Artists: ${artistText}.` + ); } export function normalizeCommandPrefix(commandPrefix: string) { diff --git a/src/lib/server/dashboard-settings.ts b/src/lib/server/dashboard-settings.ts index c4a6dbd..df1f76d 100644 --- a/src/lib/server/dashboard-settings.ts +++ b/src/lib/server/dashboard-settings.ts @@ -10,6 +10,7 @@ import { updateSettings, } from "~/lib/db/repositories"; import type { AppEnv } from "~/lib/env"; +import { defaultLocale, normalizeLocale } from "~/lib/i18n/locales"; import { getArraySetting, getRequiredPathsMatchMode, @@ -34,7 +35,7 @@ async function requireDashboardState(runtimeEnv: AppEnv) { const state = await getDashboardState(runtimeEnv, userId); if (!state) { - throw new Error("Your channel could not be loaded."); + throw new Error("Only the channel owner can manage channel settings."); } return state; @@ -83,6 +84,8 @@ export const getDashboardSettings = createServerFn({ method: "GET" }).handler( requiredPathsMatchMode: getRequiredPathsMatchMode( state.settings.requiredPathsMatchMode ), + defaultLocale: + normalizeLocale(state.settings.defaultLocale) ?? defaultLocale, cheerMinimumTokenPercent: normalizeCheerMinimumTokenPercent( state.settings.cheerMinimumTokenPercent ), diff --git a/src/lib/server/extension-panel.ts b/src/lib/server/extension-panel.ts index 1c07b7c..73dc43d 100644 --- a/src/lib/server/extension-panel.ts +++ b/src/lib/server/extension-panel.ts @@ -10,6 +10,7 @@ import { upsertUserProfile, } from "~/lib/db/repositories"; import type { AppEnv } from "~/lib/env"; +import { defaultLocale } from "~/lib/i18n/locales"; import { getArraySetting, getRequiredPathsMatchMode, @@ -103,6 +104,7 @@ type ExtensionPanelLiveState = { botReadyState?: string | null; }; settings: { + defaultLocale: string; requestsEnabled: boolean; showPlaylistPositions: boolean; autoGrantVipTokenToSubscribers: boolean; @@ -127,6 +129,7 @@ type ExtensionPanelLiveState = { login: string; displayName: string; profileImageUrl?: string | null; + preferredLocale?: string | null; vipTokensAvailable: number; }; activeRequests: Array>; @@ -227,6 +230,7 @@ export async function getExtensionBootstrapState(input: { connected: false, channel: null, settings: { + defaultLocale, requestsEnabled: true, showPlaylistPositions: false, autoGrantVipTokenToSubscribers: false, @@ -343,6 +347,7 @@ export async function getExtensionBootstrapState(input: { login: viewer.login, displayName: viewer.displayName, profileImageUrl: viewer.profileImageUrl ?? null, + preferredLocale: viewer.preferredLocale ?? null, isSubscriber: viewer.isSubscriber, subscriptionVerified: viewer.subscriptionVerified, vipTokensAvailable: viewer.vipTokensAvailable, @@ -659,6 +664,7 @@ async function getExtensionPanelLiveState(input: { botReadyState: input.channel.botReadyState, }, settings: { + defaultLocale: settings?.defaultLocale ?? defaultLocale, requestsEnabled: !!settings?.requestsEnabled, showPlaylistPositions: !!settings?.showPlaylistPositions, autoGrantVipTokenToSubscribers: @@ -693,6 +699,7 @@ async function getExtensionPanelLiveState(input: { login: input.linkedViewer.login, displayName: input.linkedViewer.displayName, profileImageUrl: input.linkedViewer.profileImageUrl ?? null, + preferredLocale: input.linkedViewer.preferredLocale ?? null, vipTokensAvailable, } : null, @@ -745,6 +752,7 @@ async function getExistingLinkedViewerIdentity( login: user.login, displayName: user.displayName, profileImageUrl: user.profileImageUrl, + preferredLocale: user.preferredLocale, }; } @@ -820,6 +828,7 @@ async function resolveLinkedViewerIdentity( login: existingUser.login, displayName: existingUser.displayName, profileImageUrl: existingUser.profileImageUrl, + preferredLocale: existingUser.preferredLocale, }; } @@ -852,6 +861,7 @@ async function resolveLinkedViewerIdentity( login: user.login, displayName: user.displayName, profileImageUrl: user.profileImageUrl, + preferredLocale: user.preferredLocale, }; } diff --git a/src/lib/server/viewer-request.ts b/src/lib/server/viewer-request.ts index ad37999..b2f4136 100644 --- a/src/lib/server/viewer-request.ts +++ b/src/lib/server/viewer-request.ts @@ -84,6 +84,7 @@ export type ViewerIdentity = { login: string; displayName: string; profileImageUrl?: string | null; + preferredLocale?: string | null; }; type ViewerChannelState = { @@ -145,6 +146,7 @@ export type ViewerRequestStatePayload = { login: string; displayName: string; profileImageUrl?: string | null; + preferredLocale?: string | null; isSubscriber: boolean; subscriptionVerified: boolean; vipTokensAvailable: number; @@ -230,6 +232,7 @@ async function resolveViewerIdentity(env: AppEnv, request: Request) { login: user.login, displayName: user.displayName, profileImageUrl: user.profileImageUrl, + preferredLocale: user.preferredLocale, } satisfies ViewerIdentity; } @@ -258,6 +261,7 @@ export async function getViewerRequestStateForChannelViewer(input: { login: context.viewer.login, displayName: context.viewer.displayName, profileImageUrl: context.viewer.profileImageUrl, + preferredLocale: context.viewer.preferredLocale, isSubscriber: context.subscription.isSubscriber, subscriptionVerified: context.subscription.verified, vipTokensAvailable: context.vipTokensAvailable, diff --git a/src/lib/streamelements/tips.ts b/src/lib/streamelements/tips.ts index c4d0102..e4e43e5 100644 --- a/src/lib/streamelements/tips.ts +++ b/src/lib/streamelements/tips.ts @@ -5,7 +5,10 @@ import { grantVipToken, } from "~/lib/db/repositories"; import type { AppEnv } from "~/lib/env"; -import { formatVipTokenCount, normalizeVipTokenCount } from "~/lib/vip-tokens"; +import { formatCurrency, formatNumber } from "~/lib/i18n/format"; +import type { AppLocale } from "~/lib/i18n/locales"; +import { getServerTranslation } from "~/lib/i18n/server"; +import { normalizeVipTokenCount } from "~/lib/vip-tokens"; export interface StreamElementsTipChannel { id: string; @@ -15,6 +18,7 @@ export interface StreamElementsTipChannel { } export interface StreamElementsTipSettings { + defaultLocale: string; autoGrantVipTokensForStreamElementsTips: boolean; streamElementsTipAmountPerVipToken: number; } @@ -95,26 +99,39 @@ function normalizeTwitchLogin(value: string | null) { return TWITCH_LOGIN_PATTERN.test(normalized) ? normalized : null; } -function formatPluralizedTokens(count: number) { - const formatted = formatVipTokenCount(count); - return `${formatted} VIP token${count === 1 ? "" : "s"}`; +function mention(login: string) { + return `@${login}`; } -function formatTipAmount(amount: number, currency: string | null) { +function formatTokenCount(locale: AppLocale, count: number) { + return formatNumber(locale, normalizeVipTokenCount(count), { + maximumFractionDigits: 2, + }); +} + +function formatTipAmount( + locale: AppLocale, + amount: number, + currency: string | null +) { if (currency) { try { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: currency.toUpperCase(), + return formatCurrency(locale, amount, currency.toUpperCase(), { minimumFractionDigits: amount % 1 === 0 ? 0 : 2, maximumFractionDigits: 2, - }).format(amount); + }); } catch { - return `${amount.toFixed(amount % 1 === 0 ? 0 : 2)} ${currency.toUpperCase()}`; + return `${formatNumber(locale, amount, { + minimumFractionDigits: amount % 1 === 0 ? 0 : 2, + maximumFractionDigits: 2, + })} ${currency.toUpperCase()}`; } } - return amount.toFixed(amount % 1 === 0 ? 0 : 2); + return formatNumber(locale, amount, { + minimumFractionDigits: amount % 1 === 0 ? 0 : 2, + maximumFractionDigits: 2, + }); } function extractEnvelope(input: Record) { @@ -243,6 +260,7 @@ export async function processStreamElementsTip(input: { if (!settings?.autoGrantVipTokensForStreamElementsTips) { return { body: "Ignored", status: 202 }; } + const { locale, t } = getServerTranslation(settings.defaultLocale, "bot"); if ( input.tip.status && @@ -311,7 +329,12 @@ export async function processStreamElementsTip(input: { await input.deps.sendChatReply(input.env, { channelId: input.channel.id, broadcasterUserId: input.channel.twitchChannelId, - message: `Added ${formatPluralizedTokens(tokenCount)} to @${input.tip.login} for a ${formatTipAmount(input.tip.amount, input.tip.currency)} StreamElements tip.`, + message: t("replies.autoGrantStreamElementsTip", { + mention: mention(input.tip.login), + count: tokenCount, + countText: formatTokenCount(locale, tokenCount), + amount: formatTipAmount(locale, input.tip.amount, input.tip.currency), + }), }); return { body: "Accepted", status: 202 }; @@ -326,6 +349,7 @@ export function createStreamElementsTipDependencies(): StreamElementsTipDependen } return { + defaultLocale: settings.defaultLocale, autoGrantVipTokensForStreamElementsTips: settings.autoGrantVipTokensForStreamElementsTips, streamElementsTipAmountPerVipToken: diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 3a3e01c..4e19a92 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { pathOptions, tuningOptions } from "./channel-options"; +import { supportedLocales } from "./i18n/locales"; const searchSortSchema = z.enum([ "relevance", @@ -151,6 +152,7 @@ export const moderationActionSchema = z.discriminatedUnion("action", [ export const settingsInputSchema = z .object({ + defaultLocale: z.enum(supportedLocales), botChannelEnabled: z.boolean(), moderatorCanManageRequests: z.boolean(), moderatorCanManageBlacklist: z.boolean(), diff --git a/src/lib/vip-token-automation.ts b/src/lib/vip-token-automation.ts index dfa6fb2..4933301 100644 --- a/src/lib/vip-token-automation.ts +++ b/src/lib/vip-token-automation.ts @@ -1,3 +1,5 @@ +import { formatCurrency, formatNumber } from "~/lib/i18n/format"; +import { type AppLocale, defaultLocale } from "~/lib/i18n/locales"; import { formatVipTokenCount, normalizeVipTokenCount } from "~/lib/vip-tokens"; export interface VipTokenAutomationSettingsLike { @@ -14,33 +16,56 @@ export interface VipTokenAutomationSettingsLike { streamElementsTipAmountPerVipToken?: number | null; } +type Translate = (key: string, options?: Record) => string; + export function getVipTokenAutomationDetails( - input: VipTokenAutomationSettingsLike + input: VipTokenAutomationSettingsLike, + options?: { + locale?: AppLocale; + translate?: Translate; + } ) { + const locale = options?.locale ?? defaultLocale; + const translate = options?.translate; const earningRules: string[] = []; const notes: string[] = []; if (input.autoGrantVipTokenToSubscribers) { - earningRules.push("New paid sub = 1 VIP token"); + earningRules.push( + translate?.("vip.ruleNewPaidSub") ?? "New paid sub = 1 VIP token" + ); } if (input.autoGrantVipTokensForSharedSubRenewalMessage) { - earningRules.push("Shared sub renewal message = 1 VIP token"); + earningRules.push( + translate?.("vip.ruleSharedSubRenewal") ?? + "Shared sub renewal message = 1 VIP token" + ); } if (input.autoGrantVipTokensToSubGifters) { - earningRules.push("Gift 1 sub = 1 VIP token to the gifter"); + earningRules.push( + translate?.("vip.ruleGiftSub") ?? "Gift 1 sub = 1 VIP token to the gifter" + ); } if (input.autoGrantVipTokensToGiftRecipients) { - earningRules.push("Receive a gifted sub = 1 VIP token"); + earningRules.push( + translate?.("vip.ruleGiftRecipient") ?? + "Receive a gifted sub = 1 VIP token" + ); } if (input.autoGrantVipTokensForCheers) { const bitsPerVipToken = normalizePositiveNumber(input.cheerBitsPerVipToken); if (bitsPerVipToken != null) { + const formattedBitsPerVipToken = formatNumber(locale, bitsPerVipToken, { + maximumFractionDigits: 2, + }); earningRules.push( - `Cheer ${formatNumber(bitsPerVipToken)} bits = 1 VIP token` + translate?.("vip.ruleCheer", { + bits: formattedBitsPerVipToken, + }) ?? `Cheer ${formattedBitsPerVipToken} bits = 1 VIP token` ); const minimumBits = Math.ceil( bitsPerVipToken * @@ -49,8 +74,23 @@ export function getVipTokenAutomationDetails( const minimumTokenCount = normalizeVipTokenCount( minimumBits / bitsPerVipToken ); + const formattedMinimumBits = formatNumber(locale, minimumBits, { + maximumFractionDigits: 2, + }); + const formattedMinimumTokenCount = formatNumber( + locale, + minimumTokenCount, + { + maximumFractionDigits: 2, + } + ); earningRules.push( - `Minimum cheer: ${formatNumber(minimumBits)} bits = ${formatVipTokenCount(minimumTokenCount)} VIP token${minimumTokenCount === 1 ? "" : "s"}.` + translate?.("vip.ruleMinimumCheer", { + bits: formattedMinimumBits, + tokens: formattedMinimumTokenCount, + count: minimumTokenCount, + }) ?? + `Minimum cheer: ${formattedMinimumBits} bits = ${formatVipTokenCount(minimumTokenCount)} VIP token${minimumTokenCount === 1 ? "" : "s"}.` ); } } @@ -61,8 +101,16 @@ export function getVipTokenAutomationDetails( ); earningRules.push( minimumRaidViewerCount > 1 - ? `Raid with ${formatNumber(minimumRaidViewerCount)}+ viewers = 1 VIP token` - : "Raid this channel = 1 VIP token" + ? (translate?.("vip.ruleRaidMultiple", { + viewers: formatNumber(locale, minimumRaidViewerCount, { + maximumFractionDigits: 2, + }), + }) ?? + `Raid with ${formatNumber(locale, minimumRaidViewerCount, { + maximumFractionDigits: 2, + })}+ viewers = 1 VIP token`) + : (translate?.("vip.ruleRaidSingle") ?? + "Raid this channel = 1 VIP token") ); } @@ -71,8 +119,19 @@ export function getVipTokenAutomationDetails( input.streamElementsTipAmountPerVipToken ); if (amountPerVipToken != null) { + const formattedTipAmount = formatCurrency( + locale, + amountPerVipToken, + "USD", + { + minimumFractionDigits: Number.isInteger(amountPerVipToken) ? 0 : 2, + maximumFractionDigits: 2, + } + ); earningRules.push( - `StreamElements tip ${formatCurrency(amountPerVipToken)} = 1 VIP token` + translate?.("vip.ruleTip", { + amount: formattedTipAmount, + }) ?? `StreamElements tip ${formattedTipAmount} = 1 VIP token` ); } } @@ -83,14 +142,16 @@ export function getVipTokenAutomationDetails( }; } -export function getVipTokenRedemptionDescription() { - return "Spend 1 VIP token for your song to be placed at the top of the playlist."; +export function getVipTokenRedemptionDescription(translate?: Translate) { + return ( + translate?.("vip.redemptionDescription") ?? + "Spend 1 VIP token for your song to be placed at the top of the playlist." + ); } -export function getVipTokenRedemptionDetails() { +export function getVipTokenRedemptionDetails(translate?: Translate) { return { - summary: - "Spend 1 VIP token for your song to be placed at the top of the playlist.", + summary: getVipTokenRedemptionDescription(translate), uses: [], }; } @@ -110,18 +171,3 @@ function normalizeRaidMinimumViewerCount(value: number | null | undefined) { ? Math.floor(value) : 1; } - -function formatNumber(value: number) { - return new Intl.NumberFormat("en-US", { - maximumFractionDigits: 2, - }).format(value); -} - -function formatCurrency(value: number) { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: Number.isInteger(value) ? 0 : 2, - maximumFractionDigits: 2, - }).format(value); -} diff --git a/src/routes/$slug/index.tsx b/src/routes/$slug/index.tsx index 435ab3d..5272da3 100644 --- a/src/routes/$slug/index.tsx +++ b/src/routes/$slug/index.tsx @@ -32,7 +32,7 @@ import { formatBlacklistReasonLabel, getBlacklistReasonCodes, } from "~/lib/channel-blacklist"; -import { useLocaleTranslation } from "~/lib/i18n/client"; +import { useAppLocale, useLocaleTranslation } from "~/lib/i18n/client"; import { getLocalizedPageTitle } from "~/lib/i18n/metadata"; import { formatSlugTitle } from "~/lib/page-title"; import { getPickNumbersForQueuedItems } from "~/lib/pick-order"; @@ -282,7 +282,8 @@ function formatPublicPlaylistSecondaryLine( } function PublicChannelPage() { - const { t } = useLocaleTranslation(["common", "playlist"]); + const { t } = useLocaleTranslation(["common", "playlist", "extension"]); + const { locale } = useAppLocale(); const { slug } = Route.useParams(); const queryClient = useQueryClient(); const [showBlacklisted, setShowBlacklisted] = useState(false); @@ -379,7 +380,15 @@ function PublicChannelPage() { ? "border-amber-500/30 bg-amber-500/10 text-amber-100" : "border-rose-500/30 bg-rose-500/10 text-rose-100"; const vipAutomationDetails = getVipTokenAutomationDetails( - data?.settings ?? {} + data?.settings ?? {}, + { + locale, + translate: (key, options) => + t(key, { + ns: "extension", + ...(options ?? {}), + }), + } ); const defaultSearchPathFilters = useMemo( () => getArraySetting(data?.settings?.requiredPathsJson), @@ -1452,8 +1461,13 @@ function VipTokenInfoContent(props: { vipAutomationDetails: ReturnType; balanceSummary?: string; }) { - const { t } = useLocaleTranslation("playlist"); - const redemptionDetails = getVipTokenRedemptionDetails(); + const { t } = useLocaleTranslation(["playlist", "extension"]); + const redemptionDetails = getVipTokenRedemptionDetails((key, options) => + t(key, { + ns: "extension", + ...(options ?? {}), + }) + ); return (
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index c10f075..83f4998 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -18,6 +18,7 @@ import { Settings2, } from "lucide-react"; import { LanguagePicker } from "~/components/language-picker"; +import { TranslationHelpButton } from "~/components/translation-help-button"; import { Button } from "~/components/ui/button"; import { Tooltip, @@ -135,6 +136,7 @@ function AppShell() {
+
+ +
+ + +
+
{ + const tempDir = mkdtempSync(join(tmpdir(), "request-bot-db-")); + const dbPath = join(tempDir, "test.sqlite"); + const migrationPaths = readdirSync(join(process.cwd(), "drizzle")) + .filter((name) => name.endsWith(".sql")) + .sort() + .map((name) => join(process.cwd(), "drizzle", name)); + + const script = ` +import json +import sqlite3 +import sys + +db_path = sys.argv[1] +migration_paths = json.loads(sys.argv[2]) + +con = sqlite3.connect(db_path) +cur = con.cursor() + +for migration_path in migration_paths: + with open(migration_path, "r", encoding="utf-8") as file: + sql = file.read() + cur.executescript(sql) + +cur.execute("insert into users (id, twitch_user_id, login, display_name) values (?, ?, ?, ?)", ("usr_test", "tw_test", "tester", "Tester")) +cur.execute("insert into channels (id, owner_user_id, twitch_channel_id, slug, login, display_name) values (?, ?, ?, ?, ?, ?)", ("chn_test", "usr_test", "tw_channel", "tester", "tester", "Tester")) +cur.execute("insert into channel_settings (channel_id) values (?)", ("chn_test",)) + +row = cur.execute("select default_locale from channel_settings where channel_id='chn_test'").fetchone() +con.commit() +con.close() +print(json.dumps(row)) +`; + + const output = execFileSync( + pythonCommand, + ["-", dbPath, JSON.stringify(migrationPaths)], + { + input: script, + encoding: "utf8", + } + ); + + expect(JSON.parse(output)).toEqual(["en"]); + }); }); diff --git a/tests/eventsub.chat-message.test.ts b/tests/eventsub.chat-message.test.ts index 5f48226..27c1fb1 100644 --- a/tests/eventsub.chat-message.test.ts +++ b/tests/eventsub.chat-message.test.ts @@ -61,6 +61,7 @@ function createSong( function createState(overrides: Record = {}) { return { settings: { + defaultLocale: "en", requestsEnabled: true, allowAnyoneToRequest: true, allowSubscribersToRequest: true, @@ -337,7 +338,37 @@ describe("processEventSubChatMessage", () => { env, expect.objectContaining({ message: - "You do not have enough VIP tokens for this channel. You have 0.5.", + "You do not have enough VIP tokens for this channel. You have 0.5 VIP tokens.", + }) + ); + }); + + it("uses the channel default locale for bot replies when translations are available", async () => { + const deps = createDeps({ + getDashboardState: vi.fn().mockResolvedValue( + createState({ + defaultLocale: "es", + requestsEnabled: false, + }) + ), + }); + + const result = await processEventSubChatMessage({ + env, + event: createEvent(), + parsed: createParsed(), + deps, + }); + + expect(result).toEqual({ + body: "Ignored", + status: 202, + }); + expect(deps.sendChatReply).toHaveBeenCalledWith( + env, + expect.objectContaining({ + message: + "Las solicitudes están desactivadas para este canal en este momento.", }) ); }); diff --git a/tests/eventsub.support-events.test.ts b/tests/eventsub.support-events.test.ts index 0900f89..a7c0f90 100644 --- a/tests/eventsub.support-events.test.ts +++ b/tests/eventsub.support-events.test.ts @@ -19,6 +19,7 @@ function createDeps( twitchChannelId: "broadcaster-1", }), getChannelSettingsByChannelId: vi.fn().mockResolvedValue({ + defaultLocale: "en", autoGrantVipTokenToSubscribers: true, autoGrantVipTokensForSharedSubRenewalMessage: true, autoGrantVipTokensToSubGifters: true, @@ -199,6 +200,7 @@ describe("support EventSub automation", () => { it("grants one VIP token to the streamer who raids the channel", async () => { const deps = createDeps({ getChannelSettingsByChannelId: vi.fn().mockResolvedValue({ + defaultLocale: "en", autoGrantVipTokenToSubscribers: true, autoGrantVipTokensForSharedSubRenewalMessage: true, autoGrantVipTokensToSubGifters: true, @@ -249,6 +251,7 @@ describe("support EventSub automation", () => { it("ignores raids below the minimum configured size", async () => { const deps = createDeps({ getChannelSettingsByChannelId: vi.fn().mockResolvedValue({ + defaultLocale: "en", autoGrantVipTokenToSubscribers: true, autoGrantVipTokensForSharedSubRenewalMessage: true, autoGrantVipTokensToSubGifters: true, @@ -287,6 +290,7 @@ describe("support EventSub automation", () => { it("ignores cheers below the configured minimum partial threshold", async () => { const deps = createDeps({ getChannelSettingsByChannelId: vi.fn().mockResolvedValue({ + defaultLocale: "en", autoGrantVipTokenToSubscribers: true, autoGrantVipTokensForSharedSubRenewalMessage: true, autoGrantVipTokensToSubGifters: true, @@ -327,6 +331,7 @@ describe("support EventSub automation", () => { it("grants proportional fractional VIP tokens for cheers above the threshold", async () => { const deps = createDeps({ getChannelSettingsByChannelId: vi.fn().mockResolvedValue({ + defaultLocale: "en", autoGrantVipTokenToSubscribers: true, autoGrantVipTokensForSharedSubRenewalMessage: true, autoGrantVipTokensToSubGifters: true, @@ -375,6 +380,52 @@ describe("support EventSub automation", () => { ); }); + it("localizes support event chat replies using the channel default locale", async () => { + const deps = createDeps({ + getChannelSettingsByChannelId: vi.fn().mockResolvedValue({ + defaultLocale: "es", + autoGrantVipTokenToSubscribers: true, + autoGrantVipTokensForSharedSubRenewalMessage: true, + autoGrantVipTokensToSubGifters: true, + autoGrantVipTokensToGiftRecipients: true, + autoGrantVipTokensForCheers: true, + autoGrantVipTokensForRaiders: true, + cheerBitsPerVipToken: 200, + cheerMinimumTokenPercent: 25, + raidMinimumViewerCount: 1, + }), + }); + + const result = await processEventSubChannelCheer({ + env, + deps, + messageId: "msg-4-es", + event: { + is_anonymous: false, + user_id: "viewer-1", + user_login: "viewer_one", + user_name: "Viewer One", + broadcaster_user_id: "broadcaster-1", + broadcaster_user_login: "streamer", + broadcaster_user_name: "Streamer", + message: "Cheer220", + bits: 220, + }, + }); + + expect(result).toEqual({ + body: "Accepted", + status: 202, + }); + expect(deps.sendChatReply).toHaveBeenCalledWith( + env, + expect.objectContaining({ + message: + "Se agregaron 1,1 tokens VIP a @viewer_one por enviar 220 bits.", + }) + ); + }); + it("ignores duplicate automation deliveries", async () => { const deps = createDeps({ claimEventSubDelivery: vi.fn().mockResolvedValue(false), diff --git a/tests/extension-panel-locale.test.ts b/tests/extension-panel-locale.test.ts new file mode 100644 index 0000000..214a954 --- /dev/null +++ b/tests/extension-panel-locale.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { resolveExtensionPanelLocale } from "~/extension/panel/locale"; + +describe("resolveExtensionPanelLocale", () => { + it("prefers Twitch locale query params when they are present", () => { + expect( + resolveExtensionPanelLocale({ + search: "?locale=pt-BR&language=pt", + documentLanguage: "en", + navigatorLanguage: "fr-CA", + }) + ).toBe("pt-BR"); + }); + + it("falls back from Twitch language query params to a supported base locale", () => { + expect( + resolveExtensionPanelLocale({ + search: "?language=es-MX", + documentLanguage: "en", + navigatorLanguage: "fr-CA", + }) + ).toBe("es"); + }); + + it("uses the document language before the browser language when query params are missing", () => { + expect( + resolveExtensionPanelLocale({ + search: "", + documentLanguage: "fr-CA", + navigatorLanguage: "es-MX", + }) + ).toBe("fr"); + }); + + it("prefers a linked viewer preference before local device settings", () => { + expect( + resolveExtensionPanelLocale({ + search: "?language=fr", + storedLocale: "pt-BR", + cookieLocale: "es", + viewerPreferredLocale: "fr", + channelDefaultLocale: "en", + documentLanguage: "en", + navigatorLanguage: "en-US", + }) + ).toBe("fr"); + }); + + it("uses the stored device locale before queryless document and browser fallbacks", () => { + expect( + resolveExtensionPanelLocale({ + search: "", + storedLocale: "pt-BR", + cookieLocale: "es", + documentLanguage: "fr-CA", + navigatorLanguage: "en-US", + }) + ).toBe("pt-BR"); + }); + + it("falls back to the channel default locale after device preferences are exhausted", () => { + expect( + resolveExtensionPanelLocale({ + search: "", + storedLocale: null, + cookieLocale: null, + channelDefaultLocale: "es", + documentLanguage: "de-DE", + navigatorLanguage: "de-DE", + }) + ).toBe("es"); + }); + + it("falls back to English when no supported locale can be resolved", () => { + expect( + resolveExtensionPanelLocale({ + search: "?locale=de-DE", + documentLanguage: "de-DE", + navigatorLanguage: "de-DE", + }) + ).toBe("en"); + }); +}); diff --git a/tests/extension-panel.test.ts b/tests/extension-panel.test.ts index 449c23c..34435c2 100644 --- a/tests/extension-panel.test.ts +++ b/tests/extension-panel.test.ts @@ -111,6 +111,7 @@ describe("extension panel service", () => { login: "viewer_one", displayName: "Viewer One", profileImageUrl: "https://example.com/viewer.png", + preferredLocale: "fr", } as never); vi.mocked(getViewerRequestStateForChannelViewer).mockResolvedValue({ viewer: { @@ -118,6 +119,7 @@ describe("extension panel service", () => { login: "viewer_one", displayName: "Viewer One", profileImageUrl: "https://example.com/viewer.png", + preferredLocale: "fr", isSubscriber: false, subscriptionVerified: false, vipTokensAvailable: 2, @@ -169,8 +171,10 @@ describe("extension panel service", () => { login: "viewer_one", displayName: "Viewer One", profileImageUrl: "https://example.com/viewer.png", + preferredLocale: "fr", } as never); vi.mocked(getChannelSettingsByChannelId).mockResolvedValue({ + defaultLocale: "es", blacklistEnabled: true, showPlaylistPositions: true, moderatorCanManageRequests: true, @@ -252,6 +256,7 @@ describe("extension panel service", () => { isLive: true, }, settings: { + defaultLocale: "es", showPlaylistPositions: true, }, playlist: { @@ -259,6 +264,9 @@ describe("extension panel service", () => { }, viewer: { isLinked: true, + profile: { + preferredLocale: "fr", + }, canRequest: true, canVipRequest: true, canEditOwnRequest: false, @@ -356,6 +364,7 @@ describe("extension panel service", () => { isLive: true, }, settings: { + defaultLocale: "es", showPlaylistPositions: true, }, playlist: { @@ -373,6 +382,7 @@ describe("extension panel service", () => { profile: { twitchUserId: "viewer-1", displayName: "Viewer One", + preferredLocale: "fr", vipTokensAvailable: 2, }, }, diff --git a/tests/i18n.client.test.ts b/tests/i18n.client.test.ts new file mode 100644 index 0000000..01a78f7 --- /dev/null +++ b/tests/i18n.client.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { getSyncedLocaleFromInitial } from "~/lib/i18n/client"; + +describe("getSyncedLocaleFromInitial", () => { + it("keeps the user's selected locale when the incoming initial locale has not changed", () => { + expect( + getSyncedLocaleFromInitial({ + currentLocale: "fr", + previousInitialLocale: "en", + nextInitialLocale: "en", + }) + ).toBe("fr"); + }); + + it("syncs to a new initial locale when the parent prop actually changes", () => { + expect( + getSyncedLocaleFromInitial({ + currentLocale: "en", + previousInitialLocale: "en", + nextInitialLocale: "fr", + }) + ).toBe("fr"); + }); + + it("does nothing when the current locale already matches the new initial locale", () => { + expect( + getSyncedLocaleFromInitial({ + currentLocale: "fr", + previousInitialLocale: "en", + nextInitialLocale: "fr", + }) + ).toBe("fr"); + }); +}); diff --git a/tests/requests.test.ts b/tests/requests.test.ts index 69827c3..5605777 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -222,6 +222,7 @@ describe("request policy", () => { ).toEqual({ allowed: false, reason: "Only subscribers or VIPs can request songs right now.", + reasonCode: "subscriber_or_vip_only", }); }); diff --git a/tests/streamelements.tips.test.ts b/tests/streamelements.tips.test.ts index dc22c58..70652e4 100644 --- a/tests/streamelements.tips.test.ts +++ b/tests/streamelements.tips.test.ts @@ -11,6 +11,7 @@ function createDeps( ): StreamElementsTipDependencies { return { getChannelSettingsByChannelId: vi.fn().mockResolvedValue({ + defaultLocale: "en", autoGrantVipTokensForStreamElementsTips: true, streamElementsTipAmountPerVipToken: 5, }), @@ -118,6 +119,55 @@ describe("StreamElements tip automation", () => { ); }); + it("localizes StreamElements tip replies using the channel default locale", async () => { + const deps = createDeps({ + getChannelSettingsByChannelId: vi.fn().mockResolvedValue({ + defaultLocale: "es", + autoGrantVipTokensForStreamElementsTips: true, + streamElementsTipAmountPerVipToken: 5, + }), + }); + const tip = parseStreamElementsTipPayload({ + topic: "channel.tips", + data: { + donation: { + user: { + username: "viewer_one", + }, + amount: 25, + currency: "USD", + }, + transactionId: "txn-2-es", + approved: "allowed", + status: "success", + }, + }); + + expect(tip).not.toBeNull(); + if (!tip) { + throw new Error("Expected a valid StreamElements tip payload."); + } + + const result = await processStreamElementsTip({ + env, + deps, + channel, + tip, + }); + + expect(result).toEqual({ + body: "Accepted", + status: 202, + }); + expect(deps.sendChatReply).toHaveBeenCalledWith( + env, + expect.objectContaining({ + message: + "Se agregaron 5 tokens VIP a @viewer_one por una propina de StreamElements de 25 US$.", + }) + ); + }); + it("ignores tips when the username is not Twitch-compatible", async () => { const deps = createDeps(); const tip = parseStreamElementsTipPayload({ diff --git a/tests/vip-token-automation.test.ts b/tests/vip-token-automation.test.ts index 04cbc15..e7e4741 100644 --- a/tests/vip-token-automation.test.ts +++ b/tests/vip-token-automation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { getServerTranslation } from "~/lib/i18n/server"; import { getVipTokenAutomationDetails } from "~/lib/vip-token-automation"; describe("getVipTokenAutomationDetails", () => { @@ -19,4 +20,29 @@ describe("getVipTokenAutomationDetails", () => { "StreamElements tip $5 = 1 VIP token", ]); }); + + it("can localize VIP token automation details for non-English website locales", () => { + const { locale, t } = getServerTranslation("es", "extension"); + const details = getVipTokenAutomationDetails( + { + autoGrantVipTokensForCheers: true, + cheerBitsPerVipToken: 200, + cheerMinimumTokenPercent: 25, + autoGrantVipTokensForStreamElementsTips: true, + streamElementsTipAmountPerVipToken: 5, + autoGrantVipTokensForSharedSubRenewalMessage: true, + }, + { + locale, + translate: (key, options) => t(key, options), + } + ); + + expect(details.earningRules).toEqual([ + "Mensaje compartido de renovación de suscripción = 1 token VIP", + "Cheer de 200 bits = 1 token VIP", + "Cheer mínimo: 50 bits = 0,25 tokens VIP.", + "Propina de StreamElements de 5 US$ = 1 token VIP", + ]); + }); });