Compare commits
No commits in common. "addBanner" and "documentations" have entirely different histories.
addBanner
...
documentat
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,11 +29,9 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -49,16 +49,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,9 +25,8 @@ enum DynamicFieldTypes: int
|
|||
case color = 14;
|
||||
case model = 15;
|
||||
case staticroute = 16;
|
||||
case video = 17;
|
||||
|
||||
// Specific inputs.
|
||||
// Specific inputs.
|
||||
case position = 101;
|
||||
case contactform = 102;
|
||||
|
||||
|
|
@ -53,7 +52,6 @@ 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',
|
||||
|
|
@ -74,13 +72,12 @@ 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',
|
||||
|
|
@ -96,34 +93,4 @@ enum DynamicFieldTypes: int
|
|||
{
|
||||
return self::VALIDATIONS[$this->name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Is array definition.
|
||||
*
|
||||
* @return array
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private const ISARRAY = [
|
||||
self::string->name => false,
|
||||
self::textarea->name => false,
|
||||
self::wysiwyg->name => false,
|
||||
self::number->name => false,
|
||||
self::boolean->name => false,
|
||||
self::url->name => false,
|
||||
self::email->name => false,
|
||||
self::image->name => false,
|
||||
self::list->name => true,
|
||||
self::file->name => false,
|
||||
self::vimeoid->name => false,
|
||||
self::youtubeid->name => false,
|
||||
self::date->name => false,
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,15 +20,4 @@ enum VideoProviderEnum: int
|
|||
self::youtube->name => 'youtube',
|
||||
self::vimeo->name => 'viméo',
|
||||
];
|
||||
|
||||
/**
|
||||
* Is array definition.
|
||||
*
|
||||
* @return array
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private const COLOR = [
|
||||
self::youtube->name => "#FF0000",
|
||||
self::vimeo->name => "#00B2EA",
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class TranslationHelper
|
|||
->whereKeyNot($model->getKey())
|
||||
->whereRaw("JSON_SEARCH(`{$dbAttribute}`, 'one', ?) IS NOT NULL", [$value]);
|
||||
|
||||
if ($anotherDbAttribute !== null) {
|
||||
if ($anotherDbAttribute !== null && $anotherValue !== null) {
|
||||
$query->where($anotherDbAttribute, $anotherValue);
|
||||
}
|
||||
if ($query->exists()) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ 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;
|
||||
|
|
@ -211,11 +210,6 @@ abstract class UpdateDynamicFieldRequest extends FormRequest
|
|||
),
|
||||
]);
|
||||
break;
|
||||
case DynamicFieldTypes::video->value:
|
||||
$rules = array_merge($rules, [
|
||||
$ruleKey => new Video(),
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
throw new \UnexpectedValueException("Unrecognized dynamic type: " . ($input['type'] ?? 'null'));
|
||||
} //end switch
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class ContactRequest extends FormRequest
|
|||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
ToolboxHelper::validateRecaptcha($this, \config('services.google.recaptcha.version'));
|
||||
// ToolboxHelper::validateRecaptcha($this, \config('services.google.recaptcha.version'));
|
||||
|
||||
$typeContactForm = $this->input('enumType');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -388,7 +387,7 @@ class Document extends Model implements TranslatableModel
|
|||
'documentables',
|
||||
'document_id',
|
||||
'documentable_id'
|
||||
)->using(Documentable::class)->withTimestamps();
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -404,6 +403,6 @@ class Document extends Model implements TranslatableModel
|
|||
'documentables',
|
||||
'document_id',
|
||||
'documentable_id'
|
||||
)->using(Documentable::class)->withTimestamps();
|
||||
)->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -19,25 +19,36 @@ 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.
|
||||
* @property string $title Title of the dynamic page.
|
||||
* @property string $slug Slug of the dynamic page.
|
||||
* @property string $content Content of the dynamic page.
|
||||
* @property integer|null $config_id Config associated to the dynamic page.
|
||||
* @property integer|null $page_id Page associated to the dynamic page.
|
||||
* @property \App\Enums\DynamicPages\PageStatusEnum $status Status of the dynamic page.
|
||||
* @property boolean $isLocked Locked status of dynamic page.
|
||||
* @property boolean $isRoot Root status of dynamic page.
|
||||
* @property-read \Illuminate\Support\Carbon $created_at Date on which the dynamic page was created.
|
||||
* @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.
|
||||
* @property string $id_key
|
||||
* Unique key identification of the dynamic page.
|
||||
* @property string $title
|
||||
* Title of the dynamic page.
|
||||
* @property string $slug
|
||||
* Slug of the dynamic page.
|
||||
* @property string $content
|
||||
* Content of the dynamic page.
|
||||
* @property integer|null $config_id
|
||||
* Config associated to the dynamic page.
|
||||
* @property integer|null $page_id
|
||||
* Page associated to the dynamic page.
|
||||
* @property \App\Enums\DynamicPages\PageStatusEnum $status
|
||||
* Status of the dynamic page.
|
||||
* @property boolean $isLocked
|
||||
* Locked status of dynamic page.
|
||||
* @property boolean $isRoot
|
||||
* Root status of dynamic page.
|
||||
* @property-read \Illuminate\Support\Carbon $created_at
|
||||
* Date on which the dynamic page was created.
|
||||
* @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.
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, \App\Models\DynamicPage> $childPages
|
||||
* Associated dynamic pages of the current page.
|
||||
*/
|
||||
class DynamicPage extends Model implements TranslatableModel, Sitemapable
|
||||
class DynamicPage extends Model implements TranslatableModel
|
||||
{
|
||||
use ActivityLog;
|
||||
use HasCache;
|
||||
|
|
@ -133,9 +144,19 @@ class DynamicPage extends Model implements TranslatableModel, Sitemapable
|
|||
]),
|
||||
]);
|
||||
}
|
||||
$model->status = PageStatusEnum::draft;
|
||||
$model->status = PageStatusEnum::unpublished;
|
||||
$model->isRoot = false;
|
||||
});
|
||||
static::deleted(function (self $model) {
|
||||
DynamicPageHelper::manageImgs($model, true);
|
||||
|
||||
$model->status = PageStatusEnum::unpublished;
|
||||
$model->saveOrFail();
|
||||
|
||||
self::setAllChildPageStatus($model, function (DynamicPage $page) {
|
||||
$page->page_id = null;
|
||||
});
|
||||
});
|
||||
static::forceDeleted(function (self $model) {
|
||||
DynamicPageHelper::manageImgs($model, true);
|
||||
});
|
||||
|
|
@ -182,17 +203,21 @@ class DynamicPage extends Model implements TranslatableModel, Sitemapable
|
|||
/**
|
||||
* Unpublished all children pages if change status published from draft/unpublished.
|
||||
*
|
||||
* @param self $model
|
||||
* @param self $model
|
||||
* @param callable|null $callback
|
||||
* @return void
|
||||
*/
|
||||
private static function setAllChildPageStatus(self $model): void
|
||||
private static function setAllChildPageStatus(self $model, ?callable $callback = null): void
|
||||
{
|
||||
if (
|
||||
!$model->status->equals(PageStatusEnum::published)
|
||||
&& !$model->getOriginal('status')->equals($model->status)
|
||||
) {
|
||||
$model->childPages->map(function (DynamicPage $page) use ($model) {
|
||||
$model->childPages->map(function (DynamicPage $page) use ($model, $callback) {
|
||||
$page->status = $model->status;
|
||||
if (!is_null($callback)) {
|
||||
$callback($page);
|
||||
}
|
||||
$page->saveOrFail();
|
||||
});
|
||||
}
|
||||
|
|
@ -475,47 +500,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,12 +17,8 @@ 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
|
||||
|
|
@ -60,13 +56,14 @@ use Uwil\SitemapGenerator\Lib\SitemapHelper;
|
|||
* @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 +132,7 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable, Sit
|
|||
'unpublished_at',
|
||||
'inverted',
|
||||
'vimeo_id',
|
||||
'has_show_picture',
|
||||
'has_show_picture'
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -469,49 +466,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 +481,7 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable, Sit
|
|||
'newsables',
|
||||
'news_id',
|
||||
'newsable_id'
|
||||
)->using(Newsable::class);
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -543,6 +497,6 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable, Sit
|
|||
'newsables',
|
||||
'news_id',
|
||||
'newsable_id'
|
||||
)->using(Newsable::class)->withTimestamps();
|
||||
)->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]))
|
||||
];
|
||||
98
docs/CRUD.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Introduction
|
||||
Cette documentation est destinée à décrire le fonctionnement des CRUDs dans un projet [Laravel](https://laravel.com/docs) avec notre environnement de développement.
|
||||
|
||||
TODO: mettre en place une commande pour création de CRUD en alimentant cette doc.
|
||||
|
||||
# Table des matières
|
||||
|
||||
- [Introduction](#introduction)
|
||||
- [Table des matières](#table-des-matières)
|
||||
- [Migration](#migration)
|
||||
- [Model](#model)
|
||||
- [Controlleur](#controlleur)
|
||||
- [Request](#request)
|
||||
- [Policy](#policy)
|
||||
- [Routes](#routes)
|
||||
- [Vues](#vues)
|
||||
- [Inputs](#inputs)
|
||||
|
||||
pour chaque, définir la convention de nommage (pluriel/singulier/pascalCase)
|
||||
|
||||
# Migration
|
||||
|
||||
table lié à un modèle
|
||||
|
||||
# Model
|
||||
|
||||
```php
|
||||
use App\Traits\Models\HasCache;
|
||||
use App\Traits\Models\HasTranslations;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Uwil\LaravelActivityLogs\Traits\ActivityLog;
|
||||
|
||||
class NomDuModel extends Model implements TranslatableModel
|
||||
{
|
||||
use ActivityLog;
|
||||
use HasCache;
|
||||
use HasFactory;
|
||||
use HasTranslations;
|
||||
use SoftDeletes;
|
||||
...
|
||||
/**
|
||||
* The translatable columns.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public $translatable = [
|
||||
'label',
|
||||
'content',
|
||||
];
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
# Policy
|
||||
|
||||
- viewAny,
|
||||
- viewTrash,
|
||||
- view,
|
||||
- create,
|
||||
- update,
|
||||
- duplicate,
|
||||
- restore,
|
||||
- delete,
|
||||
- forceDelete,
|
||||
|
||||
```php
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Model $authModel
|
||||
* @return boolean
|
||||
*/
|
||||
public function action(Model $authModel): bool
|
||||
{
|
||||
return UserStaticRules::atLeastRole($authModel, RoleEnum::conceptor);
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
'auth' => [
|
||||
...
|
||||
'policies' => [
|
||||
'dynamicPages' => [
|
||||
'viewAny' => Gate::allows('viewAny', DynamicPage::class),
|
||||
'viewTrash' => Gate::allows('viewTrash', DynamicPage::class),
|
||||
'create' => Gate::allows('create', DynamicPage::class),
|
||||
],
|
||||
'pageConfigs' => [
|
||||
'viewAny' => Gate::allows('viewAny', PageConfig::class),
|
||||
'viewTrash' => Gate::allows('viewTrash', PageConfig::class),
|
||||
'create' => Gate::allows('create', PageConfig::class),
|
||||
],
|
||||
...
|
||||
],
|
||||
],
|
||||
```
|
||||
111
docs/DYNAMIC_PAGES.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Introduction
|
||||
Cette documentation est destinée à décrire le fonctionnement des modèles **DynamicPage** et **PageConfig** ainsi que leurs relations.
|
||||
|
||||
Ces deux modèles sont associés à des CRUDs qui leur sont respectifs, chacun dédié à un usage précis. En effet, **PageConfig** est utilisé afin de définir les champs nécessaires dans une page alors que le modèle **DynamicPage** sert à renseigner les données pour ces mêmes champs d'une configuration associée.
|
||||
|
||||
# Table des matières
|
||||
- [Introduction](#introduction)
|
||||
- [Table des matières](#table-des-matières)
|
||||
- [Configuration de pages](#configuration-de-pages)
|
||||
- [Informations globales](#informations-globales)
|
||||
- [Contenu](#contenu)
|
||||
- [Import JSON](#import-json)
|
||||
- [Ajout manuel](#ajout-manuel)
|
||||
- [Nouveau type de champs](#nouveau-type-de-champs)
|
||||
- [Suppression](#suppression)
|
||||
- [Pages dynamiques](#pages-dynamiques)
|
||||
- [Informations globales](#informations-globales)
|
||||
- [Accessibilité](#accessibilité)
|
||||
- [Configuration](#configuration)
|
||||
- [Restriction](#restriction)
|
||||
- [Informations supplémentaires](#informations-supplémentaires)
|
||||
|
||||
# Configuration de pages
|
||||
Comme évoqué précédemment, le CRUD **PageConfig** est destiné à configurer toutes les futures pages qui lui seront associées en définissant chaque champs.
|
||||
|
||||
*En tout premier lieu, il est recommandé de créer une configuration de page si vous voulez ajouter une nouvelle page spécifique.*
|
||||
|
||||
## Informations globales
|
||||
Lors de la création ou l'édition d'une configuration de page, des champs classiques vous seront proposés comme:
|
||||
- le **libellé**, pour identifier la configuration courante,
|
||||
- et la **vue spécifique à associer**, qui correspond au chemin du fichier à utiliser (*attention à bien respecter l'architecture sans préciser l'extension*)
|
||||
|
||||

|
||||
|
||||
## Contenu
|
||||
Par la suite, nous avons le cœur du sytème à savoir le paramètrage d'une configuration. C'est ici que l'on définit chaque champs qui sera à renseigner via le CRUD **DynamicPage**.
|
||||
|
||||
Plusieurs manières sont disponibles afin de réaliser cette démarche.
|
||||
|
||||
### Import JSON
|
||||
La première est de passer par un import d'un contenu .json:
|
||||

|
||||
À savoir que ce même fichier .json doit correspondre à la structure d'une configuration afin de valider l'import ([voir la structure ici](#ajout-manuel)). Si ce n'est pas le cas, vous avez toujours la possibilité de la modifier dans l'éditeur json qui s'ouvrira si vous choisissez cette option:
|
||||

|
||||
|
||||
La seconde option est d'importer une configuration d'une autre langue précédemment renseignée:
|
||||

|
||||
Par exemple, si vous êtes en train de définir une configuration pour la langue anglaise qui se calque sur la configuration française, vous pouvez utiliser cette option (*attention cette action écrasera votre configuration courante*).
|
||||
|
||||
Étant donné que chaque configuration peut être différentes entre elles, et au vu des informations nécessaires, elle est sauvegardée en base sous un type json, ce qui facilite l'export si vous avez besoin de l'importer dans un autre projet par exemple:
|
||||

|
||||
|
||||
### Ajout manuel
|
||||
Vous avez néanmoins la possibilité de créer votre propre configuration de zéro en choisissant la voie manuelle.
|
||||
Pour ce faire, plusieurs informations sont nécessaires afin de définir un champs:
|
||||

|
||||
- le **libellé**, afin d'identifier le champs côté back-office,
|
||||
- la **clé**, qui est utilisé pour le traitement de l'information (unique et non éditable),
|
||||
- le **type**, pour définir quel sorte de champs à utiliser (enum **DynamicFieldTypes**),
|
||||
- et enfin la **validation** à appliquer sous forme de string (peut s'additionner à des règles définies en dur comme pour les Enums ou les Wysiwyg).
|
||||
|
||||
Pour chaque champs définis, vous pouvez modifier l'ordre d'affichage qui sera aussi ajouté en base, et copier le nom du champs si besoin. À savoir que chaque champs est préfixé par son type (par exemple `string_` ou `boolean_`) ce qui permet un traitement différent si nécessaire lors de l'enregistrement (notamment pour les types `image_` ou `wysiwyg_`).
|
||||
|
||||
### Nouveau type de champs
|
||||
Vous avez la liste des champs disponibles dans l'enum **DynamicFieldTypes** avec leur traduction ainsi que leur validation respective. À savoir que si vous souhaitez ajouter de nouveau champs, ceux-ci étant spécifiques au projet, plusieurs modifications sont à prendre en compte:
|
||||
- définir une nouvelle entrée dans l'enum **DynamicFieldTypes** pour la sélection,
|
||||
- modifier la méthode `recursiveRules()` dans la classe **UpdateDynamicFieldRequest** afin de prendre en compte ce nouveau champs lors de l'enregistrement, en utilisant la validation par défaut définie l'enum pour ce champs (avec possibilité d'ajout de règles permanentes),
|
||||
- ajouter une `factory` spécifique pour ce nouveau type dans la classe **DynamicPageFactory**, utile pour les seeders,
|
||||
- et enfin, insérer l'input dans le composant `DynamicInputs` afin d'avoir un rendu visuel de ce nouveau champs.
|
||||
|
||||
## Suppression
|
||||
Comme beaucoup de modèles, **PageConfig** hérite du trait `SoftDeletes`. Lorsqu'un modèle est archivé/supprimé, toutes les pages associées se retrouvent elles aussi archivées/supprimées.
|
||||
|
||||
# Pages dynamiques
|
||||
|
||||
## Informations globales
|
||||
Lors de la création ou l'édition d'une page dynamique, des champs classiques vous seront proposés comme:
|
||||
- le **libellé**, pour identifier la configuration courante,
|
||||
- et le **status**, qui correspond à l'état de publication de la page (enum **PageStatusEnum**).
|
||||
|
||||

|
||||
|
||||
## Accessibilité
|
||||
Chaque page peut être accessible via une url différente. En effet, plusieurs choix s'offrent à vous, définir la page en tant que:
|
||||
- **page enfant** à une autre page publiée, donc une association simple `belongsTo`, cette dernière verra son url affectée par cette association, par exemple si je définie ma page courante comme étant enfant à une page XXX, l'url pour accéder à cette page sera https://domain.fr/XXX/page-courante (attention si vous dépublié la page XXX, toutes les pages associées seront elles aussi dépubliée, si la page parente passe en tant qu'archive, la page courante deviendra orpheline),
|
||||
- **page simple** accessible via un slug unique (se basant sur le titre courant),
|
||||
- **page racine**, dans ce cas là, aucune page parente ni de chemin ne peuvent être renseigné, cette page servira donc de page d'accueil (et une seule page ne peut être définie en tant que racine à la fois).
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
C'est dans cette partie que l'on associe une configuration à notre page courante. Au changement de l'association, on met à jour le contenu donc les champs présents en dessous sont instantanément mis à jour.
|
||||
|
||||
Si aucune configuration n'est associée, un wysiwyg traductible par défaut est disponible et prend la place de la configuration dans le champs json `content` de la table.
|
||||
|
||||
## Restriction
|
||||
Étant donné que ce CRUD est accessible pour les administrateurs, certaines pages doivent être requises, c'est pourquoi on défini des pages par défaut dans les seeders.
|
||||
|
||||
Si une page dynamique nouvellement créée doit être permanente, ce champs de restriction empêche la suppression de cette dernière ainsi que le changement de status (les concepteurs peuvent néanmoins accéder à ce bouton).
|
||||
|
||||
# Informations supplémentaires
|
||||
|
||||
## Traduction
|
||||
Ces deux CRUDs, associés aux modèles **DynamicPage** et **PageConfig**, sont soumis à l'internationnalisation. Il est donc possible d'avoir une configuration de page ainsi que des valeures totalement différentes en fonction de la locale pour une même page.
|
||||
|
||||
## Récursivité
|
||||
Après avoir développé un certain nombre de champs de base (string, wysiwyg, boolean, etc.), nous avons identifié le besoin d’un élément itératif permettant de gérer une liste d’éléments. Par exemple, offrir aux clients la possibilité d’ajouter entre 2 et 6 éléments d’un même type.
|
||||
|
||||
Un travail conséquent a donc été réalisé afin de prendre en charge non seulement des groupes de champs (préfixés par list_), mais également la gestion de plusieurs niveaux de profondeur : sous-groupes, sous-sous-groupes, etc.
|
||||
|
||||
Cela a impliqué la mise en place d’un système récursif, aussi bien du côté de la configuration des pages que du côté du renseignement et de la gestion des valeurs dans les dynamic pages.
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
# Intégrer des images dans un formulaire Inertia
|
||||
# Introduction
|
||||
Cette documentation est destinée à décrire l'intégration d'images dans un formulaire Inertia.
|
||||
|
||||
# Table des matières
|
||||
|
||||
- [Introduction](#introduction)
|
||||
- [Table des matières](#table-des-matières)
|
||||
- [Côté model](#côté-model)
|
||||
- [Request et enregistrement](#request-et-enregistrement)
|
||||
- [Côté front](#côté-front)
|
||||
|
||||
## Côté model
|
||||
Le model va devoir utiliser le trait `HasUploadedFile`
|
||||
Le model ciblé va devoir utiliser le trait `HasUploadedFile`
|
||||
Ce trait aura pour but de mettre par défaut des évenements de model permettant de gérer la suppression des fichiers lors d'un **update** (en utilisant les methode de `FileHelper`, donc un update qui ne touche pas au fichier n'aura évidemment pas d'effet) ou d'un **delete**.
|
||||
A ce jour le trait permet de gérer :
|
||||
- Les champs simples
|
||||
|
|
@ -14,16 +22,14 @@ Pour que tout fonctionne il faut renseigner dans le model un tableau avec le nom
|
|||
/**
|
||||
* Champs contenant des images dynamiques à gérer.
|
||||
*
|
||||
* @var array
|
||||
* @var array
|
||||
*/
|
||||
protected array $imageFields = ['picture'];
|
||||
protected array $imageFields = ['picture'];
|
||||
|
||||
// ici les methodes de nettoyages se focaliseront uniquement sur le champs picture.
|
||||
```
|
||||
Le trait par défaut va pouvoir gérer les cas de **softDelete**, un model qui n'a pas ce trait aura une supression de tous ses fichiers sur un **delete**, bien entendu dans le cas d'un model ayant le trait `SoftDeletes` les suppressions se feront au **forceDelete**.
|
||||
|
||||
|
||||
|
||||
## Request et enregistrement
|
||||
|
||||
La `storeRequest` d'un model ayant des images, devra importer le trait `HasInertiaPicture` et utiliser la méthode `mergeInertiaPicture`
|
||||
|
|
@ -31,9 +37,9 @@ La `storeRequest` d'un model ayant des images, devra importer le trait `HasInert
|
|||
/**
|
||||
* Prepare the data for validation.
|
||||
*
|
||||
* @return void
|
||||
* @return void
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->mergeInertiaPicture('picture');
|
||||
// cette partie est à revoir car le paramètre picture ne sera sans doute plus utile
|
||||
|
|
@ -43,7 +49,7 @@ Cette méthode permet de merge le fichier de la request présent dans un tableau
|
|||
|
||||
La request devra aussi, par simplicité utiliser la méthode de validation proposée par le trait
|
||||
```php
|
||||
'picture' => $this->pictureRules("nullable|file|mimes:jpg,jpeg,png|max:5120|dimensions:width=525,height=295"),
|
||||
'picture' => $this->pictureRules("nullable|file|mimes:jpg,jpeg,png|max:5120|dimensions:width=525,height=295"),
|
||||
```
|
||||
Cette méthode permet de valider soit un fichier, utilisant les validations qu'on lui passe en paramètre, soit une string (dans le cas d'un update; avec utilisation d'une regex pour vérifier que la string correspond à l'arborescence d'un fichier sur laravel) soit null si `nullable` est déclaré.
|
||||
|
||||
|
|
@ -55,37 +61,36 @@ Deux possibilité, soit il n'y a pas de traitement spécifique à appliquer au m
|
|||
Soit, une relation est présente et le fill n'est pas possible en l'état, donc il faut un traitement.
|
||||
Ce traitement peut être effectuer dans le controller directement :
|
||||
```php
|
||||
$attributes = Arr::except($request->validated(), ['categories']);
|
||||
$attributes = Arr::except($request->validated(), ['categories']);
|
||||
$newModel->traitInertiaFill($attributes);
|
||||
```
|
||||
Soit pour éviter le DRY (entre update et create par exemple), la méthode peut être utilisée directement dans le model, et renommé exactement pareil pour que ça reste transparent côté controller, auquel cas il faut "renommer" la méthode du trait pour éviter qu'elle ne s'appelle elle même :
|
||||
```php
|
||||
use HasUploadedFile {
|
||||
use HasUploadedFile {
|
||||
HasUploadedFile::inertiaFill as traitInertiaFill;
|
||||
}
|
||||
|
||||
public function inertiaFill(array $attributes)
|
||||
public function inertiaFill(array $attributes)
|
||||
{
|
||||
$attributes = Arr::except($attributes, ['categories']);
|
||||
$this->traitInertiaFill($attributes);
|
||||
$attributes = Arr::except($attributes, ['categories']);
|
||||
$this->traitInertiaFill($attributes);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Côté front
|
||||
Un point important, lors de l'utilisation de `useForm` d'Inertia, il faudra utiliser un post au moment du submit (car utiliser un put ou un patch; pour l'update du form, ne fonctionne pas...la présence d'un fichier vide la request)
|
||||
```js
|
||||
const submit = (e: FormEvent<HTMLFormElement>) => {
|
||||
const submit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const routeUrl = route(`back.${modelRoute}.${action}`, model?.id);
|
||||
if (method !== Methods.PATCH && method !== Methods.POST) throw new Error("The method must be post or patch");
|
||||
constrouteUrl = route(`back.${modelRoute}.${action}`, model?.id);
|
||||
if (method !== Methods.PATCH && method !== Methods.POST) throw new Error("The method must be post or patch");
|
||||
post(routeUrl); // on utilise post ici
|
||||
};
|
||||
```
|
||||
Mais dans le `useForm` on surcharge la méthode :
|
||||
```js
|
||||
const { post, setData, data, reset } = useForm({
|
||||
_method: method === Methods.POST ? 'post' : 'put',
|
||||
picture: model?.picture,
|
||||
const { post, setData, data, reset } = useForm({
|
||||
_method: method === Methods.POST ? 'post' : 'put',
|
||||
picture: model?.picture,
|
||||
});
|
||||
```
|
||||
BIN
docs/assets/dynamic_pages/accessibility.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/assets/dynamic_pages/config_global_informations.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/assets/dynamic_pages/content_options_export.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/assets/dynamic_pages/content_options_import.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/assets/dynamic_pages/content_options_lang.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/assets/dynamic_pages/custom_field.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/assets/dynamic_pages/json_editor.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/assets/dynamic_pages/page_global_informations.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/assets/inputs/wysiwyg/external_link.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/assets/inputs/wysiwyg/image.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
docs/assets/inputs/wysiwyg/image_edition.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
docs/assets/inputs/wysiwyg/internal_link.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/assets/inputs/wysiwyg/replace.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/assets/inputs/wysiwyg/table.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
1
docs/inputs/IMAGE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
TODO: Incoming by karl
|
||||
94
docs/inputs/STATIC_ROUTES.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Introduction
|
||||
Cette documentation est destinée à décrire l'utilisation ainsi que l'ajout de routes statiques dans l'input StaticRouteSelect.
|
||||
|
||||
# Table des matières
|
||||
|
||||
- [Introduction](#introduction)
|
||||
- [Table des matières](#table-des-matières)
|
||||
- [Contexte](#contexte)
|
||||
- [Ajout](#ajout)
|
||||
- [Exemple](#exemple)
|
||||
|
||||
# Contexte
|
||||
|
||||
Un projet bien pensé est un projet dans lequel toutes les pages disponibles sont issues de dynamic pages. Cette approche permet de centraliser la gestion des contenus, d’assurer une meilleure maintenabilité et de limiter les incohérences entre les différentes sections du site.
|
||||
|
||||
Malheureusement, ce n’est pas une règle universelle. Certains projets comportent encore des routes définies en dur pour des besoins spécifiques ou historiques. C’est pourquoi nous avons développé un champ spécifique dédié aux routes statiques. Celui-ci nous permet d’ajouter des liens vers des routes explicitement définies dans le projet, tout en conservant un cadre structuré et contrôlé.
|
||||
|
||||
# Ajout
|
||||
|
||||
Lorsqu’une nouvelle route statique doit être ajoutée au projet, plusieurs étapes sont nécessaires afin qu’elle puisse apparaître dans la liste des routes statiques sélectionnables depuis l’interface.
|
||||
|
||||
Dans un premier temps, il faut déclarer la route dans le fichier `web-front.php`, comme n’importe quelle route classique du projet.
|
||||
|
||||
Ensuite, il est nécessaire de mettre à jour l'enum `StaticRoutesEnum` en y ajoutant une nouvelle entrée correspondant à cette route statique.
|
||||
|
||||
À noter que cet enum peut implémenter une logique propre à chaque projet. En revanche, certaines contraintes doivent impérativement être respectées :
|
||||
- respecter la structure d’un enum uwil (label, name, value),
|
||||
- ajouter une entrée supplémentaire nommé `route` dédiée à l’URL,
|
||||
- implémenter une méthode `getRoutes()` permettant de retourner la liste complète des routes statiques disponibles,
|
||||
- implémenter une méthode `getRoute()` permettant de récupérer une route spécifique à partir de la valeur de l’enum fournie en paramètre.
|
||||
|
||||
En base de données, seule la valeur de l’enum sélectionné est enregistrée. Cette valeur permet ensuite de résoudre dynamiquement l’URL correspondante via l’enum.
|
||||
|
||||
# Exemple
|
||||
|
||||
Pour illustrer avec un exemple concret, dans le projet LDC, plusieurs routes statiques étaient présentes pour la partie contact (via un enum dédié), ainsi qu’une route spécifique pour la recherche.
|
||||
|
||||
Nous avons donc récupéré la liste des pages à partir de l’enum spécifique `ContactFormType`, puis construit dynamiquement les valeurs de l'enum `StaticRoutesEnum`. De cette manière, nous avons pu fusionner les routes statiques liées au contact avec la route de recherche au sein d’un même ensemble cohérent tout en correspondant avec les valeurs de notre enum `StaticRoutesEnum`.
|
||||
|
||||
```php
|
||||
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.
|
||||
*
|
||||
* @param callable|null $callback
|
||||
* @return array
|
||||
*/
|
||||
public static function getRoutes(?callable $callback = null): array
|
||||
{
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url of a given enum value.
|
||||
*
|
||||
* @param integer $value
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getRoute(int $value): ?string
|
||||
{
|
||||
$collect = collect(self::getRoutes(fn ($route) => $route->value == $value));
|
||||
return $collect->isEmpty() ? null : $collect->first()->route;
|
||||
}
|
||||
```
|
||||
83
docs/inputs/WYSIWYG.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Introduction
|
||||
Cette documentation est destinée à décrire le fonctionnement de l'input **Wysiwyg** ainsi que ses extensions (natives et custom).
|
||||
|
||||
# Table des matières
|
||||
- [Introduction](#introduction)
|
||||
- [Table des matières](#table-des-matières)
|
||||
- [Configuration](#configuration)
|
||||
- [Toolbar](#toolbar)
|
||||
- [Extensions](#extensions)
|
||||
- [Image](#image)
|
||||
- [Link](#link)
|
||||
- [Replace](#replace)
|
||||
- [Shortcuts](#shortcuts)
|
||||
- [Table](#table)
|
||||
|
||||
# Configuration
|
||||
```
|
||||
- resources/ts/back/Components/Inputs/Wysiwyg/
|
||||
- Extensions/
|
||||
- SimpleWysiwyg.tsx
|
||||
- Toolbar.tsx
|
||||
- tiptap.d.ts
|
||||
```
|
||||
|
||||
# Toolbar
|
||||
En utilisant l'instance de l'éditeur [@tiptap](https://tiptap.dev/), on peut activer/désactiver les options qui sont appliquées de manière manuelle via une barre d'outils personnalisée. De plus, nous pouvons organiser la structure comme nous le souhaitons, en utilisant des groupes de boutons ou des listes.
|
||||
|
||||
# Extensions
|
||||
Tiptap propose de base quelques extensions ([disponibles ici](https://tiptap.dev/docs/editor/extensions/overview)) que l'on utilise dans notre configuration via le `StarterKit` ou tout simplement unitairement.
|
||||
|
||||
On peut donc retrouver, dans notre fichier `SimpleWysiwyg.tsx`, la liste des extensions que l'on utilise comme le gras, l'italique, le soulignement, les listes etc... mais dans certains cas, nous avons besoin d'une action spécifique qui est le résultat d'une extension personnalisée.
|
||||
|
||||
À savoir que chaque extension personnalisée est différente dans son implémentation et dans son utilité.
|
||||
|
||||
## Table
|
||||
Nous avons une extension qui nous permet d'insérer un tableau dans les dimensions voulues en affichant ou non l'en-tête. La sélection du nombre de lignes/colonnes peut se faire de 2 manières différentes, via les inputs prédéfinis ou via un hover des carrés visuellement disponibles. Une limite maximale du nombre de lignes/colonnes a quand même été mise en place et s'élève au nombre de 12.
|
||||
|
||||
À savoir que cette extension est une surcharge visuelle de celle de base par [@tiptap](https://tiptap.dev/) mais le fonctionnement derrière reste le même.
|
||||
|
||||

|
||||
|
||||
## Image
|
||||
Étant donné que nous avons tout un système spécifique de sauvegarde de fichiers ainsi que des règles de validation à appliquer, nous avons dû modifier l'extension image pour utiliser nos outils.
|
||||
|
||||
Au clic du bouton image, une modale apparaît afin d'insérer une nouvelle image à notre contenu.
|
||||
Afin d'y arriver, vous devez choisir un type de format pour votre future image qui, en changeant, modifiera les dimensions appliquées dans le champ image juste en dessous ([consulter la documentation officielle du composant image by Karl](./IMAGE.md)). Par la suite, des champs texte sont disponibles afin de définir un attribut alternatif de l'image, un titre ou encore une légende.
|
||||
|
||||

|
||||
|
||||
Une fois votre image et ses champs validés, l'image sera insérée dans votre contenu.
|
||||
Plus précisément un nouveau nœud html `<figure>` le sera avec comme enfant l'élément `<img>`, avec ses attribut alt et title, et sa légende `<figcaption>` si définie.
|
||||
|
||||

|
||||
|
||||
Cette insertion n'est pas définitive. En effet, vous avez toujours la possibilité de modifier la position de votre image insérée (à gauche, au centre, à droite, en flottaison à gauche ou à droite), les champs textes précédemment cités ainsi que ses dimensions, tout en respectant son ratio de base.
|
||||
|
||||
## Link
|
||||
Une autre extension que nous avons dû modifier est celle des liens.
|
||||
|
||||
Nous avons déjà eu, par le passé, des clients qui renseignaient des liens de re7 dans des wysiwyg de prod ce qui provoquait des erreurs 404 en voulant accéder à la ressource.
|
||||
|
||||
Pour résoudre ce problème, vous avons développé cette extension qui nous permet de cibler soit un lien externe, comme https://google.com (donc un domaine différent du projet courant), soit une ressource interne via un modèle.
|
||||
|
||||
Par exemple, si vous souhaitez insérer une actualité, il vous suffit de cliquer sur lien interne, puis sélectionner `actualité` dans la première liste et enfin votre ressource parmi toutes les actualités publiées.
|
||||
|
||||
Dans les deux cas (externe/interne), vous devez renseigner un libellé qui se verra attribuer votre lien avec la possibilité de l'ouvrir dans un nouvel onglet ou non.
|
||||
|
||||

|
||||

|
||||
|
||||
À savoir que des règles zod ont été mises en place afin de valider les informations qui ont été saisies que ce soit les liens ou bien les champs textuels.
|
||||
|
||||
## Replace
|
||||
Petite extension assez simple qui permet de remplacer une suite spécifique de caractères par une autre avec la possibilité de respecter la casse ou non.
|
||||
|
||||

|
||||
|
||||
## Shortcuts
|
||||
Cette extension n'est pas visuelle mais permet simplement de stopper la propagation des évènements au clavier lorsque l'éditeur wysiwyg est sélectionné.
|
||||
|
||||
À titre d'exemple, le raccourci pour mettre une sélection de caractères en gras est `ctrl` + `b` mais cette même combinaison de touches est aussi utilisée pour rouler/dérouler la navigation du layout back-end.
|
||||
|
||||
Grâce à cette extension, on scope les raccourcis seulement à l'éditeur si sélectionné et permet d'éviter des effets de bord non désirés.
|
||||
|
|
@ -192,7 +192,7 @@ return [
|
|||
"delete" => "delete the color",
|
||||
],
|
||||
"wysiwyg" => [
|
||||
"undo" => "Undo",
|
||||
"undo" => "Undo",
|
||||
"redo" => "Redo",
|
||||
"bold" => "Bold",
|
||||
"italic" => "Italic",
|
||||
|
|
@ -257,7 +257,8 @@ return [
|
|||
"position" => "Image position",
|
||||
"format" => "Image format",
|
||||
"image" => "Image",
|
||||
"alt" => "ALT attribute of the image",
|
||||
"attribute_alt" => "Alt attribute of the image",
|
||||
"attribute_title" => "Title associated to the image",
|
||||
"caption" => "Caption of the image",
|
||||
"has_caption" => "Presence of a legend",
|
||||
"landscape" => "Landscape",
|
||||
|
|
@ -295,6 +296,37 @@ return [
|
|||
"cancel" => "Cancel",
|
||||
"tooltip_cancel" => "Cancel the replacement",
|
||||
],
|
||||
"video" => [
|
||||
"tooltip" => "Video",
|
||||
"title" => "Insert an video",
|
||||
"subtitle" => "Add a new video to your content.",
|
||||
"insert" => "Insert",
|
||||
"tooltip_insert" => "Add this video",
|
||||
"cancel" => "Cancel",
|
||||
"tooltip_cancel" => "Cancel adding this video",
|
||||
"edit_title" => "Update a video",
|
||||
"edit_subtitle" => "Edit the attributes associated with the video.",
|
||||
"edit_apply" => "Apply",
|
||||
"edit_tooltip_apply" => "Update the video attributes",
|
||||
"edit_cancel" => "Cancel",
|
||||
"edit_tooltip_cancel" => "Cancel updating the video attributes",
|
||||
"position" => "Image position",
|
||||
"format" => "Image format",
|
||||
"video" => "Image",
|
||||
"alt" => "ALT attribute of the video",
|
||||
"caption" => "Caption of the video",
|
||||
"has_caption" => "Presence of a legend",
|
||||
"landscape" => "Landscape",
|
||||
"portrait" => "Portrait",
|
||||
"square" => "Square",
|
||||
"align-left" => "Align left",
|
||||
"align-center" => "Align center",
|
||||
"align-right" => "Align right",
|
||||
"wrap-left" => "Wrap left",
|
||||
"wrap-right" => "Wrap right",
|
||||
"youtube" => "Youtube",
|
||||
"vimeo" => "Vimeo",
|
||||
],
|
||||
],
|
||||
"json_editor" => [
|
||||
"item_single" => "1 item",
|
||||
|
|
@ -371,40 +403,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",
|
||||
]
|
||||
]
|
||||
];
|
||||
|
|
|
|||
|
|
@ -104,9 +104,5 @@ return [
|
|||
'product' => 'product information',
|
||||
'info' => 'other general information',
|
||||
'press' => 'a press request',
|
||||
],
|
||||
"videoDisplayFormat" => [
|
||||
"landscape" => "landscape",
|
||||
"portrait" => "portrait"
|
||||
],
|
||||
]
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,6 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'front' => [
|
||||
'news' => 'news/{news}',
|
||||
'search' => 'search'
|
||||
],
|
||||
'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 ?"
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -258,7 +258,8 @@ return [
|
|||
"position" => "Position de l'image",
|
||||
"format" => "Format de l'image",
|
||||
"image" => "Image",
|
||||
"alt" => "Texte alternatif de l'image",
|
||||
"attribute_alt" => "Texte alternatif de l'image",
|
||||
"attribute_title" => "Titre associé à l'image",
|
||||
"caption" => "Légende de l'image",
|
||||
"has_caption" => "Présence d'une légende",
|
||||
"landscape" => "Paysage",
|
||||
|
|
@ -296,6 +297,42 @@ return [
|
|||
"cancel" => "Annuler",
|
||||
"tooltip_cancel" => "Annuler le remplacement",
|
||||
],
|
||||
"video" => [
|
||||
"tooltip" => "Vidéo",
|
||||
"title" => "Insérer une vidéo",
|
||||
"subtitle" => "Ajouter une nouvelle vidéo à votre contenu.",
|
||||
"insert" => "Insérer",
|
||||
"tooltip_insert" => "Ajouter cette vidéo",
|
||||
"cancel" => "Annuler",
|
||||
"tooltip_cancel" => "Annuler l'ajout de cette vidéo",
|
||||
"edit_title" => "Mise à jour d'une vidéo",
|
||||
"edit_subtitle" => "Modifier les attributs associés à la vidéo.",
|
||||
"edit_apply" => "Appliquer",
|
||||
"edit_tooltip_apply" => "Mettre à jour les attributs de la vidéo",
|
||||
"edit_cancel" => "Annuler",
|
||||
"edit_tooltip_cancel" => "Annuler la mise à jour des attributs de la vidéo",
|
||||
"position" => "Position de la vidéo",
|
||||
"format" => "Format de la vidéo",
|
||||
"image" => "Vidéo",
|
||||
"alt" => "Texte alternatif de la vidéo",
|
||||
"caption" => "Légende de la vidéo",
|
||||
"has_caption" => "Présence d'une légende",
|
||||
"landscape" => "Paysage",
|
||||
"portrait" => "Portrait",
|
||||
"align-left" => "Aligner à gauche",
|
||||
"align-center" => "Aligner au centre",
|
||||
"align-right" => "Aligner à droite",
|
||||
"wrap-left" => "Flottant à gauche",
|
||||
"wrap-right" => "Flottant à droite",
|
||||
"youtube" => "Youtube",
|
||||
"vimeo" => "Viméo",
|
||||
"is_muted" => "muter la vidéo ?",
|
||||
"has_controls" => "permettre de controler la vidéo ?",
|
||||
"can_fullScreen" => "autoriser le plein écran ?",
|
||||
"provider" => "hébergeur de la vidéo",
|
||||
'invalid_youtube_id' => "vidéo YOUTUBE invalide",
|
||||
"invalid_vimeo_id" => "vidéo VIMEO invalide"
|
||||
],
|
||||
],
|
||||
"json_editor" => [
|
||||
"item_single" => "1 élément",
|
||||
|
|
@ -372,45 +409,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",
|
||||
]
|
||||
]
|
||||
];
|
||||
|
|
|
|||
|
|
@ -105,8 +105,4 @@ return [
|
|||
'info' => 'une autre information générale',
|
||||
'press' => 'une demande presse',
|
||||
],
|
||||
"videoDisplayFormat" => [
|
||||
"landscape" => "paysage",
|
||||
"portrait" => "portrait"
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,6 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'front' => [
|
||||
'news' => 'actualites/{news}',
|
||||
'search' => 'recherche'
|
||||
],
|
||||
'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>
|
||||
|
|
|
|||
|
|
@ -448,6 +448,7 @@
|
|||
padding-left: 10px;
|
||||
}
|
||||
[data-menu="submenu-2951"] .mobile-menu__level {
|
||||
padding-top: 0;
|
||||
min-height: calc(100vh - 80px);
|
||||
}
|
||||
.offer-form {
|
||||
|
|
|
|||
|
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 = {
|
||||
|
|
@ -62,7 +60,6 @@ export default function DynamicInputs({
|
|||
const enumElementPosition = usePage().props
|
||||
.enumElementPosition as LaravelEnum;
|
||||
const enumContactFormType = usePage().props.enumContactFormType as LaravelEnum
|
||||
const videoProviderEnum = usePage().props.videoProviderEnum as LaravelEnum;
|
||||
// Translations.
|
||||
const transAssociateTo = useTrans("back.form_associate_to");
|
||||
const transUnsupportedType = useTrans("back.unsupported_type");
|
||||
|
|
@ -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,
|
||||
)}
|
||||
|
|
@ -369,7 +360,7 @@ export default function DynamicInputs({
|
|||
<EnumSelect
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
placeholder="Positionnement de l'élement"
|
||||
placeholder="Formulaire de contact à associer"
|
||||
selected={fieldValue as number}
|
||||
options={enumElementPosition}
|
||||
setData={setData}
|
||||
|
|
@ -383,7 +374,7 @@ export default function DynamicInputs({
|
|||
<EnumSelect
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
placeholder="Formulaire de contact à associer"
|
||||
placeholder="Positionnement de l'élement"
|
||||
selected={fieldValue as number}
|
||||
options={enumContactFormType}
|
||||
setData={setData}
|
||||
|
|
@ -392,19 +383,6 @@ export default function DynamicInputs({
|
|||
)}
|
||||
/>
|
||||
)
|
||||
case enumDynamicFieldTypes.video.value:
|
||||
return (
|
||||
<VideoInput
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
value={fieldValue as VideoAttributes}
|
||||
reset={reset}
|
||||
setData={setData}
|
||||
required={extractRequiredValidation(
|
||||
field.validation,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<p
|
||||
|
|
|
|||
|
|
@ -343,18 +343,18 @@ export default function DynamicList({
|
|||
onValueChange={setOpenIndex}
|
||||
>
|
||||
{/*
|
||||
The main provider for dnd-kit.
|
||||
It manages the entire drag-and-drop system: sensors, movement, collision detection, and lifecycle callbacks (onDragStart, onDragEnd, etc.).
|
||||
*/}
|
||||
The main provider for dnd-kit.
|
||||
It manages the entire drag-and-drop system: sensors, movement, collision detection, and lifecycle callbacks (onDragStart, onDragEnd, etc.).
|
||||
*/}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleOnDragEnd}
|
||||
>
|
||||
{/*
|
||||
Defines a reorderable list area.
|
||||
It registers sortable items and applies the chosen sorting strategy (vertical, horizontal, grid) to handle item repositioning.
|
||||
*/}
|
||||
Defines a reorderable list area.
|
||||
It registers sortable items and applies the chosen sorting strategy (vertical, horizontal, grid) to handle item repositioning.
|
||||
*/}
|
||||
<SortableContext
|
||||
items={sortedListItems.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1667,7 +1667,7 @@ export const ModelSelect = forwardRef<ModelSelectRef, ModelSelectProps>(
|
|||
|
||||
{/* Pagination boutons */}
|
||||
{options.length > 0 && lastPage !== 1 && (
|
||||
<div className="border-t flex flex-col xl:flex-row justify-between items-center gap-1.5 p-1">
|
||||
<div className="border-t flex flex-col lg:flex-row justify-between items-center gap-1.5 p-1">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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,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;
|
||||
|
|
@ -23,6 +23,7 @@ const Image = TiptapImage.extend({
|
|||
width: null,
|
||||
height: null,
|
||||
alt: null,
|
||||
title: null,
|
||||
caption: null,
|
||||
style: null,
|
||||
};
|
||||
|
|
@ -44,6 +45,7 @@ const Image = TiptapImage.extend({
|
|||
return {
|
||||
src: img.src,
|
||||
alt: img.getAttribute("alt"),
|
||||
title: img.getAttribute("title"),
|
||||
width: parseInt(
|
||||
img.getAttribute("width") || imageDetails.width,
|
||||
10,
|
||||
|
|
|
|||
|
|
@ -57,10 +57,12 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
|
|||
// Values of inputs text.
|
||||
const [inputText, setInputText] = useState<{
|
||||
alt: string;
|
||||
title: string;
|
||||
caption: string | null;
|
||||
hasCaption: boolean;
|
||||
}>({
|
||||
alt: "",
|
||||
title: "",
|
||||
caption: null,
|
||||
hasCaption: false,
|
||||
});
|
||||
|
|
@ -78,7 +80,8 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
|
|||
.ucFirst()
|
||||
.value();
|
||||
const transImage = useTrans("back.wysiwyg.image.image");
|
||||
const transAlt = useTrans("back.wysiwyg.image.alt");
|
||||
const transAlt = useTrans("back.wysiwyg.image.attribute_alt");
|
||||
const transTitle = useTrans("back.wysiwyg.image.attribute_title");
|
||||
const transCaption = useTrans("back.wysiwyg.image.caption");
|
||||
const transValidation = (attribute: string) => {
|
||||
return {
|
||||
|
|
@ -94,6 +97,7 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
|
|||
};
|
||||
};
|
||||
const transValidationAlt = transValidation(transAlt);
|
||||
const transValidationTitle = transValidation(transTitle);
|
||||
const transValidationCaption = transValidation(transCaption);
|
||||
|
||||
// List of image types.
|
||||
|
|
@ -169,6 +173,7 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
|
|||
upload: "image",
|
||||
inputFile: "image",
|
||||
alt: "alt",
|
||||
title: "title",
|
||||
caption: "caption",
|
||||
};
|
||||
// Manage zod errors.
|
||||
|
|
@ -216,6 +221,7 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
|
|||
setFile(null);
|
||||
setInputText({
|
||||
alt: "",
|
||||
title: "",
|
||||
caption: null,
|
||||
hasCaption: false,
|
||||
});
|
||||
|
|
@ -275,6 +281,7 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
|
|||
const inputTextSchema = imageTextSchema({
|
||||
hasCaption: inputText.hasCaption,
|
||||
transValidationAlt,
|
||||
transValidationTitle,
|
||||
transValidationCaption,
|
||||
});
|
||||
|
||||
|
|
@ -285,6 +292,7 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
|
|||
.parse({
|
||||
inputFile,
|
||||
alt: inputText.alt,
|
||||
title: inputText.title,
|
||||
caption: inputText.caption,
|
||||
});
|
||||
|
||||
|
|
@ -295,6 +303,7 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
|
|||
editor.commands.setImage({
|
||||
src: imageSrc,
|
||||
alt: parsedZod.alt,
|
||||
title: parsedZod.title,
|
||||
caption: parsedZod.caption,
|
||||
width: String(inputType.width),
|
||||
height: String(inputType.height),
|
||||
|
|
@ -336,6 +345,7 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
|
|||
useEffect(() => {
|
||||
setInputText({
|
||||
alt: inputText.alt,
|
||||
title: inputText.title,
|
||||
caption: inputText.hasCaption ? inputText.caption : null,
|
||||
hasCaption: inputText.hasCaption,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import str from "@/hooks/use-string";
|
|||
type ImageInputsProps = {
|
||||
value: {
|
||||
alt: string;
|
||||
title: string;
|
||||
caption: string | null;
|
||||
hasCaption: boolean;
|
||||
};
|
||||
|
|
@ -31,13 +32,30 @@ export default function ImageInputs({
|
|||
{/* Input alt attribute of the image. */}
|
||||
<SimpleString
|
||||
name="alt"
|
||||
label={str(useTrans("back.wysiwyg.image.alt"))
|
||||
label={str(useTrans("back.wysiwyg.image.attribute_alt"))
|
||||
.ucFirst()
|
||||
.value()}
|
||||
placeholder="ex: John Doe"
|
||||
required
|
||||
value={value.alt}
|
||||
setData={(_, v) => onChange({ ...value, alt: v })}
|
||||
setData={(_, currentValue) =>
|
||||
onChange({ ...value, alt: currentValue })
|
||||
}
|
||||
disabled={disabled || loading}
|
||||
parentErrors={errors}
|
||||
/>
|
||||
{/* Input title attribute of the image. */}
|
||||
<SimpleString
|
||||
name="title"
|
||||
label={str(useTrans("back.wysiwyg.image.attribute_title"))
|
||||
.ucFirst()
|
||||
.value()}
|
||||
placeholder="ex: John Doe"
|
||||
required
|
||||
value={value.title}
|
||||
setData={(_, currentValue) =>
|
||||
onChange({ ...value, title: currentValue })
|
||||
}
|
||||
disabled={disabled || loading}
|
||||
parentErrors={errors}
|
||||
/>
|
||||
|
|
@ -52,11 +70,11 @@ export default function ImageInputs({
|
|||
name="hasCaption"
|
||||
checked={value.hasCaption}
|
||||
className="justify-center"
|
||||
setData={(_, v) =>
|
||||
setData={(_, currentValue) =>
|
||||
onChange({
|
||||
...value,
|
||||
hasCaption: v,
|
||||
caption: v ? value.caption : null,
|
||||
hasCaption: currentValue,
|
||||
caption: currentValue ? value.caption : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
@ -68,7 +86,9 @@ export default function ImageInputs({
|
|||
placeholder="ex: John Doe"
|
||||
required={false}
|
||||
value={value.caption ?? ""}
|
||||
setData={(_, v) => onChange({ ...value, caption: v })}
|
||||
setData={(_, currentValue) =>
|
||||
onChange({ ...value, caption: currentValue })
|
||||
}
|
||||
disabled={disabled || loading}
|
||||
parentErrors={errors}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ const PLACEMENT_STYLES: Record<PlacementValue, string> = {
|
|||
type ImageAttrs = {
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
caption: string | null;
|
||||
|
|
@ -112,10 +113,12 @@ export default function ImageNodeView(props: NodeViewProps) {
|
|||
// Values of inputs text.
|
||||
const [inputText, setInputText] = useState<{
|
||||
alt: string;
|
||||
title: string;
|
||||
caption: string | null;
|
||||
hasCaption: boolean;
|
||||
}>({
|
||||
alt: attrs.alt,
|
||||
title: attrs.title,
|
||||
caption: attrs.caption,
|
||||
hasCaption: !!attrs.caption,
|
||||
});
|
||||
|
|
@ -123,7 +126,8 @@ export default function ImageNodeView(props: NodeViewProps) {
|
|||
const [topSticky, setTopSticky] = useState<number>(0);
|
||||
|
||||
// Translations.
|
||||
const transAlt = useTrans("back.wysiwyg.image.alt");
|
||||
const transAlt = useTrans("back.wysiwyg.image.attribute_alt");
|
||||
const transTitle = useTrans("back.wysiwyg.image.attribute_title");
|
||||
const transCaption = useTrans("back.wysiwyg.image.caption");
|
||||
const transValidation = (attribute: string) => {
|
||||
return {
|
||||
|
|
@ -139,6 +143,7 @@ export default function ImageNodeView(props: NodeViewProps) {
|
|||
};
|
||||
};
|
||||
const transValidationAlt = transValidation(transAlt);
|
||||
const transValidationTitle = transValidation(transTitle);
|
||||
const transValidationCaption = transValidation(transCaption);
|
||||
|
||||
/**
|
||||
|
|
@ -260,6 +265,7 @@ export default function ImageNodeView(props: NodeViewProps) {
|
|||
const fieldErrors: typeof errors = {};
|
||||
const fieldMapping: Record<string, string> = {
|
||||
alt: "alt",
|
||||
title: "title",
|
||||
caption: "caption",
|
||||
};
|
||||
if (err instanceof z.ZodError) {
|
||||
|
|
@ -284,6 +290,7 @@ export default function ImageNodeView(props: NodeViewProps) {
|
|||
const inputTextSchema = imageTextSchema({
|
||||
hasCaption: inputText.hasCaption,
|
||||
transValidationAlt,
|
||||
transValidationTitle,
|
||||
transValidationCaption,
|
||||
});
|
||||
|
||||
|
|
@ -291,12 +298,14 @@ export default function ImageNodeView(props: NodeViewProps) {
|
|||
// Validate data.
|
||||
const parsedZod = inputTextSchema.parse({
|
||||
alt: inputText.alt,
|
||||
title: inputText.title,
|
||||
caption: inputText.caption,
|
||||
});
|
||||
|
||||
// Update data of the image in @tiptap editor.
|
||||
updateAttributes({
|
||||
alt: parsedZod.alt,
|
||||
title: parsedZod.title,
|
||||
caption: parsedZod.caption,
|
||||
});
|
||||
setOpen(false);
|
||||
|
|
@ -374,6 +383,7 @@ export default function ImageNodeView(props: NodeViewProps) {
|
|||
useEffect(() => {
|
||||
setInputText({
|
||||
alt: inputText.alt,
|
||||
title: inputText.title,
|
||||
caption: inputText.hasCaption ? inputText.caption : null,
|
||||
hasCaption: inputText.hasCaption,
|
||||
});
|
||||
|
|
@ -423,6 +433,7 @@ export default function ImageNodeView(props: NodeViewProps) {
|
|||
ref={imgRef}
|
||||
src={attrs.src.replace(url, "")}
|
||||
alt={attrs.alt}
|
||||
title={attrs.title}
|
||||
width={attrs.width}
|
||||
height={attrs.height}
|
||||
draggable={false}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const imageDetails = {
|
|||
export const imageTextSchema = ({
|
||||
hasCaption,
|
||||
transValidationAlt,
|
||||
transValidationTitle,
|
||||
transValidationCaption,
|
||||
}: {
|
||||
hasCaption: boolean;
|
||||
|
|
@ -25,6 +26,11 @@ export const imageTextSchema = ({
|
|||
max: string;
|
||||
string: string;
|
||||
};
|
||||
transValidationTitle: {
|
||||
min: string;
|
||||
max: string;
|
||||
string: string;
|
||||
};
|
||||
transValidationCaption: {
|
||||
min: string;
|
||||
max: string;
|
||||
|
|
@ -36,15 +42,21 @@ export const imageTextSchema = ({
|
|||
.string({ message: transValidationAlt.string })
|
||||
.min(3, transValidationAlt.min)
|
||||
.max(255, transValidationAlt.max);
|
||||
// Attribute title zod rules.
|
||||
const titleRules = z
|
||||
.string({ message: transValidationTitle.string })
|
||||
.min(3, transValidationTitle.min)
|
||||
.max(255, transValidationTitle.max);
|
||||
// Caption zod rules.
|
||||
const captionRules = z
|
||||
.string({ message: transValidationAlt.string })
|
||||
.string({ message: transValidationCaption.string })
|
||||
.min(3, transValidationCaption.min)
|
||||
.max(255, transValidationCaption.max);
|
||||
|
||||
// Return zod object.
|
||||
return z.object({
|
||||
alt: altRules,
|
||||
title: titleRules,
|
||||
caption: hasCaption ? captionRules : z.null(),
|
||||
});
|
||||
};
|
||||
|
|
|
|||