first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}