Add auto theme mode (light/dark/auto by time of day)

- ThemeContext: add 'auto' mode that switches light 7:00-22:00, dark otherwise, re-checks every minute
- Topbar: cycle through 3 modes on click, show Monitor icon for auto mode
- Settings → Внешний вид: 3-button grid with split preview for auto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 18:22:37 +03:00
parent 7b045b38a6
commit dd9a2a3da9
3 changed files with 63 additions and 22 deletions

View File

@@ -1,4 +1,4 @@
import { Sun, Moon, Bell, LogOut, ChevronDown, Menu, import { Sun, Moon, Monitor, Bell, LogOut, ChevronDown, Menu,
BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2, BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2,
Sparkles, AlertTriangle, Star, CreditCard, Info, Sparkles, AlertTriangle, Star, CreditCard, Info,
ArrowRight, Wrench, ArrowRight, Wrench,
@@ -204,7 +204,7 @@ interface TopbarProps {
} }
export function Topbar({ onMenuToggle, title }: TopbarProps) { export function Topbar({ onMenuToggle, title }: TopbarProps) {
const { theme, toggle } = useTheme() const { mode, toggle } = useTheme()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const { notifications } = useNotifications() const { notifications } = useNotifications()
@@ -257,8 +257,8 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
</div> </div>
{/* Theme toggle */} {/* Theme toggle */}
<button onClick={toggle} className="btn-ghost p-2"> <button onClick={toggle} className="btn-ghost p-2" title={mode === 'light' ? 'Светлая тема' : mode === 'dark' ? 'Тёмная тема' : 'Авто (по времени)'}>
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />} {mode === 'light' ? <Moon size={18} /> : mode === 'dark' ? <Sun size={18} /> : <Monitor size={18} />}
</button> </button>
{/* User menu */} {/* User menu */}

View File

@@ -1,21 +1,44 @@
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
export type ThemeMode = 'light' | 'dark' | 'auto'
type Theme = 'light' | 'dark' type Theme = 'light' | 'dark'
interface ThemeContextValue { interface ThemeContextValue {
mode: ThemeMode
theme: Theme theme: Theme
setMode: (mode: ThemeMode) => void
toggle: () => void toggle: () => void
} }
const ThemeContext = createContext<ThemeContextValue | null>(null) const ThemeContext = createContext<ThemeContextValue | null>(null)
function getAutoTheme(): Theme {
const h = new Date().getHours()
return h >= 7 && h < 22 ? 'light' : 'dark'
}
export function ThemeProvider({ children }: { children: React.ReactNode }) { export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => { const [mode, setModeState] = useState<ThemeMode>(() => {
const stored = localStorage.getItem('hotelsync-theme') as Theme | null return (localStorage.getItem('hotelsync-theme') as ThemeMode | null) ?? 'light'
if (stored) return stored
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}) })
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem('hotelsync-theme') as ThemeMode | null
if (!stored || stored === 'light') return 'light'
if (stored === 'dark') return 'dark'
return getAutoTheme()
})
useEffect(() => {
if (mode === 'auto') {
setTheme(getAutoTheme())
const interval = setInterval(() => setTheme(getAutoTheme()), 60_000)
return () => clearInterval(interval)
} else {
setTheme(mode)
}
}, [mode])
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement
if (theme === 'dark') { if (theme === 'dark') {
@@ -23,13 +46,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
} else { } else {
root.classList.remove('dark') root.classList.remove('dark')
} }
localStorage.setItem('hotelsync-theme', theme)
}, [theme]) }, [theme])
const toggle = () => setTheme(t => t === 'light' ? 'dark' : 'light') const setMode = (m: ThemeMode) => {
setModeState(m)
localStorage.setItem('hotelsync-theme', m)
}
const toggle = () => {
const next: ThemeMode = mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light'
setMode(next)
}
return ( return (
<ThemeContext.Provider value={{ theme, toggle }}> <ThemeContext.Provider value={{ mode, theme, setMode, toggle }}>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
) )

View File

@@ -33,7 +33,7 @@ export function SettingsPage() {
const [section, setSection] = useState('general') const [section, setSection] = useState('general')
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [hotel, setHotel] = useState<Hotel | null>(null) const [hotel, setHotel] = useState<Hotel | null>(null)
const { theme, toggle } = useTheme() const { mode, theme, setMode } = useTheme()
useEffect(() => { useEffect(() => {
if (!slug) return if (!slug) return
@@ -336,25 +336,36 @@ export function SettingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Тема интерфейса</p> <p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Тема интерфейса</p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-3 gap-3">
{([ {([
{ id: 'light', label: 'Светлая' }, { id: 'light', label: 'Светлая', desc: null },
{ id: 'dark', label: 'Тёмная' }, { id: 'dark', label: 'Тёмная', desc: null },
{ id: 'auto', label: 'Авто', desc: 'Светлая 7:0022:00, тёмная ночью' },
] as const).map(t => ( ] as const).map(t => (
<button <button
key={t.id} key={t.id}
onClick={() => { if (theme !== t.id) toggle() }} onClick={() => setMode(t.id)}
className={cn( className={cn(
'p-4 rounded-xl border-2 transition-all text-left', 'p-4 rounded-xl border-2 transition-all text-left',
theme === t.id ? 'border-brand-500' : 'border-slate-200 dark:border-slate-600 hover:border-slate-300', mode === t.id ? 'border-brand-500' : 'border-slate-200 dark:border-slate-600 hover:border-slate-300',
)} )}
> >
<div className={cn('w-full h-14 rounded-lg mb-2', t.id === 'light' ? 'bg-white border border-slate-200' : 'bg-slate-800')}> {t.id === 'auto' ? (
<div className={cn('h-3 w-3/4 rounded mx-2 mt-2', t.id === 'light' ? 'bg-slate-200' : 'bg-slate-600')} /> <div className="w-full h-14 rounded-lg mb-2 overflow-hidden border border-slate-200 flex">
<div className={cn('h-2 w-1/2 rounded mx-2 mt-1', t.id === 'light' ? 'bg-slate-100' : 'bg-slate-700')} /> <div className="flex-1 bg-white" />
</div> <div className="flex-1 bg-slate-800" />
</div>
) : (
<div className={cn('w-full h-14 rounded-lg mb-2', t.id === 'light' ? 'bg-white border border-slate-200' : 'bg-slate-800')}>
<div className={cn('h-3 w-3/4 rounded mx-2 mt-2', t.id === 'light' ? 'bg-slate-200' : 'bg-slate-600')} />
<div className={cn('h-2 w-1/2 rounded mx-2 mt-1', t.id === 'light' ? 'bg-slate-100' : 'bg-slate-700')} />
</div>
)}
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{t.label}</p> <p className="text-sm font-medium text-slate-900 dark:text-slate-100">{t.label}</p>
{theme === t.id && <p className="text-xs text-brand-600 dark:text-brand-400">Активна</p>} {mode === t.id
? <p className="text-xs text-brand-600 dark:text-brand-400">Активна</p>
: t.desc && <p className="text-xs text-slate-400 dark:text-slate-500 leading-tight">{t.desc}</p>
}
</button> </button>
))} ))}
</div> </div>