From 76563750d95f6a1364374378b1e8ce8a3d6e0d8d Mon Sep 17 00:00:00 2001 From: K Muller Date: Thu, 5 Mar 2026 14:30:43 +0100 Subject: [PATCH] feat: :sparkles: add video iframe input for dynamicPage/form/wysiwyg --- app/Enums/DynamicFieldTypes.php | 12 +- app/Enums/VideoProviderEnum.php | 11 + app/Http/Middleware/HandleInertiaRequests.php | 2 + .../Requests/Back/News/StoreNewsRequest.php | 2 + .../Back/UpdateDynamicFieldRequest.php | 6 + app/Models/News.php | 2 +- app/Rules/Video.php | 187 ++++++ lang/en/back.php | 69 +- lang/en/enums.php | 6 +- lang/en/validation.php | 11 +- lang/fr/back.php | 77 +-- lang/fr/enums.php | 4 + lang/fr/validation.php | 13 +- resources/sass/back/inputs/_wysiwyg.scss | 21 +- .../Components/DynamicPages/DynamicInputs.tsx | 30 +- .../DynamicWrapperModelSelect.tsx | 3 +- .../Components/Inputs/Video/VideoButton.tsx | 609 ++++++++++++++++++ .../Components/Inputs/Video/VideoInputs.tsx | 147 +++++ .../Extensions => }/Video/VideoUtils.tsx | 28 +- .../ts/back/Components/Inputs/VideoInput.tsx | 113 ++++ .../Inputs/Wysiwyg/Extensions/Video/Video.tsx | 20 +- .../Wysiwyg/Extensions/Video/VideoButton.tsx | 388 ----------- .../Wysiwyg/Extensions/Video/VideoInputs.tsx | 116 ---- .../Extensions/Video/VideoNodeView.tsx | 28 +- .../Wysiwyg/Extensions/Video/video.d.ts | 14 +- .../Inputs/Wysiwyg/SimpleWysiwyg.tsx | 13 +- .../Components/Inputs/Wysiwyg/Toolbar.tsx | 15 +- resources/ts/back/types/dynamic-pages.d.ts | 3 +- 28 files changed, 1312 insertions(+), 638 deletions(-) create mode 100644 app/Rules/Video.php create mode 100644 resources/ts/back/Components/Inputs/Video/VideoButton.tsx create mode 100644 resources/ts/back/Components/Inputs/Video/VideoInputs.tsx rename resources/ts/back/Components/Inputs/{Wysiwyg/Extensions => }/Video/VideoUtils.tsx (82%) create mode 100644 resources/ts/back/Components/Inputs/VideoInput.tsx delete mode 100644 resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Video/VideoButton.tsx delete mode 100644 resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Video/VideoInputs.tsx diff --git a/app/Enums/DynamicFieldTypes.php b/app/Enums/DynamicFieldTypes.php index b6880b6a..e8292376 100644 --- a/app/Enums/DynamicFieldTypes.php +++ b/app/Enums/DynamicFieldTypes.php @@ -8,7 +8,7 @@ enum DynamicFieldTypes: int { use BaseEnum; - // Name must be one word in lowercase. + // Name must be one word in lowercase. case string = 1; case textarea = 2; case wysiwyg = 3; @@ -25,8 +25,9 @@ enum DynamicFieldTypes: int case color = 14; case model = 15; case staticroute = 16; + case video = 17; - // Specific inputs. + // Specific inputs. case position = 101; case contactform = 102; @@ -52,6 +53,7 @@ enum DynamicFieldTypes: int self::color->name => 'enums.dynamic-field-types.color', self::model->name => 'enums.dynamic-field-types.model', self::staticroute->name => 'enums.dynamic-field-types.staticroute', + self::video->name => 'enums.dynamic-field-types.video', // Specific inputs. self::position->name => 'enums.dynamic-field-types.position', @@ -72,12 +74,13 @@ enum DynamicFieldTypes: int self::image->name => 'required|file|mimes:jpg,jpeg,png,webp|max:5120|dimensions:width=100,height=100', self::list->name => 'required|array|min:1|max:10', self::file->name => 'required|file|mimes:pdf|max:5120', - self::vimeoid->name => 'required|string|min:8|max:10', - self::youtubeid->name => 'required|string|size:11', + self::vimeoid->name => 'required', + self::youtubeid->name => 'required', self::date->name => 'required|date', self::color->name => 'required|string|hex_color', self::model->name => 'required|numeric|exists:dynamic_pages,id', self::staticroute->name => 'required|numeric|between:1,99', + self::video->name => 'required', // Specific inputs. self::position->name => 'required|numeric|between:1,5', @@ -117,6 +120,7 @@ enum DynamicFieldTypes: int self::color->name => false, self::model->name => false, self::staticroute->name => false, + self::video->name => false, // Specific inputs. self::position->name => false, diff --git a/app/Enums/VideoProviderEnum.php b/app/Enums/VideoProviderEnum.php index 2d2f8cc9..ddd92756 100644 --- a/app/Enums/VideoProviderEnum.php +++ b/app/Enums/VideoProviderEnum.php @@ -20,4 +20,15 @@ enum VideoProviderEnum: int self::youtube->name => 'youtube', self::vimeo->name => 'viméo', ]; + + /** + * Is array definition. + * + * @return array + * @phpstan-ignore-next-line + */ + private const COLOR = [ + self::youtube->name => "#FF0000", + self::vimeo->name => "#00B2EA", + ]; } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index e3a5fdb4..6b26806e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; use App\Enums\DynamicFieldTypes; use App\Enums\ModelsList; use App\Enums\StaticRoutesEnum; +use App\Enums\VideoProviderEnum; use App\Helpers\TranslationHelper; use App\Models\Company; use App\Models\CompanyActivity; @@ -139,6 +140,7 @@ class HandleInertiaRequests extends Middleware 'enumDynamicFieldTypes' => DynamicFieldTypes::toArray(), 'enumModelsList' => ModelsList::toArray(), 'enumStaticRoutes' => StaticRoutesEnum::getRoutes(), + 'videoProviderEnum' => VideoProviderEnum::toArray(), 'errors' => $errors, 'translations' => $translations, ]; diff --git a/app/Http/Requests/Back/News/StoreNewsRequest.php b/app/Http/Requests/Back/News/StoreNewsRequest.php index 97aaa4aa..d72f1b6d 100644 --- a/app/Http/Requests/Back/News/StoreNewsRequest.php +++ b/app/Http/Requests/Back/News/StoreNewsRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Back\News; use App\Enums\News\NewsStatusEnum; use App\Models\News; +use App\Rules\Video; use App\Traits\Requests\HasInertiaPicture; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Gate; @@ -47,6 +48,7 @@ class StoreNewsRequest extends FormRequest 'seo_description' => 'required|string|between:3,255', 'title' => 'required|string|between:3,255', 'slug' => 'required|string|between:3,255', + 'iframe' => ['required', new Video()], 'picture' => [ function ($attribute, $value, $fail) { $extension = null; diff --git a/app/Http/Requests/Back/UpdateDynamicFieldRequest.php b/app/Http/Requests/Back/UpdateDynamicFieldRequest.php index 6b787f1c..62e93187 100644 --- a/app/Http/Requests/Back/UpdateDynamicFieldRequest.php +++ b/app/Http/Requests/Back/UpdateDynamicFieldRequest.php @@ -7,6 +7,7 @@ use App\Enums\ElementPositionEnum; use App\Enums\DynamicFieldTypes; use App\Enums\StaticRoutesEnum; use App\Models\PageConfig; +use App\Rules\Video; use App\Traits\Requests\HasInertiaPicture; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Str; @@ -210,6 +211,11 @@ abstract class UpdateDynamicFieldRequest extends FormRequest ), ]); break; + case DynamicFieldTypes::video->value: + $rules = array_merge($rules, [ + $ruleKey => new Video(), + ]); + break; default: throw new \UnexpectedValueException("Unrecognized dynamic type: " . ($input['type'] ?? 'null')); } //end switch diff --git a/app/Models/News.php b/app/Models/News.php index 429e6f3c..f010ac56 100644 --- a/app/Models/News.php +++ b/app/Models/News.php @@ -132,7 +132,7 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable 'unpublished_at', 'inverted', 'vimeo_id', - 'has_show_picture' + 'has_show_picture', ]; /** diff --git a/app/Rules/Video.php b/app/Rules/Video.php new file mode 100644 index 00000000..cd3c19b6 --- /dev/null +++ b/app/Rules/Video.php @@ -0,0 +1,187 @@ +data = $data; + return $this; + } + + /** + * Construct method. + * + * @param integer $min The minimum length of the content (excluding tags). Default is 15. + * @param integer|null $max The maximum length of the content (excluding tags). + * Default is null (max will be defined by the type of the column in DB). + * @return void + * @throws \InvalidArgumentException If $max < $min. + */ + public function __construct() {} + + /** + * Run the validation rule. + * + * @param string $attribute + * @param mixed $value + * @param \Closure $fail + * + * @return void + */ + public function validate(string $attribute, mixed $value, \Closure $fail): void + { + + $lengthRules = [ + $attribute . '.alt' => ['min' => 3, 'max' => 255], + $attribute . '.caption' => ['min' => 3, 'max' => 255], + ]; + + if (!$value) { + return; + } + + $fields = [ + $attribute . '.alt', + $attribute . '.caption', + $attribute . '.isMuted', + $attribute . '.hasControls', + $attribute . '.canFullScreen', + $attribute . '.provider', + $attribute . '.videoId', + $attribute . '.format', + ]; + + foreach ($fields as $field) { + $fieldValue = Arr::get($this->data, $field); + if (in_array($field, [ + $attribute . '.alt', + $attribute . '.caption' + ])) { + $lengthRule = $lengthRules[$field] ?? null; + + if ($lengthRule) { + $min = $lengthRule['min'] ?? 0; + $max = $lengthRule['max'] ?? PHP_INT_MAX; + if ($fieldValue === null && $field === $attribute . '.caption') { + continue; + } + if ($fieldValue === null && $field === $attribute . '.title') { + continue; + } + if (strlen($fieldValue) < $min) { + $fail(__('validation.min.string', [ + 'attribute' => self::getTranslatedAttribute($field), + 'min' => $min + ])); + return; + } + + if (strlen($fieldValue) > $max) { + $fail(__('validation.max.string', [ + 'attribute' => self::getTranslatedAttribute($field), + 'max' => $max + ])); + return; + } + } + } + + // Boolean fields + if (in_array($field, [$attribute . '.isMuted', $attribute . '.hasControls', $attribute . '.canFullScreen'])) { + $fieldValue = (bool)$fieldValue; + if (!is_bool($fieldValue)) { + $fail(__('validation.boolean', [ + 'attribute' => self::getTranslatedAttribute($field), + ])); + return; + } + } + + if($field === $attribute . '.format') { + if($fieldValue != "landscape" && $fieldValue != "portrait") { + $fail(__('validation.custom.format', [ + 'attribute' => self::getTranslatedAttribute($field), + ])); + return; + } + } + + if ($field === $attribute . '.provider') { + + $fieldValue = (int) $fieldValue; + + if (!is_int($fieldValue)) { + $fail(__('validation.integer', [ + 'attribute' => self::getTranslatedAttribute($field), + ])); + return; + } + + $enumValues = array_map(fn($case) => $case->value, VideoProviderEnum::cases()); + if (!in_array($fieldValue, $enumValues)) { + $fail(__('validation.in', [ + 'attribute' => self::getTranslatedAttribute($field), + ])); + return; + } + + if ($field === $attribute . '.videoId') { + $provider = Arr::get($this->data, $attribute . '.provider'); + + if ($provider === \App\Enums\VideoProviderEnum::vimeo->value) { + $rule = new IsValidVimeoId(); + } elseif ($provider === \App\Enums\VideoProviderEnum::youtube->value) { + $rule = new IsValidYoutubeId(); + } else { + $fail(__('validation.invalid', [ + 'attribute' => self::getTranslatedAttribute($attribute . '.provider'), + ])); + return; + } + $rule->validate($field, $fieldValue, $fail); + } + } + } + } + + /** + * Get the correct translation for the given attrribute. + * + * @param string $attribute + * @return string + */ + private static function getTranslatedAttribute(string $attribute): string + { + $parts = explode('.', $attribute); + if (count($parts) > 1) { + $parts[0] = 'video_'; + $attribute = implode('.', $parts); + } + + /** @var \Illuminate\Translation\Translator $trans */ + $trans = trans(); + if ($trans->has('validation.attributes.' . $attribute)) { + return trans('validation.attributes.' . $attribute); + } + if ($trans->has('validation.custom.' . $attribute)) { + return trans('validation.custom.' . $attribute); + } + return $attribute; + } +} diff --git a/lang/en/back.php b/lang/en/back.php index 1cc37ff1..6bb4708a 100644 --- a/lang/en/back.php +++ b/lang/en/back.php @@ -192,7 +192,7 @@ return [ "delete" => "delete the color", ], "wysiwyg" => [ - "undo" => "Undo", + "undo" => "Undo", "redo" => "Redo", "bold" => "Bold", "italic" => "Italic", @@ -295,37 +295,6 @@ return [ "cancel" => "Cancel", "tooltip_cancel" => "Cancel the replacement", ], - "video" => [ - "tooltip" => "Video", - "title" => "Insert an video", - "subtitle" => "Add a new video to your content.", - "insert" => "Insert", - "tooltip_insert" => "Add this video", - "cancel" => "Cancel", - "tooltip_cancel" => "Cancel adding this video", - "edit_title" => "Update a video", - "edit_subtitle" => "Edit the attributes associated with the video.", - "edit_apply" => "Apply", - "edit_tooltip_apply" => "Update the video attributes", - "edit_cancel" => "Cancel", - "edit_tooltip_cancel" => "Cancel updating the video attributes", - "position" => "Image position", - "format" => "Image format", - "video" => "Image", - "alt" => "ALT attribute of the video", - "caption" => "Caption of the video", - "has_caption" => "Presence of a legend", - "landscape" => "Landscape", - "portrait" => "Portrait", - "square" => "Square", - "align-left" => "Align left", - "align-center" => "Align center", - "align-right" => "Align right", - "wrap-left" => "Wrap left", - "wrap-right" => "Wrap right", - "youtube" => "Youtube", - "vimeo" => "Vimeo", - ], ], "json_editor" => [ "item_single" => "1 item", @@ -402,4 +371,40 @@ return [ "select_value" => "select a value from the list", ], "associated_model" => "associated :model|associated :model", + "input_video" => [ + "placeholder" => "Click to add a new video", + "title" => "Insert an video", + "subtitle" => "Add a new video to your content.", + "insert" => "Insert", + "cancel" => "Cancel", + "tooltip_cancel" => "Cancel adding this video", + "edit_title" => "Update a video", + "edit_subtitle" => "Edit the attributes associated with the video.", + "edit_apply" => "Apply", + "edit_cancel" => "Cancel", + "position" => "Image position", + "format" => "Image format", + "video" => "Image", + "alt" => "ALT attribute of the video", + "caption" => "Caption of the video", + "has_caption" => "Presence of a legend", + "landscape" => "Landscape", + "portrait" => "Portrait", + "square" => "Square", + "align-left" => "Align left", + "align-center" => "Align center", + "align-right" => "Align right", + "wrap-left" => "Wrap left", + "wrap-right" => "Wrap right", + "youtube" => "Youtube", + "vimeo" => "Vimeo", + "tooltip" => [ + "title" => "video", + "delete" => "delete the video", + "reset" => "reset to original state", + "insert" => "add this video", + "edit_cancel" => "Cancel updating the video attributes", + "edit_apply" => "Update the video attributes", + ] + ] ]; diff --git a/lang/en/enums.php b/lang/en/enums.php index 32ecc17a..ff31a2a6 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -104,5 +104,9 @@ return [ 'product' => 'product information', 'info' => 'other general information', 'press' => 'a press request', - ] + ], + "videoDisplayFormat" => [ + "landscape" => "landscape", + "portrait" => "portrait" + ], ]; diff --git a/lang/en/validation.php b/lang/en/validation.php index c684132d..d06e6974 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -271,6 +271,15 @@ return [ "value" => "value", "path" => "path", "issuu_path" => "link of the Issuu preview", - "is_issuu" => "is an Issuu preview ?" + "is_issuu" => "is an Issuu preview ?", + "video" => "vidéo", + "video_" => [ + "isMuted" => "muted", + "hasControls" => "controls enabled", + "canFullScreen" => "full screen allowed", + "alt" => "alternative text", + "title" => "title", + "caption" => "caption" + ] ], ]; diff --git a/lang/fr/back.php b/lang/fr/back.php index 92ae3a1d..06090386 100644 --- a/lang/fr/back.php +++ b/lang/fr/back.php @@ -296,42 +296,6 @@ return [ "cancel" => "Annuler", "tooltip_cancel" => "Annuler le remplacement", ], - "video" => [ - "tooltip" => "Vidéo", - "title" => "Insérer une vidéo", - "subtitle" => "Ajouter une nouvelle vidéo à votre contenu.", - "insert" => "Insérer", - "tooltip_insert" => "Ajouter cette vidéo", - "cancel" => "Annuler", - "tooltip_cancel" => "Annuler l'ajout de cette vidéo", - "edit_title" => "Mise à jour d'une vidéo", - "edit_subtitle" => "Modifier les attributs associés à la vidéo.", - "edit_apply" => "Appliquer", - "edit_tooltip_apply" => "Mettre à jour les attributs de la vidéo", - "edit_cancel" => "Annuler", - "edit_tooltip_cancel" => "Annuler la mise à jour des attributs de la vidéo", - "position" => "Position de la vidéo", - "format" => "Format de la vidéo", - "image" => "Vidéo", - "alt" => "Texte alternatif de la vidéo", - "caption" => "Légende de la vidéo", - "has_caption" => "Présence d'une légende", - "landscape" => "Paysage", - "portrait" => "Portrait", - "align-left" => "Aligner à gauche", - "align-center" => "Aligner au centre", - "align-right" => "Aligner à droite", - "wrap-left" => "Flottant à gauche", - "wrap-right" => "Flottant à droite", - "youtube" => "Youtube", - "vimeo" => "Viméo", - "is_muted" => "muter la vidéo ?", - "has_controls" => "permettre de controler la vidéo ?", - "can_fullScreen" => "autoriser le plein écran ?", - "provider" => "hébergeur de la vidéo", - 'invalid_youtube_id' => "vidéo YOUTUBE invalide", - "invalid_vimeo_id" => "vidéo VIMEO invalide" - ], ], "json_editor" => [ "item_single" => "1 élément", @@ -408,4 +372,45 @@ return [ "select_value" => "sélectionnez une valeur dans la liste", ], "associated_model" => ":model associés|:model associées", + "input_video" => [ + "placeholder" => "Cliquer pour ajouter une nouvelle vidéo", + "title" => "Insérer une vidéo", + "subtitle" => "Ajouter une nouvelle vidéo à votre contenu.", + "insert" => "Insérer", + "cancel" => "Annuler", + "edit_title" => "Mise à jour d'une vidéo", + "edit_subtitle" => "Modifier les attributs associés à la vidéo.", + "edit_apply" => "Appliquer", + "edit_cancel" => "Annuler", + "position" => "Position de la vidéo", + "format" => "Format de la vidéo", + "image" => "Vidéo", + "alt" => "Texte alternatif de la vidéo", + "caption" => "Légende de la vidéo", + "has_caption" => "Présence d'une légende", + "landscape" => "Paysage", + "portrait" => "Portrait", + "align-left" => "Aligner à gauche", + "align-center" => "Aligner au centre", + "align-right" => "Aligner à droite", + "wrap-left" => "Flottant à gauche", + "wrap-right" => "Flottant à droite", + "youtube" => "Youtube", + "vimeo" => "Viméo", + "is_muted" => "muter la vidéo ?", + "has_controls" => "permettre de controler la vidéo ?", + "can_fullScreen" => "autoriser le plein écran ?", + "provider" => "hébergeur de la vidéo", + 'invalid_youtube_id' => "vidéo YOUTUBE invalide", + "invalid_vimeo_id" => "vidéo VIMEO invalide", + "tooltip" => [ + "title" => "vidéo", + "delete" => "supprimer la vidéo", + "reset" => "remettre à l'état d'origine", + "insert" => "Ajouter cette vidéo", + "cancel" => "Annuler l'ajout de cette vidéo", + "edit_apply" => "Mettre à jour les attributs de la vidéo", + "edit_cancel" => "Annuler la mise à jour des attributs de la vidéo", + ] + ] ]; diff --git a/lang/fr/enums.php b/lang/fr/enums.php index dd8f6fff..7b126fdf 100644 --- a/lang/fr/enums.php +++ b/lang/fr/enums.php @@ -105,4 +105,8 @@ return [ 'info' => 'une autre information générale', 'press' => 'une demande presse', ], + "videoDisplayFormat" => [ + "landscape" => "paysage", + "portrait" => "portrait" + ], ]; diff --git a/lang/fr/validation.php b/lang/fr/validation.php index c01c2d52..79bf2033 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -270,6 +270,17 @@ return [ "value" => "valeur", "path" => "chemin d'accès", "issuu_path" => "lien du fichier Issuu", - "is_issuu" => "est-ce un fichier Issuu ?" + "is_issuu" => "est-ce un fichier Issuu ?", + "video" => "vidéo", + "video_" => [ + "isMuted" => "désactiver le son", + "hasControls" => "activer les contrôles", + "canFullScreen" => "autoriser le plein écran", + "alt" => "texte alernatif", + "title" => "titre", + "caption" => "légende", + "provider" => "hébergeur", + "videoId" => "ID de la vidéo" + ] ], ]; diff --git a/resources/sass/back/inputs/_wysiwyg.scss b/resources/sass/back/inputs/_wysiwyg.scss index aaf4d392..0cb34ed6 100644 --- a/resources/sass/back/inputs/_wysiwyg.scss +++ b/resources/sass/back/inputs/_wysiwyg.scss @@ -1,5 +1,5 @@ // * Common css for @tiptap editor preview. -@use './../../common/wysiwyg'; +@use "./../../common/wysiwyg"; // * Custom css. .wysiwyg-error .reactjs-tiptap-editor { @@ -16,18 +16,31 @@ @apply cursor-pointer; } - [data-type='taskList'] > li { + [data-type="taskList"] > li { @apply flex flex-row justify-start items-center gap-1.5; } - + .is-editor-empty:first-child::before { @apply float-left text-muted-foreground h-0 pointer-events-none; content: attr(data-placeholder); } -} +} /* stylelint-disable-next-line selector-class-pattern */ .ProseMirror-focused { @apply rounded-b ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2; } + +figure { + position: relative; +} +.iframe-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0); + pointer-events: all; +} diff --git a/resources/ts/back/Components/DynamicPages/DynamicInputs.tsx b/resources/ts/back/Components/DynamicPages/DynamicInputs.tsx index 3d934d1d..cb4d0e9f 100644 --- a/resources/ts/back/Components/DynamicPages/DynamicInputs.tsx +++ b/resources/ts/back/Components/DynamicPages/DynamicInputs.tsx @@ -28,6 +28,8 @@ import { extractMinMaxFromValidation, extractRequiredValidation, } from "@/hooks/use-laravel-validation"; +import VideoInput from "../Inputs/VideoInput"; +import { VideoAttributes } from "../Inputs/Video/VideoButton"; /* eslint-disable no-unused-vars */ type DynamicInputsProps = { @@ -60,6 +62,7 @@ export default function DynamicInputs({ const enumElementPosition = usePage().props .enumElementPosition as LaravelEnum; const enumContactFormType = usePage().props.enumContactFormType as LaravelEnum + const videoProviderEnum = usePage().props.videoProviderEnum as LaravelEnum; // Translations. const transAssociateTo = useTrans("back.form_associate_to"); const transUnsupportedType = useTrans("back.unsupported_type"); @@ -284,10 +287,13 @@ export default function DynamicInputs({ // VimeoId input case enumDynamicFieldTypes.vimeoid.value: return ( - ) + case enumDynamicFieldTypes.video.value: + return ( + + ) default: return (

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>({}); + const [videoProvider, setVideoProvider] = useState( + DEFAULT_OPTIONS.provider, + ); + const [videoUrl, setVideoUrl] = useState( + DEFAULT_OPTIONS.videoId, + ); + const [videoUrlPreview, setVideoUrlPreview] = useState( + DEFAULT_OPTIONS.videoId, + ); + const [inputFormat, setInputFormat] = + useState(defaultValueFormat); + const [options, setOptions] = useState(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 = { + 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 ( +

{ + setOpen(status); + if (!status) { + setTimeout(() => { + resetOnQuit(); + }, animationTime); + } + }} + > + + {isSmallButton ? ( + } + tooltipContent={str( + GetTranslation("back.input_video.tooltip.title"), + ) + .ucFirst() + .value()} + disabled={disabled} + type="button" + variant="ghost" + size="icon" + /> + ) : ( +