Use custom themes
This commit is contained in:
@@ -49,6 +49,7 @@
|
|||||||
"i18next": "23.14.0",
|
"i18next": "23.14.0",
|
||||||
"i18next-browser-languagedetector": "8.0.0",
|
"i18next-browser-languagedetector": "8.0.0",
|
||||||
"i18next-http-backend": "2.6.1",
|
"i18next-http-backend": "2.6.1",
|
||||||
|
"jsonrepair": "3.8.0",
|
||||||
"material-ui-popup-state": "5.3.1",
|
"material-ui-popup-state": "5.3.1",
|
||||||
"mui-nested-menu": "3.4.0",
|
"mui-nested-menu": "3.4.0",
|
||||||
"p-limit": "6.1.0",
|
"p-limit": "6.1.0",
|
||||||
|
|||||||
@@ -328,6 +328,7 @@
|
|||||||
"restore": "Restore",
|
"restore": "Restore",
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
|
"save": "Save",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"select_all": "Select all",
|
"select_all": "Select all",
|
||||||
"set": "Set",
|
"set": "Set",
|
||||||
@@ -818,19 +819,49 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
|
"theme": {
|
||||||
|
"create": {
|
||||||
|
"action": "Creating theme…",
|
||||||
|
"dialog": {
|
||||||
|
"error": {
|
||||||
|
"invalid_json": "Invalid json",
|
||||||
|
"invalid_name": "The theme name must be unique and has a limit of 16 characters"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"creating_theme": "Create a custom theme with the <2>MUI Theme Creator</2>, copy everything after \"themeOptions\" from \"{\" to \"}\" and paste it into the \"theme\" text field.",
|
||||||
|
"theme_mode_lock": "In case the \"mode\" is defined in the \"palette\" object, the theme will be locked into this mode and will not change regardless of the apps device theme"
|
||||||
|
},
|
||||||
|
"theme_name": "Name"
|
||||||
|
},
|
||||||
|
"failure": "Could not create theme",
|
||||||
|
"success": "Created theme",
|
||||||
|
"title": "Create theme"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"action": "Deleting theme \"{{theme}}\"…",
|
||||||
|
"failure": "Could not delete theme \"{{theme}}\"",
|
||||||
|
"success": "Deleted theme \"{{theme}}\""
|
||||||
|
},
|
||||||
"device_theme": "Device theme",
|
"device_theme": "Device theme",
|
||||||
|
"edit": {
|
||||||
|
"action": "Saving theme changes…",
|
||||||
|
"failure": "Could not save theme changes",
|
||||||
|
"success": "Saved theme changes",
|
||||||
|
"title": "Edit theme"
|
||||||
|
},
|
||||||
"pure_black_mode": "Pure black dark mode",
|
"pure_black_mode": "Pure black dark mode",
|
||||||
"theme": "Theme",
|
|
||||||
"themes": {
|
"themes": {
|
||||||
"crimson": "Crimson",
|
"crimson": "Crimson",
|
||||||
"dune": "Dune",
|
"dune": "Dune",
|
||||||
"forest_dew": "Forest dew",
|
"forest_dew": "Forest dew",
|
||||||
"lavendar": "Lavendar",
|
"lavender": "Lavender",
|
||||||
"minty_miracles": "Minty Miracles",
|
"minty_miracles": "Minty Miracles",
|
||||||
"mountain_sunset": "Mountain sunset",
|
"mountain_sunset": "Mountain sunset",
|
||||||
"orange_juice": "Orange juice",
|
"orange_juice": "Orange juice",
|
||||||
"rosegold": "Rosegold"
|
"rosegold": "Rosegold"
|
||||||
},
|
},
|
||||||
|
"title": "Theme"
|
||||||
|
},
|
||||||
"title": "Appearance"
|
"title": "Appearance"
|
||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const { DeviceSetting } = loadable(() => import('@/components/settings/DeviceSet
|
|||||||
const { TrackingSettings } = loadable(() => import('@/screens/settings/TrackingSettings.tsx'), lazyLoadFallback);
|
const { TrackingSettings } = loadable(() => import('@/screens/settings/TrackingSettings.tsx'), lazyLoadFallback);
|
||||||
const { TrackerOAuthLogin } = loadable(() => import('@/screens/TrackerOAuthLogin.tsx'), lazyLoadFallback);
|
const { TrackerOAuthLogin } = loadable(() => import('@/screens/TrackerOAuthLogin.tsx'), lazyLoadFallback);
|
||||||
const { LibraryDuplicates } = loadable(() => import('@/screens/settings/LibraryDuplicates.tsx'), lazyLoadFallback);
|
const { LibraryDuplicates } = loadable(() => import('@/screens/settings/LibraryDuplicates.tsx'), lazyLoadFallback);
|
||||||
const { Appearance } = loadable(() => import('@/screens/settings/Appearance.tsx'), lazyLoadFallback);
|
const { Appearance } = loadable(() => import('@/screens/settings/appearance/Appearance.tsx'), lazyLoadFallback);
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
// Adds messages only in a dev environment
|
// Adds messages only in a dev environment
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import { NavBarContextProvider } from '@/components/navbar/NavBarContextProvider
|
|||||||
import { LibraryOptionsContextProvider } from '@/components/library/LibraryOptionsProvider';
|
import { LibraryOptionsContextProvider } from '@/components/library/LibraryOptionsProvider';
|
||||||
import { ActiveDevice, DEFAULT_DEVICE, setActiveDevice } from '@/util/device.ts';
|
import { ActiveDevice, DEFAULT_DEVICE, setActiveDevice } from '@/util/device.ts';
|
||||||
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
|
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
|
||||||
import { AppThemes } from '@/lib/ui/AppThemes.ts';
|
import { AppThemes, getTheme } from '@/lib/ui/AppThemes.ts';
|
||||||
|
import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -50,6 +51,10 @@ export const AppContext: React.FC<Props> = ({ children }) => {
|
|||||||
directionRef.current = currentDirection;
|
directionRef.current = currentDirection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings: { customThemes },
|
||||||
|
} = useMetadataServerSettings();
|
||||||
|
|
||||||
const [systemThemeMode, setSystemThemeMode] = useState<ThemeMode>(MediaQuery.getSystemThemeMode());
|
const [systemThemeMode, setSystemThemeMode] = useState<ThemeMode>(MediaQuery.getSystemThemeMode());
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const unsubscribe = MediaQuery.listenToSystemThemeChange(setSystemThemeMode);
|
const unsubscribe = MediaQuery.listenToSystemThemeChange(setSystemThemeMode);
|
||||||
@@ -80,8 +85,8 @@ export const AppContext: React.FC<Props> = ({ children }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const theme = useMemo(
|
const theme = useMemo(
|
||||||
() => createAndSetTheme(themeMode, appTheme, pureBlackMode, currentDirection),
|
() => createAndSetTheme(themeMode, getTheme(appTheme, customThemes), pureBlackMode, currentDirection),
|
||||||
[themeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme],
|
[themeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme, customThemes],
|
||||||
);
|
);
|
||||||
|
|
||||||
setActiveDevice(activeDevice);
|
setActiveDevice(activeDevice);
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [
|
|||||||
|
|
||||||
// sources
|
// sources
|
||||||
'savedSearches',
|
'savedSearches',
|
||||||
|
|
||||||
|
// themes
|
||||||
|
'customThemes',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Metadata,
|
Metadata,
|
||||||
MetadataServerSettingKeys,
|
MetadataServerSettingKeys,
|
||||||
MetadataServerSettings,
|
MetadataServerSettings,
|
||||||
|
MetadataThemeSettings,
|
||||||
} from '@/typings.ts';
|
} from '@/typings.ts';
|
||||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||||
import { convertFromGqlMeta, getMetadataFrom, requestUpdateServerMetadata } from '@/lib/metadata/metadata.ts';
|
import { convertFromGqlMeta, getMetadataFrom, requestUpdateServerMetadata } from '@/lib/metadata/metadata.ts';
|
||||||
@@ -50,6 +51,9 @@ export const getDefaultSettings = (): MetadataServerSettings => ({
|
|||||||
// updates
|
// updates
|
||||||
webUIInformAvailableUpdate: true,
|
webUIInformAvailableUpdate: true,
|
||||||
serverInformAvailableUpdate: true,
|
serverInformAvailableUpdate: true,
|
||||||
|
|
||||||
|
// themes
|
||||||
|
customThemes: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const convertSettingsToMetadata = (
|
export const convertSettingsToMetadata = (
|
||||||
@@ -57,6 +61,7 @@ export const convertSettingsToMetadata = (
|
|||||||
): Metadata<string, AllowedMetadataValueTypes> => ({
|
): Metadata<string, AllowedMetadataValueTypes> => ({
|
||||||
...settings,
|
...settings,
|
||||||
devices: JSON.stringify(settings.devices),
|
devices: JSON.stringify(settings.devices),
|
||||||
|
customThemes: JSON.stringify(settings.customThemes),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const convertMetadataToSettings = (
|
export const convertMetadataToSettings = (
|
||||||
@@ -66,6 +71,9 @@ export const convertMetadataToSettings = (
|
|||||||
...getDefaultSettings(),
|
...getDefaultSettings(),
|
||||||
...(metadata as unknown as MetadataServerSettings),
|
...(metadata as unknown as MetadataServerSettings),
|
||||||
devices: jsonSaveParse<string[]>((metadata.devices as string) ?? '') ?? getDefaultSettings().devices,
|
devices: jsonSaveParse<string[]>((metadata.devices as string) ?? '') ?? getDefaultSettings().devices,
|
||||||
|
customThemes:
|
||||||
|
jsonSaveParse<MetadataThemeSettings['customThemes']>((metadata.customThemes as string) ?? '') ??
|
||||||
|
getDefaultSettings().customThemes,
|
||||||
}) satisfies MetadataServerSettings;
|
}) satisfies MetadataServerSettings;
|
||||||
|
|
||||||
const getMetadataServerSettingsWithDefaultFallback = (
|
const getMetadataServerSettingsWithDefaultFallback = (
|
||||||
|
|||||||
@@ -6,14 +6,17 @@
|
|||||||
* 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 { PaletteOptions } from '@mui/material/styles';
|
import { ThemeOptions } from '@mui/material/styles';
|
||||||
import { t as translate } from 'i18next';
|
import { t as translate } from 'i18next';
|
||||||
|
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
|
||||||
|
|
||||||
type Theme = { getName: () => string; palette: PaletteOptions };
|
type Theme = { isCustom: boolean; getName: () => string; muiTheme: ThemeOptions };
|
||||||
|
|
||||||
const themes = {
|
const themes = {
|
||||||
default: {
|
default: {
|
||||||
|
isCustom: false,
|
||||||
getName: () => translate('global.label.default'),
|
getName: () => translate('global.label.default'),
|
||||||
|
muiTheme: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#5b74ef',
|
main: '#5b74ef',
|
||||||
@@ -23,8 +26,11 @@ const themes = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
lavender: {
|
lavender: {
|
||||||
getName: () => translate('settings.appearance.themes.lavendar'),
|
isCustom: false,
|
||||||
|
getName: () => translate('settings.appearance.theme.themes.lavender'),
|
||||||
|
muiTheme: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#5c58dc',
|
main: '#5c58dc',
|
||||||
@@ -34,8 +40,11 @@ const themes = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
dune: {
|
dune: {
|
||||||
getName: () => translate('settings.appearance.themes.dune'),
|
isCustom: false,
|
||||||
|
getName: () => translate('settings.appearance.theme.themes.dune'),
|
||||||
|
muiTheme: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#897869',
|
main: '#897869',
|
||||||
@@ -45,8 +54,11 @@ const themes = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
rosegold: {
|
rosegold: {
|
||||||
getName: () => translate('settings.appearance.themes.rosegold'),
|
isCustom: false,
|
||||||
|
getName: () => translate('settings.appearance.theme.themes.rosegold'),
|
||||||
|
muiTheme: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#f1a886',
|
main: '#f1a886',
|
||||||
@@ -56,8 +68,11 @@ const themes = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
forestDew: {
|
},
|
||||||
getName: () => translate('settings.appearance.themes.forest_dew'),
|
'forest dew': {
|
||||||
|
isCustom: false,
|
||||||
|
getName: () => translate('settings.appearance.theme.themes.forest_dew'),
|
||||||
|
muiTheme: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#53a584',
|
main: '#53a584',
|
||||||
@@ -67,8 +82,11 @@ const themes = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mountainSunset: {
|
},
|
||||||
getName: () => translate('settings.appearance.themes.mountain_sunset'),
|
'montain sunset': {
|
||||||
|
isCustom: false,
|
||||||
|
getName: () => translate('settings.appearance.theme.themes.mountain_sunset'),
|
||||||
|
muiTheme: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#c55a77',
|
main: '#c55a77',
|
||||||
@@ -78,8 +96,11 @@ const themes = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
crimson: {
|
crimson: {
|
||||||
getName: () => translate('settings.appearance.themes.crimson'),
|
isCustom: false,
|
||||||
|
getName: () => translate('settings.appearance.theme.themes.crimson'),
|
||||||
|
muiTheme: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#ff0033',
|
main: '#ff0033',
|
||||||
@@ -89,8 +110,11 @@ const themes = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mintyMiracles: {
|
},
|
||||||
getName: () => translate('settings.appearance.themes.minty_miracles'),
|
'minty miracles': {
|
||||||
|
isCustom: false,
|
||||||
|
getName: () => translate('settings.appearance.theme.themes.minty_miracles'),
|
||||||
|
muiTheme: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#9defc3',
|
main: '#9defc3',
|
||||||
@@ -100,8 +124,11 @@ const themes = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orangeJuice: {
|
},
|
||||||
getName: () => translate('settings.appearance.themes.orange_juice'),
|
'orange juice': {
|
||||||
|
isCustom: false,
|
||||||
|
getName: () => translate('settings.appearance.theme.themes.orange_juice'),
|
||||||
|
muiTheme: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#ffb546',
|
main: '#ffb546',
|
||||||
@@ -111,15 +138,33 @@ const themes = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
} as const satisfies Record<string, Theme>;
|
} as const satisfies Record<string, Theme>;
|
||||||
|
|
||||||
export type AppThemes = keyof typeof themes;
|
export type AppThemes = keyof typeof themes | string;
|
||||||
|
|
||||||
export type AppTheme = Theme & { id: AppThemes };
|
export type AppTheme = Theme & { id: AppThemes; isCustom: boolean };
|
||||||
|
|
||||||
export const appThemes = (Object.entries(themes) as [AppThemes, Theme][]).map(([id, theme]) => ({
|
export const appThemes = (Object.entries(themes) as [AppThemes, Theme][]).map(([id, theme]) => ({
|
||||||
id,
|
id,
|
||||||
...theme,
|
...theme,
|
||||||
})) satisfies AppTheme[];
|
})) satisfies AppTheme[];
|
||||||
|
|
||||||
export const getTheme = (id: AppThemes): AppTheme => ({ id, ...themes[id] });
|
export const getTheme = (id: AppThemes, customThemes: Record<string, AppTheme> = {}): AppTheme => {
|
||||||
|
try {
|
||||||
|
const allThemes = { ...themes, ...customThemes };
|
||||||
|
const theme = (allThemes[id as keyof typeof themes] as AppTheme) ?? themes.default;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// @ts-ignore - custom themes do not have a "getName" function
|
||||||
|
getName: () => id,
|
||||||
|
...theme,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
defaultPromiseErrorHandler('getTheme')(e);
|
||||||
|
}
|
||||||
|
return { id, ...themes[id as keyof typeof themes] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isThemeNameUnique = (id: string, customThemes: Record<string, AppTheme>): boolean =>
|
||||||
|
Object.keys({ ...themes, ...customThemes }).every((themeId) => themeId.toLowerCase() !== id.toLowerCase());
|
||||||
|
|||||||
@@ -1,262 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useContext, useEffect, useMemo } from 'react';
|
|
||||||
import List from '@mui/material/List';
|
|
||||||
import ListItem from '@mui/material/ListItem';
|
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
|
||||||
import ListSubheader from '@mui/material/ListSubheader';
|
|
||||||
import Switch from '@mui/material/Switch';
|
|
||||||
import Link from '@mui/material/Link';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import Typography from '@mui/material/Typography';
|
|
||||||
import Stack from '@mui/material/Stack';
|
|
||||||
import { styled, ThemeProvider } from '@mui/material/styles';
|
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
||||||
import Card from '@mui/material/Card';
|
|
||||||
import CardActionArea from '@mui/material/CardActionArea';
|
|
||||||
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
|
|
||||||
import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.tsx';
|
|
||||||
import { Select } from '@/components/atoms/Select.tsx';
|
|
||||||
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
|
|
||||||
import { NumberSetting } from '@/components/settings/NumberSetting.tsx';
|
|
||||||
import { useLocalStorage } from '@/util/useStorage.tsx';
|
|
||||||
import { I18nResourceCode, i18nResources } from '@/i18n';
|
|
||||||
import { langCodeToName } from '@/util/language.tsx';
|
|
||||||
import { AppTheme, appThemes } from '@/lib/ui/AppThemes.ts';
|
|
||||||
import { createTheme } from '@/theme.ts';
|
|
||||||
|
|
||||||
const ThemePreviewBadge = styled(Box)(({ theme }) => ({
|
|
||||||
width: '15px',
|
|
||||||
height: '20px',
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ThemePreview = ({ theme }: { theme: AppTheme }) => {
|
|
||||||
const { getName } = theme;
|
|
||||||
|
|
||||||
const { themeMode, setAppTheme, appTheme, pureBlackMode } = useContext(ThemeModeContext);
|
|
||||||
|
|
||||||
const isSelected = theme.id === appTheme;
|
|
||||||
|
|
||||||
const muiTheme = useMemo(
|
|
||||||
() => createTheme(themeMode, theme.id, pureBlackMode),
|
|
||||||
[theme.id, themeMode, pureBlackMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
padding: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minWidth: '150px',
|
|
||||||
maxWidth: '150px',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeProvider theme={muiTheme}>
|
|
||||||
<Card
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
height: '225px',
|
|
||||||
outline: '4px solid',
|
|
||||||
outlineColor: isSelected ? muiTheme.palette.primary.light : muiTheme.palette.background.paper,
|
|
||||||
borderRadius: 1,
|
|
||||||
p: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardActionArea
|
|
||||||
sx={{ height: '100%', backgroundColor: 'background.default' }}
|
|
||||||
onClick={() => setAppTheme(theme.id)}
|
|
||||||
>
|
|
||||||
<Stack sx={{ height: '100%' }}>
|
|
||||||
<Stack sx={{ height: '100%', gap: 2, p: 1 }}>
|
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
maxHeight: '20px',
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: '65%',
|
|
||||||
height: '20px',
|
|
||||||
backgroundColor: 'primary.dark',
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isSelected && (
|
|
||||||
<CheckCircleIcon
|
|
||||||
sx={{
|
|
||||||
visibility: isSelected ? 'visible' : 'hidden',
|
|
||||||
color: 'primary.light',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
width: '55%',
|
|
||||||
height: '65%',
|
|
||||||
backgroundColor: 'background.paper',
|
|
||||||
borderRadius: 1,
|
|
||||||
p: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemePreviewBadge sx={{ backgroundColor: 'primary.main' }} />
|
|
||||||
<ThemePreviewBadge sx={{ backgroundColor: 'secondary.main' }} />
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
height: '80px',
|
|
||||||
backgroundColor: muiTheme.palette.background.paper,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: 'primary.main',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flexGrow: 0.75,
|
|
||||||
height: '20px',
|
|
||||||
borderRadius: 1,
|
|
||||||
backgroundColor: 'primary.light',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</CardActionArea>
|
|
||||||
</Card>
|
|
||||||
</ThemeProvider>
|
|
||||||
<Typography>{getName()}</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemePicker = () => (
|
|
||||||
<Stack sx={{ flexDirection: 'row', flexWrap: 'no-wrap', overflowX: 'auto', gap: 3, mx: 1 }}>
|
|
||||||
{appThemes.map((theme) => (
|
|
||||||
<ThemePreview key={theme.id} theme={theme} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Appearance = () => {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const { themeMode, setThemeMode, pureBlackMode, setPureBlackMode } = useContext(ThemeModeContext);
|
|
||||||
const isDarkMode = MediaQuery.getThemeMode() === ThemeMode.DARK;
|
|
||||||
|
|
||||||
const { setTitle, setAction } = useContext(NavBarContext);
|
|
||||||
useEffect(() => {
|
|
||||||
setTitle(t('settings.appearance.title'));
|
|
||||||
setAction(null);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
setTitle('');
|
|
||||||
setAction(null);
|
|
||||||
};
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
const DEFAULT_ITEM_WIDTH = 300;
|
|
||||||
const [itemWidth, setItemWidth] = useLocalStorage<number>('ItemWidth', DEFAULT_ITEM_WIDTH);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<List
|
|
||||||
subheader={
|
|
||||||
<ListSubheader component="div" id="appearance-theme">
|
|
||||||
{t('settings.appearance.theme')}
|
|
||||||
</ListSubheader>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText primary={t('settings.appearance.device_theme')} />
|
|
||||||
<Select<ThemeMode> value={themeMode} onChange={(e) => setThemeMode(e.target.value as ThemeMode)}>
|
|
||||||
<MenuItem key={ThemeMode.SYSTEM} value={ThemeMode.SYSTEM}>
|
|
||||||
System
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key={ThemeMode.DARK} value={ThemeMode.DARK}>
|
|
||||||
Dark
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key={ThemeMode.LIGHT} value={ThemeMode.LIGHT}>
|
|
||||||
Light
|
|
||||||
</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</ListItem>
|
|
||||||
<ThemePicker />
|
|
||||||
{isDarkMode && (
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText primary={t('settings.appearance.pure_black_mode')} />
|
|
||||||
<Switch checked={pureBlackMode} onChange={(_, enabled) => setPureBlackMode(enabled)} />
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
<List
|
|
||||||
subheader={
|
|
||||||
<ListSubheader component="div" id="appearance-theme">
|
|
||||||
{t('global.label.display')}
|
|
||||||
</ListSubheader>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText
|
|
||||||
primary={t('global.language.label.language')}
|
|
||||||
secondary={
|
|
||||||
<>
|
|
||||||
<span>{t('settings.label.language_description')} </span>
|
|
||||||
<Link
|
|
||||||
href="https://hosted.weblate.org/projects/suwayomi/suwayomi-webui"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t('global.language.title.weblate')}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={i18nResources.includes(i18n.language as I18nResourceCode) ? i18n.language : 'en'}
|
|
||||||
onChange={({ target: { value: language } }) => i18n.changeLanguage(language)}
|
|
||||||
>
|
|
||||||
{i18nResources.map((language) => (
|
|
||||||
<MenuItem key={language} value={language}>
|
|
||||||
{langCodeToName(language)}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</ListItem>
|
|
||||||
<NumberSetting
|
|
||||||
settingTitle={t('settings.label.manga_item_width')}
|
|
||||||
settingValue={`px: ${itemWidth}`}
|
|
||||||
value={itemWidth}
|
|
||||||
defaultValue={DEFAULT_ITEM_WIDTH}
|
|
||||||
minValue={100}
|
|
||||||
maxValue={1000}
|
|
||||||
stepSize={10}
|
|
||||||
valueUnit="px"
|
|
||||||
showSlider
|
|
||||||
handleUpdate={setItemWidth}
|
|
||||||
/>
|
|
||||||
</List>
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
127
src/screens/settings/appearance/Appearance.tsx
Normal file
127
src/screens/settings/appearance/Appearance.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
* 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 { useContext, useEffect } from 'react';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
|
||||||
|
import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.tsx';
|
||||||
|
import { Select } from '@/components/atoms/Select.tsx';
|
||||||
|
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
|
||||||
|
import { NumberSetting } from '@/components/settings/NumberSetting.tsx';
|
||||||
|
import { useLocalStorage } from '@/util/useStorage.tsx';
|
||||||
|
import { I18nResourceCode, i18nResources } from '@/i18n';
|
||||||
|
import { langCodeToName } from '@/util/language.tsx';
|
||||||
|
import { getTheme } from '@/lib/ui/AppThemes.ts';
|
||||||
|
import { ThemeList } from '@/screens/settings/appearance/theme/ThemeList.tsx';
|
||||||
|
|
||||||
|
export const Appearance = () => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { themeMode, setThemeMode, pureBlackMode, setPureBlackMode, appTheme } = useContext(ThemeModeContext);
|
||||||
|
const isDarkMode =
|
||||||
|
getTheme(appTheme).muiTheme.palette?.mode === 'dark' || MediaQuery.getThemeMode() === ThemeMode.DARK;
|
||||||
|
|
||||||
|
const { setTitle, setAction } = useContext(NavBarContext);
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(t('settings.appearance.title'));
|
||||||
|
setAction(null);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setTitle('');
|
||||||
|
setAction(null);
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const DEFAULT_ITEM_WIDTH = 300;
|
||||||
|
const [itemWidth, setItemWidth] = useLocalStorage<number>('ItemWidth', DEFAULT_ITEM_WIDTH);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component="div" id="appearance-theme">
|
||||||
|
{t('settings.appearance.theme.title')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary={t('settings.appearance.theme.device_theme')} />
|
||||||
|
<Select<ThemeMode> value={themeMode} onChange={(e) => setThemeMode(e.target.value as ThemeMode)}>
|
||||||
|
<MenuItem key={ThemeMode.SYSTEM} value={ThemeMode.SYSTEM}>
|
||||||
|
System
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key={ThemeMode.DARK} value={ThemeMode.DARK}>
|
||||||
|
Dark
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key={ThemeMode.LIGHT} value={ThemeMode.LIGHT}>
|
||||||
|
Light
|
||||||
|
</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</ListItem>
|
||||||
|
<ThemeList />
|
||||||
|
{isDarkMode && (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary={t('settings.appearance.theme.pure_black_mode')} />
|
||||||
|
<Switch checked={pureBlackMode} onChange={(_, enabled) => setPureBlackMode(enabled)} />
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
<List
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component="div" id="appearance-theme">
|
||||||
|
{t('global.label.display')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary={t('global.language.label.language')}
|
||||||
|
secondary={
|
||||||
|
<>
|
||||||
|
<span>{t('settings.label.language_description')} </span>
|
||||||
|
<Link
|
||||||
|
href="https://hosted.weblate.org/projects/suwayomi/suwayomi-webui"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('global.language.title.weblate')}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={i18nResources.includes(i18n.language as I18nResourceCode) ? i18n.language : 'en'}
|
||||||
|
onChange={({ target: { value: language } }) => i18n.changeLanguage(language)}
|
||||||
|
>
|
||||||
|
{i18nResources.map((language) => (
|
||||||
|
<MenuItem key={language} value={language}>
|
||||||
|
{langCodeToName(language)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</ListItem>
|
||||||
|
<NumberSetting
|
||||||
|
settingTitle={t('settings.label.manga_item_width')}
|
||||||
|
settingValue={`px: ${itemWidth}`}
|
||||||
|
value={itemWidth}
|
||||||
|
defaultValue={DEFAULT_ITEM_WIDTH}
|
||||||
|
minValue={100}
|
||||||
|
maxValue={1000}
|
||||||
|
stepSize={10}
|
||||||
|
valueUnit="px"
|
||||||
|
showSlider
|
||||||
|
handleUpdate={setItemWidth}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
src/screens/settings/appearance/theme/CreateThemeButton.tsx
Normal file
38
src/screens/settings/appearance/theme/CreateThemeButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* 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 Typography from '@mui/material/Typography';
|
||||||
|
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 { ThemeCreationDialog } from '@/screens/settings/appearance/theme/CreateThemeDialog.tsx';
|
||||||
|
|
||||||
|
export const CreateThemeButton = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const popupState = usePopupState({ variant: 'popover', popupId: 'theme-creation-dialog' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack sx={{ alignItems: 'center', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
sx={{ minWidth: '150px', height: '225px', m: 1, mb: 0 }}
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
{...bindTrigger(popupState)}
|
||||||
|
>
|
||||||
|
<AddCircleIcon />
|
||||||
|
</Button>
|
||||||
|
<Typography>{t('settings.appearance.theme.create.title')}</Typography>
|
||||||
|
</Stack>
|
||||||
|
{popupState.isOpen && <ThemeCreationDialog bindDialogProps={bindDialog(popupState)} mode="create" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
220
src/screens/settings/appearance/theme/CreateThemeDialog.tsx
Normal file
220
src/screens/settings/appearance/theme/CreateThemeDialog.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/*
|
||||||
|
* 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 { jsonSaveParse } from '@/util/HelperFunctions.ts';
|
||||||
|
import { AppTheme, isThemeNameUnique } from '@/lib/ui/AppThemes.ts';
|
||||||
|
import {
|
||||||
|
createUpdateMetadataServerSettings,
|
||||||
|
useMetadataServerSettings,
|
||||||
|
} from '@/lib/metadata/metadataServerSettings.ts';
|
||||||
|
import { MetadataThemeSettings, TranslationKey } from '@/typings.ts';
|
||||||
|
import { makeToast } from '@/components/util/Toast.tsx';
|
||||||
|
import { EmptyView } from '@/components/util/EmptyView.tsx';
|
||||||
|
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx';
|
||||||
|
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
|
||||||
|
|
||||||
|
const baseCustomTheme: AppTheme = {
|
||||||
|
id: '',
|
||||||
|
isCustom: true,
|
||||||
|
getName: () => '',
|
||||||
|
muiTheme: {
|
||||||
|
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={error.message}
|
||||||
|
retry={() => refetch().catch(defaultPromiseErrorHandler('ThemeCreationDialog::refetch'))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!error && (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
<Typography>
|
||||||
|
<Trans i18nKey="settings.appearance.theme.create.dialog.info.creating_theme">
|
||||||
|
Create a custom theme with the{' '}
|
||||||
|
<Link
|
||||||
|
href="https://zenoo.github.io/mui-theme-creator/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
MUI Theme Creator
|
||||||
|
</Link>{' '}
|
||||||
|
, copy everything after "themeOptions" from "{'{'}" to "{'}'}
|
||||||
|
" and paste it into the "theme" text field.
|
||||||
|
</Trans>
|
||||||
|
</Typography>
|
||||||
|
<Typography>{t('settings.appearance.theme.create.dialog.info.theme_mode_lock')}</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}
|
||||||
|
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(() =>
|
||||||
|
makeToast(
|
||||||
|
t(dialogModeToTranslationKey[mode].failure, { theme: theme.getName() }),
|
||||||
|
'error',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.finally(() => setIsCreating(false));
|
||||||
|
}}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{t(dialogModeToTranslationKey[mode].button)}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
92
src/screens/settings/appearance/theme/ThemeList.tsx
Normal file
92
src/screens/settings/appearance/theme/ThemeList.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* 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 '@/lib/ui/AppThemes.ts';
|
||||||
|
import {
|
||||||
|
createUpdateMetadataServerSettings,
|
||||||
|
useMetadataServerSettings,
|
||||||
|
} from '@/lib/metadata/metadataServerSettings.ts';
|
||||||
|
import { MetadataThemeSettings } from '@/typings.ts';
|
||||||
|
import { makeToast } from '@/components/util/Toast.tsx';
|
||||||
|
import { EmptyView } from '@/components/util/EmptyView.tsx';
|
||||||
|
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx';
|
||||||
|
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
|
||||||
|
import { CreateThemeButton } from '@/screens/settings/appearance/theme/CreateThemeButton.tsx';
|
||||||
|
import { ThemePreview } from '@/screens/settings/appearance/theme/ThemePreview.tsx';
|
||||||
|
|
||||||
|
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={error.message}
|
||||||
|
retry={() => refetch().catch(defaultPromiseErrorHandler('ThemeList::refetch'))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack sx={{ flexDirection: 'row', flexWrap: 'no-wrap', overflowX: 'auto', gap: 1, mx: 1 }}>
|
||||||
|
<CreateThemeButton />
|
||||||
|
{allThemes.map((theme) => (
|
||||||
|
<ThemePreview
|
||||||
|
key={theme.id}
|
||||||
|
theme={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(() =>
|
||||||
|
makeToast(
|
||||||
|
t('settings.appearance.theme.delete.failure', { theme: theme.getName() }),
|
||||||
|
'error',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
185
src/screens/settings/appearance/theme/ThemePreview.tsx
Normal file
185
src/screens/settings/appearance/theme/ThemePreview.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* 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 { useContext, useMemo } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import { styled, ThemeProvider } 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 Tooltip from '@mui/material/Tooltip';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import { bindDialog, usePopupState } from 'material-ui-popup-state/hooks';
|
||||||
|
import { ThemeModeContext } from '@/components/context/ThemeModeContext.tsx';
|
||||||
|
import { AppTheme } from '@/lib/ui/AppThemes.ts';
|
||||||
|
import { createTheme } from '@/theme.ts';
|
||||||
|
import { ThemeCreationDialog } from '@/screens/settings/appearance/theme/CreateThemeDialog.tsx';
|
||||||
|
|
||||||
|
const ThemePreviewBadge = styled(Box)(({ theme }) => ({
|
||||||
|
width: '15px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ThemePreview = ({ theme, onDelete }: { theme: AppTheme; onDelete: () => void }) => {
|
||||||
|
const { getName } = theme;
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { themeMode, setAppTheme, appTheme, pureBlackMode } = useContext(ThemeModeContext);
|
||||||
|
|
||||||
|
const popupState = usePopupState({ variant: 'popover', popupId: `theme-edit-dialog-${theme.id}` });
|
||||||
|
|
||||||
|
const isSelected = theme.id === appTheme;
|
||||||
|
|
||||||
|
const muiTheme = useMemo(() => createTheme(themeMode, theme, pureBlackMode), [theme, themeMode, pureBlackMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack
|
||||||
|
sx={{
|
||||||
|
m: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minWidth: '150px',
|
||||||
|
maxWidth: '150px',
|
||||||
|
gap: 1,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeProvider theme={muiTheme}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '225px',
|
||||||
|
outline: '4px solid',
|
||||||
|
outlineColor: isSelected
|
||||||
|
? muiTheme.palette.primary.light
|
||||||
|
: muiTheme.palette.background.paper,
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardActionArea
|
||||||
|
sx={{ height: '100%', backgroundColor: 'background.default' }}
|
||||||
|
onClick={() => setAppTheme(theme.id)}
|
||||||
|
>
|
||||||
|
<Stack sx={{ height: '100%' }}>
|
||||||
|
<Stack sx={{ height: '100%', gap: 2, p: 1 }}>
|
||||||
|
<Stack
|
||||||
|
sx={{
|
||||||
|
maxHeight: '20px',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '65%',
|
||||||
|
height: '20px',
|
||||||
|
backgroundColor: 'primary.dark',
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack sx={{ height: '100%', alignItems: 'center', gap: 1 }}>
|
||||||
|
{isSelected && (
|
||||||
|
<CheckCircleIcon
|
||||||
|
sx={{
|
||||||
|
visibility: isSelected ? 'visible' : 'hidden',
|
||||||
|
color: 'primary.light',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isSelected && theme.isCustom && (
|
||||||
|
<IconButton
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
component="div"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Tooltip title={t('global.button.delete')}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</Tooltip>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{theme.isCustom && (
|
||||||
|
<IconButton
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
popupState.open();
|
||||||
|
}}
|
||||||
|
component="div"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Tooltip title={t('global.button.edit')}>
|
||||||
|
<EditIcon />
|
||||||
|
</Tooltip>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Stack
|
||||||
|
sx={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
width: '55%',
|
||||||
|
height: '65%',
|
||||||
|
backgroundColor: 'background.paper',
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemePreviewBadge sx={{ backgroundColor: 'primary.main' }} />
|
||||||
|
<ThemePreviewBadge sx={{ backgroundColor: 'secondary.main' }} />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Stack
|
||||||
|
sx={{
|
||||||
|
height: '80px',
|
||||||
|
backgroundColor: muiTheme.palette.background.paper,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 0.75,
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: 1,
|
||||||
|
backgroundColor: 'primary.light',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
</ThemeProvider>
|
||||||
|
<Typography>{getName()}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<ThemeCreationDialog bindDialogProps={bindDialog(popupState)} mode="edit" appTheme={theme} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
22
src/theme.ts
22
src/theme.ts
@@ -17,28 +17,32 @@ import {
|
|||||||
} from '@mui/material/styles';
|
} from '@mui/material/styles';
|
||||||
import { ThemeMode } from '@/components/context/ThemeModeContext.tsx';
|
import { ThemeMode } from '@/components/context/ThemeModeContext.tsx';
|
||||||
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
|
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
|
||||||
import { AppThemes, getTheme } from '@/lib/ui/AppThemes.ts';
|
import { AppTheme } from '@/lib/ui/AppThemes.ts';
|
||||||
|
|
||||||
export const SCROLLBAR_WIDTH = 14;
|
export const SCROLLBAR_WIDTH = 14;
|
||||||
|
|
||||||
export const createTheme = (
|
export const createTheme = (
|
||||||
themeMode: ThemeMode,
|
themeMode: ThemeMode,
|
||||||
appThemeId: AppThemes,
|
appTheme: AppTheme,
|
||||||
pureBlackMode: boolean = false,
|
pureBlackMode: boolean = false,
|
||||||
direction: Direction = 'ltr',
|
direction: Direction = 'ltr',
|
||||||
) => {
|
) => {
|
||||||
const appTheme = getTheme(appThemeId);
|
|
||||||
|
|
||||||
const systemMode = MediaQuery.getSystemThemeMode();
|
const systemMode = MediaQuery.getSystemThemeMode();
|
||||||
const mode = themeMode === ThemeMode.SYSTEM ? systemMode : themeMode;
|
|
||||||
|
const isStaticThemeMode = !!appTheme.muiTheme.palette?.mode;
|
||||||
|
const appThemeMode = appTheme.muiTheme.palette?.mode === 'dark' ? ThemeMode.DARK : ThemeMode.LIGHT;
|
||||||
|
const staticThemeMode = isStaticThemeMode ? appThemeMode : undefined;
|
||||||
|
|
||||||
|
const mode = staticThemeMode ?? (themeMode === ThemeMode.SYSTEM ? systemMode : themeMode);
|
||||||
const isDarkMode = mode === ThemeMode.DARK;
|
const isDarkMode = mode === ThemeMode.DARK;
|
||||||
const setPureBlackMode = isDarkMode && pureBlackMode;
|
const setPureBlackMode = isDarkMode && pureBlackMode;
|
||||||
|
|
||||||
const baseTheme = createMuiTheme({
|
const baseTheme = createMuiTheme({
|
||||||
direction,
|
direction,
|
||||||
|
...appTheme.muiTheme,
|
||||||
palette: {
|
palette: {
|
||||||
mode,
|
mode,
|
||||||
...appTheme.palette,
|
...(appTheme.muiTheme.palette ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,7 +59,11 @@ export const createTheme = (
|
|||||||
default: lighten(baseTheme.palette.primary.dark, 0.85),
|
default: lighten(baseTheme.palette.primary.dark, 0.85),
|
||||||
};
|
};
|
||||||
const backgroundThemeMode = isDarkMode ? backgroundDark : backgroundLight;
|
const backgroundThemeMode = isDarkMode ? backgroundDark : backgroundLight;
|
||||||
const background = setPureBlackMode ? backgroundTrueBlack : backgroundThemeMode;
|
const automaticBackground = setPureBlackMode ? backgroundTrueBlack : backgroundThemeMode;
|
||||||
|
const appThemeBackground = appTheme.muiTheme.palette?.background;
|
||||||
|
|
||||||
|
const requiresAutomaticBackground = setPureBlackMode || !appThemeBackground;
|
||||||
|
const background = requiresAutomaticBackground ? automaticBackground : appThemeBackground;
|
||||||
|
|
||||||
const colorTheme = createMuiTheme(baseTheme, {
|
const colorTheme = createMuiTheme(baseTheme, {
|
||||||
palette: {
|
palette: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
SourcePreferenceChangeInput,
|
SourcePreferenceChangeInput,
|
||||||
TrackerType,
|
TrackerType,
|
||||||
} from '@/lib/graphql/generated/graphql.ts';
|
} from '@/lib/graphql/generated/graphql.ts';
|
||||||
|
import { AppTheme } from '@/lib/ui/AppThemes.ts';
|
||||||
|
|
||||||
type GenericLocation<State = any> = Omit<Location, 'state'> & { state?: State };
|
type GenericLocation<State = any> = Omit<Location, 'state'> & { state?: State };
|
||||||
|
|
||||||
@@ -156,13 +157,18 @@ export type MetadataUpdateSettings = {
|
|||||||
serverInformAvailableUpdate: boolean;
|
serverInformAvailableUpdate: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MetadataThemeSettings = {
|
||||||
|
customThemes: Record<string, AppTheme>;
|
||||||
|
};
|
||||||
|
|
||||||
export type MetadataServerSettings = MetadataDownloadSettings &
|
export type MetadataServerSettings = MetadataDownloadSettings &
|
||||||
MetadataLibrarySettings &
|
MetadataLibrarySettings &
|
||||||
MetadataClientSettings &
|
MetadataClientSettings &
|
||||||
MetadataMigrationSettings &
|
MetadataMigrationSettings &
|
||||||
MetadataBrowseSettings &
|
MetadataBrowseSettings &
|
||||||
MetadataTrackingSettings &
|
MetadataTrackingSettings &
|
||||||
MetadataUpdateSettings;
|
MetadataUpdateSettings &
|
||||||
|
MetadataThemeSettings;
|
||||||
|
|
||||||
export interface ISearchSettings {
|
export interface ISearchSettings {
|
||||||
ignoreFilters: boolean;
|
ignoreFilters: boolean;
|
||||||
|
|||||||
@@ -5862,6 +5862,11 @@ jsonify@^0.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978"
|
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978"
|
||||||
integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==
|
integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==
|
||||||
|
|
||||||
|
jsonrepair@3.8.0:
|
||||||
|
version "3.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jsonrepair/-/jsonrepair-3.8.0.tgz#33a1b0d3630c452e9945ef07d760469cdfad8823"
|
||||||
|
integrity sha512-89lrxpwp+IEcJ6kwglF0HH3Tl17J08JEpYfXnvvjdp4zV4rjSoGu2NdQHxBs7yTOk3ETjTn9du48pBy8iBqj1w==
|
||||||
|
|
||||||
"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.5:
|
"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.5:
|
||||||
version "3.3.5"
|
version "3.3.5"
|
||||||
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a"
|
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a"
|
||||||
|
|||||||
Reference in New Issue
Block a user