Первоначальная версия VidConf
This commit is contained in:
177
frontend/src/hooks/useChat.ts
Normal file
177
frontend/src/hooks/useChat.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Сообщение чата в проводном формате сервера — 1:1 с pydantic-схемой
|
||||
* `ChatMessageOut` backend (зафиксированный протокол WS).
|
||||
* `created_at` — UTC ISO-8601 (в БД и API только UTC), конвертация в
|
||||
* локальное время — на стороне ChatPanel (`lib/localTime.ts`).
|
||||
*/
|
||||
export interface ChatMessageOut {
|
||||
id: number
|
||||
author_id: string | null
|
||||
author_name: string
|
||||
is_guest: boolean
|
||||
text: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Статус WS-соединения чата: `connecting` — сокет открывается,
|
||||
* `open` — транспорт открыт (auth уже отправлен, история может ещё
|
||||
* загружаться), `closed` — соединение штатно закрыто/не запускалось,
|
||||
* `error` — обрыв по невалидному токену или отказу в доступе
|
||||
* (см. `statusMessage` для пояснения пользователю).
|
||||
*/
|
||||
export type ChatConnectionStatus = 'connecting' | 'open' | 'closed' | 'error'
|
||||
|
||||
type IncomingFrame =
|
||||
| { type: 'history'; messages: ChatMessageOut[] }
|
||||
| { type: 'message'; message: ChatMessageOut }
|
||||
| { type: 'error'; code: string }
|
||||
|
||||
interface UseChatOptions {
|
||||
/** id конференции — пока не известен (страница ещё не подключилась к LiveKit), WS не открываем. */
|
||||
conferenceId: string | undefined
|
||||
/** LiveKit-токен из JoinOut — им же авторизуем WS-сессию чата (см. протокол). */
|
||||
token: string | undefined
|
||||
/** `JoinOut.chat_enabled` — при `false` хук ничего не подключает. */
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface UseChatResult {
|
||||
messages: ChatMessageOut[]
|
||||
status: ChatConnectionStatus
|
||||
/** Пояснение для баннера при обрыве/ошибке — `null`, если показывать нечего. */
|
||||
statusMessage: string | null
|
||||
/** `true` после close-кода 4404 (чат выключен на сервере/конференция не найдена) — панель нужно скрыть. */
|
||||
unavailable: boolean
|
||||
/** Отправить сообщение (1..2000 символов после strip, пустое/слишком длинное — игнорируется). */
|
||||
sendMessage: (text: string) => void
|
||||
}
|
||||
|
||||
/** Close-коды сервера — см. зафиксированный протокол WS. */
|
||||
const CLOSE_TOKEN_INVALID = 4401
|
||||
const CLOSE_FORBIDDEN = 4403
|
||||
const CLOSE_UNAVAILABLE = 4404
|
||||
|
||||
/** Собрать WS-адрес чата от текущего `window.location` (ws:// на http, wss:// на https). */
|
||||
function buildChatWsUrl(conferenceId: string): string {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${wsProtocol}//${window.location.host}/api/v1/conferences/${conferenceId}/chat`
|
||||
}
|
||||
|
||||
/**
|
||||
* WS-клиент чата комнаты конференции. Реализует зафиксированный протокол:
|
||||
* connect → `{type:"auth"}` → `{type:"history"}` → далее входящие
|
||||
* `{type:"message"}`/`{type:"error"}`.
|
||||
*
|
||||
* Optimistic-append собственных сообщений НЕ делается: сервер всегда
|
||||
* присылает наше же сообщение обратно echo-фреймом `message` — если
|
||||
* добавлять его на клиенте сразу при отправке, оно задублируется в списке.
|
||||
*/
|
||||
export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseChatResult {
|
||||
const [messages, setMessages] = useState<ChatMessageOut[]>([])
|
||||
// `wsStatus` меняется ТОЛЬКО из колбэков реального WS-соединения (см. ниже) —
|
||||
// никогда синхронно в теле эффекта, иначе react-hooks/set-state-in-effect
|
||||
// (эффект без активной подписки, только синхронизирующий производное
|
||||
// значение, — по факту тот самый анти-паттерн, который правило и ловит).
|
||||
// Начальное значение — `connecting`: типичный случай — подключение начинается
|
||||
// сразу при монтировании хука (RoomPage монтируется один раз на комнату);
|
||||
// при повторном подключении (смена conferenceId/token) статус до первого
|
||||
// события нового сокета может на короткое время показывать значение от
|
||||
// предыдущего соединения — не считается веб-сокет-ready до первого
|
||||
// onopen/onerror/onclose. Когда чат выключен/данных для подключения ещё
|
||||
// нет, наружу отдаём производный статус `closed` без всякого state — см.
|
||||
// `status` ниже.
|
||||
const [wsStatus, setWsStatus] = useState<ChatConnectionStatus>('connecting')
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const canConnect = enabled && Boolean(conferenceId) && Boolean(token)
|
||||
|
||||
useEffect(() => {
|
||||
if (!canConnect || !conferenceId || !token) {
|
||||
return
|
||||
}
|
||||
|
||||
// Флаг «эффект пересоздан/компонент размонтирован» — без него обработчики
|
||||
// СТАРОГО сокета (onclose и т.п.), сработавшие асинхронно уже после того,
|
||||
// как эффект пересоздался для нового conferenceId/token, могли бы
|
||||
// перетереть состояние уже актуального соединения.
|
||||
let stale = false
|
||||
|
||||
const ws = new WebSocket(buildChatWsUrl(conferenceId))
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
if (stale) return
|
||||
setMessages([])
|
||||
setStatusMessage(null)
|
||||
setUnavailable(false)
|
||||
setWsStatus('open')
|
||||
ws.send(JSON.stringify({ type: 'auth', token }))
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (stale) return
|
||||
let frame: IncomingFrame
|
||||
try {
|
||||
frame = JSON.parse(event.data as string) as IncomingFrame
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (frame.type === 'history') {
|
||||
setMessages(frame.messages)
|
||||
} else if (frame.type === 'message') {
|
||||
setMessages((prev) => [...prev, frame.message])
|
||||
} else if (frame.type === 'error') {
|
||||
// Ошибка отдельной операции (например, отклонённое сообщение) — соединение
|
||||
// не рвётся, просто короткое пояснение пользователю.
|
||||
setStatusMessage(`Ошибка чата: ${frame.code}`)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (stale) return
|
||||
setWsStatus('error')
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
wsRef.current = null
|
||||
if (stale) return
|
||||
if (event.code === CLOSE_TOKEN_INVALID) {
|
||||
setWsStatus('error')
|
||||
setStatusMessage('Сессия чата истекла — обновите страницу, чтобы переподключиться')
|
||||
} else if (event.code === CLOSE_FORBIDDEN) {
|
||||
setWsStatus('error')
|
||||
setStatusMessage('Нет доступа к чату этой конференции')
|
||||
} else if (event.code === CLOSE_UNAVAILABLE) {
|
||||
setWsStatus('closed')
|
||||
setUnavailable(true)
|
||||
} else {
|
||||
setWsStatus('closed')
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
stale = true
|
||||
wsRef.current = null
|
||||
ws.close()
|
||||
}
|
||||
}, [canConnect, conferenceId, token])
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || trimmed.length > 2000) return
|
||||
const ws = wsRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'message', text: trimmed }))
|
||||
}, [])
|
||||
|
||||
// Наружу — производный статус: пока подключаться нечем (выключено/нет
|
||||
// conferenceId/token), всегда `closed`, даже если внутренний `wsStatus`
|
||||
// ещё хранит значение от предыдущего подключения.
|
||||
const status: ChatConnectionStatus = canConnect ? wsStatus : 'closed'
|
||||
|
||||
return { messages, status, statusMessage, unavailable, sendMessage }
|
||||
}
|
||||
48
frontend/src/hooks/useFullscreen.ts
Normal file
48
frontend/src/hooks/useFullscreen.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useState, type RefObject } from 'react'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
interface UseFullscreenResult {
|
||||
/** `document.fullscreenEnabled` — если `false`, кнопку в тулбаре нужно скрыть (программный фуллскрин недоступен). */
|
||||
supported: boolean
|
||||
/** Активен ли полноэкранный режим сейчас. */
|
||||
active: boolean
|
||||
/** Войти/выйти из полноэкранного режима для контейнера `ref`. */
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Полноэкранный режим комнаты конференции (Fullscreen API, MDN).
|
||||
* Цель — корневой контейнер комнаты
|
||||
* (`div[data-theme="room"]` в `RoomPage.tsx`), чтобы тулбар и чат оставались
|
||||
* видны внутри фуллскрина.
|
||||
*
|
||||
* Единственный источник истины о состоянии — событие `fullscreenchange`
|
||||
* документа, а не промис `requestFullscreen()`: выход по Esc браузер
|
||||
* выполняет сам, без обратного вызова нашего кода, и событие — единственный
|
||||
* способ об этом узнать.
|
||||
*/
|
||||
export function useFullscreen(ref: RefObject<HTMLElement | null>): UseFullscreenResult {
|
||||
const toast = useToast()
|
||||
const supported = typeof document !== 'undefined' && document.fullscreenEnabled
|
||||
const [active, setActive] = useState(() => typeof document !== 'undefined' && document.fullscreenElement !== null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleChange() {
|
||||
setActive(document.fullscreenElement !== null)
|
||||
}
|
||||
document.addEventListener('fullscreenchange', handleChange)
|
||||
return () => document.removeEventListener('fullscreenchange', handleChange)
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen()
|
||||
return
|
||||
}
|
||||
ref.current?.requestFullscreen().catch(() => {
|
||||
toast.show('Не удалось включить полноэкранный режим', 'error')
|
||||
})
|
||||
}, [ref, toast])
|
||||
|
||||
return { supported, active, toggle }
|
||||
}
|
||||
183
frontend/src/hooks/useRoomPiP.ts
Normal file
183
frontend/src/hooks/useRoomPiP.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
/** Какой вид мини-плеера сейчас активен — влияет на то, что рисовать в основном окне (см. `RoomPage.tsx`). */
|
||||
type PiPMode = 'document' | 'video' | null
|
||||
|
||||
interface UseRoomPiPResult {
|
||||
/** Доступен ли мини-плеер хоть в каком-то виде (Document PiP либо video-фолбэк) — `false` в Firefox, кнопку нужно скрыть. */
|
||||
supported: boolean
|
||||
/** Активен ли сейчас мини-плеер (в любом режиме). */
|
||||
active: boolean
|
||||
mode: PiPMode
|
||||
/** Открытое PiP-окно — рендер сетки участников в него через `createPortal` (вызывающий код, `RoomPage.tsx`); `null` вне режима `'document'`. */
|
||||
pipWindow: Window | null
|
||||
/** Включить/выключить мини-плеер. ВАЖНО: вызывать СИНХРОННО из `onClick` — оба API требуют user gesture (transient activation), никакого `await` до первого вызова. */
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Копирует таблицы стилей текущего документа в PiP-окно (рецепт MDN для
|
||||
* Document Picture-in-Picture — окно открывается с пустым `<head>`, своих
|
||||
* стилей не наследует). Внешние (cross-origin) таблицы, у которых `cssRules`
|
||||
* недоступны из-за CORS, переносятся ссылкой (`<link>`), а не инлайном.
|
||||
*/
|
||||
function copyStyleSheets(target: Document) {
|
||||
for (const sheet of Array.from(document.styleSheets)) {
|
||||
try {
|
||||
const cssText = Array.from(sheet.cssRules)
|
||||
.map((rule) => rule.cssText)
|
||||
.join('\n')
|
||||
const style = target.createElement('style')
|
||||
style.textContent = cssText
|
||||
target.head.appendChild(style)
|
||||
} catch {
|
||||
if (sheet.href) {
|
||||
const link = target.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = sheet.href
|
||||
target.head.appendChild(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Мини-плеер конференции («картинка в картинке»,
|
||||
* Приоритет:
|
||||
* 1. **Document Picture-in-Picture** (Chrome/Edge 116+) — активное окно (одна
|
||||
* крупная плитка, без карусели/грида — см. `variant="pip"` у `RoomStage`)
|
||||
* рисует вызывающий код через `createPortal(<RoomStage variant="pip"/>,
|
||||
* pipWindow.document.body)`, React-контекст `LiveKitRoom` при этом
|
||||
* остаётся в основном дереве (хуки треков продолжают работать). Основное
|
||||
* окно вместо сцены показывает заглушку — см. `mode === 'document'` в
|
||||
* `RoomPage.tsx`.
|
||||
* 2. **Фолбэк video-PiP** (Safari, `document.pictureInPictureEnabled`) —
|
||||
* классический `videoEl.requestPictureInPicture()` на видео активного
|
||||
* спикера; сцена в основном окне остаётся как есть.
|
||||
* 3. Ничего не поддерживается (Firefox) — `supported: false`, кнопку в
|
||||
* тулбаре нужно СКРЫТЬ, а не задизейблить (ничего не обещаем).
|
||||
*
|
||||
* Переход в фуллскрин из PiP и обратно ничем дополнительно не
|
||||
* синхронизируется: браузер сам выходит из фуллскрина при входе в PiP,
|
||||
* `useFullscreen.active` обновится своим `fullscreenchange`.
|
||||
*/
|
||||
export function useRoomPiP(containerRef: RefObject<HTMLElement | null>): UseRoomPiPResult {
|
||||
const toast = useToast()
|
||||
const [pipWindow, setPipWindow] = useState<Window | null>(null)
|
||||
const [videoPipActive, setVideoPipActive] = useState(false)
|
||||
|
||||
const hasDocumentPiP = typeof window !== 'undefined' && 'documentPictureInPicture' in window
|
||||
const hasVideoPiP = typeof document !== 'undefined' && document.pictureInPictureEnabled
|
||||
|
||||
// Video-PiP фолбэк: состояние читаем из событий входа/выхода — они летят
|
||||
// на document независимо от того, какой именно <video> сейчас в PiP.
|
||||
useEffect(() => {
|
||||
function handleEnter() {
|
||||
setVideoPipActive(true)
|
||||
}
|
||||
function handleLeave() {
|
||||
setVideoPipActive(false)
|
||||
}
|
||||
document.addEventListener('enterpictureinpicture', handleEnter, true)
|
||||
document.addEventListener('leavepictureinpicture', handleLeave, true)
|
||||
return () => {
|
||||
document.removeEventListener('enterpictureinpicture', handleEnter, true)
|
||||
document.removeEventListener('leavepictureinpicture', handleLeave, true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Document PiP: пользователь может закрыть окно крестиком ОС, минуя нашу
|
||||
// кнопку — единственный надёжный сигнал об этом, `pagehide` самого окна.
|
||||
useEffect(() => {
|
||||
if (!pipWindow) return
|
||||
function handlePageHide() {
|
||||
setPipWindow(null)
|
||||
}
|
||||
pipWindow.addEventListener('pagehide', handlePageHide)
|
||||
return () => pipWindow.removeEventListener('pagehide', handlePageHide)
|
||||
}, [pipWindow])
|
||||
|
||||
// Зеркало `pipWindow` в ref — для cleanup-эффекта ниже: при размонтировании
|
||||
// самого хука (уход с RoomPage при активном PiP — навигация после
|
||||
// onDisconnected, смена :slug и т.п.) эффект с пустыми зависимостями видит
|
||||
// только значение на момент МОНТИРОВАНИЯ, если брать его из состояния
|
||||
// напрямую; ref всегда содержит актуальное значение на момент unmount.
|
||||
const pipWindowRef = useRef<Window | null>(null)
|
||||
useEffect(() => {
|
||||
pipWindowRef.current = pipWindow
|
||||
}, [pipWindow])
|
||||
|
||||
// Закрыть осиротевшее PiP-окно при размонтировании хука — иначе при выходе
|
||||
// из конференции (без явного «Вернуть в это окно») окно браузера остаётся
|
||||
// висеть само по себе (WICG-рекомендация для Document PiP). Объявлен ПОСЛЕ
|
||||
// эффекта с `pagehide`, поэтому при unmount его подписка уже снята до
|
||||
// вызова `close()` — повторный `setPipWindow(null)` из `handlePageHide` не
|
||||
// произойдёт (да и был бы безвреден, если бы порядок был другим: `close()`
|
||||
// на уже закрытом окне — no-op, а обновление состояния уже
|
||||
// размонтированного компонента React просто игнорирует).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pipWindowRef.current?.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (hasDocumentPiP) {
|
||||
if (pipWindow) {
|
||||
pipWindow.close()
|
||||
setPipWindow(null)
|
||||
return
|
||||
}
|
||||
// requestWindow вызывается СИНХРОННО прямо в обработчике клика — это и
|
||||
// есть user gesture (transient activation); никакой асинхронной
|
||||
// подготовки/await до этого вызова быть не должно.
|
||||
window.documentPictureInPicture
|
||||
?.requestWindow({ width: 480, height: 320 })
|
||||
.then((win) => {
|
||||
copyStyleSheets(win.document)
|
||||
// Комната — всегда своя тёмная тема;
|
||||
// `[data-theme="room"]` на <html> PiP-окна включает переменные
|
||||
// --color-room-* из скопированного tokens.css.
|
||||
win.document.documentElement.setAttribute('data-theme', 'room')
|
||||
setPipWindow(win)
|
||||
})
|
||||
.catch(() => {
|
||||
toast.show('Не удалось открыть мини-окно конференции', 'error')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (hasVideoPiP) {
|
||||
if (document.pictureInPictureElement) {
|
||||
void document.exitPictureInPicture()
|
||||
return
|
||||
}
|
||||
const container = containerRef.current
|
||||
// `.lk-focus-layout video` без уточнения брал бы ПЕРВОЕ video в DOM —
|
||||
// это миниатюра карусели (она в разметке идёт раньше фокус-плитки), а
|
||||
// не крупный контент. Фокус-плитка — прямой ребёнок `.lk-focus-layout`
|
||||
// (`> .lk-participant-tile`), карусельные — вложены внутрь `.lk-carousel`
|
||||
// ещё на уровень глубже, поэтому прямой child-селектор берёт именно её —
|
||||
// включая демонстрацию экрана, когда она в фокусе.
|
||||
const video =
|
||||
container?.querySelector<HTMLVideoElement>('.lk-focus-layout > .lk-participant-tile video') ??
|
||||
container?.querySelector<HTMLVideoElement>('video')
|
||||
if (!video) {
|
||||
toast.show('Нет активного видео для мини-окна', 'error')
|
||||
return
|
||||
}
|
||||
video.requestPictureInPicture().catch(() => {
|
||||
toast.show('Не удалось открыть мини-окно видео', 'error')
|
||||
})
|
||||
}
|
||||
}, [hasDocumentPiP, hasVideoPiP, pipWindow, containerRef, toast])
|
||||
|
||||
return {
|
||||
supported: hasDocumentPiP || hasVideoPiP,
|
||||
active: pipWindow !== null || videoPipActive,
|
||||
mode: pipWindow ? 'document' : videoPipActive ? 'video' : null,
|
||||
pipWindow,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
77
frontend/src/hooks/useTheme.ts
Normal file
77
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Ключ localStorage для явного выбора темы пользователем. ДОЛЖЕН совпадать с
|
||||
* инлайн-скриптом в `frontend/index.html` (защита от FOUC — атрибут
|
||||
* `data-theme` проставляется на `<html>` ДО первого рендера React).
|
||||
*/
|
||||
const STORAGE_KEY = 'vidconf-theme'
|
||||
|
||||
type ExplicitTheme = 'light' | 'dark'
|
||||
|
||||
function readStoredTheme(): ExplicitTheme | null {
|
||||
try {
|
||||
const value = localStorage.getItem(STORAGE_KEY)
|
||||
return value === 'light' || value === 'dark' ? value : null
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим/политики браузера) — считаем,
|
||||
// что явного выбора нет, тема определится системной prefers-color-scheme.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function applyThemeAttribute(theme: ExplicitTheme | null) {
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключатель темы оболочки (см. design/DESIGN_SYSTEM.md §0.1,
|
||||
* design/mockups/dark/README.md «Переключатель темы»).
|
||||
*
|
||||
* Контракт: явный выбор пользователя (`data-theme="light"`/`"dark"` на
|
||||
* `<html>`) сохраняется в localStorage и переживает перезагрузку; пока
|
||||
* выбора нет — действует системная тема (`prefers-color-scheme`), атрибут не
|
||||
* проставлен вообще. В макетах переключатель — сегментированный контрол из
|
||||
* двух явных кнопок «Светлая»/«Тёмная» (не циклический тумблер и без
|
||||
* третьего состояния «системная» в UI) — клик по любой из них фиксирует
|
||||
* выбор навсегда (до следующего клика по другой кнопке); вернуться к
|
||||
* автодетекту без явного выбора через этот контрол нельзя, ровно как в
|
||||
* утверждённых макетах.
|
||||
*/
|
||||
export function useTheme() {
|
||||
const [explicit, setExplicit] = useState<ExplicitTheme | null>(() => readStoredTheme())
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState(
|
||||
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
)
|
||||
|
||||
// Живое отслеживание системной темы — чтобы подсветка активной кнопки
|
||||
// оставалась верной, если пользователь ещё не делал явного выбора и сменил
|
||||
// тему ОС, не покидая вкладку.
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches)
|
||||
media.addEventListener('change', handleChange)
|
||||
return () => media.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeAttribute(explicit)
|
||||
}, [explicit])
|
||||
|
||||
const setTheme = useCallback((theme: ExplicitTheme) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, theme)
|
||||
} catch {
|
||||
// Сохранение недоступно — тема продержится до перезагрузки страницы.
|
||||
}
|
||||
setExplicit(theme)
|
||||
}, [])
|
||||
|
||||
const resolved: ExplicitTheme = explicit ?? (systemPrefersDark ? 'dark' : 'light')
|
||||
|
||||
return { resolved, setTheme }
|
||||
}
|
||||
Reference in New Issue
Block a user