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

@@ -14,7 +14,8 @@ import Typography from '@mui/material/Typography';
import client from 'util/client'; import client from 'util/client';
import useLocalStorage from 'util/useLocalStorage'; import useLocalStorage from 'util/useLocalStorage';
import { Box } from '@mui/system'; import { Box } from '@mui/system';
import { IExtension } from 'typings'; import { IExtension, TranslationKey } from 'typings';
import { useTranslation } from 'react-i18next';
interface IProps { interface IProps {
extension: IExtension; extension: IExtension;
@@ -50,7 +51,19 @@ const EXTENSION_ACTION_TO_NEXT_ACTION_MAP: { [action in ExtensionAction]: Extens
[ExtensionAction.INSTALL]: ExtensionAction.UNINSTALL, [ExtensionAction.INSTALL]: ExtensionAction.UNINSTALL,
} as const; } as const;
const INSTALLED_STATE_TO_TRANSLATION_KEY_MAP: { [installedState in InstalledStates]: TranslationKey } = {
[InstalledState.UNINSTALL]: 'extension.action.label.uninstall',
[InstalledState.INSTALL]: 'extension.action.label.install',
[InstalledState.UPDATE]: 'extension.action.label.update',
[InstalledState.OBSOLETE]: 'extension.state.label.obsolete',
[InstalledState.UPDATING]: 'extension.state.label.updating',
[InstalledState.UNINSTALLING]: 'extension.state.label.uninstalling',
[InstalledState.INSTALLING]: 'extension.state.label.installing',
} as const;
export default function ExtensionCard(props: IProps) { export default function ExtensionCard(props: IProps) {
const { t } = useTranslation();
const { const {
extension: { name, lang, versionName, installed, hasUpdate, obsolete, pkgName, iconUrl, isNsfw }, extension: { name, lang, versionName, installed, hasUpdate, obsolete, pkgName, iconUrl, isNsfw },
notifyInstall, notifyInstall,
@@ -68,7 +81,7 @@ export default function ExtensionCard(props: IProps) {
const [serverAddress] = useLocalStorage<String>('serverBaseURL', ''); const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
const [useCache] = useLocalStorage<boolean>('useCache', true); const [useCache] = useLocalStorage<boolean>('useCache', true);
const langPress = lang === 'all' ? 'All' : lang.toUpperCase(); const langPress = lang === 'all' ? t('extension.language.all') : lang.toUpperCase();
const requestExtensionAction = async (action: ExtensionAction): Promise<void> => { const requestExtensionAction = async (action: ExtensionAction): Promise<void> => {
const nextAction = EXTENSION_ACTION_TO_NEXT_ACTION_MAP[action]; const nextAction = EXTENSION_ACTION_TO_NEXT_ACTION_MAP[action];
@@ -137,7 +150,7 @@ export default function ExtensionCard(props: IProps) {
sx={{ color: installedState === InstalledState.OBSOLETE ? 'red' : 'inherit' }} sx={{ color: installedState === InstalledState.OBSOLETE ? 'red' : 'inherit' }}
onClick={() => handleButtonClick()} onClick={() => handleButtonClick()}
> >
{installedState} {t(INSTALLED_STATE_TO_TRANSLATION_KEY_MAP[installedState])}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -17,6 +17,7 @@ import { Box, styled } from '@mui/system';
import { GridLayout, useLibraryOptionsContext } from 'components/context/LibraryOptionsContext'; import { GridLayout, useLibraryOptionsContext } from 'components/context/LibraryOptionsContext';
import { BACK } from 'util/useBackTo'; import { BACK } from 'util/useBackTo';
import { IMangaCard } from 'typings'; import { IMangaCard } from 'typings';
import { useTranslation } from 'react-i18next';
const BottomGradient = styled('div')({ const BottomGradient = styled('div')({
position: 'absolute', position: 'absolute',
@@ -73,6 +74,8 @@ interface IProps {
} }
const MangaCard = React.forwardRef<HTMLDivElement, IProps>((props: IProps, ref) => { const MangaCard = React.forwardRef<HTMLDivElement, IProps>((props: IProps, ref) => {
const { t } = useTranslation();
const { const {
manga: { manga: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -131,7 +134,7 @@ const MangaCard = React.forwardRef<HTMLDivElement, IProps>((props: IProps, ref)
> >
{inLibraryIndicator && inLibrary && ( {inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}> <Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}>
In library {t('manga.button.in_library')}
</Typography> </Typography>
)} )}
{showUnreadBadge && unread! > 0 && ( {showUnreadBadge && unread! > 0 && (
@@ -252,7 +255,9 @@ const MangaCard = React.forwardRef<HTMLDivElement, IProps>((props: IProps, ref)
</Box> </Box>
<BadgeContainer> <BadgeContainer>
{inLibraryIndicator && inLibrary && ( {inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>In library</Typography> <Typography sx={{ backgroundColor: 'primary.dark' }}>
{t('manga.button.in_library')}
</Typography>
)} )}
{showUnreadBadge && unread! > 0 && ( {showUnreadBadge && unread! > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography> <Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>

View File

@@ -13,6 +13,7 @@ import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Box, styled } from '@mui/system'; import { Box, styled } from '@mui/system';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { langCodeToName } from 'util/language'; import { langCodeToName } from 'util/language';
import useLocalStorage from 'util/useLocalStorage'; import useLocalStorage from 'util/useLocalStorage';
@@ -41,6 +42,8 @@ interface IProps {
} }
const SourceCard: React.FC<IProps> = (props: IProps) => { const SourceCard: React.FC<IProps> = (props: IProps) => {
const { t } = useTranslation();
const { const {
source: { id, name, lang, iconUrl, supportsLatest, isNsfw }, source: { id, name, lang, iconUrl, supportsLatest, isNsfw },
} = props; } = props;
@@ -110,18 +113,18 @@ const SourceCard: React.FC<IProps> = (props: IProps) => {
<MobileWidthButtons> <MobileWidthButtons>
{supportsLatest && ( {supportsLatest && (
<Button variant="outlined" onClick={(e) => redirectTo(e, `/sources/${id}/latest/`)}> <Button variant="outlined" onClick={(e) => redirectTo(e, `/sources/${id}/latest/`)}>
Latest {t('global.button.latest')}
</Button> </Button>
)} )}
</MobileWidthButtons> </MobileWidthButtons>
<WiderWidthButtons> <WiderWidthButtons>
{supportsLatest && ( {supportsLatest && (
<Button component={Link} to={`/sources/${id}/latest/`} variant="outlined"> <Button component={Link} to={`/sources/${id}/latest/`} variant="outlined">
Latest {t('global.button.latest')}
</Button> </Button>
)} )}
<Button component={Link} to={`/sources/${id}/popular/`} variant="outlined"> <Button component={Link} to={`/sources/${id}/popular/`} variant="outlined">
Browse {t('global.button.browse')}
</Button> </Button>
</WiderWidthButtons> </WiderWidthButtons>
</> </>

View File

@@ -13,8 +13,7 @@ import { StringParam, useQueryParam } from 'use-query-params';
import { useMediaQuery, useTheme } from '@mui/material'; import { useMediaQuery, useTheme } from '@mui/material';
import { IMangaCard, LibrarySortMode, NullAndUndefined } from 'typings'; import { IMangaCard, LibrarySortMode, NullAndUndefined } from 'typings';
import { useSearchSettings } from 'util/searchSettings'; import { useSearchSettings } from 'util/searchSettings';
import { useTranslation } from 'react-i18next';
const FILTERED_OUT_MESSAGE = 'There are no Manga matching this filter';
const unreadFilter = (unread: NullAndUndefined<boolean>, { unreadCount }: IMangaCard): boolean => { const unreadFilter = (unread: NullAndUndefined<boolean>, { unreadCount }: IMangaCard): boolean => {
switch (unread) { switch (unread) {
@@ -118,6 +117,8 @@ const LibraryMangaGrid: React.FC<LibraryMangaGridProps & { lastLibraryUpdate: nu
message, message,
lastLibraryUpdate, lastLibraryUpdate,
}) => { }) => {
const { t } = useTranslation();
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
const { options } = useLibraryOptionsContext(); const { options } = useLibraryOptionsContext();
const { unread, downloaded } = options; const { unread, downloaded } = options;
@@ -155,7 +156,7 @@ const LibraryMangaGrid: React.FC<LibraryMangaGridProps & { lastLibraryUpdate: nu
hasNextPage={lastPageNum < totalPages} hasNextPage={lastPageNum < totalPages}
lastPageNum={lastPageNum} lastPageNum={lastPageNum}
setLastPageNum={setLastPageNum} setLastPageNum={setLastPageNum}
message={showFilteredOutMessage ? FILTERED_OUT_MESSAGE : message} message={showFilteredOutMessage ? t('library.error.label.no_matches') : message}
gridLayout={options.gridLayout} gridLayout={options.gridLayout}
/> />
); );

View File

@@ -13,19 +13,20 @@ import ThreeStateCheckboxInput from 'components/atoms/ThreeStateCheckboxInput';
import { GridLayout, useLibraryOptionsContext } from 'components/context/LibraryOptionsContext'; import { GridLayout, useLibraryOptionsContext } from 'components/context/LibraryOptionsContext';
import OptionsTabs from 'components/molecules/OptionsTabs'; import OptionsTabs from 'components/molecules/OptionsTabs';
import React from 'react'; import React from 'react';
import { LibraryOptions, LibrarySortMode } from 'typings'; import { LibraryOptions, LibrarySortMode, TranslationKey } from 'typings';
import { useTranslation } from 'react-i18next';
const TITLES = { const TITLES: { [key in 'filter' | 'sort' | 'display']: TranslationKey } = {
filter: 'Filter', filter: 'global.label.filter',
sort: 'Sort', sort: 'global.label.sort',
display: 'Display', display: 'global.label.display',
}; };
const SORT_OPTIONS: [LibrarySortMode, string][] = [ const SORT_OPTIONS: [LibrarySortMode, TranslationKey][] = [
['sortToRead', 'By Unread chapters'], ['sortToRead', 'library.option.sort.label.by_unread_chapters'],
['sortAlph', 'Alphabetically'], ['sortAlph', 'library.option.sort.label.alphabetically'],
['sortDateAdded', 'Date Added'], ['sortDateAdded', 'library.option.sort.label.by_date_added'],
['sortLastRead', 'Last Read'], ['sortLastRead', 'library.option.sort.label.by_last_read'],
]; ];
interface IProps { interface IProps {
@@ -34,6 +35,7 @@ interface IProps {
} }
const LibraryOptionsPanel: React.FC<IProps> = ({ open, onClose }) => { const LibraryOptionsPanel: React.FC<IProps> = ({ open, onClose }) => {
const { t } = useTranslation();
const { options, setOptions } = useLibraryOptionsContext(); const { options, setOptions } = useLibraryOptionsContext();
const handleFilterChange = <T extends keyof LibraryOptions>(key: T, value: LibraryOptions[T]) => { const handleFilterChange = <T extends keyof LibraryOptions>(key: T, value: LibraryOptions[T]) => {
@@ -45,18 +47,18 @@ const LibraryOptionsPanel: React.FC<IProps> = ({ open, onClose }) => {
open={open} open={open}
onClose={onClose} onClose={onClose}
tabs={['filter', 'sort', 'display']} tabs={['filter', 'sort', 'display']}
tabTitle={(key) => TITLES[key]} tabTitle={(key) => t(TITLES[key])}
tabContent={(key) => { tabContent={(key) => {
if (key === 'filter') { if (key === 'filter') {
return ( return (
<> <>
<ThreeStateCheckboxInput <ThreeStateCheckboxInput
label="Unread" label={t('global.filter.label.unread')}
checked={options.unread} checked={options.unread}
onChange={(c) => handleFilterChange('unread', c)} onChange={(c) => handleFilterChange('unread', c)}
/> />
<ThreeStateCheckboxInput <ThreeStateCheckboxInput
label="Downloaded" label={t('global.filter.label.downloaded')}
checked={options.downloaded} checked={options.downloaded}
onChange={(c) => handleFilterChange('downloaded', c)} onChange={(c) => handleFilterChange('downloaded', c)}
/> />
@@ -67,7 +69,7 @@ const LibraryOptionsPanel: React.FC<IProps> = ({ open, onClose }) => {
return SORT_OPTIONS.map(([mode, label]) => ( return SORT_OPTIONS.map(([mode, label]) => (
<SortRadioInput <SortRadioInput
key={mode} key={mode}
label={label} label={t(label) as string}
checked={options.sorts === mode} checked={options.sorts === mode}
sortDescending={options.sortDesc} sortDescending={options.sortDesc}
onClick={() => onClick={() =>
@@ -82,36 +84,36 @@ const LibraryOptionsPanel: React.FC<IProps> = ({ open, onClose }) => {
const { gridLayout, showDownloadBadge, showUnreadBadge } = options; const { gridLayout, showDownloadBadge, showUnreadBadge } = options;
return ( return (
<> <>
<FormLabel>Display mode</FormLabel> <FormLabel>{t('global.grid_layout.title')}</FormLabel>
<RadioGroup <RadioGroup
onChange={(e) => handleFilterChange('gridLayout', Number(e.target.value))} onChange={(e) => handleFilterChange('gridLayout', Number(e.target.value))}
value={gridLayout} value={gridLayout}
> >
<RadioInput <RadioInput
label="Compact grid" label={t('global.grid_layout.label.compact_grid')}
value={GridLayout.Compact} value={GridLayout.Compact}
checked={gridLayout == null || gridLayout === GridLayout.Compact} checked={gridLayout == null || gridLayout === GridLayout.Compact}
/> />
<RadioInput <RadioInput
label="Comfortable grid" label={t('global.grid_layout.label.comfortable_grid')}
value={GridLayout.Comfortable} value={GridLayout.Comfortable}
checked={gridLayout === GridLayout.Comfortable} checked={gridLayout === GridLayout.Comfortable}
/> />
<RadioInput <RadioInput
label="List" label={t('global.grid_layout.label.list')}
value={GridLayout.List} value={GridLayout.List}
checked={gridLayout === GridLayout.List} checked={gridLayout === GridLayout.List}
/> />
</RadioGroup> </RadioGroup>
<FormLabel sx={{ mt: 2 }}>Badges</FormLabel> <FormLabel sx={{ mt: 2 }}>{t('library.option.display.badge.title')}</FormLabel>
<CheckboxInput <CheckboxInput
label="Unread Badges" label={t('library.option.display.badge.label.unread_badges')}
checked={showUnreadBadge === true} checked={showUnreadBadge === true}
onChange={() => handleFilterChange('showUnreadBadge', !showUnreadBadge)} onChange={() => handleFilterChange('showUnreadBadge', !showUnreadBadge)}
/> />
<CheckboxInput <CheckboxInput
label="Download Badges" label={t('library.option.display.badge.label.download_badges')}
checked={showDownloadBadge === true} checked={showDownloadBadge === true}
onChange={() => handleFilterChange('showDownloadBadge', !showDownloadBadge)} onChange={() => handleFilterChange('showDownloadBadge', !showDownloadBadge)}
/> />

View File

@@ -7,6 +7,7 @@ import Typography from '@mui/material/Typography';
import client from 'util/client'; import client from 'util/client';
import makeToast from 'components/util/Toast'; import makeToast from 'components/util/Toast';
import { IUpdateStatus } from 'typings'; import { IUpdateStatus } from 'typings';
import { useTranslation } from 'react-i18next';
interface IProgressProps { interface IProgressProps {
progress: number; progress: number;
@@ -30,6 +31,8 @@ interface IUpdateCheckerProps {
} }
function UpdateChecker({ handleFinishedUpdate }: IUpdateCheckerProps) { function UpdateChecker({ handleFinishedUpdate }: IUpdateCheckerProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@@ -39,7 +42,7 @@ function UpdateChecker({ handleFinishedUpdate }: IUpdateCheckerProps) {
setProgress(0); setProgress(0);
await client.post('/api/v1/update/fetch'); await client.post('/api/v1/update/fetch');
} catch (e) { } catch (e) {
makeToast('Checking for updates failed!', 'error'); makeToast(t('global.error.label.update_failed'), 'error');
setLoading(false); setLoading(false);
} }
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,16 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { t } from 'i18next';
import { useReducerLocalStorage } from 'util/useLocalStorage'; 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 = { const defaultChapterOptions: ChapterListOptions = {
active: false, active: false,
@@ -35,7 +43,7 @@ function chapterOptionsReducer(state: ChapterListOptions, actions: ChapterOption
case 'showChapterNumber': case 'showChapterNumber':
return { ...state, showChapterNumber: !state.showChapterNumber }; return { ...state, showChapterNumber: !state.showChapterNumber };
default: 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, defaultChapterOptions,
); );
export const SORT_OPTIONS: [ChapterSortMode, string][] = [ export const SORT_OPTIONS: [ChapterSortMode, TranslationKey][] = [
['source', 'By Source'], ['source', 'global.sort.label.by_source'],
['fetchedAt', 'By Fetch date'], ['fetchedAt', 'global.sort.label.by_fetch_date'],
]; ];
export const isFilterActive = (options: ChapterListOptions) => { export const isFilterActive = (options: ChapterListOptions) => {

View File

@@ -10,40 +10,53 @@ import { CircularProgress } from '@mui/material';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Box } from '@mui/system'; import { Box } from '@mui/system';
import React from 'react'; import React from 'react';
import { IDownloadChapter } from 'typings'; import { useTranslation } from 'react-i18next';
import { IDownloadChapter, TranslationKey } from 'typings';
interface DownloadStateIndicatorProps { interface DownloadStateIndicatorProps {
download: IDownloadChapter; download: IDownloadChapter;
} }
const DownloadStateIndicator: React.FC<DownloadStateIndicatorProps> = ({ download }) => ( // eslint-disable-next-line @typescript-eslint/no-unused-vars
<Box const DOWNLOAD_STATE_TO_TRANSLATION_KEY_MAP: { [state in IDownloadChapter['state']]: TranslationKey } = {
sx={{ Downloading: 'download.state.label.downloading',
position: 'relative', Error: 'download.state.label.error',
display: 'inline-flex', Finished: 'download.state.label.finished',
width: '50px', Queued: 'download.state.label.queued',
justifyContent: 'center', } as const;
}}
> const DownloadStateIndicator: React.FC<DownloadStateIndicatorProps> = ({ download }) => {
{download.progress !== 0 && <CircularProgress variant="determinate" value={download.progress * 100} />} const { t } = useTranslation();
return (
<Box <Box
sx={{ sx={{
top: 0, position: 'relative',
left: 0, display: 'inline-flex',
bottom: 0, width: '50px',
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<Typography variant="caption" component="div" color="text.secondary"> {download.progress !== 0 && <CircularProgress variant="determinate" value={download.progress * 100} />}
{download.progress !== 0 && `${Math.round(download.progress * 100)}%`} <Box
{download.progress === 0 && download.state} sx={{
</Typography> top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="caption" component="div" color="text.secondary">
{download.progress !== 0 && `${Math.round(download.progress * 100)}%`}
{download.progress === 0 && t(DOWNLOAD_STATE_TO_TRANSLATION_KEY_MAP[download.state])}
</Typography>
</Box>
</Box> </Box>
</Box> );
); };
export default DownloadStateIndicator; export default DownloadStateIndicator;

View File

@@ -31,56 +31,54 @@ import { createPortal } from 'react-dom';
import useBackTo from 'util/useBackTo'; import useBackTo from 'util/useBackTo';
import DesktopSideBar from 'components/navbar/navigation/DesktopSideBar'; import DesktopSideBar from 'components/navbar/navigation/DesktopSideBar';
import MobileBottomBar from 'components/navbar/navigation/MobileBottomBar'; import MobileBottomBar from 'components/navbar/navigation/MobileBottomBar';
// import { useTranslation } from 'react-i18next';
import { t } from 'i18next';
import { NavbarItem } from 'typings'; import { NavbarItem } from 'typings';
const navbarItems: Array<NavbarItem> = [ const navbarItems: Array<NavbarItem> = [
{ {
path: '/library', path: '/library',
title: t('DefaultNavBar.navbarItems.Library'), title: 'library.title',
SelectedIconComponent: CollectionsBookmarkIcon, SelectedIconComponent: CollectionsBookmarkIcon,
IconComponent: CollectionsOutlinedBookmarkIcon, IconComponent: CollectionsOutlinedBookmarkIcon,
show: 'both', show: 'both',
}, },
{ {
path: '/updates', path: '/updates',
title: 'Updates', title: 'updates.title',
SelectedIconComponent: NewReleasesIcon, SelectedIconComponent: NewReleasesIcon,
IconComponent: NewReleasesOutlinedIcon, IconComponent: NewReleasesOutlinedIcon,
show: 'both', show: 'both',
}, },
{ {
path: '/extensions', path: '/extensions',
title: 'Extensions', title: 'extension.title',
SelectedIconComponent: ExtensionIcon, SelectedIconComponent: ExtensionIcon,
IconComponent: ExtensionOutlinedIcon, IconComponent: ExtensionOutlinedIcon,
show: 'desktop', show: 'desktop',
}, },
{ {
path: '/sources', path: '/sources',
title: 'Sources', title: 'source.title',
SelectedIconComponent: ExploreIcon, SelectedIconComponent: ExploreIcon,
IconComponent: ExploreOutlinedIcon, IconComponent: ExploreOutlinedIcon,
show: 'desktop', show: 'desktop',
}, },
{ {
path: '/browse', path: '/browse',
title: 'Browse', title: 'global.label.browse',
SelectedIconComponent: ExploreIcon, SelectedIconComponent: ExploreIcon,
IconComponent: ExploreOutlinedIcon, IconComponent: ExploreOutlinedIcon,
show: 'mobile', show: 'mobile',
}, },
{ {
path: '/downloads', path: '/downloads',
title: 'Downloads', title: 'download.title',
SelectedIconComponent: GetAppIcon, SelectedIconComponent: GetAppIcon,
IconComponent: GetAppOutlinedIcon, IconComponent: GetAppOutlinedIcon,
show: 'both', show: 'both',
}, },
{ {
path: '/settings', path: '/settings',
title: 'Settings', title: 'settings.title',
SelectedIconComponent: SettingsIcon, SelectedIconComponent: SettingsIcon,
IconComponent: SettingsIcon, IconComponent: SettingsIcon,
show: 'both', show: 'both',
@@ -88,7 +86,6 @@ const navbarItems: Array<NavbarItem> = [
]; ];
export default function DefaultNavBar() { export default function DefaultNavBar() {
// const { t } = useTranslation();
const { title, action, override } = useContext(NavBarContext); const { title, action, override } = useContext(NavBarContext);
const backTo = useBackTo(); const backTo = useBackTo();

View File

@@ -26,6 +26,7 @@ import { styled } from '@mui/system';
import useBackTo from 'util/useBackTo'; import useBackTo from 'util/useBackTo';
import ReaderSettingsOptions from 'components/reader/ReaderSettingsOptions'; import ReaderSettingsOptions from 'components/reader/ReaderSettingsOptions';
import { IChapter, IManga, IMangaCard, IReaderSettings } from 'typings'; import { IChapter, IManga, IMangaCard, IReaderSettings } from 'typings';
import { useTranslation } from 'react-i18next';
const Root = styled('div')(({ theme }) => ({ const Root = styled('div')(({ theme }) => ({
top: 0, top: 0,
@@ -121,6 +122,7 @@ interface IProps {
} }
export default function ReaderNavBar(props: IProps) { export default function ReaderNavBar(props: IProps) {
const { t } = useTranslation();
const history = useHistory(); const history = useHistory();
const backTo = useBackTo(); const backTo = useBackTo();
const location = useLocation<{ const location = useLocation<{
@@ -230,7 +232,7 @@ export default function ReaderNavBar(props: IProps) {
}, },
}} }}
> >
<ListItemText primary="Reader Settings" /> <ListItemText primary={t('reader.settings.title.reader_settings')} />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<IconButton <IconButton
edge="start" edge="start"
@@ -258,7 +260,7 @@ export default function ReaderNavBar(props: IProps) {
<Divider sx={{ my: 1, mx: 2 }} /> <Divider sx={{ my: 1, mx: 2 }} />
<Navigation> <Navigation>
<PageNavigation> <PageNavigation>
<span>Currently on page</span> <span>{t('reader.page_info.label.currently_on_page')}</span>
<FormControl size="small" sx={{ margin: '0 5px' }} disabled={chapter.pageCount === -1}> <FormControl size="small" sx={{ margin: '0 5px' }} disabled={chapter.pageCount === -1}>
<Select <Select
MenuProps={MenuProps} MenuProps={MenuProps}
@@ -278,11 +280,11 @@ export default function ReaderNavBar(props: IProps) {
))} ))}
</Select> </Select>
</FormControl> </FormControl>
<span>{`of ${chapter.pageCount}`}</span> <span>{t('reader.page_info.label.of_max_pages', { maxPages: chapter.pageCount })}</span>
</PageNavigation> </PageNavigation>
<ChapterNavigation> <ChapterNavigation>
<IconButton <IconButton
title="Previous Chapter" title={t('reader.button.previous_chapter')}
sx={{ gridArea: 'pre' }} sx={{ gridArea: 'pre' }}
disabled={chapter.index <= 1} disabled={chapter.index <= 1}
onClick={() => { onClick={() => {
@@ -317,14 +319,14 @@ export default function ReaderNavBar(props: IProps) {
.map((ignoreValue, index) => ( .map((ignoreValue, index) => (
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
<MenuItem key={`Chapter#${index + 1}`} value={index + 1}>{`Chapter ${ <MenuItem key={`Chapter#${index + 1}`} value={index + 1}>{`${t(
index + 1 'chapter.title',
}`}</MenuItem> )} ${index + 1}`}</MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>
<IconButton <IconButton
title="Next Chapter" title={t('reader.button.next_chapter')}
sx={{ gridArea: 'next' }} sx={{ gridArea: 'next' }}
disabled={chapter.index < 1 || chapter.index >= chapter.chapterCount} disabled={chapter.index < 1 || chapter.index >= chapter.chapterCount}
onClick={() => { onClick={() => {

View File

@@ -16,6 +16,7 @@ import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup'; import FormGroup from '@mui/material/FormGroup';
import client, { useQuery } from 'util/client'; import client, { useQuery } from 'util/client';
import { ICategory } from 'typings'; import { ICategory } from 'typings';
import { useTranslation } from 'react-i18next';
interface IProps { interface IProps {
open: boolean; open: boolean;
@@ -24,6 +25,8 @@ interface IProps {
} }
export default function CategorySelect(props: IProps) { export default function CategorySelect(props: IProps) {
const { t } = useTranslation();
const { open, setOpen, mangaId } = props; const { open, setOpen, mangaId } = props;
const { data: mangaCategoriesData, mutate } = useQuery<ICategory[]>(`/api/v1/manga/${mangaId}/category`); const { data: mangaCategoriesData, mutate } = useQuery<ICategory[]>(`/api/v1/manga/${mangaId}/category`);
@@ -65,14 +68,14 @@ export default function CategorySelect(props: IProps) {
maxWidth="xs" maxWidth="xs"
open={open} open={open}
> >
<DialogTitle>Set categories</DialogTitle> <DialogTitle>{t('category.title.set_categories')}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<FormGroup> <FormGroup>
{allCategories.length === 0 && ( {allCategories.length === 0 && (
<span> <span>
No categories found! {t('category.error.no_categories_found.label.info')}
<br /> <br />
You should make some from settings. {t('category.error.no_categories_found.label.hint')}
</span> </span>
)} )}
{allCategories.map((category) => ( {allCategories.map((category) => (
@@ -92,10 +95,10 @@ export default function CategorySelect(props: IProps) {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button autoFocus onClick={handleCancel} color="primary"> <Button autoFocus onClick={handleCancel} color="primary">
Cancel {t('global.button.cancel')}
</Button> </Button>
<Button onClick={handleOk} color="primary"> <Button onClick={handleOk} color="primary">
Ok {t('global.button.ok')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -18,6 +18,7 @@ import { List, ListItemSecondaryAction, ListItemText } from '@mui/material';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import { langCodeToName } from 'util/language'; import { langCodeToName } from 'util/language';
import cloneObject from 'util/cloneObject'; import cloneObject from 'util/cloneObject';
import { useTranslation } from 'react-i18next';
function removeAll(firstList: any[], secondList: any[]) { function removeAll(firstList: any[], secondList: any[]) {
secondList.forEach((item) => { secondList.forEach((item) => {
@@ -38,6 +39,8 @@ interface IProps {
} }
export default function LangSelect(props: IProps) { export default function LangSelect(props: IProps) {
const { t } = useTranslation();
const { shownLangs, setShownLangs, allLangs, forcedLangs } = props; const { shownLangs, setShownLangs, allLangs, forcedLangs } = props;
// hold a copy and only sate state on parent when OK pressed, improves performance // hold a copy and only sate state on parent when OK pressed, improves performance
const [mShownLangs, setMShownLangs] = useState(removeAll(cloneObject(shownLangs), forcedLangs!)); const [mShownLangs, setMShownLangs] = useState(removeAll(cloneObject(shownLangs), forcedLangs!));
@@ -85,7 +88,7 @@ export default function LangSelect(props: IProps) {
maxWidth="xs" maxWidth="xs"
open={open} open={open}
> >
<DialogTitle>Enabled Languages</DialogTitle> <DialogTitle>{t('global.language.title.enabled_languages')}</DialogTitle>
<DialogContent dividers sx={{ padding: 0 }}> <DialogContent dividers sx={{ padding: 0 }}>
<List> <List>
{allLangs.map((lang) => ( {allLangs.map((lang) => (
@@ -104,10 +107,10 @@ export default function LangSelect(props: IProps) {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button autoFocus onClick={handleCancel} color="primary"> <Button autoFocus onClick={handleCancel} color="primary">
Cancel {t('global.button.cancel')}
</Button> </Button>
<Button onClick={handleOk} color="primary"> <Button onClick={handleOk} color="primary">
Ok {t('global.button.ok')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -11,6 +11,7 @@ import { Link, useLocation } from 'react-router-dom';
import { styled } from '@mui/system'; import { styled } from '@mui/system';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import { NavbarItem } from 'typings'; import { NavbarItem } from 'typings';
import { useTranslation } from 'react-i18next';
const SideNavBarContainer = styled('div')(({ theme }) => ({ const SideNavBarContainer = styled('div')(({ theme }) => ({
height: '100vh', height: '100vh',
@@ -29,6 +30,7 @@ interface IProps {
} }
export default function DesktopSideBar({ navBarItems }: IProps) { export default function DesktopSideBar({ navBarItems }: IProps) {
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const theme = useTheme(); const theme = useTheme();
@@ -48,7 +50,7 @@ export default function DesktopSideBar({ navBarItems }: IProps) {
<Link to={path} style={{ color: 'inherit', textDecoration: 'none' }} key={path}> <Link to={path} style={{ color: 'inherit', textDecoration: 'none' }} key={path}>
<ListItem disableRipple button key={title}> <ListItem disableRipple button key={title}>
<ListItemIcon sx={{ minWidth: '0' }}> <ListItemIcon sx={{ minWidth: '0' }}>
<Tooltip placement="right" title={title}> <Tooltip placement="right" title={t(title)}>
{iconFor(path, IconComponent, SelectedIconComponent)} {iconFor(path, IconComponent, SelectedIconComponent)}
</Tooltip> </Tooltip>
</ListItemIcon> </ListItemIcon>

View File

@@ -11,6 +11,7 @@ import { styled, Box } from '@mui/system';
import { Link as RRDLink, useLocation } from 'react-router-dom'; import { Link as RRDLink, useLocation } from 'react-router-dom';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import { NavbarItem } from 'typings'; import { NavbarItem } from 'typings';
import { useTranslation } from 'react-i18next';
const BottomNavContainer = styled('div')(({ theme }) => ({ const BottomNavContainer = styled('div')(({ theme }) => ({
bottom: 0, bottom: 0,
@@ -38,6 +39,7 @@ interface IProps {
} }
export default function MobileBottomBar({ navBarItems }: IProps) { export default function MobileBottomBar({ navBarItems }: IProps) {
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const theme = useTheme(); const theme = useTheme();
@@ -68,7 +70,7 @@ export default function MobileBottomBar({ navBarItems }: IProps) {
: 'grey.600', : 'grey.600',
}} }}
> >
{title} {t(title)}
</Box> </Box>
</Box> </Box>
</ListItem> </ListItem>

View File

@@ -12,6 +12,7 @@ import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import React from 'react'; import React from 'react';
import { IReaderSettings } from 'typings'; import { IReaderSettings } from 'typings';
import { useTranslation } from 'react-i18next';
interface IProps extends IReaderSettings { interface IProps extends IReaderSettings {
setSettingValue: (key: keyof IReaderSettings, value: string | boolean) => void; setSettingValue: (key: keyof IReaderSettings, value: string | boolean) => void;
@@ -24,10 +25,12 @@ export default function ReaderSettingsOptions({
showPageNumber, showPageNumber,
setSettingValue, setSettingValue,
}: IProps) { }: IProps) {
const { t } = useTranslation();
return ( return (
<List> <List>
<ListItem> <ListItem>
<ListItemText primary="Static Navigation" /> <ListItemText primary={t('reader.settings.label.static_navigation')} />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Switch <Switch
edge="end" edge="end"
@@ -37,7 +40,7 @@ export default function ReaderSettingsOptions({
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText primary="Show page number" /> <ListItemText primary={t('reader.settings.label.show_page_number')} />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Switch <Switch
edge="end" edge="end"
@@ -47,7 +50,7 @@ export default function ReaderSettingsOptions({
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText primary="Load next chapter at ending" /> <ListItemText primary={t('reader.settings.label.load_next_chapter')} />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Switch <Switch
edge="end" edge="end"
@@ -57,24 +60,30 @@ export default function ReaderSettingsOptions({
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText primary="Reader Type" /> <ListItemText primary={t('reader.settings.label.reader_type')} />
<Select <Select
variant="standard" variant="standard"
value={readerType} value={readerType}
onChange={(e) => setSettingValue('readerType', e.target.value)} onChange={(e) => setSettingValue('readerType', e.target.value)}
sx={{ p: 0 }} sx={{ p: 0 }}
> >
<MenuItem value="SingleLTR">Single Page (LTR)</MenuItem> <MenuItem value="SingleLTR">{t('reader.settings.reader_type.label.single_page_ltr')}</MenuItem>
<MenuItem value="SingleRTL">Single Page (RTL)</MenuItem> <MenuItem value="SingleRTL">{t('reader.settings.reader_type.label.single_page_rtl')}</MenuItem>
{/* <MenuItem value="SingleVertical"> {/* <MenuItem value="SingleVertical">
Vertical(WIP) Vertical(WIP)
</MenuItem> */} </MenuItem> */}
<MenuItem value="DoubleLTR">Double Page (LTR)</MenuItem> <MenuItem value="DoubleLTR">{t('reader.settings.reader_type.label.double_page_ltr')}</MenuItem>
<MenuItem value="DoubleRTL">Double Page (RTL)</MenuItem> <MenuItem value="DoubleRTL">{t('reader.settings.reader_type.label.double_page_rtl')}</MenuItem>
<MenuItem value="Webtoon">Webtoon</MenuItem> <MenuItem value="Webtoon">{t('reader.settings.reader_type.label.webtoon')}</MenuItem>
<MenuItem value="ContinuesVertical">Continues Vertical</MenuItem> <MenuItem value="ContinuesVertical">
<MenuItem value="ContinuesHorizontalLTR">Horizontal (LTR)</MenuItem> {t('reader.settings.reader_type.label.continuous_vertical')}
<MenuItem value="ContinuesHorizontalRTL">Horizontal (RTL)</MenuItem> </MenuItem>
<MenuItem value="ContinuesHorizontalLTR">
{t('reader.settings.reader_type.label.continuous_horizontal_ltr')}
</MenuItem>
<MenuItem value="ContinuesHorizontalRTL">
{t('reader.settings.reader_type.label.continuous_horizontal_rtl')}
</MenuItem>
</Select> </Select>
</ListItem> </ListItem>
</List> </List>

View File

@@ -9,9 +9,12 @@ import { IconButton, Menu, MenuItem, FormControlLabel, Radio } from '@mui/materi
import React from 'react'; import React from 'react';
import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { GridLayout, useLibraryOptionsContext } from 'components/context/LibraryOptionsContext'; import { GridLayout, useLibraryOptionsContext } from 'components/context/LibraryOptionsContext';
import { useTranslation } from 'react-i18next';
// TODO: clean up this to use a FormControl, and remove dependency on name o radio button // TODO: clean up this to use a FormControl, and remove dependency on name o radio button
export default function SourceGridLayout() { export default function SourceGridLayout() {
const { t } = useTranslation();
const { const {
options: { SourcegridLayout }, options: { SourcegridLayout },
setOptions, setOptions,
@@ -53,7 +56,7 @@ export default function SourceGridLayout() {
> >
<MenuItem onClick={handleClose}> <MenuItem onClick={handleClose}>
<FormControlLabel <FormControlLabel
label="Compact grid" label={t('global.grid_layout.label.compact_grid')}
value={GridLayout.Compact} value={GridLayout.Compact}
control={ control={
<Radio <Radio
@@ -66,7 +69,7 @@ export default function SourceGridLayout() {
</MenuItem> </MenuItem>
<MenuItem onClick={handleClose}> <MenuItem onClick={handleClose}>
<FormControlLabel <FormControlLabel
label="Comfortable grid" label={t('global.grid_layout.label.comfortable_grid')}
control={ control={
<Radio <Radio
name={GridLayout.Comfortable.toString()} name={GridLayout.Comfortable.toString()}
@@ -78,7 +81,7 @@ export default function SourceGridLayout() {
</MenuItem> </MenuItem>
<MenuItem onClick={handleClose}> <MenuItem onClick={handleClose}>
<FormControlLabel <FormControlLabel
label="List" label={t('global.grid_layout.label.list')}
control={ control={
<Radio <Radio
name={GridLayout.List.toString()} name={GridLayout.List.toString()}

View File

@@ -9,14 +9,14 @@
import React from 'react'; import React from 'react';
import MangaGrid, { IMangaGridProps } from 'components/MangaGrid'; import MangaGrid, { IMangaGridProps } from 'components/MangaGrid';
import { IMangaCard } from 'typings'; import { IMangaCard } from 'typings';
import { useTranslation } from 'react-i18next';
const FILTERED_OUT_MESSAGE = 'There are no Manga matching this filter';
function filterManga(mangas: IMangaCard[]): IMangaCard[] { function filterManga(mangas: IMangaCard[]): IMangaCard[] {
return mangas; return mangas;
} }
export default function SourceMangaGrid(props: IMangaGridProps) { export default function SourceMangaGrid(props: IMangaGridProps) {
const { t } = useTranslation();
const { mangas, isLoading, hasNextPage, lastPageNum, setLastPageNum, message, messageExtra, gridLayout } = props; const { mangas, isLoading, hasNextPage, lastPageNum, setLastPageNum, message, messageExtra, gridLayout } = props;
const filteredManga = filterManga(mangas); const filteredManga = filterManga(mangas);
@@ -29,7 +29,7 @@ export default function SourceMangaGrid(props: IMangaGridProps) {
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
lastPageNum={lastPageNum} lastPageNum={lastPageNum}
setLastPageNum={setLastPageNum} setLastPageNum={setLastPageNum}
message={showFilteredOutMessage ? FILTERED_OUT_MESSAGE : message} message={showFilteredOutMessage ? t('manga.error.label.no_matches') : message}
messageExtra={messageExtra} messageExtra={messageExtra}
gridLayout={gridLayout} gridLayout={gridLayout}
inLibraryIndicator inLibraryIndicator

View File

@@ -22,6 +22,7 @@ import TriStateFilter from 'components/source/filters/TriStateFilter';
import GroupFilter from 'components/source/filters/GroupFilter'; import GroupFilter from 'components/source/filters/GroupFilter';
import SeperatorFilter from 'components/source/filters/SeparatorFilter'; import SeperatorFilter from 'components/source/filters/SeparatorFilter';
import { ISourceFilters, IState } from 'typings'; import { ISourceFilters, IState } from 'typings';
import { useTranslation } from 'react-i18next';
interface IFilters { interface IFilters {
sourceFilter: ISourceFilters[]; sourceFilter: ISourceFilters[];
@@ -143,6 +144,7 @@ export default function SourceOptions({
setSearch, setSearch,
update, update,
}: IFilters1) { }: IFilters1) {
const { t } = useTranslation();
const [FilterOptions, setFilterOptions] = React.useState(false); const [FilterOptions, setFilterOptions] = React.useState(false);
function handleReset() { function handleReset() {
@@ -165,14 +167,14 @@ export default function SourceOptions({
color="primary" color="primary"
> >
<FilterListIcon /> <FilterListIcon />
Filter {t('global.button.filter')}
</Fab> </Fab>
<OptionsPanel open={FilterOptions} onClose={() => setFilterOptions(false)}> <OptionsPanel open={FilterOptions} onClose={() => setFilterOptions(false)}>
<Box sx={{ display: 'flex', p: 2, pb: 0 }}> <Box sx={{ display: 'flex', p: 2, pb: 0 }}>
<Button onClick={handleReset}>Reset</Button> <Button onClick={handleReset}>{t('global.button.reset')}</Button>
<Button sx={{ marginLeft: 'auto' }} variant="contained" onClick={handleSubmit}> <Button sx={{ marginLeft: 'auto' }} variant="contained" onClick={handleSubmit}>
Submit {t('global.button.submit')}
</Button> </Button>
</Box> </Box>
<Box <Box

View File

@@ -16,8 +16,11 @@ import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { EditTextPreferenceProps } from 'typings'; import { EditTextPreferenceProps } from 'typings';
import { useTranslation } from 'react-i18next';
export default function EditTextPreference(props: EditTextPreferenceProps) { export default function EditTextPreference(props: EditTextPreferenceProps) {
const { t } = useTranslation();
const { title, summary, dialogTitle, dialogMessage, currentValue, updateValue } = props; const { title, summary, dialogTitle, dialogMessage, currentValue, updateValue } = props;
const [internalCurrentValue, setInternalCurrentValue] = useState<string>(currentValue); const [internalCurrentValue, setInternalCurrentValue] = useState<string>(currentValue);
@@ -57,10 +60,10 @@ export default function EditTextPreference(props: EditTextPreferenceProps) {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleDialogCancel} color="primary"> <Button onClick={handleDialogCancel} color="primary">
Cancel {t('global.button.cancel')}
</Button> </Button>
<Button onClick={handleDialogSubmit} color="primary"> <Button onClick={handleDialogSubmit} color="primary">
OK {t('global.button.ok')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -17,6 +17,7 @@ import Radio from '@mui/material/Radio';
import FormControlLabel from '@mui/material/FormControlLabel'; import FormControlLabel from '@mui/material/FormControlLabel';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { ListPreferenceProps } from 'typings'; import { ListPreferenceProps } from 'typings';
import { useTranslation } from 'react-i18next';
interface IListDialogProps { interface IListDialogProps {
value: string; value: string;
@@ -27,6 +28,8 @@ interface IListDialogProps {
} }
function ListDialog(props: IListDialogProps) { function ListDialog(props: IListDialogProps) {
const { t } = useTranslation();
const { value: valueProp, open, onClose, options, title } = props; const { value: valueProp, open, onClose, options, title } = props;
const [value, setValue] = React.useState(valueProp); const [value, setValue] = React.useState(valueProp);
const radioGroupRef = React.useRef<HTMLDivElement>(null); const radioGroupRef = React.useRef<HTMLDivElement>(null);
@@ -72,9 +75,9 @@ function ListDialog(props: IListDialogProps) {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button autoFocus onClick={handleCancel}> <Button autoFocus onClick={handleCancel}>
Cancel {t('global.button.cancel')}
</Button> </Button>
<Button onClick={handleOk}>Ok</Button> <Button onClick={handleOk}>{t('global.button.ok')}</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );

View File

@@ -18,6 +18,7 @@ import FormControlLabel from '@mui/material/FormControlLabel';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import cloneObject from 'util/cloneObject'; import cloneObject from 'util/cloneObject';
import { MultiSelectListPreferenceProps } from 'typings'; import { MultiSelectListPreferenceProps } from 'typings';
import { useTranslation } from 'react-i18next';
interface IListDialogProps { interface IListDialogProps {
selectedValues: string[]; selectedValues: string[];
@@ -28,6 +29,8 @@ interface IListDialogProps {
} }
function ListDialog(props: IListDialogProps) { function ListDialog(props: IListDialogProps) {
const { t } = useTranslation();
const { selectedValues: selectedValuesProp, open, onClose, values, title } = props; const { selectedValues: selectedValuesProp, open, onClose, values, title } = props;
const [selectedValues, setSelectedValues] = React.useState(selectedValuesProp); const [selectedValues, setSelectedValues] = React.useState(selectedValuesProp);
@@ -86,9 +89,9 @@ function ListDialog(props: IListDialogProps) {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button autoFocus onClick={handleCancel}> <Button autoFocus onClick={handleCancel}>
Cancel {t('global.button.cancel')}
</Button> </Button>
<Button onClick={handleOk}>Ok</Button> <Button onClick={handleOk}>{t('global.button.ok')}</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );

View File

@@ -1,19 +0,0 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */
// eslint-disable-next-line import/prefer-default-export
export const pluralize = (count: number, input: string | { one: string; many: string }) => {
if (typeof input === 'string') {
return `${input}${count === 1 ? '' : 's'}`;
}
return input[count === 1 ? 'one' : 'many'];
};
export const interpolate = (count: number, input: { one: string; many: string }) => {
const text = count === 1 ? input.one : input.many;
return text.replaceAll('%count%', count.toString());
};

9
src/i18n/i18next.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import 'i18next';
import resources from 'i18n/translations';
declare module 'i18next' {
interface CustomTypeOptions {
returnNull: false;
resources: typeof resources.en;
}
}

View File

@@ -1,19 +1,426 @@
{ {
"screens": { "category": {
"Library": { "dialog": {
"Library": "Library", "title": {
"could-not-load-categories": "Could not load categories", "edit_category": "Edit Catalog",
"your-library-is-empty": "Your Library is empty", "new_category": "New Catalog"
"category-is-empty": "Category is Empty", }
"could-not-load-manga": "Could not load manga"
}, },
"Browse": { "error": {
"sources": "Sources", "label": {
"extensions": "Extensions" "empty": "Category is Empty",
"request_failure": "Could not load categories"
},
"no_categories_found": {
"label": {
"hint": "You should make some from settings.",
"info": "No categories found!"
}
}
}, },
"DownloadQueue": { "label": {
"no-downloads": "No downloads", "category_name": "Category Name",
"download-queue": "Download Queue" "use_as_default_category": "Default category when adding new manga to library"
},
"title": {
"categories": "Categories",
"set_categories": "Set categories"
} }
},
"chapter": {
"action": {
"bookmark": {
"add": {
"button": {
"selected": "Bookmark selected"
},
"label": {
"action": "Add bookmark",
"error_one": "Error bookmarking chapter",
"error_other": "Error bookmarking chapters",
"success_one": "Chapter bookmarked",
"success_other": "{{count}} chapters bookmarked"
}
},
"remove": {
"button": {
"selected": "Remove bookmarks from selected"
},
"label": {
"action": "Remove bookmark",
"error_one": "Error removing bookmark",
"error_other": "Error removing bookmarks",
"success_one": "Chapter bookmark removed",
"success_other": "{{count}} chapter bookmarks removed"
}
}
},
"download": {
"add": {
"button": {
"selected": "Download selected"
},
"label": {
"action": "Download",
"error_one": "Error adding download",
"error_other": "Error adding downloads",
"success_one": "Download added",
"success_other": "{{count}} downloads added"
}
},
"delete": {
"button": {
"selected": "Delete selected"
},
"label": {
"action": "Delete",
"error_one": "Error deleting chapter",
"error_other": "Error deleting chapters",
"success_one": "Chapter deleted",
"success_other": "{{count}} chapters deleted"
}
}
},
"label": {
"select": "Select"
},
"mark_as_read": {
"add": {
"button": {
"selected": "Mark selected as read"
},
"label": {
"action": {
"current": "Mark as read",
"previous": "Mark previous as Read"
},
"error_one": "Error marking chapter as read",
"error_other": "Error marking chapters as read",
"success_one": "Chapter marked as read",
"success_other": "{{count}} chapters marked as read"
}
},
"remove": {
"button": {
"selected": "Mark selected as unread"
},
"label": {
"action": "Mark as unread",
"error_one": "Error marking chapter as unread",
"error_other": "Error marking chapters as unread",
"success_one": "Chapter marked as unread",
"success_other": "{{count}} chapters marked as unread"
}
}
}
},
"error": {
"label": {
"no_chapter_found": "No chapters found",
"no_matches": "No chapters matching filter"
}
},
"option": {
"display": {
"label": {
"chapter_number": "Chapter Number",
"source_title": "Source Title"
}
}
},
"status": {
"label": {
"downloaded": "Downloaded"
}
},
"title": "Chapter",
"title_one": "Chapter",
"title_other": "Chapters"
},
"download": {
"queue": {
"label": {
"no_downloads": "No downloads"
},
"title": "Download Queue"
},
"state": {
"label": {
"downloading": "Downloading",
"error": "Error",
"finished": "Finished",
"queued": "Queued"
}
},
"title": "Downloads"
},
"extension": {
"action": {
"label": {
"install": "install",
"uninstall": "uninstall",
"update": "update"
}
},
"label": {
"installation_failed": "Extension installation failed!",
"installed_successfully": "Installed extension successfully!",
"installing_file": "Installing Extension File..."
},
"language": {
"all": "All"
},
"state": {
"label": {
"installing": "installing",
"obsolete": "obsolete",
"uninstalling": "uninstalling",
"updating": "updating"
}
},
"title": "Extensions"
},
"global": {
"button": {
"browse": "Browse",
"cancel": "Cancel",
"clear": "Clear",
"filter": "Filter",
"latest": "Latest",
"ok": "Ok",
"open_site": "Open Site",
"reset": "Reset",
"reset_to_default": "Reset to Default",
"resume": "Resume",
"select_all": "Select all",
"set": "Set",
"start": "Start",
"submit": "Submit"
},
"date": {
"label": {
"today": "TODAY",
"today_at": "Today at {{timeString}}",
"yesterday": "YESTERDAY",
"yesterday_at": "Yesterday at {{timeString}}"
}
},
"error": {
"label": {
"invalid_action": "This is not a valid Action",
"invalid_file_type": "invalid file type!",
"update_failed": "Checking for updates failed!"
}
},
"filter": {
"label": {
"bookmarked": "Bookmarked",
"downloaded": "Downloaded",
"unread": "Unread"
}
},
"grid_layout": {
"label": {
"comfortable_grid": "Comfortable grid",
"compact_grid": "Compact grid",
"list": "List"
},
"title": "Display mode"
},
"label": {
"browse": "Browse",
"display": "Display",
"filter": "Filter",
"loading": "Loading...",
"sort": "Sort"
},
"language": {
"label": {
"language_with_code": "language with code: {{code}}"
},
"title": {
"enabled_languages": "Enabled Languages"
}
},
"sort": {
"label": {
"by_fetch_date": "By Fetch date",
"by_source": "By Source"
}
}
},
"library": {
"error": {
"label": {
"empty": "Your Library is empty",
"no_matches": "There are no Manga matching this filter"
}
},
"option": {
"display": {
"badge": {
"label": {
"download_badges": "Download Badges",
"unread_badges": "Unread Badges"
},
"title": "Badges"
}
},
"sort": {
"label": {
"alphabetically": "Alphabetically",
"by_date_added": "By Date Added",
"by_last_read": "By Last Read",
"by_unread_chapters": "By Unread chapters"
}
}
},
"title": "Library"
},
"manga": {
"button": {
"add_to_library": "Add To Library",
"in_library": "In Library"
},
"error": {
"label": {
"no_mangas_found": "No manga found!",
"no_matches": "There are no Manga matching this filter",
"request_failure": "Could not load manga"
}
},
"label": {
"artist": "Artist",
"author": "Author",
"edit_categories": "Edit manga categories",
"reload_from_source": "Reload data from source",
"status": "Status"
},
"title": "Manga"
},
"reader": {
"button": {
"next_chapter": "Next Chapter",
"previous_chapter": "Previous Chapter"
},
"page_info": {
"label": {
"currently_on_page": "Currently on page",
"of_max_pages": "of {{maxPages}}"
}
},
"settings": {
"error": {
"label": {
"failed_to_save_default_settings": "Failed to save the default reader settings to the server",
"failed_to_save_settings": "Failed to save the reader settings to the server"
}
},
"label": {
"load_next_chapter": "Load next chapter at ending",
"reader_type": "Reader Type",
"show_page_number": "Show page number",
"static_navigation": "Static Navigation"
},
"reader_type": {
"label": {
"continuous_horizontal_ltr": "Horizontal (LTR)",
"continuous_horizontal_rtl": "Horizontal (RTL)",
"continuous_vertical": "Continues Vertical",
"double_page_ltr": "Double Page (LTR)",
"double_page_rtl": "Double Page (RTL)",
"single_page_ltr": "Single Page (LTR)",
"single_page_rtl": "Single Page (RTL)",
"webtoon": "Webtoon"
}
},
"title": {
"default_reader_settings": "Default Reader Settings",
"reader_settings": "Reader Settings"
}
},
"title": "Reader - Manga {{mangaId}} Chapter {{chapterIndex}}"
},
"search": {
"error": {
"label": {
"failed_to_save_settings": "Failed to save the default search settings to the server"
}
},
"label": {
"ignore_filters": "Ignore Filters when Searching"
},
"title": {
"global_search": "Global Search"
}
},
"settings": {
"about": {
"label": {
"build_time": "Build time",
"discord": "Discord",
"github": "Github",
"server": "Server",
"server_address": "Server Address",
"server_version": "Server version"
},
"title": "About"
},
"backup": {
"label": {
"backup_restore_failed": "Backup restore failed!",
"create_backup": "Create Backup",
"create_backup_info": "Backup library as a Tachiyomi backup",
"legacy_backup_unsupported": "legacy backups are not supported!",
"restore_backup": "Restore Backup",
"restore_backup_info": "You can also drag and drop the backup file here to restore",
"restored_backup": "Backup restore finished!",
"restoring_backup": "Restoring backup..."
},
"title": "Backup"
},
"label": {
"dark_theme": "Dark Theme",
"image_cache": "Use image cache",
"image_cache_description": "Disabling image cache makes images load faster if you have a slow disk, but uses it much more internet traffic in turn",
"manga_item_width": "Manga Item width",
"show_nsfw": "Show NSFW",
"show_nsfw_description": "Hide NSFW extensions and sources"
},
"server_address": {
"dialog": {
"label": {
"enter_address": "Enter Server Address"
}
}
},
"title": "Settings"
},
"source": {
"configuration": {
"title": "Source Configuration"
},
"error": {
"label": {
"no_sources_found": "No sources found. Install Some Extensions first."
}
},
"local_source": {
"label": {
"checkout": "Check out",
"guide": "Local source guide"
}
},
"title": "Source",
"title_one": "Source",
"title_other": "Sources"
},
"updates": {
"error": {
"label": {
"no_updates_available": "You don't have any updates yet."
}
},
"title": "Updates"
} }
} }

View File

@@ -1,7 +1,5 @@
{ {
"screens": { "library": {
"Library": { "title": "المكتبة"
"Library": "المكتبة"
}
} }
} }

View File

@@ -1,19 +1,39 @@
{ {
"screens": { "category": {
"Library": { "error": {
"Library": "Bibliotek", "label": {
"could-not-load-categories": "Kategorien konnten nicht geladen werden", "empty": "Kategorie ist leer",
"category-is-empty": "Kategorie ist leer", "request_failure": "Kategorien konnten nicht geladen werden"
"could-not-load-manga": "Manga konnte nicht geladen werden", }
"your-library-is-empty": "Deine Bibliothek ist leer"
},
"Browse": {
"sources": "Quellen",
"extensions": "Erweiterungen"
},
"DownloadQueue": {
"no-downloads": "Keine Downloads",
"download-queue": "Herunterladen-Warteschlange"
} }
},
"download": {
"queue": {
"label": {
"no_downloads": "Keine Downloads"
},
"title": "Herunterladen-Warteschlange"
}
},
"extension": {
"title": "Erweiterungen"
},
"library": {
"error": {
"label": {
"empty": "Deine Bibliothek ist leer"
}
},
"title": "Bibliotek"
},
"manga": {
"error": {
"label": {
"request_failure": "Manga konnte nicht geladen werden"
}
}
},
"source": {
"title": "Quellen"
} }
} }

View File

@@ -1,19 +1,39 @@
{ {
"screens": { "category": {
"Library": { "error": {
"Library": "Biblioteca", "label": {
"could-not-load-categories": "No se pudieron cargar las categorías", "empty": "La categoría está vacía",
"your-library-is-empty": "Tu biblioteca esta vacia", "request_failure": "No se pudieron cargar las categorías"
"category-is-empty": "La categoría está vacía", }
"could-not-load-manga": "No se pudo cargar el manga"
},
"DownloadQueue": {
"no-downloads": "No hay descargas",
"download-queue": "Descarga en la cola"
},
"Browse": {
"extensions": "Extensiones",
"sources": "Fuentes"
} }
},
"download": {
"queue": {
"label": {
"no_downloads": "No hay descargas"
},
"title": "Descarga en la cola"
}
},
"extension": {
"title": "Extensiones"
},
"library": {
"error": {
"label": {
"empty": "Tu biblioteca esta vacia"
}
},
"title": "Biblioteca"
},
"manga": {
"error": {
"label": {
"request_failure": "No se pudo cargar el manga"
}
}
},
"source": {
"title": "Fuentes"
} }
} }

View File

@@ -1,19 +1,39 @@
{ {
"screens": { "category": {
"Library": { "error": {
"Library": "Bibliothèque", "label": {
"could-not-load-categories": "Impossible de charger les catégories", "empty": "La catégorie est vide",
"your-library-is-empty": "Votre bibliothèque est vide", "request_failure": "Impossible de charger les catégories"
"category-is-empty": "La catégorie est vide", }
"could-not-load-manga": "Impossible de charger le manga"
},
"Browse": {
"sources": "Sources",
"extensions": "Extensions"
},
"DownloadQueue": {
"no-downloads": "Aucun téléchargement",
"download-queue": "File dattente des téléchargements"
} }
},
"download": {
"queue": {
"label": {
"no_downloads": "Aucun téléchargement"
},
"title": "File dattente des téléchargements"
}
},
"extension": {
"title": "Extensions"
},
"library": {
"error": {
"label": {
"empty": "Votre bibliothèque est vide"
}
},
"title": "Bibliothèque"
},
"manga": {
"error": {
"label": {
"request_failure": "Impossible de charger le manga"
}
}
},
"source": {
"title": "Sources"
} }
} }

View File

@@ -7,12 +7,12 @@
import en from 'i18n/locale/en.json'; import en from 'i18n/locale/en.json';
const translationHelper = (lng: any) => ({ const translationHelper = <T>(lng: T) => ({
translation: lng, translation: lng,
}); });
const resources = { const resources = {
en: translationHelper(en), en: translationHelper(en),
}; } as const;
export default resources; export default resources;

View File

@@ -30,8 +30,8 @@ export default function Browse() {
scrollButtons scrollButtons
allowScrollButtonsMobile allowScrollButtonsMobile
> >
<Tab sx={{ textTransform: 'none' }} label={t('screens.Browse.sources')} /> <Tab sx={{ textTransform: 'none' }} label={t('source.title')} />
<Tab sx={{ textTransform: 'none' }} label={t('screens.Browse.extensions')} /> <Tab sx={{ textTransform: 'none' }} label={t('extension.title')} />
</Tabs> </Tabs>
<TabPanel index={0} currentIndex={tabNum}> <TabPanel index={0} currentIndex={tabNum}>
<Sources /> <Sources />

View File

@@ -51,7 +51,7 @@ const DownloadQueue: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
setTitle(t('screens.DownloadQueue.download-queue')); setTitle(t('download.queue.title'));
setAction(null); setAction(null);
}, []); }, []);
@@ -59,7 +59,7 @@ const DownloadQueue: React.FC = () => {
const onDragEnd = (result: DropResult) => {}; const onDragEnd = (result: DropResult) => {};
if (queue.length === 0) { if (queue.length === 0) {
return <EmptyView message={t('screens.DownloadQueue.no-downloads')} />; return <EmptyView message={t('download.queue.label.no_downloads')} />;
} }
const handleDelete = (chapter: IChapter) => { const handleDelete = (chapter: IChapter) => {

View File

@@ -22,6 +22,7 @@ import { useQueryParam, StringParam } from 'use-query-params';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { Typography, useMediaQuery, useTheme } from '@mui/material'; import { Typography, useMediaQuery, useTheme } from '@mui/material';
import { IExtension } from 'typings'; import { IExtension } from 'typings';
import { useTranslation } from 'react-i18next';
const LANGUAGE = 0; const LANGUAGE = 0;
const EXTENSIONS = 1; const EXTENSIONS = 1;
@@ -66,6 +67,8 @@ function groupExtensions(extensions: IExtension[]) {
} }
export default function MangaExtensions() { export default function MangaExtensions() {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs()); const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs());
@@ -75,7 +78,7 @@ export default function MangaExtensions() {
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
useEffect(() => { useEffect(() => {
setTitle('Extensions'); setTitle(t('extension.title'));
setAction( setAction(
<> <>
<AppbarSearch /> <AppbarSearch />
@@ -120,18 +123,18 @@ export default function MangaExtensions() {
inputRef.current.value = ''; inputRef.current.value = '';
} }
makeToast('Installing Extension File....', 'info'); makeToast(t('extension.label.installing_file'), 'info');
client client
.post('/api/v1/extension/install', formData, { .post('/api/v1/extension/install', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}) })
.then(() => { .then(() => {
makeToast('Installed extension successfully!', 'success'); makeToast(t('extension.label.installed_successfully'), 'success');
mutate(); mutate();
}) })
.catch(() => makeToast('Extension installion failed!', 'error')); .catch(() => makeToast(t('extension.label.installation_failed'), 'error'));
} else { } else {
makeToast('invalid file type!', 'error'); makeToast(t('global.error.label.invalid_file_type'), 'error');
} }
}; };

View File

@@ -41,7 +41,7 @@ export default function Library() {
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { useEffect(() => {
setTitle(t('screens.Library.Library')); setTitle(t('library.title'));
setAction( setAction(
<> <>
<AppbarSearch /> <AppbarSearch />
@@ -62,7 +62,7 @@ export default function Library() {
if (tabsError != null) { if (tabsError != null) {
return ( return (
<EmptyView <EmptyView
message={t('screens.Library.could-not-load-categories')} message={t('category.error.label.request_failure')}
messageExtra={tabsError?.message ?? tabsError} messageExtra={tabsError?.message ?? tabsError}
/> />
); );
@@ -73,7 +73,7 @@ export default function Library() {
} }
if (tabs.length === 0) { if (tabs.length === 0) {
return <EmptyView message={t('screens.Library.your-library-is-empty')} />; return <EmptyView message={t('library.error.label.empty')} />;
} }
if (tabs.length === 1) { if (tabs.length === 1) {
@@ -81,7 +81,7 @@ export default function Library() {
<LibraryMangaGrid <LibraryMangaGrid
mangas={mangas} mangas={mangas}
lastLibraryUpdate={lastLibraryUpdate} lastLibraryUpdate={lastLibraryUpdate}
message={t('screens.Library.your-library-is-empty') as string} message={t('library.error.label.empty') as string}
isLoading={activeTab != null && mangaLoading} isLoading={activeTab != null && mangaLoading}
/> />
); );
@@ -112,14 +112,14 @@ export default function Library() {
{tab === activeTab && {tab === activeTab &&
(mangaError ? ( (mangaError ? (
<EmptyView <EmptyView
message={t('screens.Library.could-not-load-manga')} message={t('manga.error.label.request_failure')}
messageExtra={mangaError?.message ?? mangaError} messageExtra={mangaError?.message ?? mangaError}
/> />
) : ( ) : (
<LibraryMangaGrid <LibraryMangaGrid
mangas={mangas} mangas={mangas}
lastLibraryUpdate={lastLibraryUpdate} lastLibraryUpdate={lastLibraryUpdate}
message={t('screens.Library.category-is-empty') as string} message={t('library.error.label.empty') as string}
isLoading={mangaLoading} isLoading={mangaLoading}
/> />
))} ))}

View File

@@ -17,6 +17,7 @@ import { NavbarToolbar } from 'components/navbar/DefaultNavBar';
import EmptyView from 'components/util/EmptyView'; import EmptyView from 'components/util/EmptyView';
import LoadingPlaceholder from 'components/util/LoadingPlaceholder'; import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
import React, { useContext, useEffect, useRef } from 'react'; import React, { useContext, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQuery } from 'util/client'; import { useQuery } from 'util/client';
import { IManga } from 'typings'; import { IManga } from 'typings';
@@ -24,6 +25,8 @@ import { IManga } from 'typings';
const AUTOFETCH_AGE = 60 * 60 * 24; // 24 hours const AUTOFETCH_AGE = 60 * 60 * 24; // 24 hours
const Manga: React.FC = () => { const Manga: React.FC = () => {
const { t } = useTranslation();
const { setTitle } = useContext(NavbarContext); const { setTitle } = useContext(NavbarContext);
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const autofetchedRef = useRef(false); const autofetchedRef = useRef(false);
@@ -58,11 +61,11 @@ const Manga: React.FC = () => {
}, [manga]); }, [manga]);
useEffect(() => { useEffect(() => {
setTitle(manga?.title ?? 'Manga'); setTitle(manga?.title ?? t('manga.title'));
}, [manga?.title]); }, [manga?.title]);
if (error && !manga) { if (error && !manga) {
return <EmptyView message="Could not load manga" messageExtra={error.message ?? error} />; return <EmptyView message={t('manga.error.label.request_failure')} messageExtra={error.message ?? error} />;
} }
return ( return (
<Box sx={{ display: { md: 'flex' }, overflow: 'hidden' }}> <Box sx={{ display: { md: 'flex' }, overflow: 'hidden' }}>
@@ -72,7 +75,7 @@ const Manga: React.FC = () => {
<Tooltip <Tooltip
title={ title={
<> <>
Could not fetch manga data {t('manga.error.label.request_failure')}
<br /> <br />
{error.message ?? error} {error.message ?? error}
</> </>

View File

@@ -26,6 +26,7 @@ import {
} from 'util/readerSettings'; } from 'util/readerSettings';
import makeToast from 'components/util/Toast'; import makeToast from 'components/util/Toast';
import { IChapter, IManga, IMangaCard, IPartialChapter, IReaderSettings, ReaderType } from 'typings'; import { IChapter, IManga, IMangaCard, IPartialChapter, IReaderSettings, ReaderType } from 'typings';
import { useTranslation } from 'react-i18next';
const getReaderComponent = (readerType: ReaderType) => { const getReaderComponent = (readerType: ReaderType) => {
switch (readerType) { switch (readerType) {
@@ -62,6 +63,7 @@ const initialChapter = () => ({
}); });
export default function Reader() { export default function Reader() {
const { t } = useTranslation();
const history = useHistory(); const history = useHistory();
const [serverAddress] = useLocalStorage<String>('serverBaseURL', ''); const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
@@ -87,13 +89,13 @@ export default function Reader() {
const setSettingValue = (key: keyof IReaderSettings, value: string | boolean) => { const setSettingValue = (key: keyof IReaderSettings, value: string | boolean) => {
setSettings({ ...settings, [key]: value }); setSettings({ ...settings, [key]: value });
requestUpdateMangaMetadata(manga, [[key, value]]).catch(() => requestUpdateMangaMetadata(manga, [[key, value]]).catch(() =>
makeToast('Failed to save the reader settings to the server', 'warning'), makeToast(t('reader.settings.error.label.failed_to_save_settings'), 'warning'),
); );
}; };
useEffect(() => { useEffect(() => {
if (!manga?.title || (chapter as IChapter)?.name === 'Loading...') { if (!manga?.title || (chapter as IChapter)?.name === t('global.label.loading')) {
setTitle(`Reader - Manga ${mangaId} Chapter ${chapterIndex}`); setTitle(t('reader.title'));
} else { } else {
setTitle(`${manga.title}: ${(chapter as IChapter).name}`); setTitle(`${manga.title}: ${(chapter as IChapter).name}`);
} }

View File

@@ -19,6 +19,7 @@ import client from 'util/client';
import { langCodeToName, langSortCmp, sourceDefualtLangs, sourceForcedDefaultLangs } from 'util/language'; import { langCodeToName, langSortCmp, sourceDefualtLangs, sourceForcedDefaultLangs } from 'util/language';
import useLocalStorage from 'util/useLocalStorage'; import useLocalStorage from 'util/useLocalStorage';
import { ISource } from 'typings'; import { ISource } from 'typings';
import { useTranslation } from 'react-i18next';
function sourceToLangList(sources: ISource[]) { function sourceToLangList(sources: ISource[]) {
const result: string[] = []; const result: string[] = [];
@@ -34,6 +35,8 @@ function sourceToLangList(sources: ISource[]) {
} }
const SearchAll: React.FC = () => { const SearchAll: React.FC = () => {
const { t } = useTranslation();
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
const [triggerUpdate, setTriggerUpdate] = useState<number>(2); const [triggerUpdate, setTriggerUpdate] = useState<number>(2);
@@ -53,7 +56,7 @@ const SearchAll: React.FC = () => {
const limit = new PQueue({ concurrency: 5 }); const limit = new PQueue({ concurrency: 5 });
useEffect(() => { useEffect(() => {
setTitle('Global Search'); setTitle(t('search.title.global_search'));
setAction(<AppbarSearch />); setAction(<AppbarSearch />);
}, []); }, []);
@@ -146,7 +149,7 @@ const SearchAll: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
setTitle('Sources'); setTitle(t('source.title'));
setAction( setAction(
<> <>
<AppbarSearch autoOpen /> <AppbarSearch autoOpen />
@@ -210,7 +213,7 @@ const SearchAll: React.FC = () => {
setLastPageNum={setLastPageNum} setLastPageNum={setLastPageNum}
horizontal horizontal
noFaces noFaces
message={fetched[id] ? 'No manga was found!' : undefined} message={fetched[id] ? t('manga.error.label.no_mangas_found') : undefined}
inLibraryIndicator inLibraryIndicator
/> />
</> </>

View File

@@ -36,11 +36,14 @@ import DarkTheme from 'components/context/DarkTheme';
import useLocalStorage from 'util/useLocalStorage'; import useLocalStorage from 'util/useLocalStorage';
import ListItemLink from 'components/util/ListItemLink'; import ListItemLink from 'components/util/ListItemLink';
import SearchSettings from 'screens/settings/SearchSettings'; import SearchSettings from 'screens/settings/SearchSettings';
import { useTranslation } from 'react-i18next';
export default function Settings() { export default function Settings() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { useEffect(() => {
setTitle('Settings'); setTitle(t('settings.title'));
setAction(null); setAction(null);
}, []); }, []);
@@ -99,25 +102,25 @@ export default function Settings() {
<ListItemIcon> <ListItemIcon>
<ListAltIcon /> <ListAltIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Categories" /> <ListItemText primary={t('category.title.categories')} />
</ListItemLink> </ListItemLink>
<ListItemLink to="/settings/defaultReaderSettings"> <ListItemLink to="/settings/defaultReaderSettings">
<ListItemIcon> <ListItemIcon>
<AutoStoriesIcon /> <AutoStoriesIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Default Reader Settings" /> <ListItemText primary={t('reader.settings.title.default_reader_settings')} />
</ListItemLink> </ListItemLink>
<ListItemLink to="/settings/backup"> <ListItemLink to="/settings/backup">
<ListItemIcon> <ListItemIcon>
<BackupIcon /> <BackupIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Backup" /> <ListItemText primary={t('settings.backup.title')} />
</ListItemLink> </ListItemLink>
<ListItem> <ListItem>
<ListItemIcon> <ListItemIcon>
<Brightness6Icon /> <Brightness6Icon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Dark Theme" /> <ListItemText primary={t('settings.label.dark_theme')} />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Switch edge="end" checked={darkTheme} onChange={() => setDarkTheme(!darkTheme)} /> <Switch edge="end" checked={darkTheme} onChange={() => setDarkTheme(!darkTheme)} />
</ListItemSecondaryAction> </ListItemSecondaryAction>
@@ -128,7 +131,7 @@ export default function Settings() {
<ViewModuleIcon /> <ViewModuleIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary="Manga Item width" primary={t('settings.label.manga_item_width')}
secondary={`px:${ItemWidth}`} secondary={`px:${ItemWidth}`}
onClick={() => { onClick={() => {
handleDialogOpenItemWidth(); handleDialogOpenItemWidth();
@@ -139,7 +142,10 @@ export default function Settings() {
<ListItemIcon> <ListItemIcon>
<FavoriteIcon /> <FavoriteIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Show NSFW" secondary="Hide NSFW extensions and sources" /> <ListItemText
primary={t('settings.label.show_nsfw')}
secondary={t('settings.label.show_nsfw_description')}
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Switch edge="end" checked={showNsfw} onChange={() => setShowNsfw(!showNsfw)} /> <Switch edge="end" checked={showNsfw} onChange={() => setShowNsfw(!showNsfw)} />
</ListItemSecondaryAction> </ListItemSecondaryAction>
@@ -149,9 +155,8 @@ export default function Settings() {
<CachedIcon /> <CachedIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary="Use image cache" primary={t('settings.label.image_cache')}
secondary="Disabling image cache makes images load faster if you have a slow disk, secondary={t('settings.label.image_cache_description')}
but uses it much more internet traffic in turn"
/> />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Switch edge="end" checked={useCache} onChange={() => setUseCache(!useCache)} /> <Switch edge="end" checked={useCache} onChange={() => setUseCache(!useCache)} />
@@ -161,7 +166,7 @@ export default function Settings() {
<ListItemIcon> <ListItemIcon>
<DnsIcon /> <DnsIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Server Address" secondary={serverAddress} /> <ListItemText primary={t('settings.about.label.server_address')} secondary={serverAddress} />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<IconButton <IconButton
onClick={() => { onClick={() => {
@@ -177,18 +182,18 @@ export default function Settings() {
<ListItemIcon> <ListItemIcon>
<InfoIcon /> <InfoIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="About" /> <ListItemText primary={t('settings.about.title')} />
</ListItemLink> </ListItemLink>
</List> </List>
<Dialog open={dialogOpen} onClose={handleDialogCancel}> <Dialog open={dialogOpen} onClose={handleDialogCancel}>
<DialogContent> <DialogContent>
<DialogContentText>Enter Server Address</DialogContentText> <DialogContentText>{t('settings.server_address.dialog.label.enter_address')}</DialogContentText>
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
id="name" id="name"
label="Server Address" label={t('settings.about.label.server_address')}
type="text" type="text"
fullWidth fullWidth
value={dialogValue} value={dialogValue}
@@ -198,16 +203,16 @@ export default function Settings() {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleDialogCancel} color="primary"> <Button onClick={handleDialogCancel} color="primary">
Cancel {t('global.button.cancel')}
</Button> </Button>
<Button onClick={handleDialogSubmit} color="primary"> <Button onClick={handleDialogSubmit} color="primary">
Set {t('global.button.set')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Dialog open={dialogOpenItemWidth} onClose={handleDialogCancelItemWidth}> <Dialog open={dialogOpenItemWidth} onClose={handleDialogCancelItemWidth}>
<DialogTitle>Manga Item width</DialogTitle> <DialogTitle>{t('settings.label.manga_item_width')}</DialogTitle>
<DialogContent <DialogContent
sx={{ sx={{
width: '98%', width: '98%',
@@ -236,13 +241,13 @@ export default function Settings() {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleDialogResetItemWidth} color="primary"> <Button onClick={handleDialogResetItemWidth} color="primary">
Reset to Default {t('global.button.reset_to_default')}
</Button> </Button>
<Button onClick={handleDialogCancelItemWidth} color="primary"> <Button onClick={handleDialogCancelItemWidth} color="primary">
Cancel {t('global.button.cancel')}
</Button> </Button>
<Button onClick={handleDialogSubmitItemWidth} color="primary"> <Button onClick={handleDialogSubmitItemWidth} color="primary">
OK {t('global.button.ok')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -16,6 +16,7 @@ import MultiSelectListPreference from 'components/sourceConfiguration/MultiSelec
import List from '@mui/material/List'; import List from '@mui/material/List';
import cloneObject from 'util/cloneObject'; import cloneObject from 'util/cloneObject';
import { SourcePreferences } from 'typings'; import { SourcePreferences } from 'typings';
import { useTranslation } from 'react-i18next';
function getPrefComponent(type: string) { function getPrefComponent(type: string) {
switch (type) { switch (type) {
@@ -35,10 +36,11 @@ function getPrefComponent(type: string) {
} }
export default function SourceConfigure() { export default function SourceConfigure() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { useEffect(() => {
setTitle('Source Configuration'); setTitle(t('source.configuration.title'));
setAction(null); setAction(null);
}, []); }, []);

View File

@@ -18,6 +18,7 @@ import { useQueryParam, StringParam } from 'use-query-params';
import SourceGridLayout from 'components/source/GridLayouts'; import SourceGridLayout from 'components/source/GridLayouts';
import { useLibraryOptionsContext } from 'components/context/LibraryOptionsContext'; import { useLibraryOptionsContext } from 'components/context/LibraryOptionsContext';
import { IManga, IMangaCard, ISource, ISourceFilters } from 'typings'; import { IManga, IMangaCard, ISource, ISourceFilters } from 'typings';
import { useTranslation } from 'react-i18next';
interface IPos { interface IPos {
position: number; position: number;
@@ -26,6 +27,7 @@ interface IPos {
} }
export default function SourceMangas({ popular }: { popular: boolean }) { export default function SourceMangas({ popular }: { popular: boolean }) {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
const history = useHistory(); const history = useHistory();
@@ -58,7 +60,7 @@ export default function SourceMangas({ popular }: { popular: boolean }) {
} }
useEffect(() => { useEffect(() => {
setTitle('Source'); // title is later set after a fetch but we set it here once setTitle(t('source.title')); // title is later set after a fetch but we set it here once
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -220,12 +222,14 @@ export default function SourceMangas({ popular }: { popular: boolean }) {
let messageExtra; let messageExtra;
if (fetched) { if (fetched) {
message = 'No manga was found!'; message = t('manga.error.label.no_mangas_found');
if (sourceId === '0') { if (sourceId === '0') {
messageExtra = ( messageExtra = (
<> <>
<span>Check out </span> <span>{t('source.local_source.label.checkout')} </span>
<a href="https://github.com/Suwayomi/Tachidesk-Server/wiki/Local-Source">Local source guide</a> <a href="https://github.com/Suwayomi/Tachidesk-Server/wiki/Local-Source">
{t('source.local_source.label.guide')}
</a>
</> </>
); );
} }

View File

@@ -17,6 +17,7 @@ import TravelExploreIcon from '@mui/icons-material/TravelExplore';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { useQuery } from 'util/client'; import { useQuery } from 'util/client';
import { ISource } from 'typings'; import { ISource } from 'typings';
import { useTranslation } from 'react-i18next';
function sourceToLangList(sources: ISource[]) { function sourceToLangList(sources: ISource[]) {
const result: string[] = []; const result: string[] = [];
@@ -44,6 +45,7 @@ function groupByLang(sources: ISource[]) {
} }
export default function Sources() { export default function Sources() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs()); const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs());
@@ -70,7 +72,7 @@ export default function Sources() {
}, []); }, []);
useEffect(() => { useEffect(() => {
setTitle('Sources'); setTitle(t('source.title'));
setAction( setAction(
<> <>
<IconButton onClick={() => history.push('/sources/all/search/')} size="large"> <IconButton onClick={() => history.push('/sources/all/search/')} size="large">
@@ -89,7 +91,7 @@ export default function Sources() {
if (loading) return <LoadingPlaceholder />; if (loading) return <LoadingPlaceholder />;
if (sources?.length === 0) { if (sources?.length === 0) {
return <h3>No sources found. Install Some Extensions first.</h3>; return <h3>{t('source.error.label.no_sources_found')}</h3>;
} }
return ( return (

View File

@@ -22,6 +22,8 @@ import { Link, useHistory } from 'react-router-dom';
import client from 'util/client'; import client from 'util/client';
import useLocalStorage from 'util/useLocalStorage'; import useLocalStorage from 'util/useLocalStorage';
import { IChapter, IMangaChapter, IQueue, PaginatedList } from 'typings'; import { IChapter, IMangaChapter, IQueue, PaginatedList } from 'typings';
import { useTranslation } from 'react-i18next';
import { t as translate } from 'i18next';
function epochToDate(epoch: number) { function epochToDate(epoch: number) {
const date = new Date(0); // The 0 there is the key, which sets the date to the epoch const date = new Date(0); // The 0 there is the key, which sets the date to the epoch
@@ -39,11 +41,11 @@ function isTheSameDay(first: Date, second: Date) {
function getDateString(date: Date) { function getDateString(date: Date) {
const today = new Date(); const today = new Date();
if (isTheSameDay(today, date)) return 'TODAY'; if (isTheSameDay(today, date)) return translate('global.date.label.today');
// calculate yesterday // calculate yesterday
const yesterday = new Date(); const yesterday = new Date();
yesterday.setDate(today.getDate() - 1); yesterday.setDate(today.getDate() - 1);
if (isTheSameDay(yesterday, date)) return 'YESTERDAY'; if (isTheSameDay(yesterday, date)) return translate('global.date.label.yesterday');
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
@@ -70,6 +72,7 @@ const initialQueue = {
} as IQueue; } as IQueue;
const Updates: React.FC = () => { const Updates: React.FC = () => {
const { t } = useTranslation();
const history = useHistory(); const history = useHistory();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
@@ -97,7 +100,7 @@ const Updates: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
setTitle('Updates'); setTitle(t('updates.title'));
setAction(null); setAction(null);
}, []); }, []);
@@ -136,7 +139,7 @@ const Updates: React.FC = () => {
return <LoadingPlaceholder />; return <LoadingPlaceholder />;
} }
if (fetched && updateEntries.length === 0) { if (fetched && updateEntries.length === 0) {
return <EmptyView message="You don't have any updates yet." />; return <EmptyView message={t('updates.error.label.no_updates_available')} />;
} }
const downloadForChapter = (chapter: IChapter) => { const downloadForChapter = (chapter: IChapter) => {

View File

@@ -7,12 +7,14 @@ import NavbarContext from 'components/context/NavbarContext';
import LoadingPlaceholder from 'components/util/LoadingPlaceholder'; import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
import { useQuery } from 'util/client'; import { useQuery } from 'util/client';
import { IAbout } from 'typings'; import { IAbout } from 'typings';
import { useTranslation } from 'react-i18next';
export default function About() { export default function About() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { useEffect(() => {
setTitle('About'); setTitle(t('settings.about.title'));
setAction(null); setAction(null);
}, []); }, []);
@@ -32,19 +34,22 @@ export default function About() {
return ( return (
<List> <List>
<ListItem> <ListItem>
<ListItemText primary="Server" secondary={`${about.name} ${about.buildType}`} /> <ListItemText
primary={t('settings.about.label.server')}
secondary={`${about.name} ${about.buildType}`}
/>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText primary="Server version" secondary={version()} /> <ListItemText primary={t('settings.about.label.server_version')} secondary={version()} />
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText primary="Build time" secondary={buildTime()} /> <ListItemText primary={t('settings.about.label.build_time')} secondary={buildTime()} />
</ListItem> </ListItem>
<ListItemLink to={about.github}> <ListItemLink to={about.github}>
<ListItemText primary="Github" secondary={about.github} /> <ListItemText primary={t('settings.about.label.github')} secondary={about.github} />
</ListItemLink> </ListItemLink>
<ListItemLink to={about.discord}> <ListItemLink to={about.discord}>
<ListItemText primary="Discord" secondary={about.discord} /> <ListItemText primary={t('settings.about.label.discord')} secondary={about.discord} />
</ListItemLink> </ListItemLink>
</List> </List>
); );

View File

@@ -14,11 +14,13 @@ import client from 'util/client';
import makeToast from 'components/util/Toast'; import makeToast from 'components/util/Toast';
import ListItemLink from 'components/util/ListItemLink'; import ListItemLink from 'components/util/ListItemLink';
import NavbarContext from 'components/context/NavbarContext'; import NavbarContext from 'components/context/NavbarContext';
import { useTranslation } from 'react-i18next';
export default function Backup() { export default function Backup() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { useEffect(() => {
setTitle('Backup'); setTitle(t('settings.backup.title'));
setAction(null); setAction(null);
}, []); }, []);
@@ -29,17 +31,17 @@ export default function Backup() {
const formData = new FormData(); const formData = new FormData();
formData.append('backup.proto.gz', file); formData.append('backup.proto.gz', file);
makeToast('Restoring backup....', 'info'); makeToast(t('settings.backup.label.restoring_backup'), 'info');
client client
.post('/api/v1/backup/import/file', formData, { .post('/api/v1/backup/import/file', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}) })
.then(() => makeToast('Backup restore finished!', 'success')) .then(() => makeToast(t('settings.backup.label.restored_backup'), 'success'))
.catch(() => makeToast('Backup restore failed!', 'error')); .catch(() => makeToast(t('settings.backup.label.backup_restore_failed'), 'error'));
} else if (file.name.toLowerCase().endsWith('json')) { } else if (file.name.toLowerCase().endsWith('json')) {
makeToast('legacy backups are not supported!', 'error'); makeToast(t('settings.backup.label.legacy_backup_unsupported'), 'error');
} else { } else {
makeToast('invalid file type!', 'error'); makeToast(t('global.error.label.invalid_file_type'), 'error');
} }
}; };
@@ -74,12 +76,15 @@ export default function Backup() {
<> <>
<List sx={{ padding: 0 }}> <List sx={{ padding: 0 }}>
<ListItemLink to={`${baseURL}/api/v1/backup/export/file`} directLink> <ListItemLink to={`${baseURL}/api/v1/backup/export/file`} directLink>
<ListItemText primary="Create Backup" secondary="Backup library as a Tachiyomi backup" /> <ListItemText
primary={t('settings.backup.label.create_backup')}
secondary={t('settings.backup.label.create_backup_info')}
/>
</ListItemLink> </ListItemLink>
<ListItem button onClick={() => document.getElementById('backup-file')?.click()}> <ListItem button onClick={() => document.getElementById('backup-file')?.click()}>
<ListItemText <ListItemText
primary="Restore Backup" primary={t('settings.backup.label.restore_backup')}
secondary="You can also drag and drop the backup file here to restore" secondary={t('settings.backup.label.restore_backup_info')}
/> />
</ListItem> </ListItem>
</List> </List>

View File

@@ -34,6 +34,7 @@ import FormControlLabel from '@mui/material/FormControlLabel';
import NavbarContext from 'components/context/NavbarContext'; import NavbarContext from 'components/context/NavbarContext';
import client, { useQuery } from 'util/client'; import client, { useQuery } from 'util/client';
import { ICategory } from 'typings'; import { ICategory } from 'typings';
import { useTranslation } from 'react-i18next';
const getItemStyle = ( const getItemStyle = (
isDragging: boolean, isDragging: boolean,
@@ -49,9 +50,11 @@ const getItemStyle = (
}); });
export default function Categories() { export default function Categories() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { useEffect(() => {
setTitle('Categories'); setTitle(t('category.title.categories'));
setAction(null); setAction(null);
}, []); }, []);
@@ -196,14 +199,16 @@ export default function Categories() {
</Fab> </Fab>
<Dialog open={dialogOpen} onClose={handleDialogCancel}> <Dialog open={dialogOpen} onClose={handleDialogCancel}>
<DialogTitle id="form-dialog-title"> <DialogTitle id="form-dialog-title">
{categoryToEdit === -1 ? 'New Catalog' : 'Edit Catalog'} {categoryToEdit === -1
? t('category.dialog.title.new_category')
: t('category.dialog.title.edit_category')}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
id="name" id="name"
label="Category Name" label={t('category.label.category_name')}
type="text" type="text"
fullWidth fullWidth
value={dialogName} value={dialogName}
@@ -217,15 +222,15 @@ export default function Categories() {
color="default" color="default"
/> />
} }
label="Default category when adding new manga to library" label={t('category.label.use_as_default_category')}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleDialogCancel} color="primary"> <Button onClick={handleDialogCancel} color="primary">
Cancel {t('global.button.cancel')}
</Button> </Button>
<Button onClick={handleDialogSubmit} color="primary"> <Button onClick={handleDialogSubmit} color="primary">
Submit {t('global.button.submit')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -21,11 +21,13 @@ import {
} from 'util/readerSettings'; } from 'util/readerSettings';
import ReaderSettingsOptions from 'components/reader/ReaderSettingsOptions'; import ReaderSettingsOptions from 'components/reader/ReaderSettingsOptions';
import { IReaderSettings } from 'typings'; import { IReaderSettings } from 'typings';
import { useTranslation } from 'react-i18next';
export default function DefaultReaderSettings() { export default function DefaultReaderSettings() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { useEffect(() => {
setTitle('Default Reader Settings'); setTitle(t('reader.settings.title.default_reader_settings'));
setAction(null); setAction(null);
}, []); }, []);
@@ -33,7 +35,7 @@ export default function DefaultReaderSettings() {
const setSettingValue = (key: keyof IReaderSettings, value: string | boolean) => { const setSettingValue = (key: keyof IReaderSettings, value: string | boolean) => {
requestUpdateServerMetadata(metadata ?? {}, [[key, value]]).catch(() => requestUpdateServerMetadata(metadata ?? {}, [[key, value]]).catch(() =>
makeToast('Failed to save the default reader settings to the server', 'warning'), makeToast(t('reader.settings.error.label.failed_to_save_settings'), 'warning'),
); );
}; };

View File

@@ -7,13 +7,15 @@ import makeToast from 'components/util/Toast';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import { SearchMetadataKeys } from 'typings'; import { SearchMetadataKeys } from 'typings';
import { useTranslation } from 'react-i18next';
export default function SearchSettings() { export default function SearchSettings() {
const { t } = useTranslation();
const { metadata, settings } = useSearchSettings(); const { metadata, settings } = useSearchSettings();
const setSettingValue = (key: SearchMetadataKeys, value: boolean) => { const setSettingValue = (key: SearchMetadataKeys, value: boolean) => {
requestUpdateServerMetadata(metadata ?? {}, [[key, value]]).catch(() => requestUpdateServerMetadata(metadata ?? {}, [[key, value]]).catch(() =>
makeToast('Failed to save the default search settings to the server', 'warning'), makeToast(t('search.error.label.failed_to_save_settings'), 'warning'),
); );
}; };
return ( return (
@@ -21,7 +23,7 @@ export default function SearchSettings() {
<ListItemIcon> <ListItemIcon>
<SearchIcon /> <SearchIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Ignore Filters when Searching" /> <ListItemText primary={t('search.label.ignore_filters')} />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Switch <Switch
edge="end" edge="end"

View File

@@ -7,6 +7,9 @@
import { OverridableComponent } from '@mui/material/OverridableComponent'; import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon/SvgIcon'; import { SvgIconTypeMap } from '@mui/material/SvgIcon/SvgIcon';
import { TFuncKey } from 'i18next';
export type TranslationKey = TFuncKey;
export interface IExtension { export interface IExtension {
name: string; name: string;
@@ -301,7 +304,7 @@ export interface SourcePreferences {
export interface NavbarItem { export interface NavbarItem {
path: string; path: string;
title: string; title: TranslationKey;
SelectedIconComponent: OverridableComponent<SvgIconTypeMap<{}, 'svg'>>; SelectedIconComponent: OverridableComponent<SvgIconTypeMap<{}, 'svg'>>;
IconComponent: OverridableComponent<SvgIconTypeMap<{}, 'svg'>>; IconComponent: OverridableComponent<SvgIconTypeMap<{}, 'svg'>>;
show: 'mobile' | 'desktop' | 'both'; show: 'mobile' | 'desktop' | 'both';

View File

@@ -5,6 +5,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { t } from 'i18next';
export const isWithinLastXMillis = (date: Date, timeMS: number) => { export const isWithinLastXMillis = (date: Date, timeMS: number) => {
const timeDifference = Date.now() - date.getTime(); const timeDifference = Date.now() - date.getTime();
@@ -54,11 +55,11 @@ export const getUploadDateString = (date: Date | number) => {
: ''; : '';
if (wasUploadedToday) { if (wasUploadedToday) {
return `Today at ${timeString}`; return t('global.date.label.today_at', { timeString });
} }
if (wasUploadedYesterday) { if (wasUploadedYesterday) {
return `Yesterday at ${timeString}`; return t('global.date.label.yesterday_at', { timeString });
} }
return uploadDate.toLocaleDateString(undefined, { return uploadDate.toLocaleDateString(undefined, {

View File

@@ -4,6 +4,7 @@
* This Source Code Form is subject to the terms of the Mozilla Public * This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { t } from 'i18next';
export const ISOLanguages = [ export const ISOLanguages = [
{ code: 'all', name: 'All', nativeName: 'All' }, { code: 'all', name: 'All', nativeName: 'All' },
@@ -80,7 +81,7 @@ export function langCodeToName(code: string): string {
const whereToCut = code.indexOf('-') !== -1 ? code.indexOf('-') : code.length; const whereToCut = code.indexOf('-') !== -1 ? code.indexOf('-') : code.length;
const proccessedCode = code.toLocaleLowerCase().substring(0, whereToCut); const proccessedCode = code.toLocaleLowerCase().substring(0, whereToCut);
let result = `language with code: ${code}`; let result = t('global.language.label.language_with_code', { code });
for (let i = 0; i < ISOLanguages.length; i++) { for (let i = 0; i < ISOLanguages.length; i++) {
if (ISOLanguages[i].code === proccessedCode || ISOLanguages[i].code === code.toLocaleLowerCase()) { if (ISOLanguages[i].code === proccessedCode || ISOLanguages[i].code === code.toLocaleLowerCase()) {