-
-
Save shricodev/07d46534f0f3e2523ddc2f3e4c814795 to your computer and use it in GitHub Desktop.
Test 1: Add a global Action Palette (Ctrl + K) - gemini-3-pro
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/gemini-3.patch b/gemini-3.patch | |
| new file mode 100644 | |
| index 0000000..787aa4e | |
| --- /dev/null | |
| +++ b/gemini-3.patch | |
| @@ -0,0 +1,195 @@ | |
| +diff --git a/src/components/App.tsx b/src/components/App.tsx | |
| +index a578cc8..9c00492 100644 | |
| +--- a/src/components/App.tsx | |
| ++++ b/src/components/App.tsx | |
| +@@ -1,18 +1,20 @@ | |
| + import { BrowserRouter, useRoutes } from 'react-router-dom'; | |
| + import routesConfig from '../config/routesConfig'; | |
| + import Navbar from './Navbar'; | |
| +-import { Suspense, useState, useEffect } from 'react'; | |
| ++import { Suspense } from 'react'; | |
| + import Loading from './Loading'; | |
| +-import { CssBaseline, Theme, ThemeProvider } from '@mui/material'; | |
| ++import { CssBaseline, ThemeProvider } from '@mui/material'; | |
| + import { CustomSnackBarProvider } from '../contexts/CustomSnackBarContext'; | |
| + import { SnackbarProvider } from 'notistack'; | |
| + import { tools } from '../tools'; | |
| + import './index.css'; | |
| +-import { darkTheme, lightTheme } from '../config/muiConfig'; | |
| + import ScrollToTopButton from './ScrollToTopButton'; | |
| + import { I18nextProvider } from 'react-i18next'; | |
| + import i18n from '../i18n'; | |
| + import { UserTypeFilterProvider } from 'providers/UserTypeFilterProvider'; | |
| ++import { ThemeContextProvider, useThemeContext } from '../contexts/ThemeContext'; | |
| ++ | |
| ++import ActionPalette from './ActionPalette/ActionPalette'; | |
| + | |
| + export type Mode = 'dark' | 'light' | 'system'; | |
| + | |
| +@@ -24,77 +26,44 @@ const AppRoutes = () => { | |
| + return useRoutes(updatedRoutesConfig); | |
| + }; | |
| + | |
| +-function App() { | |
| +- const [mode, setMode] = useState<Mode>( | |
| +- () => (localStorage.getItem('theme') || 'system') as Mode | |
| +- ); | |
| +- const [theme, setTheme] = useState<Theme>(() => getTheme(mode)); | |
| +- useEffect(() => setTheme(getTheme(mode)), [mode]); | |
| +- | |
| +- // Make sure to update the theme when the mode changes | |
| +- useEffect(() => { | |
| +- const systemDarkModeQuery = window.matchMedia( | |
| +- '(prefers-color-scheme: dark)' | |
| +- ); | |
| +- const handleThemeChange = (e: MediaQueryListEvent) => { | |
| +- setTheme(e.matches ? darkTheme : lightTheme); | |
| +- }; | |
| +- systemDarkModeQuery.addEventListener('change', handleThemeChange); | |
| +- | |
| +- return () => { | |
| +- systemDarkModeQuery.removeEventListener('change', handleThemeChange); | |
| +- }; | |
| +- }, []); | |
| ++function AppContent() { | |
| ++ const { theme, mode, toggleMode } = useThemeContext(); | |
| + | |
| + return ( | |
| +- <I18nextProvider i18n={i18n}> | |
| +- <ThemeProvider theme={theme}> | |
| +- <CssBaseline /> | |
| +- <SnackbarProvider | |
| +- maxSnack={5} | |
| +- anchorOrigin={{ | |
| +- vertical: 'bottom', | |
| +- horizontal: 'right' | |
| +- }} | |
| +- > | |
| +- <CustomSnackBarProvider> | |
| +- <UserTypeFilterProvider> | |
| +- <BrowserRouter> | |
| +- <Navbar | |
| +- mode={mode} | |
| +- onChangeMode={() => { | |
| +- setMode((prev) => nextMode(prev)); | |
| +- localStorage.setItem('theme', nextMode(mode)); | |
| +- }} | |
| +- /> | |
| +- <Suspense fallback={<Loading />}> | |
| +- <AppRoutes /> | |
| +- </Suspense> | |
| +- </BrowserRouter> | |
| +- </UserTypeFilterProvider> | |
| +- </CustomSnackBarProvider> | |
| +- </SnackbarProvider> | |
| ++ <ThemeProvider theme={theme}> | |
| ++ <CssBaseline /> | |
| ++ <SnackbarProvider | |
| ++ maxSnack={5} | |
| ++ anchorOrigin={{ | |
| ++ vertical: 'bottom', | |
| ++ horizontal: 'right' | |
| ++ }} | |
| ++ > | |
| ++ <BrowserRouter> | |
| ++ <Navbar /> | |
| ++ <ActionPalette /> | |
| ++ <Suspense fallback={<Loading />}> | |
| ++ <AppRoutes /> | |
| ++ </Suspense> | |
| ++ </BrowserRouter> | |
| + <ScrollToTopButton /> | |
| +- </ThemeProvider> | |
| +- </I18nextProvider> | |
| ++ </SnackbarProvider> | |
| ++ </ThemeProvider> | |
| + ); | |
| + } | |
| + | |
| +-function getTheme(mode: Mode): Theme { | |
| +- switch (mode) { | |
| +- case 'dark': | |
| +- return darkTheme; | |
| +- case 'light': | |
| +- return lightTheme; | |
| +- default: | |
| +- return window.matchMedia('(prefers-color-scheme: dark)').matches | |
| +- ? darkTheme | |
| +- : lightTheme; | |
| +- } | |
| +-} | |
| +- | |
| +-function nextMode(mode: Mode): Mode { | |
| +- return mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'; | |
| ++function App() { | |
| ++ return ( | |
| ++ <I18nextProvider i18n={i18n}> | |
| ++ <CustomSnackBarProvider> | |
| ++ <UserTypeFilterProvider> | |
| ++ <ThemeContextProvider> | |
| ++ <AppContent /> | |
| ++ </ThemeContextProvider> | |
| ++ </UserTypeFilterProvider> | |
| ++ </CustomSnackBarProvider> | |
| ++ </I18nextProvider> | |
| ++ ); | |
| + } | |
| + | |
| + export default App; | |
| +diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx | |
| +index dbacb3a..0d6b3d5 100644 | |
| +--- a/src/components/Navbar/index.tsx | |
| ++++ b/src/components/Navbar/index.tsx | |
| +@@ -21,13 +21,10 @@ import { | |
| + import useMediaQuery from '@mui/material/useMediaQuery'; | |
| + import { useTheme } from '@mui/material/styles'; | |
| + import { Icon } from '@iconify/react'; | |
| +-import { Mode } from 'components/App'; | |
| + import { useTranslation } from 'react-i18next'; | |
| ++import { useThemeContext } from '../../contexts/ThemeContext'; | |
| + | |
| +-interface NavbarProps { | |
| +- mode: Mode; | |
| +- onChangeMode: () => void; | |
| +-} | |
| ++interface NavbarProps {} | |
| + const languages = [ | |
| + { code: 'en', label: 'English' }, | |
| + { code: 'de', label: 'Deutsch' }, | |
| +@@ -41,13 +38,11 @@ const languages = [ | |
| + { code: 'zh', label: '中文' } | |
| + ]; | |
| + | |
| +-const Navbar: React.FC<NavbarProps> = ({ | |
| +- mode, | |
| +- onChangeMode: onChangeMode | |
| +-}) => { | |
| ++const Navbar: React.FC<NavbarProps> = () => { | |
| + const { t, i18n } = useTranslation(); | |
| + const navigate = useNavigate(); | |
| + const theme = useTheme(); | |
| ++ const { mode, toggleMode } = useThemeContext(); | |
| + const isMobile = useMediaQuery(theme.breakpoints.down('md')); | |
| + const [drawerOpen, setDrawerOpen] = useState(false); | |
| + const toggleDrawer = (open: boolean) => () => { | |
| +@@ -100,7 +95,7 @@ const Navbar: React.FC<NavbarProps> = ({ | |
| + languageSelector, | |
| + <Icon | |
| + key={mode} | |
| +- onClick={onChangeMode} | |
| ++ onClick={toggleMode} | |
| + style={{ cursor: 'pointer' }} | |
| + fontSize={30} | |
| + icon={ | |
| +diff --git a/src/tools/index.ts b/src/tools/index.ts | |
| +index 1dbeb2f..b1bd6f9 100644 | |
| +--- a/src/tools/index.ts | |
| ++++ b/src/tools/index.ts | |
| +@@ -1,6 +1,7 @@ | |
| + import { stringTools } from '../pages/tools/string'; | |
| + import { imageTools } from '../pages/tools/image'; | |
| + import { DefinedTool, ToolCategory, UserType } from './defineTool'; | |
| ++export type { DefinedTool, ToolCategory, UserType }; | |
| + import { capitalizeFirstLetter } from '@utils/string'; | |
| + import { numberTools } from '../pages/tools/number'; | |
| + import { videoTools } from '../pages/tools/video'; | |
| diff --git a/src/components/ActionPalette/ActionPalette.tsx b/src/components/ActionPalette/ActionPalette.tsx | |
| new file mode 100644 | |
| index 0000000..fa96730 | |
| --- /dev/null | |
| +++ b/src/components/ActionPalette/ActionPalette.tsx | |
| @@ -0,0 +1,373 @@ | |
| +import React, { useState, useEffect, useMemo, useRef } from 'react'; | |
| +import { | |
| + Dialog, | |
| + DialogContent, | |
| + InputBase, | |
| + List, | |
| + ListItemButton, | |
| + ListItemIcon, | |
| + ListItemText, | |
| + Typography, | |
| + Box, | |
| + useTheme, | |
| + alpha, | |
| + Chip | |
| +} from '@mui/material'; | |
| +import { Icon } from '@iconify/react'; | |
| +import { useNavigate } from 'react-router-dom'; | |
| +import { useTranslation } from 'react-i18next'; | |
| +import { useThemeContext } from '../../contexts/ThemeContext'; | |
| +import { useUserTypeFilter } from '../../providers/UserTypeFilterProvider'; | |
| +import { tools, filterTools, DefinedTool } from '../../tools'; | |
| +import { I18nNamespaces } from '../../i18n'; | |
| + | |
| +interface Action { | |
| + id: string; | |
| + name: string; | |
| + icon: string; | |
| + action: () => void; | |
| + section: 'Actions' | 'Tools' | 'Recent'; | |
| +} | |
| + | |
| +const RECENT_TOOLS_KEY = 'omni-tools-recent'; | |
| +const MAX_RECENT_TOOLS = 5; | |
| + | |
| +export default function ActionPalette() { | |
| + const [open, setOpen] = useState(false); | |
| + const [query, setQuery] = useState(''); | |
| + const [selectedIndex, setSelectedIndex] = useState(0); | |
| + const [recentToolPaths, setRecentToolPaths] = useState<string[]>(() => { | |
| + try { | |
| + const saved = localStorage.getItem(RECENT_TOOLS_KEY); | |
| + return saved ? JSON.parse(saved) : []; | |
| + } catch { | |
| + return []; | |
| + } | |
| + }); | |
| + | |
| + const navigate = useNavigate(); | |
| + const { t } = useTranslation<I18nNamespaces[]>(); | |
| + const theme = useTheme(); | |
| + const { toggleMode, mode } = useThemeContext(); | |
| + const { selectedUserTypes, setSelectedUserTypes } = useUserTypeFilter(); | |
| + | |
| + const inputRef = useRef<HTMLInputElement>(null); | |
| + | |
| + useEffect(() => { | |
| + const handleKeyDown = (e: KeyboardEvent) => { | |
| + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { | |
| + e.preventDefault(); | |
| + setOpen((prev) => !prev); | |
| + } | |
| + }; | |
| + | |
| + window.addEventListener('keydown', handleKeyDown); | |
| + return () => window.removeEventListener('keydown', handleKeyDown); | |
| + }, []); | |
| + | |
| + useEffect(() => { | |
| + if (open) { | |
| + setQuery(''); | |
| + setSelectedIndex(0); | |
| + // Focus input is handled by autoFocus prop on InputBase, | |
| + // but sometimes we need to force it after transition | |
| + setTimeout(() => inputRef.current?.focus(), 50); | |
| + } | |
| + }, [open]); | |
| + | |
| + const addToRecent = (path: string) => { | |
| + const newRecent = [ | |
| + path, | |
| + ...recentToolPaths.filter((p) => p !== path) | |
| + ].slice(0, MAX_RECENT_TOOLS); | |
| + setRecentToolPaths(newRecent); | |
| + localStorage.setItem(RECENT_TOOLS_KEY, JSON.stringify(newRecent)); | |
| + }; | |
| + | |
| + const clearRecent = () => { | |
| + setRecentToolPaths([]); | |
| + localStorage.removeItem(RECENT_TOOLS_KEY); | |
| + }; | |
| + | |
| + const filteredTools = useMemo(() => { | |
| + return filterTools(tools, query, selectedUserTypes, t); | |
| + }, [query, selectedUserTypes, t]); | |
| + | |
| + const recentTools = useMemo(() => { | |
| + if (query) return []; // Don't show recent if searching | |
| + return recentToolPaths | |
| + .map((path) => tools.find((t) => t.path === path)) | |
| + .filter((t): t is DefinedTool => !!t); | |
| + }, [recentToolPaths, query]); | |
| + | |
| + const actions: Action[] = useMemo(() => { | |
| + const list: Action[] = []; | |
| + | |
| + // System Actions | |
| + if (!query || 'dark mode'.includes(query.toLowerCase())) { | |
| + list.push({ | |
| + id: 'toggle-theme', | |
| + name: mode === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode', | |
| + icon: mode === 'dark' ? 'ic:round-light-mode' : 'ic:round-dark-mode', | |
| + action: toggleMode, | |
| + section: 'Actions' | |
| + }); | |
| + } | |
| + | |
| + // Toggle User Type | |
| + const isDev = selectedUserTypes.includes('developers'); | |
| + if ( | |
| + !query || | |
| + 'developer mode'.includes(query.toLowerCase()) || | |
| + 'user type'.includes(query.toLowerCase()) | |
| + ) { | |
| + list.push({ | |
| + id: 'toggle-user-type', | |
| + name: isDev ? 'Switch to General User' : 'Switch to Developer Mode', | |
| + icon: isDev ? 'ph:user' : 'ph:code', | |
| + action: () => { | |
| + setSelectedUserTypes(isDev ? ['generalUsers'] : ['developers']); | |
| + }, | |
| + section: 'Actions' | |
| + }); | |
| + } | |
| + | |
| + if (!query || 'home'.includes(query.toLowerCase())) { | |
| + list.push({ | |
| + id: 'go-home', | |
| + name: 'Go to Home', | |
| + icon: 'solar:home-2-outline', | |
| + action: () => navigate('/'), | |
| + section: 'Actions' | |
| + }); | |
| + } | |
| + | |
| + if ( | |
| + (!query && recentToolPaths.length > 0) || | |
| + 'clear recent'.includes(query.toLowerCase()) | |
| + ) { | |
| + list.push({ | |
| + id: 'clear-recent', | |
| + name: 'Clear Recently Used Tools', | |
| + icon: 'solar:trash-bin-trash-outline', | |
| + action: clearRecent, | |
| + section: 'Actions' | |
| + }); | |
| + } | |
| + | |
| + return list; | |
| + }, [ | |
| + query, | |
| + mode, | |
| + toggleMode, | |
| + selectedUserTypes, | |
| + setSelectedUserTypes, | |
| + navigate, | |
| + recentToolPaths.length | |
| + ]); | |
| + | |
| + const allItems = useMemo(() => { | |
| + const items: (Action | DefinedTool)[] = []; | |
| + | |
| + // 1. Recent Tools | |
| + recentTools.forEach((tool) => items.push(tool)); | |
| + | |
| + // 2. Actions | |
| + actions.forEach((action) => items.push(action)); | |
| + | |
| + // 3. Filtered Tools (exclude if already in recent and query is empty, otherwise show all matches) | |
| + // Actually, simple approach: show matches. If query is empty, we show all tools? No, that's too many. | |
| + // If query is empty, we only show Recent + Actions. | |
| + // If query is NOT empty, we show Actions + Filtered Tools. | |
| + | |
| + if (query) { | |
| + filteredTools.forEach((tool) => items.push(tool)); | |
| + } | |
| + | |
| + return items; | |
| + }, [recentTools, actions, filteredTools, query]); | |
| + | |
| + // Adjust selection index when items change | |
| + useEffect(() => { | |
| + setSelectedIndex(0); | |
| + }, [allItems.length, query]); | |
| + | |
| + const handleSelect = (item: Action | DefinedTool) => { | |
| + if ('section' in item) { | |
| + // It's an Action | |
| + (item as Action).action(); | |
| + // Close only if it's not a toggle that we might want to see result of? | |
| + // Usually palettes close on action. | |
| + setOpen(false); | |
| + } else { | |
| + // It's a Tool | |
| + addToRecent(item.path); | |
| + navigate(item.path); | |
| + setOpen(false); | |
| + } | |
| + }; | |
| + | |
| + const handleKeyDown = (e: React.KeyboardEvent) => { | |
| + if (e.key === 'ArrowDown') { | |
| + e.preventDefault(); | |
| + setSelectedIndex((prev) => Math.min(prev + 1, allItems.length - 1)); | |
| + } else if (e.key === 'ArrowUp') { | |
| + e.preventDefault(); | |
| + setSelectedIndex((prev) => Math.max(prev - 1, 0)); | |
| + } else if (e.key === 'Enter') { | |
| + e.preventDefault(); | |
| + if (allItems[selectedIndex]) { | |
| + handleSelect(allItems[selectedIndex]); | |
| + } | |
| + } else if (e.key === 'Escape') { | |
| + // e.preventDefault(); // Default dialog behavior handles escape, but let's be explicit if needed | |
| + // setOpen(false); | |
| + } | |
| + }; | |
| + | |
| + // Group items for rendering with headers | |
| + // This is a bit complex for a flat list logic, so I'll just render flat list with conditional headers? | |
| + // Or just render flat list. Visual headers are nice. | |
| + | |
| + const renderItem = (item: Action | DefinedTool, index: number) => { | |
| + const isSelected = index === selectedIndex; | |
| + const isAction = 'section' in item; | |
| + | |
| + return ( | |
| + <ListItemButton | |
| + key={isAction ? (item as Action).id : (item as DefinedTool).path} | |
| + selected={isSelected} | |
| + onClick={() => handleSelect(item)} | |
| + onMouseEnter={() => setSelectedIndex(index)} | |
| + ref={ | |
| + isSelected ? (el) => el?.scrollIntoView({ block: 'nearest' }) : null | |
| + } | |
| + sx={{ | |
| + borderRadius: 1, | |
| + mb: 0.5, | |
| + '&.Mui-selected': { | |
| + backgroundColor: alpha(theme.palette.primary.main, 0.15), | |
| + borderLeft: `3px solid ${theme.palette.primary.main}`, | |
| + '&:hover': { | |
| + backgroundColor: alpha(theme.palette.primary.main, 0.25) | |
| + } | |
| + } | |
| + }} | |
| + > | |
| + <ListItemIcon sx={{ minWidth: 40 }}> | |
| + <Icon icon={item.icon} fontSize={24} /> | |
| + </ListItemIcon> | |
| + <ListItemText | |
| + primary={ | |
| + isAction ? (item as Action).name : t((item as DefinedTool).name) | |
| + } | |
| + secondary={ | |
| + !isAction ? t((item as DefinedTool).shortDescription) : undefined | |
| + } | |
| + primaryTypographyProps={{ | |
| + variant: 'body1', | |
| + fontWeight: isSelected ? 'bold' : 'normal' | |
| + }} | |
| + secondaryTypographyProps={{ variant: 'caption', noWrap: true }} | |
| + /> | |
| + {isAction && (item as Action).section === 'Recent' && ( | |
| + <Chip | |
| + label="Recent" | |
| + size="small" | |
| + variant="outlined" | |
| + sx={{ height: 20, fontSize: '0.6rem' }} | |
| + /> | |
| + )} | |
| + </ListItemButton> | |
| + ); | |
| + }; | |
| + | |
| + return ( | |
| + <Dialog | |
| + open={open} | |
| + onClose={() => setOpen(false)} | |
| + fullWidth | |
| + maxWidth="sm" | |
| + PaperProps={{ | |
| + sx: { | |
| + position: 'fixed', | |
| + top: 50, | |
| + m: 0, | |
| + borderRadius: 3, | |
| + backgroundColor: theme.palette.background.paper, | |
| + backgroundImage: 'none', | |
| + boxShadow: theme.shadows[10] | |
| + } | |
| + }} | |
| + transitionDuration={0} | |
| + > | |
| + <Box sx={{ p: 2, pb: 0 }}> | |
| + <Box | |
| + sx={{ | |
| + display: 'flex', | |
| + alignItems: 'center', | |
| + backgroundColor: alpha(theme.palette.text.primary, 0.05), | |
| + borderRadius: 1, | |
| + px: 2, | |
| + py: 1 | |
| + }} | |
| + > | |
| + <Icon | |
| + icon="solar:magnifer-linear" | |
| + fontSize={24} | |
| + style={{ opacity: 0.5, marginRight: 8 }} | |
| + /> | |
| + <InputBase | |
| + inputRef={inputRef} | |
| + fullWidth | |
| + placeholder="Type a command or search..." | |
| + value={query} | |
| + onChange={(e) => setQuery(e.target.value)} | |
| + onKeyDown={handleKeyDown} | |
| + autoFocus | |
| + sx={{ fontSize: '1.1rem' }} | |
| + /> | |
| + <Box sx={{ display: 'flex', gap: 0.5 }}> | |
| + <Chip | |
| + label="ESC" | |
| + size="small" | |
| + sx={{ height: 20, fontSize: '0.6rem', opacity: 0.7 }} | |
| + /> | |
| + </Box> | |
| + </Box> | |
| + </Box> | |
| + | |
| + <DialogContent | |
| + sx={{ p: 2, minHeight: 300, maxHeight: 500, overflowY: 'auto' }} | |
| + > | |
| + <List disablePadding> | |
| + {allItems.length === 0 ? ( | |
| + <Box sx={{ textAlign: 'center', py: 5, opacity: 0.5 }}> | |
| + <Typography>No results found</Typography> | |
| + </Box> | |
| + ) : ( | |
| + allItems.map((item, index) => renderItem(item, index)) | |
| + )} | |
| + </List> | |
| + <Box | |
| + sx={{ | |
| + mt: 2, | |
| + pt: 2, | |
| + borderTop: `1px solid ${theme.palette.divider}`, | |
| + display: 'flex', | |
| + justifyContent: 'space-between', | |
| + opacity: 0.5 | |
| + }} | |
| + > | |
| + <Typography variant="caption"> | |
| + <Icon icon="solar:arrow-up-linear" inline />{' '} | |
| + <Icon icon="solar:arrow-down-linear" inline /> to navigate | |
| + </Typography> | |
| + <Typography variant="caption"> | |
| + <Icon icon="solar:enter-linear" inline /> to select | |
| + </Typography> | |
| + </Box> | |
| + </DialogContent> | |
| + </Dialog> | |
| + ); | |
| +} | |
| diff --git a/src/components/App.tsx b/src/components/App.tsx | |
| index a578cc8..63c3ac8 100644 | |
| --- a/src/components/App.tsx | |
| +++ b/src/components/App.tsx | |
| @@ -1,18 +1,23 @@ | |
| import { BrowserRouter, useRoutes } from 'react-router-dom'; | |
| import routesConfig from '../config/routesConfig'; | |
| import Navbar from './Navbar'; | |
| -import { Suspense, useState, useEffect } from 'react'; | |
| +import { Suspense } from 'react'; | |
| import Loading from './Loading'; | |
| -import { CssBaseline, Theme, ThemeProvider } from '@mui/material'; | |
| +import { CssBaseline, ThemeProvider } from '@mui/material'; | |
| import { CustomSnackBarProvider } from '../contexts/CustomSnackBarContext'; | |
| import { SnackbarProvider } from 'notistack'; | |
| import { tools } from '../tools'; | |
| import './index.css'; | |
| -import { darkTheme, lightTheme } from '../config/muiConfig'; | |
| import ScrollToTopButton from './ScrollToTopButton'; | |
| import { I18nextProvider } from 'react-i18next'; | |
| import i18n from '../i18n'; | |
| import { UserTypeFilterProvider } from 'providers/UserTypeFilterProvider'; | |
| +import { | |
| + ThemeContextProvider, | |
| + useThemeContext | |
| +} from '../contexts/ThemeContext'; | |
| + | |
| +import ActionPalette from './ActionPalette/ActionPalette'; | |
| export type Mode = 'dark' | 'light' | 'system'; | |
| @@ -24,77 +29,44 @@ const AppRoutes = () => { | |
| return useRoutes(updatedRoutesConfig); | |
| }; | |
| -function App() { | |
| - const [mode, setMode] = useState<Mode>( | |
| - () => (localStorage.getItem('theme') || 'system') as Mode | |
| - ); | |
| - const [theme, setTheme] = useState<Theme>(() => getTheme(mode)); | |
| - useEffect(() => setTheme(getTheme(mode)), [mode]); | |
| - | |
| - // Make sure to update the theme when the mode changes | |
| - useEffect(() => { | |
| - const systemDarkModeQuery = window.matchMedia( | |
| - '(prefers-color-scheme: dark)' | |
| - ); | |
| - const handleThemeChange = (e: MediaQueryListEvent) => { | |
| - setTheme(e.matches ? darkTheme : lightTheme); | |
| - }; | |
| - systemDarkModeQuery.addEventListener('change', handleThemeChange); | |
| - | |
| - return () => { | |
| - systemDarkModeQuery.removeEventListener('change', handleThemeChange); | |
| - }; | |
| - }, []); | |
| +function AppContent() { | |
| + const { theme, mode, toggleMode } = useThemeContext(); | |
| return ( | |
| - <I18nextProvider i18n={i18n}> | |
| - <ThemeProvider theme={theme}> | |
| - <CssBaseline /> | |
| - <SnackbarProvider | |
| - maxSnack={5} | |
| - anchorOrigin={{ | |
| - vertical: 'bottom', | |
| - horizontal: 'right' | |
| - }} | |
| - > | |
| - <CustomSnackBarProvider> | |
| - <UserTypeFilterProvider> | |
| - <BrowserRouter> | |
| - <Navbar | |
| - mode={mode} | |
| - onChangeMode={() => { | |
| - setMode((prev) => nextMode(prev)); | |
| - localStorage.setItem('theme', nextMode(mode)); | |
| - }} | |
| - /> | |
| - <Suspense fallback={<Loading />}> | |
| - <AppRoutes /> | |
| - </Suspense> | |
| - </BrowserRouter> | |
| - </UserTypeFilterProvider> | |
| - </CustomSnackBarProvider> | |
| - </SnackbarProvider> | |
| + <ThemeProvider theme={theme}> | |
| + <CssBaseline /> | |
| + <SnackbarProvider | |
| + maxSnack={5} | |
| + anchorOrigin={{ | |
| + vertical: 'bottom', | |
| + horizontal: 'right' | |
| + }} | |
| + > | |
| + <BrowserRouter> | |
| + <Navbar /> | |
| + <ActionPalette /> | |
| + <Suspense fallback={<Loading />}> | |
| + <AppRoutes /> | |
| + </Suspense> | |
| + </BrowserRouter> | |
| <ScrollToTopButton /> | |
| - </ThemeProvider> | |
| - </I18nextProvider> | |
| + </SnackbarProvider> | |
| + </ThemeProvider> | |
| ); | |
| } | |
| -function getTheme(mode: Mode): Theme { | |
| - switch (mode) { | |
| - case 'dark': | |
| - return darkTheme; | |
| - case 'light': | |
| - return lightTheme; | |
| - default: | |
| - return window.matchMedia('(prefers-color-scheme: dark)').matches | |
| - ? darkTheme | |
| - : lightTheme; | |
| - } | |
| -} | |
| - | |
| -function nextMode(mode: Mode): Mode { | |
| - return mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'; | |
| +function App() { | |
| + return ( | |
| + <I18nextProvider i18n={i18n}> | |
| + <CustomSnackBarProvider> | |
| + <UserTypeFilterProvider> | |
| + <ThemeContextProvider> | |
| + <AppContent /> | |
| + </ThemeContextProvider> | |
| + </UserTypeFilterProvider> | |
| + </CustomSnackBarProvider> | |
| + </I18nextProvider> | |
| + ); | |
| } | |
| export default App; | |
| diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx | |
| index dbacb3a..0d6b3d5 100644 | |
| --- a/src/components/Navbar/index.tsx | |
| +++ b/src/components/Navbar/index.tsx | |
| @@ -21,13 +21,10 @@ import { | |
| import useMediaQuery from '@mui/material/useMediaQuery'; | |
| import { useTheme } from '@mui/material/styles'; | |
| import { Icon } from '@iconify/react'; | |
| -import { Mode } from 'components/App'; | |
| import { useTranslation } from 'react-i18next'; | |
| +import { useThemeContext } from '../../contexts/ThemeContext'; | |
| -interface NavbarProps { | |
| - mode: Mode; | |
| - onChangeMode: () => void; | |
| -} | |
| +interface NavbarProps {} | |
| const languages = [ | |
| { code: 'en', label: 'English' }, | |
| { code: 'de', label: 'Deutsch' }, | |
| @@ -41,13 +38,11 @@ const languages = [ | |
| { code: 'zh', label: '中文' } | |
| ]; | |
| -const Navbar: React.FC<NavbarProps> = ({ | |
| - mode, | |
| - onChangeMode: onChangeMode | |
| -}) => { | |
| +const Navbar: React.FC<NavbarProps> = () => { | |
| const { t, i18n } = useTranslation(); | |
| const navigate = useNavigate(); | |
| const theme = useTheme(); | |
| + const { mode, toggleMode } = useThemeContext(); | |
| const isMobile = useMediaQuery(theme.breakpoints.down('md')); | |
| const [drawerOpen, setDrawerOpen] = useState(false); | |
| const toggleDrawer = (open: boolean) => () => { | |
| @@ -100,7 +95,7 @@ const Navbar: React.FC<NavbarProps> = ({ | |
| languageSelector, | |
| <Icon | |
| key={mode} | |
| - onClick={onChangeMode} | |
| + onClick={toggleMode} | |
| style={{ cursor: 'pointer' }} | |
| fontSize={30} | |
| icon={ | |
| diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx | |
| new file mode 100644 | |
| index 0000000..46317e5 | |
| --- /dev/null | |
| +++ b/src/contexts/ThemeContext.tsx | |
| @@ -0,0 +1,88 @@ | |
| +import React, { | |
| + createContext, | |
| + useContext, | |
| + useEffect, | |
| + useState, | |
| + ReactNode | |
| +} from 'react'; | |
| +import { Theme } from '@mui/material'; | |
| +import { darkTheme, lightTheme } from '../config/muiConfig'; | |
| + | |
| +export type Mode = 'dark' | 'light' | 'system'; | |
| + | |
| +interface ThemeContextType { | |
| + mode: Mode; | |
| + theme: Theme; | |
| + setMode: (mode: Mode) => void; | |
| + toggleMode: () => void; | |
| +} | |
| + | |
| +const ThemeContext = createContext<ThemeContextType | undefined>(undefined); | |
| + | |
| +export const useThemeContext = () => { | |
| + const context = useContext(ThemeContext); | |
| + if (!context) { | |
| + throw new Error( | |
| + 'useThemeContext must be used within a ThemeContextProvider' | |
| + ); | |
| + } | |
| + return context; | |
| +}; | |
| + | |
| +export const ThemeContextProvider: React.FC<{ children: ReactNode }> = ({ | |
| + children | |
| +}) => { | |
| + const [mode, setMode] = useState<Mode>( | |
| + () => (localStorage.getItem('theme') || 'system') as Mode | |
| + ); | |
| + | |
| + const [theme, setTheme] = useState<Theme>(() => getTheme(mode)); | |
| + | |
| + useEffect(() => { | |
| + localStorage.setItem('theme', mode); | |
| + setTheme(getTheme(mode)); | |
| + }, [mode]); | |
| + | |
| + useEffect(() => { | |
| + const systemDarkModeQuery = window.matchMedia( | |
| + '(prefers-color-scheme: dark)' | |
| + ); | |
| + const handleThemeChange = (e: MediaQueryListEvent) => { | |
| + if (mode === 'system') { | |
| + setTheme(e.matches ? darkTheme : lightTheme); | |
| + } | |
| + }; | |
| + | |
| + systemDarkModeQuery.addEventListener('change', handleThemeChange); | |
| + return () => { | |
| + systemDarkModeQuery.removeEventListener('change', handleThemeChange); | |
| + }; | |
| + }, [mode]); | |
| + | |
| + const toggleMode = () => { | |
| + setMode((prev) => nextMode(prev)); | |
| + }; | |
| + | |
| + return ( | |
| + <ThemeContext.Provider value={{ mode, theme, setMode, toggleMode }}> | |
| + {children} | |
| + </ThemeContext.Provider> | |
| + ); | |
| +}; | |
| + | |
| +function getTheme(mode: Mode): Theme { | |
| + switch (mode) { | |
| + case 'dark': | |
| + return darkTheme; | |
| + case 'light': | |
| + return lightTheme; | |
| + default: | |
| + return window.matchMedia('(prefers-color-scheme: dark)').matches | |
| + ? darkTheme | |
| + : lightTheme; | |
| + } | |
| +} | |
| + | |
| +function nextMode(mode: Mode): Mode { | |
| + return mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'; | |
| +} | |
| diff --git a/src/tools/index.ts b/src/tools/index.ts | |
| index 1dbeb2f..b1bd6f9 100644 | |
| --- a/src/tools/index.ts | |
| +++ b/src/tools/index.ts | |
| @@ -1,6 +1,7 @@ | |
| import { stringTools } from '../pages/tools/string'; | |
| import { imageTools } from '../pages/tools/image'; | |
| import { DefinedTool, ToolCategory, UserType } from './defineTool'; | |
| +export type { DefinedTool, ToolCategory, UserType }; | |
| import { capitalizeFirstLetter } from '@utils/string'; | |
| import { numberTools } from '../pages/tools/number'; | |
| import { videoTools } from '../pages/tools/video'; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment