add translation keys (#246)

* Fix i18n tsc issue

"t" can return null by default.
This was already disabled in the config, but it had no effect on tsc.

* Fix translation in "DefaultNavBar"

key was missing and since this is a constant the translation wouldn't update in case the language got changed after first load.

* Enable typing for i18n translation keys

* Add translation keys
This commit is contained in:
schroda
2023-03-23 13:59:32 +01:00
committed by GitHub
parent 8c129f2e08
commit d58bd6cd92
56 changed files with 1030 additions and 416 deletions

View File

@@ -30,6 +30,7 @@ import client from 'util/client';
import { BACK } from 'util/useBackTo';
import { getUploadDateString } from 'util/date';
import { IChapter, IDownloadChapter } from 'typings';
import { useTranslation } from 'react-i18next';
interface IProps {
chapter: IChapter;
@@ -41,6 +42,7 @@ interface IProps {
}
const ChapterCard: React.FC<IProps> = (props: IProps) => {
const { t } = useTranslation();
const theme = useTheme();
const { chapter, triggerChaptersUpdate, downloadChapter: dc, showChapterNumber, onSelect, selected } = props;
@@ -135,12 +137,12 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
sx={{ mr: 0.5, position: 'relative', top: '0.15em' }}
/>
)}
{showChapterNumber ? `Chapter ${chapter.chapterNumber}` : chapter.name}
{showChapterNumber ? `${t('chapter.title')} ${chapter.chapterNumber}` : chapter.name}
</Typography>
<Typography variant="caption">{chapter.scanlator}</Typography>
<Typography variant="caption">
{getUploadDateString(chapter.uploadDate)}
{isDownloaded && ' • Downloaded'}
{isDownloaded && `${t('chapter.status.label.downloaded')}`}
</Typography>
</Stack>
@@ -160,14 +162,14 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
<ListItemIcon>
<CheckBoxOutlineBlank fontSize="small" />
</ListItemIcon>
<ListItemText>Select</ListItemText>
<ListItemText>{t('chapter.action.label.select')}</ListItemText>
</MenuItem>
{isDownloaded && (
<MenuItem onClick={deleteChapter}>
<ListItemIcon>
<Delete fontSize="small" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
<ListItemText>{t('chapter.action.download.delete.label.action')}</ListItemText>
</MenuItem>
)}
{canBeDownloaded && (
@@ -175,7 +177,7 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
<ListItemIcon>
<Download fontSize="small" />
</ListItemIcon>
<ListItemText>Download</ListItemText>
<ListItemText>{t('chapter.action.download.add.label.action')}</ListItemText>
</MenuItem>
)}
<MenuItem onClick={() => sendChange('bookmarked', !chapter.bookmarked)}>
@@ -184,8 +186,8 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
{!chapter.bookmarked && <BookmarkAdd fontSize="small" />}
</ListItemIcon>
<ListItemText>
{chapter.bookmarked && 'Remove bookmark'}
{!chapter.bookmarked && 'Add bookmark'}
{chapter.bookmarked && t('chapter.action.bookmark.remove.label.action')}
{!chapter.bookmarked && t('chapter.action.bookmark.add.label.action')}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => sendChange('read', !chapter.read)}>
@@ -194,15 +196,15 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
{!chapter.read && <Done fontSize="small" />}
</ListItemIcon>
<ListItemText>
{chapter.read && 'Mark as unread'}
{!chapter.read && 'Mark as read'}
{chapter.read && t('chapter.action.mark_as_read.remove.label.action')}
{!chapter.read && t('chapter.action.mark_as_read.add.label.action.current')}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => sendChange('markPrevRead', true)}>
<ListItemIcon>
<DoneAll fontSize="small" />
</ListItemIcon>
<ListItemText>Mark previous as Read</ListItemText>
<ListItemText>{t('chapter.action.mark_as_read.add.label.action.previous')}</ListItemText>
</MenuItem>
</Menu>
</Card>

View File

@@ -13,14 +13,14 @@ import ChapterCard from 'components/manga/ChapterCard';
import ResumeFab from 'components/manga/ResumeFAB';
import { filterAndSortChapters, useChapterOptions } from 'components/manga/util';
import EmptyView from 'components/util/EmptyView';
import { interpolate } from 'components/util/helpers';
import makeToast from 'components/util/Toast';
import React, { ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import client, { useQuery } from 'util/client';
import ChaptersToolbarMenu from 'components/manga/ChaptersToolbarMenu';
import SelectionFAB from 'components/manga/SelectionFAB';
import { BatchChaptersChange, IChapter, IDownloadChapter, IQueue } from 'typings';
import { BatchChaptersChange, IChapter, IDownloadChapter, IQueue, TranslationKey } from 'typings';
import { useTranslation } from 'react-i18next';
const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({
listStyle: 'none',
@@ -34,30 +34,35 @@ const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({
},
}));
const actionsStrings = {
const actionsStrings: {
[key in 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread']: {
success: TranslationKey;
error: TranslationKey;
};
} = {
download: {
success: { one: 'Download added', many: '%count% downloads added' },
error: { one: 'Error adding download', many: 'Error adding downloads' },
success: 'chapter.action.download.add.label.success',
error: 'chapter.action.download.add.label.error',
},
delete: {
success: { one: 'Chapter deleted', many: '%count% chapters deleted' },
error: { one: 'Error deleting chapter', many: 'Error deleting chapters' },
success: 'chapter.action.download.delete.label.success',
error: 'chapter.action.download.delete.label.error',
},
bookmark: {
success: { one: 'Chapter bookmarked', many: '%count% chapters bookmarked' },
error: { one: 'Error bookmarking chapter', many: 'Error bookmarking chapters' },
success: 'chapter.action.bookmark.add.label.success',
error: 'chapter.action.bookmark.add.label.error',
},
unbookmark: {
success: { one: 'Chapter bookmark removed', many: '%count% chapter bookmarks removed' },
error: { one: 'Error removing bookmark', many: 'Error removing bookmarks' },
success: 'chapter.action.bookmark.remove.label.success',
error: 'chapter.action.bookmark.remove.label.error',
},
mark_as_read: {
success: { one: 'Chapter marked as read', many: '%count% chapters marked as read' },
error: { one: 'Error marking chapter as read', many: 'Error marking chapters as read' },
success: 'chapter.action.mark_as_read.add.label.success',
error: 'chapter.action.mark_as_read.add.label.error',
},
mark_as_unread: {
success: { one: 'Chapter marked as unread', many: '%count% chapters marked as unread' },
error: { one: 'Error marking chapter as unread', many: 'Error marking chapters as unread' },
success: 'chapter.action.mark_as_read.remove.label.success',
error: 'chapter.action.mark_as_read.remove.label.error',
},
};
@@ -72,6 +77,8 @@ interface IProps {
}
const ChapterList: React.FC<IProps> = ({ mangaId }) => {
const { t } = useTranslation();
const [selection, setSelection] = useState<number[] | null>(null);
const prevQueueRef = useRef<IDownloadChapter[]>();
const queue = useSubscription<IQueue>('/api/v1/downloads').data?.queue;
@@ -162,9 +169,9 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
}
actionPromise
.then(() => makeToast(interpolate(chapterIds.length, actionsStrings[action].success), 'success'))
.then(() => makeToast(t(actionsStrings[action].success, { count: chapterIds.length }) as string, 'success'))
.then(() => mutate())
.catch(() => makeToast(interpolate(chapterIds.length, actionsStrings[action].error), 'error'));
.catch(() => makeToast(t(actionsStrings[action].error, { count: chapterIds.length }) as string, 'error'));
};
if (loading) {
@@ -214,7 +221,9 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
}}
>
<Typography variant="h5">
{`${visibleChapters.length} Chapter${visibleChapters.length === 1 ? '' : 's'}`}
{`${visibleChapters.length} ${t('chapter.title', {
count: visibleChapters.length,
})}`}
</Typography>
{selection === null ? (
@@ -222,17 +231,17 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
) : (
<Stack direction="row">
<Button size="small" onClick={handleSelectAll}>
Select all
{t('global.button.select_all')}
</Button>
<Button size="small" onClick={handleClear}>
Clear
{t('global.button.clear')}
</Button>
</Stack>
)}
</Stack>
{noChaptersFound && <EmptyView message="No chapters found" />}
{noChaptersMatchingFilter && <EmptyView message="No chapters matching filter" />}
{noChaptersFound && <EmptyView message={t('chapter.error.label.no_chapter_found')} />}
{noChaptersMatchingFilter && <EmptyView message={t('chapter.error.label.no_matches')} />}
<StyledVirtuoso
style={{

View File

@@ -12,7 +12,8 @@ import ThreeStateCheckboxInput from 'components/atoms/ThreeStateCheckboxInput';
import OptionsTabs from 'components/molecules/OptionsTabs';
import React from 'react';
import { SORT_OPTIONS } from 'components/manga/util';
import { ChapterListOptions, ChapterOptionsReducerAction } from 'typings';
import { ChapterListOptions, ChapterOptionsReducerAction, TranslationKey } from 'typings';
import { useTranslation } from 'react-i18next';
interface IProps {
open: boolean;
@@ -21,88 +22,92 @@ interface IProps {
optionsDispatch: React.Dispatch<ChapterOptionsReducerAction>;
}
const TITLES = {
filter: 'Filter',
sort: 'Sort',
display: 'Display',
const TITLES: { [key in 'filter' | 'sort' | 'display']: TranslationKey } = {
filter: 'global.label.filter',
sort: 'global.label.sort',
display: 'global.label.display',
};
const ChapterOptions: React.FC<IProps> = ({ open, onClose, options, optionsDispatch }) => (
<OptionsTabs<'filter' | 'sort' | 'display'>
open={open}
onClose={onClose}
minHeight={150}
tabs={['filter', 'sort', 'display']}
tabTitle={(key) => TITLES[key]}
tabContent={(key) => {
if (key === 'filter') {
return (
<>
<ThreeStateCheckboxInput
label="Unread"
checked={options.unread}
onChange={(c) =>
optionsDispatch({
type: 'filter',
filterType: 'unread',
filterValue: c,
})
const ChapterOptions: React.FC<IProps> = ({ open, onClose, options, optionsDispatch }) => {
const { t } = useTranslation();
return (
<OptionsTabs<'filter' | 'sort' | 'display'>
open={open}
onClose={onClose}
minHeight={150}
tabs={['filter', 'sort', 'display']}
tabTitle={(key) => t(TITLES[key])}
tabContent={(key) => {
if (key === 'filter') {
return (
<>
<ThreeStateCheckboxInput
label={t('global.filter.label.unread')}
checked={options.unread}
onChange={(c) =>
optionsDispatch({
type: 'filter',
filterType: 'unread',
filterValue: c,
})
}
/>
<ThreeStateCheckboxInput
label={t('global.filter.label.downloaded')}
checked={options.downloaded}
onChange={(c) =>
optionsDispatch({
type: 'filter',
filterType: 'downloaded',
filterValue: c,
})
}
/>
<ThreeStateCheckboxInput
label={t('global.filter.label.bookmarked')}
checked={options.bookmarked}
onChange={(c) =>
optionsDispatch({
type: 'filter',
filterType: 'bookmarked',
filterValue: c,
})
}
/>
</>
);
}
if (key === 'sort') {
return SORT_OPTIONS.map(([mode, label]) => (
<SortRadioInput
key={mode}
label={t(label) as string}
checked={options.sortBy === mode}
sortDescending={options.reverse}
onClick={() =>
mode !== options.sortBy
? optionsDispatch({ type: 'sortBy', sortBy: mode })
: optionsDispatch({ type: 'sortReverse' })
}
/>
<ThreeStateCheckboxInput
label="Downloaded"
checked={options.downloaded}
onChange={(c) =>
optionsDispatch({
type: 'filter',
filterType: 'downloaded',
filterValue: c,
})
}
/>
<ThreeStateCheckboxInput
label="Bookmarked"
checked={options.bookmarked}
onChange={(c) =>
optionsDispatch({
type: 'filter',
filterType: 'bookmarked',
filterValue: c,
})
}
/>
</>
);
}
if (key === 'sort') {
return SORT_OPTIONS.map(([mode, label]) => (
<SortRadioInput
key={mode}
label={label}
checked={options.sortBy === mode}
sortDescending={options.reverse}
onClick={() =>
mode !== options.sortBy
? optionsDispatch({ type: 'sortBy', sortBy: mode })
: optionsDispatch({ type: 'sortReverse' })
}
/>
));
}
if (key === 'display') {
return (
<RadioGroup
onChange={() => optionsDispatch({ type: 'showChapterNumber' })}
value={options.showChapterNumber}
>
<RadioInput label="Source Title" value={false} />
<RadioInput label="Chapter Number" value />
</RadioGroup>
);
}
return null;
}}
/>
);
));
}
if (key === 'display') {
return (
<RadioGroup
onChange={() => optionsDispatch({ type: 'showChapterNumber' })}
value={options.showChapterNumber}
>
<RadioInput label={t('chapter.option.display.label.source_title')} value={false} />
<RadioInput label={t('chapter.option.display.label.chapter_number')} value />
</RadioGroup>
);
}
return null;
}}
/>
);
};
export default ChapterOptions;

View File

@@ -13,6 +13,7 @@ import IconButton from '@mui/material/IconButton';
import { Theme } from '@mui/material/styles';
import makeStyles from '@mui/styles/makeStyles';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { mutate } from 'swr';
import client from 'util/client';
import useLocalStorage from 'util/useLocalStorage';
@@ -129,6 +130,8 @@ function getValueOrUnknown(val: string) {
}
const MangaDetails: React.FC<IProps> = ({ manga }) => {
const { t } = useTranslation();
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
const [useCache] = useLocalStorage<boolean>('useCache', true);
@@ -158,15 +161,15 @@ const MangaDetails: React.FC<IProps> = ({ manga }) => {
<div className={classes.rightSide}>
<h1>{manga.title}</h1>
<h3>
{'Author: '}
{`${t('manga.label.author')}: `}
<span>{getValueOrUnknown(manga.author)}</span>
</h3>
<h3>
{'Artist: '}
{`${t('manga.label.artist')}: `}
<span>{getValueOrUnknown(manga.artist)}</span>
</h3>
<h3>{`Status: ${manga.status}`}</h3>
<h3>{`Source: ${getSourceName(manga.source)}`}</h3>
<h3>{`${t('manga.label.status')}: ${manga.status}`}</h3>
<h3>{`${t('source.title')}: ${getSourceName(manga.source)}`}</h3>
</div>
</div>
<div className={classes.buttons}>
@@ -174,21 +177,23 @@ const MangaDetails: React.FC<IProps> = ({ manga }) => {
<IconButton onClick={manga.inLibrary ? removeFromLibrary : addToLibrary} size="large">
{manga.inLibrary ? <FavoriteIcon sx={{ mr: 1 }} /> : <FavoriteBorderIcon sx={{ mr: 1 }} />}
<Typography sx={{ fontSize: { xs: '0.75em', sm: '0.85em' } }}>
{manga.inLibrary ? 'In Library' : 'Add To Library'}
{manga.inLibrary ? t('manga.button.in_library') : t('manga.button.add_to_library')}
</Typography>
</IconButton>
</div>
<a href={manga.realUrl} target="_blank" rel="noreferrer">
<IconButton size="large">
<PublicIcon sx={{ mr: 1 }} />
<Typography sx={{ fontSize: { xs: '0.75em', sm: '0.85em' } }}>Open Site</Typography>
<Typography sx={{ fontSize: { xs: '0.75em', sm: '0.85em' } }}>
{t('global.button.open_site')}
</Typography>
</IconButton>
</a>
</div>
</div>
<div className={classes.bottom}>
<div className={classes.description}>
<h4>About</h4>
<h4>{t('settings.about.title')}</h4>
<p>{manga.description}</p>
</div>
<div className={classes.genre}>

View File

@@ -21,6 +21,7 @@ import {
import CategorySelect from 'components/navbar/action/CategorySelect';
import React, { useState } from 'react';
import { IManga } from 'typings';
import { useTranslation } from 'react-i18next';
interface IProps {
manga: IManga;
@@ -29,6 +30,7 @@ interface IProps {
}
const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
@@ -44,7 +46,7 @@ const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
<>
{isLargeScreen && (
<>
<Tooltip title="Reload data from source">
<Tooltip title={t('manga.label.reload_from_source')}>
<IconButton
onClick={() => {
onRefresh();
@@ -55,7 +57,7 @@ const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
</IconButton>
</Tooltip>
{manga.inLibrary && (
<Tooltip title="Edit manga categories">
<Tooltip title={t('manga.label.edit_categories')}>
<IconButton
onClick={() => {
setEditCategories(true);
@@ -97,7 +99,7 @@ const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
<ListItemIcon>
<Refresh fontSize="small" />
</ListItemIcon>
<ListItemText>Reload data from source</ListItemText>
<ListItemText>{t('manga.label.reload_from_source')}</ListItemText>
</MenuItem>
{manga.inLibrary && (
<MenuItem
@@ -109,7 +111,7 @@ const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
<ListItemIcon>
<Label fontSize="small" />
</ListItemIcon>
<ListItemText>Edit manga categories</ListItemText>
<ListItemText>{t('manga.label.edit_categories')}</ListItemText>
</MenuItem>
)}
</Menu>

View File

@@ -11,6 +11,7 @@ import { Link } from 'react-router-dom';
import { PlayArrow } from '@mui/icons-material';
import { BACK } from 'util/useBackTo';
import { IChapter } from 'typings';
import { useTranslation } from 'react-i18next';
interface ResumeFABProps {
chapter: IChapter;
@@ -18,6 +19,8 @@ interface ResumeFABProps {
}
export default function ResumeFab(props: ResumeFABProps) {
const { t } = useTranslation();
const {
chapter: { index, lastPageRead },
mangaId,
@@ -34,7 +37,7 @@ export default function ResumeFab(props: ResumeFABProps) {
}}
>
<PlayArrow />
{index === 1 ? 'Start' : 'Resume'}
{index === 1 ? t('global.button.start') : t('global.button.resume')}
</Fab>
);
}

View File

@@ -8,10 +8,10 @@
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import { Fab, Menu } from '@mui/material';
import { Box } from '@mui/system';
import { pluralize } from 'components/util/helpers';
import React, { useRef, useState } from 'react';
import type { IChapterWithMeta } from 'components/manga/ChapterList';
import SelectionFABActionItem from 'components/manga/SelectionFABActionItem';
import { useTranslation } from 'react-i18next';
export type SelectionAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
@@ -21,6 +21,8 @@ interface SelectionFABProps {
}
const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
const { t } = useTranslation();
const { selectedChapters, onAction } = props;
const count = selectedChapters.length;
@@ -44,7 +46,7 @@ const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
ref={anchorEl}
>
<Fab variant="extended" color="primary" id="selectionMenuButton" onClick={() => setOpen(true)}>
{`${count} ${pluralize(count, 'chapter')}`}
{`${count} ${t('chapter.title', { count })}`}
<MoreHoriz sx={{ ml: 1 }} />
</Fab>
<Menu
@@ -64,37 +66,37 @@ const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
({ chapter: c, downloadChapter: dc }) => !c.downloaded && dc === undefined,
)}
onClick={handleAction}
title="Download selected"
title={t('chapter.action.download.add.button.selected')}
/>
<SelectionFABActionItem
action="delete"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.downloaded)}
onClick={handleAction}
title="Delete selected"
title={t('chapter.action.download.delete.button.selected')}
/>
<SelectionFABActionItem
action="bookmark"
matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.bookmarked)}
onClick={handleAction}
title="Bookmark selected"
title={t('chapter.action.bookmark.add.button.selected')}
/>
<SelectionFABActionItem
action="unbookmark"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.bookmarked)}
onClick={handleAction}
title="Remove bookmarks from selected"
title={t('chapter.action.bookmark.remove.button.selected')}
/>
<SelectionFABActionItem
action="mark_as_read"
matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.read)}
onClick={handleAction}
title="Mark selected as read"
title={t('chapter.action.mark_as_read.add.button.selected')}
/>
<SelectionFABActionItem
action="mark_as_unread"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.read)}
onClick={handleAction}
title="Mark selected as unread"
title={t('chapter.action.mark_as_read.remove.button.selected')}
/>
</Menu>
</Box>

View File

@@ -5,8 +5,16 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { t } from 'i18next';
import { useReducerLocalStorage } from 'util/useLocalStorage';
import { ChapterListOptions, ChapterOptionsReducerAction, ChapterSortMode, IChapter, NullAndUndefined } from 'typings';
import {
ChapterListOptions,
ChapterOptionsReducerAction,
ChapterSortMode,
IChapter,
NullAndUndefined,
TranslationKey,
} from 'typings';
const defaultChapterOptions: ChapterListOptions = {
active: false,
@@ -35,7 +43,7 @@ function chapterOptionsReducer(state: ChapterListOptions, actions: ChapterOption
case 'showChapterNumber':
return { ...state, showChapterNumber: !state.showChapterNumber };
default:
throw Error('This is not a valid Action');
throw Error(t('global.error.label.invalid_action'));
}
}
@@ -95,9 +103,9 @@ export const useChapterOptions = (mangaId: string) =>
defaultChapterOptions,
);
export const SORT_OPTIONS: [ChapterSortMode, string][] = [
['source', 'By Source'],
['fetchedAt', 'By Fetch date'],
export const SORT_OPTIONS: [ChapterSortMode, TranslationKey][] = [
['source', 'global.sort.label.by_source'],
['fetchedAt', 'global.sort.label.by_fetch_date'],
];
export const isFilterActive = (options: ChapterListOptions) => {