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:
commit
ebc8536c1d
28 changed files with 1312 additions and 638 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
187
app/Rules/Video.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
]
|
||||
]
|
||||
];
|
||||
|
|
|
|||
|
|
@ -104,5 +104,9 @@ return [
|
|||
'product' => 'product information',
|
||||
'info' => 'other general information',
|
||||
'press' => 'a press request',
|
||||
]
|
||||
],
|
||||
"videoDisplayFormat" => [
|
||||
"landscape" => "landscape",
|
||||
"portrait" => "portrait"
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
]
|
||||
];
|
||||
|
|
|
|||
|
|
@ -105,4 +105,8 @@ return [
|
|||
'info' => 'une autre information générale',
|
||||
'press' => 'une demande presse',
|
||||
],
|
||||
"videoDisplayFormat" => [
|
||||
"landscape" => "paysage",
|
||||
"portrait" => "portrait"
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
609
resources/ts/back/Components/Inputs/Video/VideoButton.tsx
Normal file
609
resources/ts/back/Components/Inputs/Video/VideoButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
resources/ts/back/Components/Inputs/Video/VideoInputs.tsx
Normal file
147
resources/ts/back/Components/Inputs/Video/VideoInputs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
};
|
||||
|
||||
113
resources/ts/back/Components/Inputs/VideoInput.tsx
Normal file
113
resources/ts/back/Components/Inputs/VideoInput.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
|
|
|
|||
3
resources/ts/back/types/dynamic-pages.d.ts
vendored
3
resources/ts/back/types/dynamic-pages.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue