Skip to content

Instantly share code, notes, and snippets.

@shricodev
Last active January 18, 2026 15:35
Show Gist options
  • Select an option

  • Save shricodev/07d46534f0f3e2523ddc2f3e4c814795 to your computer and use it in GitHub Desktop.

Select an option

Save shricodev/07d46534f0f3e2523ddc2f3e4c814795 to your computer and use it in GitHub Desktop.
Test 1: Add a global Action Palette (Ctrl + K) - gemini-3-pro
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