From 281d081e6b7ffbd6badc95de9f9d462984e90eaf Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 29 Jul 2026 23:26:32 +0300 Subject: [PATCH] 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. --- .../features/admin/bumpers/BumpersPanel.tsx | 39 +++--- .../features/admin/channels/ChannelsPanel.tsx | 60 ++++++--- .../admin/collections/CollectionsPanel.tsx | 51 ++++---- .../src/features/admin/groups/GroupsPanel.tsx | 46 ++++--- .../admin/interstitials/ClipGroupPanel.tsx | 40 +++--- .../interstitials/InterstitialsPanel.tsx | 6 +- .../features/admin/shows/CreateShowDialog.tsx | 123 ++++++++++++++++++ .../src/features/admin/shows/ShowsPanel.tsx | 85 ++---------- frontend/src/shared/lib/locales/en.ts | 10 +- frontend/src/shared/lib/locales/ru.ts | 10 +- frontend/src/shared/lib/use-create-dialog.ts | 21 +++ frontend/src/shared/ui/create-name-dialog.tsx | 81 ++++++++++++ frontend/src/shared/ui/hls-video.tsx | 19 ++- 13 files changed, 408 insertions(+), 183 deletions(-) create mode 100644 frontend/src/features/admin/shows/CreateShowDialog.tsx create mode 100644 frontend/src/shared/lib/use-create-dialog.ts create mode 100644 frontend/src/shared/ui/create-name-dialog.tsx diff --git a/frontend/src/features/admin/bumpers/BumpersPanel.tsx b/frontend/src/features/admin/bumpers/BumpersPanel.tsx index 1a1427f..46a6150 100644 --- a/frontend/src/features/admin/bumpers/BumpersPanel.tsx +++ b/frontend/src/features/admin/bumpers/BumpersPanel.tsx @@ -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('') 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() { -
-
- - setName(e.target.value)} - /> -
-
@@ -106,6 +93,18 @@ export function BumpersPanel() {

{t('admin.bumpers.empty')}

)} + + {createDialog.open && ( + create.mutate()} + onClose={createDialog.close} + /> + )} ) } diff --git a/frontend/src/features/admin/channels/ChannelsPanel.tsx b/frontend/src/features/admin/channels/ChannelsPanel.tsx index 1444515..b0bc2af 100644 --- a/frontend/src/features/admin/channels/ChannelsPanel.tsx +++ b/frontend/src/features/admin/channels/ChannelsPanel.tsx @@ -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 (
-

{t('admin.channels.title')}

- -
- setName(e.target.value)} - /> - setSlug(slugify(e.target.value))} - /> +
+

{t('admin.channels.title')}

@@ -103,6 +101,28 @@ export function ChannelsPanel() {
+ + {create.open && ( + createMutation.mutate()} + onClose={create.close} + > +
+ + {/* Пустой slug выводится из названия — поле нужно только тогда, когда хочется своё. */} + setSlug(slugify(e.target.value))} + /> +
+
+ )}
) } diff --git a/frontend/src/features/admin/collections/CollectionsPanel.tsx b/frontend/src/features/admin/collections/CollectionsPanel.tsx index 2576c07..41816f9 100644 --- a/frontend/src/features/admin/collections/CollectionsPanel.tsx +++ b/frontend/src/features/admin/collections/CollectionsPanel.tsx @@ -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 (
-
-

{t('admin.collections.title')}

-

{t('admin.collections.hint')}

+
+
+

{t('admin.collections.title')}

+

{t('admin.collections.hint')}

+
+
- setName(e.target.value)} - /> - - {/* Постер коллекции — обложка её первой части: своей картинки у франшизы нет, а без обложки она теряется в списке одинаковых строк. */}
+ + {create.open && ( + createMutation.mutate()} + onClose={create.close} + /> + )}
) } diff --git a/frontend/src/features/admin/groups/GroupsPanel.tsx b/frontend/src/features/admin/groups/GroupsPanel.tsx index fc076eb..c68f11f 100644 --- a/frontend/src/features/admin/groups/GroupsPanel.tsx +++ b/frontend/src/features/admin/groups/GroupsPanel.tsx @@ -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 (
-
-

{t('admin.groups.title')}

-

{t('admin.groups.hint')}

-
- -
- setName(e.target.value)} - /> -
@@ -156,6 +149,19 @@ export function GroupsPanel() {
+ + {create.open && ( + createMutation.mutate()} + onClose={create.close} + /> + )}
) } diff --git a/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx b/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx index dd34ff1..82c5c61 100644 --- a/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx +++ b/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx @@ -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) )} -
- setNewName(e.target.value)} + + + {create.open && ( + createMutation.mutate()} + onClose={create.close} /> - -
+ )} ) } diff --git a/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx b/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx index b57e2c1..8bae104 100644 --- a/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx +++ b/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx @@ -277,7 +277,11 @@ export function InterstitialsPanel() { {preview?.name} - {preview?.mediaAssetId && } + {/* Кнопка в списке — это «плей», а не «открыть карточку»: жать ещё раз внутри окна + незачем, ролик на тридцать секунд. */} + {preview?.mediaAssetId && ( + + )} diff --git a/frontend/src/features/admin/shows/CreateShowDialog.tsx b/frontend/src/features/admin/shows/CreateShowDialog.tsx new file mode 100644 index 0000000..3fa95d2 --- /dev/null +++ b/frontend/src/features/admin/shows/CreateShowDialog.tsx @@ -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('Series') + // Новое шоу заводится без рейтинга: проставят метаданные либо админ руками. + const [audience, setAudience] = useState(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 ( + !open && onClose()}> + + + {t('admin.shows.createTitle')} + + +
+
+ + setName(e.target.value)} + // Enter в единственном обязательном поле — самый быстрый путь: форма короткая. + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> +
+ +
+ + setOriginalName(e.target.value)} /> +
+ +
+ + +
+ +
+ + +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index cff355c..8c776b9 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -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('Series') - // Новое шоу заводится без рейтинга: проставят метаданные либо админ руками. - const [audience, setAudience] = useState(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(null) const deleteMutation = useMutation({ @@ -133,52 +110,10 @@ export function ShowsPanel() { return (
-

{t('admin.shows.title')}

- -
- setName(e.target.value)} - /> - setOriginalName(e.target.value)} - /> - - -
@@ -340,6 +275,8 @@ export function ShowsPanel() { + {creating && setCreating(false)} onCreated={invalidate} />} + {toDelete && ( { + setValue('') + setOpen(true) + }, + close: () => setOpen(false), + } +} diff --git a/frontend/src/shared/ui/create-name-dialog.tsx b/frontend/src/shared/ui/create-name-dialog.tsx new file mode 100644 index 0000000..f13175e --- /dev/null +++ b/frontend/src/shared/ui/create-name-dialog.tsx @@ -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' + +/** + * Заведение сущности, у которой из обязательного — одно название. Такая форма стояла на половине + * экранов админки строкой над таблицей, вплотную к полю поиска, и в неё регулярно начинали вводить + * поисковый запрос. Отдельным окном перепутать их уже нельзя. + * + * — дополнительные поля, если у сущности есть что-то ещё (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 ( + !open && onClose()}> + + + {title} + {description && {description}} + + +
+
+ + onChange(e.target.value)} + // Форма в одно поле — Enter здесь ожидаем как «создать». + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> +
+ {children} +
+ + + + + +
+
+ ) +} diff --git a/frontend/src/shared/ui/hls-video.tsx b/frontend/src/shared/ui/hls-video.tsx index 48e596d..a618ed7 100644 --- a/frontend/src/shared/ui/hls-video.tsx +++ b/frontend/src/shared/ui/hls-video.tsx @@ -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(null) useEffect(() => { const video = videoRef.current if (!video) return + + // Атрибута autoplay мало: к моменту вставки