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 мало: к моменту вставки