diff --git a/package.json b/package.json index b89ac90f..4dbe6ed3 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "i18next": "23.14.0", "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "2.6.1", + "jsonrepair": "3.8.0", "material-ui-popup-state": "5.3.1", "mui-nested-menu": "3.4.0", "p-limit": "6.1.0", diff --git a/public/locales/en.json b/public/locales/en.json index b8f6897d..707a676c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -328,6 +328,7 @@ "restore": "Restore", "resume": "Resume", "retry": "Retry", + "save": "Save", "select": "Select", "select_all": "Select all", "set": "Set", @@ -818,18 +819,48 @@ } }, "appearance": { - "device_theme": "Device theme", - "pure_black_mode": "Pure black dark mode", - "theme": "Theme", - "themes": { - "crimson": "Crimson", - "dune": "Dune", - "forest_dew": "Forest dew", - "lavendar": "Lavendar", - "minty_miracles": "Minty Miracles", - "mountain_sunset": "Mountain sunset", - "orange_juice": "Orange juice", - "rosegold": "Rosegold" + "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, 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", + "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", + "themes": { + "crimson": "Crimson", + "dune": "Dune", + "forest_dew": "Forest dew", + "lavender": "Lavender", + "minty_miracles": "Minty Miracles", + "mountain_sunset": "Mountain sunset", + "orange_juice": "Orange juice", + "rosegold": "Rosegold" + }, + "title": "Theme" }, "title": "Appearance" }, diff --git a/src/App.tsx b/src/App.tsx index 7e6becd6..8c121887 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,7 +46,7 @@ const { DeviceSetting } = loadable(() => import('@/components/settings/DeviceSet const { TrackingSettings } = loadable(() => import('@/screens/settings/TrackingSettings.tsx'), lazyLoadFallback); const { TrackerOAuthLogin } = loadable(() => import('@/screens/TrackerOAuthLogin.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') { // Adds messages only in a dev environment diff --git a/src/components/context/AppContext.tsx b/src/components/context/AppContext.tsx index 51ef3a82..59d2c84b 100644 --- a/src/components/context/AppContext.tsx +++ b/src/components/context/AppContext.tsx @@ -23,7 +23,8 @@ import { NavBarContextProvider } from '@/components/navbar/NavBarContextProvider import { LibraryOptionsContextProvider } from '@/components/library/LibraryOptionsProvider'; import { ActiveDevice, DEFAULT_DEVICE, setActiveDevice } from '@/util/device.ts'; 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 { children: React.ReactNode; @@ -50,6 +51,10 @@ export const AppContext: React.FC = ({ children }) => { directionRef.current = currentDirection; } + const { + settings: { customThemes }, + } = useMetadataServerSettings(); + const [systemThemeMode, setSystemThemeMode] = useState(MediaQuery.getSystemThemeMode()); useLayoutEffect(() => { const unsubscribe = MediaQuery.listenToSystemThemeChange(setSystemThemeMode); @@ -80,8 +85,8 @@ export const AppContext: React.FC = ({ children }) => { ); const theme = useMemo( - () => createAndSetTheme(themeMode, appTheme, pureBlackMode, currentDirection), - [themeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme], + () => createAndSetTheme(themeMode, getTheme(appTheme, customThemes), pureBlackMode, currentDirection), + [themeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme, customThemes], ); setActiveDevice(activeDevice); diff --git a/src/lib/metadata/metadata.ts b/src/lib/metadata/metadata.ts index 7a01e5c0..22bbe958 100644 --- a/src/lib/metadata/metadata.ts +++ b/src/lib/metadata/metadata.ts @@ -57,6 +57,9 @@ const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [ // sources 'savedSearches', + + // themes + 'customThemes', ]; /** diff --git a/src/lib/metadata/metadataServerSettings.ts b/src/lib/metadata/metadataServerSettings.ts index 3ffb464d..615938d7 100644 --- a/src/lib/metadata/metadataServerSettings.ts +++ b/src/lib/metadata/metadataServerSettings.ts @@ -12,6 +12,7 @@ import { Metadata, MetadataServerSettingKeys, MetadataServerSettings, + MetadataThemeSettings, } from '@/typings.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { convertFromGqlMeta, getMetadataFrom, requestUpdateServerMetadata } from '@/lib/metadata/metadata.ts'; @@ -50,6 +51,9 @@ export const getDefaultSettings = (): MetadataServerSettings => ({ // updates webUIInformAvailableUpdate: true, serverInformAvailableUpdate: true, + + // themes + customThemes: {}, }); export const convertSettingsToMetadata = ( @@ -57,6 +61,7 @@ export const convertSettingsToMetadata = ( ): Metadata => ({ ...settings, devices: JSON.stringify(settings.devices), + customThemes: JSON.stringify(settings.customThemes), }); export const convertMetadataToSettings = ( @@ -66,6 +71,9 @@ export const convertMetadataToSettings = ( ...getDefaultSettings(), ...(metadata as unknown as MetadataServerSettings), devices: jsonSaveParse((metadata.devices as string) ?? '') ?? getDefaultSettings().devices, + customThemes: + jsonSaveParse((metadata.customThemes as string) ?? '') ?? + getDefaultSettings().customThemes, }) satisfies MetadataServerSettings; const getMetadataServerSettingsWithDefaultFallback = ( diff --git a/src/lib/ui/AppThemes.ts b/src/lib/ui/AppThemes.ts index 703cb1d4..28390cd7 100644 --- a/src/lib/ui/AppThemes.ts +++ b/src/lib/ui/AppThemes.ts @@ -6,120 +6,165 @@ * 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 { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; -type Theme = { getName: () => string; palette: PaletteOptions }; +type Theme = { isCustom: boolean; getName: () => string; muiTheme: ThemeOptions }; const themes = { default: { + isCustom: false, getName: () => translate('global.label.default'), - palette: { - primary: { - main: '#5b74ef', - }, - secondary: { - main: '#efd65b', + muiTheme: { + palette: { + primary: { + main: '#5b74ef', + }, + secondary: { + main: '#efd65b', + }, }, }, }, lavender: { - getName: () => translate('settings.appearance.themes.lavendar'), - palette: { - primary: { - main: '#5c58dc', - }, - secondary: { - main: '#d8dc58', + isCustom: false, + getName: () => translate('settings.appearance.theme.themes.lavender'), + muiTheme: { + palette: { + primary: { + main: '#5c58dc', + }, + secondary: { + main: '#d8dc58', + }, }, }, }, dune: { - getName: () => translate('settings.appearance.themes.dune'), - palette: { - primary: { - main: '#897869', - }, - secondary: { - main: '#697a89', + isCustom: false, + getName: () => translate('settings.appearance.theme.themes.dune'), + muiTheme: { + palette: { + primary: { + main: '#897869', + }, + secondary: { + main: '#697a89', + }, }, }, }, rosegold: { - getName: () => translate('settings.appearance.themes.rosegold'), - palette: { - primary: { - main: '#f1a886', - }, - secondary: { - main: '#86cff1', + isCustom: false, + getName: () => translate('settings.appearance.theme.themes.rosegold'), + muiTheme: { + palette: { + primary: { + main: '#f1a886', + }, + secondary: { + main: '#86cff1', + }, }, }, }, - forestDew: { - getName: () => translate('settings.appearance.themes.forest_dew'), - palette: { - primary: { - main: '#53a584', - }, - secondary: { - main: '#a55374', + 'forest dew': { + isCustom: false, + getName: () => translate('settings.appearance.theme.themes.forest_dew'), + muiTheme: { + palette: { + primary: { + main: '#53a584', + }, + secondary: { + main: '#a55374', + }, }, }, }, - mountainSunset: { - getName: () => translate('settings.appearance.themes.mountain_sunset'), - palette: { - primary: { - main: '#c55a77', - }, - secondary: { - main: '#5ac5a8', + 'montain sunset': { + isCustom: false, + getName: () => translate('settings.appearance.theme.themes.mountain_sunset'), + muiTheme: { + palette: { + primary: { + main: '#c55a77', + }, + secondary: { + main: '#5ac5a8', + }, }, }, }, crimson: { - getName: () => translate('settings.appearance.themes.crimson'), - palette: { - primary: { - main: '#ff0033', - }, - secondary: { - main: '#00ffcc', + isCustom: false, + getName: () => translate('settings.appearance.theme.themes.crimson'), + muiTheme: { + palette: { + primary: { + main: '#ff0033', + }, + secondary: { + main: '#00ffcc', + }, }, }, }, - mintyMiracles: { - getName: () => translate('settings.appearance.themes.minty_miracles'), - palette: { - primary: { - main: '#9defc3', - }, - secondary: { - main: '#ef9dc9', + 'minty miracles': { + isCustom: false, + getName: () => translate('settings.appearance.theme.themes.minty_miracles'), + muiTheme: { + palette: { + primary: { + main: '#9defc3', + }, + secondary: { + main: '#ef9dc9', + }, }, }, }, - orangeJuice: { - getName: () => translate('settings.appearance.themes.orange_juice'), - palette: { - primary: { - main: '#ffb546', - }, - secondary: { - main: '#4690ff', + 'orange juice': { + isCustom: false, + getName: () => translate('settings.appearance.theme.themes.orange_juice'), + muiTheme: { + palette: { + primary: { + main: '#ffb546', + }, + secondary: { + main: '#4690ff', + }, }, }, }, } as const satisfies Record; -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]) => ({ id, ...theme, })) satisfies AppTheme[]; -export const getTheme = (id: AppThemes): AppTheme => ({ id, ...themes[id] }); +export const getTheme = (id: AppThemes, customThemes: Record = {}): 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): boolean => + Object.keys({ ...themes, ...customThemes }).every((themeId) => themeId.toLowerCase() !== id.toLowerCase()); diff --git a/src/screens/settings/Appearance.tsx b/src/screens/settings/Appearance.tsx deleted file mode 100644 index ee0654af..00000000 --- a/src/screens/settings/Appearance.tsx +++ /dev/null @@ -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 ( - - - - setAppTheme(theme.id)} - > - - - - - {isSelected && ( - - )} - - - - - - - - - - - - - - - {getName()} - - ); -}; - -const ThemePicker = () => ( - - {appThemes.map((theme) => ( - - ))} - -); - -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('ItemWidth', DEFAULT_ITEM_WIDTH); - - return ( - - {t('settings.appearance.theme')} - - } - > - - - value={themeMode} onChange={(e) => setThemeMode(e.target.value as ThemeMode)}> - - System - - - Dark - - - Light - - - - - {isDarkMode && ( - - - setPureBlackMode(enabled)} /> - - )} - - {t('global.label.display')} - - } - > - - - {t('settings.label.language_description')} - - {t('global.language.title.weblate')} - - - } - /> - - - - - - ); -}; diff --git a/src/screens/settings/appearance/Appearance.tsx b/src/screens/settings/appearance/Appearance.tsx new file mode 100644 index 00000000..218eb871 --- /dev/null +++ b/src/screens/settings/appearance/Appearance.tsx @@ -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('ItemWidth', DEFAULT_ITEM_WIDTH); + + return ( + + {t('settings.appearance.theme.title')} + + } + > + + + value={themeMode} onChange={(e) => setThemeMode(e.target.value as ThemeMode)}> + + System + + + Dark + + + Light + + + + + {isDarkMode && ( + + + setPureBlackMode(enabled)} /> + + )} + + {t('global.label.display')} + + } + > + + + {t('settings.label.language_description')} + + {t('global.language.title.weblate')} + + + } + /> + + + + + + ); +}; diff --git a/src/screens/settings/appearance/theme/CreateThemeButton.tsx b/src/screens/settings/appearance/theme/CreateThemeButton.tsx new file mode 100644 index 00000000..9ce4a053 --- /dev/null +++ b/src/screens/settings/appearance/theme/CreateThemeButton.tsx @@ -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 ( + <> + + + {t('settings.appearance.theme.create.title')} + + {popupState.isOpen && } + + ); +}; diff --git a/src/screens/settings/appearance/theme/CreateThemeDialog.tsx b/src/screens/settings/appearance/theme/CreateThemeDialog.tsx new file mode 100644 index 00000000..7c06bc53 --- /dev/null +++ b/src/screens/settings/appearance/theme/CreateThemeDialog.tsx @@ -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; + mode: DialogMode; + appTheme?: AppTheme; +}) => { + const { t } = useTranslation(); + + const { + settings: { customThemes }, + request: { loading, error, refetch }, + } = useMetadataServerSettings(); + + const updateCustomThemes = createUpdateMetadataServerSettings((e) => { + throw e; + }); + + const [theme, setTheme] = useState(appTheme); + const [invalidName, setInvalidName] = useState(false); + const [invalidTheme, setInvalidTheme] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [didThemeChange, setDidThemeChange] = useState(mode === 'create'); + + return ( + + {t(dialogModeToTranslationKey[mode].title)} + + {loading && } + {error && ( + refetch().catch(defaultPromiseErrorHandler('ThemeCreationDialog::refetch'))} + /> + )} + {!error && ( + + + + Create a custom theme with the{' '} + + MUI Theme Creator + {' '} + , copy everything after "themeOptions" from "{'{'}" to "{'}'} + " and paste it into the "theme" text field. + + + {t('settings.appearance.theme.create.dialog.info.theme_mode_lock')} + { + 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, + }); + }} + /> + { + 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 }); + } + }} + /> + + )} + + + + + + + ); +}; diff --git a/src/screens/settings/appearance/theme/ThemeList.tsx b/src/screens/settings/appearance/theme/ThemeList.tsx new file mode 100644 index 00000000..ae70cd1e --- /dev/null +++ b/src/screens/settings/appearance/theme/ThemeList.tsx @@ -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((e) => { + throw e; + }); + + if (loading) { + return ; + } + + if (error) { + return ( + refetch().catch(defaultPromiseErrorHandler('ThemeList::refetch'))} + /> + ); + } + + return ( + + + {allThemes.map((theme) => ( + { + 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', + ), + ); + }} + /> + ))} + + ); +}; diff --git a/src/screens/settings/appearance/theme/ThemePreview.tsx b/src/screens/settings/appearance/theme/ThemePreview.tsx new file mode 100644 index 00000000..4f99e175 --- /dev/null +++ b/src/screens/settings/appearance/theme/ThemePreview.tsx @@ -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 ( + <> + + + + setAppTheme(theme.id)} + > + + + + + + {isSelected && ( + + )} + {!isSelected && theme.isCustom && ( + { + e.preventDefault(); + e.stopPropagation(); + onDelete(); + }} + component="div" + size="small" + > + + + + + )} + {theme.isCustom && ( + { + e.preventDefault(); + e.stopPropagation(); + popupState.open(); + }} + component="div" + size="small" + > + + + + + )} + + + + + + + + + + + + + + + + {getName()} + + + + ); +}; diff --git a/src/theme.ts b/src/theme.ts index 95616d4f..f67e19aa 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -17,28 +17,32 @@ import { } from '@mui/material/styles'; import { ThemeMode } from '@/components/context/ThemeModeContext.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 createTheme = ( themeMode: ThemeMode, - appThemeId: AppThemes, + appTheme: AppTheme, pureBlackMode: boolean = false, direction: Direction = 'ltr', ) => { - const appTheme = getTheme(appThemeId); - 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 setPureBlackMode = isDarkMode && pureBlackMode; const baseTheme = createMuiTheme({ direction, + ...appTheme.muiTheme, palette: { mode, - ...appTheme.palette, + ...(appTheme.muiTheme.palette ?? {}), }, }); @@ -55,7 +59,11 @@ export const createTheme = ( default: lighten(baseTheme.palette.primary.dark, 0.85), }; 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, { palette: { diff --git a/src/typings.ts b/src/typings.ts index 8298a673..e8cfa5a5 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -20,6 +20,7 @@ import { SourcePreferenceChangeInput, TrackerType, } from '@/lib/graphql/generated/graphql.ts'; +import { AppTheme } from '@/lib/ui/AppThemes.ts'; type GenericLocation = Omit & { state?: State }; @@ -156,13 +157,18 @@ export type MetadataUpdateSettings = { serverInformAvailableUpdate: boolean; }; +export type MetadataThemeSettings = { + customThemes: Record; +}; + export type MetadataServerSettings = MetadataDownloadSettings & MetadataLibrarySettings & MetadataClientSettings & MetadataMigrationSettings & MetadataBrowseSettings & MetadataTrackingSettings & - MetadataUpdateSettings; + MetadataUpdateSettings & + MetadataThemeSettings; export interface ISearchSettings { ignoreFilters: boolean; diff --git a/yarn.lock b/yarn.lock index 4c31e7d4..9075beac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5862,6 +5862,11 @@ jsonify@^0.0.1: resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" 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: version "3.3.5" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a"