ldc-groupe/resources/ts/back/Components/Inputs/Video/VideoButton.tsx

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