Merge branch 'videoInput' into 'develop'

feat: ✨ add video iframe input for dynamicPage/form/wysiwyg

See merge request ldc-web/projets/ldc!142
This commit is contained in:
karl.muller 2026-03-11 08:56:00 +00:00
commit ebc8536c1d
28 changed files with 1312 additions and 638 deletions

View file

@ -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,

View file

@ -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",
];
}

View file

@ -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,
];

View file

@ -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;

View file

@ -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

View file

@ -132,7 +132,7 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable
'unpublished_at',
'inverted',
'vimeo_id',
'has_show_picture'
'has_show_picture',
];
/**

187
app/Rules/Video.php Normal file
View file

@ -0,0 +1,187 @@
<?php
namespace App\Rules;
use App\Enums\VideoProviderEnum;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Arr;
use Uwil\LaravelCommon\Rules\IsValidVimeoId;
use Uwil\LaravelCommon\Rules\IsValidYoutubeId;
/**
* Check the length of a Wysiwyg field, excluding tags and end spaces.
*
* @package Uwil\LaravelCommon\Rules
*/
class Video implements ValidationRule, DataAwareRule
{
protected array $data = [];
public function setData(array $data): static
{
$this->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;
}
}

View file

@ -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",
]
]
];

View file

@ -104,5 +104,9 @@ return [
'product' => 'product information',
'info' => 'other general information',
'press' => 'a press request',
]
],
"videoDisplayFormat" => [
"landscape" => "landscape",
"portrait" => "portrait"
],
];

View file

@ -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"
]
],
];

View file

@ -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",
]
]
];

View file

@ -105,4 +105,8 @@ return [
'info' => 'une autre information générale',
'press' => 'une demande presse',
],
"videoDisplayFormat" => [
"landscape" => "paysage",
"portrait" => "portrait"
],
];

View file

@ -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"
]
],
];

View file

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

View file

@ -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 (
<SimpleVimeoId
<VideoInput
name={fieldName}
forcedProvider={videoProviderEnum.vimeo.value}
label={getTrans(field.label)}
value={fieldValue as VideoAttributes}
reset={reset}
setData={setData}
value={String(fieldValue)}
required={extractRequiredValidation(
field.validation,
)}
@ -296,10 +302,13 @@ export default function DynamicInputs({
// YoutubeId input
case enumDynamicFieldTypes.youtubeid.value:
return (
<SimpleYoutubeId
<VideoInput
name={fieldName}
forcedProvider={videoProviderEnum.youtube.value}
label={getTrans(field.label)}
value={fieldValue as VideoAttributes}
reset={reset}
setData={setData}
value={String(fieldValue)}
required={extractRequiredValidation(
field.validation,
)}
@ -383,6 +392,19 @@ export default function DynamicInputs({
)}
/>
)
case enumDynamicFieldTypes.video.value:
return (
<VideoInput
name={fieldName}
label={getTrans(field.label)}
value={fieldValue as VideoAttributes}
reset={reset}
setData={setData}
required={extractRequiredValidation(
field.validation,
)}
/>
)
default:
return (
<p

View file

@ -8,12 +8,13 @@ import {
extractTableFromExists,
} from "@/hooks/use-laravel-validation";
import { ConfigField, DynamicInternalField } from "@/back/types/dynamic-pages";
import { VideoAttributes } from "../Inputs/Video/VideoButton";
/* eslint-disable no-unused-vars */
type DynamicWrapperModelSelectProps = {
field: ConfigField;
fieldName: string;
fieldValue: string | number | boolean | File | Date;
fieldValue: string | number | boolean | File | Date | VideoAttributes;
content: DynamicInternalField | null;
setData: (
name: string,

View file

@ -0,0 +1,609 @@
// * 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>
);
}

View file

@ -0,0 +1,147 @@
// * Other
import SimpleString from "@/back/Components/Inputs/SimpleString";
import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
import { InputVideoValues } from "./VideoButton";
import { Separator } from "@/components/ui/separator";
import { ReactNode } from "react";
type VideoInputsProps = {
value: InputVideoValues;
onChange: (value: InputVideoValues) => void;
errors?: Record<string, string>;
disabled?: boolean;
loading?: boolean;
};
export default function VideoInputs({
value,
onChange,
errors = {},
disabled = false,
loading = false,
}: VideoInputsProps) {
const transVideoCaption = useTrans("back.input_video.caption");
return (
<>
{/* Input alt attribute of the image. */}
<SimpleString
name="alt"
label={str(useTrans("back.input_video.alt"))
.ucFirst()
.value()}
placeholder="ex: John Doe"
required
value={value.alt}
setData={(_, v) => onChange({ ...value, alt: v })}
disabled={disabled || loading}
parentErrors={errors}
/>
<SimpleSwitchLayout>
{/* Input switch to set the caption visibility. */}
<SimpleSwitch
label={str(useTrans("back.input_video.has_caption"))
.ucFirst()
.value()}
leftHelper={str(useTrans("back.no")).ucFirst().value()}
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
name="hasCaption"
checked={value.hasCaption}
setData={(_, v) =>
onChange({
...value,
hasCaption: v,
caption: v ? value.caption : null,
})
}
/>
{/* Input caption of the video. */}
{value.hasCaption && (
<div className="px-4">
<Separator className="my-4" />
<SimpleString
name="caption"
label={str(transVideoCaption).ucFirst().value()}
placeholder="ex: John Doe"
required={false}
value={value.caption ?? ""}
setData={(_, v) => onChange({ ...value, caption: v })}
disabled={disabled || loading}
parentErrors={errors}
/>
</div>
)}
</SimpleSwitchLayout>
<Separator className="my-1" />
<SimpleSwitchLayout>
<SimpleSwitch
label={str(useTrans("back.input_video.is_muted"))
.ucFirst()
.value()}
leftHelper={str(useTrans("back.no")).ucFirst().value()}
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
name="isMuted"
checked={value.isMuted}
setData={(_, v) =>
onChange({
...value,
isMuted: v,
})
}
parentErrors={errors}
/>
</SimpleSwitchLayout>
<Separator className="my-1" />
<SimpleSwitchLayout>
<SimpleSwitch
label={str(useTrans("back.input_video.has_controls"))
.ucFirst()
.value()}
leftHelper={str(useTrans("back.no")).ucFirst().value()}
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
name="hasControls"
checked={value.hasControls}
setData={(_, v) =>
onChange({
...value,
hasControls: v,
})
}
parentErrors={errors}
/>
{value.hasControls && (
<div className="px-4">
<Separator className="my-4" />
<SimpleSwitch
label={str(useTrans("back.input_video.can_fullScreen"))
.ucFirst()
.value()}
leftHelper={str(useTrans("back.no")).ucFirst().value()}
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
name="canFullScreen"
checked={value.canFullScreen}
setData={(_, v) =>
onChange({
...value,
canFullScreen: v,
})
}
parentErrors={errors}
/>
</div>
)}
</SimpleSwitchLayout>
</>
);
}
const SimpleSwitchLayout = ({ children }: { children: ReactNode }) => {
return (
<div className="border-l border-1 h-full p-4">
{children}
</div>
)
}

View file

@ -43,25 +43,23 @@ export const extractVimeoId = (input: string): string | null => {
* Build the iframe src URL for YouTube or Vimeo with options.
*/
export const buildVideoSrc = (attrs: {
youtubeId?: string | null;
vimeoId?: string | null;
videoId?: string | null;
isMuted?: boolean;
hasControls?: boolean;
provider: number;
}): string => {
const params = new URLSearchParams();
if (attrs.youtubeId) {
const query = params.toString();
if (attrs.provider == 1) {
if (attrs.isMuted) params.set("mute", "1");
params.set("controls", attrs.hasControls ? "1" : "0");
const query = params.toString();
return `https://www.youtube.com/embed/${attrs.youtubeId}${query ? `?${query}` : ""}`;
return `https://www.youtube.com/embed/${attrs.videoId}${query ? `?${query}` : ""}`;
}
if (attrs.vimeoId) {
if (attrs.provider == 2) {
if (attrs.isMuted) params.set("muted", "1");
if (!attrs.hasControls) params.set("controls", "0");
const query = params.toString();
return `https://player.vimeo.com/video/${attrs.vimeoId}${query ? `?${query}` : ""}`;
return `https://player.vimeo.com/video/${attrs.videoId}${query ? `?${query}` : ""}`;
}
return "";
@ -73,10 +71,21 @@ export const buildVideoSrc = (attrs: {
export const videoTextSchema = ({
hasCaption,
transValidationCaption,
transValidationAlt,
}: {
hasCaption: boolean;
transValidationCaption: { min: string; max: string; string: string, boolean: string };
transValidationAlt: {
min: string;
max: string;
string: string;
};
}) => {
const altRules = z
.string({ message: transValidationAlt.string })
.min(3, transValidationAlt.min)
.max(255, transValidationAlt.max);
const captionRules = z
.string({ message: transValidationCaption.string })
.min(3, transValidationCaption.min)
@ -89,6 +98,7 @@ export const videoTextSchema = ({
isMuted: isBool,
hasControls: isBool,
canFullScreen: isBool,
alt: altRules
});
};

View file

@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import VideoButton, { VideoAttributes } from "./Video/VideoButton";
import { usePage } from "@inertiajs/react";
import InputError from "../InputError";
import { Label } from "@/components/ui/label";
import { cn, GetTranslation } from "@/lib/utils";
import BtnOptionnalLinkWithTooltip from "../BtnOptionnalLinkWithTooltip";
import { RefreshCcw, Trash } from "lucide-react";
import str from "@/hooks/use-string";
interface Props {
label: string;
name: string;
value: VideoAttributes | null;
// eslint-disable-next-line no-unused-vars
setData: (name: string, value: VideoAttributes | null) => void;
className?: string;
required?: boolean;
disabled?: boolean;
forcedFormat?: string;
forcedProvider?: number;
reset?: (name?: string) => void;
}
const VideoInput = (props: Props) => {
const { name, setData, value, className, label, required = true, disabled = false, forcedFormat, forcedProvider, reset } = props;
const { errors } = usePage().props;
const [refresh, setRefresh] = useState(Date.now());
useEffect(() => {
}, [refresh]);
return (
<div
className={cn(
"flex flex-col w-full justify-between items-start gap-1.5",
className,
)}
>
<Label htmlFor={name}>{label + (required ? " *" : "")}</Label>
<div className="flex w-full">
<div className="w-full">
<VideoButton
disabled={disabled}
isSmallButton={false}
value={value}
key={refresh}
forcedFormat={forcedFormat}
forcedProvider={forcedProvider}
hasError={errors[name] ? true : false}
onValidateVideo={(value) => {
if (value) {
setData(name, {
alt: value.alt,
canFullScreen: value.canFullScreen,
caption: value.caption,
hasControls: value.hasControls,
isMuted: value.isMuted,
provider: value.provider,
videoId: value.videoId,
format: value.format,
})
setRefresh(Date.now())
} else {
setData(name, null)
}
}} />
</div>
<div>
<BtnOptionnalLinkWithTooltip
content={<RefreshCcw />}
tooltipContent={
<p>
{str(GetTranslation('back.input_video.tooltip.reset')).ucFirst().value()}
</p>
}
onClick={() => {
reset?.(name)
setRefresh(Date.now())
}}
size="sm"
className={cn(!required ? "h-40 rounded-r-md" : "h-20 rounded-b-none", " rounded-l-none")}
variant="secondary"
/>
{
required && (
<BtnOptionnalLinkWithTooltip
content={<Trash />}
disabled={!value}
onClick={() => {
setData(name, null)
setRefresh(Date.now())
}}
tooltipContent={
<p>
{str(GetTranslation('back.input_video.tooltip.delete')).ucFirst().value()}
</p>
}
size="sm"
className="h-20 rounded-t-none rounded-l-none"
variant="destructive"
/>
)
}
</div>
</div>
<InputError message={errors[name]} />
</div>
)
}
export default VideoInput;

View file

@ -1,9 +1,9 @@
import { Node } from '@tiptap/core';
import { ReactNodeViewRenderer } from "@tiptap/react";
import { buildVideoSrc } from "./VideoUtils";
import { buildVideoSrc } from "../../../Video/VideoUtils";
import VideoNodeView, { FLOAT_STYLES, FloatOption } from "./VideoNodeView";
import { CommandProps } from '@tiptap/core';
import { VideoAttributes } from './video';
import { VideoAttributes } from '../../../Video/VideoButton';
export const Video = Node.create({
name: "video",
@ -12,8 +12,7 @@ export const Video = Node.create({
addAttributes() {
return {
youtubeId: { default: null },
vimeoId: { default: null },
videoId: { default: null },
width: { default: 1008 },
height: { default: 567 },
caption: { default: null },
@ -22,6 +21,10 @@ export const Video = Node.create({
isMuted: { default: false },
hasControls: { default: true },
canFullScreen: { default: true },
format: { default: "landscape" },
hasCaption: { default: false },
alt: { default: "" },
provider: { default : 1 }
};
},
@ -39,8 +42,7 @@ export const Video = Node.create({
const vimeoMatch = src.match(/vimeo\.com\/video\/(\d+)/);
return {
youtubeId: youtubeMatch?.[1] ?? null,
vimeoId: vimeoMatch?.[1] ?? null,
videoId: youtubeMatch?.[1] ?? vimeoMatch?.[1] ?? null,
width: parseInt(iframe.getAttribute("width") || "1008"),
height: parseInt(iframe.getAttribute("height") || "567"),
caption: figcaption?.textContent ?? null,
@ -56,8 +58,8 @@ export const Video = Node.create({
// Render the node to HTML (used for storage and front-end display).
renderHTML({ node }) {
const {
youtubeId,
vimeoId,
videoId,
provider,
width,
height,
caption,
@ -68,7 +70,7 @@ export const Video = Node.create({
canFullScreen,
} = node.attrs as VideoAttributes;
const src = buildVideoSrc({ youtubeId, vimeoId, isMuted, hasControls });
const src = buildVideoSrc({ videoId, isMuted, hasControls, provider });
const floatStyle = float ? FLOAT_STYLES[float as FloatOption] : null;
const alignStyle = !float && style ? style : null;

View file

@ -1,388 +0,0 @@
// * Packages
import { 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,
} from "@/back/Components/Inputs/Wysiwyg/Extensions/Video/VideoUtils";
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
import VideoInputs from "./VideoInputs";
import SimpleVimeoId from "../../../SimpleVimeoId";
import SimpleYoutubeId from "../../../SimpleYoutubeId";
import { Editor } from "@tiptap/core";
import { GetTranslation } from "@/lib/utils";
import z from "zod";
export type VideoProviderType = "youtube" | "vimeo";
export type InputVideoValues = {
caption: string | null;
hasCaption: boolean;
isMuted: boolean;
hasControls: boolean;
canFullScreen: boolean;
};
type VideoFormatOption = {
name: "landscape" | "portrait";
width: number;
height: number;
};
type VideoProps = {
editor: Editor;
disabled: boolean;
};
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 DEFAULT_OPTIONS: InputVideoValues = {
caption: null,
hasCaption: false,
isMuted: false,
hasControls: true,
canFullScreen: true,
};
const animationTime = 200;
export default function VideoButton({ editor, disabled }: VideoProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [videoProvider, setVideoProvider] = useState<VideoProviderType>("youtube");
const [videoUrl, setVideoUrl] = useState<string>("");
const [inputFormat, setInputFormat] = useState<VideoFormatOption>(VIDEO_FORMATS[0]);
const [options, setOptions] = useState<InputVideoValues>(DEFAULT_OPTIONS);
const resetComponent = () => {
setVideoUrl("");
setOptions(DEFAULT_OPTIONS);
setVideoProvider("youtube");
setInputFormat(VIDEO_FORMATS[0]);
setErrors({});
setLoading(false);
};
//* TRANSLATIONS
const transCaption = GetTranslation("back.wysiwyg.video.caption");
const transIsMuted = GetTranslation("back.wysiwyg.video.is_muted");
const transHasControls = GetTranslation("back.wysiwyg.video.has_controls");
const transCanFullScreen = GetTranslation("back.wysiwyg.video.can_fullScreen");
const youtubeError = str(GetTranslation("back.wysiwyg.video.invalid_youtube_id")).ucFirst().value();
const vimeoError = str(GetTranslation("back.wysiwyg.video.invalid_vimeo_id")).ucFirst().value();
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);
/**
* 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"
};
// 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
});
const isBoolsSchemaValidation = isBoolsSchema({
canFullScreen: transValidationCanFullScreen.boolean,
isMuted: transValidationIsMuted.boolean,
hasControls: transValidationHasControls.boolean
});
const videoData = {
caption: options.caption,
isMuted: options.isMuted,
hasControls: options.hasControls,
canFullScreen: options.canFullScreen,
url: videoUrl
};
let youtubeId: string | null = null;
let vimeoId: string | null = null;
try {
videoMetadataSchema.parse(videoData);
isBoolsSchemaValidation.parse(videoData);
if (videoProvider === "youtube") {
youtubeId = extractYoutubeId(videoUrl);
if (!youtubeId) {
setErrors({ url: youtubeError});
setLoading(false);
return;
}
} else {
vimeoId = extractVimeoId(videoUrl);
if (!vimeoId) {
setErrors({ url: vimeoError});
setLoading(false);
return;
}
}
editor.commands.setVideo({
youtubeId,
vimeoId,
width: inputFormat.width,
height: inputFormat.height,
caption: options.hasCaption ? options.caption : null,
isMuted: options.isMuted,
hasControls: options.hasControls,
canFullScreen: options.canFullScreen,
});
setOpen(false);
setTimeout(() => resetComponent(), animationTime);
} catch (err) {
console.log(err)
setNewErrors(err);
}
setLoading(false);
};
const handleCancel = (): void => {
setOpen(false);
setTimeout(() => resetComponent(), animationTime);
};
return (
<Dialog
open={open}
onOpenChange={(status) => {
setOpen(status);
if (!status) setTimeout(() => resetComponent(), animationTime);
}}
>
<DialogTrigger asChild>
<BtnOptionnalLinkWithTooltip
content={<VideoIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.video.tooltip")).ucFirst().value()}
disabled={disabled}
type="button"
variant="ghost"
size="icon"
/>
</DialogTrigger>
<DialogContent className="p-4">
<DialogHeader className="px-2 pt-2">
<DialogTitle>
{useTrans("back.wysiwyg.video.title")}
</DialogTitle>
<DialogDescription>
{useTrans("back.wysiwyg.video.subtitle")}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto max-h-[70vh] flex flex-col gap-4 px-2">
{/* Provider selection */}
<div className="flex flex-col gap-1.5">
<Label>
{str(useTrans("back.wysiwyg.video.provider")).ucFirst().value()}
</Label>
<div className="flex gap-4">
{(["youtube", "vimeo"] as VideoProviderType[]).map((provider) => (
<Button
key={provider}
type="button"
onClick={() => {
setVideoProvider(provider);
setVideoUrl("");
setErrors({});
}}
variant={videoProvider === provider ? "default" : "secondary"}
className="flex-1 flex flex-col items-center gap-2 h-fit py-3 [&_svg]:size-8"
>
{provider === "youtube" ? <YoutubeIcon /> : <VimeoIcon />}
<span className="text-sm font-medium capitalize">{provider}</span>
</Button>
))}
</div>
</div>
{/* URL / ID input */}
{videoProvider === "youtube" ? (
<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 */}
<div className="flex flex-col gap-1.5">
<Label>
{str(useTrans("back.wysiwyg.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(useTrans(`back.wysiwyg.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(useTrans("back.wysiwyg.video.cancel")).ucFirst().value()}
tooltipContent={useTrans("back.wysiwyg.video.tooltip_cancel")}
className="cursor-pointer"
variant="secondary"
type="button"
size="default"
disabled={loading}
onClick={handleCancel}
/>
<BtnOptionnalLinkWithTooltip
content={str(useTrans("back.wysiwyg.video.insert")).ucFirst().value()}
tooltipContent={useTrans("back.wysiwyg.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>
);
}

View file

@ -1,116 +0,0 @@
// * Other
import SimpleString from "@/back/Components/Inputs/SimpleString";
import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
import { InputVideoValues } from "./VideoButton";
type VideoInputsProps = {
value: InputVideoValues;
onChange: (value: InputVideoValues) => void;
errors?: Record<string, string>;
disabled?: boolean;
loading?: boolean;
};
export default function VideoInputs({
value,
onChange,
errors = {},
disabled = false,
loading = false
}: VideoInputsProps) {
const transVideoCaption = useTrans("back.wysiwyg.video.caption");
return (
<>
<div className="flex flex-col gap-4 p-6 border rounded-md">
{/* Input switch to set the caption visibility. */}
<SimpleSwitch
label={str(useTrans("back.wysiwyg.video.has_caption"))
.ucFirst()
.value()}
leftHelper={str(useTrans("back.no")).ucFirst().value()}
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
name="hasCaption"
checked={value.hasCaption}
className="justify-center"
setData={(_, v) =>
onChange({
...value,
hasCaption: v,
caption: v ? value.caption : null,
})
}
/>
{/* Input caption of the video. */}
{value.hasCaption && (
<SimpleString
name="caption"
label={str(transVideoCaption).ucFirst().value()}
placeholder="ex: John Doe"
required={false}
value={value.caption ?? ""}
setData={(_, v) => onChange({ ...value, caption: v })}
disabled={disabled || loading}
parentErrors={errors}
/>
)}
</div>
<SimpleSwitch
label={str(useTrans("back.wysiwyg.video.is_muted"))
.ucFirst()
.value()}
leftHelper={str(useTrans("back.no")).ucFirst().value()}
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
name="isMuted"
checked={value.isMuted}
className="justify-center"
setData={(_, v) =>
onChange({
...value,
isMuted: v,
})
}
parentErrors={errors}
/>
<SimpleSwitch
label={str(useTrans("back.wysiwyg.video.has_controls"))
.ucFirst()
.value()}
leftHelper={str(useTrans("back.no")).ucFirst().value()}
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
name="hasControls"
checked={value.hasControls}
className="justify-center"
setData={(_, v) =>
onChange({
...value,
hasControls: v,
})
}
parentErrors={errors}
/>
<SimpleSwitch
label={str(useTrans("back.wysiwyg.video.can_fullScreen"))
.ucFirst()
.value()}
leftHelper={str(useTrans("back.no")).ucFirst().value()}
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
name="canFullScreen"
checked={value.canFullScreen}
className="justify-center"
setData={(_, v) =>
onChange({
...value,
canFullScreen: v,
})
}
parentErrors={errors}
/>
</>
);
}

View file

@ -31,15 +31,14 @@ import {
import {
buildVideoSrc,
videoDetails,
} from "@/back/Components/Inputs/Wysiwyg/Extensions/Video/VideoUtils";
} from "@/back/Components/Inputs/Video/VideoUtils";
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
import { cn } from "@/lib/utils";
import { parseStyleString } from "@/hooks/use-inline-style";
import VideoInputs from "@/back/Components/Inputs/Wysiwyg/Extensions/Video/VideoInputs";
import { InputVideoValues } from "./VideoButton";
import { VideoAttributes } from "./video";
import VideoInputs from "@/back/Components/Inputs/Video/VideoInputs";
import { InputVideoValues, VideoAttributes } from "../../../Video/VideoButton";
export type AllowedAligns = "left" | "center" | "right";
@ -98,14 +97,18 @@ export default function VideoNodeView(props: NodeViewProps) {
isMuted: attrs.isMuted,
hasControls: attrs.hasControls,
canFullScreen: attrs.canFullScreen,
alt: attrs.alt,
format: attrs.format,
provider: attrs.provider,
videoId: attrs.videoId ?? ""
});
// Rebuild iframe src when attrs change (e.g. after edit dialog save)
const src = buildVideoSrc({
youtubeId: attrs.youtubeId,
vimeoId: attrs.vimeoId,
videoId: attrs.videoId,
isMuted: attrs.isMuted,
hasControls: attrs.hasControls,
provider: attrs.provider
});
const getCurrentPlacement = (): PlacementValue | null => {
@ -232,6 +235,7 @@ export default function VideoNodeView(props: NodeViewProps) {
// Disable pointer events when selected so clicks go to the editor
style={{ pointerEvents: selected ? "none" : "auto", display: "block" }}
/>
<div className="iframe-overlay"></div>
</div>
{selected && (
@ -297,10 +301,10 @@ export default function VideoNodeView(props: NodeViewProps) {
<DialogContent className="p-4">
<DialogHeader className="px-2 pt-2">
<DialogTitle>
{useTrans("back.wysiwyg.video.edit_title")}
{useTrans("back.input_video.edit_title")}
</DialogTitle>
<DialogDescription>
{useTrans("back.wysiwyg.video.edit_subtitle")}
{useTrans("back.input_video.edit_subtitle")}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto max-h-[70vh] flex flex-col gap-4 px-2">
@ -311,16 +315,16 @@ export default function VideoNodeView(props: NodeViewProps) {
</div>
<DialogFooter className="gap-1.5 px-2 pb-2">
<BtnOptionnalLinkWithTooltip
content={useTrans("back.wysiwyg.video.edit_cancel")}
tooltipContent={str(useTrans("back.wysiwyg.video.edit_tooltip_cancel")).ucFirst().value()}
content={useTrans("back.input_video.edit_cancel")}
tooltipContent={str(useTrans("back.input_video.tooltip.edit_cancel")).ucFirst().value()}
onClick={() => setOpen(false)}
type="button"
variant="secondary"
size="default"
/>
<BtnOptionnalLinkWithTooltip
content={useTrans("back.wysiwyg.video.edit_apply")}
tooltipContent={str(useTrans("back.wysiwyg.video.edit_tooltip_apply")).ucFirst().value()}
content={useTrans("back.input_video.edit_apply")}
tooltipContent={str(useTrans("back.input_video.tooltip.edit_apply")).ucFirst().value()}
onClick={saveDialog}
type="button"
variant="default"

View file

@ -1,5 +1,6 @@
// * Packages
import "@tiptap/core";
import { VideoAttributes } from "../../../Video/VideoButton";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@ -9,16 +10,3 @@ declare module "@tiptap/core" {
};
}
}
interface VideoAttributes {
youtubeId: string | null;
vimeoId: string | null;
width: number;
style?: string | null;
float?: string | null;
height: number;
caption: string | null;
isMuted: boolean;
hasControls: boolean;
canFullScreen: boolean;
}

View file

@ -71,8 +71,15 @@ export default function SimpleWysiwyg({
const stripHtmlTags = (html: string): string | null => {
const temp = document.createElement("div");
temp.innerHTML = html;
const text = temp.textContent || temp.innerText || "";
return text.trim() === "" ? null : text;
const text = temp.textContent?.trim() || "";
const hasValidImage = Array.from(temp.querySelectorAll("img"))
.some(img => img.getAttribute("src")?.trim());
const hasValidIframe = Array.from(temp.querySelectorAll("iframe"))
.some(iframe => iframe.getAttribute("src")?.trim());
if (!text && !hasValidImage && !hasValidIframe) {
return null;
}
return text || html;
};
/**
@ -119,7 +126,7 @@ export default function SimpleWysiwyg({
}),
Image,
Link,
Video
Video,
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],

View file

@ -47,7 +47,7 @@ import FindReplaceButton from "@/back/Components/Inputs/Wysiwyg/Extensions/Repla
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import str from "@/hooks/use-string";
import { cn, GetTranslation } from "@/lib/utils";
import VideoButton from "./Extensions/Video/VideoButton";
import VideoButton from "../Video/VideoButton";
type AllowedHeadings = 1 | 2 | 3 | 4 | 5 | 6;
@ -436,7 +436,18 @@ export default function Toolbar({ editor }: ToolbarProps) {
<TableButton editor={editor} disabled={docSelected} />
<ImageButton editor={editor} disabled={docSelected} />
<LinkButton editor={editor} disabled={docSelected} />
<VideoButton editor={editor} disabled={docSelected} />
<VideoButton
disabled={docSelected}
isSmallButton={true}
onValidateVideo={(value) => {
if(value) {
editor.commands.setVideo({
...value,
caption: value.hasCaption ? value.caption : null,
});
}
}}
/>
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<PilcrowIcon className="w-4 h-4 m-auto" />}

View file

@ -1,6 +1,7 @@
// * Other
import { ModelPolicy } from "@/back/types/global";
import { TranslationField } from "@/back/types/i18n";
import { VideoAttributes } from "../Components/Inputs/Video/VideoButton";
// Dynamic Page type, with policies.
type DynamicPageModel = {
@ -33,7 +34,7 @@ type DynamicFieldTypes = Record<
type DynamicInternalField = {
id?: number;
value: string | number | boolean | File | Date;
value: string | number | boolean | File | Date | VideoAttributes;
order: number;
};