Migrate to lingui

Switch to "lingui" for better DX.
Tried to persist existing languages as much as possible.

Removed "vite-plugin-node-polyfills" because it's incompatible with "lingui"
This commit is contained in:
schroda
2026-01-10 19:45:02 +01:00
parent c1ac58f4d6
commit 65a9a905be
287 changed files with 120766 additions and 37748 deletions

View File

@@ -11,7 +11,6 @@ import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import IconButton from '@mui/material/IconButton';
import SaveIcon from '@mui/icons-material/Save';
import Chip from '@mui/material/Chip';
@@ -23,6 +22,7 @@ import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import TextField from '@mui/material/TextField';
import { useLingui } from '@lingui/react/macro';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { OptionsPanel } from '@/base/components/modals/OptionsPanel.tsx';
import { CheckBoxFilter } from '@/features/source/browse/components/filters/CheckBoxFilter.tsx';
@@ -168,7 +168,7 @@ export function SourceOptions({
setTriggerUpdate,
update,
}: IFilters1) {
const { t } = useTranslation();
const { t } = useLingui();
const [FilterOptions, setFilterOptions] = useState(false);
const [newSavedSearch, setNewSavedSearch] = useState('');
@@ -189,23 +189,22 @@ export function SourceOptions({
<>
<StyledFab onClick={() => setFilterOptions(!FilterOptions)} variant="extended" color="primary">
<FilterListIcon />
{t('global.button.filter')}
{t`Filter`}
</StyledFab>
<OptionsPanel open={FilterOptions} onClose={() => setFilterOptions(false)}>
<Box sx={{ p: 2, pb: savedSearchesExist ? undefined : 0 }}>
<Box sx={{ display: 'flex', pb: 1 }}>
<Button onClick={handleReset}>{t('global.button.reset')}</Button>
<Button onClick={handleReset}>{t`Reset`}</Button>
<PopupState variant="dialog" popupId="source-browse-save-search">
{(popupState) => (
<>
<CustomTooltip title={t('source.filter.save_search.label.save')}>
<CustomTooltip title={t`Save search`}>
<IconButton sx={{ marginLeft: 'auto' }} {...bindTrigger(popupState)}>
<SaveIcon />
</IconButton>
</CustomTooltip>
<Dialog {...bindDialog(popupState)} maxWidth="xs" fullWidth>
<DialogTitle>{t('source.filter.save_search.dialog.label.title')}</DialogTitle>
<DialogTitle>{t`Save current search`}</DialogTitle>
<DialogContent>
<TextField
sx={{ width: '100%' }}
@@ -223,7 +222,7 @@ export function SourceOptions({
popupState.close();
}}
>
{t('global.button.cancel')}
{t`Cancel`}
</Button>
<Button
onClick={() => {
@@ -232,7 +231,7 @@ export function SourceOptions({
popupState.close();
}}
>
{t('global.button.ok')}
{t`Ok`}
</Button>
</DialogActions>
</Dialog>
@@ -241,7 +240,7 @@ export function SourceOptions({
</PopupState>
<Button variant="contained" onClick={handleSubmit}>
{t('global.button.submit')}
{t`Submit`}
</Button>
</Box>
{savedSearchesExist && (
@@ -257,19 +256,19 @@ export function SourceOptions({
}}
onDelete={() => {
Confirmation.show({
title: t('global.label.are_you_sure'),
message: t('source.filter.save_search.dialog.label.delete', {
name: savedSearch,
}),
title: t`Are you sure?`,
message: t`This will delete the saved search "${savedSearch}"`,
actions: {
confirm: { title: t('global.button.delete') },
confirm: {
title: t`Delete`,
},
},
})
.then(() => updateSavedSearches(savedSearch, 'delete'))
.catch(defaultPromiseErrorHandler('SourceOptions::deleteSavedSearch'));
}}
deleteIcon={
<CustomTooltip title={t('source.filter.save_search.label.delete')}>
<CustomTooltip title={t`Delete search`}>
<DeleteIcon />
</CustomTooltip>
}

View File

@@ -6,12 +6,12 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { MessageDescriptor } from '@lingui/core';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams, useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import IconButton from '@mui/material/IconButton';
import SettingsIcon from '@mui/icons-material/Settings';
import { useQueryParam, StringParam } from 'use-query-params';
import { useTranslation } from 'react-i18next';
import Link from '@mui/material/Link';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
@@ -19,6 +19,8 @@ import { styled } from '@mui/material/styles';
import FavoriteIcon from '@mui/icons-material/Favorite';
import NewReleasesIcon from '@mui/icons-material/NewReleases';
import FilterListIcon from '@mui/icons-material/FilterList';
import { useLingui } from '@lingui/react/macro';
import { msg } from '@lingui/core/macro';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import {
requestManager,
@@ -49,7 +51,7 @@ import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'
import { EmptyView } from '@/base/components/feedback/EmptyView.tsx';
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { GridLayout, SearchParam, TranslationKey } from '@/base/Base.types.ts';
import { GridLayout, SearchParam } from '@/base/Base.types';
import { AppRoutes } from '@/base/AppRoute.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { Sources } from '@/features/source/services/Sources.ts';
@@ -83,10 +85,10 @@ export enum SourceContentType {
SEARCH,
}
const SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY: { [contentType in SourceContentType]: TranslationKey } = {
[SourceContentType.POPULAR]: 'manga.error.label.no_mangas_found',
[SourceContentType.LATEST]: 'manga.error.label.no_mangas_found',
[SourceContentType.SEARCH]: 'manga.error.label.no_matches',
const SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY: { [contentType in SourceContentType]: MessageDescriptor } = {
[SourceContentType.POPULAR]: msg`No manga found`,
[SourceContentType.LATEST]: msg`No manga found`,
[SourceContentType.SEARCH]: msg`No manga matches this filter`,
};
const getUniqueMangas = <Manga extends MangaIdInfo>(mangas: Manga[]): Manga[] => {
@@ -207,7 +209,7 @@ const useSourceManga = (
};
export function SourceMangas() {
const { t } = useTranslation();
const { t } = useLingui();
const { appBarHeight } = useNavBarContext();
const { sourceId } = useParams<{ sourceId: string }>();
@@ -301,7 +303,7 @@ export function SourceMangas() {
const filters = source?.filters ?? [];
const { savedSearches = {} } = useGetSourceMetadata(source ?? DEFAULT_SOURCE);
const updateSourceMetadata = createUpdateSourceMetadata<'savedSearches'>(source ?? { id: '-1' }, (e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)),
);
const selectSavedSearch = useCallback(
@@ -343,9 +345,9 @@ export function SourceMangas() {
const isLocalSource = sourceId === Sources.LOCAL_SOURCE_ID;
const messageExtra = isLocalSource ? (
<>
<span>{t('source.local_source.label.checkout')} </span>
<span>{t`Check out`} </span>
<Link href="https://github.com/Suwayomi/Suwayomi-Server/wiki/Local-Source" target="_blank" rel="noreferrer">
{t('source.local_source.label.guide')}
{t`Local source guide`}
</Link>
</>
) : undefined;
@@ -413,11 +415,11 @@ export function SourceMangas() {
}, [clearCache]);
useAppTitleAndAction(
source?.displayName ?? t('source.title_one'),
source?.displayName ?? t`Source`,
<>
<AppbarSearch />
<SourceGridLayout />
<CustomTooltip title={t('global.button.open_webview')} disabled={!source?.baseUrl}>
<CustomTooltip title={t`Open in WebView`} disabled={!source?.baseUrl}>
<IconButton
disabled={!source?.baseUrl}
href={source?.baseUrl ? requestManager.getWebviewUrl(source?.baseUrl) : ''}
@@ -429,7 +431,7 @@ export function SourceMangas() {
</IconButton>
</CustomTooltip>
{source?.isConfigurable && (
<CustomTooltip title={t('settings.title')}>
<CustomTooltip title={t`Settings`}>
<IconButton
onClick={() => navigate(AppRoutes.sources.childRoutes.configure.path(sourceId))}
aria-label="display more actions"
@@ -454,7 +456,7 @@ export function SourceMangas() {
startIcon={<FavoriteIcon />}
onClick={() => updateContentType(SourceContentType.POPULAR)}
>
{t('global.button.popular')}
{t`Popular`}
</ContentTypeButton>
{source?.supportsLatest === undefined || source.supportsLatest ? (
<ContentTypeButton
@@ -463,7 +465,7 @@ export function SourceMangas() {
startIcon={<NewReleasesIcon />}
onClick={() => updateContentType(SourceContentType.LATEST)}
>
{t('global.button.latest')}
{t`Latest`}
</ContentTypeButton>
) : null}
<ContentTypeButton
@@ -471,10 +473,9 @@ export function SourceMangas() {
startIcon={<FilterListIcon />}
onClick={() => updateContentType(SourceContentType.SEARCH, query)}
>
{t('global.button.filter')}
{t`Filter`}
</ContentTypeButton>
</ContentTypeMenu>
{(isLoading || !error || (!!error && !!mangas.length)) && (
<BaseMangaGrid
// the key needs to include filters and query to force a re-render of the virtuoso grid to prevent https://github.com/petyosi/react-virtuoso/issues/1242
@@ -491,15 +492,13 @@ export function SourceMangas() {
inLibraryIndicator
/>
)}
{error && (
<EmptyViewComponent
message={t('global.error.label.failed_to_load_data')}
message={t`Unable to load data`}
messageExtra={getErrorMessage(error)}
retry={() => loadPage(lastPageNum).catch(defaultPromiseErrorHandler('SourceMangas::refetch'))}
/>
)}
{contentType === SourceContentType.SEARCH && (
<SourceOptions
savedSearches={savedSearches}

View File

@@ -17,10 +17,10 @@ import IconButton from '@mui/material/IconButton';
import FilterListIcon from '@mui/icons-material/FilterList';
import ListItemText from '@mui/material/ListItemText';
import ListItem from '@mui/material/ListItem';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import Box from '@mui/material/Box';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import { useLingui } from '@lingui/react/macro';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { translateExtensionLanguage } from '@/features/extension/Extensions.utils.ts';
import { languageSortComparator } from '@/base/utils/Languages.ts';
@@ -56,7 +56,7 @@ export const SourceLanguageSelect = ({
SourceIconInfo &
SourceMetaInfo)[];
}) => {
const { t } = useTranslation();
const { t } = useLingui();
const [tmpSourceIdToEnabledState, setTmpSourceIdToEnabledState] = useState<Record<SourceIdInfo['id'], boolean>>({});
@@ -88,7 +88,7 @@ export const SourceLanguageSelect = ({
Object.entries(tmpSourceIdToEnabledState).map(([sourceId, enabled]) =>
updateSourceMetadata(sources.find((source) => source.id === sourceId)!, 'isEnabled', enabled),
),
).catch((e) => makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)));
).catch((e) => makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)));
setTmpSourceIdToEnabledState({});
setSelectedLanguages(toUniqueISOLanguageCodes(tmpSelectedLanguages));
};
@@ -103,13 +103,13 @@ export const SourceLanguageSelect = ({
return (
<>
<CustomTooltip title={t('settings.title')}>
<CustomTooltip title={t`Settings`}>
<IconButton onClick={() => setOpen(true)} aria-label="display more actions" edge="end" color="inherit">
<FilterListIcon />
</IconButton>
</CustomTooltip>
<Dialog fullWidth maxWidth="xs" open={open} onClose={handleCancel}>
<DialogTitle>{t('global.language.title.enabled_languages')}</DialogTitle>
<DialogTitle>{t`Allowed Languages`}</DialogTitle>
<DialogContent dividers sx={{ padding: 0 }}>
<Virtuoso
style={{
@@ -178,10 +178,10 @@ export const SourceLanguageSelect = ({
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleCancel} color="primary">
{t('global.button.cancel')}
{t`Cancel`}
</Button>
<Button onClick={handleOk} color="primary">
{t('global.button.ok')}
{t`Ok`}
</Button>
</DialogActions>
</Dialog>

View File

@@ -15,13 +15,12 @@ import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import ListItemButton from '@mui/material/ListItemButton';
import { useLingui } from '@lingui/react/macro';
import { EditTextPreferenceProps } from '@/features/source/Source.types.ts';
export function EditTextPreference(props: EditTextPreferenceProps) {
const { t } = useTranslation();
const { t } = useLingui();
const {
EditTextPreferenceTitle: title,
@@ -69,10 +68,10 @@ export function EditTextPreference(props: EditTextPreferenceProps) {
</DialogContent>
<DialogActions>
<Button onClick={handleDialogCancel} color="primary">
{t('global.button.cancel')}
{t`Cancel`}
</Button>
<Button onClick={handleDialogSubmit} color="primary">
{t('global.button.ok')}
{t`Ok`}
</Button>
</DialogActions>
</Dialog>

View File

@@ -16,9 +16,8 @@ import RadioGroup from '@mui/material/RadioGroup';
import Radio from '@mui/material/Radio';
import FormControlLabel from '@mui/material/FormControlLabel';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import ListItemButton from '@mui/material/ListItemButton';
import { useLingui } from '@lingui/react/macro';
import { ListPreferenceProps } from '@/features/source/Source.types.ts';
interface IListDialogProps {
@@ -30,7 +29,7 @@ interface IListDialogProps {
}
function ListDialog(props: IListDialogProps) {
const { t } = useTranslation();
const { t } = useLingui();
const { value: valueProp, open, onClose, options, title } = props;
const [value, setValue] = React.useState(valueProp);
@@ -78,9 +77,9 @@ function ListDialog(props: IListDialogProps) {
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleCancel}>
{t('global.button.cancel')}
{t`Cancel`}
</Button>
<Button onClick={handleOk}>{t('global.button.ok')}</Button>
<Button onClick={handleOk}>{t`Ok`}</Button>
</DialogActions>
</Dialog>
);

View File

@@ -16,8 +16,8 @@ import FormGroup from '@mui/material/FormGroup';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import ListItemButton from '@mui/material/ListItemButton';
import { useLingui } from '@lingui/react/macro';
import { cloneObject } from '@/base/utils/cloneObject.tsx';
import { MultiSelectListPreferenceProps } from '@/features/source/Source.types.ts';
@@ -30,7 +30,7 @@ interface IListDialogProps {
}
function ListDialog(props: IListDialogProps) {
const { t } = useTranslation();
const { t } = useLingui();
const { selectedValues: selectedValuesProp, open, onClose, values, title } = props;
const [selectedValues, setSelectedValues] = React.useState(selectedValuesProp);
@@ -94,9 +94,9 @@ function ListDialog(props: IListDialogProps) {
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleCancel}>
{t('global.button.cancel')}
{t`Cancel`}
</Button>
<Button onClick={handleOk}>{t('global.button.ok')}</Button>
<Button onClick={handleOk}>{t`Ok`}</Button>
</DialogActions>
</Dialog>
);

View File

@@ -9,7 +9,7 @@
import { createElement } from 'react';
import { useParams } from 'react-router-dom';
import List from '@mui/material/List';
import { useTranslation } from 'react-i18next';
import { useLingui } from '@lingui/react/macro';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { cloneObject } from '@/base/utils/cloneObject.tsx';
import {
@@ -47,9 +47,9 @@ function getPrefComponent(type: string) {
}
export function SourceConfigure() {
const { t } = useTranslation();
const { t } = useLingui();
useAppTitle(t('source.configuration.title'));
useAppTitle(t`Source Configuration`);
const { sourceId } = useParams<{ sourceId: string }>();
const { data, loading, error, refetch } = requestManager.useGetSource<
@@ -65,9 +65,7 @@ export function SourceConfigure() {
(type, value) => {
requestManager
.setSourcePreferences(sourceId, { position, [type]: value })
.response.catch((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
.response.catch((e) => makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)));
};
if (loading) {
@@ -77,7 +75,7 @@ export function SourceConfigure() {
if (error) {
return (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
message={t`Unable to load data`}
messageExtra={getErrorMessage(error)}
retry={() => refetch().catch(defaultPromiseErrorHandler('SourceConfigure::refetch'))}
/>

View File

@@ -6,8 +6,8 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import { useCallback } from 'react';
import { useLingui } from '@lingui/react/macro';
import {
SourceDisplayNameInfo,
SourceIdInfo,
@@ -151,13 +151,13 @@ export class Sources {
languages: string[];
setLanguages: (languages: string[]) => void;
} {
const { t } = useTranslation();
const { t } = useLingui();
const {
settings: { sourceLanguages },
} = useMetadataServerSettings();
const updateSetting = createUpdateMetadataServerSettings<'sourceLanguages'>((e) =>
makeToast(t('global.error.label.failed_to_save_changes', getErrorMessage(e)), 'error'),
makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)),
);
const setLanguages = useCallback((languages: string[]) => updateSetting('sourceLanguages', languages), []);