Enhance EntryTraceDialog component by refactoring data display logic into dedicated summary functions for improved readability and maintainability. Update GridTab to streamline checkbox state management with a new toggle function. Refactor RulesCard to simplify window removal logic. Adjust CollectionsPanel, GenresPanel, GroupsPanel, RolesPanel, ShowsPanel, and UsersPanel to import sorting utilities from a centralized location, enhancing code organization. Update ThemeProvider to utilize a shared theme context for better consistency across the application.
This commit is contained in:
@@ -4,5 +4,15 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"react/rules-of-hooks": "error",
|
"react/rules-of-hooks": "error",
|
||||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
// File-based routing TanStack Router: файл роута обязан экспортировать `Route` рядом с
|
||||||
|
// компонентом страницы — правило тут неисполнимо в принципе.
|
||||||
|
"files": ["src/routes/**"],
|
||||||
|
"rules": {
|
||||||
|
"react/only-export-components": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { qk } from '@/shared/api/query-keys'
|
import { qk } from '@/shared/api/query-keys'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { EntryTraceDto } from '@/shared/api/types'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -42,52 +43,11 @@ export function EntryTraceDialog({
|
|||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
|
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||||
<Row label={t('admin.channels.traceLayer')}>
|
<Row label={t('admin.channels.traceLayer')}>{layerSummary(data, t)}</Row>
|
||||||
{data.layerName
|
<Row label={t('admin.channels.traceSlot')}>{slotSummary(data, t)}</Row>
|
||||||
? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}`
|
<Row label={t('admin.channels.traceGroup')}>{groupSummary(data)}</Row>
|
||||||
: null}
|
|
||||||
</Row>
|
|
||||||
<Row label={t('admin.channels.traceSlot')}>
|
|
||||||
{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}
|
|
||||||
</Row>
|
|
||||||
<Row label={t('admin.channels.traceGroup')}>
|
|
||||||
{data.groupName
|
|
||||||
? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
|
|
||||||
: null}
|
|
||||||
</Row>
|
|
||||||
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
|
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
|
||||||
<Row label={t('admin.channels.traceStrategy')}>
|
<Row label={t('admin.channels.traceStrategy')}>{strategySummary(data, t)}</Row>
|
||||||
{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}
|
|
||||||
</Row>
|
|
||||||
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
|
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
@@ -96,6 +56,49 @@ export function EntryTraceDialog({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Translate = ReturnType<typeof useTranslation>['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 }) {
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export function GridTab({
|
|||||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||||
const [copyFromChannel, setCopyFromChannel] = 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 { data: channels } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
|
||||||
|
|
||||||
const addLayerMutation = useMutation({
|
const addLayerMutation = useMutation({
|
||||||
@@ -318,13 +321,7 @@ export function GridTab({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={copyTargets.includes(day)}
|
checked={copyTargets.includes(day)}
|
||||||
onChange={(e) =>
|
onChange={(e) => toggleCopyTarget(day, e.target.checked)}
|
||||||
setCopyTargets((current) =>
|
|
||||||
e.target.checked
|
|
||||||
? [...current, day]
|
|
||||||
: current.filter((d) => d !== day),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{t(`admin.channels.weekdays.${day}`)}
|
{t(`admin.channels.weekdays.${day}`)}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export function RulesCard({
|
|||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const removeWindow = (key: string) =>
|
||||||
|
setWindows((current) => current.filter((row) => row.key !== key))
|
||||||
|
|
||||||
const patchWindow = (key: string, part: Partial<AudienceWindow>) =>
|
const patchWindow = (key: string, part: Partial<AudienceWindow>) =>
|
||||||
setWindows((current) =>
|
setWindows((current) =>
|
||||||
current.map((row) =>
|
current.map((row) =>
|
||||||
@@ -156,7 +159,7 @@ export function RulesCard({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setWindows((c) => c.filter((row) => row.key !== key))}
|
onClick={() => removeWindow(key)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { qk } from '@/shared/api/query-keys'
|
|||||||
import { useApiError } from '@/shared/lib/use-api-error'
|
import { useApiError } from '@/shared/lib/use-api-error'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
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'
|
import { createCollection, deleteCollection, listCollections } from './api'
|
||||||
|
|
||||||
export function CollectionsPanel() {
|
export function CollectionsPanel() {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
} from '@/shared/ui/dialog'
|
} from '@/shared/ui/dialog'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
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'
|
import { createGenre, deleteGenre, listGenres, updateGenre } from './api'
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { useApiError } from '@/shared/lib/use-api-error'
|
|||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
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 { createGroup, deleteGroup, listGroups } from './api'
|
||||||
import { DurationLabel } from './DurationLabel'
|
import { DurationLabel } from './DurationLabel'
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export function BlockBuilder({
|
|||||||
setItems((current) => [...current, item])
|
setItems((current) => [...current, item])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeAt = (index: number) => setItems((current) => current.filter((_, i) => i !== index))
|
||||||
|
|
||||||
/** Перестановка внутри черновика — до сохранения всё живёт в состоянии, запросов нет. */
|
/** Перестановка внутри черновика — до сохранения всё живёт в состоянии, запросов нет. */
|
||||||
const reorder = (target: number) => {
|
const reorder = (target: number) => {
|
||||||
if (dragged === null || dragged === target) return
|
if (dragged === null || dragged === target) return
|
||||||
@@ -110,7 +112,7 @@ export function BlockBuilder({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setItems((c) => c.filter((_, i) => i !== index))}
|
onClick={() => removeAt(index)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -188,6 +188,11 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
|||||||
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
|
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 toggleFolder = (files: ManualInboxFileDto[]) => {
|
||||||
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
|
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
|
||||||
const allSelected = paths.every((p) => selected.includes(p))
|
const allSelected = paths.every((p) => selected.includes(p))
|
||||||
@@ -355,11 +360,7 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
onClick={() =>
|
onClick={() => toggleCollapsed(folder)}
|
||||||
setCollapsed((c) =>
|
|
||||||
c.includes(folder) ? c.filter((f) => f !== folder) : [...c, folder],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
|||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Pager } from '@/shared/ui/pager'
|
import { Pager } from '@/shared/ui/pager'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
|
import { useTableSort } from '@/shared/lib/table-sort'
|
||||||
|
import { SortHeader } from '@/shared/ui/sortable'
|
||||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||||
|
import { formatDuration } from './format'
|
||||||
import { ManualInboxDialog } from './ManualInboxDialog'
|
import { ManualInboxDialog } from './ManualInboxDialog'
|
||||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||||
import { useUploadStore } from './upload-store'
|
import { useUploadStore } from './upload-store'
|
||||||
@@ -28,16 +30,6 @@ const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
|
|||||||
Failed: ['Failed'],
|
Failed: ['Failed'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(seconds: number | null): string {
|
|
||||||
if (seconds == null) return '—'
|
|
||||||
const total = Math.round(seconds)
|
|
||||||
const h = Math.floor(total / 3600)
|
|
||||||
const m = Math.floor((total % 3600) / 60)
|
|
||||||
const s = total % 60
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
|
||||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
|
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
|
||||||
Ready: 'default',
|
Ready: 'default',
|
||||||
Processing: 'muted',
|
Processing: 'muted',
|
||||||
|
|||||||
@@ -7,54 +7,60 @@ type ParseOptions = {
|
|||||||
|
|
||||||
export type ParsedEpisode = { season: number | null; episode: number | null }
|
export type ParsedEpisode = { season: number | null; episode: number | null }
|
||||||
|
|
||||||
|
/** Встроенные шаблоны: SxxEyy, NxNN, ведущий номер серии. */
|
||||||
|
function parseBuiltin(name: string): ParsedEpisode {
|
||||||
|
const se = name.match(/[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/)
|
||||||
|
if (se) return { season: Number(se[1]), episode: Number(se[2]) }
|
||||||
|
|
||||||
|
const nx = name.match(/(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i)
|
||||||
|
if (nx) return { season: Number(nx[1]), episode: Number(nx[2]) }
|
||||||
|
|
||||||
|
// Ведущий номер серии: «01. Название», «02 - Название», «03_Название», «4) Название».
|
||||||
|
const lead = name.match(/^\s*(\d{1,3})[\s._)\]-]/)
|
||||||
|
return { season: null, episode: lead ? Number(lead[1]) : null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пользовательский regex: 1 группа = серия, 2 группы = (сезон, серия). <c>null</c> — шаблон не
|
||||||
|
* сработал (или невалиден), распознанное встроенными шаблонами остаётся как есть. <c>season: null</c>
|
||||||
|
* при одной группе означает «сезон не трогаем».
|
||||||
|
*/
|
||||||
|
function parseCustom(name: string, pattern: string): ParsedEpisode | null {
|
||||||
|
let match: RegExpMatchArray | null
|
||||||
|
try {
|
||||||
|
match = name.match(new RegExp(pattern, 'i'))
|
||||||
|
} catch {
|
||||||
|
return null // невалидный regex — просто игнорируем
|
||||||
|
}
|
||||||
|
if (!match) return null
|
||||||
|
if (match.length >= 3 && match[1] != null && match[2] != null) {
|
||||||
|
return { season: Number(match[1]), episode: Number(match[2]) }
|
||||||
|
}
|
||||||
|
return match[1] != null ? { season: null, episode: Number(match[1]) } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const finiteOrNull = (value: number | null) =>
|
||||||
|
value != null && Number.isFinite(value) ? value : null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Пытается распознать сезон/серию из имени файла. Сначала встроенные шаблоны (SxxEyy, NxNN), затем —
|
* Пытается распознать сезон/серию из имени файла. Сначала встроенные шаблоны (SxxEyy, NxNN), затем —
|
||||||
* пользовательский regex (перебивает серию, а при двух группах и сезон), в конце — ручной сезон.
|
* пользовательский regex (перебивает серию, а при двух группах и сезон), в конце — ручной сезон.
|
||||||
* Если серия распознана, а сезон нет — сезон считается первым.
|
* Если серия распознана, а сезон нет — сезон считается первым.
|
||||||
*/
|
*/
|
||||||
export function parseEpisodeName(name: string, opts?: ParseOptions): ParsedEpisode {
|
export function parseEpisodeName(name: string, opts?: ParseOptions): ParsedEpisode {
|
||||||
let season: number | null = null
|
let { season, episode } = parseBuiltin(name)
|
||||||
let episode: number | null = null
|
|
||||||
|
|
||||||
const se = name.match(/[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/)
|
|
||||||
if (se) {
|
|
||||||
season = Number(se[1])
|
|
||||||
episode = Number(se[2])
|
|
||||||
} else {
|
|
||||||
const nx = name.match(/(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i)
|
|
||||||
if (nx) {
|
|
||||||
season = Number(nx[1])
|
|
||||||
episode = Number(nx[2])
|
|
||||||
} else {
|
|
||||||
// Ведущий номер серии: «01. Название», «02 - Название», «03_Название», «4) Название».
|
|
||||||
const lead = name.match(/^\s*(\d{1,3})[\s._)\]-]/)
|
|
||||||
if (lead) episode = Number(lead[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawRegex = opts?.episodeRegex?.trim()
|
const rawRegex = opts?.episodeRegex?.trim()
|
||||||
if (rawRegex) {
|
const custom = rawRegex ? parseCustom(name, rawRegex) : null
|
||||||
try {
|
if (custom) {
|
||||||
const match = name.match(new RegExp(rawRegex, 'i'))
|
episode = custom.episode
|
||||||
if (match) {
|
if (custom.season != null) season = custom.season
|
||||||
if (match.length >= 3 && match[1] != null && match[2] != null) {
|
|
||||||
season = Number(match[1])
|
|
||||||
episode = Number(match[2])
|
|
||||||
} else if (match[1] != null) {
|
|
||||||
episode = Number(match[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// невалидный regex — просто игнорируем
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts?.seasonOverride != null) season = opts.seasonOverride
|
if (opts?.seasonOverride != null) season = opts.seasonOverride
|
||||||
if (episode != null && season == null) season = 1
|
if (episode != null && season == null) season = 1
|
||||||
|
|
||||||
if (episode != null && !Number.isFinite(episode)) episode = null
|
return { season: finiteOrNull(season), episode: finiteOrNull(episode) }
|
||||||
if (season != null && !Number.isFinite(season)) season = null
|
|
||||||
return { season, episode }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pad2 = (n: number) => String(n).padStart(2, '0')
|
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Длительность в «ч:мм:сс» (часы — только когда есть); null — прочерк. */
|
||||||
|
export function formatDuration(seconds: number | null): string {
|
||||||
|
if (seconds == null) return '—'
|
||||||
|
const total = Math.round(seconds)
|
||||||
|
const h = Math.floor(total / 3600)
|
||||||
|
const m = Math.floor((total % 3600) / 60)
|
||||||
|
const s = total % 60
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { queryClient } from '@/shared/api/query-client'
|
|||||||
import { importInterstitials } from '@/features/admin/interstitials/api'
|
import { importInterstitials } from '@/features/admin/interstitials/api'
|
||||||
import { addEpisode } from '@/features/admin/shows/api'
|
import { addEpisode } from '@/features/admin/shows/api'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import type { CreatedIdResponse } from '@/shared/api/types'
|
||||||
import { listMedia, uploadMedia } from './api'
|
import { listMedia, uploadMedia } from './api'
|
||||||
|
|
||||||
export type UploadItem = {
|
export type UploadItem = {
|
||||||
@@ -67,51 +68,43 @@ const patch = (id: string, changes: Partial<UploadItem>) =>
|
|||||||
items: s.items.map((i) => (i.id === id ? { ...i, ...changes } : i)),
|
items: s.items.map((i) => (i.id === id ? { ...i, ...changes } : i)),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
async function pump() {
|
/**
|
||||||
if (running) return
|
* Стоит ли повторить попытку: отмена — нет; истёкший access-токен (XHR идёт мимо авто-refresh) —
|
||||||
running = true
|
* обновляем и повторяем сразу; прочие временные сбои — после паузы.
|
||||||
useUploadStore.setState({ active: true, minimized: false })
|
*/
|
||||||
|
async function shouldRetry(error: unknown, attempt: number, signal: AbortSignal): Promise<boolean> {
|
||||||
|
if (isAbort(error) || signal.aborted) return false
|
||||||
|
if (attempt >= MAX_ATTEMPTS) return false
|
||||||
|
if (error instanceof HttpError && error.status === 401) return await refreshAccessToken()
|
||||||
|
if (!isTransient(error)) return false
|
||||||
|
await delay(RETRY_DELAY_MS)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
while (queue.length > 0) {
|
/** Аплоад с ретраями временных сбоев (например, 502 от прокси) — до MAX_ATTEMPTS попыток. */
|
||||||
const job = queue.shift()!
|
async function uploadWithRetries(
|
||||||
const controller = new AbortController()
|
job: Job,
|
||||||
controllers.set(job.id, controller)
|
signal: AbortSignal,
|
||||||
|
): Promise<{ created: CreatedIdResponse | null; lastError: unknown }> {
|
||||||
let created: { id: string } | null = null
|
|
||||||
let lastError: unknown = null
|
let lastError: unknown = null
|
||||||
// Ретраим временные сбои (например, 502 от прокси) — до MAX_ATTEMPTS попыток.
|
|
||||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
patch(job.id, { status: 'uploading', percent: 0 })
|
patch(job.id, { status: 'uploading', percent: 0 })
|
||||||
try {
|
try {
|
||||||
created = await uploadMedia(
|
const created = await uploadMedia(job.file, (percent) => patch(job.id, { percent }), signal)
|
||||||
job.file,
|
return { created, lastError: null }
|
||||||
(percent) => patch(job.id, { percent }),
|
|
||||||
controller.signal,
|
|
||||||
)
|
|
||||||
lastError = null
|
|
||||||
break
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error
|
lastError = error
|
||||||
if (isAbort(error) || controller.signal.aborted) break
|
if (!(await shouldRetry(error, attempt, signal))) break
|
||||||
// Истёк access-токен (XHR идёт мимо авто-refresh) — обновляем и повторяем сразу.
|
|
||||||
if (error instanceof HttpError && error.status === 401 && attempt < MAX_ATTEMPTS) {
|
|
||||||
if (await refreshAccessToken()) continue
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (attempt < MAX_ATTEMPTS && isTransient(error)) await delay(RETRY_DELAY_MS)
|
|
||||||
else break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
controllers.delete(job.id)
|
return { created: null, lastError }
|
||||||
|
}
|
||||||
|
|
||||||
if (created) {
|
/** Что делаем со свежим ассетом: привязка к шоу серией (порядок — как в очереди) либо ролик. */
|
||||||
patch(job.id, { status: 'done', percent: 100 })
|
async function linkUploaded(job: Job, assetId: string) {
|
||||||
void queryClient.invalidateQueries({ queryKey: qk.media.all })
|
|
||||||
|
|
||||||
// Привязка к шоу: добавляем свежий ассет серией (порядок — как в очереди).
|
|
||||||
if (job.showId) {
|
if (job.showId) {
|
||||||
try {
|
try {
|
||||||
await addEpisode(job.showId, created.id)
|
await addEpisode(job.showId, assetId)
|
||||||
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`${job.file.name}: не удалось добавить в шоу`)
|
toast.error(`${job.file.name}: не удалось добавить в шоу`)
|
||||||
@@ -119,20 +112,38 @@ async function pump() {
|
|||||||
} else if (job.interstitial) {
|
} else if (job.interstitial) {
|
||||||
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
|
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
|
||||||
try {
|
try {
|
||||||
await importInterstitials([created.id])
|
await importInterstitials([assetId])
|
||||||
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
|
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`${job.file.name}: не удалось завести ролик`)
|
toast.error(`${job.file.name}: не удалось завести ролик`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isAbort(lastError) || controller.signal.aborted) {
|
}
|
||||||
// Отмена — тихо: элемент уже убран из списка.
|
|
||||||
} else {
|
async function runJob(job: Job) {
|
||||||
failed.set(job.id, job) // сохраняем для ручного повтора
|
const controller = new AbortController()
|
||||||
|
controllers.set(job.id, controller)
|
||||||
|
const { created, lastError } = await uploadWithRetries(job, controller.signal)
|
||||||
|
controllers.delete(job.id)
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
patch(job.id, { status: 'done', percent: 100 })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: qk.media.all })
|
||||||
|
await linkUploaded(job, created.id)
|
||||||
|
} else if (!isAbort(lastError) && !controller.signal.aborted) {
|
||||||
|
// Отмена — тихо: элемент уже убран из списка; всё остальное оставляем для ручного повтора.
|
||||||
|
failed.set(job.id, job)
|
||||||
patch(job.id, { status: 'error' })
|
patch(job.id, { status: 'error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pump() {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
useUploadStore.setState({ active: true, minimized: false })
|
||||||
|
|
||||||
|
while (queue.length > 0) await runJob(queue.shift()!)
|
||||||
|
|
||||||
running = false
|
running = false
|
||||||
useUploadStore.setState({ active: false })
|
useUploadStore.setState({ active: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
} from '@/shared/ui/dialog'
|
} from '@/shared/ui/dialog'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
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 { createRole, deleteRole, listRoles, updateRole } from './api'
|
import { createRole, deleteRole, listRoles, updateRole } from './api'
|
||||||
|
|
||||||
const schema = z.object({ name: z.string().min(1).max(64) })
|
const schema = z.object({ name: z.string().min(1).max(64) })
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
formatSeasonEpisode,
|
formatSeasonEpisode,
|
||||||
parseEpisodeName,
|
parseEpisodeName,
|
||||||
} from '@/features/admin/media/episode-parse'
|
} from '@/features/admin/media/episode-parse'
|
||||||
import { formatDuration } from '@/features/admin/media/MediaPanel'
|
import { formatDuration } from '@/features/admin/media/format'
|
||||||
import { ShowGenresField } from './ShowGenresField'
|
import { ShowGenresField } from './ShowGenresField'
|
||||||
import { ShowMetadataCard } from './ShowMetadataCard'
|
import { ShowMetadataCard } from './ShowMetadataCard'
|
||||||
import { imageUrl } from '@/features/admin/images/api'
|
import { imageUrl } from '@/features/admin/images/api'
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { Button } from '@/shared/ui/button'
|
|||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Pager } from '@/shared/ui/pager'
|
import { Pager } from '@/shared/ui/pager'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
||||||
|
import { SortHeader } from '@/shared/ui/sortable'
|
||||||
import { listGenres } from '@/features/admin/genres/api'
|
import { listGenres } from '@/features/admin/genres/api'
|
||||||
import { createShow, deleteShow, listShows } from './api'
|
import { createShow, deleteShow, listShows } from './api'
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
|
import { useTableSort } from '@/shared/lib/table-sort'
|
||||||
|
import { SortHeader } from '@/shared/ui/sortable'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import type { UserSummaryDto } from '@/shared/api/types'
|
import type { UserSummaryDto } from '@/shared/api/types'
|
||||||
import { changeUserRole } from '@/features/admin/roles/api'
|
import { changeUserRole } from '@/features/admin/roles/api'
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ function readStoredAudio(): { volume: number; muted: boolean } {
|
|||||||
return { volume: 1, muted: true }
|
return { volume: 1, muted: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстанавливает сохранённую громкость/mute и запускает воспроизведение; если браузер блокирует
|
||||||
|
* автоплей со звуком — откатывается на воспроизведение без звука (о чём сообщает <c>onMuted</c>).
|
||||||
|
*/
|
||||||
|
function startPlaybackWithAudio(
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
audio: { volume: number; muted: boolean },
|
||||||
|
onMuted: (muted: boolean) => void,
|
||||||
|
) {
|
||||||
|
video.volume = audio.volume
|
||||||
|
video.muted = audio.muted
|
||||||
|
video.play().catch(() => {
|
||||||
|
video.muted = true
|
||||||
|
onMuted(true)
|
||||||
|
void video.play().catch(() => undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
||||||
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
||||||
@@ -101,18 +119,7 @@ export function ChannelPlayer({
|
|||||||
const src = `/api/channels/${slug}/live.m3u8`
|
const src = `/api/channels/${slug}/live.m3u8`
|
||||||
let hls: Hls | null = null
|
let hls: Hls | null = null
|
||||||
|
|
||||||
// Восстанавливаем сохранённую громкость/mute и запускаем; если браузер блокирует автоплей со
|
const startPlayback = () => startPlaybackWithAudio(video, audioRef.current, setMuted)
|
||||||
// звуком — откатываемся на воспроизведение без звука.
|
|
||||||
const startPlayback = () => {
|
|
||||||
const audio = audioRef.current
|
|
||||||
video.volume = audio.volume
|
|
||||||
video.muted = audio.muted
|
|
||||||
video.play().catch(() => {
|
|
||||||
video.muted = true
|
|
||||||
setMuted(true)
|
|
||||||
void video.play().catch(() => undefined)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
||||||
const onNativeError = () => onUnavailable?.()
|
const onNativeError = () => onUnavailable?.()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { RouterProvider } from '@tanstack/react-router'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import './shared/lib/i18n'
|
import './shared/lib/i18n'
|
||||||
import { ThemeProvider } from './theme/ThemeProvider'
|
import { ThemeProvider } from './theme/ThemeProvider'
|
||||||
import { ToastProvider } from './shared/ui/toast-store'
|
import { ToastProvider } from './shared/ui/ToastProvider'
|
||||||
import { Toaster } from './shared/ui/toaster'
|
import { Toaster } from './shared/ui/toaster'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import { queryClient } from './shared/api/query-client'
|
import { queryClient } from './shared/api/query-client'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { Menu, Radio, X } from 'lucide-react'
|
import { Menu, Radio, X } from 'lucide-react'
|
||||||
import { useAuthStore } from '@/features/auth/store'
|
import { useAuthStore } from '@/features/auth/store'
|
||||||
import { bootstrapSession, logout, clearSession } from '@/features/auth/api'
|
import { bootstrapSession, logout, clearSession } from '@/features/auth/api'
|
||||||
import { useTheme } from '@/theme/ThemeProvider'
|
import { useTheme } from '@/theme/theme-context'
|
||||||
import { setLanguage } from '@/shared/lib/i18n'
|
import { setLanguage } from '@/shared/lib/i18n'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
import { UploadSnackbar } from '@/features/admin/media/UploadSnackbar'
|
import { UploadSnackbar } from '@/features/admin/media/UploadSnackbar'
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export type SortState = { key: string; desc: boolean }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние сортировки таблицы. Клик по новому столбцу — по возрастанию; повторный клик по тому же —
|
||||||
|
* переключает направление. Для серверных списков `sort`/`desc` передаются в API (и в queryKey), для
|
||||||
|
* клиентских — в {@link sortRows}.
|
||||||
|
*/
|
||||||
|
export function useTableSort(defaultKey: string, defaultDesc = false) {
|
||||||
|
const [sort, setSort] = useState<SortState>({ key: defaultKey, desc: defaultDesc })
|
||||||
|
const toggle = (key: string) =>
|
||||||
|
setSort((s) => (s.key === key ? { key, desc: !s.desc } : { key, desc: false }))
|
||||||
|
return { sort, toggle }
|
||||||
|
}
|
||||||
|
|
||||||
|
type Comparable = string | number | boolean | null | undefined
|
||||||
|
|
||||||
|
/** Клиентская сортировка строк по выбранному ключу (для непагинированных списков). nulls — в конец. */
|
||||||
|
export function sortRows<T>(
|
||||||
|
rows: T[],
|
||||||
|
sort: SortState,
|
||||||
|
accessors: Record<string, (row: T) => Comparable>,
|
||||||
|
): T[] {
|
||||||
|
const accessor = accessors[sort.key]
|
||||||
|
if (!accessor) return rows
|
||||||
|
const dir = sort.desc ? -1 : 1
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const av = accessor(a)
|
||||||
|
const bv = accessor(b)
|
||||||
|
if (av == null && bv == null) return 0
|
||||||
|
if (av == null) return 1
|
||||||
|
if (bv == null) return -1
|
||||||
|
if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir
|
||||||
|
return (av < bv ? -1 : av > bv ? 1 : 0) * dir
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
ToastContext,
|
||||||
|
registerToastPush,
|
||||||
|
type ToastItem,
|
||||||
|
type ToastVariant,
|
||||||
|
} from './toast-store'
|
||||||
|
|
||||||
|
let nextId = 1
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
|
|
||||||
|
const push = useCallback((message: string, variant: ToastVariant) => {
|
||||||
|
setToasts((prev) => [...prev, { id: nextId++, message, variant }])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
registerToastPush(push)
|
||||||
|
|
||||||
|
// Литерал в value пересоздавался бы на каждый рендер провайдера и перерисовывал всех потребителей
|
||||||
|
// контекста, даже когда список тостов не менялся.
|
||||||
|
const value = useMemo(() => ({ toasts, dismiss }), [toasts, dismiss])
|
||||||
|
|
||||||
|
return <ToastContext value={value}>{children}</ToastContext>
|
||||||
|
}
|
||||||
@@ -1,20 +1,6 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
|
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import type { SortState } from '@/shared/lib/table-sort'
|
||||||
type SortState = { key: string; desc: boolean }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Состояние сортировки таблицы. Клик по новому столбцу — по возрастанию; повторный клик по тому же —
|
|
||||||
* переключает направление. Для серверных списков `sort`/`desc` передаются в API (и в queryKey), для
|
|
||||||
* клиентских — в {@link sortRows}.
|
|
||||||
*/
|
|
||||||
export function useTableSort(defaultKey: string, defaultDesc = false) {
|
|
||||||
const [sort, setSort] = useState<SortState>({ key: defaultKey, desc: defaultDesc })
|
|
||||||
const toggle = (key: string) =>
|
|
||||||
setSort((s) => (s.key === key ? { key, desc: !s.desc } : { key, desc: false }))
|
|
||||||
return { sort, toggle }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Заголовок-кнопка столбца со стрелкой сортировки. */
|
/** Заголовок-кнопка столбца со стрелкой сортировки. */
|
||||||
export function SortHeader({
|
export function SortHeader({
|
||||||
@@ -53,25 +39,3 @@ export function SortHeader({
|
|||||||
</th>
|
</th>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Comparable = string | number | boolean | null | undefined
|
|
||||||
|
|
||||||
/** Клиентская сортировка строк по выбранному ключу (для непагинированных списков). nulls — в конец. */
|
|
||||||
export function sortRows<T>(
|
|
||||||
rows: T[],
|
|
||||||
sort: SortState,
|
|
||||||
accessors: Record<string, (row: T) => Comparable>,
|
|
||||||
): T[] {
|
|
||||||
const accessor = accessors[sort.key]
|
|
||||||
if (!accessor) return rows
|
|
||||||
const dir = sort.desc ? -1 : 1
|
|
||||||
return [...rows].sort((a, b) => {
|
|
||||||
const av = accessor(a)
|
|
||||||
const bv = accessor(b)
|
|
||||||
if (av == null && bv == null) return 0
|
|
||||||
if (av == null) return 1
|
|
||||||
if (bv == null) return -1
|
|
||||||
if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir
|
|
||||||
return (av < bv ? -1 : av > bv ? 1 : 0) * dir
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
export type ToastVariant = 'default' | 'success' | 'error'
|
||||||
|
export type ToastItem = { id: number; message: string; variant: ToastVariant }
|
||||||
|
|
||||||
|
export type ToastContextValue = {
|
||||||
|
toasts: ToastItem[]
|
||||||
|
dismiss: (id: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToastContext = createContext<ToastContextValue | null>(null)
|
||||||
|
|
||||||
|
let pushImpl: ((message: string, variant: ToastVariant) => void) | null = null
|
||||||
|
|
||||||
|
/** Провайдер отдаёт сюда свою реализацию — через неё работает императивный {@link toast}. */
|
||||||
|
export function registerToastPush(push: (message: string, variant: ToastVariant) => void) {
|
||||||
|
pushImpl = push
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToastContext() {
|
||||||
|
const ctx = useContext(ToastContext)
|
||||||
|
if (!ctx) throw new Error('useToastContext must be used within ToastProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Императивный вызов из любого места (не только компонентов). */
|
||||||
|
export const toast = {
|
||||||
|
success: (message: string) => pushImpl?.(message, 'success'),
|
||||||
|
error: (message: string) => pushImpl?.(message, 'error'),
|
||||||
|
message: (message: string) => pushImpl?.(message, 'default'),
|
||||||
|
}
|
||||||
@@ -1,22 +1,5 @@
|
|||||||
import {
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
createContext,
|
import { THEME_STORAGE_KEY, ThemeContext, type Theme } from './theme-context'
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from 'react'
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system'
|
|
||||||
|
|
||||||
type ThemeContextValue = {
|
|
||||||
theme: Theme
|
|
||||||
setTheme: (theme: Theme) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'tw-theme'
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
|
|
||||||
|
|
||||||
function resolve(theme: Theme): 'light' | 'dark' {
|
function resolve(theme: Theme): 'light' | 'dark' {
|
||||||
if (theme === 'system') {
|
if (theme === 'system') {
|
||||||
@@ -32,7 +15,7 @@ function applyTheme(theme: Theme) {
|
|||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setThemeState] = useState<Theme>(
|
const [theme, setThemeState] = useState<Theme>(
|
||||||
() => (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'dark',
|
() => (localStorage.getItem(THEME_STORAGE_KEY) as Theme | null) ?? 'dark',
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,7 +28,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
const setTheme = useCallback((next: Theme) => {
|
const setTheme = useCallback((next: Theme) => {
|
||||||
localStorage.setItem(STORAGE_KEY, next)
|
localStorage.setItem(THEME_STORAGE_KEY, next)
|
||||||
setThemeState(next)
|
setThemeState(next)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -54,9 +37,3 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return <ThemeContext value={value}>{children}</ThemeContext>
|
return <ThemeContext value={value}>{children}</ThemeContext>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme(): ThemeContextValue {
|
|
||||||
const ctx = useContext(ThemeContext)
|
|
||||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
export type ThemeContextValue = {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEME_STORAGE_KEY = 'tw-theme'
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext)
|
||||||
|
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user