Refactor admin panels to utilize CreateNameDialog for creating new entities
Updated the BumpersPanel, ChannelsPanel, CollectionsPanel, GroupsPanel, ClipGroupPanel, and ShowsPanel components to replace direct input fields with a CreateNameDialog for creating new items. This change enhances user experience by centralizing the creation process and improving UI consistency. Localization strings were also updated to reflect new dialog titles and labels in both English and Russian.
This commit is contained in:
@@ -5,9 +5,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { listChannels } from '@/features/admin/channels/api'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { useCreateDialog } from '@/shared/lib/use-create-dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { CreateNameDialog } from '@/shared/ui/create-name-dialog'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { createBumperTemplate, listBumperTemplates } from './api'
|
||||
import { BumperTemplateEditor } from './components/BumperTemplateEditor'
|
||||
@@ -21,7 +22,7 @@ export function BumpersPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const onError = useApiError()
|
||||
const [name, setName] = useState('')
|
||||
const createDialog = useCreateDialog()
|
||||
const [channelId, setChannelId] = useState<string>('')
|
||||
|
||||
const { data: templates } = useQuery({
|
||||
@@ -35,9 +36,9 @@ export function BumpersPanel() {
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => createBumperTemplate(name.trim()),
|
||||
mutationFn: () => createBumperTemplate(createDialog.value.trim()),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
createDialog.close()
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
@@ -69,22 +70,8 @@ export function BumpersPanel() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-end gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label>{t('admin.bumpers.newName')}</Label>
|
||||
<Input
|
||||
placeholder={t('admin.bumpers.newNamePlaceholder')}
|
||||
value={name}
|
||||
maxLength={64}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!name.trim() || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
<div className="flex flex-1 items-end justify-end">
|
||||
<Button size="sm" onClick={createDialog.show}>
|
||||
<Plus className="h-4 w-4" /> {t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -106,6 +93,18 @@ export function BumpersPanel() {
|
||||
<p className="text-sm text-muted-foreground">{t('admin.bumpers.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createDialog.open && (
|
||||
<CreateNameDialog
|
||||
title={t('admin.bumpers.createTitle')}
|
||||
label={t('admin.bumpers.newName')}
|
||||
value={createDialog.value}
|
||||
onChange={createDialog.setValue}
|
||||
pending={create.isPending}
|
||||
onCreate={() => create.mutate()}
|
||||
onClose={createDialog.close}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { useCreateDialog } from '@/shared/lib/use-create-dialog'
|
||||
import { CreateNameDialog } from '@/shared/ui/create-name-dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { createChannel, listChannels } from './api'
|
||||
|
||||
function slugify(value: string) {
|
||||
@@ -19,7 +23,7 @@ function slugify(value: string) {
|
||||
export function ChannelsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const create = useCreateDialog()
|
||||
const [slug, setSlug] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
|
||||
@@ -27,10 +31,14 @@ export function ChannelsPanel() {
|
||||
const onError = useApiError()
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createChannel({ name: name.trim(), slug: slug || slugify(name) }),
|
||||
mutationFn: () =>
|
||||
createChannel({
|
||||
name: create.value.trim(),
|
||||
slug: slug || slugify(create.value),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
setSlug('')
|
||||
create.close()
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
@@ -38,26 +46,16 @@ export function ChannelsPanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.channels.title')}</h2>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.channels.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.channels.slug')}
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(slugify(e.target.value))}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.channels.title')}</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
onClick={() => {
|
||||
setSlug('')
|
||||
create.show()
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -103,6 +101,28 @@ export function ChannelsPanel() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{create.open && (
|
||||
<CreateNameDialog
|
||||
title={t('admin.channels.createTitle')}
|
||||
label={t('admin.channels.name')}
|
||||
value={create.value}
|
||||
onChange={create.setValue}
|
||||
pending={createMutation.isPending}
|
||||
onCreate={() => createMutation.mutate()}
|
||||
onClose={create.close}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slug')}</Label>
|
||||
{/* Пустой slug выводится из названия — поле нужно только тогда, когда хочется своё. */}
|
||||
<Input
|
||||
placeholder={slugify(create.value)}
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(slugify(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</CreateNameDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Image as ImageIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Image as ImageIcon, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
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 { useCreateDialog } from '@/shared/lib/use-create-dialog'
|
||||
import { CreateNameDialog } from '@/shared/ui/create-name-dialog'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
@@ -17,7 +17,7 @@ import { CollectionSuggestions } from './CollectionSuggestions'
|
||||
export function CollectionsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const create = useCreateDialog()
|
||||
const { sort, toggle } = useTableSort('name', false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -29,9 +29,9 @@ export function CollectionsPanel() {
|
||||
const onError = useApiError()
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createCollection({ name: name.trim() }),
|
||||
mutationFn: () => createCollection({ name: create.value.trim() }),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
create.close()
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
@@ -58,26 +58,18 @@ export function CollectionsPanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.collections.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.collections.hint')}</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.collections.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.collections.hint')}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={create.show}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.collections.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
|
||||
{/* Постер коллекции — обложка её первой части: своей картинки у франшизы нет, а без
|
||||
обложки она теряется в списке одинаковых строк. */}
|
||||
<Button
|
||||
@@ -166,6 +158,19 @@ export function CollectionsPanel() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{create.open && (
|
||||
<CreateNameDialog
|
||||
title={t('admin.collections.createTitle')}
|
||||
label={t('admin.collections.name')}
|
||||
description={t('admin.collections.hint')}
|
||||
value={create.value}
|
||||
onChange={create.setValue}
|
||||
pending={createMutation.isPending}
|
||||
onCreate={() => createMutation.mutate()}
|
||||
onClose={create.close}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { useCreateDialog } from '@/shared/lib/use-create-dialog'
|
||||
import { CreateNameDialog } from '@/shared/ui/create-name-dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
@@ -16,7 +19,7 @@ import { GroupSuggestions } from './GroupSuggestions'
|
||||
export function GroupsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const create = useCreateDialog()
|
||||
const [query, setQuery] = useState('')
|
||||
const { sort, toggle } = useTableSort('name', false)
|
||||
|
||||
@@ -26,9 +29,9 @@ export function GroupsPanel() {
|
||||
const onError = useApiError()
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createGroup({ name: name.trim() }),
|
||||
mutationFn: () => createGroup({ name: create.value.trim() }),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
create.close()
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
@@ -48,23 +51,13 @@ export function GroupsPanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.groups.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.groups.hint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.groups.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.groups.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.groups.hint')}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={create.show}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -156,6 +149,19 @@ export function GroupsPanel() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{create.open && (
|
||||
<CreateNameDialog
|
||||
title={t('admin.groups.createTitle')}
|
||||
label={t('admin.groups.name')}
|
||||
description={t('admin.groups.hint')}
|
||||
value={create.value}
|
||||
onChange={create.setValue}
|
||||
pending={createMutation.isPending}
|
||||
onCreate={() => createMutation.mutate()}
|
||||
onClose={create.close}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { addGroupElements, createGroup, listGroups } from '@/features/admin/groups/api'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useCreateDialog } from '@/shared/lib/use-create-dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { CreateNameDialog } from '@/shared/ui/create-name-dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { readDragItem } from './dnd'
|
||||
@@ -19,7 +21,7 @@ export function ClipGroupPanel({ onError }: Readonly<{ onError: (error: unknown)
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [selected, setSelected] = useState('')
|
||||
const [newName, setNewName] = useState('')
|
||||
const create = useCreateDialog()
|
||||
const [over, setOver] = useState(false)
|
||||
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
@@ -28,9 +30,9 @@ export function ClipGroupPanel({ onError }: Readonly<{ onError: (error: unknown)
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createGroup({ name: newName.trim() }),
|
||||
mutationFn: () => createGroup({ name: create.value.trim() }),
|
||||
onSuccess: ({ id }) => {
|
||||
setNewName('')
|
||||
create.close()
|
||||
setSelected(id)
|
||||
invalidate()
|
||||
},
|
||||
@@ -112,22 +114,22 @@ export function ClipGroupPanel({ onError }: Readonly<{ onError: (error: unknown)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('admin.interstitials.newGroupName')}
|
||||
value={newName}
|
||||
maxLength={256}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
<Button size="sm" variant="outline" onClick={create.show}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
|
||||
{create.open && (
|
||||
<CreateNameDialog
|
||||
title={t('admin.interstitials.createGroupTitle')}
|
||||
label={t('admin.interstitials.newGroupName')}
|
||||
value={create.value}
|
||||
onChange={create.setValue}
|
||||
pending={createMutation.isPending}
|
||||
onCreate={() => createMutation.mutate()}
|
||||
onClose={create.close}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!newName.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -277,7 +277,11 @@ export function InterstitialsPanel() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{preview?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{preview?.mediaAssetId && <HlsVideo src={mediaPreviewUrl(preview.mediaAssetId)} />}
|
||||
{/* Кнопка в списке — это «плей», а не «открыть карточку»: жать ещё раз внутри окна
|
||||
незачем, ролик на тридцать секунд. */}
|
||||
{preview?.mediaAssetId && (
|
||||
<HlsVideo src={mediaPreviewUrl(preview.mediaAssetId)} autoPlay />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AUDIENCE_UNSET,
|
||||
SHOW_AUDIENCES,
|
||||
type ShowAudience,
|
||||
type ShowKind,
|
||||
} from '@/shared/api/types'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { createShow } from './api'
|
||||
|
||||
/**
|
||||
* Заведение шоу. Отдельным окном, а не строкой над таблицей: поля создания стояли вплотную к полям
|
||||
* фильтра, и в них постоянно начинали искать.
|
||||
*/
|
||||
export function CreateShowDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: Readonly<{ onClose: () => void; onCreated: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
const onError = useApiError()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [originalName, setOriginalName] = useState('')
|
||||
const [kind, setKind] = useState<ShowKind>('Series')
|
||||
// Новое шоу заводится без рейтинга: проставят метаданные либо админ руками.
|
||||
const [audience, setAudience] = useState<ShowAudience | null>(null)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createShow({
|
||||
name: name.trim(),
|
||||
kind,
|
||||
originalName: originalName.trim() || undefined,
|
||||
audience,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onCreated()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
if (name.trim() && !createMutation.isPending) createMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.shows.createTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.shows.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
// Enter в единственном обязательном поле — самый быстрый путь: форма короткая.
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.shows.originalName')}</Label>
|
||||
<Input value={originalName} onChange={(e) => setOriginalName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.shows.kind')}</Label>
|
||||
<Select value={kind} onValueChange={(v) => setKind(v as ShowKind)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Series">{t('admin.shows.kinds.Series')}</SelectItem>
|
||||
<SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.shows.audience')}</Label>
|
||||
<Select
|
||||
value={audience ?? AUDIENCE_UNSET}
|
||||
onValueChange={(v) => setAudience(v === AUDIENCE_UNSET ? null : (v as ShowAudience))}
|
||||
>
|
||||
<SelectTrigger className="whitespace-nowrap">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AUDIENCE_UNSET}>{t('admin.shows.audienceUnset')}</SelectItem>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={!name.trim() || createMutation.isPending} onClick={submit}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import {
|
||||
AUDIENCE_UNSET,
|
||||
SHOW_AUDIENCES,
|
||||
type ShowAudience,
|
||||
type ShowKind,
|
||||
type ShowSummaryDto,
|
||||
} from '@/shared/api/types'
|
||||
import type { ShowKind, ShowSummaryDto } from '@/shared/api/types'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
@@ -19,7 +14,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
import { listGenres } from '@/features/admin/genres/api'
|
||||
import { createShow, deleteShow, listShows } from './api'
|
||||
import { deleteShow, listShows } from './api'
|
||||
import { CreateShowDialog } from './CreateShowDialog'
|
||||
import { DeleteShowDialog } from './DeleteShowDialog'
|
||||
import { BulkTagBar } from './BulkTagBar'
|
||||
|
||||
@@ -28,11 +24,7 @@ const PAGE_SIZE = 20
|
||||
export function ShowsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [originalName, setOriginalName] = useState('')
|
||||
const [kind, setKind] = useState<ShowKind>('Series')
|
||||
// Новое шоу заводится без рейтинга: проставят метаданные либо админ руками.
|
||||
const [audience, setAudience] = useState<ShowAudience | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const { sort, toggle } = useTableSort('name', false)
|
||||
@@ -97,21 +89,6 @@ export function ShowsPanel() {
|
||||
current.includes(showId) ? current.filter((id) => id !== showId) : [...current, showId],
|
||||
)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createShow({
|
||||
name: name.trim(),
|
||||
kind,
|
||||
originalName: originalName.trim() || undefined,
|
||||
audience,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
setOriginalName('')
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
// Шоу к удалению: сначала спрашиваем про файлы, и только потом удаляем.
|
||||
const [toDelete, setToDelete] = useState<ShowSummaryDto | null>(null)
|
||||
const deleteMutation = useMutation({
|
||||
@@ -133,52 +110,10 @@ export function ShowsPanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.shows.title')}</h2>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.shows.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.shows.originalName')}
|
||||
value={originalName}
|
||||
onChange={(e) => setOriginalName(e.target.value)}
|
||||
/>
|
||||
<Select value={kind} onValueChange={(v) => setKind(v as ShowKind)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Series">{t('admin.shows.kinds.Series')}</SelectItem>
|
||||
<SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={audience ?? AUDIENCE_UNSET}
|
||||
onValueChange={(v) => setAudience(v === AUDIENCE_UNSET ? null : (v as ShowAudience))}
|
||||
>
|
||||
{/* Та же ширина, что у селекта на карточке шоу: расшифровки рейтингов длинные. */}
|
||||
<SelectTrigger className="w-64 whitespace-nowrap">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AUDIENCE_UNSET}>{t('admin.shows.audienceUnset')}</SelectItem>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.shows.title')}</h2>
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -340,6 +275,8 @@ export function ShowsPanel() {
|
||||
|
||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||
|
||||
{creating && <CreateShowDialog onClose={() => setCreating(false)} onCreated={invalidate} />}
|
||||
|
||||
{toDelete && (
|
||||
<DeleteShowDialog
|
||||
show={toDelete}
|
||||
|
||||
@@ -87,6 +87,7 @@ export const en = {
|
||||
admin: {
|
||||
groups: {
|
||||
title: 'Groups',
|
||||
createTitle: 'Create a group',
|
||||
hint: 'A group is what may go on air. Grid slots reference it; the strategy picks an element from it.',
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
@@ -169,6 +170,7 @@ export const en = {
|
||||
'Takes the first part’s cover for collections that have no poster yet. A hand-picked one is left alone.',
|
||||
postersDone: 'Posters filled: {{count}}',
|
||||
title: 'Collections',
|
||||
createTitle: 'Create a collection',
|
||||
hint: 'A franchise is an ordered set of films played as a single unit.',
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
@@ -213,7 +215,8 @@ export const en = {
|
||||
pickGroupFirst: 'Pick a group first',
|
||||
dropToGroup: 'Drop a clip or a block here',
|
||||
openGroup: 'Open group',
|
||||
newGroupName: 'New group',
|
||||
newGroupName: 'Group name',
|
||||
createGroupTitle: 'Create a clip group',
|
||||
},
|
||||
genres: {
|
||||
title: 'Genres',
|
||||
@@ -400,6 +403,7 @@ export const en = {
|
||||
},
|
||||
shows: {
|
||||
title: 'Shows',
|
||||
createTitle: 'Create a show',
|
||||
name: 'Name',
|
||||
originalName: 'Original name (eng)',
|
||||
kind: 'Kind',
|
||||
@@ -760,6 +764,7 @@ export const en = {
|
||||
},
|
||||
|
||||
title: 'Channels',
|
||||
createTitle: 'Create a channel',
|
||||
name: 'Name',
|
||||
slug: 'Slug',
|
||||
state: 'State',
|
||||
@@ -838,7 +843,8 @@ export const en = {
|
||||
title: 'Bumpers',
|
||||
hint: 'Bumper blocks are shared across channels: look and sound belong to the block, text to its variants. A bumper reaches air as a junction break.',
|
||||
empty: 'No bumper blocks yet.',
|
||||
newName: 'New block',
|
||||
newName: 'Block name',
|
||||
createTitle: 'Create a bumper block',
|
||||
newNamePlaceholder: 'Block name',
|
||||
newVariantName: 'New text',
|
||||
sampleChannel: 'Preview as channel',
|
||||
|
||||
@@ -87,6 +87,7 @@ export const ru = {
|
||||
admin: {
|
||||
groups: {
|
||||
title: 'Группы',
|
||||
createTitle: 'Создать группу',
|
||||
hint: 'Группа — что может попасть в эфир. На неё ссылается слот сетки, из неё стратегия выбирает элемент.',
|
||||
name: 'Название',
|
||||
description: 'Описание',
|
||||
@@ -169,6 +170,7 @@ export const ru = {
|
||||
'Берёт обложку первой части у коллекций, у которых постера ещё нет. Выбранный руками не трогает.',
|
||||
postersDone: 'Постеры проставлены: {{count}}',
|
||||
title: 'Коллекции',
|
||||
createTitle: 'Создать коллекцию',
|
||||
hint: 'Франшиза — упорядоченный набор фильмов, который играется как одно целое.',
|
||||
name: 'Название',
|
||||
description: 'Описание',
|
||||
@@ -213,7 +215,8 @@ export const ru = {
|
||||
pickGroupFirst: 'Сначала выберите группу',
|
||||
dropToGroup: 'Перетащите сюда ролик или блок',
|
||||
openGroup: 'Открыть группу',
|
||||
newGroupName: 'Новая группа',
|
||||
newGroupName: 'Название группы',
|
||||
createGroupTitle: 'Создать группу роликов',
|
||||
},
|
||||
genres: {
|
||||
title: 'Жанры',
|
||||
@@ -397,6 +400,7 @@ export const ru = {
|
||||
},
|
||||
shows: {
|
||||
title: 'Шоу',
|
||||
createTitle: 'Создать шоу',
|
||||
name: 'Название',
|
||||
originalName: 'Оригинальное название (eng)',
|
||||
kind: 'Тип',
|
||||
@@ -755,6 +759,7 @@ export const ru = {
|
||||
},
|
||||
|
||||
title: 'Каналы',
|
||||
createTitle: 'Создать канал',
|
||||
name: 'Название',
|
||||
slug: 'Slug',
|
||||
state: 'Состояние',
|
||||
@@ -833,7 +838,8 @@ export const ru = {
|
||||
title: 'Заставки',
|
||||
hint: 'Блоки заставок общие для всех каналов: оформление и звук — у блока, текст — у подблоков. В эфир заставка попадает врезкой стыка.',
|
||||
empty: 'Блоков заставок пока нет.',
|
||||
newName: 'Новый блок',
|
||||
newName: 'Название блока',
|
||||
createTitle: 'Создать блок заставки',
|
||||
newNamePlaceholder: 'Название блока',
|
||||
newVariantName: 'Новый текст',
|
||||
sampleChannel: 'Смотреть глазами канала',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* Состояние окна создания: открыто ли и что введено. Отдельным хуком, потому что панели заводят
|
||||
* эти две переменные одинаково и одинаково же чистят поле, открывая окно заново.
|
||||
*/
|
||||
export function useCreateDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return {
|
||||
open,
|
||||
value,
|
||||
setValue,
|
||||
show: () => {
|
||||
setValue('')
|
||||
setOpen(true)
|
||||
},
|
||||
close: () => setOpen(false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
|
||||
/**
|
||||
* Заведение сущности, у которой из обязательного — одно название. Такая форма стояла на половине
|
||||
* экранов админки строкой над таблицей, вплотную к полю поиска, и в неё регулярно начинали вводить
|
||||
* поисковый запрос. Отдельным окном перепутать их уже нельзя.
|
||||
*
|
||||
* <paramref name="children"/> — дополнительные поля, если у сущности есть что-то ещё (slug канала).
|
||||
*/
|
||||
export function CreateNameDialog({
|
||||
title,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
pending,
|
||||
onCreate,
|
||||
onClose,
|
||||
children,
|
||||
}: Readonly<{
|
||||
title: string
|
||||
label: string
|
||||
description?: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
pending: boolean
|
||||
onCreate: () => void
|
||||
onClose: () => void
|
||||
children?: ReactNode
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const submit = () => {
|
||||
if (value.trim() && !pending) onCreate()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
// Форма в одно поле — Enter здесь ожидаем как «создать».
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={!value.trim() || pending} onClick={submit}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -7,12 +7,23 @@ import { cn } from '@/shared/lib/cn'
|
||||
* Мини-плеер HLS для админки: плейлист и сегменты лежат под admin-роутами (JWT), поэтому запросы
|
||||
* идут через hls.js с Bearer-заголовком. Нативный путь (Safari) — только там, где hls.js не нужен.
|
||||
*/
|
||||
export function HlsVideo({ src, className }: Readonly<{ src: string; className?: string }>) {
|
||||
export function HlsVideo({
|
||||
src,
|
||||
className,
|
||||
autoPlay = false,
|
||||
}: Readonly<{ src: string; className?: string; autoPlay?: boolean }>) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
// Атрибута autoplay мало: к моменту вставки <video> у него нет источника — плейлист доедет
|
||||
// позже, и браузер стартовать будет уже нечему. Поэтому запускаем руками по готовности.
|
||||
// Отказ глотаем: политика автовоспроизведения может не пустить, и это не ошибка — у плеера
|
||||
// есть кнопка.
|
||||
const start = () => void video.play().catch(() => undefined)
|
||||
|
||||
let hls: Hls | null = null
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
@@ -23,13 +34,17 @@ export function HlsVideo({ src, className }: Readonly<{ src: string; className?:
|
||||
})
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
if (autoPlay) hls.on(Hls.Events.MANIFEST_PARSED, start)
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
if (autoPlay) video.addEventListener('loadedmetadata', start, { once: true })
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', start)
|
||||
hls?.destroy()
|
||||
}
|
||||
}, [src])
|
||||
}, [src, autoPlay])
|
||||
|
||||
return (
|
||||
<video
|
||||
|
||||
Reference in New Issue
Block a user