Rename folder "modules" to "features"

This commit is contained in:
schroda
2025-08-15 22:02:58 +02:00
parent 7e6ced1d09
commit 1b4bf22542
415 changed files with 1859 additions and 1852 deletions

View File

@@ -0,0 +1,46 @@
/*
* 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/.
*/
import { useTranslation } from 'react-i18next';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import { bindDialog, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { ThemeCreationDialog } from '@/features/theme/components/CreateThemeDialog.tsx';
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
export const CreateThemeButton = () => {
const { t } = useTranslation();
const popupState = usePopupState({ variant: 'popover', popupId: 'theme-creation-dialog' });
return (
<>
<Stack sx={{ alignItems: 'center', gap: 1, minWidth: '150px', maxWidth: '150px' }}>
<Button
sx={{
width: '100%',
height: '225px',
}}
variant="contained"
size="large"
{...bindTrigger(popupState)}
>
<AddCircleIcon fontSize="large" />
</Button>
<CustomTooltip title={t('settings.appearance.theme.create.title')} placement="top">
<TypographyMaxLines sx={{ maxWidth: '100%' }}>
{t('settings.appearance.theme.create.title')}
</TypographyMaxLines>
</CustomTooltip>
</Stack>
{popupState.isOpen && <ThemeCreationDialog bindDialogProps={bindDialog(popupState)} mode="create" />}
</>
);
};

View File

@@ -0,0 +1,248 @@
/*
* 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/.
*/
import { Trans, useTranslation } from 'react-i18next';
import { useState } from 'react';
import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import { bindDialog } from 'material-ui-popup-state/hooks';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField';
import Link from '@mui/material/Link';
import { jsonrepair } from 'jsonrepair';
import { getErrorMessage, jsonSaveParse } from '@/lib/HelperFunctions.ts';
import { AppTheme, isThemeNameUnique } from '@/features/theme/services/AppThemes.ts';
import {
createUpdateMetadataServerSettings,
useMetadataServerSettings,
} from '@/features/settings/services/ServerSettingsMetadata.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { EmptyView } from '@/features/core/components/feedback/EmptyView.tsx';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { TranslationKey } from '@/Base.types.ts';
import { MetadataThemeSettings } from '@/features/theme/AppTheme.types.ts';
const baseCustomTheme: AppTheme = {
id: '',
isCustom: true,
getName: () => '',
muiTheme: {
colorSchemes: {
light: {
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#9c27b0',
},
},
},
dark: {
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#9c27b0',
},
},
},
},
},
};
type DialogMode = 'create' | 'edit';
const dialogModeToTranslationKey: Record<
DialogMode,
{
title: TranslationKey;
button: TranslationKey;
action: TranslationKey;
success: TranslationKey;
failure: TranslationKey;
}
> = {
create: {
title: 'settings.appearance.theme.create.title',
button: 'global.button.ok',
action: 'settings.appearance.theme.create.action',
success: 'settings.appearance.theme.create.success',
failure: 'settings.appearance.theme.create.failure',
},
edit: {
title: 'settings.appearance.theme.edit.title',
button: 'global.button.save',
action: 'settings.appearance.theme.edit.action',
success: 'settings.appearance.theme.edit.success',
failure: 'settings.appearance.theme.edit.failure',
},
};
export const ThemeCreationDialog = ({
bindDialogProps,
mode,
appTheme = baseCustomTheme,
}: {
bindDialogProps: ReturnType<typeof bindDialog>;
mode: DialogMode;
appTheme?: AppTheme;
}) => {
const { t } = useTranslation();
const {
settings: { customThemes },
request: { loading, error, refetch },
} = useMetadataServerSettings();
const updateCustomThemes = createUpdateMetadataServerSettings<keyof MetadataThemeSettings>((e) => {
throw e;
});
const [theme, setTheme] = useState<AppTheme>(appTheme);
const [invalidName, setInvalidName] = useState(false);
const [invalidTheme, setInvalidTheme] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [didThemeChange, setDidThemeChange] = useState(mode === 'create');
return (
<Dialog {...bindDialogProps} fullWidth maxWidth="md">
<DialogTitle>{t(dialogModeToTranslationKey[mode].title)}</DialogTitle>
<DialogContent>
{loading && <LoadingPlaceholder />}
{error && (
<EmptyView
message={t('global.error.label.failed_to_load_data')}
messageExtra={getErrorMessage(error)}
retry={() => refetch().catch(defaultPromiseErrorHandler('ThemeCreationDialog::refetch'))}
/>
)}
{!error && (
<Stack sx={{ gap: 2, whiteSpace: 'pre-line' }}>
<Typography>
<Trans i18nKey="settings.appearance.theme.create.dialog.description">
<Link
href="https://mui.com/material-ui/customization/how-to-customize/"
target="_blank"
rel="noreferrer"
>
MUI documentation
</Link>
<Link
href="https://mui.com/material-ui/customization/palette/#color-schemes"
target="_blank"
rel="noreferrer"
>
MUI documentation
</Link>
<Link
href="https://zenoo.github.io/mui-theme-creator/"
target="_blank"
rel="noreferrer"
>
MUI Theme Creator
</Link>{' '}
, copy everything after &quot;themeOptions&quot; from &quot;{'{'}&quot; to &quot;{'}'}
&quot; and paste it into the &quot;theme&quot; text field.
</Trans>
</Typography>
<TextField
disabled={mode === 'edit'}
label={t('settings.appearance.theme.create.dialog.theme_name')}
value={theme.getName()}
error={invalidName}
helperText={invalidName && t('settings.appearance.theme.create.dialog.error.invalid_name')}
onChange={(e) => {
const name = e.target.value.trim();
const isValidLength = name.length <= 16;
const isUnique = isThemeNameUnique(name, customThemes);
setInvalidName(!isValidLength || !isUnique);
setTheme({
...theme,
id: e.target.value,
getName: () => e.target.value,
});
}}
/>
<TextField
label={t('settings.appearance.theme.title')}
multiline
error={invalidTheme}
helperText={invalidTheme && t('settings.appearance.theme.create.dialog.error.invalid_json')}
defaultValue={JSON.stringify(theme.muiTheme, null, 2)}
onChange={(e) => {
const invalidThemeStr = 'invalid';
const newMuiTheme = (() => {
try {
return jsonrepair(e.target.value);
} catch (_) {
return invalidThemeStr;
}
})();
const newMuiThemeParsed = jsonSaveParse(newMuiTheme);
const isInvalid = newMuiTheme === invalidThemeStr || !newMuiThemeParsed;
const isThemeDifferentFromOriginal =
JSON.stringify(newMuiThemeParsed) !== JSON.stringify(appTheme.muiTheme);
setDidThemeChange(mode === 'create' ? true : isThemeDifferentFromOriginal);
setInvalidTheme(isInvalid);
const didPaletteChange =
!isInvalid && JSON.stringify(newMuiThemeParsed) !== JSON.stringify(theme.muiTheme);
if (didPaletteChange) {
setTheme({ ...theme, muiTheme: newMuiThemeParsed });
}
}}
/>
</Stack>
)}
</DialogContent>
<DialogActions>
<Button autoFocus onClick={bindDialogProps.onClose} color="primary">
{t('global.button.cancel')}
</Button>
<Button
disabled={invalidTheme || invalidName || isCreating || !didThemeChange || !theme.id.length}
onClick={(e) => {
makeToast(t(dialogModeToTranslationKey[mode].action, { theme: theme.getName() }), 'info');
setIsCreating(true);
updateCustomThemes('customThemes', { ...customThemes, [theme.id]: theme })
.then(() => {
makeToast(
t(dialogModeToTranslationKey[mode].success, { theme: theme.getName() }),
'success',
);
bindDialogProps.onClose(e);
})
.catch((updateError) =>
makeToast(
t(dialogModeToTranslationKey[mode].failure, { theme: theme.getName() }),
'error',
getErrorMessage(updateError),
),
)
.finally(() => setIsCreating(false));
}}
color="primary"
>
{t(dialogModeToTranslationKey[mode].button)}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,96 @@
/*
* 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/.
*/
import { useTranslation } from 'react-i18next';
import Stack from '@mui/material/Stack';
import { useMemo } from 'react';
import { appThemes } from '@/features/theme/services/AppThemes.ts';
import {
createUpdateMetadataServerSettings,
useMetadataServerSettings,
} from '@/features/settings/services/ServerSettingsMetadata.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { EmptyView } from '@/features/core/components/feedback/EmptyView.tsx';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { CreateThemeButton } from '@/features/theme/components/CreateThemeButton.tsx';
import { ThemePreview } from '@/features/theme/components/ThemePreview.tsx';
import { MetadataThemeSettings } from '@/features/theme/AppTheme.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
export const ThemeList = () => {
const { t } = useTranslation();
const {
settings: { customThemes },
request: { loading, error, refetch },
} = useMetadataServerSettings();
const allThemes = useMemo(
() => [
...appThemes,
...Object.values(customThemes).map((customTheme) => ({
...customTheme,
getName: () => customTheme.id,
})),
],
[customThemes],
);
const updateCustomThemes = createUpdateMetadataServerSettings<keyof MetadataThemeSettings>((e) => {
throw e;
});
if (loading) {
return <LoadingPlaceholder />;
}
if (error) {
return (
<EmptyView
message={t('global.error.label.failed_to_load_data')}
messageExtra={getErrorMessage(error)}
retry={() => refetch().catch(defaultPromiseErrorHandler('ThemeList::refetch'))}
/>
);
}
return (
<Stack
sx={{ flexDirection: 'row', flexWrap: 'no-wrap', alignItems: 'start', overflowX: 'auto', gap: 1, mx: 2 }}
>
<CreateThemeButton />
{allThemes.map((theme) => (
<ThemePreview
key={theme.id}
appTheme={theme}
onDelete={() => {
makeToast(t('settings.appearance.theme.delete.action', { theme: theme.getName() }), 'info');
updateCustomThemes(
'customThemes',
Object.fromEntries(Object.entries(customThemes).filter(([id]) => id !== theme.id)),
)
.then(() =>
makeToast(
t('settings.appearance.theme.delete.success', { theme: theme.getName() }),
'success',
),
)
.catch((e) =>
makeToast(
t('settings.appearance.theme.delete.failure', { theme: theme.getName() }),
'error',
getErrorMessage(e),
),
);
}}
/>
))}
</Stack>
);
};

View File

@@ -0,0 +1,239 @@
/*
* 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/.
*/
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import { styled, ThemeProvider, useTheme } from '@mui/material/styles';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import DeleteIcon from '@mui/icons-material/Delete';
import IconButton from '@mui/material/IconButton';
import EditIcon from '@mui/icons-material/Edit';
import { bindDialog, usePopupState } from 'material-ui-popup-state/hooks';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { useAppThemeContext } from '@/features/theme/contexts/AppThemeContext.tsx';
import { AppTheme, hasMissingFonts, loadThemeFonts } from '@/features/theme/services/AppThemes.ts';
import { createTheme } from '@/features/theme/services/ThemeCreator.ts';
import { ThemeCreationDialog } from '@/features/theme/components/CreateThemeDialog.tsx';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
const ThemePreviewBadge = styled(Box)(() => ({
width: '15px',
height: '20px',
}));
export const ThemePreview = ({ appTheme, onDelete }: { appTheme: AppTheme; onDelete: () => void }) => {
const { getName } = appTheme;
const { t } = useTranslation();
const theme = useTheme();
const { themeMode, setAppTheme, appTheme: activeAppTheme, shouldUsePureBlackMode } = useAppThemeContext();
const popupState = usePopupState({ variant: 'popover', popupId: `theme-edit-dialog-${appTheme.id}` });
const isSelected = appTheme.id === activeAppTheme;
const muiTheme = useMemo(
() => createTheme(themeMode, appTheme, shouldUsePureBlackMode),
[appTheme, themeMode, shouldUsePureBlackMode],
);
return (
<>
<Stack
sx={{
justifyContent: 'center',
alignItems: 'center',
minWidth: '150px',
maxWidth: '150px',
gap: 1,
}}
>
<ThemeProvider theme={muiTheme}>
<Card
sx={{
width: '100%',
height: '225px',
position: 'relative',
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
border: '4px solid',
borderColor: isSelected ? muiTheme.palette.primary.light : muiTheme.palette.grey['100'],
zIndex: 1,
pointerEvents: 'none',
borderRadius: 1,
...theme.applyStyles('dark', {
borderColor: isSelected
? muiTheme.palette.primary.light
: muiTheme.palette.grey['900'],
}),
}}
/>
<CardActionArea
sx={{
height: '100%',
backgroundColor: 'background.default',
}}
onClick={() => {
const needToLoadFonts = hasMissingFonts(appTheme.muiTheme);
if (!needToLoadFonts) {
setAppTheme(appTheme.id);
return;
}
makeToast(t('settings.appearance.theme.select.fonts.loading'), 'info');
loadThemeFonts(appTheme.muiTheme)
.then(() => setAppTheme(appTheme.id))
.catch((e) =>
makeToast(
t('settings.appearance.theme.select.fonts.error'),
'error',
getErrorMessage(e),
),
);
}}
>
<Stack sx={{ height: '100%', m: 0 }}>
<Stack sx={{ height: '100%', gap: 2, p: 2 }}>
<Stack
sx={{
maxHeight: '20px',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<Box
sx={{
width: '65%',
height: '20px',
backgroundColor: 'primary.dark',
borderRadius: 1,
}}
/>
<Stack
sx={{
alignItems: 'center',
gap: 1,
mt: -0.25,
}}
>
{isSelected && (
<CheckCircleIcon
sx={{
visibility: isSelected ? 'visible' : 'hidden',
color: 'primary.light',
}}
/>
)}
{!isSelected && appTheme.isCustom && (
<IconButton
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
component="div"
size="small"
sx={{ mt: -0.5 }}
>
<CustomTooltip title={t('global.button.delete')}>
<DeleteIcon />
</CustomTooltip>
</IconButton>
)}
{appTheme.isCustom && (
<IconButton
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
popupState.open();
}}
component="div"
size="small"
>
<CustomTooltip title={t('global.button.edit')}>
<EditIcon />
</CustomTooltip>
</IconButton>
)}
</Stack>
</Stack>
<Box
sx={{
width: '55%',
height: '65%',
backgroundColor: 'background.paper',
p: 1,
borderRadius: 1,
}}
>
<Stack
sx={{
width: 'max-content',
flexDirection: 'row',
overflow: 'hidden',
borderRadius: 1,
}}
>
<ThemePreviewBadge sx={{ backgroundColor: 'primary.main' }} />
<ThemePreviewBadge sx={{ backgroundColor: 'secondary.main' }} />
</Stack>
</Box>
</Stack>
<Stack
sx={{
height: '25%',
backgroundColor: 'background.paper',
alignItems: 'center',
flexDirection: 'row',
gap: 2,
p: 2,
}}
>
<Box
sx={{
width: '20px',
height: '20px',
borderRadius: '50%',
backgroundColor: 'primary.main',
}}
/>
<Box
sx={{
flexGrow: 1,
height: '20px',
borderRadius: 1,
backgroundColor: 'primary.light',
}}
/>
</Stack>
</Stack>
</CardActionArea>
</Card>
</ThemeProvider>
<CustomTooltip title={getName()} placement="top">
<TypographyMaxLines sx={{ maxWidth: '100%' }}>{getName()}</TypographyMaxLines>
</CustomTooltip>
</Stack>
<ThemeCreationDialog bindDialogProps={bindDialog(popupState)} mode="edit" appTheme={appTheme} />
</>
);
};