185
resources/client/admin/videos/captions/captions-panel.tsx
Executable file
185
resources/client/admin/videos/captions/captions-panel.tsx
Executable file
@@ -0,0 +1,185 @@
|
||||
import {useFieldArray, useFormContext} from 'react-hook-form';
|
||||
import {CreateVideoPayload} from '@app/admin/videos/requests/use-create-video';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {AddIcon} from '@common/icons/material/Add';
|
||||
import {CrupdateCaptionDialog} from '@app/admin/videos/crupdate/crupdate-caption-dialog';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {SubtitlesIcon} from '@common/icons/material/Subtitles';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {DragHandleIcon} from '@common/icons/material/DragHandle';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {CloseIcon} from '@common/icons/material/Close';
|
||||
import React, {useRef} from 'react';
|
||||
import {useIsTouchDevice} from '@common/utils/hooks/is-touch-device';
|
||||
import {DragPreviewRenderer} from '@common/ui/interactions/dnd/use-draggable';
|
||||
import {DragPreview} from '@common/ui/interactions/dnd/drag-preview';
|
||||
import {Video, VideoCaption} from '@app/titles/models/video';
|
||||
import {SettingsIcon} from '@common/icons/material/Settings';
|
||||
import {useSortable} from '@common/ui/interactions/dnd/sortable/use-sortable';
|
||||
|
||||
export function CaptionsPanel() {
|
||||
const {watch} = useFormContext<CreateVideoPayload>();
|
||||
const {fields, append, remove, swap, update} = useFieldArray<
|
||||
CreateVideoPayload,
|
||||
'captions',
|
||||
'key'
|
||||
>({
|
||||
name: 'captions',
|
||||
keyName: 'key',
|
||||
});
|
||||
const sourceType = watch('type');
|
||||
const supportsCaptions = sourceType === 'video';
|
||||
|
||||
return (
|
||||
<div className="mt-24">
|
||||
<div className="flex items-center justify-between gap-24">
|
||||
<div className="text-xl font-medium">
|
||||
<Trans message="Captions" />
|
||||
</div>
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={values => {
|
||||
if (values) {
|
||||
append(values);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
startIcon={<AddIcon />}
|
||||
size="xs"
|
||||
disabled={!supportsCaptions}
|
||||
>
|
||||
<Trans message="Add caption" />
|
||||
</Button>
|
||||
<CrupdateCaptionDialog />
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<div className="mt-24">
|
||||
{!supportsCaptions || !fields?.length ? (
|
||||
<IllustratedMessage
|
||||
size="sm"
|
||||
image={<SubtitlesIcon />}
|
||||
imageHeight="h-24"
|
||||
imageMargin="mb-12"
|
||||
title={<NoCaptionsMessage sourceType={sourceType} />}
|
||||
/>
|
||||
) : null}
|
||||
{supportsCaptions &&
|
||||
fields.map((caption, index) => (
|
||||
<CaptionItem
|
||||
key={caption.key}
|
||||
caption={caption}
|
||||
captions={fields}
|
||||
onSort={(oldIndex, newIndex) => swap(oldIndex, newIndex)}
|
||||
onRemove={() => remove(index)}
|
||||
onUpdate={values => update(index, values)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CaptionItemProps {
|
||||
caption: VideoCaption;
|
||||
captions: VideoCaption[];
|
||||
onSort: (oldIndex: number, newIndex: number) => void;
|
||||
onRemove: () => void;
|
||||
onUpdate: (caption: VideoCaption) => void;
|
||||
}
|
||||
function CaptionItem({
|
||||
caption,
|
||||
captions,
|
||||
onSort,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
}: CaptionItemProps) {
|
||||
const domRef = useRef<HTMLDivElement>(null);
|
||||
const previewRef = useRef<DragPreviewRenderer>(null);
|
||||
const isTouchDevice = useIsTouchDevice();
|
||||
|
||||
const {sortableProps, dragHandleRef} = useSortable({
|
||||
ref: domRef,
|
||||
disabled: isTouchDevice ?? false,
|
||||
item: caption,
|
||||
items: captions,
|
||||
type: 'captionItem',
|
||||
preview: previewRef,
|
||||
strategy: 'line',
|
||||
onSortEnd: (oldIndex, newIndex) => onSort(oldIndex, newIndex),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center border-b border-t border-transparent"
|
||||
ref={domRef}
|
||||
{...sortableProps}
|
||||
>
|
||||
<IconButton ref={dragHandleRef} aria-label="Sort captions">
|
||||
<DragHandleIcon />
|
||||
</IconButton>
|
||||
<div className="ml-12 capitalize">{caption.name}</div>
|
||||
<div className="ml-auto mr-12 rounded border px-8 py-4 text-xs uppercase">
|
||||
{caption.language}
|
||||
</div>
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={values => {
|
||||
if (values) {
|
||||
onUpdate(values);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip label={<Trans message="Edit" />}>
|
||||
<IconButton onClick={() => onRemove()} className="text-muted">
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<CrupdateCaptionDialog caption={caption} />
|
||||
</DialogTrigger>
|
||||
<Tooltip label={<Trans message="Remove" />}>
|
||||
<IconButton onClick={() => onRemove()} className="text-danger">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<CaptionItemDragPreview caption={caption} ref={previewRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CaptionItemDragPreviewProps {
|
||||
caption: VideoCaption;
|
||||
}
|
||||
const CaptionItemDragPreview = React.forwardRef<
|
||||
DragPreviewRenderer,
|
||||
CaptionItemDragPreviewProps
|
||||
>(({caption}, ref) => {
|
||||
return (
|
||||
<DragPreview ref={ref}>
|
||||
{() => (
|
||||
<div className="rounded bg-background p-8 text-base shadow">
|
||||
{caption.name}
|
||||
</div>
|
||||
)}
|
||||
</DragPreview>
|
||||
);
|
||||
});
|
||||
|
||||
interface NoCaptionsMessageProps {
|
||||
sourceType: Video['type'];
|
||||
}
|
||||
function NoCaptionsMessage({sourceType}: NoCaptionsMessageProps) {
|
||||
switch (sourceType) {
|
||||
case 'video':
|
||||
return <Trans message="No captions have been added to this video yet." />;
|
||||
case 'stream':
|
||||
return (
|
||||
<Trans message="Captions (if available) are embedded within the stream itself." />
|
||||
);
|
||||
default:
|
||||
return <Trans message="This source type does not support captions." />;
|
||||
}
|
||||
}
|
||||
75
resources/client/admin/videos/crupdate/create-video-page.tsx
Executable file
75
resources/client/admin/videos/crupdate/create-video-page.tsx
Executable file
@@ -0,0 +1,75 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';
|
||||
import {
|
||||
CreateVideoPayload,
|
||||
useCreateVideo,
|
||||
} from '@app/admin/videos/requests/use-create-video';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {CrupdateVideoForm} from '@app/admin/videos/crupdate/crupdate-video-form';
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ArrowBackIcon} from '@common/icons/material/ArrowBack';
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
}
|
||||
export function CreateVideoPage({children}: Props) {
|
||||
const {titleId, season, episode} = useParams();
|
||||
const navigate = useNavigate();
|
||||
const form = useForm<CreateVideoPayload>({
|
||||
defaultValues: {
|
||||
quality: 'regular',
|
||||
language: 'en',
|
||||
category: 'trailer',
|
||||
type: 'embed',
|
||||
title_id: titleId ? Number(titleId) : undefined,
|
||||
season_num: season ? Number(season) : undefined,
|
||||
episode_num: episode ? Number(episode) : undefined,
|
||||
},
|
||||
});
|
||||
const createVideo = useCreateVideo(form);
|
||||
|
||||
return (
|
||||
<CrupdateResourceLayout
|
||||
onSubmit={values => {
|
||||
createVideo.mutate(values, {
|
||||
onSuccess: response => {
|
||||
toast(message('Video created'));
|
||||
if (titleId) {
|
||||
navigate(`../`, {
|
||||
relative: 'path',
|
||||
});
|
||||
} else {
|
||||
navigate(`../${response.video.id}/edit`, {
|
||||
relative: 'path',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
backButton={
|
||||
titleId ? (
|
||||
<IconButton
|
||||
className="text-muted"
|
||||
elementType={Link}
|
||||
to="../"
|
||||
relative="path"
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
}
|
||||
form={form}
|
||||
title={<Trans message="New video" />}
|
||||
isLoading={createVideo.isPending}
|
||||
disableSaveWhenNotDirty
|
||||
>
|
||||
{children}
|
||||
<CrupdateVideoForm form={form} />
|
||||
</CrupdateResourceLayout>
|
||||
);
|
||||
}
|
||||
97
resources/client/admin/videos/crupdate/crupdate-caption-dialog.tsx
Executable file
97
resources/client/admin/videos/crupdate/crupdate-caption-dialog.tsx
Executable file
@@ -0,0 +1,97 @@
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {useValueLists} from '@common/http/value-lists';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {VideoCaption} from '@app/titles/models/video';
|
||||
import {FormFileEntryField} from '@common/ui/forms/input-field/file-entry-field';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import {Disk} from '@common/uploads/types/backend-metadata';
|
||||
|
||||
interface Props {
|
||||
caption?: VideoCaption;
|
||||
}
|
||||
export function CrupdateCaptionDialog({caption}: Props) {
|
||||
const {close, formId} = useDialogContext();
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
language: 'en',
|
||||
...caption,
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
{caption ? (
|
||||
<Trans message="Update caption" />
|
||||
) : (
|
||||
<Trans message="Add caption" />
|
||||
)}
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Form id={formId} form={form} onSubmit={newValues => close(newValues)}>
|
||||
<FormTextField
|
||||
name="name"
|
||||
label={<Trans message="Name" />}
|
||||
className="mb-24"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<LanguageSelect />
|
||||
<FileUploadProvider>
|
||||
<FormFileEntryField
|
||||
required={!caption}
|
||||
name="url"
|
||||
diskPrefix="captions"
|
||||
disk={Disk.public}
|
||||
allowedFileTypes={['.vtt']}
|
||||
maxFileSize={1024 * 1024}
|
||||
label={<Trans message="Caption file" />}
|
||||
onChange={() => {
|
||||
form.clearErrors();
|
||||
}}
|
||||
/>
|
||||
</FileUploadProvider>
|
||||
</Form>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => close()}>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button form={formId} variant="flat" color="primary" type="submit">
|
||||
{caption ? <Trans message="Update" /> : <Trans message="Add" />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageSelect() {
|
||||
const {trans} = useTrans();
|
||||
const {data} = useValueLists(['languages']);
|
||||
return (
|
||||
<FormSelect
|
||||
name="language"
|
||||
selectionMode="single"
|
||||
showSearchField
|
||||
searchPlaceholder={trans(message('Search languages'))}
|
||||
label={<Trans message="Language" />}
|
||||
className="mb-24"
|
||||
>
|
||||
{data?.languages?.map(language => (
|
||||
<Option value={language.code} key={language.code} capitalizeFirst>
|
||||
<Trans message={language.name} />
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
292
resources/client/admin/videos/crupdate/crupdate-video-form.tsx
Executable file
292
resources/client/admin/videos/crupdate/crupdate-video-form.tsx
Executable file
@@ -0,0 +1,292 @@
|
||||
import React, {useState} from 'react';
|
||||
import {useFormContext, UseFormReturn} from 'react-hook-form';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {TitleSelect} from '@app/titles/title-select';
|
||||
import {FormImageSelector} from '@common/ui/images/image-selector';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {useValueLists} from '@common/http/value-lists';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {CreateVideoPayload} from '@app/admin/videos/requests/use-create-video';
|
||||
import {InfoDialogTriggerIcon} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger-icon';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {VideoPlayerSkeleton} from '@app/videos/video-player-skeleton';
|
||||
import {SiteVideoPlayer} from '@app/videos/site-video-player';
|
||||
import {CaptionsPanel} from '@app/admin/videos/captions/captions-panel';
|
||||
import {FormFileEntryField} from '@common/ui/forms/input-field/file-entry-field';
|
||||
import {RadioGroup} from '@common/ui/forms/radio-group/radio-group';
|
||||
import {Radio} from '@common/ui/forms/radio-group/radio';
|
||||
import {Disk} from '@common/uploads/types/backend-metadata';
|
||||
|
||||
interface Props {
|
||||
form: UseFormReturn<CreateVideoPayload>;
|
||||
video?: Video;
|
||||
}
|
||||
export function CrupdateVideoForm({form, video}: Props) {
|
||||
return (
|
||||
<div className="flex items-start gap-54">
|
||||
<div className="flex-auto">
|
||||
<VideoPreview video={video} />
|
||||
<ReloadMessage form={form} />
|
||||
<CaptionsPanel />
|
||||
</div>
|
||||
<div className="w-440 flex-shrink-0">
|
||||
<VideoForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReloadMessage({form}: Props) {
|
||||
const dirty = form.formState.dirtyFields;
|
||||
if (!dirty.src && !dirty.thumbnail) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-12 flex items-center gap-6 text-sm text-muted">
|
||||
<InfoDialogTriggerIcon
|
||||
size="xs"
|
||||
className="text-muted"
|
||||
viewBox="0 0 16 16"
|
||||
/>
|
||||
<Trans message="Save your changes to reload video preview." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoPreviewProps {
|
||||
video?: Video;
|
||||
}
|
||||
function VideoPreview({video}: VideoPreviewProps) {
|
||||
if (!video || !video.src) {
|
||||
return <VideoPlayerSkeleton animate={false} />;
|
||||
}
|
||||
// timestamp will force reload of player when video is updated
|
||||
return (
|
||||
<SiteVideoPlayer
|
||||
video={video}
|
||||
mediaItemId={`${video.id}-${video.updated_at}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoForm() {
|
||||
return (
|
||||
<FileUploadProvider>
|
||||
<FormTextField
|
||||
name="name"
|
||||
label={<Trans message="Name" />}
|
||||
className="mb-24"
|
||||
required
|
||||
/>
|
||||
<TitleSelect
|
||||
name="title_id"
|
||||
seasonName="season_num"
|
||||
episodeName="episode_num"
|
||||
className="mb-24"
|
||||
/>
|
||||
<FormImageSelector
|
||||
name="thumbnail"
|
||||
label={<Trans message="Thumbnail" />}
|
||||
diskPrefix="video-thumbnails"
|
||||
className="mb-24"
|
||||
/>
|
||||
<SourceTypeSelect />
|
||||
<SourceField />
|
||||
<QualitySelect />
|
||||
<LanguageSelect />
|
||||
<ContentTypeSelect />
|
||||
</FileUploadProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceTypeSelect() {
|
||||
const {setValue} = useFormContext<CreateVideoPayload>();
|
||||
return (
|
||||
<FormSelect
|
||||
name="type"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Source type" />}
|
||||
className="mb-24"
|
||||
onSelectionChange={() => setValue('src', '')}
|
||||
>
|
||||
<Option
|
||||
value="embed"
|
||||
description={
|
||||
<Trans message="Embed video hosted on another site. Youtube, vimeo etc." />
|
||||
}
|
||||
>
|
||||
<Trans message="Embed" />
|
||||
</Option>
|
||||
<Option
|
||||
value="video"
|
||||
description={
|
||||
<Trans message="Upload a video file or enter a url to direct video (.mp4, .webm, .avi, .mov etc.) hosted online." />
|
||||
}
|
||||
>
|
||||
<Trans message="Direct" />
|
||||
</Option>
|
||||
<Option
|
||||
value="stream"
|
||||
description={<Trans message="Enter a url to HLS or DASH stream." />}
|
||||
>
|
||||
<Trans message="Adaptive stream" />
|
||||
</Option>
|
||||
<Option
|
||||
value="external"
|
||||
description={
|
||||
<Trans message="Enter any url. User will be redirected to this url after clicking the video." />
|
||||
}
|
||||
>
|
||||
<Trans message="Basic url" />
|
||||
</Option>
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceField() {
|
||||
const {watch} = useFormContext<CreateVideoPayload>();
|
||||
const isEmbed = watch('type') === 'embed';
|
||||
const isUrl = watch('type') === 'external';
|
||||
const canUpload = watch('type') === 'video';
|
||||
const {trans} = useTrans();
|
||||
|
||||
if (canUpload) {
|
||||
return <DirectSourceField />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormTextField
|
||||
required
|
||||
name="src"
|
||||
label={<Trans message="Source" />}
|
||||
className="mb-24"
|
||||
type={isUrl ? 'url' : undefined}
|
||||
placeholder={
|
||||
isEmbed
|
||||
? trans(message('Full embed code snippet or just src url'))
|
||||
: undefined
|
||||
}
|
||||
inputElementType={isEmbed ? 'textarea' : 'input'}
|
||||
rows={4}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectSourceField() {
|
||||
const form = useFormContext<CreateVideoPayload>();
|
||||
const [type, setType] = useState<'url' | 'file'>(() => {
|
||||
const src = form.getValues('src');
|
||||
return src.includes('api/v1/file-entries') ||
|
||||
src.includes('storage/title-videos')
|
||||
? 'file'
|
||||
: 'url';
|
||||
});
|
||||
return (
|
||||
<div className="mb-24">
|
||||
<RadioGroup size="sm" className="mb-8" name="direct-type">
|
||||
<Radio
|
||||
value="url"
|
||||
checked={type === 'url'}
|
||||
onChange={e => setType(e.target.value as any)}
|
||||
>
|
||||
<Trans message="Url" />
|
||||
</Radio>
|
||||
<Radio
|
||||
value="file"
|
||||
checked={type === 'file'}
|
||||
onChange={e => setType(e.target.value as any)}
|
||||
>
|
||||
<Trans message="File" />
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
{type === 'file' ? (
|
||||
<FormFileEntryField
|
||||
required
|
||||
name="src"
|
||||
disk={Disk.public}
|
||||
diskPrefix="title-videos"
|
||||
label={<Trans message="Source" />}
|
||||
/>
|
||||
) : (
|
||||
<FormTextField
|
||||
name="src"
|
||||
label={<Trans message="source" />}
|
||||
inputElementType="textarea"
|
||||
rows={2}
|
||||
required
|
||||
type="url"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QualitySelect() {
|
||||
const {streaming} = useSettings();
|
||||
const qualities = streaming?.qualities || [];
|
||||
return (
|
||||
<FormSelect
|
||||
name="quality"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Quality" />}
|
||||
className="mb-24"
|
||||
>
|
||||
{qualities.map((quality: string) => (
|
||||
<Option value={quality.toLowerCase()} key={quality} capitalizeFirst>
|
||||
<Trans message={quality} />
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageSelect() {
|
||||
const {trans} = useTrans();
|
||||
const query = useValueLists(['languages']);
|
||||
return (
|
||||
<FormSelect
|
||||
name="language"
|
||||
selectionMode="single"
|
||||
showSearchField
|
||||
searchPlaceholder={trans(message('Search languages'))}
|
||||
label={<Trans message="Language" />}
|
||||
className="mb-24"
|
||||
>
|
||||
{query.data?.languages?.map(language => (
|
||||
<Option value={language.code} key={language.code} capitalizeFirst>
|
||||
<Trans message={language.name} />
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentTypeSelect() {
|
||||
return (
|
||||
<FormSelect
|
||||
name="category"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Content type" />}
|
||||
className="mb-24"
|
||||
>
|
||||
<Option value="trailer">
|
||||
<Trans message="Trailer" />
|
||||
</Option>
|
||||
<Option value="clip">
|
||||
<Trans message="Clip" />
|
||||
</Option>
|
||||
<Option value="featurette">
|
||||
<Trans message="Featurette" />
|
||||
</Option>
|
||||
<Option value="teaser">
|
||||
<Trans message="Teaser" />
|
||||
</Option>
|
||||
<Option value="full">
|
||||
<Trans message="Full Movie or Episode" />
|
||||
</Option>
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
104
resources/client/admin/videos/crupdate/edit-video-page.tsx
Executable file
104
resources/client/admin/videos/crupdate/edit-video-page.tsx
Executable file
@@ -0,0 +1,104 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React, {useEffect} from 'react';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';
|
||||
import {CreateVideoPayload} from '@app/admin/videos/requests/use-create-video';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {CrupdateVideoForm} from '@app/admin/videos/crupdate/crupdate-video-form';
|
||||
import {useUpdateVideo} from '@app/admin/videos/requests/use-update-video';
|
||||
import {useVideo} from '@app/admin/videos/requests/use-video';
|
||||
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {OpenInNewIcon} from '@common/icons/material/OpenInNew';
|
||||
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {ArrowBackIcon} from '@common/icons/material/ArrowBack';
|
||||
|
||||
export function EditVideoPage() {
|
||||
const {titleId} = useParams();
|
||||
const navigate = useNavigate();
|
||||
const form = useForm<CreateVideoPayload>();
|
||||
const query = useVideo();
|
||||
const video = query.data?.video;
|
||||
const updateVideo = useUpdateVideo(form);
|
||||
const link = video ? getWatchLink(video) : video;
|
||||
|
||||
useEffect(() => {
|
||||
if (video && !form.getValues().name) {
|
||||
form.reset({
|
||||
name: video.name,
|
||||
title_id: video.title_id,
|
||||
season_num: video.season_num,
|
||||
episode_num: video.episode_num,
|
||||
thumbnail: video.thumbnail,
|
||||
type: video.type,
|
||||
src: video.src,
|
||||
quality: video.quality,
|
||||
language: video.language,
|
||||
category: video.category,
|
||||
captions:
|
||||
video.captions?.map(caption => ({
|
||||
id: caption.id,
|
||||
name: caption.name,
|
||||
url: caption.url,
|
||||
language: caption.language,
|
||||
})) || [],
|
||||
});
|
||||
}
|
||||
}, [video, form]);
|
||||
|
||||
return (
|
||||
<CrupdateResourceLayout
|
||||
onSubmit={values => {
|
||||
updateVideo.mutate(values, {
|
||||
onSuccess: () => {
|
||||
form.reset(values);
|
||||
toast(message('Video updated'));
|
||||
if (titleId) {
|
||||
navigate(`../../`, {
|
||||
relative: 'path',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
backButton={
|
||||
titleId ? (
|
||||
<IconButton
|
||||
className="text-muted"
|
||||
elementType={Link}
|
||||
to="../../"
|
||||
relative="path"
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
}
|
||||
form={form}
|
||||
title={
|
||||
video ? (
|
||||
<Trans values={{name: video.name}} message="Edit “:name“" />
|
||||
) : (
|
||||
<Trans message="Edit video" />
|
||||
)
|
||||
}
|
||||
actions={
|
||||
link ? (
|
||||
<IconButton size="sm" elementType={Link} to={link} target="_blank">
|
||||
<OpenInNewIcon />
|
||||
</IconButton>
|
||||
) : null
|
||||
}
|
||||
isLoading={query.isLoading || updateVideo.isPending}
|
||||
disableSaveWhenNotDirty
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<FullPageLoader />
|
||||
) : (
|
||||
<CrupdateVideoForm form={form} video={video} />
|
||||
)}
|
||||
</CrupdateResourceLayout>
|
||||
);
|
||||
}
|
||||
39
resources/client/admin/videos/requests/use-create-video.ts
Executable file
39
resources/client/admin/videos/requests/use-create-video.ts
Executable file
@@ -0,0 +1,39 @@
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {Video, VideoCaption} from '@app/titles/models/video';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
video: Video;
|
||||
}
|
||||
|
||||
export interface CreateVideoPayload {
|
||||
name: string;
|
||||
title_id: number;
|
||||
season_num?: number;
|
||||
episode_num?: number;
|
||||
thumbnail?: string;
|
||||
type: Video['type'];
|
||||
src: string;
|
||||
quality: Video['quality'];
|
||||
language: string;
|
||||
category: Video['category'];
|
||||
captions?: VideoCaption[];
|
||||
}
|
||||
|
||||
export function useCreateVideo(form: UseFormReturn<CreateVideoPayload>) {
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateVideoPayload) => createVideo(payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['video']});
|
||||
},
|
||||
onError: r => (form ? onFormQueryError(r, form) : showHttpErrorToast(r)),
|
||||
});
|
||||
}
|
||||
|
||||
function createVideo(payload: CreateVideoPayload): Promise<Response> {
|
||||
return apiClient.post(`videos`, payload).then(r => r.data);
|
||||
}
|
||||
26
resources/client/admin/videos/requests/use-delete-videos.ts
Executable file
26
resources/client/admin/videos/requests/use-delete-videos.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
//
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
videoIds: number[];
|
||||
}
|
||||
|
||||
export function useDeleteVideos() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => deleteVideos(payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['video']});
|
||||
},
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function deleteVideos({videoIds}: Payload): Promise<Response> {
|
||||
return apiClient.delete(`videos/${videoIds.join(',')}`).then(r => r.data);
|
||||
}
|
||||
30
resources/client/admin/videos/requests/use-update-video.ts
Executable file
30
resources/client/admin/videos/requests/use-update-video.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {CreateVideoPayload} from '@app/admin/videos/requests/use-create-video';
|
||||
import {useParams} from 'react-router-dom';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
video: Video;
|
||||
}
|
||||
|
||||
export function useUpdateVideo(form: UseFormReturn<CreateVideoPayload>) {
|
||||
const {videoId} = useParams();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateVideoPayload) => updateVideo(videoId!, payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['video']});
|
||||
},
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function updateVideo(
|
||||
videoId: string | number,
|
||||
payload: CreateVideoPayload,
|
||||
): Promise<Response> {
|
||||
return apiClient.put(`videos/${videoId}`, payload).then(r => r.data);
|
||||
}
|
||||
23
resources/client/admin/videos/requests/use-video.ts
Executable file
23
resources/client/admin/videos/requests/use-video.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {useParams} from 'react-router-dom';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
video: Video;
|
||||
}
|
||||
|
||||
export function useVideo() {
|
||||
const {videoId} = useParams();
|
||||
return useQuery({
|
||||
queryKey: ['video', `${videoId}`],
|
||||
queryFn: () => fetchVideo(videoId!),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchVideo(videoId: number | string) {
|
||||
return apiClient
|
||||
.get<Response>(`videos/${videoId}`)
|
||||
.then(response => response.data);
|
||||
}
|
||||
1
resources/client/admin/videos/video-files.svg
Executable file
1
resources/client/admin/videos/video-files.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.4 KiB |
131
resources/client/admin/videos/videos-datatable-columns.tsx
Executable file
131
resources/client/admin/videos/videos-datatable-columns.tsx
Executable file
@@ -0,0 +1,131 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
import React, {Fragment} from 'react';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {BooleanIndicator} from '@common/datatable/column-templates/boolean-indicator';
|
||||
import {FormattedNumber} from '@common/i18n/formatted-number';
|
||||
import {TitlePoster} from '@app/titles/title-poster/title-poster';
|
||||
import {BarChartIcon} from '@common/icons/material/BarChart';
|
||||
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
|
||||
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
|
||||
|
||||
export const VideosDatatableColumns: ColumnConfig<Video>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
allowsSorting: true,
|
||||
width: 'flex-3',
|
||||
visibleInMode: 'all',
|
||||
header: () => <Trans message="Video" />,
|
||||
body: video => (
|
||||
<div className="flex items-center gap-12">
|
||||
{video.title ? (
|
||||
<TitlePoster
|
||||
title={video.title}
|
||||
srcSize="sm"
|
||||
size="w-32"
|
||||
aspect="aspect-square"
|
||||
/>
|
||||
) : null}
|
||||
<div className="overflow-hidden min-w-0">
|
||||
<div className="overflow-hidden overflow-ellipsis">
|
||||
<Link
|
||||
to={getWatchLink(video)}
|
||||
target="_blank"
|
||||
className="hover:underline"
|
||||
>
|
||||
{video.title?.name}
|
||||
{video.season_num | video.episode_num ? (
|
||||
<span>
|
||||
{' '}
|
||||
(
|
||||
<CompactSeasonEpisode
|
||||
seasonNum={video.season_num}
|
||||
episodeNum={video.episode_num}
|
||||
/>
|
||||
)
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-muted text-xs overflow-hidden overflow-ellipsis">
|
||||
{video.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Type" />,
|
||||
body: video => <span className="capitalize">{video.type}</span>,
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Category" />,
|
||||
body: video => <span className="capitalize">{video.category}</span>,
|
||||
},
|
||||
{
|
||||
key: 'approved',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Approved" />,
|
||||
body: video => <BooleanIndicator value={video.approved} />,
|
||||
width: 'w-80 flex-shrink-0',
|
||||
},
|
||||
{
|
||||
key: 'plays_count',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Plays" />,
|
||||
body: video =>
|
||||
video.plays_count ? <FormattedNumber value={video.plays_count} /> : null,
|
||||
width: 'w-80 flex-shrink-0',
|
||||
},
|
||||
{
|
||||
key: 'reports_count',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Reports" />,
|
||||
body: video =>
|
||||
video.reports_count ? (
|
||||
<FormattedNumber value={video.reports_count} />
|
||||
) : null,
|
||||
width: 'w-80 flex-shrink-0',
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
allowsSorting: true,
|
||||
maxWidth: 'max-w-100',
|
||||
header: () => <Trans message="Last updated" />,
|
||||
body: video =>
|
||||
video.updated_at ? <FormattedDate date={video.updated_at} /> : '',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
visibleInMode: 'all',
|
||||
align: 'end',
|
||||
width: 'w-84 flex-shrink-0',
|
||||
body: video => (
|
||||
<Fragment>
|
||||
<IconButton
|
||||
size="md"
|
||||
className="text-muted"
|
||||
elementType={Link}
|
||||
to={`${video.id}/insights`}
|
||||
>
|
||||
<BarChartIcon />
|
||||
</IconButton>
|
||||
<Link to={`${video.id}/edit`} className="text-muted">
|
||||
<IconButton size="md">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</Fragment>
|
||||
),
|
||||
},
|
||||
];
|
||||
129
resources/client/admin/videos/videos-datatable-filters.tsx
Executable file
129
resources/client/admin/videos/videos-datatable-filters.tsx
Executable file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
} from '@common/datatable/filters/backend-filter';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {USER_MODEL} from '@common/auth/user';
|
||||
import {
|
||||
createdAtFilter,
|
||||
updatedAtFilter,
|
||||
} from '@common/datatable/filters/timestamp-filters';
|
||||
import {TitleFilterControl} from '@app/admin/reviews/title-filter/title-filter-control';
|
||||
import {TitleFilterPanel} from '@app/admin/reviews/title-filter/title-filter-panel';
|
||||
|
||||
export const VideosDatatableFilters: BackendFilter[] = [
|
||||
{
|
||||
key: 'user_id',
|
||||
label: message('User'),
|
||||
description: message('User video was created by'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.SelectModel,
|
||||
model: USER_MODEL,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'title_id',
|
||||
label: message('Title'),
|
||||
description: message('Movie or series video was created for'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Custom,
|
||||
panel: TitleFilterPanel,
|
||||
listItem: TitleFilterControl,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'approved',
|
||||
label: message('Status'),
|
||||
description: message('Whether video is approved or not'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: false,
|
||||
options: [
|
||||
{label: message('Approved'), key: 'approved', value: true},
|
||||
{label: message('Not approved'), key: 'not_approved', value: false},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'origin',
|
||||
label: message('Origin'),
|
||||
description: message('Whether video origin is local or external'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: 'local',
|
||||
options: [
|
||||
{label: message('Local'), key: 'local', value: 'local'},
|
||||
{
|
||||
label: message('External'),
|
||||
key: 'external',
|
||||
value: {operator: FilterOperator.ne, value: 'local'},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: message('Type'),
|
||||
description: message('Type of the video'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: 'embed',
|
||||
options: [
|
||||
{label: message('Embed'), key: 'embed', value: 'embed'},
|
||||
{label: message('Direct Video'), key: 'video', value: 'video'},
|
||||
{label: message('Stream'), key: 'stream', value: 'stream'},
|
||||
{label: message('Remote Link'), key: 'remote', value: 'remote'},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'quality',
|
||||
label: message('Quality'),
|
||||
description: message('Quality of video'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: 'hd',
|
||||
options: [
|
||||
{label: message('HD'), key: 'hd', value: 'hd'},
|
||||
{label: message('SD'), key: 'sd', value: 'sd'},
|
||||
{label: message('Stream'), key: 'stream', value: 'stream'},
|
||||
{label: message('Remote Link'), key: 'remote', value: 'remote'},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: message('Category'),
|
||||
description: message('Video category'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: 'trailer',
|
||||
options: [
|
||||
{label: message('Trailer'), key: 'trailer', value: 'trailer'},
|
||||
{label: message('Full Movie or episode'), key: 'full', value: 'full'},
|
||||
{label: message('Clip'), key: 'clip', value: 'clip'},
|
||||
{label: message('Teaser'), key: 'teaser', value: 'teaser'},
|
||||
{label: message('Featurette'), key: 'featurette', value: 'featurette'},
|
||||
{
|
||||
label: message('Behind the scenes'),
|
||||
key: 'behind_the_scenes',
|
||||
value: 'behind the scenes',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
createdAtFilter({
|
||||
description: message('Date video was created'),
|
||||
}),
|
||||
updatedAtFilter({
|
||||
description: message('Date video was last updated'),
|
||||
}),
|
||||
];
|
||||
42
resources/client/admin/videos/videos-datatable-page.tsx
Executable file
42
resources/client/admin/videos/videos-datatable-page.tsx
Executable file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import {DataTablePage} from '@common/datatable/page/data-table-page';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DeleteSelectedItemsAction} from '@common/datatable/page/delete-selected-items-action';
|
||||
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
|
||||
import videoFilesImage from './video-files.svg';
|
||||
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {VideosDatatableColumns} from '@app/admin/videos/videos-datatable-columns';
|
||||
import {VideosDatatableFilters} from '@app/admin/videos/videos-datatable-filters';
|
||||
|
||||
export function VideosDatatablePage() {
|
||||
return (
|
||||
<DataTablePage
|
||||
endpoint="videos"
|
||||
queryParams={{
|
||||
withCount: 'plays,reports',
|
||||
with: 'episode',
|
||||
}}
|
||||
title={<Trans message="Videos" />}
|
||||
columns={VideosDatatableColumns}
|
||||
filters={VideosDatatableFilters}
|
||||
actions={<Actions />}
|
||||
selectedActions={<DeleteSelectedItemsAction />}
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={videoFilesImage}
|
||||
title={<Trans message="No videos have been created yet" />}
|
||||
filteringTitle={<Trans message="No matching videos" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions() {
|
||||
return (
|
||||
<DataTableAddItemButton elementType={Link} to="new">
|
||||
<Trans message="Add video" />
|
||||
</DataTableAddItemButton>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user