diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json
index 6fa991d..1c24eee 100644
--- a/frontend/.oxlintrc.json
+++ b/frontend/.oxlintrc.json
@@ -4,5 +4,15 @@
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
- }
+ },
+ "overrides": [
+ {
+ // File-based routing TanStack Router: файл роута обязан экспортировать `Route` рядом с
+ // компонентом страницы — правило тут неисполнимо в принципе.
+ "files": ["src/routes/**"],
+ "rules": {
+ "react/only-export-components": "off"
+ }
+ }
+ ]
}
diff --git a/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx b/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx
index 8d5fd04..6d9b3e2 100644
--- a/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx
+++ b/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { qk } from '@/shared/api/query-keys'
import { useTranslation } from 'react-i18next'
+import type { EntryTraceDto } from '@/shared/api/types'
import {
Dialog,
DialogContent,
@@ -42,52 +43,11 @@ export function EntryTraceDialog({
{data && (
-
- {data.layerName
- ? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}`
- : null}
-
-
- {data.slotTitle
- ? [
- data.slotTitle,
- data.slotWeekday === null
- ? t('admin.channels.everyDay')
- : t(`admin.channels.weekdays.${data.slotWeekday}`),
- data.slotTargetStart?.slice(0, 5),
- data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
- data.driftMinutes !== 0
- ? t('admin.channels.traceDrift', { minutes: data.driftMinutes })
- : null,
- data.snapped ? t('admin.channels.traceSnapped') : null,
- ]
- .filter(Boolean)
- .join(' · ')
- : null}
-
-
- {data.groupName
- ? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
- : null}
-
+ {layerSummary(data, t)}
+ {slotSummary(data, t)}
+ {groupSummary(data)}
{data.collectionName}
-
- {data.strategy
- ? [
- t(`admin.channels.strategies.${data.strategy}`),
- data.cooldownDays
- ? t('admin.channels.traceCooldown', { days: data.cooldownDays })
- : null,
- data.candidatesAfterCooldown !== null
- ? t('admin.channels.traceCandidates', {
- count: data.candidatesAfterCooldown,
- })
- : null,
- ]
- .filter(Boolean)
- .join(' · ')
- : null}
-
+ {strategySummary(data, t)}
{data.junctionName}
)}
@@ -96,6 +56,49 @@ export function EntryTraceDialog({
)
}
+type Translate = ReturnType['t']
+
+/** Склейка непустых частей строки трейса; пусто — значит строка не заполнена (покажем «—»). */
+const joinParts = (parts: (string | null | undefined)[]) => parts.filter(Boolean).join(' · ') || null
+
+function layerSummary(data: EntryTraceDto, t: Translate) {
+ if (!data.layerName) return null
+ const priority =
+ data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''
+ return `${data.layerName}${priority}`
+}
+
+function slotSummary(data: EntryTraceDto, t: Translate) {
+ if (!data.slotTitle) return null
+ return joinParts([
+ data.slotTitle,
+ data.slotWeekday === null
+ ? t('admin.channels.everyDay')
+ : t(`admin.channels.weekdays.${data.slotWeekday}`),
+ data.slotTargetStart?.slice(0, 5),
+ data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
+ data.driftMinutes !== 0 ? t('admin.channels.traceDrift', { minutes: data.driftMinutes }) : null,
+ data.snapped ? t('admin.channels.traceSnapped') : null,
+ ])
+}
+
+function groupSummary(data: EntryTraceDto) {
+ if (!data.groupName) return null
+ const count = data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''
+ return `${data.groupName}${count}`
+}
+
+function strategySummary(data: EntryTraceDto, t: Translate) {
+ if (!data.strategy) return null
+ return joinParts([
+ t(`admin.channels.strategies.${data.strategy}`),
+ data.cooldownDays ? t('admin.channels.traceCooldown', { days: data.cooldownDays }) : null,
+ data.candidatesAfterCooldown !== null
+ ? t('admin.channels.traceCandidates', { count: data.candidatesAfterCooldown })
+ : null,
+ ])
+}
+
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
diff --git a/frontend/src/features/admin/channels/components/GridTab.tsx b/frontend/src/features/admin/channels/components/GridTab.tsx
index 227ba00..0d9b378 100644
--- a/frontend/src/features/admin/channels/components/GridTab.tsx
+++ b/frontend/src/features/admin/channels/components/GridTab.tsx
@@ -55,6 +55,9 @@ export function GridTab({
const [copyTargets, setCopyTargets] = useState([])
const [copyFromChannel, setCopyFromChannel] = useState('')
+ const toggleCopyTarget = (day: number, checked: boolean) =>
+ setCopyTargets((current) => (checked ? [...current, day] : current.filter((d) => d !== day)))
+
const { data: channels } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
const addLayerMutation = useMutation({
@@ -318,13 +321,7 @@ export function GridTab({
- setCopyTargets((current) =>
- e.target.checked
- ? [...current, day]
- : current.filter((d) => d !== day),
- )
- }
+ onChange={(e) => toggleCopyTarget(day, e.target.checked)}
/>
{t(`admin.channels.weekdays.${day}`)}
diff --git a/frontend/src/features/admin/channels/components/RulesCard.tsx b/frontend/src/features/admin/channels/components/RulesCard.tsx
index 71c579a..b029781 100644
--- a/frontend/src/features/admin/channels/components/RulesCard.tsx
+++ b/frontend/src/features/admin/channels/components/RulesCard.tsx
@@ -87,6 +87,9 @@ export function RulesCard({
onError,
})
+ const removeWindow = (key: string) =>
+ setWindows((current) => current.filter((row) => row.key !== key))
+
const patchWindow = (key: string, part: Partial) =>
setWindows((current) =>
current.map((row) =>
@@ -156,7 +159,7 @@ export function RulesCard({
diff --git a/frontend/src/features/admin/collections/CollectionsPanel.tsx b/frontend/src/features/admin/collections/CollectionsPanel.tsx
index d1f2201..2d56f1c 100644
--- a/frontend/src/features/admin/collections/CollectionsPanel.tsx
+++ b/frontend/src/features/admin/collections/CollectionsPanel.tsx
@@ -7,7 +7,8 @@ import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
-import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
+import { sortRows, useTableSort } from '@/shared/lib/table-sort'
+import { SortHeader } from '@/shared/ui/sortable'
import { createCollection, deleteCollection, listCollections } from './api'
export function CollectionsPanel() {
diff --git a/frontend/src/features/admin/genres/GenresPanel.tsx b/frontend/src/features/admin/genres/GenresPanel.tsx
index e8202fa..eaa7fb1 100644
--- a/frontend/src/features/admin/genres/GenresPanel.tsx
+++ b/frontend/src/features/admin/genres/GenresPanel.tsx
@@ -19,7 +19,8 @@ import {
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
-import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
+import { sortRows, useTableSort } from '@/shared/lib/table-sort'
+import { SortHeader } from '@/shared/ui/sortable'
import { createGenre, deleteGenre, listGenres, updateGenre } from './api'
const createSchema = z.object({
diff --git a/frontend/src/features/admin/groups/GroupsPanel.tsx b/frontend/src/features/admin/groups/GroupsPanel.tsx
index 1a38f7f..208af53 100644
--- a/frontend/src/features/admin/groups/GroupsPanel.tsx
+++ b/frontend/src/features/admin/groups/GroupsPanel.tsx
@@ -7,7 +7,8 @@ import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
-import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
+import { sortRows, useTableSort } from '@/shared/lib/table-sort'
+import { SortHeader } from '@/shared/ui/sortable'
import { createGroup, deleteGroup, listGroups } from './api'
import { DurationLabel } from './DurationLabel'
diff --git a/frontend/src/features/admin/interstitials/BlockBuilder.tsx b/frontend/src/features/admin/interstitials/BlockBuilder.tsx
index 2e0ed35..b956d4d 100644
--- a/frontend/src/features/admin/interstitials/BlockBuilder.tsx
+++ b/frontend/src/features/admin/interstitials/BlockBuilder.tsx
@@ -51,6 +51,8 @@ export function BlockBuilder({
setItems((current) => [...current, item])
}
+ const removeAt = (index: number) => setItems((current) => current.filter((_, i) => i !== index))
+
/** Перестановка внутри черновика — до сохранения всё живёт в состоянии, запросов нет. */
const reorder = (target: number) => {
if (dragged === null || dragged === target) return
@@ -110,7 +112,7 @@ export function BlockBuilder({
diff --git a/frontend/src/features/admin/media/ManualInboxDialog.tsx b/frontend/src/features/admin/media/ManualInboxDialog.tsx
index 75db6bd..b3dbec3 100644
--- a/frontend/src/features/admin/media/ManualInboxDialog.tsx
+++ b/frontend/src/features/admin/media/ManualInboxDialog.tsx
@@ -188,6 +188,11 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
)
+ const toggleCollapsed = (folder: string) =>
+ setCollapsed((current) =>
+ current.includes(folder) ? current.filter((f) => f !== folder) : [...current, folder],
+ )
+
const toggleFolder = (files: ManualInboxFileDto[]) => {
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
const allSelected = paths.every((p) => selected.includes(p))
@@ -355,11 +360,7 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {