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:
@@ -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