base commit
This commit is contained in:
@@ -2,7 +2,9 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { MainLayout } from '@/app/layout/MainLayout'
|
||||
import { AppProviders } from '@/app/providers/AppProviders'
|
||||
import { AdminPage } from '@/pages/admin'
|
||||
import { AuthPage } from '@/pages/auth'
|
||||
import { HomePage } from '@/pages/home'
|
||||
import { MePage } from '@/pages/me/ui/MePage'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -12,6 +14,8 @@ export function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
|
||||
@@ -1,14 +1,65 @@
|
||||
import { type PropsWithChildren } from 'react'
|
||||
import { type PropsWithChildren, useState } from 'react'
|
||||
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
|
||||
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
|
||||
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
|
||||
import AppBar from '@mui/material/AppBar'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Container from '@mui/material/Container'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Menu from '@mui/material/Menu'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Select from '@mui/material/Select'
|
||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
||||
import { useUnit } from 'effector-react'
|
||||
import type { ColorScheme } from '@/app/providers/theme-controller'
|
||||
import { useThemeController } from '@/app/providers/theme-controller'
|
||||
import { STORE_NAME } from '@/shared/config'
|
||||
import { $user, logout, tokenSet } from '@/shared/model/auth'
|
||||
|
||||
export function MainLayout({ children }: PropsWithChildren) {
|
||||
const { mode, resolvedMode, scheme, setMode, setScheme, cycleMode } = useThemeController()
|
||||
const user = useUnit($user)
|
||||
const navigate = useNavigate()
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const menuOpen = Boolean(anchorEl)
|
||||
|
||||
const onSchemeChange = (e: SelectChangeEvent<string>) => {
|
||||
setScheme(e.target.value as ColorScheme)
|
||||
}
|
||||
|
||||
const onModeChange = (e: SelectChangeEvent<string>) => {
|
||||
const v = e.target.value
|
||||
if (v === 'system' || v === 'light' || v === 'dark') setMode(v)
|
||||
}
|
||||
|
||||
const openUserMenu = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget)
|
||||
const closeUserMenu = () => setAnchorEl(null)
|
||||
|
||||
const onLogout = () => {
|
||||
tokenSet(null)
|
||||
logout()
|
||||
closeUserMenu()
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
const goToAuth = () => {
|
||||
closeUserMenu()
|
||||
navigate('/auth')
|
||||
}
|
||||
|
||||
const goToMe = () => {
|
||||
closeUserMenu()
|
||||
navigate('/me')
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
@@ -27,6 +78,107 @@ export function MainLayout({ children }: PropsWithChildren) {
|
||||
<Button component={RouterLink} to="/admin" color="inherit">
|
||||
Админка
|
||||
</Button>
|
||||
|
||||
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
|
||||
<Badge
|
||||
variant="dot"
|
||||
color="success"
|
||||
overlap="circular"
|
||||
invisible={!user}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<AccountCircleOutlinedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={menuOpen}
|
||||
onClose={closeUserMenu}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
{user ? (
|
||||
<>
|
||||
<MenuItem onClick={goToMe}>
|
||||
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onLogout}>Выход</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem onClick={goToAuth}>Войти / регистрация</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 2,
|
||||
minWidth: 160,
|
||||
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
|
||||
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
|
||||
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
|
||||
}}
|
||||
>
|
||||
<InputLabel id="scheme-label">Тема</InputLabel>
|
||||
<Select
|
||||
labelId="scheme-label"
|
||||
value={scheme}
|
||||
label="Тема"
|
||||
onChange={onSchemeChange}
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
|
||||
'.MuiSvgIcon-root': { color: 'inherit' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="craft">Крафт</MenuItem>
|
||||
<MenuItem value="forest">Лес</MenuItem>
|
||||
<MenuItem value="ocean">Океан</MenuItem>
|
||||
<MenuItem value="berry">Ягоды</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 2,
|
||||
minWidth: 150,
|
||||
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
|
||||
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
|
||||
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
|
||||
}}
|
||||
>
|
||||
<InputLabel id="mode-label">Режим</InputLabel>
|
||||
<Select
|
||||
labelId="mode-label"
|
||||
value={mode}
|
||||
label="Режим"
|
||||
onChange={onModeChange}
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
|
||||
'.MuiSvgIcon-root': { color: 'inherit' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="system">Авто (система)</MenuItem>
|
||||
<MenuItem value="light">Светлая</MenuItem>
|
||||
<MenuItem value="dark">Тёмная</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={cycleMode}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="Переключить режим темы (авто/светлая/тёмная)"
|
||||
title={`Сейчас: ${mode === 'system' ? `авто (${resolvedMode})` : mode}`}
|
||||
>
|
||||
{resolvedMode === 'dark' ? <LightModeOutlinedIcon /> : <DarkModeOutlinedIcon />}
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box component="main" sx={{ flex: 1, py: 3 }}>
|
||||
|
||||
@@ -2,6 +2,103 @@ import { type PropsWithChildren, useMemo } from 'react'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller'
|
||||
|
||||
function AppThemeInner({ children }: PropsWithChildren) {
|
||||
const controller = useThemeController()
|
||||
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: (() => {
|
||||
const isDark = controller.resolvedMode === 'dark'
|
||||
const common = { mode: controller.resolvedMode }
|
||||
|
||||
const text = isDark
|
||||
? { primary: '#F2F2F2', secondary: 'rgba(242,242,242,0.72)', disabled: 'rgba(242,242,242,0.48)' }
|
||||
: { primary: '#1F1B16', secondary: 'rgba(31,27,22,0.72)', disabled: 'rgba(31,27,22,0.48)' }
|
||||
|
||||
switch (controller.scheme) {
|
||||
case 'forest':
|
||||
return {
|
||||
...common,
|
||||
primary: { main: isDark ? '#4CAF50' : '#2E7D32' },
|
||||
secondary: { main: isDark ? '#A1887F' : '#6D4C41' },
|
||||
info: { main: isDark ? '#29B6F6' : '#0288D1' },
|
||||
success: { main: isDark ? '#66BB6A' : '#2E7D32' },
|
||||
warning: { main: isDark ? '#FFB74D' : '#ED6C02' },
|
||||
error: { main: isDark ? '#EF5350' : '#D32F2F' },
|
||||
divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)',
|
||||
text,
|
||||
background: isDark
|
||||
? { default: '#0E1510', paper: '#121B14' }
|
||||
: { default: '#F6FAF6', paper: '#FFFFFF' },
|
||||
}
|
||||
case 'ocean':
|
||||
return {
|
||||
...common,
|
||||
primary: { main: isDark ? '#42A5F5' : '#1565C0' },
|
||||
secondary: { main: isDark ? '#4DD0E1' : '#00838F' },
|
||||
info: { main: isDark ? '#4FC3F7' : '#0288D1' },
|
||||
success: { main: isDark ? '#26C6DA' : '#00838F' },
|
||||
warning: { main: isDark ? '#FFCC80' : '#ED6C02' },
|
||||
error: { main: isDark ? '#EF5350' : '#D32F2F' },
|
||||
divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)',
|
||||
text,
|
||||
background: isDark
|
||||
? { default: '#0B1220', paper: '#0F172A' }
|
||||
: { default: '#F6FAFF', paper: '#FFFFFF' },
|
||||
}
|
||||
case 'berry':
|
||||
return {
|
||||
...common,
|
||||
primary: { main: isDark ? '#BA68C8' : '#7B1FA2' },
|
||||
secondary: { main: isDark ? '#F06292' : '#C2185B' },
|
||||
info: { main: isDark ? '#64B5F6' : '#1976D2' },
|
||||
success: { main: isDark ? '#81C784' : '#2E7D32' },
|
||||
warning: { main: isDark ? '#FFB74D' : '#ED6C02' },
|
||||
error: { main: isDark ? '#EF5350' : '#D32F2F' },
|
||||
divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)',
|
||||
text,
|
||||
background: isDark
|
||||
? { default: '#140A17', paper: '#1B0F20' }
|
||||
: { default: '#FFF7FD', paper: '#FFFFFF' },
|
||||
}
|
||||
case 'craft':
|
||||
default:
|
||||
return {
|
||||
...common,
|
||||
primary: { main: isDark ? '#BCAAA4' : '#6D4C41' },
|
||||
secondary: { main: isDark ? '#FFCCBC' : '#8D6E63' },
|
||||
info: { main: isDark ? '#90CAF9' : '#1976D2' },
|
||||
success: { main: isDark ? '#A5D6A7' : '#2E7D32' },
|
||||
warning: { main: isDark ? '#FFB74D' : '#ED6C02' },
|
||||
error: { main: isDark ? '#EF9A9A' : '#D32F2F' },
|
||||
divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)',
|
||||
text,
|
||||
background: isDark
|
||||
? { default: '#12100F', paper: '#191615' }
|
||||
: { default: '#FAF8F5', paper: '#FFFFFF' },
|
||||
}
|
||||
}
|
||||
})(),
|
||||
shape: { borderRadius: 12 },
|
||||
typography: {
|
||||
fontFamily: '"Segoe UI", system-ui, sans-serif',
|
||||
h4: { fontWeight: 700 },
|
||||
h5: { fontWeight: 600 },
|
||||
},
|
||||
}),
|
||||
[controller.resolvedMode, controller.scheme],
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: PropsWithChildren) {
|
||||
const queryClient = useMemo(
|
||||
@@ -18,31 +115,11 @@ export function AppProviders({ children }: PropsWithChildren) {
|
||||
[],
|
||||
)
|
||||
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: '#6d4c41' },
|
||||
secondary: { main: '#8d6e63' },
|
||||
background: { default: '#faf8f5', paper: '#ffffff' },
|
||||
},
|
||||
shape: { borderRadius: 12 },
|
||||
typography: {
|
||||
fontFamily: '"Segoe UI", system-ui, sans-serif',
|
||||
h4: { fontWeight: 700 },
|
||||
h5: { fontWeight: 600 },
|
||||
},
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
<ThemeControllerProvider>
|
||||
<AppThemeInner>{children}</AppThemeInner>
|
||||
</ThemeControllerProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createContext, type PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { PaletteMode } from '@mui/material'
|
||||
|
||||
export type ColorScheme = 'craft' | 'forest' | 'ocean' | 'berry'
|
||||
|
||||
export type ThemeModePreference = 'system' | PaletteMode
|
||||
|
||||
export type ThemeSettings = {
|
||||
mode: ThemeModePreference
|
||||
scheme: ColorScheme
|
||||
}
|
||||
|
||||
export type ThemeController = ThemeSettings & {
|
||||
/** Итоговый режим, учитывая system. */
|
||||
resolvedMode: PaletteMode
|
||||
setMode: (mode: ThemeModePreference) => void
|
||||
toggleMode: () => void
|
||||
cycleMode: () => void
|
||||
setScheme: (scheme: ColorScheme) => void
|
||||
}
|
||||
|
||||
const THEME_STORAGE_KEY = 'craftshop_theme'
|
||||
|
||||
function readStoredTheme(): ThemeSettings | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
const mode: unknown = parsed?.mode
|
||||
const scheme: unknown = parsed?.scheme
|
||||
const modeOk = mode === 'light' || mode === 'dark' || mode === 'system'
|
||||
const schemeOk = scheme === 'craft' || scheme === 'forest' || scheme === 'ocean' || scheme === 'berry'
|
||||
if (!modeOk || !schemeOk) return null
|
||||
return { mode, scheme }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getSystemMode(): PaletteMode {
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function resolveMode(pref: ThemeModePreference): PaletteMode {
|
||||
return pref === 'system' ? getSystemMode() : pref
|
||||
}
|
||||
|
||||
const ThemeControllerContext = createContext<ThemeController | null>(null)
|
||||
|
||||
export function useThemeController(): ThemeController {
|
||||
const ctx = useContext(ThemeControllerContext)
|
||||
if (!ctx) throw new Error('useThemeController must be used within ThemeControllerProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function ThemeControllerProvider({ children }: PropsWithChildren) {
|
||||
const [settings, setSettings] = useState<ThemeSettings>(
|
||||
() => readStoredTheme() ?? { mode: 'system', scheme: 'craft' },
|
||||
)
|
||||
|
||||
const [systemMode, setSystemMode] = useState<PaletteMode>(() => getSystemMode())
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia?.('(prefers-color-scheme: dark)')
|
||||
if (!mql) return
|
||||
|
||||
const handler = () => setSystemMode(mql.matches ? 'dark' : 'light')
|
||||
|
||||
// начальное значение
|
||||
handler()
|
||||
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', handler)
|
||||
return () => mql.removeEventListener('change', handler)
|
||||
}
|
||||
|
||||
// Safari старых версий
|
||||
mql.addListener(handler)
|
||||
return () => mql.removeListener(handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
const resolvedMode = settings.mode === 'system' ? systemMode : settings.mode
|
||||
|
||||
const controller = useMemo<ThemeController>(
|
||||
() => ({
|
||||
mode: settings.mode,
|
||||
resolvedMode,
|
||||
scheme: settings.scheme,
|
||||
setMode: (mode) => setSettings((s) => ({ ...s, mode })),
|
||||
toggleMode: () =>
|
||||
setSettings((s) => ({
|
||||
...s,
|
||||
mode: resolveMode(s.mode) === 'light' ? 'dark' : 'light',
|
||||
})),
|
||||
cycleMode: () =>
|
||||
setSettings((s) => ({
|
||||
...s,
|
||||
mode: s.mode === 'system' ? 'light' : s.mode === 'light' ? 'dark' : 'system',
|
||||
})),
|
||||
setScheme: (scheme) => setSettings((s) => ({ ...s, scheme })),
|
||||
}),
|
||||
[resolvedMode, settings.mode, settings.scheme],
|
||||
)
|
||||
|
||||
return <ThemeControllerContext.Provider value={controller}>{children}</ThemeControllerContext.Provider>
|
||||
}
|
||||
Reference in New Issue
Block a user