Refactor LayerApplicabilityDialog and RulesCard components to utilize useKeyedList for managing lists, improving state handling and performance. Update ScheduleGrid to enhance key assignment for mapped elements, ensuring better React reconciliation. Modify ManualInboxDialog to use unique keys for parts in sample name processing, and improve ToastProvider and ThemeProvider by memoizing context values to prevent unnecessary re-renders.
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { useKeyedList } from '@/shared/lib/keyed-list'
|
||||
import { updateLayer } from '../api'
|
||||
import { isEmpty, toIsoDate } from '../lib/applicability'
|
||||
|
||||
@@ -37,21 +38,17 @@ export function LayerApplicabilityDialog({
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(layer.name)
|
||||
const [weekdays, setWeekdays] = useState<number[]>(layer.applicability?.weekdays ?? [])
|
||||
const [dateRanges, setDateRanges] = useState<DateRange[]>(layer.applicability?.dateRanges ?? [])
|
||||
const [annualRanges, setAnnualRanges] = useState<AnnualRange[]>(
|
||||
layer.applicability?.annualRanges ?? [],
|
||||
)
|
||||
const [specificDates, setSpecificDates] = useState<string[]>(
|
||||
layer.applicability?.specificDates ?? [],
|
||||
)
|
||||
const dateRanges = useKeyedList<DateRange>(layer.applicability?.dateRanges ?? [])
|
||||
const annualRanges = useKeyedList<AnnualRange>(layer.applicability?.annualRanges ?? [])
|
||||
const specificDates = useKeyedList<string>(layer.applicability?.specificDates ?? [])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const applicability: LayerApplicability = {
|
||||
weekdays: weekdays.length > 0 ? [...weekdays].sort((a, b) => a - b) : null,
|
||||
dateRanges: dateRanges.length > 0 ? dateRanges : null,
|
||||
annualRanges: annualRanges.length > 0 ? annualRanges : null,
|
||||
specificDates: specificDates.length > 0 ? specificDates : null,
|
||||
dateRanges: dateRanges.values.length > 0 ? dateRanges.values : null,
|
||||
annualRanges: annualRanges.values.length > 0 ? annualRanges.values : null,
|
||||
specificDates: specificDates.values.length > 0 ? specificDates.values : null,
|
||||
}
|
||||
return updateLayer(layer.id, {
|
||||
name: name.trim() || layer.name,
|
||||
@@ -112,36 +109,26 @@ export function LayerApplicabilityDialog({
|
||||
|
||||
<Section
|
||||
title={t('admin.channels.applicabilityDateRanges')}
|
||||
onAdd={() => setDateRanges((c) => [...c, { from: today, to: today }])}
|
||||
empty={dateRanges.length === 0}
|
||||
onAdd={() => dateRanges.add({ from: today, to: today })}
|
||||
empty={dateRanges.rows.length === 0}
|
||||
>
|
||||
{dateRanges.map((range, index) => (
|
||||
<li key={index} className="flex flex-wrap items-end gap-2">
|
||||
{dateRanges.rows.map(({ key, value: range }) => (
|
||||
<li key={key} className="flex flex-wrap items-end gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="w-40"
|
||||
value={range.from}
|
||||
onChange={(e) =>
|
||||
setDateRanges((c) =>
|
||||
c.map((r, i) => (i === index ? { ...r, from: e.target.value } : r)),
|
||||
)
|
||||
dateRanges.patch(key, (r) => ({ ...r, from: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
className="w-40"
|
||||
value={range.to}
|
||||
onChange={(e) =>
|
||||
setDateRanges((c) =>
|
||||
c.map((r, i) => (i === index ? { ...r, to: e.target.value } : r)),
|
||||
)
|
||||
}
|
||||
onChange={(e) => dateRanges.patch(key, (r) => ({ ...r, to: e.target.value }))}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDateRanges((c) => c.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={() => dateRanges.remove(key)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
@@ -150,33 +137,23 @@ export function LayerApplicabilityDialog({
|
||||
|
||||
<Section
|
||||
title={t('admin.channels.applicabilityAnnual')}
|
||||
onAdd={() =>
|
||||
setAnnualRanges((c) => [...c, { fromMonth: 12, fromDay: 20, toMonth: 1, toDay: 8 }])
|
||||
}
|
||||
empty={annualRanges.length === 0}
|
||||
onAdd={() => annualRanges.add({ fromMonth: 12, fromDay: 20, toMonth: 1, toDay: 8 })}
|
||||
empty={annualRanges.rows.length === 0}
|
||||
>
|
||||
{annualRanges.map((range, index) => (
|
||||
<li key={index} className="flex flex-wrap items-end gap-2">
|
||||
{annualRanges.rows.map(({ key, value: range }) => (
|
||||
<li key={key} className="flex flex-wrap items-end gap-2">
|
||||
<MonthDay
|
||||
value={range}
|
||||
prefix="from"
|
||||
onChange={(part) =>
|
||||
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
|
||||
}
|
||||
onChange={(part) => annualRanges.patch(key, (r) => ({ ...r, ...part }))}
|
||||
/>
|
||||
<span className="pb-2 text-muted-foreground">—</span>
|
||||
<MonthDay
|
||||
value={range}
|
||||
prefix="to"
|
||||
onChange={(part) =>
|
||||
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
|
||||
}
|
||||
onChange={(part) => annualRanges.patch(key, (r) => ({ ...r, ...part }))}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setAnnualRanges((c) => c.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={() => annualRanges.remove(key)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
@@ -185,24 +162,18 @@ export function LayerApplicabilityDialog({
|
||||
|
||||
<Section
|
||||
title={t('admin.channels.applicabilityDates')}
|
||||
onAdd={() => setSpecificDates((c) => [...c, today])}
|
||||
empty={specificDates.length === 0}
|
||||
onAdd={() => specificDates.add(today)}
|
||||
empty={specificDates.rows.length === 0}
|
||||
>
|
||||
{specificDates.map((date, index) => (
|
||||
<li key={index} className="flex items-end gap-2">
|
||||
{specificDates.rows.map(({ key, value: date }) => (
|
||||
<li key={key} className="flex items-end gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="w-40"
|
||||
value={date}
|
||||
onChange={(e) =>
|
||||
setSpecificDates((c) => c.map((d, i) => (i === index ? e.target.value : d)))
|
||||
}
|
||||
onChange={(e) => specificDates.patch(key, () => e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSpecificDates((c) => c.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={() => specificDates.remove(key)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
|
||||
@@ -17,6 +17,16 @@ import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'PG-13' }
|
||||
|
||||
/**
|
||||
* Окно со стабильным ключом. Индекс в качестве key не годится: строки удаляются из середины, и React
|
||||
* сопоставил бы уцелевшие узлы не с теми окнами — фокус и внутреннее состояние полей переехали бы
|
||||
* в соседнюю строку. Ключ живёт только на клиенте и в API не уезжает.
|
||||
*/
|
||||
type WindowRow = { key: string; window: AudienceWindow }
|
||||
|
||||
const toRows = (windows: AudienceWindow[]): WindowRow[] =>
|
||||
windows.map((window) => ({ key: crypto.randomUUID(), window }))
|
||||
|
||||
/**
|
||||
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
|
||||
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
|
||||
@@ -34,8 +44,8 @@ export function RulesCard({
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [windows, setWindows] = useState<AudienceWindow[]>(
|
||||
() => template.rules?.maxAudienceByTime ?? [],
|
||||
const [windows, setWindows] = useState<WindowRow[]>(() =>
|
||||
toRows(template.rules?.maxAudienceByTime ?? []),
|
||||
)
|
||||
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
|
||||
const [windowDays, setWindowDays] = useState(
|
||||
@@ -47,7 +57,7 @@ export function RulesCard({
|
||||
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
|
||||
|
||||
useEffect(() => {
|
||||
setWindows(template.rules?.maxAudienceByTime ?? [])
|
||||
setWindows(toRows(template.rules?.maxAudienceByTime ?? []))
|
||||
setLimitOn(template.rules?.maxRepeatsInWindow != null)
|
||||
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
|
||||
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||
@@ -59,7 +69,7 @@ export function RulesCard({
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const rules: PlanningRules = {
|
||||
maxAudienceByTime: windows.length > 0 ? windows : null,
|
||||
maxAudienceByTime: windows.length > 0 ? windows.map((row) => row.window) : null,
|
||||
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
|
||||
// Ноль означает «не проверять»: отдельного выключателя на каждый порог не нужно.
|
||||
maxBreakMinutesPerHour: breakCap > 0 ? breakCap : null,
|
||||
@@ -77,8 +87,12 @@ export function RulesCard({
|
||||
onError,
|
||||
})
|
||||
|
||||
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
|
||||
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
|
||||
const patchWindow = (key: string, part: Partial<AudienceWindow>) =>
|
||||
setWindows((current) =>
|
||||
current.map((row) =>
|
||||
row.key === key ? { ...row, window: { ...row.window, ...part } } : row,
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.rules')} bare={bare}>
|
||||
@@ -93,7 +107,7 @@ export function RulesCard({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
|
||||
onClick={() => setWindows((c) => [...c, ...toRows([EMPTY_WINDOW])])}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -103,15 +117,15 @@ export function RulesCard({
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{windows.map((window, index) => (
|
||||
<li key={index} className="flex flex-wrap items-end gap-2">
|
||||
{windows.map(({ key, window }) => (
|
||||
<li key={key} className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
className="w-28"
|
||||
value={window.from.slice(0, 5)}
|
||||
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
|
||||
onChange={(e) => patchWindow(key, { from: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -120,7 +134,7 @@ export function RulesCard({
|
||||
type="time"
|
||||
className="w-28"
|
||||
value={window.to.slice(0, 5)}
|
||||
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
|
||||
onChange={(e) => patchWindow(key, { to: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -129,7 +143,7 @@ export function RulesCard({
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={window.maxAudience}
|
||||
onChange={(e) =>
|
||||
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
|
||||
patchWindow(key, { maxAudience: e.target.value as ShowAudience })
|
||||
}
|
||||
>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
@@ -142,7 +156,7 @@ export function RulesCard({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
|
||||
onClick={() => setWindows((c) => c.filter((row) => row.key !== key))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -174,9 +174,9 @@ export function ScheduleGrid({
|
||||
|
||||
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
|
||||
<div>
|
||||
{hours.map((hour, index) => (
|
||||
{hours.map((hour) => (
|
||||
<div
|
||||
key={index}
|
||||
key={hour}
|
||||
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
@@ -197,9 +197,9 @@ export function ScheduleGrid({
|
||||
onDragOver={(e) => dragged && e.preventDefault()}
|
||||
onDrop={(e) => drop(e, weekday)}
|
||||
>
|
||||
{hours.map((_, index) => (
|
||||
{hours.map((hour, index) => (
|
||||
<button
|
||||
key={index}
|
||||
key={hour}
|
||||
type="button"
|
||||
title={t('admin.channels.addSlotHere')}
|
||||
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
|
||||
@@ -244,8 +244,12 @@ export function ScheduleGrid({
|
||||
{slot.weekday === null && <Repeat className="h-3 w-3 shrink-0 opacity-60" />}
|
||||
</span>
|
||||
<span className="block truncate">{slot.title}</span>
|
||||
{/* Ручка растягивания — исключительно мышиная: role="presentation" на элементе
|
||||
с обработчиком противоречив (роль говорит «меня нет», а элемент реагирует),
|
||||
поэтому прячем её от вспомогательных технологий. Длительность слота
|
||||
правится с клавиатуры в инспекторе — доступный путь есть. */}
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
title={t('admin.channels.resizeSlot')}
|
||||
className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize hover:bg-primary/40"
|
||||
onMouseDown={(e) =>
|
||||
|
||||
@@ -112,16 +112,22 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
const sampleParts = useMemo(() => {
|
||||
if (!sample) return []
|
||||
const numbers = findNumbers(sample.name)
|
||||
const parts: { text: string; number: number | null }[] = []
|
||||
// start — позиция куска в имени файла: она уникальна в пределах образца и годится как key,
|
||||
// в отличие от индекса (куски одинакового текста встречаются в имени по нескольку раз).
|
||||
const parts: { start: number; text: string; number: number | null }[] = []
|
||||
let cursor = 0
|
||||
for (const number of numbers) {
|
||||
if (number.start > cursor)
|
||||
parts.push({ text: sample.name.slice(cursor, number.start), number: null })
|
||||
parts.push({ text: number.text, number: number.index })
|
||||
parts.push({
|
||||
start: cursor,
|
||||
text: sample.name.slice(cursor, number.start),
|
||||
number: null,
|
||||
})
|
||||
parts.push({ start: number.start, text: number.text, number: number.index })
|
||||
cursor = number.start + number.text.length
|
||||
}
|
||||
if (cursor < sample.name.length)
|
||||
parts.push({ text: sample.name.slice(cursor), number: null })
|
||||
parts.push({ start: cursor, text: sample.name.slice(cursor), number: null })
|
||||
return parts
|
||||
}, [sample])
|
||||
/**
|
||||
@@ -264,14 +270,14 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
{t('admin.media.regexPickHint')}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
||||
{sampleParts.map((part, index) =>
|
||||
{sampleParts.map((part) =>
|
||||
part.number === null ? (
|
||||
<span key={index} className="text-muted-foreground">
|
||||
<span key={part.start} className="text-muted-foreground">
|
||||
{part.text}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
key={part.start}
|
||||
type="button"
|
||||
title={t('admin.media.regexPickTitle')}
|
||||
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export type KeyedRow<T> = { key: string; value: T }
|
||||
|
||||
const toRows = <T,>(values: readonly T[]): KeyedRow<T>[] =>
|
||||
values.map((value) => ({ key: crypto.randomUUID(), value }))
|
||||
|
||||
/**
|
||||
* Редактируемый список со стабильными ключами строк.
|
||||
*
|
||||
* Индекс в качестве `key` не годится там, где строки удаляются из середины или переставляются:
|
||||
* React сопоставит уцелевшие DOM-узлы не с теми значениями, и фокус вместе с внутренним состоянием
|
||||
* полей переедет в соседнюю строку. Ключ существует только на клиенте и наружу не уезжает —
|
||||
* в API отправляется `values`.
|
||||
*
|
||||
* Заодно правки строк перестают быть замыканиями по индексу внутри JSX: `patch`/`remove` работают
|
||||
* по ключу и не зависят от текущей позиции строки в списке.
|
||||
*/
|
||||
export function useKeyedList<T>(initial: readonly T[]) {
|
||||
const [rows, setRows] = useState<KeyedRow<T>[]>(() => toRows(initial))
|
||||
|
||||
const reset = useCallback((values: readonly T[]) => setRows(toRows(values)), [])
|
||||
|
||||
const add = useCallback((value: T) => setRows((c) => [...c, ...toRows([value])]), [])
|
||||
|
||||
const remove = useCallback(
|
||||
(key: string) => setRows((c) => c.filter((row) => row.key !== key)),
|
||||
[],
|
||||
)
|
||||
|
||||
const patch = useCallback(
|
||||
(key: string, next: (value: T) => T) =>
|
||||
setRows((c) =>
|
||||
c.map((row) => (row.key === key ? { key: row.key, value: next(row.value) } : row)),
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
return { rows, values: rows.map((row) => row.value), reset, add, remove, patch }
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
type ToastVariant = 'default' | 'success' | 'error'
|
||||
export type ToastItem = { id: number; message: string; variant: ToastVariant }
|
||||
@@ -26,7 +33,11 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
pushImpl = push
|
||||
|
||||
return <ToastContext value={{ toasts, dismiss }}>{children}</ToastContext>
|
||||
// Литерал в value пересоздавался бы на каждый рендер провайдера и перерисовывал всех потребителей
|
||||
// контекста, даже когда список тостов не менялся.
|
||||
const value = useMemo(() => ({ toasts, dismiss }), [toasts, dismiss])
|
||||
|
||||
return <ToastContext value={value}>{children}</ToastContext>
|
||||
}
|
||||
|
||||
export function useToastContext() {
|
||||
|
||||
@@ -36,14 +36,15 @@ function ToastItem({
|
||||
|
||||
// Живой регион остаётся обычным контейнером, а закрытие висит на настоящей кнопке: обработчик
|
||||
// клика на самом сообщении недоступен с клавиатуры, и скринридер о нём никак не сообщает.
|
||||
// <output> — нативный живой регион: роль status у него подразумевается, и поддержка у
|
||||
// скринридеров лучше, чем у div с проставленной ролью.
|
||||
return (
|
||||
<div
|
||||
<output
|
||||
className={cn(
|
||||
'crt-panel pointer-events-auto flex items-start gap-3 rounded-md px-4 py-3 text-sm shadow-lg',
|
||||
variant === 'success' && 'border-primary/60',
|
||||
variant === 'error' && 'border-red-700/60 text-red-400',
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<span className="flex-1">{message}</span>
|
||||
<button
|
||||
@@ -54,6 +55,6 @@ function ToastItem({
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</output>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
@@ -36,12 +44,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return () => media.removeEventListener('change', onChange)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = (next: Theme) => {
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
setThemeState(next)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <ThemeContext value={{ theme, setTheme }}>{children}</ThemeContext>
|
||||
// Литерал в value пересоздавался бы на каждый рендер и перерисовывал всех потребителей темы.
|
||||
const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme])
|
||||
|
||||
return <ThemeContext value={value}>{children}</ThemeContext>
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
|
||||
Reference in New Issue
Block a user