Refactor BumperPlaceholders and BumperFacts for improved logic and readability
Updated the BumperPlaceholders class to streamline token extraction from texts, enhancing performance and clarity. Modified BumperFacts to initialize slot titles with an empty array instead of a dictionary for better consistency. Renamed methods in BumperResolver for clarity, and refactored GridScheduleGenerator to simplify return logic. Additionally, improved the BumperLinesEditor component by implementing a keyed list for better state management and user experience.
This commit is contained in:
@@ -5,6 +5,7 @@ import type { BumperLineColor, BumperLineDto, BumperLineStyle } from '@/shared/a
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { useKeyedList } from '@/shared/lib/keyed-list'
|
||||
import { hasVolatileToken, PLACEHOLDERS, resolveSample, unknownTokens } from '../placeholders'
|
||||
|
||||
const STYLES: BumperLineStyle[] = ['Label', 'Title', 'Caption']
|
||||
@@ -44,45 +45,71 @@ function presets(t: (key: string) => string): { key: string; lines: BumperLineDt
|
||||
* Строки заставки: порядок перетаскиванием, палитра плейсхолдеров под фокусированным полем,
|
||||
* пресеты кнопкой. Ошибка ввода (незнакомый плейсхолдер) видна сразу — сервер её всё равно
|
||||
* отвергнет, но узнавать об этом при сохранении неудобно.
|
||||
*
|
||||
* Список свой, со стабильными ключами строк (см. useKeyedList), а наружу уезжает только значение:
|
||||
* при индексных ключах удаление строки из середины уводило бы фокус в соседнюю. Пересев на другой
|
||||
* подблок, редактор пересоздаётся по `key` — поэтому props читаются только на первом рендере.
|
||||
*/
|
||||
export function BumperLinesEditor({
|
||||
lines,
|
||||
lines: initial,
|
||||
onChange,
|
||||
}: Readonly<{
|
||||
lines: BumperLineDto[]
|
||||
onChange: (lines: BumperLineDto[]) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const focused = useRef<number | null>(null)
|
||||
const inputs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const dragged = useRef<number | null>(null)
|
||||
const { rows, reset, add, remove, patch } = useKeyedList(initial)
|
||||
const focused = useRef<string | null>(null)
|
||||
const inputs = useRef(new Map<string, HTMLInputElement | null>())
|
||||
const dragged = useRef<string | null>(null)
|
||||
|
||||
const patch = (index: number, part: Partial<BumperLineDto>) =>
|
||||
onChange(lines.map((line, i) => (i === index ? { ...line, ...part } : line)))
|
||||
|
||||
const add = () => {
|
||||
const line: BumperLineDto = { style: 'Title', color: 'Text', text: '' }
|
||||
onChange([...lines, line].slice(0, MAX_LINES))
|
||||
const setLine = (key: string, part: Partial<BumperLineDto>) => {
|
||||
patch(key, (line) => ({ ...line, ...part }))
|
||||
// Правка уезжает наверх сразу: сохраняет подблок родитель, у него же лежит остальная форма.
|
||||
onChange(rows.map((row) => (row.key === key ? { ...row.value, ...part } : row.value)))
|
||||
}
|
||||
|
||||
const remove = (index: number) => onChange(lines.filter((_, i) => i !== index))
|
||||
const addLine = () => {
|
||||
if (rows.length >= MAX_LINES) return
|
||||
const line: BumperLineDto = { style: 'Title', color: 'Text', text: '' }
|
||||
add(line)
|
||||
onChange([...rows.map((row) => row.value), line])
|
||||
}
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (from === to) return
|
||||
const next = [...lines]
|
||||
const [line] = next.splice(from, 1)
|
||||
next.splice(to, 0, line)
|
||||
onChange(next)
|
||||
const removeLine = (key: string) => {
|
||||
remove(key)
|
||||
onChange(rows.filter((row) => row.key !== key).map((row) => row.value))
|
||||
}
|
||||
|
||||
const applyPreset = (preset: BumperLineDto[]) => {
|
||||
reset(preset)
|
||||
onChange(preset)
|
||||
}
|
||||
|
||||
const move = (fromKey: string, toKey: string) => {
|
||||
if (fromKey === toKey) return
|
||||
const next = [...rows]
|
||||
const from = next.findIndex((row) => row.key === fromKey)
|
||||
const to = next.findIndex((row) => row.key === toKey)
|
||||
if (from < 0 || to < 0) return
|
||||
|
||||
const [row] = next.splice(from, 1)
|
||||
next.splice(to, 0, row)
|
||||
const values = next.map((r) => r.value)
|
||||
reset(values)
|
||||
onChange(values)
|
||||
}
|
||||
|
||||
/** Вставка плейсхолдера в позицию курсора — иначе его пришлось бы допечатывать руками. */
|
||||
const insert = (token: string) => {
|
||||
const index = focused.current ?? lines.length - 1
|
||||
if (index < 0) return
|
||||
const input = inputs.current[index]
|
||||
const text = lines[index].text
|
||||
const at = input?.selectionStart ?? text.length
|
||||
patch(index, { text: `${text.slice(0, at)}{${token}}${text.slice(at)}` })
|
||||
const key = focused.current ?? rows.at(-1)?.key
|
||||
const row = rows.find((r) => r.key === key)
|
||||
if (!key || !row) return
|
||||
|
||||
const input = inputs.current.get(key)
|
||||
const at = input?.selectionStart ?? row.value.text.length
|
||||
const text = row.value.text
|
||||
setLine(key, { text: `${text.slice(0, at)}{${token}}${text.slice(at)}` })
|
||||
requestAnimationFrame(() => {
|
||||
input?.focus()
|
||||
const caret = at + token.length + 2
|
||||
@@ -101,25 +128,25 @@ export function BumperLinesEditor({
|
||||
key={preset.key}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(preset.lines)}
|
||||
onClick={() => applyPreset(preset.lines)}
|
||||
>
|
||||
{t(`admin.bumpers.preset_${preset.key}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{lines.map((line, index) => {
|
||||
{rows.map(({ key, value: line }) => {
|
||||
const unknown = unknownTokens(line.text)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
key={key}
|
||||
draggable
|
||||
onDragStart={() => {
|
||||
dragged.current = index
|
||||
dragged.current = key
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (dragged.current !== null) move(dragged.current, index)
|
||||
if (dragged.current !== null) move(dragged.current, key)
|
||||
dragged.current = null
|
||||
}}
|
||||
className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/40 p-2"
|
||||
@@ -128,7 +155,7 @@ export function BumperLinesEditor({
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-xs"
|
||||
value={line.style}
|
||||
onChange={(e) => patch(index, { style: e.target.value as BumperLineStyle })}
|
||||
onChange={(e) => setLine(key, { style: e.target.value as BumperLineStyle })}
|
||||
>
|
||||
{STYLES.map((style) => (
|
||||
<option key={style} value={style}>
|
||||
@@ -139,7 +166,7 @@ export function BumperLinesEditor({
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-xs"
|
||||
value={line.color}
|
||||
onChange={(e) => patch(index, { color: e.target.value as BumperLineColor })}
|
||||
onChange={(e) => setLine(key, { color: e.target.value as BumperLineColor })}
|
||||
>
|
||||
{COLORS.map((color) => (
|
||||
<option key={color} value={color}>
|
||||
@@ -149,17 +176,17 @@ export function BumperLinesEditor({
|
||||
</select>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
inputs.current[index] = el
|
||||
inputs.current.set(key, el)
|
||||
}}
|
||||
className={cn('h-8 min-w-40 flex-1', unknown.length > 0 && 'border-destructive')}
|
||||
value={line.text}
|
||||
maxLength={120}
|
||||
onFocus={() => {
|
||||
focused.current = index
|
||||
focused.current = key
|
||||
}}
|
||||
onChange={(e) => patch(index, { text: e.target.value })}
|
||||
onChange={(e) => setLine(key, { text: e.target.value })}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={() => remove(index)}>
|
||||
<Button size="sm" variant="ghost" onClick={() => removeLine(key)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-full pl-6 text-xs">
|
||||
@@ -181,24 +208,27 @@ export function BumperLinesEditor({
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={lines.length >= MAX_LINES} onClick={add}>
|
||||
<Button size="sm" variant="outline" disabled={rows.length >= MAX_LINES} onClick={addLine}>
|
||||
<Plus className="h-4 w-4" /> {t('admin.bumpers.addLine')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Палитра: клик вставляет плейсхолдер в фокусированное поле, подсказка показывает образец. */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{PLACEHOLDERS.map((placeholder) => (
|
||||
<button
|
||||
key={placeholder.token}
|
||||
type="button"
|
||||
title={`${t(`admin.bumpers.tokens.${placeholder.token}`)} → ${placeholder.sample}`}
|
||||
onClick={() => insert(placeholder.token)}
|
||||
className="rounded border border-border px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground hover:border-primary hover:text-foreground"
|
||||
>
|
||||
{`{${placeholder.token}}`}
|
||||
</button>
|
||||
))}
|
||||
{PLACEHOLDERS.map((placeholder) => {
|
||||
const description = t(`admin.bumpers.tokens.${placeholder.token}`)
|
||||
return (
|
||||
<button
|
||||
key={placeholder.token}
|
||||
type="button"
|
||||
title={`${description} → ${placeholder.sample}`}
|
||||
onClick={() => insert(placeholder.token)}
|
||||
className="rounded border border-border px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground hover:border-primary hover:text-foreground"
|
||||
>
|
||||
{`{${placeholder.token}}`}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -300,14 +300,17 @@ export function JunctionChain({
|
||||
{/* Линейка: доля каждого звена в стыке. Пустые (без источника) в неё не попадают. */}
|
||||
{total > 0 && (
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.key}
|
||||
className={KIND_COLORS[step.elements[0].kind]}
|
||||
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
|
||||
title={`${t(`admin.junctions.kinds.${step.elements[0].kind}`)} · ${formatClock(estimates[index].seconds)}`}
|
||||
/>
|
||||
))}
|
||||
{steps.map((step, index) => {
|
||||
const kind = t(`admin.junctions.kinds.${step.elements[0].kind}`)
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
className={KIND_COLORS[step.elements[0].kind]}
|
||||
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
|
||||
title={`${kind} · ${formatClock(estimates[index].seconds)}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -163,14 +163,17 @@ export function StoragePanel() {
|
||||
{/* Полоса состава хранилища — доли областей друг относительно друга. */}
|
||||
{storage > 0 && (
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-muted/40">
|
||||
{areas.map((area) => (
|
||||
<div
|
||||
key={area.area}
|
||||
className={AREA_COLORS[area.area]}
|
||||
style={{ width: `${percentOf(area.bytes, storage)}%` }}
|
||||
title={`${t(`admin.storage.areas.${area.area}`)} · ${formatBytes(area.bytes)}`}
|
||||
/>
|
||||
))}
|
||||
{areas.map((area) => {
|
||||
const name = t(`admin.storage.areas.${area.area}`)
|
||||
return (
|
||||
<div
|
||||
key={area.area}
|
||||
className={AREA_COLORS[area.area]}
|
||||
style={{ width: `${percentOf(area.bytes, storage)}%` }}
|
||||
title={`${name} · ${formatBytes(area.bytes)}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user