refactor(theme): hoist ThemeProvider to App.tsx root

This commit is contained in:
kcar 2026-05-31 11:00:55 +00:00 committed by CC Worker
parent fedbd903ff
commit 07ceef1294
4 changed files with 98 additions and 95 deletions

View File

@ -1,5 +1,7 @@
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles'; import { useMemo } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { useTLDraw } from './contexts/TLDrawContext';
import { theme } from './services/themeService'; import { theme } from './services/themeService';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { TLDrawProvider } from './contexts/TLDrawContext'; import { TLDrawProvider } from './contexts/TLDrawContext';
@ -8,21 +10,43 @@ import AppRoutes from './AppRoutes';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import React from 'react'; import React from 'react';
const App = React.memo(() => ( const App = React.memo(() => {
<ErrorBoundary> const { tldrawPreferences } = useTLDraw();
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider theme={theme}> const prefersDarkMode =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const appTheme = useMemo(() => {
let mode: 'light' | 'dark';
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
return createTheme({
palette: { mode },
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
return (
<ErrorBoundary>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<AuthProvider> <AuthProvider>
<UserProvider> <UserProvider>
<TLDrawProvider> <TLDrawProvider>
<AppRoutes /> <ThemeProvider theme={appTheme}>
<AppRoutes />
</ThemeProvider>
</TLDrawProvider> </TLDrawProvider>
</UserProvider> </UserProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </BrowserRouter>
</BrowserRouter> </ErrorBoundary>
</ErrorBoundary> );
)); });
App.displayName = import.meta.env.VITE_APP_NAME; App.displayName = import.meta.env.VITE_APP_NAME;

View File

@ -169,8 +169,8 @@ const AppRoutes: React.FC = () => {
</Route> </Route>
)} )}
{/* Fallback route - use different NotFound pages based on auth state */} {/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
<Route path="*" element={user ? <NotFound /> : <NotFoundPublic />} /> <Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
</Routes> </Routes>
</Layout> </Layout>
); );

View File

@ -7,6 +7,7 @@ import {
Typography, Typography,
IconButton, IconButton,
Box, Box,
Chip,
useTheme, useTheme,
Menu, Menu,
MenuItem, MenuItem,
@ -126,6 +127,15 @@ const Header: React.FC = () => {
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{isAuthenticated && bootstrapData?.onboarding?.next_step && bootstrapData.onboarding.next_step !== 'ready' && (
<Chip
label={bootstrapData.onboarding.next_step.replace(/_/g, ' ')}
color="warning"
size="small"
onClick={() => navigate('/dashboard')}
sx={{ cursor: 'pointer', display: { xs: 'none', sm: 'flex' } }}
/>
)}
{isAuthenticated && ( {isAuthenticated && (
<Typography <Typography
variant="body2" variant="body2"

View File

@ -1,16 +1,14 @@
import React, { useEffect, useRef, useMemo } from 'react'; import React, { useEffect, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useTheme } from '@mui/material/styles';
import { TldrawUiButton } from '@tldraw/tldraw'; import { TldrawUiButton } from '@tldraw/tldraw';
import { import {
Button, Button,
Menu, Menu,
MenuItem, MenuItem,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
styled, styled,
ThemeProvider,
createTheme,
useMediaQuery
} from '@mui/material'; } from '@mui/material';
import { import {
PushPin as PushPinIcon, PushPin as PushPinIcon,
@ -24,7 +22,7 @@ import {
Navigation as NavigationIcon, Navigation as NavigationIcon,
Save as NodeIcon, Save as NodeIcon,
Assignment as ExamIcon, Assignment as ExamIcon,
Mic as MicIcon Mic as MicIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { CCShapesPanel } from './CCShapesPanel'; import { CCShapesPanel } from './CCShapesPanel';
import { CCSlidesPanel } from './CCSlidesPanel'; import { CCSlidesPanel } from './CCSlidesPanel';
@ -95,7 +93,7 @@ const PanelTypeButton = styled(Button)(() => ({
'& .MuiSvgIcon-root': { '& .MuiSvgIcon-root': {
fontSize: '1.25rem', fontSize: '1.25rem',
color: 'inherit', color: 'inherit',
} },
})); }));
const StyledMenuItem = styled(MenuItem)(() => ({ const StyledMenuItem = styled(MenuItem)(() => ({
@ -106,7 +104,7 @@ const StyledMenuItem = styled(MenuItem)(() => ({
backgroundColor: 'var(--color-hover)', backgroundColor: 'var(--color-hover)',
'& .MuiListItemIcon-root': { '& .MuiListItemIcon-root': {
color: 'var(--color-selected)', color: 'var(--color-selected)',
} },
}, },
'& .MuiListItemIcon-root': { '& .MuiListItemIcon-root': {
color: 'var(--color-text)', color: 'var(--color-text)',
@ -114,8 +112,8 @@ const StyledMenuItem = styled(MenuItem)(() => ({
transition: 'color 200ms ease', transition: 'color 200ms ease',
'& .MuiSvgIcon-root': { '& .MuiSvgIcon-root': {
fontSize: '1.25rem', fontSize: '1.25rem',
} },
} },
})); }));
export const BasePanel: React.FC<BasePanelProps> = ({ export const BasePanel: React.FC<BasePanelProps> = ({
@ -130,45 +128,35 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}) => { }) => {
const location = useLocation(); const location = useLocation();
const { tldrawPreferences } = useTLDraw(); const { tldrawPreferences } = useTLDraw();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null); const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
// Create a dynamic theme based on TLDraw preferences
const theme = useMemo(() => {
let mode: 'light' | 'dark';
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
return createTheme({ // Mui theme object from root provider
palette: { const computedTheme = useTheme();
mode, const accentColor = useMemo(() => {
}, if (tldrawPreferences?.colorScheme === 'dark') return '#e2e8f0';
}); if (tldrawPreferences?.colorScheme === 'system') return prefersDarkMode ? '#e2e8f0' : '#1e293b';
return '#1e293b';
}, [tldrawPreferences?.colorScheme, prefersDarkMode]); }, [tldrawPreferences?.colorScheme, prefersDarkMode]);
const isExamMarkerRoute = location.pathname === '/exam-marker'; const isExamMarkerRoute = location.pathname === '/exam-marker';
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default; const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>( const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>(
isExamMarkerRoute ? 'exam-marker' : initialPanelType isExamMarkerRoute ? 'exam-marker' : initialPanelType,
); );
// Use controlled state if provided, otherwise use internal state
const [internalIsExpanded, setInternalIsExpanded] = React.useState(true); const [internalIsExpanded, setInternalIsExpanded] = React.useState(true);
const [internalIsPinned, setInternalIsPinned] = React.useState(true); const [internalIsPinned, setInternalIsPinned] = React.useState(true);
const isExpanded = controlledIsExpanded ?? internalIsExpanded; const isExpanded = controlledIsExpanded ?? internalIsExpanded;
const isPinned = controlledIsPinned ?? internalIsPinned; const isPinned = controlledIsPinned ?? internalIsPinned;
const handleExpandedChange = (expanded: boolean) => { const handleExpandedChange = (expanded: boolean) => {
setInternalIsExpanded(expanded); setInternalIsExpanded(expanded);
onExpandedChange?.(expanded); onExpandedChange?.(expanded);
}; };
const handlePinToggle = () => { const handlePinToggle = () => {
const newPinned = !isPinned; const newPinned = !isPinned;
setInternalIsPinned(newPinned); setInternalIsPinned(newPinned);
@ -178,16 +166,11 @@ export const BasePanel: React.FC<BasePanelProps> = ({
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS]; const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS];
// Handle click outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
// Don't close if pinned
if (isPinned) return; if (isPinned) return;
// Check if click is outside panel
const isClickOutside = panelRef.current && !panelRef.current.contains(event.target as Node); const isClickOutside = panelRef.current && !panelRef.current.contains(event.target as Node);
// Check if click is not on a panel-related element
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
const isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button'); const isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button');
@ -288,13 +271,11 @@ export const BasePanel: React.FC<BasePanelProps> = ({
} }
}; };
// Handle menu button click
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => { const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget); setMenuAnchorEl(event.currentTarget);
onMenuOpenChange(true); onMenuOpenChange(true);
}; };
// Handle menu close
const handleMenuClose = () => { const handleMenuClose = () => {
setMenuAnchorEl(null); setMenuAnchorEl(null);
onMenuOpenChange(false); onMenuOpenChange(false);
@ -303,7 +284,7 @@ export const BasePanel: React.FC<BasePanelProps> = ({
return ( return (
<> <>
{!isExpanded && ( {!isExpanded && (
<div <div
className="panel-handle" className="panel-handle"
onClick={() => handleExpandedChange(true)} onClick={() => handleExpandedChange(true)}
onTouchEnd={(e) => { onTouchEnd={(e) => {
@ -316,7 +297,7 @@ export const BasePanel: React.FC<BasePanelProps> = ({
)} )}
{isExpanded && ( {isExpanded && (
<div <div
ref={panelRef} ref={panelRef}
className="panel-root" className="panel-root"
style={{ style={{
@ -327,30 +308,29 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}} }}
> >
<div className="panel-header"> <div className="panel-header">
<ThemeProvider theme={theme}> <PanelTypeButton
<PanelTypeButton onClick={handleMenuClick}
onClick={handleMenuClick} endIcon={<ExpandMoreIcon />}
endIcon={<ExpandMoreIcon />} startIcon={getIconForPanel(currentPanelType)}
startIcon={getIconForPanel(currentPanelType)} >
> {availablePanels.find((p) => p.id === currentPanelType)?.label}
{availablePanels.find(p => p.id === currentPanelType)?.label} </PanelTypeButton>
</PanelTypeButton>
<Menu <Menu
anchorEl={menuAnchorEl} anchorEl={menuAnchorEl}
open={isMenuOpen} open={isMenuOpen}
onClose={handleMenuClose} onClose={handleMenuClose}
PaperProps={{ PaperProps={{
elevation: 8, elevation: 8,
sx: { sx: {
border: '1px solid var(--color-divider)', border: '1px solid var(--color-divider)',
boxShadow: 'var(--shadow-popup)', boxShadow: 'var(--shadow-popup)',
} },
}} }}
> >
{[...availablePanels] {[...availablePanels]
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map(type => ( .map((type) => (
<StyledMenuItem <StyledMenuItem
key={type.id} key={type.id}
onClick={() => { onClick={() => {
@ -359,42 +339,31 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}} }}
selected={currentPanelType === type.id} selected={currentPanelType === type.id}
> >
<ListItemIcon> <ListItemIcon>{getIconForPanel(type.id as PanelType)}</ListItemIcon>
{getIconForPanel(type.id as PanelType)} <ListItemText
</ListItemIcon>
<ListItemText
primary={type.label} primary={type.label}
secondary={getDescriptionForPanel(type.id as PanelType)} secondary={getDescriptionForPanel(type.id as PanelType)}
primaryTypographyProps={{ primaryTypographyProps={{
sx: { color: 'var(--color-text)' } sx: { color: 'var(--color-text)' },
}} }}
secondaryTypographyProps={{ secondaryTypographyProps={{
sx: { color: 'var(--color-text-secondary)' } sx: { color: 'var(--color-text-secondary)' },
}} }}
/> />
</StyledMenuItem> </StyledMenuItem>
))} ))}
</Menu> </Menu>
</ThemeProvider>
<div className="panel-header-actions"> <div className="panel-header-actions">
<TldrawUiButton <TldrawUiButton type="icon" onClick={handlePinToggle} className="pin-button">
type="icon"
onClick={handlePinToggle}
className="pin-button"
>
{isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />} {isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />}
</TldrawUiButton> </TldrawUiButton>
</div> </div>
</div> </div>
<ThemeProvider theme={theme}> <div className="panel-content">{renderCurrentPanel()}</div>
<div className="panel-content">
{renderCurrentPanel()}
</div>
</ThemeProvider>
</div> </div>
)} )}
</> </>
); );
}; };