609 lines
24 KiB
TypeScript
609 lines
24 KiB
TypeScript
// * Packages
|
|
import { useEffect, useState } from "react";
|
|
import { VideoIcon } from "lucide-react";
|
|
// * Components shadcn
|
|
import {
|
|
Dialog,
|
|
DialogDescription,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
// * Other
|
|
import {
|
|
videoDetails,
|
|
extractYoutubeId,
|
|
extractVimeoId,
|
|
videoTextSchema,
|
|
isBoolsSchema,
|
|
buildVideoSrc,
|
|
} from "@/back/Components/Inputs/Video/VideoUtils";
|
|
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
|
import str from "@/hooks/use-string";
|
|
import VideoInputs from "./VideoInputs";
|
|
import SimpleVimeoId from "../SimpleVimeoId";
|
|
import SimpleYoutubeId from "../SimpleYoutubeId";
|
|
import { cn, GetTranslation } from "@/lib/utils";
|
|
import z from "zod";
|
|
import { LaravelEnum } from "@/back/types/global";
|
|
import { usePage } from "@inertiajs/react";
|
|
|
|
export interface VideoAttributes {
|
|
videoId: string | null;
|
|
width: number;
|
|
style?: string | null;
|
|
float?: string | null;
|
|
height: number;
|
|
caption: string | null;
|
|
isMuted: boolean;
|
|
hasControls: boolean;
|
|
canFullScreen: boolean;
|
|
alt: string;
|
|
format: string;
|
|
provider: number;
|
|
hasCaption: boolean;
|
|
}
|
|
|
|
export type InputVideoValues = {
|
|
alt: string;
|
|
caption: string | null;
|
|
hasCaption: boolean;
|
|
isMuted: boolean;
|
|
hasControls: boolean;
|
|
canFullScreen: boolean;
|
|
format: string;
|
|
provider: number;
|
|
videoId: string;
|
|
};
|
|
|
|
type VideoFormatOption = {
|
|
name: "landscape" | "portrait";
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
type VideoProps = {
|
|
disabled: boolean;
|
|
onValidateVideo?: (value: VideoAttributes | null) => void;
|
|
value?: VideoAttributes | null;
|
|
isSmallButton?: boolean;
|
|
hasError?: boolean;
|
|
forcedProvider?: number;
|
|
forcedFormat?: string;
|
|
};
|
|
|
|
const VIDEO_FORMATS: VideoFormatOption[] = [
|
|
{
|
|
name: "landscape",
|
|
width: Number(videoDetails.width),
|
|
height: Number(videoDetails.height),
|
|
},
|
|
{
|
|
name: "portrait",
|
|
width: Number(videoDetails.height),
|
|
height: Number(videoDetails.width),
|
|
},
|
|
];
|
|
|
|
const animationTime = 200;
|
|
type VideoProviderEnum = {
|
|
[K in keyof LaravelEnum]: LaravelEnum[K] & { color: string };
|
|
};
|
|
export default function VideoButton({
|
|
disabled,
|
|
onValidateVideo,
|
|
value,
|
|
isSmallButton = false,
|
|
hasError = false,
|
|
forcedProvider,
|
|
forcedFormat,
|
|
}: VideoProps) {
|
|
const videoProviderEnum = usePage().props.videoProviderEnum as VideoProviderEnum;
|
|
|
|
const defaultValueFormat =
|
|
VIDEO_FORMATS.find((f) => f.name === value?.format) ?? VIDEO_FORMATS[0];
|
|
|
|
const DEFAULT_OPTIONS: InputVideoValues = {
|
|
alt: value && value.alt ? value.alt : "",
|
|
caption: value && value.caption ? value.caption : null,
|
|
hasCaption: value && value.caption ? true : false,
|
|
isMuted: value && value.isMuted ? Boolean(value.isMuted) : false,
|
|
hasControls: value && value.hasControls ? Boolean(value.hasControls) : true,
|
|
canFullScreen:
|
|
value && value.canFullScreen ? Boolean(value.canFullScreen) : true,
|
|
format: forcedFormat
|
|
? forcedFormat
|
|
: value && value.format
|
|
? value.format
|
|
: VIDEO_FORMATS[0].name,
|
|
provider: forcedProvider
|
|
? forcedProvider
|
|
: value && value.provider
|
|
? Number(value.provider)
|
|
: videoProviderEnum.youtube.value,
|
|
videoId: value && value.videoId ? value.videoId : "",
|
|
};
|
|
|
|
const defaultProviderEnum = Object.values(videoProviderEnum).find(
|
|
(provider) => provider.value == DEFAULT_OPTIONS.provider
|
|
);
|
|
|
|
const [open, setOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [videoProvider, setVideoProvider] = useState<number>(
|
|
DEFAULT_OPTIONS.provider,
|
|
);
|
|
const [videoUrl, setVideoUrl] = useState<string>(
|
|
DEFAULT_OPTIONS.videoId,
|
|
);
|
|
const [videoUrlPreview, setVideoUrlPreview] = useState<string>(
|
|
DEFAULT_OPTIONS.videoId,
|
|
);
|
|
const [inputFormat, setInputFormat] =
|
|
useState<VideoFormatOption>(defaultValueFormat);
|
|
const [options, setOptions] = useState<InputVideoValues>(DEFAULT_OPTIONS);
|
|
|
|
const src = buildVideoSrc({
|
|
videoId: videoUrl,
|
|
isMuted: value?.isMuted,
|
|
hasControls: value?.hasControls,
|
|
provider: value?.provider ?? videoProviderEnum.youtube.value
|
|
});
|
|
|
|
// * List of global functions for wysiwyg or basic input
|
|
const resetComponent = () => {
|
|
setVideoUrl(DEFAULT_OPTIONS.videoId);
|
|
setVideoUrlPreview(DEFAULT_OPTIONS.videoId);
|
|
setOptions(DEFAULT_OPTIONS);
|
|
setVideoProvider(DEFAULT_OPTIONS.provider);
|
|
setInputFormat(defaultValueFormat);
|
|
setErrors({});
|
|
setLoading(false);
|
|
};
|
|
|
|
//* List of functions used for wysiwyg usage
|
|
|
|
//* TRANSLATIONS
|
|
const transCaption = GetTranslation("back.input_video.caption");
|
|
const transIsMuted = GetTranslation("back.input_video.is_muted");
|
|
const transHasControls = GetTranslation("back.input_video.has_controls");
|
|
const transCanFullScreen = GetTranslation(
|
|
"back.input_video.can_fullScreen",
|
|
);
|
|
const youtubeError = str(
|
|
GetTranslation("back.input_video.invalid_youtube_id"),
|
|
)
|
|
.ucFirst()
|
|
.value();
|
|
const vimeoError = str(
|
|
GetTranslation("back.input_video.invalid_vimeo_id"),
|
|
)
|
|
.ucFirst()
|
|
.value();
|
|
const transAlt = GetTranslation("back.input_video.alt");
|
|
|
|
const transValidation = (attribute: string) => {
|
|
return {
|
|
min: GetTranslation("validation.min.string", {
|
|
params: { attribute, min: "3" },
|
|
}),
|
|
max: GetTranslation("validation.max.string", {
|
|
params: { attribute, max: "255" },
|
|
}),
|
|
string: GetTranslation("validation.string", {
|
|
params: { attribute },
|
|
}),
|
|
boolean: GetTranslation("validation.boolean", {
|
|
params: { attribute },
|
|
}),
|
|
};
|
|
};
|
|
const transValidationCaption = transValidation(transCaption);
|
|
const transValidationIsMuted = transValidation(transIsMuted);
|
|
const transValidationHasControls = transValidation(transHasControls);
|
|
const transValidationCanFullScreen = transValidation(transCanFullScreen);
|
|
const transValidationAlt = transValidation(transAlt);
|
|
|
|
/**
|
|
* Set new errors from object errors
|
|
*
|
|
* @param err unknown
|
|
* @return void
|
|
*/
|
|
const setNewErrors = (err: unknown): void => {
|
|
const fieldErrors: typeof errors = {};
|
|
const fieldMapping: Record<string, string> = {
|
|
caption: "caption",
|
|
isMuted: "isMuted",
|
|
hasControls: "hasControls",
|
|
canFullScreen: "canFullScreen",
|
|
alt: "alt",
|
|
};
|
|
// Manage zod errors.
|
|
if (err instanceof z.ZodError) {
|
|
err.issues.forEach((error) => {
|
|
const key = error.path[0] as string | undefined;
|
|
const mappedKey =
|
|
(key && fieldMapping[key]) ??
|
|
(error.path.length === 0 ? "form" : undefined);
|
|
if (mappedKey) fieldErrors[mappedKey] = error.message;
|
|
});
|
|
|
|
setErrors(fieldErrors);
|
|
return;
|
|
}
|
|
};
|
|
|
|
const handleAddVideo = (): void => {
|
|
setLoading(true);
|
|
setErrors({});
|
|
|
|
const videoMetadataSchema = videoTextSchema({
|
|
hasCaption: options.hasCaption,
|
|
transValidationCaption: transValidationCaption,
|
|
transValidationAlt,
|
|
});
|
|
const isBoolsSchemaValidation = isBoolsSchema({
|
|
canFullScreen: transValidationCanFullScreen.boolean,
|
|
isMuted: transValidationIsMuted.boolean,
|
|
hasControls: transValidationHasControls.boolean,
|
|
});
|
|
|
|
const videoData = {
|
|
caption: options.caption,
|
|
alt: options.alt,
|
|
isMuted: options.isMuted,
|
|
hasControls: options.hasControls,
|
|
canFullScreen: options.canFullScreen,
|
|
url: videoUrl,
|
|
};
|
|
|
|
let videoId: string | null;
|
|
try {
|
|
videoMetadataSchema.parse(videoData);
|
|
isBoolsSchemaValidation.parse(videoData);
|
|
if (videoProvider === videoProviderEnum.youtube.value) {
|
|
videoId = extractYoutubeId(videoUrl);
|
|
if (!videoId) {
|
|
setErrors({ url: youtubeError });
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
} else {
|
|
videoId = extractVimeoId(videoUrl);
|
|
if (!videoId) {
|
|
setErrors({ url: vimeoError });
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
onValidateVideo?.({
|
|
canFullScreen: options.canFullScreen,
|
|
caption: options.caption,
|
|
hasControls: options.hasControls,
|
|
isMuted: options.isMuted,
|
|
videoId: videoId ?? "",
|
|
provider: videoProvider,
|
|
alt: options.alt,
|
|
format: inputFormat.name,
|
|
width: inputFormat.width,
|
|
height: inputFormat.height,
|
|
hasCaption: options.hasCaption
|
|
});
|
|
setVideoUrlPreview(videoId ?? "");
|
|
|
|
setOpen(false);
|
|
setTimeout(() => {
|
|
if (!value) {
|
|
resetComponent();
|
|
}
|
|
}, animationTime);
|
|
} catch (err) {
|
|
console.log(err);
|
|
setNewErrors(err);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
const resetOnQuit = () => {
|
|
if (!value) {
|
|
onValidateVideo?.(null);
|
|
setOptions(DEFAULT_OPTIONS);
|
|
setVideoUrl("");
|
|
setVideoProvider(DEFAULT_OPTIONS.provider);
|
|
setInputFormat(defaultValueFormat);
|
|
} else {
|
|
onValidateVideo?.({
|
|
...DEFAULT_OPTIONS,
|
|
videoId: DEFAULT_OPTIONS.videoId,
|
|
provider: DEFAULT_OPTIONS.provider,
|
|
width: VIDEO_FORMATS[0].width,
|
|
height: VIDEO_FORMATS[0].height
|
|
});
|
|
setVideoUrl(DEFAULT_OPTIONS.videoId);
|
|
setOptions(DEFAULT_OPTIONS);
|
|
setVideoProvider(DEFAULT_OPTIONS.provider);
|
|
setInputFormat(defaultValueFormat);
|
|
}
|
|
setVideoUrlPreview(DEFAULT_OPTIONS.videoId ?? "");
|
|
setErrors({});
|
|
setLoading(false);
|
|
}
|
|
const handleCancel = (): void => {
|
|
setOpen(false);
|
|
setTimeout(() => {
|
|
resetOnQuit();
|
|
}, animationTime);
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(status) => {
|
|
setOpen(status);
|
|
if (!status) {
|
|
setTimeout(() => {
|
|
resetOnQuit();
|
|
}, animationTime);
|
|
}
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>
|
|
{isSmallButton ? (
|
|
<BtnOptionnalLinkWithTooltip
|
|
content={<VideoIcon className="w-4 h-4 m-auto" />}
|
|
tooltipContent={str(
|
|
GetTranslation("back.input_video.tooltip.title"),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
disabled={disabled}
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
/>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
disabled={disabled}
|
|
type="button"
|
|
className={cn(
|
|
"flex border border-dashed overflow-hidden whitespace-normal [&_svg]:!pointer-events-auto [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-40 rounded-r-none",
|
|
{ "border-destructive": hasError },
|
|
)}
|
|
>
|
|
{videoUrlPreview ? (
|
|
<div
|
|
className={cn(
|
|
"relative",
|
|
inputFormat.name === "landscape" &&
|
|
"aspect-[16/9]",
|
|
inputFormat.name === "portrait" &&
|
|
"aspect-[9/16]",
|
|
)}
|
|
>
|
|
<iframe
|
|
src={src}
|
|
className="w-full h-full"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
allowFullScreen={value?.canFullScreen}
|
|
title={value?.caption || "video"}
|
|
/>
|
|
<div className="iframe-overlay"></div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col justify-center items-center">
|
|
<VideoIcon />
|
|
<p className="text-center break-normal w-full mt-2">
|
|
{str(GetTranslation('back.input_video.placeholder')).ucFirst().value()}
|
|
{forcedProvider ? (defaultProviderEnum?.label ?
|
|
<span style={{
|
|
color: defaultProviderEnum?.color
|
|
}}>{" " + str(defaultProviderEnum?.label).ucFirst().value()}</span> : "") : ""}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</DialogTrigger>
|
|
<DialogContent className={cn("p-4", "selected_" + videoProvider)}>
|
|
<DialogHeader className="px-2 pt-2">
|
|
<DialogTitle>
|
|
{GetTranslation("back.input_video.title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{GetTranslation("back.input_video.subtitle")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="overflow-y-auto max-h-[70vh] flex flex-col gap-4 px-2">
|
|
{/* Provider selection */}
|
|
{!forcedProvider && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label>
|
|
{str(GetTranslation("back.input_video.provider"))
|
|
.ucFirst()
|
|
.value()}
|
|
</Label>
|
|
<div className="flex gap-4">
|
|
{Object.values(videoProviderEnum).map(
|
|
(provider) => (
|
|
<Button
|
|
key={provider.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setVideoProvider(
|
|
provider.value,
|
|
);
|
|
setVideoUrl("");
|
|
setVideoUrlPreview("");
|
|
setErrors({});
|
|
}}
|
|
variant={
|
|
videoProvider === provider.value
|
|
? "default"
|
|
: "secondary"
|
|
}
|
|
className="flex-1 flex flex-col items-center gap-2 h-fit py-3 [&_svg]:size-8"
|
|
>
|
|
{provider.value ===
|
|
videoProviderEnum.youtube.value ? (
|
|
<YoutubeIcon />
|
|
) : (
|
|
<VimeoIcon />
|
|
)}
|
|
<span className="text-sm font-medium capitalize">
|
|
{provider.label}
|
|
</span>
|
|
</Button>
|
|
),
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* URL / ID input */}
|
|
{videoProvider === videoProviderEnum.youtube.value ? (
|
|
<SimpleYoutubeId
|
|
name="url"
|
|
value={videoUrl}
|
|
setData={(_, v) => {
|
|
setErrors((prev) => ({ ...prev, url: "" }));
|
|
setVideoUrl(v as string);
|
|
}}
|
|
required={false}
|
|
parentErrors={errors}
|
|
disabled={loading}
|
|
/>
|
|
) : (
|
|
<SimpleVimeoId
|
|
name="url"
|
|
value={videoUrl}
|
|
setData={(_, v) => {
|
|
setErrors((prev) => ({ ...prev, url: "" }));
|
|
setVideoUrl(v as string);
|
|
}}
|
|
required={false}
|
|
parentErrors={errors}
|
|
disabled={loading}
|
|
/>
|
|
)}
|
|
{/* Format selection */}
|
|
{!forcedFormat && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label>
|
|
{str(GetTranslation("back.input_video.format"))
|
|
.ucFirst()
|
|
.value()}
|
|
</Label>
|
|
<div className="flex gap-4">
|
|
{VIDEO_FORMATS.map((format) => (
|
|
<Button
|
|
key={format.name}
|
|
type="button"
|
|
onClick={() => setInputFormat(format)}
|
|
variant={
|
|
inputFormat.name === format.name
|
|
? "default"
|
|
: "secondary"
|
|
}
|
|
className="flex-1 flex flex-col items-center gap-2 h-fit py-3"
|
|
>
|
|
<div className="w-16 h-12 flex items-center justify-center">
|
|
<div
|
|
className={
|
|
format.name === "landscape"
|
|
? "w-14 h-8 bg-current opacity-50 rounded"
|
|
: "w-8 h-12 bg-current opacity-50 rounded"
|
|
}
|
|
/>
|
|
</div>
|
|
<span className="text-sm font-medium">
|
|
{str(
|
|
GetTranslation(
|
|
`back.input_video.${format.name}`,
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Options: caption, muted, controls, fullscreen */}
|
|
<VideoInputs
|
|
value={options}
|
|
onChange={setOptions}
|
|
disabled={loading}
|
|
errors={errors}
|
|
/>
|
|
</div>
|
|
<DialogFooter className="gap-1.5 px-2 pb-2">
|
|
<BtnOptionnalLinkWithTooltip
|
|
content={str(GetTranslation("back.input_video.cancel"))
|
|
.ucFirst()
|
|
.value()}
|
|
tooltipContent={GetTranslation(
|
|
"back.input_video.tooltip.cancel",
|
|
)}
|
|
className="cursor-pointer"
|
|
variant="secondary"
|
|
type="button"
|
|
size="default"
|
|
disabled={loading}
|
|
onClick={handleCancel}
|
|
/>
|
|
<BtnOptionnalLinkWithTooltip
|
|
content={str(GetTranslation("back.input_video.insert"))
|
|
.ucFirst()
|
|
.value()}
|
|
tooltipContent={GetTranslation(
|
|
"back.input_video.tooltip.insert",
|
|
)}
|
|
className="cursor-pointer"
|
|
type="button"
|
|
size="default"
|
|
disabled={loading || !videoUrl.trim()}
|
|
onClick={handleAddVideo}
|
|
/>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function YoutubeIcon() {
|
|
return (
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
<path
|
|
fill="#FF0000"
|
|
d="M23.498 6.186a2.998 2.998 0 0 0-2.11-2.117C19.66 3.5 12 3.5 12 3.5s-7.66 0-9.388.569A2.998 2.998 0 0 0 .502 6.186 31.198 31.198 0 0 0 0 12a31.198 31.198 0 0 0 .502 5.814 2.998 2.998 0 0 0 2.11 2.117C4.34 20.5 12 20.5 12 20.5s7.66 0 9.388-.569a2.998 2.998 0 0 0 2.11-2.117A31.198 31.198 0 0 0 24 12a31.198 31.198 0 0 0-.502-5.814z"
|
|
/>
|
|
<path
|
|
fill="#FFF"
|
|
d="M9.545 15.568l6.364-3.568-6.364-3.568v7.136z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function VimeoIcon() {
|
|
return (
|
|
<svg
|
|
viewBox="0 -3.5 48 48"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
>
|
|
<path
|
|
fill="#00B2EA"
|
|
d="M47.975851,9.479329 C47.767002,14.100014 44.498808,20.41644 38.193846,28.431401 C31.668746,36.81233 26.13991,41 21.621448,41 C18.827396,41 16.459507,38.452198 14.526249,33.339832 C13.230825,28.649305 11.943867,23.958779 10.651265,19.282221 C9.211905,14.167061 7.670943,11.610878 6.022735,11.610878 C5.664306,11.610878 4.414038,12.356781 2.25782,13.85138 L0,10.971134 C2.365066,8.917807 4.696265,6.856098 6.996419,4.799977 C10.146078,2.101318 12.513967,0.684941 14.094441,0.536878 C17.819844,0.179292 20.117175,2.701951 20.983614,8.096476 C21.906498,13.921221 22.555621,17.541782 22.91405,18.960952 C23.992159,23.788367 25.17187,26.196487 26.464472,26.196487 C27.466379,26.196487 28.973474,24.637634 30.982934,21.517135 C32.992393,18.391049 34.062036,16.016453 34.208794,14.379378 C34.496666,11.680719 33.421379,10.339771 30.982934,10.339771 C29.834268,10.339771 28.648912,10.580024 27.432512,11.08288 C29.803223,3.486965 34.318863,-0.206231 40.99072,0.00888 C45.932524,0.145768 48.266545,3.308172 47.975851,9.479329"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|