Compare commits
No commits in common. "addBanner" and "newFixesErrors" have entirely different histories.
addBanner
...
newFixesEr
15
.env.example
|
|
@ -56,14 +56,6 @@ MAIL_PASSWORD=null
|
|||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
MAIL_LIST_BREEDER=
|
||||
MAIL_LIST_COMMITMENTS=
|
||||
MAIL_LIST_EXPORT=
|
||||
MAIL_LIST_FINANCE=
|
||||
MAIL_LIST_INFO=
|
||||
MAIL_LIST_PRESS=
|
||||
MAIL_LIST_RESTAURATION=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# TELESCOPE
|
||||
|
|
@ -88,10 +80,3 @@ MEILISEARCH_KEY=
|
|||
UWIL_ADMIN_SECURE_CIDR_LIST=""
|
||||
UWIL_ADMIN_SECURE_CIDR_PROJECT_ALLOWED_LIST=""
|
||||
|
||||
# MATOMO
|
||||
MATOMO_SITE_ID=
|
||||
|
||||
# RECAPTCHA
|
||||
GOOGLE_RECAPTCHA_PUBLIC=
|
||||
GOOGLE_RECAPTCHA_PRIVATE=
|
||||
GOOGLE_RECAPTCHA_VERSION=
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
npx --no -- commitlint --config node_modules/@uwil/husky-config/commitlint.config.cjs --edit $1
|
||||
|
|
@ -29,17 +29,14 @@ COPY --from=vendor /app/vendor/ /app/vendor/
|
|||
WORKDIR /app
|
||||
RUN npm config set @ldc-web:registry https://lab.s-web.fr/api/v4/packages/npm/
|
||||
RUN npm config set -- '//lab.s-web.fr/api/v4/packages/npm/:_authToken' "${GITLAB_PASSWORD}"
|
||||
RUN npm config set @uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/
|
||||
RUN npm config set -- '//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken' "${GITLAB_PASSWORD}"
|
||||
RUN npm ci && npm run prod
|
||||
|
||||
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/uwil-production-8.5:${PHP_8_5_TAG}
|
||||
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/uwil-production-8.4:170625-130159
|
||||
|
||||
ARG USER=www-data
|
||||
|
||||
COPY --chown=${USER}:${USER} . /app/srcs
|
||||
COPY --chown=${USER}:${USER} ./resources/favicon/front/ /app/srcs/public/
|
||||
COPY --chown=${USER}:${USER} ./resources/favicon/back/ /app/srcs/public/back/
|
||||
COPY --chown=${USER}:${USER} ./resources/favicon /app/srcs/public
|
||||
COPY --from=vendor --chown=${USER}:${USER} /app/vendor/ /app/srcs/vendor/
|
||||
COPY --from=frontend --chown=${USER}:${USER} /app/public/build/ /app/srcs/public/build/
|
||||
|
||||
|
|
|
|||
|
|
@ -27,18 +27,215 @@ enum ContactFormType: int
|
|||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private const LABELS = [
|
||||
self::collaborator->name => 'enums.contactFormType.collaborator',
|
||||
self::breeder->name => 'enums.contactFormType.breeder',
|
||||
self::professional->name => 'enums.contactFormType.professional',
|
||||
self::restauration->name => 'enums.contactFormType.restauration',
|
||||
self::export->name => 'enums.contactFormType.export',
|
||||
self::finance->name => 'enums.contactFormType.finance',
|
||||
self::commitments->name => 'enums.contactFormType.commitments',
|
||||
self::product->name => 'enums.contactFormType.product',
|
||||
self::info->name => 'enums.contactFormType.info',
|
||||
self::press->name => 'enums.contactFormType.press',
|
||||
self::collaborator->name => 'enums.contactFormType.collaborator.label',
|
||||
self::breeder->name => 'enums.contactFormType.breeder.label',
|
||||
self::professional->name => 'enums.contactFormType.professional.label',
|
||||
self::restauration->name => 'enums.contactFormType.restauration.label',
|
||||
self::export->name => 'enums.contactFormType.export.label',
|
||||
self::finance->name => 'enums.contactFormType.finance.label',
|
||||
self::commitments->name => 'enums.contactFormType.commitments.label',
|
||||
self::product->name => 'enums.contactFormType.product.label',
|
||||
self::info->name => 'enums.contactFormType.info.label',
|
||||
self::press->name => 'enums.contactFormType.press.label',
|
||||
];
|
||||
|
||||
/**
|
||||
* Optionnal SUBTYPES definition.
|
||||
*/
|
||||
private const SUBTYPES = [
|
||||
self::professional->name => [
|
||||
self::restauration,
|
||||
self::export,
|
||||
],
|
||||
self::collaborator->name => [],
|
||||
self::breeder->name => [],
|
||||
self::restauration->name => [],
|
||||
self::export->name => [],
|
||||
self::finance->name => [],
|
||||
self::commitments->name => [],
|
||||
self::product->name => [],
|
||||
self::info->name => [],
|
||||
self::press->name => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* Get sub type status.
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function isSubtype(): bool
|
||||
{
|
||||
foreach (self::SUBTYPES as $subtypes) {
|
||||
if (in_array($this, $subtypes, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionnal TOPLEVELS definition.
|
||||
*/
|
||||
private const TOPLEVELS = [
|
||||
self::professional->name => true,
|
||||
self::collaborator->name => true,
|
||||
self::breeder->name => true,
|
||||
self::restauration->name => false,
|
||||
self::export->name => false,
|
||||
self::finance->name => true,
|
||||
self::commitments->name => true,
|
||||
self::product->name => true,
|
||||
self::info->name => true,
|
||||
self::press->name => true,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get top level status.
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function topLevel(): bool
|
||||
{
|
||||
return !empty(self::TOPLEVELS[$this->name]) ?
|
||||
self::TOPLEVELS[$this->name] : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data-target.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function dataTarget(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::professional => 'askPro',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subtitle.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function subtitle(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::restauration => __('enums.contactFormType.' . $this->name . '.subtitle'),
|
||||
default => __('enums.contactFormType.' . $this->name . '.label'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slug for each type.
|
||||
*
|
||||
* @param string|null $locale
|
||||
* @return string|null
|
||||
* @phpcs:disable Generic.Metrics.CyclomaticComplexity
|
||||
*/
|
||||
public function slug(?string $locale = null): ?string
|
||||
{
|
||||
// phpcs:enable
|
||||
return match ($this) {
|
||||
self::collaborator => Lang::get('enums.contactFormType.collaborator.slug', [], $locale),
|
||||
self::breeder => Lang::get('enums.contactFormType.breeder.slug', [], $locale),
|
||||
self::professional => null,
|
||||
self::restauration => Lang::get('enums.contactFormType.restauration.slug', [], $locale),
|
||||
self::export => Lang::get('enums.contactFormType.export.slug', [], $locale),
|
||||
self::finance => Lang::get('enums.contactFormType.finance.slug', [], $locale),
|
||||
self::commitments => Lang::get('enums.contactFormType.commitments.slug', [], $locale),
|
||||
self::product => Lang::get('enums.contactFormType.product.slug', [], $locale),
|
||||
self::info => Lang::get('enums.contactFormType.info.slug', [], $locale),
|
||||
self::press => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current lang slug match in specific lang slug.
|
||||
*
|
||||
* @param string $slug
|
||||
* @param string $locale
|
||||
* @return self
|
||||
*/
|
||||
public static function fromSlug(string $slug, string $locale): ?self
|
||||
{
|
||||
foreach (self::cases() as $case) {
|
||||
$caseSlug = $case->slug($locale);
|
||||
|
||||
if (
|
||||
$caseSlug !== null &&
|
||||
mb_strtolower($caseSlug) === mb_strtolower($slug)
|
||||
) {
|
||||
return $case;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function route(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::collaborator,
|
||||
self::breeder,
|
||||
self::export,
|
||||
self::finance,
|
||||
self::commitments,
|
||||
self::product,
|
||||
self::info,
|
||||
self::restauration => route('front.contact.show', ['slug' => $this->slug()]),
|
||||
self::professional => null,
|
||||
self::press => RouteHelper::routeNameChecker('front.dynamic-pages.espace-presse') . '#page-press-form',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route for the contact form type.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getLabelisedRoutes(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn($enum) => [
|
||||
$enum->name => (object) [
|
||||
"label" => $enum->label(),
|
||||
"route" => $enum->route(),
|
||||
],
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slug for each type.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function seo(): array
|
||||
{
|
||||
$commonRoutes = [
|
||||
self::collaborator,
|
||||
self::breeder,
|
||||
self::export,
|
||||
self::finance,
|
||||
self::commitments,
|
||||
self::professional,
|
||||
self::product,
|
||||
self::info,
|
||||
self::press,
|
||||
self::restauration
|
||||
];
|
||||
return match (true) {
|
||||
in_array($this, $commonRoutes, true) => [
|
||||
'title' => __('enums.contactFormType.' . $this->name . '.seo.title'),
|
||||
'description' => __('enums.contactFormType.' . $this->name . '.seo.description'),
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slug for each type.
|
||||
*
|
||||
|
|
@ -49,16 +246,34 @@ enum ContactFormType: int
|
|||
{
|
||||
// phpcs:enable
|
||||
return match ($this) {
|
||||
self::breeder => config('mail.to.breeder'),
|
||||
self::restauration => config('mail.to.restauration'),
|
||||
self::export => config('mail.to.export'),
|
||||
self::finance => config('mail.to.finance'),
|
||||
self::commitments => config('mail.to.commitments'),
|
||||
self::info => config('mail.to.info'),
|
||||
self::press => config('mail.to.press'),
|
||||
self::collaborator,
|
||||
self::product,
|
||||
self::professional => null
|
||||
self::collaborator => [],
|
||||
self::breeder => [
|
||||
'marie-charlotte.garnier@ldc.fr',
|
||||
'dylan.chevalier@ldc.fr',
|
||||
],
|
||||
self::professional => null,
|
||||
self::restauration => [
|
||||
'rhd@ldc.fr',
|
||||
],
|
||||
self::export => [
|
||||
'alexis.lambert@ldc.fr',
|
||||
'bruno.lafon@ldc.fr',
|
||||
],
|
||||
self::finance => [
|
||||
'david.eluaud@ldc.fr',
|
||||
'malaury.offe@ldc.fr',
|
||||
],
|
||||
self::commitments => [
|
||||
'claire-marie.gesland@ldc.fr',
|
||||
],
|
||||
self::product => [],
|
||||
self::info => [
|
||||
'blandine.durand-gratian@ldc.fr',
|
||||
'malaury.offe@ldc.fr',
|
||||
],
|
||||
self::press => [
|
||||
'communicationgroupe@ldc.fr',
|
||||
],
|
||||
// phpcs:ignore Squiz.Commenting.InlineComment
|
||||
}; //end match
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums\Contact;
|
||||
|
||||
use Uwil\LaravelCommon\Traits\Enums\BaseEnum;
|
||||
|
||||
enum ExportAskingType: int
|
||||
{
|
||||
use BaseEnum;
|
||||
|
||||
case customer = 1;
|
||||
case provider = 2;
|
||||
case other = 3;
|
||||
|
||||
/**
|
||||
* Optionnal labels definition.
|
||||
*
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private const LABELS = [
|
||||
self::customer->name => 'enums.exportAskingType.customer',
|
||||
self::provider->name => 'enums.exportAskingType.provider',
|
||||
self::other->name => 'enums.exportAskingType.other',
|
||||
];
|
||||
}
|
||||
|
|
@ -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,11 +25,9 @@ enum DynamicFieldTypes: int
|
|||
case color = 14;
|
||||
case model = 15;
|
||||
case staticroute = 16;
|
||||
case video = 17;
|
||||
|
||||
// Specific inputs.
|
||||
case position = 101;
|
||||
case contactform = 102;
|
||||
// Specific inputs.
|
||||
case position = 101;
|
||||
|
||||
/**
|
||||
* Labels definition.
|
||||
|
|
@ -53,11 +51,9 @@ 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',
|
||||
self::contactform->name => 'enums.dynamic-field-types.contactform',
|
||||
self::position->name => 'enums.dynamic-field-types.position',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -74,17 +70,15 @@ 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',
|
||||
self::youtubeid->name => 'required',
|
||||
self::vimeoid->name => 'required|string|min:8|max:10',
|
||||
self::youtubeid->name => 'required|string|size:11',
|
||||
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',
|
||||
self::contactform->name => 'required|numeric|between:1,10'
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -120,10 +114,8 @@ 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,
|
||||
self::contactform->name => false,
|
||||
self::position->name => false,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ enum StaticRoutesEnum: int
|
|||
use BaseEnum;
|
||||
|
||||
// Set all cases for each static routes.
|
||||
case collaborator = 1;
|
||||
case breeder = 2;
|
||||
case professional = 3;
|
||||
case restauration = 4;
|
||||
case export = 5;
|
||||
case finance = 6;
|
||||
case commitments = 7;
|
||||
case product = 8;
|
||||
case info = 9;
|
||||
case press = 10;
|
||||
case search = 11;
|
||||
|
||||
/**
|
||||
* Get array of each route with his matching value.
|
||||
|
|
@ -17,29 +28,29 @@ enum StaticRoutesEnum: int
|
|||
* @param callable|null $callback
|
||||
* @return array
|
||||
*/
|
||||
public static function getRoutes(?callable $callback = null)
|
||||
public static function getRoutes(?callable $callback = null): array
|
||||
{
|
||||
// $dataKeyValue = array_merge([], [
|
||||
// self::search->name => (object) [
|
||||
// 'label' => trans('front.search.title'),
|
||||
// 'route' => '/recherche?texte=LDC',
|
||||
// ]
|
||||
// ]);
|
||||
// return collect($dataKeyValue)
|
||||
// ->reject(fn($item) => is_null($item->label) || is_null($item->route))
|
||||
// ->map(function ($item, $key) {
|
||||
// $targetEnum = collect(self::cases())
|
||||
// ->first(fn($enum) => $enum->name === $key);
|
||||
$dataKeyValue = array_merge(ContactFormType::getLabelisedRoutes(), [
|
||||
self::search->name => (object) [
|
||||
'label' => trans('front.search.title'),
|
||||
'route' => '/recherche?texte=LDC',
|
||||
]
|
||||
]);
|
||||
return collect($dataKeyValue)
|
||||
->reject(fn($item) => is_null($item->label) || is_null($item->route))
|
||||
->map(function ($item, $key) {
|
||||
$targetEnum = collect(self::cases())
|
||||
->first(fn($enum) => $enum->name === $key);
|
||||
|
||||
// return (object) [
|
||||
// 'label' => ucfirst($item->label),
|
||||
// 'value' => $targetEnum->value,
|
||||
// 'route' => $item->route,
|
||||
// ];
|
||||
// })
|
||||
// ->when(!is_null($callback), fn ($collection) => $collection->filter($callback))
|
||||
// ->values()
|
||||
// ->toArray();
|
||||
return (object) [
|
||||
'label' => ucfirst($item->label),
|
||||
'value' => $targetEnum->value,
|
||||
'route' => $item->route,
|
||||
];
|
||||
})
|
||||
->when(!is_null($callback), fn ($collection) => $collection->filter($callback))
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Uwil\LaravelCommon\Traits\Enums\BaseEnum;
|
||||
|
||||
enum VideoProviderEnum: int
|
||||
{
|
||||
use BaseEnum;
|
||||
|
||||
case youtube = 1;
|
||||
case vimeo = 2;
|
||||
|
||||
/**
|
||||
* Optionnal labels definition.
|
||||
*
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private const LABELS = [
|
||||
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",
|
||||
];
|
||||
}
|
||||
|
|
@ -151,7 +151,7 @@ class DynamicPageHelper
|
|||
? ''
|
||||
: data_get($newContent, $key, '');
|
||||
if ($old) {
|
||||
FileHelper::removeOldModelFilesFromHtml($new ?? '', $old);
|
||||
FileHelper::removeOldModelFilesFromHtml($new, $old);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Enums\Contact\ContactFormType;
|
||||
use App\Models\DynamicPage;
|
||||
use Illuminate\Routing\Route;
|
||||
use Uwil\LaravelCommon\Controllers\Controller;
|
||||
|
|
@ -62,6 +63,7 @@ class RouteHelper extends Controller
|
|||
}
|
||||
return self::resolve(
|
||||
self::getDynamicPageLocalizedUrl($route, $localeCode),
|
||||
self::getContactShowLocalizedUrl($route, $localeCode),
|
||||
) ?? LaravelLocalization::getLocalizedURL($localeCode, null, [], false);
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +83,50 @@ class RouteHelper extends Controller
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the localized url for contact page show.
|
||||
*
|
||||
* @param \Illuminate\Routing\Route $route
|
||||
* @param string $localeCode
|
||||
* @return string|null
|
||||
*/
|
||||
private static function getContactShowLocalizedUrl(Route $route, string $localeCode): ?string
|
||||
{
|
||||
$contactPage = $route->parameter('contactPage');
|
||||
|
||||
if ($contactPage) {
|
||||
$localizedUrl = LaravelLocalization::getLocalizedURL(
|
||||
$localeCode,
|
||||
null,
|
||||
[],
|
||||
false
|
||||
);
|
||||
$parsed = parse_url($localizedUrl);
|
||||
if (!isset($parsed['path'])) {
|
||||
return $localizedUrl;
|
||||
}
|
||||
|
||||
$segments = explode('/', trim($parsed['path'], '/'));
|
||||
$currentSlug = end($segments);
|
||||
|
||||
$enum = ContactFormType::fromSlug(
|
||||
$currentSlug,
|
||||
app()->getLocale()
|
||||
);
|
||||
|
||||
if ($enum) {
|
||||
$translatedSlug = $enum->slug($localeCode);
|
||||
if ($translatedSlug) {
|
||||
$segments[count($segments) - 1] = $translatedSlug;
|
||||
return '/' . implode('/', $segments);
|
||||
}
|
||||
}
|
||||
return $localizedUrl;
|
||||
} //end if
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the localized url for all dynamic pages.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -64,43 +64,29 @@ class TranslationHelper
|
|||
}
|
||||
|
||||
/**
|
||||
* Assert that a value is unique inside a JSON translation field.
|
||||
*
|
||||
* The uniqueness can optionally be scoped to another attribute/value pair.
|
||||
* If $anotherDbAttribute and $anotherValue are provided, the uniqueness
|
||||
* will be checked only within that scope. Otherwise, the value must be
|
||||
* globally unique.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Model $model The model instance being validated.
|
||||
* @param string $value The translated value to check.
|
||||
* @param string $dbAttribute The JSON column containing translations.
|
||||
* @param string|null $anotherValue Optional value used to scope uniqueness.
|
||||
* @param string|null $anotherDbAttribute Optional database column used to scope uniqueness.
|
||||
* @param string|null $errorAttribute Optional attribute name used in the validation error.
|
||||
* @param string|null $errorTranslation Optional translated attribute label for the error message.
|
||||
* Assert value is unique in a translation field.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Model $model
|
||||
* @param string $value
|
||||
* @param string $dbAttribute
|
||||
* @param string|null $errorAttribute
|
||||
* @param string|null $errorTranslation
|
||||
* @return void
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException If the value is already used.
|
||||
* @throws \Illuminate\Validation\ValidationException Value is already used.
|
||||
*/
|
||||
public static function assertTranslationFieldIsUnique(
|
||||
Model $model,
|
||||
string $value,
|
||||
string $dbAttribute,
|
||||
?string $anotherValue = null,
|
||||
?string $anotherDbAttribute = null,
|
||||
?string $errorAttribute = null,
|
||||
?string $errorTranslation = null,
|
||||
): void {
|
||||
$modelClass = get_class($model);
|
||||
$query = $modelClass::query()
|
||||
$exists = $modelClass::query()
|
||||
->whereKeyNot($model->getKey())
|
||||
->whereRaw("JSON_SEARCH(`{$dbAttribute}`, 'one', ?) IS NOT NULL", [$value]);
|
||||
|
||||
if ($anotherDbAttribute !== null) {
|
||||
$query->where($anotherDbAttribute, $anotherValue);
|
||||
}
|
||||
if ($query->exists()) {
|
||||
->whereRaw("JSON_SEARCH($dbAttribute, 'one', ?) IS NOT NULL", [$value])
|
||||
->exists();
|
||||
if ($exists) {
|
||||
throw ValidationException::withMessages([
|
||||
$errorAttribute ?? $dbAttribute => trans("validation.unique", [
|
||||
'attribute' => $errorTranslation ?? trans("validation.attributes.$dbAttribute"),
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Company;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CompanyController
|
||||
{
|
||||
/**
|
||||
* Retrieve all published companies with their associated activity and sector.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getCompanies(): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$companies = Company::query()->where('published', true)->with('companyActivity', 'companySector')->get();
|
||||
|
||||
$data = $companies->map(function ($company) {
|
||||
return array_filter([
|
||||
'name' => $company->name,
|
||||
'logo' => $company->logo,
|
||||
'informations' => $company->informations,
|
||||
'responsable' => $company->responsable,
|
||||
'employees' => $company->employees,
|
||||
'url' => $company->url,
|
||||
'latitude' => $company->latitude,
|
||||
'longitude' => $company->longitude,
|
||||
'type_activite' => Str::lower($company->companyActivity->name),
|
||||
'filiere' => $company->companySector?->name,
|
||||
], fn($value) => !is_null($value));
|
||||
});
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Back;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Uwil\LaravelBackend\Controllers\AuthenticatedSessionController as PackageAuthenticatedSessionController;
|
||||
|
||||
class AuthenticatedSessionController extends PackageAuthenticatedSessionController
|
||||
{
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('backend')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return to_route('front.dynamic-pages.accueil.fr');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Controllers\Back;
|
||||
|
||||
use App\Enums\Contact\ContactFormType;
|
||||
use App\Enums\DynamicPages\PageStatusEnum;
|
||||
use App\Enums\ElementPositionEnum;
|
||||
use App\Http\Requests\Back\DynamicPages\StoreDynamicPageRequest;
|
||||
|
|
@ -93,7 +92,6 @@ class DynamicPageController extends Controller
|
|||
'pageConfigModels' => $pageConfigModels,
|
||||
'enumDynamicPageStatus' => PageStatusEnum::toArray(),
|
||||
'enumElementPosition' => ElementPositionEnum::toArray(),
|
||||
'enumContactFormType' => ContactFormType::toArray(),
|
||||
'modelName' => $this->modelName,
|
||||
]);
|
||||
}
|
||||
|
|
@ -152,9 +150,7 @@ class DynamicPageController extends Controller
|
|||
'pageConfigModels' => $pageConfigModels,
|
||||
'enumDynamicPageStatus' => PageStatusEnum::toArray(),
|
||||
'enumElementPosition' => ElementPositionEnum::toArray(),
|
||||
'enumContactFormType' => ContactFormType::toArray(),
|
||||
'modelName' => $this->modelName,
|
||||
'dynamicPageUrl' => DynamicPage::getAccessModel($dynamic_page->id),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,33 @@ use LDC\LDCCRM\Models\SingleOptin;
|
|||
|
||||
class ContactController
|
||||
{
|
||||
/**
|
||||
* Show contact index page.
|
||||
*
|
||||
* @return \Illuminate\Contracts\View\View
|
||||
*/
|
||||
public function index(): \Illuminate\Contracts\View\View
|
||||
{
|
||||
$types = ContactFormType::toArray();
|
||||
return view('front.pages.contact.index', compact('types'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show contact show page.
|
||||
*
|
||||
* @param string $slug
|
||||
* @return\Illuminate\Http\RedirectResponse|\Illuminate\Contracts\View\View
|
||||
*/
|
||||
public function show(string $slug): \Illuminate\Http\RedirectResponse|\Illuminate\Contracts\View\View
|
||||
{
|
||||
$enumType = collect(ContactFormType::all())->first(function ($enum) use ($slug) {
|
||||
return $enum->slug() === $slug;
|
||||
});
|
||||
if (!$enumType) {
|
||||
return redirect()->route('front.contact.index');
|
||||
}
|
||||
return view('front.pages.contact.show', compact('enumType'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact send.
|
||||
|
|
@ -35,7 +62,7 @@ class ContactController
|
|||
$hasNewsletterError = false;
|
||||
|
||||
/** @var boolean $isNewsletter */
|
||||
$isNewsletter = isset($validatedData['newsletter']) ? $validatedData['newsletter'] : false;
|
||||
$isNewsletter = $validatedData['newsletter'];
|
||||
|
||||
try {
|
||||
dispatch(new SendContactMail($messageContact))
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
|
||||
namespace App\Http\Controllers\Front;
|
||||
|
||||
use App\Enums\OrdersEnum;
|
||||
use App\Enums\YearsEnum;
|
||||
use App\Http\Requests\Front\PostsRequest;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentTag;
|
||||
use Carbon\Carbon;
|
||||
use App\Models\News;
|
||||
use App\Models\NewsTag;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Enums\YearsEnum;
|
||||
use App\Models\Document;
|
||||
use App\Enums\OrdersEnum;
|
||||
use App\Models\DocumentTag;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Database\Eloquent\Collection as DatabaseCollection;
|
||||
use App\Http\Requests\Front\PostsRequest;
|
||||
use Uwil\LaravelCommon\Controllers\Controller;
|
||||
use Uwil\LaravelCommon\Helpers\CollectionHelper;
|
||||
|
||||
|
|
@ -27,14 +27,12 @@ class PostsController extends Controller
|
|||
/**
|
||||
* Extract unique years from date field in a collection.
|
||||
*
|
||||
* @param EloquentCollection $model
|
||||
* @param string $field
|
||||
* @param \Illuminate\Support\Collection $model
|
||||
* @param string $field
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
private function getUniqueYearsFromCollection(
|
||||
EloquentCollection $model,
|
||||
string $field,
|
||||
): \Illuminate\Support\Collection {
|
||||
private function getUniqueYearsFromCollection(Collection $model, string $field): \Illuminate\Support\Collection
|
||||
{
|
||||
return $model->pluck($field)
|
||||
->map(fn($date) => Carbon::parse($date)->year)
|
||||
->unique()
|
||||
|
|
@ -61,27 +59,30 @@ class PostsController extends Controller
|
|||
* Build the common filters configuration for front-end listing pages.
|
||||
* If the type is "documents" the "order" filter is included.
|
||||
*
|
||||
* @param string $type
|
||||
* @param string $category
|
||||
* @param EloquentCollection $publishedTags
|
||||
* @param EloquentCollection $model
|
||||
* @template TTag of \App\Models\DocumentTag|\App\Models\NewsTag
|
||||
* @template TModel of \App\Models\Document|\App\Models\News
|
||||
*
|
||||
* @param string $type
|
||||
* @param string $category
|
||||
* @param \Illuminate\Database\Eloquent\Collection<int, TTag> $publishedTags
|
||||
* @param \Illuminate\Database\Eloquent\Collection<int, TModel> $model
|
||||
* @return array
|
||||
*/
|
||||
private function getFilters(
|
||||
string $type,
|
||||
string $category,
|
||||
EloquentCollection $publishedTags,
|
||||
EloquentCollection $model,
|
||||
): array {
|
||||
string $type, string $category,
|
||||
\Illuminate\Database\Eloquent\Collection $publishedTags,
|
||||
\Illuminate\Database\Eloquent\Collection $model
|
||||
): array
|
||||
{
|
||||
$filters = [];
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$tags = $publishedTags->map(fn(DocumentTag|NewsTag $tag) => [
|
||||
'title' => $tag->name,
|
||||
'tag' => $tag->slug,
|
||||
'category' => $category,
|
||||
])
|
||||
->values();
|
||||
$tags = collect($publishedTags)
|
||||
->map(fn($tag) => [
|
||||
'title' => $tag->name,
|
||||
'tag' => $tag->slug,
|
||||
'category' => $category,
|
||||
])
|
||||
->values();
|
||||
|
||||
$enumYears = collect(YearsEnum::toArray())
|
||||
->map(fn($year) => YearsEnum::make((int) $year->value))
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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;
|
||||
|
|
@ -140,7 +139,6 @@ class HandleInertiaRequests extends Middleware
|
|||
'enumDynamicFieldTypes' => DynamicFieldTypes::toArray(),
|
||||
'enumModelsList' => ModelsList::toArray(),
|
||||
'enumStaticRoutes' => StaticRoutesEnum::getRoutes(),
|
||||
'videoProviderEnum' => VideoProviderEnum::toArray(),
|
||||
'errors' => $errors,
|
||||
'translations' => $translations,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,10 @@
|
|||
|
||||
namespace App\Http\Requests\Back;
|
||||
|
||||
use App\Enums\Contact\ContactFormType;
|
||||
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;
|
||||
|
|
@ -203,19 +201,6 @@ abstract class UpdateDynamicFieldRequest extends FormRequest
|
|||
),
|
||||
]);
|
||||
break;
|
||||
case DynamicFieldTypes::contactform->value:
|
||||
$rules = array_merge($rules, [
|
||||
$ruleKey => array_merge(
|
||||
$this->compileValidate(DynamicFieldTypes::position->getValidation(), $input),
|
||||
[new Enum(ContactFormType::class)],
|
||||
),
|
||||
]);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ namespace App\Http\Requests\Front\Contact;
|
|||
use App\Enums\Contact\BreedType;
|
||||
use App\Enums\Contact\ContactFormType;
|
||||
use App\Enums\Contact\CustomerAskingType;
|
||||
use App\Enums\Contact\ExportAskingType;
|
||||
use App\Enums\Contact\PressType;
|
||||
use App\Enums\Department;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
|
@ -94,19 +93,12 @@ class ContactRequest extends FormRequest
|
|||
|
||||
// Validation for restauration and export commun fields.
|
||||
if (
|
||||
ContactFormType::restauration->equals(ContactFormType::make($typeContactForm))
|
||||
) {
|
||||
$rules = array_merge($rules, [
|
||||
'type' => ['array', 'min:1'],
|
||||
'type.*' => [Rule::enum(CustomerAskingType::class)],
|
||||
]);
|
||||
}
|
||||
if (
|
||||
ContactFormType::restauration->equals(ContactFormType::make($typeContactForm)) ||
|
||||
ContactFormType::export->equals(ContactFormType::make($typeContactForm))
|
||||
) {
|
||||
$rules = array_merge($rules, [
|
||||
'type' => ['array', 'min:1'],
|
||||
'type.*' => [Rule::enum(ExportAskingType::class)],
|
||||
'type.*' => [Rule::enum(CustomerAskingType::class)],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@
|
|||
|
||||
namespace App\Http\Requests\Front;
|
||||
|
||||
use App\Traits\Requests\Api\HasLaravelFailedValidation;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PostsRequest extends FormRequest
|
||||
{
|
||||
use HasLaravelFailedValidation;
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
|
|
@ -19,7 +16,7 @@ class PostsRequest extends FormRequest
|
|||
return [
|
||||
'page' => 'required|integer',
|
||||
'category' => 'required|string',
|
||||
'locale' => ['required', 'string', Rule::in(config('app.locales'))],
|
||||
'locale' => 'required|string',
|
||||
'filters' => 'nullable|array',
|
||||
'filters.order' => 'nullable|string|in:asc,desc',
|
||||
'filters.tags' => 'nullable|array',
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ use App\Enums\Document\DocumentStatusEnum;
|
|||
use App\Enums\YearsEnum;
|
||||
use App\Helpers\DocumentHelper;
|
||||
use App\Helpers\TranslationHelper;
|
||||
use App\Models\Documentable;
|
||||
use App\Traits\Models\HasCache;
|
||||
use App\Traits\Models\HasTranslations;
|
||||
use App\Traits\Models\HasUwilImages;
|
||||
|
|
@ -20,30 +19,18 @@ use Laravel\Scout\Searchable;
|
|||
use Uwil\LaravelActivityLogs\Traits\ActivityLog;
|
||||
|
||||
/**
|
||||
* @property integer $id
|
||||
* Id of the document.
|
||||
* @property string $name
|
||||
* Name of the document.
|
||||
* @property string $path
|
||||
* Path of the document.
|
||||
* @property boolean $is_issuu
|
||||
* Issu status of the document.
|
||||
* @property string $issuu_path
|
||||
* Issu path of the document.
|
||||
* @property string $exercice
|
||||
* Exercice of the document.
|
||||
* @property \App\Enums\Document\DocumentStatusEnum $status
|
||||
* Status of the document.
|
||||
* @property-read \Carbon\Carbon $created_at
|
||||
* Created date of the document.
|
||||
* @property-read \Carbon\Carbon $updated_at
|
||||
* Updated date of the document.
|
||||
* @property-read \Illuminate\Support\Carbon $deleted_at
|
||||
* Deleted date of the document.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\DocumentCategory> $categories
|
||||
* Associated document categories of the document.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\DocumentTag> $tags
|
||||
* Associated document tags of the document.
|
||||
* @property integer $id Id of the document.
|
||||
* @property string $name Name of the document.
|
||||
* @property string $path Path of the document.
|
||||
* @property boolean $is_issuu Issu status of the document.
|
||||
* @property string $issuu_path Issu path of the document.
|
||||
* @property string $exercice Exercice of the document.
|
||||
* @property \App\Enums\Document\DocumentStatusEnum $status Status of the document.
|
||||
* @property-read \Carbon\Carbon $created_at Created date of the document.
|
||||
* @property-read \Carbon\Carbon $updated_at Updated date of the document.
|
||||
* @property-read \Illuminate\Support\Carbon $deleted_at Deleted date of the document.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\DocumentCategory> $categories Associated document categories of the document.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\DocumentTag> $tags Associated document tags of the document.
|
||||
*/
|
||||
class Document extends Model implements TranslatableModel
|
||||
{
|
||||
|
|
@ -388,7 +375,7 @@ class Document extends Model implements TranslatableModel
|
|||
'documentables',
|
||||
'document_id',
|
||||
'documentable_id'
|
||||
)->using(Documentable::class)->withTimestamps();
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -404,6 +391,6 @@ class Document extends Model implements TranslatableModel
|
|||
'documentables',
|
||||
'document_id',
|
||||
'documentable_id'
|
||||
)->using(Documentable::class)->withTimestamps();
|
||||
)->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,20 +11,13 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
use Uwil\LaravelActivityLogs\Traits\ActivityLog;
|
||||
|
||||
/**
|
||||
* @property integer $id
|
||||
* Id of the category.
|
||||
* @property string $name
|
||||
* Name of the category.
|
||||
* @property string $slug
|
||||
* Slug of the category.
|
||||
* @property-read \Carbon\Carbon $created_at
|
||||
* Created date of the category.
|
||||
* @property-read \Carbon\Carbon $updated_at
|
||||
* Updated date of the category.
|
||||
* @property-read \Illuminate\Support\Carbon $deleted_at
|
||||
* Deleted date of the category.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\Document> $documents
|
||||
* Associated documents of the category.
|
||||
* @property integer $id Id of the category.
|
||||
* @property string $name Name of the category.
|
||||
* @property string $slug Slug of the category.
|
||||
* @property-read \Carbon\Carbon $created_at Created date of the category.
|
||||
* @property-read \Carbon\Carbon $updated_at Updated date of the category.
|
||||
* @property-read \Illuminate\Support\Carbon $deleted_at Deleted date of the category.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\Document> $documents Associated documents of the category.
|
||||
*/
|
||||
class DocumentCategory extends Model implements TranslatableModel
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,22 +12,14 @@ use Illuminate\Support\Facades\App;
|
|||
use Uwil\LaravelActivityLogs\Traits\ActivityLog;
|
||||
|
||||
/**
|
||||
* @property integer $id
|
||||
* Id of the tag.
|
||||
* @property string $name
|
||||
* Name of the tag.
|
||||
* @property string $slug
|
||||
* Slug of the tag.
|
||||
* @property string $color
|
||||
* Color of the tag.
|
||||
* @property-read \Carbon\Carbon $created_at
|
||||
* Created date of the tag.
|
||||
* @property-read \Carbon\Carbon $updated_at
|
||||
* Updated date of the tag.
|
||||
* @property-read \Illuminate\Support\Carbon $deleted_at
|
||||
* Deleted date of the tag.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\Document> $documents
|
||||
* Associated documents of the tag.
|
||||
* @property integer $id Id of the tag.
|
||||
* @property string $name Name of the tag.
|
||||
* @property string $slug Slug of the tag.
|
||||
* @property string $color Color of the tag.
|
||||
* @property-read \Carbon\Carbon $created_at Created date of the tag.
|
||||
* @property-read \Carbon\Carbon $updated_at Updated date of the tag.
|
||||
* @property-read \Illuminate\Support\Carbon $deleted_at Deleted date of the tag.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\Document> $documents Associated documents of the tag.
|
||||
*/
|
||||
class DocumentTag extends Model implements TranslatableModel
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphPivot;
|
||||
|
||||
/**
|
||||
* @property integer $id Id of the document.
|
||||
* @property \App\Models\Document $document_id Name of the document.
|
||||
* @property \App\Models\DocumentCategory|\App\Models\DocumentTag $documentable_id Name of the category or tag.
|
||||
* @property class-string<static> $documentable_type Class of the category or tag.
|
||||
* @property-read \Carbon\Carbon $created_at Created date of the document.
|
||||
* @property-read \Carbon\Carbon $updated_at Updated date of the document.
|
||||
*/
|
||||
class Documentable extends MorphPivot
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $table = 'documentables';
|
||||
|
||||
/**
|
||||
* Perform any actions required after the model boots.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(fn() => Document::resetCache());
|
||||
static::updated(fn() => Document::resetCache());
|
||||
static::deleted(fn() => Document::resetCache());
|
||||
}
|
||||
}
|
||||
|
|
@ -13,15 +13,11 @@ use App\Traits\Models\HasUwilImages;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
use Uwil\LaravelActivityLogs\Traits\ActivityLog;
|
||||
use Spatie\Sitemap\Contracts\Sitemapable;
|
||||
use Spatie\Sitemap\Tags\Url;
|
||||
use Uwil\SitemapGenerator\Lib\SitemapHelper;
|
||||
|
||||
/**
|
||||
* @property string $id_key Unique key identification of the dynamic page.
|
||||
|
|
@ -37,7 +33,7 @@ use Uwil\SitemapGenerator\Lib\SitemapHelper;
|
|||
* @property-read \Illuminate\Support\Carbon $updated_at Date on which the dynamic page was updated.
|
||||
* @property-read \Illuminate\Support\Carbon $deleted_at Date on which the dynamic page was updated.
|
||||
*/
|
||||
class DynamicPage extends Model implements TranslatableModel, Sitemapable
|
||||
class DynamicPage extends Model implements TranslatableModel
|
||||
{
|
||||
use ActivityLog;
|
||||
use HasCache;
|
||||
|
|
@ -108,14 +104,7 @@ class DynamicPage extends Model implements TranslatableModel, Sitemapable
|
|||
static::creating(function (self $model) {
|
||||
self::manageFields($model);
|
||||
// Set id_key from title.
|
||||
$idKey = self::generateUniqueField(
|
||||
'dynamic_pages',
|
||||
'id_key',
|
||||
Str::slug($model->title),
|
||||
$model->id
|
||||
);
|
||||
|
||||
$model->id_key = $idKey;
|
||||
$model->id_key = Str::slug($model->title);
|
||||
});
|
||||
static::updating(function (self $model) {
|
||||
self::manageFields($model);
|
||||
|
|
@ -142,42 +131,6 @@ class DynamicPage extends Model implements TranslatableModel, Sitemapable
|
|||
}
|
||||
|
||||
// * METHODS
|
||||
/**
|
||||
* Generate unique value from model field
|
||||
*
|
||||
* @param string $table
|
||||
* @param string $field
|
||||
* @param string $baseValue
|
||||
* @param mixed $ignoreId
|
||||
* @return string
|
||||
*/
|
||||
public static function generateUniqueField(
|
||||
string $table,
|
||||
string $field,
|
||||
string $baseValue,
|
||||
int|string|null $ignoreId = null
|
||||
): string {
|
||||
$value = $baseValue;
|
||||
$counter = 1;
|
||||
|
||||
// Boucle tant que le slug existe en base
|
||||
while (true) {
|
||||
$query = DB::table($table)->where($field, $value);
|
||||
|
||||
if ($ignoreId) {
|
||||
$query->where('id', '!=', $ignoreId);
|
||||
}
|
||||
|
||||
if (!$query->exists()) {
|
||||
// On a trouvé un slug unique
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Sinon on incrémente le suffixe
|
||||
$value = $baseValue . '_' . $counter;
|
||||
$counter++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublished all children pages if change status published from draft/unpublished.
|
||||
|
|
@ -245,8 +198,8 @@ class DynamicPage extends Model implements TranslatableModel, Sitemapable
|
|||
self::assertRootIsUnique($model);
|
||||
} else {
|
||||
$slug = $model->slug ?? Str::slug($model->title);
|
||||
TranslationHelper::assertTranslationFieldIsUnique($model, $model->title, 'title', $model->page_id, 'page_id');
|
||||
TranslationHelper::assertTranslationFieldIsUnique($model, $slug, 'slug', $model->page_id, 'page_id');
|
||||
TranslationHelper::assertTranslationFieldIsUnique($model, $model->title, 'title');
|
||||
TranslationHelper::assertTranslationFieldIsUnique($model, $slug, 'slug');
|
||||
$model->setAttribute('slug', $slug);
|
||||
}
|
||||
// Check translations.
|
||||
|
|
@ -460,7 +413,7 @@ class DynamicPage extends Model implements TranslatableModel, Sitemapable
|
|||
? strip_tags(collect($this->getTranslation('content', $locale))
|
||||
->flatten()
|
||||
->filter(fn($value, $key) =>
|
||||
is_string($value) &&
|
||||
is_string($value) &&
|
||||
!empty($value) &&
|
||||
!is_numeric($value) &&
|
||||
!in_array($key, ['order', 'id']) &&
|
||||
|
|
@ -475,47 +428,6 @@ class DynamicPage extends Model implements TranslatableModel, Sitemapable
|
|||
return $data;
|
||||
}
|
||||
|
||||
// * SITEMAP
|
||||
|
||||
/**
|
||||
* toSitemapTag method
|
||||
*
|
||||
* @return Url|string|array
|
||||
*/
|
||||
public function toSitemapTag(): Url | string | array
|
||||
{
|
||||
$sitemapLines = [];
|
||||
$locales = array_keys(config('laravellocalization.supportedLocales', ['fr' => "Français"]));
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
$slug = $this->getTranslation('slug', $locale, false);
|
||||
|
||||
// Homepage has no slug.
|
||||
if ($slug || $this->isRoot) {
|
||||
$sitemapLines[] = SitemapHelper::generateUrl(
|
||||
$this->getLocalizedUrl($locale),
|
||||
locales: $this->locales(),
|
||||
getAlternate: fn (string $locale) => $this->getLocalizedUrl($locale)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $sitemapLines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localized URL.
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string|false
|
||||
*/
|
||||
public function getLocalizedUrl(string $locale): string|false
|
||||
{
|
||||
return in_array($locale, $this->locales()) ?
|
||||
route("front.dynamic-pages.$this->id_key.$locale") :
|
||||
false;
|
||||
}
|
||||
|
||||
// * RELATIONSHIPS
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,56 +17,36 @@ use Illuminate\Support\Arr;
|
|||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
use Mcamara\LaravelLocalization\Interfaces\LocalizedUrlRoutable;
|
||||
use Uwil\LaravelActivityLogs\Traits\ActivityLog;
|
||||
use Spatie\Sitemap\Tags\Url;
|
||||
use Uwil\SitemapGenerator\Interfaces\Sitemapable;
|
||||
use Uwil\SitemapGenerator\Lib\SitemapHelper;
|
||||
|
||||
/**
|
||||
* @property string $seo_title
|
||||
* SEO title of the news.
|
||||
* @property string $seo_description
|
||||
* SEO description of the news.
|
||||
* @property string $title
|
||||
* Title of the news.
|
||||
* @property string $slug
|
||||
* Slug of the news.
|
||||
* @property string $picture
|
||||
* Main img of the news.
|
||||
* @property string $picture_alt
|
||||
* Alt text for the picture.
|
||||
* @property string $picture_title
|
||||
* Title for the picture.
|
||||
* @property string $content
|
||||
* Content of the news.
|
||||
* @property boolean $inverted
|
||||
* Are image and content inverted in front
|
||||
* @property string $vimeo_id
|
||||
* Optionnal vimeo_id
|
||||
* @property boolean $has_show_picture
|
||||
* Show the picture in the show page
|
||||
* @property \App\Enums\News\NewsStatusEnum $status
|
||||
* Status of the news.
|
||||
* @property-read \Illuminate\Support\Carbon $created_at
|
||||
* Date on which the news was created.
|
||||
* @property-read \Illuminate\Support\Carbon $updated_at
|
||||
* Date on which the news was updated.
|
||||
* @property-read \Illuminate\Support\Carbon $deleted_at
|
||||
* Date on which the news was updated.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\NewsCategory> $categories
|
||||
* Associated news categories of the news.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\NewsTag> $tags
|
||||
* Associated news tags of the news.
|
||||
* @property string $seo_title SEO title of the news.
|
||||
* @property string $seo_description SEO description of the news.
|
||||
* @property string $title Title of the news.
|
||||
* @property string $slug Slug of the news.
|
||||
* @property string $picture Main img of the news.
|
||||
* @property string $picture_alt Alt text for the picture.
|
||||
* @property string $picture_title Title for the picture.
|
||||
* @property string $content Content of the news.
|
||||
* @property boolean $inverted Are image and content inverted in front
|
||||
* @property string $vimeo_id Optionnal vimeo_id
|
||||
* @property boolean $has_show_picture Show the picture in the show page
|
||||
* @property \App\Enums\News\NewsStatusEnum $status Status of the news.
|
||||
* @property-read \Illuminate\Support\Carbon $created_at Date on which the news was created.
|
||||
* @property-read \Illuminate\Support\Carbon $updated_at Date on which the news was updated.
|
||||
* @property-read \Illuminate\Support\Carbon $deleted_at Date on which the news was updated.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\NewsCategory> $categories Associated news categories of the news.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\NewsTag> $tags Associated news tags of the news.
|
||||
*/
|
||||
class News extends Model implements TranslatableModel, LocalizedUrlRoutable, Sitemapable
|
||||
class News extends Model implements TranslatableModel, LocalizedUrlRoutable
|
||||
{
|
||||
use ActivityLog;
|
||||
use HasCache;
|
||||
use HasFactory;
|
||||
use HasTranslations;
|
||||
use SoftDeletes;
|
||||
use HasCache;
|
||||
use HasUwilImages {
|
||||
HasUwilImages::inertiaFill as traitInertiaFill;
|
||||
}
|
||||
|
|
@ -135,7 +115,7 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable, Sit
|
|||
'unpublished_at',
|
||||
'inverted',
|
||||
'vimeo_id',
|
||||
'has_show_picture',
|
||||
'has_show_picture'
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -469,49 +449,6 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable, Sit
|
|||
});
|
||||
}
|
||||
|
||||
// * SITEMAP
|
||||
|
||||
/**
|
||||
* toSitemapTag method
|
||||
*
|
||||
* @return Url|string|array
|
||||
*/
|
||||
public function toSitemapTag(): Url | string | array
|
||||
{
|
||||
$model = $this;
|
||||
$locales = $model->locales();
|
||||
$sitemapUrls = [];
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
if (in_array($locale, config('sitemap-generator.supported_locales'))) {
|
||||
$sitemapUrls[] = SitemapHelper::generateUrl(
|
||||
$model->getLocalizedUrl($locale),
|
||||
$model,
|
||||
$locales,
|
||||
currentLocale: $locale,
|
||||
isNews: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $sitemapUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localized URL from Mcamara\LaravelLocalization.
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
*/
|
||||
public function getLocalizedUrl(string $locale): string
|
||||
{
|
||||
return LaravelLocalization::getURLFromRouteNameTranslated(
|
||||
$locale,
|
||||
'routes.front.news',
|
||||
['news' => $this]
|
||||
) ?? route('front.news.show', $this);
|
||||
}
|
||||
|
||||
// * RELATIONSHIPS
|
||||
|
||||
/**
|
||||
|
|
@ -527,7 +464,7 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable, Sit
|
|||
'newsables',
|
||||
'news_id',
|
||||
'newsable_id'
|
||||
)->using(Newsable::class);
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -543,6 +480,6 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable, Sit
|
|||
'newsables',
|
||||
'news_id',
|
||||
'newsable_id'
|
||||
)->using(Newsable::class)->withTimestamps();
|
||||
)->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,16 +13,11 @@ use Illuminate\Support\Str;
|
|||
use Uwil\LaravelActivityLogs\Traits\ActivityLog;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* Id of the news.
|
||||
* @property string $name
|
||||
* Name of the category.
|
||||
* @property-read \Illuminate\Support\Carbon $created_at
|
||||
* Date on which the category was created.
|
||||
* @property-read \Illuminate\Support\Carbon $updated_at
|
||||
* Date on which the category was updated.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\News> $news
|
||||
* Associated news of the category.
|
||||
* @property int $id Id of the news.
|
||||
* @property string $name Name of the category.
|
||||
* @property-read \Illuminate\Support\Carbon $created_at Date on which the category was created.
|
||||
* @property-read \Illuminate\Support\Carbon $updated_at Date on which the category was updated.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\News> $news Associated news of the category.
|
||||
*/
|
||||
class NewsCategory extends Model implements TranslatableModel
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,18 +12,12 @@ use Uwil\LaravelActivityLogs\Traits\ActivityLog;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
/**
|
||||
* @property int $news_id
|
||||
* News focused.
|
||||
* @property string $name
|
||||
* Name of the tag.
|
||||
* @property string $color
|
||||
* Color of the tag
|
||||
* @property-read \Illuminate\Support\Carbon $created_at
|
||||
* Date on which the tag was created.
|
||||
* @property-read \Illuminate\Support\Carbon $updated_at
|
||||
* Date on which the tag was updated.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\News> $news
|
||||
* Associated news of the tag.
|
||||
* @property int $news_id News focused.
|
||||
* @property string $name Name of the tag.
|
||||
* @property string $color Color of the tag
|
||||
* @property-read \Illuminate\Support\Carbon $created_at Date on which the tag was created.
|
||||
* @property-read \Illuminate\Support\Carbon $updated_at Date on which the tag was updated.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\News> $news Associated news of the tag.
|
||||
*/
|
||||
class NewsTag extends Model implements TranslatableModel
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphPivot;
|
||||
|
||||
/**
|
||||
* @property integer $id Id of the news.
|
||||
* @property \App\Models\News $news_id Name of the news.
|
||||
* @property \App\Models\NewsCategory|\App\Models\NewsTag $newsable_id Name of the category or tag.
|
||||
* @property class-string<static> $newsable_type Class of the category or tag.
|
||||
* @property-read \Carbon\Carbon $created_at Created date of the news.
|
||||
* @property-read \Carbon\Carbon $updated_at Updated date of the news.
|
||||
*/
|
||||
class Newsable extends MorphPivot
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $table = 'newsables';
|
||||
|
||||
/**
|
||||
* Perform any actions required after the model boots.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(fn() => News::resetCache());
|
||||
static::updated(fn() => News::resetCache());
|
||||
static::deleted(fn() => News::resetCache());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,10 +21,11 @@ class NewsNextEventComponent extends Component
|
|||
$newsCategory = NewsCategory::getModelsCached()
|
||||
->filter(fn($category) => $category->getTranslation('slug', 'fr') === "actualites")
|
||||
->first();
|
||||
|
||||
/** @var \App\Models\NewsTag|null $eventTag */
|
||||
$eventTag = NewsTag::getTagsByCategoryInCurrentLocale($newsCategory->slug)
|
||||
->filter(
|
||||
fn(\Illuminate\Database\Eloquent\Model $tag) => $tag->getTranslation('slug', 'fr') === "actualites-rh"
|
||||
fn(\Illuminate\Database\Eloquent\Model $tag) => $tag->getTranslation('slug', 'fr') === "evenement"
|
||||
)
|
||||
->first();
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
__DIR__ . '/../routes/web-front.php',
|
||||
__DIR__ . '/../routes/web-back.php',
|
||||
],
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
module.exports = require('@uwil/husky-config/commitlint.config.cjs');
|
||||
|
|
@ -24,12 +24,10 @@
|
|||
"sentry/sentry-laravel": "^4.18",
|
||||
"spatie/laravel-translatable": "^6.11",
|
||||
"tightenco/ziggy": "^2.5",
|
||||
"uwil/admin-secure-laravel": "^5.1",
|
||||
"uwil/admin-secure-laravel": "^5.0",
|
||||
"uwil/k8s-laravel": "^2.0",
|
||||
"uwil/laravel-backend": "^3.0",
|
||||
"uwil/laravel-common": "^4.1",
|
||||
"uwil/sitemap-generator": "^6.0",
|
||||
"verschuur/laravel-robotstxt": "*"
|
||||
"uwil/laravel-common": "^4.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
|
|
|||
1218
composer.lock
generated
|
|
@ -14,7 +14,7 @@ return [
|
|||
*
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'smtp'),
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/**
|
||||
*--------------------------------------------------------------------------
|
||||
|
|
@ -114,14 +114,4 @@ return [
|
|||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
],
|
||||
|
||||
'to' => [
|
||||
'breeder' => explode(',', env('MAIL_LIST_BREEDER')) ?? [],
|
||||
'commitments' => explode(',', env('MAIL_LIST_COMMITMENTS')) ?? [],
|
||||
'export' => explode(',', env('MAIL_LIST_EXPORT')) ?? [],
|
||||
'finance' => explode(',', env('MAIL_LIST_FINANCE')) ?? [],
|
||||
'info' => explode(',', env('MAIL_LIST_INFO')) ?? [],
|
||||
'press' => explode(',', env('MAIL_LIST_PRESS')) ?? [],
|
||||
'restauration' => explode(',', env('MAIL_LIST_RESTAURATION')) ?? [],
|
||||
]
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
return [
|
||||
'environments' => [
|
||||
'production' => [
|
||||
'paths' => [
|
||||
'*' => [
|
||||
'disallow' => [
|
||||
'/admin',
|
||||
'*.pdf$'
|
||||
],
|
||||
'allow' => []
|
||||
],
|
||||
],
|
||||
'sitemaps' => [
|
||||
'sitemap.xml'
|
||||
]
|
||||
],
|
||||
'staging' => [
|
||||
'paths' => [
|
||||
'*' => [
|
||||
'disallow' => [
|
||||
'/'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
]
|
||||
];
|
||||
|
|
@ -20,7 +20,7 @@ return [
|
|||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SCOUT_DRIVER', null),
|
||||
'driver' => env('SCOUT_DRIVER', 'meilisearch'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -37,14 +37,5 @@ return [
|
|||
|
||||
'google' => [
|
||||
'maps' => env('GOOGLE_MAPS_KEY'),
|
||||
'recaptcha' => [
|
||||
'public' => env('GOOGLE_RECAPTCHA_PUBLIC'),
|
||||
'private' => env('GOOGLE_RECAPTCHA_PRIVATE'),
|
||||
'version' => env('GOOGLE_RECAPTCHA_VERSION', "3") === "3" ? true : false
|
||||
]
|
||||
],
|
||||
|
||||
'matomo' => [
|
||||
'site_id' => env('MATOMO_SITE_ID')
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
|
||||
return [
|
||||
|
||||
/**
|
||||
* Single route names
|
||||
*
|
||||
* Ex:
|
||||
*
|
||||
* 'singlePages' => [
|
||||
* 'homepage',
|
||||
* ],
|
||||
*/
|
||||
'singlePages' => [
|
||||
'front.search',
|
||||
],
|
||||
|
||||
/**
|
||||
* Model namespaces with filters
|
||||
*
|
||||
* Ex: (modelFilters & relationFilters are not necessaries)
|
||||
* 'cruds' => [
|
||||
* [
|
||||
* 'model' => 'App\Models\Product',
|
||||
* 'modelFilters' => [
|
||||
* ['published', '=', true]
|
||||
* ],
|
||||
* 'relationFilters' => [
|
||||
* 'range' => fn(Builder $q) => $q->where('status', true),
|
||||
* ],
|
||||
* ],
|
||||
* [
|
||||
* 'model' => 'App\Models\Article',
|
||||
* ]
|
||||
* ],
|
||||
*/
|
||||
'cruds' => [
|
||||
[
|
||||
'model' => 'App\Models\News',
|
||||
'filters' => [
|
||||
['status', '=', \App\Enums\News\NewsStatusEnum::published]
|
||||
],
|
||||
],
|
||||
[
|
||||
'model' => 'App\Models\DynamicPage',
|
||||
'filters' => [
|
||||
['status', '=', \App\Enums\DynamicPages\PageStatusEnum::published]
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/**
|
||||
* Enable or disable schedule for the job
|
||||
* If set to true the sitemap generator will be run
|
||||
* using the 'cron' instruction
|
||||
*/
|
||||
'schedule' => true,
|
||||
|
||||
/**
|
||||
* If you want to set specific
|
||||
* instruction for when to schedule job
|
||||
* you can set this with a cron instruction
|
||||
* You can generate one using https://crontab.guru/
|
||||
*
|
||||
* schedule must be set to true
|
||||
*
|
||||
* Default is every day at 15:45
|
||||
*/
|
||||
'cron' => '45 15 * * *',
|
||||
|
||||
'supported_locales' => array_keys(config('laravellocalization.supportedLocales', ['fr' => "Fr"]))
|
||||
];
|
||||
|
|
@ -192,7 +192,7 @@ return [
|
|||
"delete" => "delete the color",
|
||||
],
|
||||
"wysiwyg" => [
|
||||
"undo" => "Undo",
|
||||
"undo" => "Undo",
|
||||
"redo" => "Redo",
|
||||
"bold" => "Bold",
|
||||
"italic" => "Italic",
|
||||
|
|
@ -371,40 +371,4 @@ 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",
|
||||
]
|
||||
]
|
||||
];
|
||||
|
|
|
|||
|
|
@ -31,7 +31,47 @@ return [
|
|||
'model' => 'Model',
|
||||
'staticroute' => "Static route",
|
||||
'position' => 'Element position',
|
||||
'contactform' => 'Contact form type',
|
||||
],
|
||||
'contactFormType' => [
|
||||
'collaborator' => [
|
||||
'label' => 'join us as an employee',
|
||||
'slug' => 'join-us-as-an-employee'
|
||||
],
|
||||
'breeder' => [
|
||||
'label' => 'join us as a farmer',
|
||||
'slug' => 'join-us-as-a-farmer'
|
||||
],
|
||||
'professional' => [
|
||||
'label' => 'a request as a professional'
|
||||
],
|
||||
'restauration' => [
|
||||
'label' => 'foodservice, Industry, Retailer France & Overseas',
|
||||
'slug' => 'foodservice-industry-retailer-distributor',
|
||||
'subtitle' => 'a request as a customer / retailer / distributor'
|
||||
],
|
||||
'export' => [
|
||||
'label' => 'other countries / Export',
|
||||
'slug' => 'export'
|
||||
],
|
||||
'finance' => [
|
||||
'label' => 'finance',
|
||||
'slug' => 'finance-2' #FIXME: revoir le slug
|
||||
],
|
||||
'commitments' => [
|
||||
'label' => 'our commitments',
|
||||
'slug' => 'our-commitments-2' #FIXME: revoir le slug
|
||||
],
|
||||
'product' => [
|
||||
'label' => 'product information',
|
||||
'slug' => 'product-information'
|
||||
],
|
||||
'info' => [
|
||||
'label' => 'other general information',
|
||||
'slug' => 'other-general-information'
|
||||
],
|
||||
'press' => [
|
||||
'label' => 'a press request'
|
||||
],
|
||||
],
|
||||
'breedType' => [
|
||||
'chicken' => 'Chickens / turkeys / guinea fowl',
|
||||
|
|
@ -88,25 +128,4 @@ return [
|
|||
'asc' => 'From oldest to newest.',
|
||||
'desc' => 'From the most recent to the oldest.'
|
||||
],
|
||||
'exportAskingType' => [
|
||||
"customer" => "Customer",
|
||||
"provider" => "Supplier",
|
||||
"other" => "Other"
|
||||
],
|
||||
'contactFormType' => [
|
||||
'collaborator' => 'join us as an employee',
|
||||
'breeder' => 'join us as a farmer',
|
||||
'professional' => 'a request as a professional',
|
||||
'restauration' => 'foodservice, Industry, Retailer France & Overseas',
|
||||
'export' => 'other countries / Export',
|
||||
'finance' => 'finance',
|
||||
'commitments' => 'our commitments',
|
||||
'product' => 'product information',
|
||||
'info' => 'other general information',
|
||||
'press' => 'a press request',
|
||||
],
|
||||
"videoDisplayFormat" => [
|
||||
"landscape" => "landscape",
|
||||
"portrait" => "portrait"
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -145,11 +145,5 @@ return [
|
|||
'subtitle' => 'jobs, internships and apprenticeships',
|
||||
'link_text' => 'access our recruitment portal'
|
||||
],
|
||||
'discover_our_jobs' => 'discover our broad range of jobs',
|
||||
'newsletter' => [
|
||||
'title' => 'Subscribe to our newsletter !',
|
||||
'placeholder' => 'Enter your email',
|
||||
'validate' => 'Confirm',
|
||||
'policy_message' => 'For any additional information regarding your personal data and your rights, please consult our '
|
||||
],
|
||||
'discover_our_jobs' => 'discover our broad range of jobs'
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
"classes" => [
|
||||
"user" => "user|users",
|
||||
"user" => "user",
|
||||
"dynamic-page" => "dynamic page|dynamic pages",
|
||||
"page-config" => "page configuration|page configurations",
|
||||
"activity-log" => "activity log",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'front' => [
|
||||
'news' => 'news/{news}',
|
||||
'search' => 'search'
|
||||
],
|
||||
'contact' => 'contact-us/{slug}',
|
||||
'contact_index' => 'contact-us',
|
||||
'news' => 'news/{news}',
|
||||
'search' => 'search',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -271,15 +271,6 @@ return [
|
|||
"value" => "value",
|
||||
"path" => "path",
|
||||
"issuu_path" => "link of the 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"
|
||||
]
|
||||
"is_issuu" => "is an Issuu preview ?"
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -372,45 +372,4 @@ 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",
|
||||
]
|
||||
]
|
||||
];
|
||||
|
|
|
|||
|
|
@ -31,7 +31,47 @@ return [
|
|||
'model' => 'Modèle',
|
||||
'staticroute' => "Route statique",
|
||||
'position' => 'Positionnement de l\'élement',
|
||||
'contactform' => 'Type de formulaire de contact',
|
||||
],
|
||||
'contactFormType' => [
|
||||
'collaborator' => [
|
||||
'label' => 'nous rejoindre en tant que collaborateur',
|
||||
'slug' => 'nous-rejoindre-en-tant-que-collaborateur'
|
||||
],
|
||||
'breeder' => [
|
||||
'label' => 'nous rejoindre en tant qu’éleveur',
|
||||
'slug' => 'nous-rejoindre-en-tant-queleveur'
|
||||
],
|
||||
'professional' => [
|
||||
'label' => 'une demande en tant que professionnel'
|
||||
],
|
||||
'restauration' => [
|
||||
'label' => 'restaurations, Distributeurs et Détaillants France',
|
||||
'subtitle' => 'une demande en tant que client / détaillant / distributeur',
|
||||
'slug' => 'une-demande-en-tant-que-client-detaillant-distributeur'
|
||||
],
|
||||
'export' => [
|
||||
'label' => 'l’export',
|
||||
'slug' => 'lexport'
|
||||
],
|
||||
'finance' => [
|
||||
'label' => 'la finance',
|
||||
'slug' => 'la-finance'
|
||||
],
|
||||
'commitments' => [
|
||||
'label' => 'nos engagements',
|
||||
'slug' => 'nos-engagements'
|
||||
],
|
||||
'product' => [
|
||||
'label' => 'une information produit',
|
||||
'slug' => 'une-information-produit'
|
||||
],
|
||||
'info' => [
|
||||
'label' => 'une autre information générale',
|
||||
'slug' => 'une-autre-information-generale'
|
||||
],
|
||||
'press' => [
|
||||
'label' => 'une demande presse'
|
||||
],
|
||||
],
|
||||
'breedType' => [
|
||||
'chicken' => 'Poulets/dindes/pintades',
|
||||
|
|
@ -88,25 +128,4 @@ return [
|
|||
'asc' => 'du plus ancien au plus récent.',
|
||||
'desc' => 'du plus récent au plus ancien.'
|
||||
],
|
||||
'exportAskingType' => [
|
||||
"customer" => "Client",
|
||||
"provider" => "Fournisseur",
|
||||
"other" => "Autres"
|
||||
],
|
||||
'contactFormType' => [
|
||||
'collaborator' => 'nous rejoindre en tant que collaborateur',
|
||||
'breeder' => 'nous rejoindre en tant qu’éleveur',
|
||||
'professional' => 'une demande en tant que professionnel',
|
||||
'restauration' => 'restaurations, Distributeurs et Détaillants France',
|
||||
'export' => 'l’export',
|
||||
'finance' => 'la finance',
|
||||
'commitments' => 'nos engagements',
|
||||
'product' => 'une information produit',
|
||||
'info' => 'une autre information générale',
|
||||
'press' => 'une demande presse',
|
||||
],
|
||||
"videoDisplayFormat" => [
|
||||
"landscape" => "paysage",
|
||||
"portrait" => "portrait"
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -146,11 +146,5 @@ return [
|
|||
'subtitle' => 'd\'emploi, de stage et d\'alternance',
|
||||
'link_text' => 'accédez à notre portail de recrutement'
|
||||
],
|
||||
'discover_our_jobs' => 'découvrez nos métiers',
|
||||
'newsletter' => [
|
||||
'title' => 'Inscrivez-vous à notre newsletter !',
|
||||
'placeholder' => 'Renseignez votre email',
|
||||
'validate' => 'Valider',
|
||||
'policy_message' => 'Pour toute information complémentaire concernant vos données personnelles et vos droits, merci de consulter notre'
|
||||
],
|
||||
'discover_our_jobs' => 'découvrez nos métiers'
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
"classes" => [
|
||||
"user" => "utilisateur|utilisateurs",
|
||||
"user" => "utilisateur",
|
||||
"dynamic-page" => "page dynamique|pages dynamiques",
|
||||
"page-config" => "configuration de page|configurations de page",
|
||||
"activity-log" => "journal d'activité",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'front' => [
|
||||
'news' => 'actualites/{news}',
|
||||
'search' => 'recherche'
|
||||
],
|
||||
'contact' => 'nous-contacter/{slug}',
|
||||
'contact_index' => 'nous-contacter',
|
||||
'news' => 'actualites/{news}',
|
||||
'search' => 'recherche',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -270,17 +270,6 @@ return [
|
|||
"value" => "valeur",
|
||||
"path" => "chemin d'accès",
|
||||
"issuu_path" => "lien du 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"
|
||||
]
|
||||
"is_issuu" => "est-ce un fichier Issuu ?"
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ data:
|
|||
APP_ENV: "${APP_APP_ENV}"
|
||||
APP_KEY: "${APP_APP_KEY}"
|
||||
APP_DEBUG: "${APP_APP_DEBUG}"
|
||||
APP_URL: "https://${REDIRECT}${APP_DOMAIN}"
|
||||
APP_URL: "https://${APP_DOMAIN}"
|
||||
LOG_CHANNEL: "k8s"
|
||||
CACHE_STORE: "${APP_CACHE_STORE}"
|
||||
DB_HOST: "${APP_DB_HOST}"
|
||||
|
|
@ -23,13 +23,6 @@ data:
|
|||
MAIL_ENCRYPTION: "${APP_MAIL_ENCRYPTION}"
|
||||
MAIL_FROM_ADDRESS: "${APP_MAIL_FROM_ADDRESS}"
|
||||
MAIL_FROM_NAME: "${APP_APP_NAME}"
|
||||
MAIL_LIST_BREEDER: "${APP_MAIL_LIST_BREEDER}"
|
||||
MAIL_LIST_COMMITMENTS: "${APP_MAIL_LIST_COMMITMENTS}"
|
||||
MAIL_LIST_EXPORT: "${APP_MAIL_LIST_EXPORT}"
|
||||
MAIL_LIST_FINANCE: "${APP_MAIL_LIST_FINANCE}"
|
||||
MAIL_LIST_INFO: "${APP_MAIL_LIST_INFO}"
|
||||
MAIL_LIST_PRESS: "${APP_MAIL_LIST_PRESS}"
|
||||
MAIL_LIST_RESTAURATION: "${APP_MAIL_LIST_RESTAURATION}"
|
||||
SENTRY_LARAVEL_DSN: "${APP_SENTRY_LARAVEL_DSN}"
|
||||
TELESCOPE_ENABLED: "${APP_TELESCOPE_ENABLED}"
|
||||
CRM_ENDPOINT: "${APP_CRM_ENDPOINT}"
|
||||
|
|
@ -63,18 +56,6 @@ spec:
|
|||
requests:
|
||||
storage: $APP_PVC_SIZE
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ${CI_PROJECT_PATH_SLUG}-wp-uploads-pvc-storage
|
||||
spec:
|
||||
storageClassName: "csi-cinder-classic"
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: $APP_WP_UPLOADS_PVC_SIZE
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
|
|
@ -105,8 +86,6 @@ spec:
|
|||
volumeMounts:
|
||||
- name: storage
|
||||
mountPath: /app/srcs/storage
|
||||
- name: wp-uploads-storage
|
||||
mountPath: /app/srcs/public/app/uploads
|
||||
- name: ${CI_PROJECT_PATH_SLUG}-queue-worker
|
||||
image: $IMAGE_TAG
|
||||
lifecycle:
|
||||
|
|
@ -141,9 +120,6 @@ spec:
|
|||
- name: storage
|
||||
persistentVolumeClaim:
|
||||
claimName: ${CI_PROJECT_PATH_SLUG}-pvc-storage
|
||||
- name: wp-uploads-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: ${CI_PROJECT_PATH_SLUG}-wp-uploads-pvc-storage
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
|
@ -165,29 +141,14 @@ spec:
|
|||
- web
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`${APP_DOMAIN}`) || Host(`www.${APP_DOMAIN}`)
|
||||
- match: Host(`${APP_DOMAIN}`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: ${CI_PROJECT_PATH_SLUG}-service
|
||||
port: 80
|
||||
middlewares:
|
||||
- name: redirect-no-www-to-www
|
||||
- name: redirect-ldc-rgpd-to-rgpd
|
||||
|
||||
tls:
|
||||
secretName: ${CRT_TLS_NAME}
|
||||
---
|
||||
# Redirect with domain replacement
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: redirect-ldc-rgpd-to-rgpd
|
||||
spec:
|
||||
redirectRegex:
|
||||
regex: ^https://${REDIRECT}${APP_DOMAIN}/rgpd/(professionnels|salaries|consommateurs)(/(en|de))?
|
||||
replacement: https://rgpd.ldc.fr/fr/${1}
|
||||
permanent: true
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
|
|
|
|||
645
package-lock.json
generated
|
|
@ -33,6 +33,7 @@
|
|||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"concurrently": "^9.2.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
|
|
@ -103,7 +104,6 @@
|
|||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@uwil/husky-config": "^1.0.1",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ parameters:
|
|||
paths:
|
||||
- app/
|
||||
|
||||
level: 7
|
||||
level: 5
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ metadata:
|
|||
spec:
|
||||
dnsNames:
|
||||
- $APP_DOMAIN
|
||||
- www.${APP_DOMAIN}
|
||||
secretName: $APP_DOMAIN
|
||||
issuerRef:
|
||||
name: $APP_CERT_ISSUER
|
||||
|
|
|
|||
174
public/.htaccess
|
|
@ -18,182 +18,8 @@
|
|||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Redirect /index.php/anything to /anything
|
||||
RewriteCond %{THE_REQUEST} /index\.php/([^\s?]*) [NC]
|
||||
RewriteRule ^index\.php/(.*)$ /$1 [L,R=301,NC]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
|
||||
# Redirection from old website
|
||||
Redirect 301 "/category/news" "/actualites"
|
||||
Redirect 301 "/100%des-elevages-nature-deleveurs-dici-a-2025" "/actualites/100-des-elevages-nature-deleveurs-dici-a-2025"
|
||||
Redirect 301 "/acquisition-de-la-societe-asia-general-food" "/actualites/acquisition-de-la-societe-asia-general-food"
|
||||
Redirect 301 "/big-bang-de-lemploi-2023-rencontrons-nous-dans-des-conditions-exceptionnelles" "/actualites/big-bang-de-lemploi-2023-rencontrons-nous-dans-des-conditions-exceptionnelles"
|
||||
Redirect 301 "/bonne-annee-2024-a-tous" "/actualites/bonne-annee-2024-a-tous"
|
||||
Redirect 301 "/decouvrez-notre-nouvelle-plaquette-rse" "/actualites/decouvrez-notre-nouvelle-plaquette-rse"
|
||||
Redirect 301 "/decouvrez-notre-rapport-annuel-2020-2021" "/actualites/decouvrez-notre-rapport-annuel-2020-2021"
|
||||
Redirect 301 "/decouvrez-notre-rapport-annuel-2021-2022" "/actualites/decouvrez-notre-rapport-annuel-2021-2022"
|
||||
Redirect 301 "/echanges-entre-les-etudiants-de-lessca-et-le-raf-du-pole-volaille-ldc" "/actualites/echanges-entre-les-etudiants-de-lessca-et-le-raf-du-pole-volaille-ldc"
|
||||
Redirect 301 "/echanges-entre-les-etudiants-unilasalle-et-des-collaborateurs-ldc" "/actualites/echanges-entre-les-etudiants-unilasalle-et-des-collaborateurs-ldc"
|
||||
Redirect 301 "/entree-en-negociation-exclusive-en-vue-de-lacquisition-de-la-societe-indykpol-sa-2" "/actualites/entree-en-negociation-exclusive-en-vue-de-lacquisition-de-la-societe-indykpol-sa"
|
||||
Redirect 301 "/entree-en-negociation-exclusive-en-vue-de-lacquisition-de-la-societe-indykpol-sa-3" "/actualites/entree-en-negociation-exclusive-en-vue-de-lacquisition-de-la-societe-indykpol-sa"
|
||||
Redirect 301 "/entree-en-negociation-exclusive-en-vue-de-lacquisition-de-lusine-konspol-de-nowy-sacz-en-pologne-2" "/actualites/entree-en-negociation-exclusive-en-vue-de-lacquisition-de-lusine-konspol-de-nowy-sacz-en-pologne"
|
||||
Redirect 301 "/entree-en-negociation-exclusive-en-vue-de-lacquisition-potentielle-de-100-du-capital-du-groupe-pierre-martinet-2" "/actualites/entree-en-negociation-exclusive-en-vue-de-lacquisition-potentielle-de-100-du-capital-du-groupe-pierre-martinet"
|
||||
Redirect 301 "/entree-en-negociation-exclusive-en-vue-de-lacquisition-potentielle-de-100-du-capital-du-groupe-pierre-martinet-3" "/actualites/entree-en-negociation-exclusive-en-vue-de-lacquisition-potentielle-de-100-du-capital-du-groupe-pierre-martinet"
|
||||
Redirect 301 "/entree-en-partenariat-avec-la-famille-buchanan-au-royaume-uni-2" "/actualites/entree-en-partenariat-avec-la-famille-buchanan-au-royaume-uni"
|
||||
Redirect 301 "/feu-vert-de-lautorite-de-la-concurrence-au-rachat-du-groupe-ronsard-2" "/actualites/feu-vert-de-lautorite-de-la-concurrence-au-rachat-du-groupe-ronsard"
|
||||
Redirect 301 "/feu-vert-de-lautorite-de-la-concurrence-au-rachat-du-groupe-ronsard-3" "/actualites/feu-vert-de-lautorite-de-la-concurrence-au-rachat-du-groupe-ronsard"
|
||||
Redirect 301 "/interview-philippe-gelin-sur-la-chaine-youtube-meilleurtaux-placement" "/actualites/interview-philippe-gelin-sur-la-chaine-youtube-meilleurtaux-placement"
|
||||
Redirect 301 "/journee-mondiale-de-leau" "/actualites/journee-mondiale-de-leau"
|
||||
Redirect 301 "/journee-mondiale-du-recyclage-%e2%99%bb" "/actualites/journee-mondiale-du-recyclage"
|
||||
Redirect 301 "/journee-portes-ouvertes-ldc-sable-le-1er-avril-2023" "/actualites/journee-portes-ouvertes-ldc-sable-le-1er-avril-2023"
|
||||
Redirect 301 "/joyeux-nouvel-an-chinois" "/actualites/joyeux-nouvel-an-chinois"
|
||||
Redirect 301 "/lancement-campagne-alternance-2024" "/actualites/lancement-campagne-alternance-2024"
|
||||
Redirect 301 "/ldc-a-la-reconquete-du-marche-national" "/actualites/ldc-a-la-reconquete-du-marche-national"
|
||||
Redirect 301 "/ldc-partage-sa-politique-alimentation-des-volailles" "/actualites/ldc-partage-sa-politique-dalimentation-durable-des-volailles"
|
||||
Redirect 301 "/ldc-participe-a-la-semaine-nationale-de-lemploi-agroalimentaire-100-digital" "/actualites/ldc-participe-a-la-semaine-nationale-de-lemploi-agroalimentaire-100-digital"
|
||||
Redirect 301 "/ldc-participe-au-campus-des-metiers-et-des-qualifications" "/actualites/ldc-participe-au-campus-des-metiers-et-des-qualifications"
|
||||
Redirect 301 "/ldc-sable-lance-sa-premiere-formation-qualifiante-post-bac" "/actualites/ldc-sable-lance-sa-premiere-formation-qualifiante-post-bac"
|
||||
Redirect 301 "/ldc-sable-le-gaulois-signe-un-partenariat-avec-coolplanet-pour-optimiser-ses-consommations-denergie-et-accelerer-sa-feuille-de-route-en-matiere-de-decarbonation" "/actualites/ldc-sable-le-gaulois-signe-un-partenariat-avec-coolplanet-pour-optimiser-ses-consommations-denergie-et-accelerer-sa-feuille-de-route-en-matiere-de-decarbonation"
|
||||
Redirect 301 "/le-cavol-a-la-reconquete-de-la-rhd" "/actualites/le-cavol-a-la-reconquete-de-la-rhd"
|
||||
Redirect 301 "/le-gaulois-maitre-coq-loue-il-est-le-champion-du-poulet-toutes-categories" "/actualites/le-gaulois-maitre-coq-loue-il-est-le-champion-du-poulet-toutes-categories"
|
||||
Redirect 301 "/le-gaulois-professionnel-lance-son-site-internet" "/actualites/le-gaulois-professionnel-lance-son-site-internet"
|
||||
Redirect 301 "/le-gaulois-signe-un-partenariat-avec-programme-malin" "/actualites/le-gaulois-signe-un-partenariat-avec-programme-malin"
|
||||
Redirect 301 "/le-groupe-ldc-a-participe-au-forum-uni-la-salle-de-beauvais-le-7-octobre-2021" "/actualites/le-groupe-ldc-a-participe-au-forum-uni-la-salle-de-beauvais-le-7-octobre-2021"
|
||||
Redirect 301 "/le-groupe-ldc-cree-un-partenariat-avec-citeo-pour-le-developpement-dune-filiere-de-recyclage-du-polystyrene-en-france" "/actualites/le-groupe-ldc-cree-un-partenariat-avec-citeo-pour-le-developpement-dune-filiere-de-recyclage-du-polystyrene-en-france"
|
||||
Redirect 301 "/le-groupe-ldc-officialise-sa-strategie-climat-biodiversite-2030-%f0%9f%8c%8d" "/actualites/le-groupe-ldc-officialise-sa-strategie-climat-biodiversite-2030"
|
||||
Redirect 301 "/le-groupe-ldc-participe-a-la-semaine-de-lagro-grand-ouest-du-12-au-20-avril" "/actualites/le-groupe-ldc-participe-a-la-semaine-de-lagro-grand-ouest-du-12-au-20-avril"
|
||||
Redirect 301 "/le-groupe-ldc-participe-a-la-semaine-nationale-de-lemploi-agroalimentaire" "/actualites/le-groupe-ldc-participe-a-la-semaine-nationale-de-lemploi-agroalimentaire"
|
||||
Redirect 301 "/anuga-2023" "/actualites/le-groupe-ldc-present-a-lanuga-2023"
|
||||
Redirect 301 "/le-groupe-ldc-present-au-forum-oniris-le-18-novembre-2021" "/actualites/le-groupe-ldc-present-au-forum-oniris-le-18-novembre-2021"
|
||||
Redirect 301 "/le-groupe-ldc-present-au-jobfest-du-mans-le-30-mars-2023" "/actualites/le-groupe-ldc-present-au-jobfest-du-mans-le-30-mars-2023"
|
||||
Redirect 301 "/le-groupe-ldc-present-aux-forums-ecoles-de-lesa-angers-et-agrosup-dijon" "/actualites/le-groupe-ldc-present-aux-forums-ecoles-de-lesa-angers-et-agrosup-dijon"
|
||||
Redirect 301 "/le-groupe-ldc-present-aux-forums-ecoles-de-linstitut-agro-dijon-esa-angers-oniris-nantes-esaip-angers" "/actualites/le-groupe-ldc-present-aux-forums-ecoles-de-linstitut-agro-dijon-esa-angers-oniris-nantes-esaip-angers"
|
||||
Redirect 301 "/le-groupe-ldc-present-aux-forums-polytech-angers-et-agro-campus-rennes-le-18-octobre-2022" "/actualites/le-groupe-ldc-present-aux-forums-polytech-angers-et-agro-campus-rennes-le-18-octobre-2022"
|
||||
Redirect 301 "/le-groupe-ldc-reagit-face-aux-nouvelles-attaques-de-l214" "/actualites/le-groupe-ldc-reagit-face-aux-nouvelles-attaques-de-l214"
|
||||
Redirect 301 "/le-groupe-ldc-renforce-ses-engagements-et-son-objectif-soja-zero-deforestation" "/actualites/le-groupe-ldc-reagit-face-aux-nouvelles-attaques-de-l214"
|
||||
Redirect 301 "/le-groupe-ldc-renforce-ses-engagements-et-son-objectif-soja-zero-deforestation-2" "/actualites/le-groupe-ldc-renforce-ses-engagements-et-son-objectif-soja-zero-deforestation"
|
||||
Redirect 301 "/les-industries-de-la-transformation-des-volailles-offrent-une-diversite-de-metiers-et-de-nombreuses-opportunites-demploi" "/actualites/les-industries-de-la-transformation-des-volailles-offrent-une-diversite-de-metiers-et-de-nombreuses-opportunites-demploi"
|
||||
Redirect 301 "/les-talents-de-la-sarthe-2019" "/actualites/les-talents-de-la-sarthe-2019"
|
||||
Redirect 301 "/les-talents-de-la-sarthe-2019-2" "/actualites/les-talents-de-la-sarthe-2019"
|
||||
Redirect 301 "/maitre-coq-devient-partenaire-officiel-dangers-sco-handball" "/actualites/maitre-coq-devient-partenaire-officiel-dangers-sco-handball"
|
||||
Redirect 301 "/marie-surgeles-organise-ses-job-dating-en-entreprise" "/actualites/marie-surgeles-organise-ses-job-dating-en-entreprise"
|
||||
Redirect 301 "/nos-equipes-volena-et-sbv-etaient-presentes-au-food-talent-du-cfia-expo-rennes-ce-14-mars" "/actualites/nos-equipes-volena-et-sbv-etaient-presentes-au-food-talent-du-cfia-expo-rennes-ce-14-mars"
|
||||
Redirect 301 "/note-dinformation-deces-de-monsieur-chancereul-gerard" "/actualites/note-dinformation-deces-de-monsieur-chancereul-gerard"
|
||||
Redirect 301 "/partenariat-ecole-les-etudiants-uni-la-salle-en-visite-chez-ldc-groupe" "/actualites/partenariat-ecole-les-etudiants-unilasalle-en-visite-chez-ldc-groupe"
|
||||
Redirect 301 "/partenariat-ecole-visite-de-lesa-sur-lunite-dinde-ldc-sable" "/actualites/partenariat-ecole-visite-de-lesa-sur-lunite-dinde-ldc-sable"
|
||||
Redirect 301 "/publication-de-notre-rapport-annuel-2022-2023" "/actualites/publication-de-notre-rapport-annuel-2022-2023"
|
||||
Redirect 301 "/recrutement-en-alternance-plus-de-100-opportunites-dans-tous-les-metiers" "/actualites/recrutement-en-alternance-plus-de-100-opportunites-dans-tous-les-metiers"
|
||||
Redirect 301 "/reprise-de-kiplama-en-belgique" "/actualites/reprise-de-kiplama-en-belgique"
|
||||
Redirect 301 "/reprise-de-luche-tradition-volailles-a-luche-pringe-sarthe" "/actualites/reprise-de-luche-tradition-volailles-a-luche-pringe-sarthe"
|
||||
Redirect 301 "/reprise-de-marnevall-en-hongrie" "/actualites/reprise-de-marnevall-en-hongrie"
|
||||
Redirect 301 "/resultats-annuels-2021-2022-2" "/actualites/resultats-annuels-2021-2022"
|
||||
Redirect 301 "/resultats-semestriels-2020-2021-3" "/actualites/resultats-semestriels-2020-2021"
|
||||
Redirect 301 "/resultats-semestriels-2023-2024-2" "/actualites/resultats-semestriels-20232024"
|
||||
Redirect 301 "/retour-en-video-sur-le-space-rennes-2024" "/actualites/retour-en-video-sur-le-space-rennes-2024"
|
||||
Redirect 301 "/retour-en-video-sur-notre-evenement-interne-challenge-remy-lambert" "/actualites/retour-en-video-sur-notre-evenement-interne-challenge-remy-lambert"
|
||||
Redirect 301 "/retrospective-2024-retrouvez-les-entreprises-qui-ont-rejoint-le-groupe-en-2024" "/actualites/retrospective-2024-retrouvez-les-entreprises-qui-ont-rejoint-le-groupe-en-2024"
|
||||
Redirect 301 "/retrouvez-nous-aux-forums-ecoles-en-fevrier-2025" "/actualites/retrouvez-nous-aux-forums-ecoles-en-fevrier-2025"
|
||||
Redirect 301 "/retrouvez-nous-aux-forums-ecoles-en-mars-2025" "/actualites/retrouvez-nous-aux-forums-ecoles-en-mars-2025"
|
||||
Redirect 301 "/signature-de-conventions-avec-les-banques-alimentaires" "/actualites/signature-de-conventions-avec-les-banques-alimentaires"
|
||||
Redirect 301 "/sirha-lyon-2025-le-groupe-ldc-et-ses-marques-restauration-seront-presents-du-23-au-27-janvier-2025" "/actualites/sirha-lyon-2025-le-groupe-ldc-et-ses-marques-restauration-seront-presents-du-23-au-27-janvier-2025"
|
||||
Redirect 301 "/sponsoring-sportif-maitre-coq-et-yannick-bestaven" "/actualites/sponsoring-sportif-maitre-coq-et-yannick-bestaven"
|
||||
Redirect 301 "/synthese-rse-du-groupe-ldc-2020" "/actualites/synthese-rse-du-groupe-ldc-2020"
|
||||
Redirect 301 "/teasing-retrouvez-le-pole-amont-au-space-du-17-au-19-septembre-2024" "/actualites/teasing-retrouvez-le-pole-amont-au-space-du-17-au-19-septembre-2024"
|
||||
Redirect 301 "/top-depart-pour-le-vendee-globe-avec-maitre-coq" "/actualites/top-depart-pour-le-vendee-globe-avec-maitre-coq"
|
||||
Redirect 301 "/tous-derriere-yannick-et-julien-pour-la-transat-jacques-vabre" "/actualites/tous-derriere-yannick-et-julien-pour-la-transat-jacques-vabre"
|
||||
Redirect 301 "/une-annee-de-plus-ou-nos-produits-ont-ete-recompenses-par-le-concours-general-agricole-2024" "/actualites/une-annee-de-plus-ou-nos-produits-ont-ete-recompenses-par-le-concours-general-agricole-2024"
|
||||
Redirect 301 "/venez-nous-rencontrer-au-salon-work-in-agro-2023" "/actualites/venez-nous-rencontrer-au-salon-work-in-agro-2023"
|
||||
Redirect 301 "/victoire-de-yannick-bestaven-et-son-maitre-coq-iv-de-la-9eme-edition-du-vendee-globe" "/actualites/victoire-de-yannick-bestaven-et-son-maitre-coq-iv-de-la-9eme-edition-du-vendee-globe"
|
||||
Redirect 301 "/victoire-de-yannick-bestaven-et-son-maitre-coq-iv-de-la-9eme-edition-du-vendee-globe-2" "/actualites/victoire-de-yannick-bestaven-et-son-maitre-coq-iv-de-la-9eme-edition-du-vendee-globe"
|
||||
Redirect 301 "/ldc-sable-lance-sa-premiere-formation-qualifiante-post-bac-pour-devenir-conducteur-de-ligne" "/actualites/video-ldc-sable-lance-sa-premiere-formation-qualifiante-post-bac-pour-devenir-conducteur-de-ligne"
|
||||
Redirect 301 "/volena-volaillers-engages-pour-lavenir-le-nouveau-nom-du-pole-normandie" "/actualites/volena-volaillers-engages-pour-lavenir-le-nouveau-nom-du-pole-normandie"
|
||||
Redirect 301 "/webinaire-entre-les-etudiants-doniris-et-des-collaborateurs-ldc" "/actualites/webinaire-entre-les-etudiants-doniris-et-des-collaborateurs-ldc"
|
||||
Redirect 301 "/en/100-of-nature-deleveurs-farms-by-2025" "/en/news/100-of-nature-deleveurs-farms-by-2025"
|
||||
Redirect 301 "/en/acquisition-of-asia-general-food" "/en/news/acquisition-of-asia-general-food"
|
||||
Redirect 301 "/en/cavol-to-re-conquer-hrd" "/en/news/cavol-to-re-conquer-hrd"
|
||||
Redirect 301 "/en/entry-into-partnership-with-the-buchanan-family-2" "/en/news/entry-into-partnership-with-the-buchanan-family"
|
||||
Redirect 301 "/en/ldc-participates-in-the-trades-and-skills-campus" "/en/news/ldc-participates-in-the-trades-and-skills-campus"
|
||||
Redirect 301 "/en/ldc-to-regain-the-national-market" "/en/news/ldc-to-regain-the-national-market"
|
||||
Redirect 301 "/en/le-gaulois-maitre-coq-loue-the-chicken-champion-across-the-categories" "/en/news/le-gaulois-maitre-coq-loue-the-chicken-champion-across-the-categories"
|
||||
Redirect 301 "/en/le-gaulois-signs-a-partnership-with-programme-malin" "/en/news/le-gaulois-signs-a-partnership-with-programme-malin"
|
||||
Redirect 301 "/en/maitre-coq-becomes-an-official-partner-of-angers-sco-handball" "/en/news/maitre-coq-becomes-an-official-partner-of-angers-sco-handball"
|
||||
Redirect 301 "/en/signature-of-agreements-with-food-banks" "/en/news/signature-of-agreements-with-food-banks"
|
||||
Redirect 301 "/en/take-over-of-kiplama-in-belgium" "/en/news/take-over-of-kiplama-in-belgium"
|
||||
Redirect 301 "/en/takeover-of-luche-tradition-volailles-in-luche-pringe-sarthe-2" "/en/news/takeover-of-luche-tradition-volailles-in-luche-pringe-sarthe"
|
||||
Redirect 301 "/en/takeover-of-marnevall-in-hungary" "/en/news/takeover-of-marnevall-in-hungary"
|
||||
Redirect 301 "/en/les-talents-de-la-sarthe-2019-2" "/en/news/talents-in-the-sarthe-2019"
|
||||
Redirect 301 "/en/talents-in-the-sarthe-2019" "/en/news/talents-in-the-sarthe-2019"
|
||||
Redirect 301 "/en/the-ldc-group-creates-a-partnership-with-citeo-for-the-development-of-a-polystyrene-recycling-industry-in-france" "/en/news/the-ldc-group-creates-a-partnership-with-citeo-for-the-development-of-a-polystyrene-recycling-industry-in-france"
|
||||
Redirect 301 "/en/top-start-for-the-vendee-globe-with-maitre-coq" "/en/news/top-start-for-the-vendee-globe-with-maitre-coq"
|
||||
Redirect 301 "/search" "/recherche"
|
||||
Redirect 301 "/nous-contacter/nos-engagements" "/nous-contacter/engagements"
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
|
||||
# Images
|
||||
ExpiresByType image/jpg "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/gif "access plus 1 year"
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
ExpiresByType image/svg+xml "access plus 1 year"
|
||||
|
||||
# CSS / JS
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType text/javascript "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
ExpiresByType application/x-javascript "access plus 1 year"
|
||||
|
||||
# WOFF / WOFF2 / Fonts
|
||||
ExpiresByType application/font-woff "access plus 1 year"
|
||||
ExpiresByType font/woff "access plus 1 year"
|
||||
ExpiresByType application/font-woff2 "access plus 1 year"
|
||||
ExpiresByType font/woff2 "access plus 1 year"
|
||||
|
||||
# Favicon
|
||||
ExpiresByType image/x-icon "access plus 1 year"
|
||||
</IfModule>
|
||||
|
||||
<FilesMatch "\.(jpe?g|png|gif|webp|css|js|woff|woff2|ico)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
</FilesMatch>
|
||||
|
||||
<IfModule mod_brotli.c>
|
||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/css text/plain text/xml text/javascript application/javascript application/x-javascript
|
||||
AddType text/plain .br
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html
|
||||
AddOutputFilterByType DEFLATE text/plain
|
||||
AddOutputFilterByType DEFLATE text/xml
|
||||
AddOutputFilterByType DEFLATE text/css
|
||||
AddOutputFilterByType DEFLATE text/javascript
|
||||
AddOutputFilterByType DEFLATE application/javascript
|
||||
AddOutputFilterByType DEFLATE application/x-javascript
|
||||
AddOutputFilterByType DEFLATE application/xml
|
||||
AddOutputFilterByType DEFLATE application/xhtml+xml
|
||||
AddOutputFilterByType DEFLATE application/rss+xml
|
||||
AddOutputFilterByType DEFLATE image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_mime.c>
|
||||
AddType application/javascript .js
|
||||
AddType font/woff2 .woff2
|
||||
AddType application/font-woff2 .woff2
|
||||
AddType application/font-woff .woff
|
||||
AddType font/woff .woff
|
||||
AddType image/svg+xml .svg
|
||||
</IfModule>
|
||||
|
||||
<FilesMatch "\.(css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2)$">
|
||||
FileETag None
|
||||
Header unset ETag
|
||||
Header unset Set-Cookie
|
||||
</FilesMatch>
|
||||
|
|
|
|||
|
|
@ -20,21 +20,6 @@
|
|||
.image-border_bottom {
|
||||
top: auto;
|
||||
}
|
||||
.textblock__container_swap > .image-content-size img {
|
||||
width: 100% !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
.textblock__container_row > .image-content-size img {
|
||||
width: 100% !important;
|
||||
height: 300px !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
.textblock__container_side > .image-content-size img {
|
||||
width: 100% !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1600px) {
|
||||
.wrapper {
|
||||
|
|
@ -103,11 +88,6 @@
|
|||
.textblock__container_swap .textblock__image {
|
||||
margin-left: 70px;
|
||||
}
|
||||
.textblock__container_row > .image-content-size img {
|
||||
width: 100% !important;
|
||||
height: 300px !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
.breadcrumbs {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
|
@ -266,35 +246,10 @@
|
|||
width: 310px;
|
||||
margin: 0px 11px 35px;
|
||||
}
|
||||
|
||||
.image-border-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.textblock__container_side > .image-content-size img {
|
||||
width: 100% !important;
|
||||
height: 425px !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
.textblock__container_swap_left > .image-content-size img {
|
||||
width: 100% !important;
|
||||
height: 425px !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
.textblock__container_row > .image-content-size {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.textblock__container_row > .image-content-size img {
|
||||
height: 210px !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
|
||||
.values {
|
||||
padding-bottom: 55px;
|
||||
}
|
||||
|
|
@ -448,6 +403,7 @@
|
|||
padding-left: 10px;
|
||||
}
|
||||
[data-menu="submenu-2951"] .mobile-menu__level {
|
||||
padding-top: 0;
|
||||
min-height: calc(100vh - 80px);
|
||||
}
|
||||
.offer-form {
|
||||
|
|
@ -962,7 +918,7 @@
|
|||
font-size: 34px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
|
||||
.engagements__wrapper {
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -1023,30 +979,8 @@
|
|||
border: 1px solid #a0a0a0;
|
||||
}
|
||||
.image-border-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.textblock__container_swap > .image-content-size img {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
.textblock__container_side > .image-content-size img {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
.textblock__container_row > .image-content-size {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: 210px !important;
|
||||
}
|
||||
|
||||
.image-content-size img {
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
.list-item__title {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.9 KiB |
BIN
public/assets/img/logo_en.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/news/26_01_07/img/header.jpg
Normal file
|
After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 730 KiB |
2
public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<svg width="294" height="337" viewBox="0 0 294 337" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.4228 111.776C14.6117 110.408 25.7752 110.685 28.852 111.103C37.1063 112.231 64.4775 121.512 69.5848 127.591C70.4727 128.638 72.0427 131.006 71.9117 132.281C71.2439 133.656 70.4943 135.273 69.007 135.8C64.0032 137.596 39.3983 133.232 33.1303 131.412C28.0684 129.948 0.834785 119.02 12.4179 111.762" fill="white"/>
|
||||
<path d="M2.97139 193.412C4.55436 191.287 15.073 187.284 18.0841 186.505C26.1789 184.414 54.8679 182.618 61.7042 186.336C62.8749 186.981 65.1454 188.588 65.4632 189.813C65.3042 191.356 65.1572 193.152 63.9439 194.213C59.8699 197.795 35.4282 203.133 28.956 203.822C23.7339 204.385 -5.39998 204.59 2.96651 193.398" fill="white"/>
|
||||
<path d="M75.2995 40.5103C77.803 40.9766 86.0923 48.8901 88.1699 51.3401C93.7375 57.8946 108.805 83.7796 108.956 91.9089C108.984 93.2947 108.718 96.178 107.854 97.0673C106.509 97.6501 104.936 98.3556 103.492 97.741C98.5929 95.6417 82.6162 75.3846 78.9803 69.6842C76.0412 65.0878 62.0826 38.0233 75.3044 40.5244" fill="white"/>
|
||||
<path d="M151.519 3.32306C153.617 4.87176 158.163 15.7889 159.131 18.9375C161.715 27.4087 165.61 57.8236 162.682 65.3407C162.179 66.6428 160.86 69.1607 159.724 69.5796C158.289 69.5181 156.593 69.479 155.501 68.2594C151.841 64.1709 144.975 38.5187 143.82 31.6839C142.897 26.167 140.429 -4.81499 151.519 3.32306Z" fill="white"/>
|
||||
<path d="M252.66 22.1829C253.318 24.7802 250.104 36.0545 248.91 39.068C245.694 47.1654 230.038 72.5666 223.176 76.188C221.998 76.8158 219.438 77.7851 218.31 77.3393C217.248 76.3078 215.978 75.104 215.89 73.4619C215.601 67.8917 226.072 44.0237 229.37 38.114C232.047 33.3611 249.15 8.50266 252.647 22.1881" fill="white"/>
|
||||
<path d="M87.8419 179.142C94.5684 132.863 133.992 97.2846 176.139 87.677C204.358 81.0745 234.752 88.5554 256.665 106.934C312.841 154.252 315.672 242.841 251.639 287.943L251.319 288.16C299.285 254.332 309.052 191.702 275.906 144.766C245.4 100.22 190.312 89.7794 145.72 121.366C124.838 135.187 108.355 156.232 103.18 181.721C91.9838 233.582 124.292 292.602 174.098 304.765C197.844 311.099 222.819 304.456 244.572 292.265C222.667 304.706 197.998 312.303 173.514 307.368C119.264 297.745 78.7607 237.682 87.8419 179.142ZM244.614 292.242C246.465 291.19 248.298 290.104 250.109 288.987L251.319 288.16C250.92 288.442 250.518 288.723 250.113 289.001C248.305 290.119 246.471 291.201 244.614 292.242Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="108" height="108" viewBox="0 0 108 108" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M104.492 48.4181C100.67 60.1225 94.7398 70.9442 87.3675 79.8302C85.9257 81.5634 84.4302 83.2612 82.8803 84.8228C80.7646 87.2306 78.414 89.3922 75.9016 91.3468C59.3762 104.228 36.3246 107.963 36.3246 107.963C33.6282 107.52 26.4674 97.2729 20.5617 88.0457C17.9584 84.0773 15.4174 79.9953 13.0357 75.8182C12.2143 74.429 11.7321 73.5823 11.7072 73.5022C11.7072 73.5022 11.7118 73.4779 11.7165 73.4536C8.2699 67.162 5.23377 60.6225 2.93227 53.7217C2.81789 53.373 2.70352 53.0245 2.58914 52.6758C0.811171 46.9311 -0.135125 40.2153 0.0156226 33.5598C0.324677 19.3706 5.5941 5.38113 18.3648 1.12661C26.1608 -1.47079 34.3677 0.609583 40.7529 5.4558C43.6869 7.70355 49.819 17.326 51.2083 20.5835C53.1412 23.845 54.7225 26.8379 56.0006 29.5212C60.5848 23.9191 65.635 18.9342 71.0452 14.697C77.309 9.79469 84.3595 4.36504 91.5366 5.09145C98.0241 5.73554 102.877 10.6893 105.536 17.4075C107.259 21.7596 108.061 26.8141 107.816 31.9688C107.55 37.5467 106.221 43.0962 104.492 48.4181Z" fill="#E00A1E"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
|
@ -1,5 +1,5 @@
|
|||
// * Common css for @tiptap editor preview.
|
||||
@use "./../../common/wysiwyg";
|
||||
@use './../../common/wysiwyg';
|
||||
|
||||
// * Custom css.
|
||||
.wysiwyg-error .reactjs-tiptap-editor {
|
||||
|
|
@ -16,31 +16,18 @@
|
|||
@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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,50 +8,3 @@
|
|||
|
||||
// * BASE
|
||||
@use './base/common';
|
||||
|
||||
|
||||
//////////////////// Poppins ////////////////////
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('&/fonts/Poppins/Poppins-Regular.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('&/fonts/Poppins/Poppins-Bold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('&/fonts/Poppins/Poppins-Light.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Poppins';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
src: url('&/fonts/Poppins/Poppins-BoldItalic.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Poppins';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
src: url('&/fonts/Poppins/Poppins-Italic.ttf') format('truetype');
|
||||
}
|
||||
|
||||
.f-poppins {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,43 +13,3 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
#r_captcha {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.error-recaptcha {
|
||||
color: red;
|
||||
text-align: center;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.custom-alert-danger {
|
||||
background: #ffe5e5;
|
||||
border: 1px solid #ffb3b3;
|
||||
border-left: 5px solid #dc3545;
|
||||
color: #842029;
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
|
||||
.alert-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 5px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ 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 = {
|
||||
|
|
@ -61,8 +59,7 @@ export default function DynamicInputs({
|
|||
.enumDynamicFieldTypes as DynamicFieldTypes;
|
||||
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");
|
||||
|
|
@ -287,13 +284,10 @@ export default function DynamicInputs({
|
|||
// VimeoId input
|
||||
case enumDynamicFieldTypes.vimeoid.value:
|
||||
return (
|
||||
<VideoInput
|
||||
<SimpleVimeoId
|
||||
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,
|
||||
)}
|
||||
|
|
@ -302,13 +296,10 @@ export default function DynamicInputs({
|
|||
// YoutubeId input
|
||||
case enumDynamicFieldTypes.youtubeid.value:
|
||||
return (
|
||||
<VideoInput
|
||||
<SimpleYoutubeId
|
||||
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,
|
||||
)}
|
||||
|
|
@ -378,33 +369,6 @@ export default function DynamicInputs({
|
|||
)}
|
||||
/>
|
||||
);
|
||||
case enumDynamicFieldTypes.contactform.value:
|
||||
return (
|
||||
<EnumSelect
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
placeholder="Formulaire de contact à associer"
|
||||
selected={fieldValue as number}
|
||||
options={enumContactFormType}
|
||||
setData={setData}
|
||||
required={extractRequiredValidation(
|
||||
field.validation,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
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,13 +8,12 @@ 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 | VideoAttributes;
|
||||
fieldValue: string | number | boolean | File | Date;
|
||||
content: DynamicInternalField | null;
|
||||
setData: (
|
||||
name: string,
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export default function EnumSelect({
|
|||
|
||||
/**
|
||||
* Get the identification field of specific option.
|
||||
* @param value number
|
||||
* @param option Model
|
||||
* @return string
|
||||
*/
|
||||
const getEnumLabel = (value: number): string => {
|
||||
|
|
@ -165,7 +165,7 @@ export default function EnumSelect({
|
|||
)}
|
||||
>
|
||||
{selectedEnum
|
||||
? str(getEnumLabel(selectedEnum)).ucFirst().value()
|
||||
? getEnumLabel(selectedEnum)
|
||||
: placeholder}
|
||||
<ChevronsUpDownIcon className="opacity-50" />
|
||||
</Button>
|
||||
|
|
@ -221,7 +221,7 @@ export default function EnumSelect({
|
|||
handleSelect(option.value)
|
||||
}
|
||||
>
|
||||
<span>{str(option.label).ucFirst().value()}</span>
|
||||
<span>{option.label}</span>
|
||||
{selectedEnum === option.value && (
|
||||
<CheckIcon />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,155 +0,0 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import {
|
||||
ChangeEvent,
|
||||
MouseEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type Props = {
|
||||
width: number | string;
|
||||
height: number;
|
||||
|
||||
className?: string;
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
id?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
parentErrors?: Record<string, string> | null;
|
||||
setData: (name: string, value: string) => void;
|
||||
};
|
||||
|
||||
export default function SimpleVideoUpload(props: Props) {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
name,
|
||||
label,
|
||||
id,
|
||||
required,
|
||||
disabled,
|
||||
parentErrors,
|
||||
setData,
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { errors } = usePage<{ errors: Record<string, string> }>().props;
|
||||
const localErrors = parentErrors ?? errors ?? {};
|
||||
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
setSource(url);
|
||||
setFileName(file.name);
|
||||
|
||||
// Important pour Inertia
|
||||
setData(name, file as unknown as string);
|
||||
};
|
||||
|
||||
const handleChoose = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
// Nettoyage mémoire
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (source) {
|
||||
URL.revokeObjectURL(source);
|
||||
}
|
||||
};
|
||||
}, [source]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 w-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Label htmlFor={name} className="font-medium">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
|
||||
{/* Input caché */}
|
||||
<Input
|
||||
id={id}
|
||||
name={name}
|
||||
type="file"
|
||||
accept=".mov,.mp4"
|
||||
ref={inputRef}
|
||||
onChange={handleFileChange}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Zone preview */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative text-sm flex justify-center border border-dashed overflow-hidden whitespace-normal [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-36",
|
||||
{
|
||||
"border-destructive": localErrors[name],
|
||||
"cursor-pointer": !disabled,
|
||||
}
|
||||
)}
|
||||
style={{ width }}
|
||||
onClick={!disabled ? handleChoose : undefined}
|
||||
>
|
||||
{!source ? (
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<UploadIcon />
|
||||
<p className="text-center break-normal w-full mt-2">
|
||||
Cliquer pour sélectionner une vidéo
|
||||
</p>
|
||||
<small>
|
||||
Format accepté : .mp4, .mov
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-full items-center">
|
||||
<video
|
||||
className="rounded-md shadow-md max-w-full object-contain"
|
||||
style={{ maxHeight: height }}
|
||||
controls
|
||||
src={source}
|
||||
/>
|
||||
|
||||
<div className="text-sm text-muted-foreground text-center break-all">
|
||||
{fileName}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleChoose}
|
||||
className="text-xs px-3 py-1 rounded-md bg-primary text-primary-foreground hover:opacity-90 transition"
|
||||
>
|
||||
Changer la vidéo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Erreur */}
|
||||
{localErrors[name] && (
|
||||
<p className="text-sm text-destructive">
|
||||
{localErrors[name]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,609 +0,0 @@
|
|||
// * 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,147 +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";
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const videoDetails = {
|
||||
width: "1008",
|
||||
height: "567",
|
||||
minDim: "100",
|
||||
maxDim: "1008",
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract YouTube video ID from a URL or a raw ID.
|
||||
*/
|
||||
export const extractYoutubeId = (input: string): string | null => {
|
||||
const trimmed = input.trim();
|
||||
// Direct ID (11 chars)
|
||||
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed;
|
||||
// youtube.com/watch?v=ID
|
||||
const watchMatch = trimmed.match(/[?&]v=([a-zA-Z0-9_-]{11})/);
|
||||
if (watchMatch) return watchMatch[1];
|
||||
// youtu.be/ID
|
||||
const shortMatch = trimmed.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/);
|
||||
if (shortMatch) return shortMatch[1];
|
||||
// youtube.com/embed/ID
|
||||
const embedMatch = trimmed.match(/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/);
|
||||
if (embedMatch) return embedMatch[1];
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract Vimeo video ID from a URL or a raw ID.
|
||||
*/
|
||||
export const extractVimeoId = (input: string): string | null => {
|
||||
const trimmed = input.trim();
|
||||
// Direct numeric ID
|
||||
if (/^\d+$/.test(trimmed)) return trimmed;
|
||||
// vimeo.com/ID or vimeo.com/video/ID
|
||||
const match = trimmed.match(/vimeo\.com\/(?:video\/)?(\d+)/);
|
||||
if (match) return match[1];
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the iframe src URL for YouTube or Vimeo with options.
|
||||
*/
|
||||
export const buildVideoSrc = (attrs: {
|
||||
videoId?: string | null;
|
||||
isMuted?: boolean;
|
||||
hasControls?: boolean;
|
||||
provider: number;
|
||||
}): string => {
|
||||
const params = new URLSearchParams();
|
||||
const query = params.toString();
|
||||
if (attrs.provider == 1) {
|
||||
if (attrs.isMuted) params.set("mute", "1");
|
||||
params.set("controls", attrs.hasControls ? "1" : "0");
|
||||
return `https://www.youtube.com/embed/${attrs.videoId}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
if (attrs.provider == 2) {
|
||||
if (attrs.isMuted) params.set("muted", "1");
|
||||
if (!attrs.hasControls) params.set("controls", "0");
|
||||
return `https://player.vimeo.com/video/${attrs.videoId}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Zod schema for video caption input.
|
||||
*/
|
||||
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)
|
||||
.max(255, transValidationCaption.max);
|
||||
|
||||
const isBool = z.boolean({ message: transValidationCaption.boolean });
|
||||
|
||||
return z.object({
|
||||
caption: hasCaption ? captionRules : z.null(),
|
||||
isMuted: isBool,
|
||||
hasControls: isBool,
|
||||
canFullScreen: isBool,
|
||||
alt: altRules
|
||||
});
|
||||
};
|
||||
|
||||
export const isBoolsSchema = (transValidation: { isMuted: string; hasControls: string; canFullScreen: string }) => {
|
||||
const isMuted = z.boolean({ message: transValidation.isMuted });
|
||||
const hasControls = z.boolean({ message: transValidation.hasControls });
|
||||
const canFullScreen = z.boolean({ message: transValidation.canFullScreen });
|
||||
|
||||
return z.object({
|
||||
isMuted: isMuted,
|
||||
hasControls: hasControls,
|
||||
canFullScreen: canFullScreen,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
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;
|
||||
|
|
@ -8,13 +8,6 @@ import ImageNodeView, {
|
|||
} from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageNodeView";
|
||||
import { imageDetails } from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageUtils";
|
||||
|
||||
interface AttrsType {
|
||||
src: string;
|
||||
caption?: string;
|
||||
float?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const Image = TiptapImage.extend({
|
||||
// Define attributes we need.
|
||||
addAttributes() {
|
||||
|
|
@ -25,9 +18,9 @@ const Image = TiptapImage.extend({
|
|||
alt: null,
|
||||
caption: null,
|
||||
style: null,
|
||||
float: null,
|
||||
};
|
||||
},
|
||||
|
||||
// Rules for each tag.
|
||||
// Set all attributes if matching on simple image or figure structure.
|
||||
// Use for render html in @tiptap editor.
|
||||
|
|
@ -41,6 +34,7 @@ const Image = TiptapImage.extend({
|
|||
"img",
|
||||
) as HTMLImageElement;
|
||||
const figcaption = element.querySelector("figcaption");
|
||||
|
||||
return {
|
||||
src: img.src,
|
||||
alt: img.getAttribute("alt"),
|
||||
|
|
@ -66,10 +60,7 @@ const Image = TiptapImage.extend({
|
|||
// Define the structure of the render.
|
||||
// Use for render html in front files.
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const attrsCopy: AttrsType = {
|
||||
...HTMLAttributes,
|
||||
src: HTMLAttributes.src.replace(/^https?:\/\/[^/]+/, ""),
|
||||
};
|
||||
const attrsCopy = { ...HTMLAttributes };
|
||||
|
||||
delete attrsCopy.caption;
|
||||
delete attrsCopy.float;
|
||||
|
|
|
|||
|
|
@ -40,8 +40,6 @@ import useTrans from "@/hooks/use-translations";
|
|||
import str from "@/hooks/use-string";
|
||||
import { cn, GetTranslation } from "@/lib/utils";
|
||||
import { parseStyleString } from "@/hooks/use-inline-style";
|
||||
import { AppEnv } from "@/back/types/global";
|
||||
import { usePage } from "@inertiajs/react";
|
||||
|
||||
export type AllowedAligns = "left" | "center" | "right";
|
||||
|
||||
|
|
@ -93,8 +91,8 @@ type ImageAttrs = {
|
|||
|
||||
export default function ImageNodeView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const { url } = usePage().props.appEnv as AppEnv;
|
||||
const attrs = node.attrs as ImageAttrs;
|
||||
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const resizeState = useRef<{
|
||||
startX: number;
|
||||
|
|
@ -421,7 +419,7 @@ export default function ImageNodeView(props: NodeViewProps) {
|
|||
)}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={attrs.src.replace(url, "")}
|
||||
src={attrs.src}
|
||||
alt={attrs.alt}
|
||||
width={attrs.width}
|
||||
height={attrs.height}
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
import { Node } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { buildVideoSrc } from "../../../Video/VideoUtils";
|
||||
import VideoNodeView, { FLOAT_STYLES, FloatOption } from "./VideoNodeView";
|
||||
import { CommandProps } from '@tiptap/core';
|
||||
import { VideoAttributes } from '../../../Video/VideoButton';
|
||||
|
||||
export const Video = Node.create({
|
||||
name: "video",
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
videoId: { default: null },
|
||||
width: { default: 1008 },
|
||||
height: { default: 567 },
|
||||
caption: { default: null },
|
||||
style: { default: null },
|
||||
float: { default: null },
|
||||
isMuted: { default: false },
|
||||
hasControls: { default: true },
|
||||
canFullScreen: { default: true },
|
||||
format: { default: "landscape" },
|
||||
hasCaption: { default: false },
|
||||
alt: { default: "" },
|
||||
provider: { default : 1 }
|
||||
};
|
||||
},
|
||||
|
||||
// Parse <figure><iframe .../></figure> back into the node.
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "figure:has(iframe)",
|
||||
getAttrs: (element: HTMLElement) => {
|
||||
const iframe = element.querySelector("iframe") as HTMLIFrameElement;
|
||||
const figcaption = element.querySelector("figcaption");
|
||||
const src = iframe.getAttribute("src") || "";
|
||||
|
||||
const youtubeMatch = src.match(/youtube\.com\/embed\/([a-zA-Z0-9_-]+)/);
|
||||
const vimeoMatch = src.match(/vimeo\.com\/video\/(\d+)/);
|
||||
|
||||
return {
|
||||
videoId: youtubeMatch?.[1] ?? vimeoMatch?.[1] ?? null,
|
||||
width: parseInt(iframe.getAttribute("width") || "1008"),
|
||||
height: parseInt(iframe.getAttribute("height") || "567"),
|
||||
caption: figcaption?.textContent ?? null,
|
||||
isMuted: src.includes("mute=1") || src.includes("muted=1"),
|
||||
hasControls: !src.includes("controls=0"),
|
||||
canFullScreen: iframe.hasAttribute("allowfullscreen"),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Render the node to HTML (used for storage and front-end display).
|
||||
renderHTML({ node }) {
|
||||
const {
|
||||
videoId,
|
||||
provider,
|
||||
width,
|
||||
height,
|
||||
caption,
|
||||
style,
|
||||
float,
|
||||
isMuted,
|
||||
hasControls,
|
||||
canFullScreen,
|
||||
} = node.attrs as VideoAttributes;
|
||||
|
||||
const src = buildVideoSrc({ videoId, isMuted, hasControls, provider });
|
||||
|
||||
const floatStyle = float ? FLOAT_STYLES[float as FloatOption] : null;
|
||||
const alignStyle = !float && style ? style : null;
|
||||
const finalStyle = [alignStyle, floatStyle].filter(Boolean).join(" ");
|
||||
|
||||
const iframeAttrs: Record<string, string | number> = {
|
||||
src,
|
||||
width: width || 1008,
|
||||
height: height || 567,
|
||||
frameborder: "0",
|
||||
allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
|
||||
};
|
||||
|
||||
if (canFullScreen) {
|
||||
iframeAttrs.allowfullscreen = "true";
|
||||
}
|
||||
|
||||
const figureAttrs: Record<string, string> = {};
|
||||
if (finalStyle) figureAttrs.style = finalStyle;
|
||||
|
||||
if (caption) {
|
||||
return ["figure", figureAttrs, ["iframe", iframeAttrs], ["figcaption", {}, caption]];
|
||||
}
|
||||
|
||||
return ["figure", figureAttrs, ["iframe", iframeAttrs]];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(VideoNodeView);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setVideo:
|
||||
(attrs: VideoAttributes) =>
|
||||
({ commands }: CommandProps) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs,
|
||||
});
|
||||
},
|
||||
unsetVideo:
|
||||
() =>
|
||||
({ commands }: CommandProps) => {
|
||||
return commands.deleteSelection();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
// * Packages
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import {
|
||||
MoveDiagonal2Icon,
|
||||
AlignLeftIcon,
|
||||
AlignCenterIcon,
|
||||
AlignRightIcon,
|
||||
ArrowLeftRightIcon,
|
||||
ArrowRightLeftIcon,
|
||||
LayoutIcon,
|
||||
PencilIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// * Components shadcn
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
// * Other
|
||||
import {
|
||||
buildVideoSrc,
|
||||
videoDetails,
|
||||
} 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/Video/VideoInputs";
|
||||
import { InputVideoValues, VideoAttributes } from "../../../Video/VideoButton";
|
||||
|
||||
export type AllowedAligns = "left" | "center" | "right";
|
||||
|
||||
export type FloatOption = "left" | "right";
|
||||
|
||||
export const ALIGN_STYLES: Record<AllowedAligns, string> = {
|
||||
left: "margin: 0 auto 0 0; width: fit-content;",
|
||||
center: "margin: 0 auto 0 auto; width: fit-content;",
|
||||
right: "margin: 0 0 0 auto; width: fit-content;",
|
||||
};
|
||||
|
||||
export const FLOAT_STYLES: Record<FloatOption, string> = {
|
||||
left: "float: left; margin: 0 1rem 0 0;",
|
||||
right: "float: right; margin: 0 0 0 1rem;",
|
||||
};
|
||||
|
||||
type PlacementValue =
|
||||
| "align-left"
|
||||
| "align-center"
|
||||
| "align-right"
|
||||
| "float-left"
|
||||
| "float-right";
|
||||
|
||||
const PLACEMENT_OPTIONS: PlacementValue[] = [
|
||||
"align-left",
|
||||
"align-center",
|
||||
"align-right",
|
||||
"float-left",
|
||||
"float-right",
|
||||
];
|
||||
|
||||
const PLACEMENT_STYLES: Record<PlacementValue, string> = {
|
||||
"align-left": ALIGN_STYLES.left,
|
||||
"align-center": ALIGN_STYLES.center,
|
||||
"align-right": ALIGN_STYLES.right,
|
||||
"float-left": FLOAT_STYLES.left,
|
||||
"float-right": FLOAT_STYLES.right,
|
||||
};
|
||||
|
||||
|
||||
export default function VideoNodeView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const attrs = node.attrs as VideoAttributes;
|
||||
|
||||
// Wrapper div used to measure size for resize
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const resizeState = useRef<{ startX: number; startWidth: number; ratio: number } | null>(null);
|
||||
const minSize = Number(videoDetails.minDim);
|
||||
const maxSize = Number(videoDetails.maxDim);
|
||||
const [topSticky, setTopSticky] = useState<number>(0);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValues, setInputValues] = useState<InputVideoValues>({
|
||||
caption: attrs.caption,
|
||||
hasCaption: !!attrs.caption,
|
||||
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({
|
||||
videoId: attrs.videoId,
|
||||
isMuted: attrs.isMuted,
|
||||
hasControls: attrs.hasControls,
|
||||
provider: attrs.provider
|
||||
});
|
||||
|
||||
const getCurrentPlacement = (): PlacementValue | null => {
|
||||
if (attrs.float === "left") return "float-left";
|
||||
if (attrs.float === "right") return "float-right";
|
||||
if (attrs.style?.includes(ALIGN_STYLES.left)) return "align-left";
|
||||
if (attrs.style?.includes(ALIGN_STYLES.center)) return "align-center";
|
||||
if (attrs.style?.includes(ALIGN_STYLES.right)) return "align-right";
|
||||
return null;
|
||||
};
|
||||
|
||||
const handlePlacementChange = (value: PlacementValue) => {
|
||||
updateAttributes({
|
||||
style: PLACEMENT_STYLES[value],
|
||||
float: value.startsWith("float-") ? (value.split("-")[1] as FloatOption) : null,
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseDown = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
resizeState.current = {
|
||||
startX: event.clientX,
|
||||
startWidth: wrapper.offsetWidth,
|
||||
ratio: (attrs.width || Number(videoDetails.width)) / (attrs.height || Number(videoDetails.height)),
|
||||
};
|
||||
setIsResizing(true);
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (!isResizing || !resizeState.current) return;
|
||||
const { startX, startWidth, ratio } = resizeState.current;
|
||||
let newWidth = startWidth + (event.clientX - startX);
|
||||
let newHeight = newWidth / ratio;
|
||||
if (newWidth < minSize) { newWidth = minSize; newHeight = newWidth / ratio; }
|
||||
if (newHeight < minSize) { newHeight = minSize; newWidth = newHeight * ratio; }
|
||||
if (newWidth > maxSize) { newWidth = maxSize; newHeight = newWidth / ratio; }
|
||||
if (newHeight > maxSize) { newHeight = maxSize; newWidth = newHeight * ratio; }
|
||||
updateAttributes({ width: Math.round(newWidth), height: Math.round(newHeight) });
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const currentHeaderHeight =
|
||||
document.querySelector("header")?.clientHeight ?? 0;
|
||||
const currentToolbarHeight =
|
||||
document.querySelectorAll(".tiptap-toolbar")[0].clientHeight ??
|
||||
0;
|
||||
setTopSticky(currentHeaderHeight + currentToolbarHeight);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
handleResize();
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const onMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
resizeState.current = null;
|
||||
};
|
||||
|
||||
const saveDialog = () => {
|
||||
updateAttributes({
|
||||
caption: inputValues.hasCaption ? inputValues.caption : null,
|
||||
isMuted: inputValues.isMuted,
|
||||
hasControls: inputValues.hasControls,
|
||||
canFullScreen: inputValues.canFullScreen,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isResizing]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
ref={wrapperRef}
|
||||
as="figure"
|
||||
data-type="video"
|
||||
style={parseStyleString(attrs.style)}
|
||||
className={cn("relative w-fit", {
|
||||
"outline outline-2 outline-dashed": selected,
|
||||
})}
|
||||
>
|
||||
{selected && (
|
||||
<div
|
||||
className="sticky"
|
||||
style={{
|
||||
top: topSticky,
|
||||
}}
|
||||
>
|
||||
<span className="absolute top-2 left-2 bg-background backdrop-blur-sm rounded-md border shadow-md m-0 p-1">
|
||||
{`${attrs.width || videoDetails.width}x${attrs.height || videoDetails.height}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{
|
||||
width: attrs.width || Number(videoDetails.width),
|
||||
height: attrs.height || Number(videoDetails.height),
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={src}
|
||||
width={attrs.width || Number(videoDetails.width)}
|
||||
height={attrs.height || Number(videoDetails.height)}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen={attrs.canFullScreen}
|
||||
title={attrs.caption || "video"}
|
||||
// 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 && (
|
||||
<div className="absolute bottom-2 right-2 flex flex-col gap-1 p-1 bg-background/90 backdrop-blur-sm rounded-md border shadow-md">
|
||||
{/* Placement dropdown */}
|
||||
<Select
|
||||
value={getCurrentPlacement() || undefined}
|
||||
onValueChange={handlePlacementChange}
|
||||
>
|
||||
<SelectTrigger className="w-10 h-10 hover:bg-accent hover:text-accent-foreground p-0">
|
||||
<LayoutIcon className="w-4 h-4 mx-auto" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{PLACEMENT_OPTIONS.map((value) => {
|
||||
const [type, dir] = value.split("-") as ["align" | "float", string];
|
||||
const label = type === "align" ? `Align ${dir}` : `Wrap ${dir}`;
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{value === "align-left" && <AlignLeftIcon className="w-4 h-4" />}
|
||||
{value === "align-center" && <AlignCenterIcon className="w-4 h-4" />}
|
||||
{value === "align-right" && <AlignRightIcon className="w-4 h-4" />}
|
||||
{value === "float-left" && <ArrowLeftRightIcon className="w-4 h-4" />}
|
||||
{value === "float-right" && <ArrowRightLeftIcon className="w-4 h-4" />}
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Edit button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
{/* Resize button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onMouseDown={onMouseDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn("cursor-nw-resize", {
|
||||
"bg-accent text-accent-foreground": isResizing,
|
||||
})}
|
||||
>
|
||||
<MoveDiagonal2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{attrs.caption && <figcaption>{attrs.caption}</figcaption>}
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="p-4">
|
||||
<DialogHeader className="px-2 pt-2">
|
||||
<DialogTitle>
|
||||
{useTrans("back.input_video.edit_title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{useTrans("back.input_video.edit_subtitle")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto max-h-[70vh] flex flex-col gap-4 px-2">
|
||||
<VideoInputs
|
||||
value={inputValues}
|
||||
onChange={setInputValues}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-1.5 px-2 pb-2">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
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.input_video.edit_apply")}
|
||||
tooltipContent={str(useTrans("back.input_video.tooltip.edit_apply")).ucFirst().value()}
|
||||
onClick={saveDialog}
|
||||
type="button"
|
||||
variant="default"
|
||||
size="default"
|
||||
/>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
// * Packages
|
||||
import "@tiptap/core";
|
||||
import { VideoAttributes } from "../../../Video/VideoButton";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
Video: {
|
||||
setVideo: (attrs: VideoAttributes) => ReturnType;
|
||||
unsetVideo: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,6 @@ import { Label } from "@/components/ui/label";
|
|||
import ShortcutBoundary from "@/back/Components/Inputs/Wysiwyg/Extensions/ShortCuts/ShortcutBoundary";
|
||||
import { Link } from "@/back/Components/Inputs/Wysiwyg/Extensions/Link/Link";
|
||||
import Image from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/Image";
|
||||
import {Video} from "@/back/Components/Inputs/Wysiwyg/Extensions/Video/Video";
|
||||
import InputError from "@/back/Components/InputError";
|
||||
import WordCounter from "@/back/Components/WordCounter";
|
||||
import Toolbar from "@/back/Components/Inputs/Wysiwyg/Toolbar";
|
||||
|
|
@ -71,15 +70,8 @@ export default function SimpleWysiwyg({
|
|||
const stripHtmlTags = (html: string): string | null => {
|
||||
const temp = document.createElement("div");
|
||||
temp.innerHTML = html;
|
||||
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;
|
||||
const text = temp.textContent || temp.innerText || "";
|
||||
return text.trim() === "" ? null : text;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -126,7 +118,6 @@ export default function SimpleWysiwyg({
|
|||
}),
|
||||
Image,
|
||||
Link,
|
||||
Video,
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ 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 "../Video/VideoButton";
|
||||
|
||||
type AllowedHeadings = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
|
|
@ -436,18 +435,6 @@ export default function Toolbar({ editor }: ToolbarProps) {
|
|||
<TableButton editor={editor} disabled={docSelected} />
|
||||
<ImageButton editor={editor} disabled={docSelected} />
|
||||
<LinkButton 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" />}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export default function AppSidebar() {
|
|||
choice: 2,
|
||||
});
|
||||
const transAdmin = useTrans("enums.user-role.admin");
|
||||
const transUsers = useTrans("models.classes.user", { choice: 2 });
|
||||
const transUsers = useTrans("models.classes.user");
|
||||
const transActivityLogs = useTrans("models.classes.activity-log");
|
||||
const transPageConfigs = useTrans("models.classes.page-config", {
|
||||
choice: 2,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
// * Other
|
||||
import { DefaultModel, Resource, Views } from "@/back/types/global";
|
||||
import { Model, Resource, Views } from "@/back/types/global";
|
||||
import ModelActions from "@/back/Components/ModelActions";
|
||||
import DataTableFilterButton from "@/back/Components/Table/DataTableFilterButton";
|
||||
import PaginationWrapper from "@/back/Components/Pagination/PaginationWrapper";
|
||||
|
|
@ -102,9 +102,7 @@ export default function DataTable<TData, TValue>({
|
|||
<ModelActions
|
||||
actions={actions}
|
||||
modelRoute={modelRoute}
|
||||
model={
|
||||
row.original as DefaultModel
|
||||
}
|
||||
model={row.original as Model}
|
||||
inDialog
|
||||
/>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -11,16 +11,16 @@ import {
|
|||
} from "@/components/ui/table";
|
||||
// * Other
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import { DefaultModel } from "@/back/types/global";
|
||||
import { Model } from "@/back/types/global";
|
||||
|
||||
interface DetailsTableProps {
|
||||
columns: {
|
||||
accessorKey: keyof DefaultModel;
|
||||
accessorKey: keyof Model;
|
||||
header: string;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
cell?: (params: { value: unknown }) => ReactNode;
|
||||
}[];
|
||||
model: DefaultModel;
|
||||
model: Model;
|
||||
forceRenderValue?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||