diff --git a/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx b/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx index 337dfed..c2df1a9 100644 --- a/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx +++ b/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx @@ -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(layer.applicability?.weekdays ?? []) - const [dateRanges, setDateRanges] = useState(layer.applicability?.dateRanges ?? []) - const [annualRanges, setAnnualRanges] = useState( - layer.applicability?.annualRanges ?? [], - ) - const [specificDates, setSpecificDates] = useState( - layer.applicability?.specificDates ?? [], - ) + const dateRanges = useKeyedList(layer.applicability?.dateRanges ?? []) + const annualRanges = useKeyedList(layer.applicability?.annualRanges ?? []) + const specificDates = useKeyedList(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({
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) => ( -
  • + {dateRanges.rows.map(({ key, value: range }) => ( +
  • - setDateRanges((c) => - c.map((r, i) => (i === index ? { ...r, from: e.target.value } : r)), - ) + dateRanges.patch(key, (r) => ({ ...r, from: e.target.value })) } /> - 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 }))} /> -
  • @@ -150,33 +137,23 @@ export function LayerApplicabilityDialog({
    - 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) => ( -
  • + {annualRanges.rows.map(({ key, value: range }) => ( +
  • - setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r))) - } + onChange={(part) => annualRanges.patch(key, (r) => ({ ...r, ...part }))} /> - setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r))) - } + onChange={(part) => annualRanges.patch(key, (r) => ({ ...r, ...part }))} /> -
  • @@ -185,24 +162,18 @@ export function LayerApplicabilityDialog({
    setSpecificDates((c) => [...c, today])} - empty={specificDates.length === 0} + onAdd={() => specificDates.add(today)} + empty={specificDates.rows.length === 0} > - {specificDates.map((date, index) => ( -
  • + {specificDates.rows.map(({ key, value: date }) => ( +
  • - setSpecificDates((c) => c.map((d, i) => (i === index ? e.target.value : d))) - } + onChange={(e) => specificDates.patch(key, () => e.target.value)} /> -
  • diff --git a/frontend/src/features/admin/channels/components/RulesCard.tsx b/frontend/src/features/admin/channels/components/RulesCard.tsx index 6be588c..71c579a 100644 --- a/frontend/src/features/admin/channels/components/RulesCard.tsx +++ b/frontend/src/features/admin/channels/components/RulesCard.tsx @@ -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( - () => template.rules?.maxAudienceByTime ?? [], + const [windows, setWindows] = useState(() => + 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) => - setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w))) + const patchWindow = (key: string, part: Partial) => + setWindows((current) => + current.map((row) => + row.key === key ? { ...row, window: { ...row.window, ...part } } : row, + ), + ) return ( @@ -93,7 +107,7 @@ export function RulesCard({ @@ -103,15 +117,15 @@ export function RulesCard({

    {t('admin.channels.noAudienceWindows')}

    ) : (
      - {windows.map((window, index) => ( -
    • + {windows.map(({ key, window }) => ( +
    • patchWindow(index, { from: `${e.target.value}:00` })} + onChange={(e) => patchWindow(key, { from: `${e.target.value}:00` })} />
      @@ -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` })} />
      @@ -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({ diff --git a/frontend/src/features/admin/channels/components/ScheduleGrid.tsx b/frontend/src/features/admin/channels/components/ScheduleGrid.tsx index fd46ece..eaa81c7 100644 --- a/frontend/src/features/admin/channels/components/ScheduleGrid.tsx +++ b/frontend/src/features/admin/channels/components/ScheduleGrid.tsx @@ -174,9 +174,9 @@ export function ScheduleGrid({
      - {hours.map((hour, index) => ( + {hours.map((hour) => (
      @@ -197,9 +197,9 @@ export function ScheduleGrid({ onDragOver={(e) => dragged && e.preventDefault()} onDrop={(e) => drop(e, weekday)} > - {hours.map((_, index) => ( + {hours.map((hour, index) => ( -
      + ) } diff --git a/frontend/src/theme/ThemeProvider.tsx b/frontend/src/theme/ThemeProvider.tsx index 563d4b3..18d1ffa 100644 --- a/frontend/src/theme/ThemeProvider.tsx +++ b/frontend/src/theme/ThemeProvider.tsx @@ -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 {children} + // Литерал в value пересоздавался бы на каждый рендер и перерисовывал всех потребителей темы. + const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]) + + return {children} } export function useTheme(): ThemeContextValue {