feat(admin-ui): shadcn/ui + Tailwind v4 foundation + theme switch

This commit is contained in:
Александр Зимин
2026-05-10 21:46:47 +00:00
parent c11a70128c
commit 3bfa7cae17
10 changed files with 1448 additions and 26 deletions
@@ -0,0 +1,101 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
/**
* Theme: 'light' (Earth) | 'dark' (Claude-warm) | 'system' (follow prefers-color-scheme).
*
* <p>Persist'ится в {@code localStorage[STORAGE_KEY]}. На mount читает saved
* preference или fallback'ает к 'system'. Меняет {@code data-theme} attr на
* {@code <html>} → CSS custom properties в styles.css переключают палитру.
*
* <p>{@code system} mode — если user ничего не выбрал, следим за prefers-color-scheme
* media query. matchMedia listener обновляет attr реально-time при OS theme change.
*/
const STORAGE_KEY = 'ord-theme'
export type ThemePreference = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
type ThemeContextValue = {
preference: ThemePreference
resolved: ResolvedTheme
setPreference: (next: ThemePreference) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
function readSystemPreference(): ResolvedTheme {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function readSavedPreference(): ThemePreference {
if (typeof window === 'undefined') return 'system'
const raw = window.localStorage.getItem(STORAGE_KEY)
if (raw === 'light' || raw === 'dark' || raw === 'system') return raw
return 'system'
}
function applyToDom(theme: ResolvedTheme) {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-theme', theme)
// shadcn convention — добавляем .dark class тоже на случай third-party
// компонентов которые ждут именно его. Custom variant в styles.css всё равно
// их связывает, но эта мера держит совместимость на корню.
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
// <meta name="color-scheme"> чтобы system browser UI (scrollbars, autofill)
// был в гармонии с темой.
let meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]')
if (!meta) {
meta = document.createElement('meta')
meta.name = 'color-scheme'
document.head.appendChild(meta)
}
meta.content = theme
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [preference, setPreferenceState] = useState<ThemePreference>(() => readSavedPreference())
const [systemResolved, setSystemResolved] = useState<ResolvedTheme>(() => readSystemPreference())
// Слушаем OS theme change при mode='system'.
useEffect(() => {
if (typeof window === 'undefined') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => setSystemResolved(mq.matches ? 'dark' : 'light')
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const resolved: ResolvedTheme = preference === 'system' ? systemResolved : preference
// Применяем к DOM каждый раз когда resolved меняется.
useEffect(() => {
applyToDom(resolved)
}, [resolved])
const setPreference = (next: ThemePreference) => {
setPreferenceState(next)
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, next)
}
}
return (
<ThemeContext.Provider value={{ preference, resolved, setPreference }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) {
throw new Error('useTheme must be used inside <ThemeProvider>')
}
return ctx
}