added theme manager

This commit is contained in:
2025-04-28 16:13:41 +03:00
parent efc83c89aa
commit ba9062dbcf
11 changed files with 880 additions and 53 deletions

View File

@@ -110,7 +110,6 @@ export const BuyQortInformation = ({ balance }) => {
</Typography>
<List
sx={{
bgcolor: theme.palette.background.default,
maxWidth: 360,
width: '100%',
}}
@@ -118,21 +117,13 @@ export const BuyQortInformation = ({ balance }) => {
>
<ListItem disablePadding>
<ListItemIcon>
<RadioButtonCheckedIcon
sx={{
color: theme.palette.primary.dark,
}}
/>
<RadioButtonCheckedIcon />
</ListItemIcon>
<ListItemText primary="Create transactions on the Qortal Blockchain" />
</ListItem>
<ListItem disablePadding>
<ListItemIcon>
<RadioButtonCheckedIcon
sx={{
color: theme.palette.primary.dark,
}}
/>
<RadioButtonCheckedIcon />
</ListItemIcon>
<ListItemText primary="Having at least 4 QORT in your balance allows you to send chat messages at near instant speed." />
</ListItem>

View File

@@ -48,6 +48,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import ErrorBoundary from '../../common/ErrorBoundary';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { getFee } from '../../background';
export const requestQueuePromos = new RequestQueueWithPromise(20);
export function utf8ToBase64(inputString: string): string {

View File

@@ -18,6 +18,7 @@ import { TransitionProps } from '@mui/material/transitions';
import { Box, FormControlLabel, Switch, styled, useTheme } from '@mui/material';
import { enabledDevModeAtom } from '../../atoms/global';
import { useRecoilState } from 'recoil';
import ThemeManager from '../Theme/ThemeManager';
const LocalNodeSwitch = styled(Switch)(({ theme }) => ({
padding: 8,
@@ -185,6 +186,7 @@ export const Settings = ({ address, open, setOpen }) => {
label="Enable dev mode"
/>
)}
<ThemeManager />
</Box>
</Dialog>
</Fragment>

View File

@@ -6,57 +6,129 @@ import {
useEffect,
useCallback,
} from 'react';
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import { darkTheme } from '../../styles/theme-dark';
import { lightTheme } from '../../styles/theme-light';
import {
ThemeProvider as MuiThemeProvider,
createTheme,
} from '@mui/material/styles';
import { lightThemeOptions } from '../../styles/theme-light';
import { darkThemeOptions } from '../../styles/theme-dark';
const defaultTheme = {
id: 'default',
name: 'Default Theme',
light: lightThemeOptions.palette,
dark: darkThemeOptions.palette,
};
const ThemeContext = createContext({
themeMode: 'light',
toggleTheme: () => {},
userThemes: [defaultTheme],
addUserTheme: (themes) => {},
setUserTheme: (theme) => {},
currentThemeId: 'default',
});
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
export const ThemeProvider = ({ children }) => {
const [themeMode, setThemeMode] = useState('light');
const [userThemes, setUserThemes] = useState([defaultTheme]);
const [currentThemeId, setCurrentThemeId] = useState('default');
const theme = useMemo(
() => (themeMode === 'light' ? lightTheme : darkTheme),
[themeMode]
);
const currentTheme =
userThemes.find((theme) => theme.id === currentThemeId) || defaultTheme;
const muiTheme = useMemo(() => {
if (themeMode === 'light') {
return createTheme({
...lightThemeOptions,
palette: {
...currentTheme.light,
},
});
} else {
return createTheme({
...lightThemeOptions,
palette: {
...currentTheme.dark,
},
});
}
}, [themeMode, currentTheme]);
const saveSettings = (
themes = userThemes,
mode = themeMode,
themeId = currentThemeId
) => {
localStorage.setItem(
'saved_ui_theme',
JSON.stringify({
mode,
userThemes: themes,
currentThemeId: themeId,
})
);
};
const toggleTheme = () => {
setThemeMode((prevMode) => {
const newMode = prevMode === 'light' ? 'dark' : 'light';
const themeProperties = {
mode: newMode,
};
localStorage.setItem('saved_ui_theme', JSON.stringify(themeProperties));
setThemeMode((prev) => {
const newMode = prev === 'light' ? 'dark' : 'light';
saveSettings(userThemes, newMode, currentThemeId);
return newMode;
});
};
const getSavedTheme = useCallback(async () => {
try {
const themeProperties = JSON.parse(
localStorage.getItem(`saved_ui_theme`) || '{}'
);
const addUserTheme = (themes) => {
setUserThemes(themes);
saveSettings(themes);
};
const theme = themeProperties?.mode || 'light';
setThemeMode(theme);
} catch (error) {
console.log('error', error);
const setUserTheme = (theme) => {
if (theme.id === 'default') {
setCurrentThemeId('default');
saveSettings(userThemes, themeMode, 'default');
} else {
setCurrentThemeId(theme.id);
saveSettings(userThemes, themeMode, theme.id);
}
};
const loadSettings = useCallback(() => {
const saved = localStorage.getItem('saved_ui_theme');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.mode === 'light' || parsed.mode === 'dark')
setThemeMode(parsed.mode);
if (Array.isArray(parsed.userThemes)) {
const filteredThemes = parsed.userThemes.filter(
(theme) => theme.id !== 'default'
);
setUserThemes([defaultTheme, ...filteredThemes]);
}
if (parsed.currentThemeId) setCurrentThemeId(parsed.currentThemeId);
} catch (error) {
console.error('Failed to parse saved_ui_theme:', error);
}
}
}, []);
useEffect(() => {
getSavedTheme();
}, [getSavedTheme]);
loadSettings();
}, [loadSettings]);
return (
<ThemeContext.Provider value={{ themeMode, toggleTheme }}>
<MuiThemeProvider theme={theme}>{children}</MuiThemeProvider>
<ThemeContext.Provider
value={{
themeMode,
toggleTheme,
userThemes,
addUserTheme,
setUserTheme,
currentThemeId,
}}
>
<MuiThemeProvider theme={muiTheme}>{children}</MuiThemeProvider>
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,309 @@
import React, { useState, useRef, useEffect } from 'react';
import {
Box,
Button,
IconButton,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
List,
ListItemText,
ListItemSecondaryAction,
TextField,
Tabs,
Tab,
ListItemButton,
} from '@mui/material';
import { Sketch } from '@uiw/react-color';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import AddIcon from '@mui/icons-material/Add';
import CheckIcon from '@mui/icons-material/Check';
import { useThemeContext } from './ThemeContext';
import { darkThemeOptions } from '../../styles/theme-dark';
import { lightThemeOptions } from '../../styles/theme-light';
import ShortUniqueId from 'short-unique-id';
import { rgbStringToHsva, rgbaStringToHsva } from '@uiw/color-convert';
const uid = new ShortUniqueId({ length: 8 });
function detectColorFormat(color) {
if (typeof color !== 'string') return null;
if (color.startsWith('rgba')) return 'rgba';
if (color.startsWith('rgb')) return 'rgb';
return null;
}
export default function ThemeManager() {
const { userThemes, addUserTheme, setUserTheme, currentThemeId } =
useThemeContext();
const [openEditor, setOpenEditor] = useState(false);
const [themeDraft, setThemeDraft] = useState({
id: '',
name: '',
light: {},
dark: {},
});
const [currentTab, setCurrentTab] = useState('light');
const nameInputRef = useRef(null);
useEffect(() => {
if (openEditor && nameInputRef.current) {
nameInputRef.current.focus();
}
}, [openEditor]);
const handleAddTheme = () => {
setThemeDraft({
id: '',
name: '',
light: structuredClone(lightThemeOptions.palette),
dark: structuredClone(darkThemeOptions.palette),
});
setOpenEditor(true);
};
const handleEditTheme = (themeId) => {
const themeToEdit = userThemes.find((theme) => theme.id === themeId);
if (themeToEdit) {
setThemeDraft({ ...themeToEdit });
setOpenEditor(true);
}
};
const handleSaveTheme = () => {
if (themeDraft.id) {
const updatedThemes = [...userThemes];
const index = updatedThemes.findIndex(
(theme) => theme.id === themeDraft.id
);
if (index !== -1) {
updatedThemes[index] = themeDraft;
addUserTheme(updatedThemes);
}
} else {
const newTheme = { ...themeDraft, id: uid.rnd() };
const updatedThemes = [...userThemes, newTheme];
addUserTheme(updatedThemes);
setUserTheme(newTheme);
}
setOpenEditor(false);
};
const handleDeleteTheme = (id) => {
const updatedThemes = userThemes.filter((theme) => theme.id !== id);
addUserTheme(updatedThemes);
if (id === currentThemeId) {
// Find the default theme object in the list
const defaultTheme = updatedThemes.find(
(theme) => theme.id === 'default'
);
if (defaultTheme) {
setUserTheme(defaultTheme);
} else {
// Emergency fallback
setUserTheme({
light: lightThemeOptions,
dark: darkThemeOptions,
});
}
}
};
const handleApplyTheme = (theme) => {
setUserTheme(theme);
};
const handleColorChange = (mode, fieldPath, color) => {
setThemeDraft((prev) => {
const updated = { ...prev };
const paths = fieldPath.split('.');
updated[mode][paths[0]][paths[1]] = color.hex;
return updated;
});
};
const renderColorPicker = (mode, label, fieldPath, currentValue) => {
let color = currentValue || '#ffffff';
const format = detectColorFormat(currentValue);
if (format === 'rgba') {
color = rgbaStringToHsva(currentValue);
} else if (format === 'rgb') {
color = rgbStringToHsva(currentValue);
}
return (
<Box
mb={2}
{...{ 'data-color-mode': mode === 'dark' ? 'dark' : 'light' }}
>
<Typography variant="body2" mb={1}>
{label}
</Typography>
<Sketch
key={`${mode}-${fieldPath}`}
color={color}
onChange={(color) => handleColorChange(mode, fieldPath, color)}
/>
</Box>
);
};
return (
<Box p={2}>
<Typography variant="h5" gutterBottom>
Theme Manager
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddTheme}
>
Add Theme
</Button>
<List>
{userThemes?.map((theme, index) => (
<ListItemButton
key={theme?.id || index}
selected={theme?.id === currentThemeId}
>
<ListItemText
primary={`${theme?.name || `Theme ${index + 1}`} ${theme?.id === currentThemeId ? '(Current)' : ''}`}
/>
<ListItemSecondaryAction>
{theme.id !== 'default' && (
<>
<IconButton onClick={() => handleEditTheme(theme.id)}>
<EditIcon />
</IconButton>
<IconButton onClick={() => handleDeleteTheme(theme.id)}>
<DeleteIcon />
</IconButton>
</>
)}
<IconButton onClick={() => handleApplyTheme(theme)}>
<CheckIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItemButton>
))}
</List>
<Dialog
open={openEditor}
onClose={() => setOpenEditor(false)}
fullWidth
maxWidth="md"
>
<DialogTitle>
{themeDraft.id ? 'Edit Theme' : 'Add New Theme'}
</DialogTitle>
<DialogContent>
<TextField
inputRef={nameInputRef}
margin="dense"
label="Theme Name"
fullWidth
value={themeDraft.name}
onChange={(e) =>
setThemeDraft((prev) => ({ ...prev, name: e.target.value }))
}
/>
<Tabs
value={currentTab}
onChange={(e, newValue) => setCurrentTab(newValue)}
sx={{ mt: 2, mb: 2 }}
>
<Tab label="Light" value="light" />
<Tab label="Dark" value="dark" />
</Tabs>
<Box>
{renderColorPicker(
currentTab,
'Primary Main',
'primary.main',
themeDraft[currentTab]?.primary?.main
)}
{renderColorPicker(
currentTab,
'Primary Dark',
'primary.dark',
themeDraft[currentTab]?.primary?.dark
)}
{renderColorPicker(
currentTab,
'Primary Light',
'primary.light',
themeDraft[currentTab]?.primary?.light
)}
{renderColorPicker(
currentTab,
'Secondary Main',
'secondary.main',
themeDraft[currentTab]?.secondary?.main
)}
{renderColorPicker(
currentTab,
'Background Default',
'background.default',
themeDraft[currentTab]?.background?.default
)}
{renderColorPicker(
currentTab,
'Background Paper',
'background.paper',
themeDraft[currentTab]?.background?.paper
)}
{renderColorPicker(
currentTab,
'Background Surface',
'background.surface',
themeDraft[currentTab]?.background?.surface
)}
{renderColorPicker(
currentTab,
'Text Primary',
'text.primary',
themeDraft[currentTab]?.text?.primary
)}
{renderColorPicker(
currentTab,
'Text Secondary',
'text.secondary',
themeDraft[currentTab]?.text?.secondary
)}
{renderColorPicker(
currentTab,
'Border Main',
'border.main',
themeDraft[currentTab]?.border?.main
)}
{renderColorPicker(
currentTab,
'Border Subtle',
'border.subtle',
themeDraft[currentTab]?.border?.subtle
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenEditor(false)}>Cancel</Button>
<Button
disabled={!themeDraft.name}
onClick={handleSaveTheme}
variant="contained"
>
Save
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,39 @@
[data-color-mode*='dark'] .w-color-sketch {
--sketch-background: #323232 !important;
}
[data-color-mode*='dark'] .w-color-swatch {
--sketch-swatch-border-top: 1px solid #525252 !important;
}
[data-color-mode*='dark'] .w-color-block {
--block-background-color: #323232 !important;
--block-box-shadow: rgb(0 0 0 / 10%) 0 1px !important;
}
[data-color-mode*='dark'] .w-color-editable-input {
--editable-input-label-color: #757575 !important;
--editable-input-box-shadow: #616161 0px 0px 0px 1px inset !important;
--editable-input-color: #bbb !important;
}
[data-color-mode*='dark'] .w-color-github {
--github-border: 1px solid rgba(0, 0, 0, 0.2) !important;
--github-background-color: #323232 !important;
--github-box-shadow: rgb(0 0 0 / 15%) 0px 3px 12px !important;
--github-arrow-border-color: rgba(0, 0, 0, 0.15) !important;
}
[data-color-mode*='dark'] .w-color-compact {
--compact-background-color: #323232 !important;
}
[data-color-mode*='dark'] .w-color-material {
--material-background-color: #323232 !important;
--material-border-bottom-color: #707070 !important;
}
[data-color-mode*='dark'] .w-color-alpha {
--alpha-pointer-background-color: #6a6a6a !important;
--alpha-pointer-box-shadow: rgb(0 0 0 / 37%) 0px 1px 4px 0px !important;
}