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