mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: add i18n support to web app
This commit is contained in:
1266
pnpm-lock.yaml
generated
1266
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
148
web/docs/i18n.md
Normal file
148
web/docs/i18n.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Internationalization (i18n) Guide
|
||||
|
||||
Tunarr's web app uses [Lingui v5](https://lingui.dev/) for internationalization.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Library:** Lingui v5.9.0 with SWC plugin and Vite plugin
|
||||
- **Source locale:** English (`en`) — all source strings are written in English
|
||||
- **Catalog format:** GNU `.po` files (human-editable, compatible with standard translation tools)
|
||||
- **Runtime:** Lingui compiles `.po` files to `.ts` modules via `pnpm compile-messages`
|
||||
|
||||
## Adding Translatable Strings
|
||||
|
||||
### JSX text content — use `<Trans>`
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
<Typography><Trans>Settings</Trans></Typography>
|
||||
```
|
||||
|
||||
### String props (labels, placeholders, aria attributes) — use `t` from `useLingui()`
|
||||
|
||||
```tsx
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useLingui();
|
||||
return <TextField placeholder={t`Server Port`} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Plurals — use `<Plural>` or `plural()`
|
||||
|
||||
```tsx
|
||||
import { Plural } from '@lingui/react/macro';
|
||||
|
||||
<Plural value={count} one="# channel" other="# channels" />
|
||||
```
|
||||
|
||||
```ts
|
||||
import { plural } from '@lingui/core/macro';
|
||||
|
||||
const msg = plural(count, { one: '# item', other: '# items' });
|
||||
```
|
||||
|
||||
### Interpolation
|
||||
|
||||
```tsx
|
||||
<Trans>Delete "{name}"?</Trans>
|
||||
```
|
||||
|
||||
### Non-component code (snackbar messages, etc.)
|
||||
|
||||
```tsx
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
const { t } = useLingui();
|
||||
enqueueSnackbar(t`Settings saved`);
|
||||
```
|
||||
|
||||
## Extraction & Compilation Workflow
|
||||
|
||||
1. **Wrap strings** with `<Trans>`, `t`, `<Plural>`, etc.
|
||||
2. **Extract** new strings into `.po` catalog files:
|
||||
```bash
|
||||
cd web && pnpm extract-messages
|
||||
```
|
||||
This updates `src/locales/en/messages.po` and creates/updates all other locale `.po` files.
|
||||
3. **Translate** the new `msgid` entries in each locale's `.po` file (leave `msgstr` empty to fall back to English automatically).
|
||||
4. **Compile** catalogs to TypeScript:
|
||||
```bash
|
||||
cd web && pnpm compile-messages
|
||||
```
|
||||
5. **Commit** both the `.po` source files and compiled `.ts` files.
|
||||
|
||||
## Adding a New Language
|
||||
|
||||
1. **Update `lingui.config.ts`** — add the locale code to the `locales` array and add a fallback:
|
||||
```ts
|
||||
locales: ['en', 'es', 'fr', 'pseudo-LOCALE'],
|
||||
fallbackLocales: {
|
||||
fr: 'en',
|
||||
// ...
|
||||
},
|
||||
```
|
||||
|
||||
2. **Update `SupportedLocales`** in `web/src/store/settings/store.ts`:
|
||||
```ts
|
||||
export type SupportedLocales = 'en' | 'es' | 'fr' | 'pseudo-LOCALE';
|
||||
```
|
||||
|
||||
3. **Add to the language picker** in `web/src/components/settings/general/WebSettings.tsx`:
|
||||
```tsx
|
||||
<MenuItem value="fr">Français</MenuItem>
|
||||
```
|
||||
|
||||
4. **Add to `LINGUI_TO_DAYJS`** in `web/src/helpers/localeLoader.ts`:
|
||||
```ts
|
||||
export const LINGUI_TO_DAYJS: Record<string, string> = {
|
||||
en: 'en',
|
||||
es: 'es',
|
||||
fr: 'fr',
|
||||
'pseudo-LOCALE': 'en',
|
||||
};
|
||||
```
|
||||
|
||||
5. **Add MUI locale** in `web/src/Tunarr.tsx` if MUI ships a locale for it:
|
||||
```tsx
|
||||
import { frFR as muiFrFR } from '@mui/material/locale';
|
||||
import { frFR as pickersFrFR } from '@mui/x-date-pickers/locales';
|
||||
// ...
|
||||
if (locale === 'fr') return createTheme(Theme, muiFrFR, pickersFrFR);
|
||||
```
|
||||
|
||||
6. **Run extraction and compilation:**
|
||||
```bash
|
||||
cd web
|
||||
pnpm extract-messages # creates src/locales/fr/messages.po
|
||||
# translate the .po file
|
||||
pnpm compile-messages # generates src/locales/fr/messages.ts
|
||||
```
|
||||
|
||||
## Testing with pseudo-LOCALE
|
||||
|
||||
`pseudo-LOCALE` is a development locale that transforms all translatable strings into a visually distinct format (e.g., adds diacritics). Any string that is **not** pseudo-localized when `pseudo-LOCALE` is active is missing i18n wrapping.
|
||||
|
||||
To activate it in the running app:
|
||||
1. Go to **Settings → Web Settings**
|
||||
2. Select **pseudo-LOCALE (dev)** in the Language dropdown (only visible in dev mode)
|
||||
|
||||
Scan all pages for any plain English text — those strings need `<Trans>` or `t` wrapping.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Zustand store (browser-local)
|
||||
└── settings.ui.i18n.locale → controls Lingui catalog + dayjs locale + MUI locale
|
||||
└── settings.ui.i18n.timeFormat → controls dayjs time display (12h / 24h / auto)
|
||||
```
|
||||
|
||||
- **`setUiLocale(locale)`** — async action that loads the Lingui catalog and dayjs locale module, then updates the store. Components re-render automatically.
|
||||
- **`setTimeFormat(format)`** — async action that loads `dayjs/locale/en-gb` when needed (for 24h), then updates the store.
|
||||
- **`DayjsProvider`** — derives the effective dayjs locale from `locale` + `timeFormat` (auto → language native, 12h → en, 24h → en-gb) and sets it globally.
|
||||
- **`Tunarr.tsx`** — loads English synchronously on startup (preventing blank text), then loads the persisted locale asynchronously via `useEffect`.
|
||||
- **`I18nProvider`** — wraps the entire app so all `<Trans>` / `t` macros access the active catalog.
|
||||
|
||||
Locale preference is stored in the browser (Zustand + localStorage). Server-side locale storage is intentionally not implemented — Tunarr is primarily a single-user/household application.
|
||||
18
web/lingui.config.ts
Normal file
18
web/lingui.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from '@lingui/cli';
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: 'en',
|
||||
locales: ['en', 'es', 'pseudo-LOCALE'],
|
||||
pseudoLocale: 'pseudo-LOCALE',
|
||||
fallbackLocales: {
|
||||
es: 'en',
|
||||
'pseudo-LOCALE': 'en',
|
||||
default: 'en',
|
||||
},
|
||||
catalogs: [
|
||||
{
|
||||
path: '<rootDir>/src/locales/{locale}/messages',
|
||||
include: ['src'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -6,10 +6,12 @@
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json --noEmit",
|
||||
"build-dev": "tsc -p tsconfig.build.json --noEmit --watch",
|
||||
"regen-routes": "tsr generate",
|
||||
"generate-client": "openapi-ts",
|
||||
"bundle": "vite build",
|
||||
"dev": "vite",
|
||||
"extract-messages": "lingui extract",
|
||||
"compile-messages": "lingui compile --typescript",
|
||||
"regen-routes": "tsr generate",
|
||||
"generate-client": "openapi-ts",
|
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest --run",
|
||||
@@ -22,6 +24,8 @@
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@hookform/error-message": "^2.0.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lingui/core": "^5.9.0",
|
||||
"@lingui/react": "^5.9.0",
|
||||
"@mui/icons-material": "^7.0.2",
|
||||
"@mui/material": "^7.0.2",
|
||||
"@mui/x-date-pickers": "^8.4.0",
|
||||
@@ -66,6 +70,9 @@
|
||||
"@tanstack/react-devtools": "^0.9.4",
|
||||
"@tanstack/react-form-devtools": "^0.2.13",
|
||||
"@tanstack/react-router-devtools": "^1.158.1",
|
||||
"@lingui/cli": "^5.9.0",
|
||||
"@lingui/swc-plugin": "^5.10.1",
|
||||
"@lingui/vite-plugin": "^5.9.0",
|
||||
"@tanstack/react-table": "8.19.3",
|
||||
"@tanstack/router-cli": "^1.35.4",
|
||||
"@tanstack/router-vite-plugin": "^1.133.13",
|
||||
@@ -81,7 +88,7 @@
|
||||
"@types/uuid": "^9.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Alert, Box, Toolbar, useTheme } from '@mui/material';
|
||||
import Container from '@mui/material/Container';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
@@ -13,8 +14,6 @@ import { TopBar } from './components/TopBar.tsx';
|
||||
import { useServerEventsSnackbar } from './hooks/useServerEvents.ts';
|
||||
import { useIsDarkMode } from './hooks/useTunarrTheme.ts';
|
||||
import { useVersion } from './hooks/useVersion.tsx';
|
||||
import { strings } from './strings.ts';
|
||||
|
||||
export function Root({ children }: { children?: React.ReactNode }) {
|
||||
useServerEventsSnackbar();
|
||||
|
||||
@@ -75,7 +74,11 @@ export function Root({ children }: { children?: React.ReactNode }) {
|
||||
</RouterButtonLink>
|
||||
}
|
||||
>
|
||||
{strings.FFMPEG_MISSING}
|
||||
<Trans>
|
||||
FFmpeg not found. For all features to work, we recommend
|
||||
installing FFmpeg 7.1+ or update your FFmpeg executable path in
|
||||
settings.
|
||||
</Trans>
|
||||
</Alert>
|
||||
) : null}
|
||||
{children ?? <Outlet />}
|
||||
@@ -88,7 +91,9 @@ export function Root({ children }: { children?: React.ReactNode }) {
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<RouterLink to={'/channels'}>Channels</RouterLink>
|
||||
<RouterLink to={'/channels'}>
|
||||
<Trans>Channels</Trans>
|
||||
</RouterLink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,67 @@
|
||||
import { setUiLocale } from '@/store/settings/actions.ts';
|
||||
import { DayjsProvider } from '@/providers/DayjsProvider.tsx';
|
||||
import useStore from '@/store/index.ts';
|
||||
import { ThemeProvider } from '@mui/material';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { ThemeProvider, createTheme } from '@mui/material';
|
||||
import { esES as muiEsES } from '@mui/material/locale';
|
||||
import { esES as pickersEsES } from '@mui/x-date-pickers/locales';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { ServerEventsProvider } from './components/server_events/ServerEventsProvider.tsx';
|
||||
import { TunarrApiProvider } from './context/TunarrApiContext.tsx';
|
||||
import { messages as enMessages } from './locales/en/messages';
|
||||
import { queryClient } from './queryClient.ts';
|
||||
import { router } from './router.ts';
|
||||
import { Theme } from './theme.ts';
|
||||
// Load English immediately as a synchronous fallback so the first render
|
||||
// always has text (no blank flash or message IDs).
|
||||
i18n.loadAndActivate({ locale: 'en', messages: enMessages });
|
||||
|
||||
export const Tunarr = () => {
|
||||
const locale = useStore((store) => store.settings.ui.i18n.locale);
|
||||
|
||||
// On mount and whenever the stored locale changes, load the catalog and
|
||||
// dayjs locale module. setUiLocale handles both; the English catalog is
|
||||
// already loaded above so 'en' is effectively idempotent.
|
||||
useEffect(() => {
|
||||
void setUiLocale(locale);
|
||||
}, [locale]);
|
||||
|
||||
const muiLocaleTheme = useMemo(() => {
|
||||
if (locale === 'es') {
|
||||
return createTheme(Theme, muiEsES, pickersEsES);
|
||||
}
|
||||
return Theme;
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<TunarrApiProvider queryClient={queryClient}>
|
||||
<DayjsProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ServerEventsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SnackbarProvider maxSnack={2} autoHideDuration={5000}>
|
||||
<ThemeProvider theme={Theme} noSsr>
|
||||
<RouterProvider basepath="/web" router={router} />
|
||||
</ThemeProvider>
|
||||
</SnackbarProvider>
|
||||
</QueryClientProvider>
|
||||
</ServerEventsProvider>
|
||||
</DndProvider>
|
||||
</LocalizationProvider>
|
||||
</DayjsProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<DayjsProvider>
|
||||
<LocalizationProvider
|
||||
dateAdapter={AdapterDayjs}
|
||||
>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ServerEventsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SnackbarProvider maxSnack={2} autoHideDuration={5000}>
|
||||
<ThemeProvider theme={muiLocaleTheme} noSsr>
|
||||
<RouterProvider basepath="/web" router={router} />
|
||||
</ThemeProvider>
|
||||
</SnackbarProvider>
|
||||
</QueryClientProvider>
|
||||
</ServerEventsProvider>
|
||||
</DndProvider>
|
||||
</LocalizationProvider>
|
||||
</DayjsProvider>
|
||||
</I18nProvider>
|
||||
</TunarrApiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useCyclicShuffle } from '../../hooks/programming_controls/useCyclicShuf
|
||||
import { useEpisodeNumberSort } from '../../hooks/programming_controls/useEpisodeNumberSort.ts';
|
||||
import { useProgramShuffle } from '../../hooks/programming_controls/useRandomSort.ts';
|
||||
import { useReleaseDateSort } from '../../hooks/programming_controls/useReleaseDateSort.ts';
|
||||
import { strings } from '../../strings.ts';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { ElevatedTooltip } from '../base/ElevatedTooltip.tsx';
|
||||
import { StyledMenu } from '../base/StyledMenu.tsx';
|
||||
import AddBlockShuffleModal from '../programming_controls/AddBlockShuffleModal.tsx';
|
||||
@@ -36,6 +36,7 @@ type SortOption =
|
||||
| 'shows';
|
||||
|
||||
export function ChannelProgrammingSort() {
|
||||
const { t } = useLingui();
|
||||
const [sort, setSort] = useState<SortOption | null>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [addBlockShuffleModalOpen, setAddBlockShuffleModalOpen] =
|
||||
@@ -140,7 +141,7 @@ export function ChannelProgrammingSort() {
|
||||
</MenuItem>
|
||||
<ElevatedTooltip
|
||||
elevation={5}
|
||||
title={strings.SHUFFLE_TOOLTIP}
|
||||
title={t`Completely randomizes the order of programs.`}
|
||||
placement="right"
|
||||
>
|
||||
<MenuItem
|
||||
@@ -157,7 +158,7 @@ export function ChannelProgrammingSort() {
|
||||
|
||||
<ElevatedTooltip
|
||||
elevation={5}
|
||||
title={strings.CYCLIC_SHUFFLE_TOOLTIP}
|
||||
title={t`Like Random Shuffle, but tries to preserve the sequence of episodes for each TV show. If a TV show has multiple instances of its episodes, they are also cycled appropriately.`}
|
||||
placement="right"
|
||||
>
|
||||
<MenuItem
|
||||
@@ -174,7 +175,7 @@ export function ChannelProgrammingSort() {
|
||||
</ElevatedTooltip>
|
||||
<ElevatedTooltip
|
||||
elevation={5}
|
||||
title={strings.BLOCK_SHUFFLE_TOOLTIP}
|
||||
title={t`Alternates TV shows in blocks of episodes. You can pick the number of episodes per show in each block and if the order of shows in each block should be randomized. Movies are moved to the bottom.`}
|
||||
placement="right"
|
||||
>
|
||||
<MenuItem
|
||||
@@ -192,7 +193,7 @@ export function ChannelProgrammingSort() {
|
||||
|
||||
<ElevatedTooltip
|
||||
elevation={5}
|
||||
title={strings.ALPHA_SORT_TOOLTIP}
|
||||
title={t`Sorts alphabetically by program title`}
|
||||
placement="right"
|
||||
>
|
||||
<MenuItem
|
||||
@@ -210,7 +211,7 @@ export function ChannelProgrammingSort() {
|
||||
|
||||
<ElevatedTooltip
|
||||
elevation={5}
|
||||
title={strings.RELEASE_SORT_TOOLTIP}
|
||||
title={t`Sorts everything by its release date. This will only work correctly if the release dates in Plex are correct. In case any item does not have a release date specified, it will be moved to the bottom.`}
|
||||
placement="right"
|
||||
>
|
||||
<MenuItem
|
||||
@@ -227,7 +228,7 @@ export function ChannelProgrammingSort() {
|
||||
</ElevatedTooltip>
|
||||
<ElevatedTooltip
|
||||
elevation={5}
|
||||
title={strings.EPISODE_SORT_TOOLTIP}
|
||||
title={t`Sorts the list by TV Show and the episodes in each TV show by their season/episode number. Movies are moved to the bottom of the schedule.`}
|
||||
placement="right"
|
||||
>
|
||||
<MenuItem
|
||||
|
||||
@@ -17,9 +17,9 @@ import React, { useState } from 'react';
|
||||
import { useCustomShowBlockShuffle } from '../../hooks/programming_controls/useBlockShuffle.ts';
|
||||
import { useProgramShuffle } from '../../hooks/programming_controls/useRandomSort.ts';
|
||||
import { useCustomShowReleaseDateSort } from '../../hooks/programming_controls/useReleaseDateSort.ts';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { setCurrentCustomShowProgramming } from '../../store/customShowEditor/actions.ts';
|
||||
import { useCustomShowEditor } from '../../store/selectors.ts';
|
||||
import { strings } from '../../strings.ts';
|
||||
import { ElevatedTooltip } from '../base/ElevatedTooltip.tsx';
|
||||
import { StyledMenu } from '../base/StyledMenu.tsx';
|
||||
import AddBlockShuffleModal from '../programming_controls/AddBlockShuffleModal.tsx';
|
||||
@@ -32,6 +32,7 @@ type OrdereredSort<T extends string> = `${T}-asc` | `${T}-desc`;
|
||||
type PossibleSorts = 'random' | OrdereredSort<'release'> | 'block';
|
||||
|
||||
export const CustomShowSortToolsMenu = () => {
|
||||
const { t } = useLingui();
|
||||
const { programList } = useCustomShowEditor();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = !!anchorEl;
|
||||
@@ -125,7 +126,7 @@ export const CustomShowSortToolsMenu = () => {
|
||||
Sort By...
|
||||
</MenuItem>
|
||||
<ElevatedTooltip
|
||||
title={strings.SHUFFLE_TOOLTIP}
|
||||
title={t`Completely randomizes the order of programs.`}
|
||||
placement="right"
|
||||
elevation={10}
|
||||
>
|
||||
@@ -144,7 +145,7 @@ export const CustomShowSortToolsMenu = () => {
|
||||
</MenuItem>
|
||||
</ElevatedTooltip>
|
||||
<ElevatedTooltip
|
||||
title={strings.RELEASE_SORT_TOOLTIP}
|
||||
title={t`Sorts everything by its release date. This will only work correctly if the release dates in Plex are correct. In case any item does not have a release date specified, it will be moved to the bottom.`}
|
||||
placement="right"
|
||||
elevation={10}
|
||||
>
|
||||
@@ -163,7 +164,7 @@ export const CustomShowSortToolsMenu = () => {
|
||||
</MenuItem>
|
||||
</ElevatedTooltip>
|
||||
<ElevatedTooltip
|
||||
title={strings.BLOCK_SHUFFLE_TOOLTIP}
|
||||
title={t`Alternates TV shows in blocks of episodes. You can pick the number of episodes per show in each block and if the order of shows in each block should be randomized. Movies are moved to the bottom.`}
|
||||
placement="right"
|
||||
elevation={10}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import DarkModeButton from '@/components/settings/DarkModeButton.tsx';
|
||||
import useStore from '@/store/index.ts';
|
||||
import { setUiLocale } from '@/store/settings/actions.ts';
|
||||
import type { SupportedLocales } from '@/store/settings/store.ts';
|
||||
import {
|
||||
setTimeFormat,
|
||||
setUiLocale,
|
||||
} from '@/store/settings/actions.ts';
|
||||
import type { SupportedLocales, TimeFormat } from '@/store/settings/store.ts';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
@@ -13,12 +21,18 @@ import { useCallback } from 'react';
|
||||
import type { Nullable } from '../../../types/util.ts';
|
||||
|
||||
export const WebSettings = () => {
|
||||
const { t } = useLingui();
|
||||
const locale = useStore((state) => state.settings.ui.i18n.locale);
|
||||
const timeFormat = useStore((state) => state.settings.ui.i18n.timeFormat);
|
||||
|
||||
const handleUiLocaleChange = useCallback(
|
||||
(value: Nullable<SupportedLocales>) => {
|
||||
const handleLocaleChange = useCallback((value: SupportedLocales) => {
|
||||
void setUiLocale(value);
|
||||
}, []);
|
||||
|
||||
const handleTimeFormatChange = useCallback(
|
||||
(value: Nullable<TimeFormat>) => {
|
||||
if (value) {
|
||||
setUiLocale(value);
|
||||
void setTimeFormat(value);
|
||||
}
|
||||
},
|
||||
[],
|
||||
@@ -27,34 +41,74 @@ export const WebSettings = () => {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="h5">Web Settings</Typography>
|
||||
<Typography variant="h5">
|
||||
<Trans>Web Settings</Trans>
|
||||
</Typography>
|
||||
<Typography variant="subtitle2">
|
||||
These settings are stored in your browser and are saved automatically
|
||||
when changed.
|
||||
<Trans>
|
||||
These settings are stored in your browser and are saved
|
||||
automatically when changed.
|
||||
</Trans>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<ToggleButtonGroup
|
||||
value={locale}
|
||||
exclusive
|
||||
onChange={(_, value) =>
|
||||
handleUiLocaleChange(value as Nullable<SupportedLocales>)
|
||||
}
|
||||
aria-label="text alignment"
|
||||
>
|
||||
<ToggleButton value="en" aria-label="left aligned">
|
||||
12-hour
|
||||
</ToggleButton>
|
||||
<ToggleButton value="en-gb" aria-label="centered">
|
||||
24-hour
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
<Trans>Language</Trans>
|
||||
</Typography>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel id="language-select-label">
|
||||
<Trans>Language</Trans>
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="language-select-label"
|
||||
value={locale}
|
||||
label={t`Language`}
|
||||
onChange={(e) =>
|
||||
handleLocaleChange(e.target.value as SupportedLocales)
|
||||
}
|
||||
>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="es">Español</MenuItem>
|
||||
{import.meta.env.DEV && (
|
||||
<MenuItem value="pseudo-LOCALE">pseudo-LOCALE (dev)</MenuItem>
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Theme Settings
|
||||
<Trans>Time Format</Trans>
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={timeFormat}
|
||||
exclusive
|
||||
onChange={(_, value) =>
|
||||
handleTimeFormatChange(value as Nullable<TimeFormat>)
|
||||
}
|
||||
aria-label={t`Time format`}
|
||||
>
|
||||
<ToggleButton value="12h">
|
||||
<Trans>12-hour</Trans>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="24h">
|
||||
<Trans>24-hour</Trans>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="auto">
|
||||
<Trans>Auto</Trans>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
|
||||
<Trans>
|
||||
Auto uses the time convention for the selected language.
|
||||
</Trans>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
<Trans>Theme Settings</Trans>
|
||||
</Typography>
|
||||
<DarkModeButton />
|
||||
</Box>
|
||||
|
||||
27
web/src/helpers/localeLoader.ts
Normal file
27
web/src/helpers/localeLoader.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Maps Lingui locale codes to their corresponding dayjs locale codes.
|
||||
* 'pseudo-LOCALE' has no dayjs equivalent, so it falls back to 'en'.
|
||||
*/
|
||||
export const LINGUI_TO_DAYJS: Record<string, string> = {
|
||||
en: 'en',
|
||||
es: 'es',
|
||||
'pseudo-LOCALE': 'en',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the dayjs locale code for a given Lingui locale.
|
||||
*/
|
||||
export function getLinguiToDayjsLocale(locale: string): string {
|
||||
return LINGUI_TO_DAYJS[locale] ?? 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically imports the dayjs locale module for a given Lingui locale.
|
||||
* 'en' is always built into dayjs and requires no dynamic import.
|
||||
*/
|
||||
export async function loadDayjsLocale(locale: string): Promise<void> {
|
||||
const dayjsLocale = getLinguiToDayjsLocale(locale);
|
||||
if (dayjsLocale !== 'en') {
|
||||
await import(`dayjs/locale/${dayjsLocale}.js`);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { router } from '@/router.ts';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
Computer,
|
||||
Delete,
|
||||
@@ -52,52 +53,52 @@ export const useNavItems = () => {
|
||||
return useMemo<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
name: 'Welcome',
|
||||
name: t`Welcome`,
|
||||
path: '/welcome',
|
||||
hidden: !showWelcome,
|
||||
icon: <Home />,
|
||||
},
|
||||
{ name: 'Guide', path: '/guide', icon: <Tv /> },
|
||||
{ name: t`Guide`, path: '/guide', icon: <Tv /> },
|
||||
{
|
||||
name: 'Channels',
|
||||
name: t`Channels`,
|
||||
path: '/channels',
|
||||
icon: <SettingsRemote />,
|
||||
},
|
||||
// { name: 'Watch', path: '/watch', hidden: true, icon: <LiveTv /> },
|
||||
{
|
||||
name: 'Library',
|
||||
name: t`Library`,
|
||||
path: '/library',
|
||||
icon: <VideoLibrary />,
|
||||
children: [
|
||||
{
|
||||
name: 'Filler Lists',
|
||||
name: t`Filler Lists`,
|
||||
path: '/library/fillers',
|
||||
icon: <Preview />,
|
||||
},
|
||||
{
|
||||
name: 'Smart Collections',
|
||||
name: t`Smart Collections`,
|
||||
path: '/library/smart_collections',
|
||||
icon: <Psychology />,
|
||||
},
|
||||
{
|
||||
name: 'Custom Shows',
|
||||
name: t`Custom Shows`,
|
||||
path: '/library/custom-shows' as const,
|
||||
icon: <Theaters />,
|
||||
},
|
||||
{
|
||||
name: 'Trash',
|
||||
name: t`Trash`,
|
||||
path: '/library/trash',
|
||||
icon: <Delete />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Sources',
|
||||
name: t`Sources`,
|
||||
path: '/media_sources',
|
||||
icon: <StorageIcon />,
|
||||
},
|
||||
{
|
||||
name: 'System',
|
||||
name: t`System`,
|
||||
path: '/system',
|
||||
icon: <Computer />,
|
||||
badge: highestSev
|
||||
@@ -108,24 +109,24 @@ export const useNavItems = () => {
|
||||
: undefined,
|
||||
children: [
|
||||
{
|
||||
name: 'Status',
|
||||
name: t`Status`,
|
||||
path: '/system',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
name: 'Debug',
|
||||
name: t`Debug`,
|
||||
path: '/system/debug',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
name: t`Logs`,
|
||||
path: '/system/logs',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
name: t`Settings`,
|
||||
path: '/settings/general',
|
||||
icon: <Settings />,
|
||||
children: [
|
||||
|
||||
19
web/src/i18n.ts
Normal file
19
web/src/i18n.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { messages as enMessages } from './locales/en/messages';
|
||||
|
||||
/**
|
||||
* Loads and activates a Lingui message catalog for the given locale.
|
||||
* Falls back to English if the catalog cannot be loaded.
|
||||
* Idempotent: skips loading if the locale is already active.
|
||||
*/
|
||||
export async function loadCatalog(locale: string) {
|
||||
if (i18n.locale === locale) return;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { messages } = await import(`./locales/${locale}/messages`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
i18n.loadAndActivate({ locale, messages });
|
||||
} catch {
|
||||
i18n.loadAndActivate({ locale: 'en', messages: enMessages });
|
||||
}
|
||||
}
|
||||
100
web/src/locales/en/messages.po
Normal file
100
web/src/locales/en/messages.po
Normal file
@@ -0,0 +1,100 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-02-10 20:45-0500\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: en\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/pages/channels/ChannelsPage.tsx:195
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
#: src/App.tsx:93
|
||||
#: src/hooks/useNavItems.tsx:63
|
||||
msgid "Channels"
|
||||
msgstr "Channels"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:84
|
||||
msgid "Custom Shows"
|
||||
msgstr "Custom Shows"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:117
|
||||
msgid "Debug"
|
||||
msgstr "Debug"
|
||||
|
||||
#: src/pages/channels/ChannelsPage.tsx:201
|
||||
msgid "Delete"
|
||||
msgstr "Delete"
|
||||
|
||||
#: src/pages/channels/ChannelsPage.tsx:180
|
||||
msgid "Delete Channel"
|
||||
msgstr "Delete Channel"
|
||||
|
||||
#: src/pages/channels/ChannelsPage.tsx:184
|
||||
msgid "Deleting a Channel will remove all programming from the channel. This action cannot be undone."
|
||||
msgstr "Deleting a Channel will remove all programming from the channel. This action cannot be undone."
|
||||
|
||||
#: src/hooks/useNavItems.tsx:74
|
||||
msgid "Filler Lists"
|
||||
msgstr "Filler Lists"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:61
|
||||
#: src/pages/guide/GuidePage.tsx:120
|
||||
msgid "Guide"
|
||||
msgstr "Guide"
|
||||
|
||||
#: src/pages/guide/GuidePage.tsx:137
|
||||
msgid "Guide Start Time"
|
||||
msgstr "Guide Start Time"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:69
|
||||
msgid "Library"
|
||||
msgstr "Library"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:122
|
||||
msgid "Logs"
|
||||
msgstr "Logs"
|
||||
|
||||
#: src/pages/guide/GuidePage.tsx:142
|
||||
msgid "Reset to current date/time"
|
||||
msgstr "Reset to current date/time"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:129
|
||||
msgid "Settings"
|
||||
msgstr "Settings"
|
||||
|
||||
#: src/pages/guide/GuidePage.tsx:155
|
||||
msgid "Show Stealth"
|
||||
msgstr "Show Stealth"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:79
|
||||
msgid "Smart Collections"
|
||||
msgstr "Smart Collections"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:96
|
||||
msgid "Sources"
|
||||
msgstr "Sources"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:112
|
||||
msgid "Status"
|
||||
msgstr "Status"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:101
|
||||
msgid "System"
|
||||
msgstr "System"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:89
|
||||
msgid "Trash"
|
||||
msgstr "Trash"
|
||||
|
||||
#: src/hooks/useNavItems.tsx:56
|
||||
msgid "Welcome"
|
||||
msgstr "Welcome"
|
||||
1
web/src/locales/en/messages.ts
Normal file
1
web/src/locales/en/messages.ts
Normal file
@@ -0,0 +1 @@
|
||||
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"KkOthv\":[\"Guide\"],\"lfFsZ4\":[\"Channels\"]}")as Messages;
|
||||
14
web/src/locales/es/messages.po
Normal file
14
web/src/locales/es/messages.po
Normal file
@@ -0,0 +1,14 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-04-11 00:00+0000\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: es\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
1
web/src/locales/es/messages.ts
Normal file
1
web/src/locales/es/messages.ts
Normal file
@@ -0,0 +1 @@
|
||||
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{}")as Messages;
|
||||
100
web/src/locales/pseudo-LOCALE/messages.po
Normal file
100
web/src/locales/pseudo-LOCALE/messages.po
Normal file
@@ -0,0 +1,100 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-02-11 20:42-0500\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: pseudo-LOCALE\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/pages/channels/ChannelsPage.tsx:195
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: src/App.tsx:93
|
||||
#: src/hooks/useNavItems.tsx:63
|
||||
msgid "Channels"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:84
|
||||
msgid "Custom Shows"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:117
|
||||
msgid "Debug"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/channels/ChannelsPage.tsx:201
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/channels/ChannelsPage.tsx:180
|
||||
msgid "Delete Channel"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/channels/ChannelsPage.tsx:184
|
||||
msgid "Deleting a Channel will remove all programming from the channel. This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:74
|
||||
msgid "Filler Lists"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:61
|
||||
#: src/pages/guide/GuidePage.tsx:120
|
||||
msgid "Guide"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/guide/GuidePage.tsx:137
|
||||
msgid "Guide Start Time"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:69
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:122
|
||||
msgid "Logs"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/guide/GuidePage.tsx:142
|
||||
msgid "Reset to current date/time"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:129
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/guide/GuidePage.tsx:155
|
||||
msgid "Show Stealth"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:79
|
||||
msgid "Smart Collections"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:96
|
||||
msgid "Sources"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:112
|
||||
msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:101
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:89
|
||||
msgid "Trash"
|
||||
msgstr ""
|
||||
|
||||
#: src/hooks/useNavItems.tsx:56
|
||||
msgid "Welcome"
|
||||
msgstr ""
|
||||
1
web/src/locales/pseudo-LOCALE/messages.ts
Normal file
1
web/src/locales/pseudo-LOCALE/messages.ts
Normal file
@@ -0,0 +1 @@
|
||||
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"KkOthv\":[\"Ĝũĩďē\"],\"lfFsZ4\":[\"Ćĥàńńēĺś\"]}")as Messages;
|
||||
@@ -2,7 +2,6 @@ import languages from '@cospired/i18n-iso-languages';
|
||||
import en from '@cospired/i18n-iso-languages/langs/en.json';
|
||||
import { ColorSpace, LCH, OKLCH, sRGB } from 'colorjs.io/fn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/en-gb';
|
||||
import localeData from 'dayjs/plugin/localeData';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import React from 'react';
|
||||
@@ -17,7 +16,6 @@ ColorSpace.register(sRGB);
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(localeData);
|
||||
dayjs.locale('en-gb');
|
||||
|
||||
// Initialize the languages database with English names
|
||||
// TODO: localize this and make it a context provider
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { betterHumanize } from '@/helpers/dayjs.ts';
|
||||
import { useTranscodeConfigs } from '@/hooks/settingsHooks.ts';
|
||||
import type { Maybe } from '@/types/util.ts';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Check, Close, Edit, MoreVert } from '@mui/icons-material';
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import type { BoxProps } from '@mui/material';
|
||||
@@ -182,12 +183,14 @@ export default function ChannelsPage() {
|
||||
{deleteChannelConfirmation && (
|
||||
<>
|
||||
<DialogTitle id="delete-channel-title">
|
||||
Delete Channel "{deleteChannelConfirmation.name}"?
|
||||
<Trans>Delete Channel</Trans> "{deleteChannelConfirmation.name}"?
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="delete-channel-description">
|
||||
Deleting a Channel will remove all programming from the channel.
|
||||
This action cannot be undone.
|
||||
<Trans>
|
||||
Deleting a Channel will remove all programming from the
|
||||
channel. This action cannot be undone.
|
||||
</Trans>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -195,13 +198,13 @@ export default function ChannelsPage() {
|
||||
onClick={() => setDeleteChannelConfirmation(undefined)}
|
||||
autoFocus
|
||||
>
|
||||
Cancel
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => removeChannel(deleteChannelConfirmation.id)}
|
||||
variant="contained"
|
||||
>
|
||||
Delete
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowBackIos,
|
||||
ArrowForwardIos,
|
||||
@@ -115,7 +117,7 @@ export default function GuidePage({ channelId }: Props = { channelId: 'all' }) {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h3" mb={2}>
|
||||
Guide
|
||||
<Trans>Guide</Trans>
|
||||
</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }}>
|
||||
<Stack
|
||||
@@ -132,12 +134,12 @@ export default function GuidePage({ channelId }: Props = { channelId: 'all' }) {
|
||||
minDateTime={roundCurrentTime(15)}
|
||||
value={start}
|
||||
onChange={(v) => handleDayChange(v)}
|
||||
label="Guide Start Time"
|
||||
label={t`Guide Start Time`}
|
||||
/>
|
||||
</FormControl>
|
||||
<Typography>{dayjs.duration(end.diff(start)).humanize()}</Typography>
|
||||
{!dayjs().isBetween(start, end) && (
|
||||
<Tooltip title={'Reset to current date/time'} placement="top">
|
||||
<Tooltip title={t`Reset to current date/time`} placement="top">
|
||||
<IconButton onClick={handleNavigationReset}>
|
||||
<History />
|
||||
</IconButton>
|
||||
@@ -150,7 +152,7 @@ export default function GuidePage({ channelId }: Props = { channelId: 'all' }) {
|
||||
onChange={(_, checked) => setShowStealth(checked)}
|
||||
/>
|
||||
}
|
||||
label="Show Stealth"
|
||||
label={t`Show Stealth`}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getLinguiToDayjsLocale } from '@/helpers/localeLoader.ts';
|
||||
import useStore from '@/store';
|
||||
import originalDayjs from 'dayjs';
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import type { ContextType } from './DayjsContext.tsx';
|
||||
import { DayjsContext } from './DayjsContext.tsx';
|
||||
|
||||
@@ -10,14 +11,35 @@ type Props = {
|
||||
|
||||
export const DayjsProvider = ({ children }: Props) => {
|
||||
const locale = useStore((store) => store.settings.ui.i18n.locale);
|
||||
const timeFormat = useStore((store) => store.settings.ui.i18n.timeFormat);
|
||||
|
||||
const effectiveDayjsLocale = useMemo(() => {
|
||||
if (timeFormat === '12h') return 'en';
|
||||
if (timeFormat === '24h') return 'en-gb';
|
||||
return getLinguiToDayjsLocale(locale);
|
||||
}, [locale, timeFormat]);
|
||||
|
||||
// Async-load the dayjs locale module if needed (handles stored locale on startup
|
||||
// and the 'en-gb' module for 24h format)
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (effectiveDayjsLocale !== 'en') {
|
||||
await import(`dayjs/locale/${effectiveDayjsLocale}.js`);
|
||||
}
|
||||
originalDayjs.locale(effectiveDayjsLocale);
|
||||
};
|
||||
void load();
|
||||
}, [effectiveDayjsLocale]);
|
||||
|
||||
const value = useMemo(() => {
|
||||
originalDayjs.locale(locale);
|
||||
originalDayjs.locale(effectiveDayjsLocale);
|
||||
return {
|
||||
dayjs: (date?: originalDayjs.ConfigType) => {
|
||||
return originalDayjs(date);
|
||||
},
|
||||
} satisfies ContextType;
|
||||
}, [locale]);
|
||||
}, [effectiveDayjsLocale]);
|
||||
|
||||
return (
|
||||
<DayjsContext.Provider value={value}>{children}</DayjsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SupportedLocales } from '@/store/settings/store.ts';
|
||||
import { loadDayjsLocale } from '@/helpers/localeLoader.ts';
|
||||
import { loadCatalog } from '@/i18n.ts';
|
||||
import type { SupportedLocales, TimeFormat } from '@/store/settings/store.ts';
|
||||
import type { PaginationState, SortingState } from '@tanstack/react-table';
|
||||
import dayjs from 'dayjs';
|
||||
import useStore from '..';
|
||||
|
||||
export const setBackendUri = (uri: string) =>
|
||||
@@ -49,11 +50,22 @@ export const setChannelPaginationState = (p: PaginationState) =>
|
||||
settings.ui.channelTablePagination = p;
|
||||
});
|
||||
|
||||
export const setUiLocale = (locale: SupportedLocales) =>
|
||||
export const setUiLocale = async (locale: SupportedLocales) => {
|
||||
await loadDayjsLocale(locale);
|
||||
await loadCatalog(locale);
|
||||
useStore.setState(({ settings }) => {
|
||||
dayjs.locale(locale); // Changes the default dayjs locale globally
|
||||
settings.ui.i18n.locale = locale;
|
||||
});
|
||||
};
|
||||
|
||||
export const setTimeFormat = async (format: TimeFormat) => {
|
||||
if (format === '24h') {
|
||||
await import('dayjs/locale/en-gb');
|
||||
}
|
||||
useStore.setState(({ settings }) => {
|
||||
settings.ui.i18n.timeFormat = format;
|
||||
});
|
||||
};
|
||||
|
||||
export const setShowAdvancedSettings = (value: boolean) =>
|
||||
useStore.setState(({ settings }) => {
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { DeepPartial } from 'ts-essentials';
|
||||
import { z } from 'zod';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
// Only these 2 are supported currently
|
||||
export const SupportedLocales = ['en', 'en-gb'] as const;
|
||||
export const SupportedLocales = ['en', 'es', 'pseudo-LOCALE'] as const;
|
||||
export type SupportedLocales = TupleToUnion<typeof SupportedLocales>;
|
||||
export type TimeFormat = '12h' | '24h' | 'auto';
|
||||
|
||||
const CurrentSettingsSchemaVersion = 1;
|
||||
|
||||
@@ -35,6 +35,7 @@ export const SettingsStateInternalSchema = z.object({
|
||||
channelTableColumnModel: z.record(z.string(), z.boolean()),
|
||||
i18n: z.object({
|
||||
locale: z.enum(SupportedLocales),
|
||||
timeFormat: z.enum(['12h', '24h', 'auto']).default('auto'),
|
||||
}),
|
||||
tableSettings: z.record(z.string(), TableSettingsSchema),
|
||||
showAdvancedSettings: z.boolean(),
|
||||
@@ -73,6 +74,7 @@ export const createSettingsSlice: StateCreator<SettingsState> = () => ({
|
||||
},
|
||||
i18n: {
|
||||
locale: 'en',
|
||||
timeFormat: 'auto',
|
||||
},
|
||||
tableSettings: {},
|
||||
showAdvancedSettings: false,
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
export const strings = {
|
||||
SHUFFLE_TOOLTIP: 'Completely randomizes the order of programs.',
|
||||
ALPHA_SORT_TOOLTIP: 'Sorts alphabetically by program title',
|
||||
CYCLIC_SHUFFLE_TOOLTIP:
|
||||
'Like Random Shuffle, but tries to preserve the sequence of episodes for each TV show. If a TV show has multiple instances of its episodes, they are also cycled appropriately.',
|
||||
BLOCK_SHUFFLE_TOOLTIP:
|
||||
'Alternates TV shows in blocks of episodes. You can pick the number of episodes per show in each block and if the order of shows in each block should be randomized. Movies are moved to the bottom.',
|
||||
EPISODE_SORT_TOOLTIP:
|
||||
'Sorts the list by TV Show and the episodes in each TV show by their season/episode number. Movies are moved to the bottom of the schedule.',
|
||||
RELEASE_SORT_TOOLTIP:
|
||||
'Sorts everything by its release date. This will only work correctly if the release dates in Plex are correct. In case any item does not have a release date specified, it will be moved to the bottom.',
|
||||
FFMPEG_MISSING:
|
||||
'FFmpeg not found. For all features to work, we recommend installing FFmpeg 7.1+ or update your FFmpeg executable path in settings.',
|
||||
};
|
||||
// This file has been migrated to Lingui i18n macros.
|
||||
// All strings are now inlined at their usage sites using <Trans> and t``.
|
||||
// This file is kept as an empty export to avoid breaking any lingering imports
|
||||
// at compile time, but all imports of `strings` should be removed.
|
||||
export const strings = {} as Record<string, never>;
|
||||
|
||||
31
web/src/test/i18n.test.ts
Normal file
31
web/src/test/i18n.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { loadCatalog } from '../i18n.ts';
|
||||
|
||||
describe('loadCatalog', () => {
|
||||
beforeEach(() => {
|
||||
// Reset to a known state before each test
|
||||
i18n.loadAndActivate({ locale: 'en', messages: {} });
|
||||
});
|
||||
|
||||
it('activates the English locale', async () => {
|
||||
await loadCatalog('en');
|
||||
expect(i18n.locale).toBe('en');
|
||||
});
|
||||
|
||||
it('is idempotent — loading the same locale twice does not error', async () => {
|
||||
await loadCatalog('en');
|
||||
await expect(loadCatalog('en')).resolves.toBeUndefined();
|
||||
expect(i18n.locale).toBe('en');
|
||||
});
|
||||
|
||||
it('falls back to English when given a nonexistent locale', async () => {
|
||||
await loadCatalog('nonexistent-LOCALE');
|
||||
expect(i18n.locale).toBe('en');
|
||||
});
|
||||
|
||||
it('activates Spanish locale', async () => {
|
||||
await loadCatalog('es');
|
||||
expect(i18n.locale).toBe('es');
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,21 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { afterEach } from 'vitest';
|
||||
import { afterEach, beforeAll } from 'vitest';
|
||||
|
||||
// Load dayjs plugins needed by components
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
// Initialize Lingui with an empty English catalog so components using
|
||||
// <Trans> and t`` render their message IDs (English source strings) in tests.
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: 'en', messages: {} });
|
||||
});
|
||||
|
||||
// Automatically cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { Theme } from '@/theme.ts';
|
||||
import { ThemeProvider } from '@mui/material';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { ThemeProvider } from '@mui/material';
|
||||
import { Theme } from '@/theme.ts';
|
||||
|
||||
/**
|
||||
* Creates a fresh QueryClient configured for testing.
|
||||
@@ -37,11 +39,13 @@ function TestProviders({ children, queryClient }: TestProvidersProps) {
|
||||
const client = queryClient ?? createTestQueryClient();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ThemeProvider theme={Theme}>{children}</ThemeProvider>
|
||||
</DndProvider>
|
||||
</QueryClientProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ThemeProvider theme={Theme}>{children}</ThemeProvider>
|
||||
</DndProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
"clean-build": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"compile-messages": {
|
||||
"dependsOn": ["extract-messages"]
|
||||
},
|
||||
"dev": {
|
||||
"dependsOn": ["compile-messages"]
|
||||
},
|
||||
"extract-messages": {},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import dotenv from '@dotenvx/dotenvx';
|
||||
dotenv.config({ debug: false });
|
||||
|
||||
import { lingui } from '@lingui/vite-plugin';
|
||||
import { tanstackRouter } from '@tanstack/router-vite-plugin';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import path from 'node:path';
|
||||
@@ -33,7 +34,10 @@ const version = (() => {
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
react({
|
||||
plugins: [['@lingui/swc-plugin', {}]],
|
||||
}),
|
||||
lingui(),
|
||||
tanstackRouter({
|
||||
semicolons: true,
|
||||
routesDirectory: path.resolve(__dirname, './src/routes'),
|
||||
|
||||
Reference in New Issue
Block a user