From 3e464e6d1f534c08d4987f99cd46bed437327cee Mon Sep 17 00:00:00 2001 From: K Muller Date: Fri, 26 Sep 2025 15:08:37 +0200 Subject: [PATCH] feat: :sparkles: add image cropper to dynamic pages --- app/Enums/DynamicFieldTypes.php | 3 + app/Helpers/DynamicPageHelper.php | 103 ++ .../Back/DynamicPageController.php | 5 +- .../Controllers/Back/PageConfigController.php | 1 + app/Http/Middleware/HandleInertiaRequests.php | 1 + .../Back/UpdateDynamicFieldRequest.php | 79 +- app/Models/DynamicPage.php | 44 +- app/Models/PageConfig.php | 7 +- app/Traits/Requests/HasInertiaPicture.php | 64 + composer.json | 2 +- composer.lock | 301 ++--- lang/en/back.php | 35 + lang/fr/back.php | 37 + lang/fr/enums.php | 1 + package-lock.json | 104 +- package.json | 3 + .../ts/back/Components/DynamicInputs.tsx | 65 +- .../back/Components/Inputs/ImageCropper.tsx | 1036 +++++++++++++++++ .../back/Components/Inputs/SimpleNumber.tsx | 12 +- .../ts/back/Components/Layout/AppSidebar.tsx | 12 +- .../Components/PageConfigs/SortableField.tsx | 71 +- resources/ts/back/Pages/DynamicPages/Form.tsx | 28 +- resources/ts/back/types/global.d.ts | 1 + resources/ts/components/ui/slider.tsx | 63 + 24 files changed, 1799 insertions(+), 279 deletions(-) create mode 100644 app/Helpers/DynamicPageHelper.php create mode 100644 app/Traits/Requests/HasInertiaPicture.php create mode 100644 resources/ts/back/Components/Inputs/ImageCropper.tsx create mode 100644 resources/ts/components/ui/slider.tsx diff --git a/app/Enums/DynamicFieldTypes.php b/app/Enums/DynamicFieldTypes.php index 0d4fd462..10ecdec0 100644 --- a/app/Enums/DynamicFieldTypes.php +++ b/app/Enums/DynamicFieldTypes.php @@ -14,6 +14,7 @@ enum DynamicFieldTypes: int case boolean = 4; case url = 5; case email = 6; + case image = 7; /** * Labels definition. @@ -27,6 +28,7 @@ enum DynamicFieldTypes: int self::boolean->name => 'enums.dynamic-field-types.boolean', self::url->name => 'enums.dynamic-field-types.url', self::email->name => 'enums.dynamic-field-types.email', + self::image->name => 'enums.dynamic-field-types.image', ]; /** @@ -39,6 +41,7 @@ enum DynamicFieldTypes: int self::boolean->name => 'required|boolean', self::url->name => 'required|url:https', self::email->name => 'required|email:rfc,strict,dns,spoof,filter', + self::image->name => 'required|file|mimes:jpg,jpeg,png,pdf|max:5120|dimensions:width=100,height=100', ]; /** diff --git a/app/Helpers/DynamicPageHelper.php b/app/Helpers/DynamicPageHelper.php new file mode 100644 index 00000000..57fcd38b --- /dev/null +++ b/app/Helpers/DynamicPageHelper.php @@ -0,0 +1,103 @@ +load('config'); + + $currentContent = json_decode($model->getAttributes()['content'], true); + $originalContent = $model->getOriginal('content'); + + $currentContentLocal = is_array($currentContent[$locale]) ? $currentContent[$locale] : ["text_" => $currentContent[$locale]]; + $originalContentLocal = is_array($originalContent[$locale]) ? $originalContent[$locale] : ["text_" => $originalContent[$locale]]; + static::removeDynamicImages($model, $originalContentLocal, $currentContentLocal, $forceDeleteImage); + static::removeWysiwygImages($originalContentLocal, $currentContentLocal); + } + + /** + * Remove imgs from image inputs. + * + * @param Model $model + * @param array $oldContent + * @param array $newContent + * @param bool $forceDeleteImage In forceDelete event we need to ensure all the images will be destroyed + * @return void + */ + protected static function removeDynamicImages(Model $model, array $oldContent, array $newContent, bool $forceDeleteImage): void + { + $prefix = DynamicFieldTypes::image->name . '_'; + $allKeys = static::getDynamicFieldKeys($oldContent, $newContent, $prefix); + foreach ($allKeys as $key) { + $old = $oldContent[$key] ?? null; + $new = $newContent[$key] ?? null; + // Cas 1 : Valeurs identiques + if ((is_string($old) === is_string($new)) && $forceDeleteImage) { + // In this case we need to remove the actual file, not the old as we do in other conditions + FileHelper::removeFile($model, "content.$key"); + } + // // Cas 2 : ancienne = string, nouvelle = null → suppression + if (is_string($old) && is_null($new)) { + // Action suppression + FileHelper::removeOldFile($model, "content.$key"); + continue; + } + + // // Cas 3 : ancienne = null, nouvelle = string → ajout + + // // Cas 4 : ancienne = string, nouvelle = autre string → remplacement + if (is_string($old) && is_string($new) && $old !== $new) { + FileHelper::removeOldFile($model, "content.$key"); + continue; + } + } + } + + /** + * Remove imgs from wysiwygs. + * + * @param array $oldContent + * @param array $newContent + * @return void + */ + protected static function removeWysiwygImages(array $oldContent, array $newContent): void + { + $prefix = DynamicFieldTypes::text->name . '_'; + + static::getDynamicFieldKeys($oldContent, $newContent, $prefix) + ->each(function ($key) use ($oldContent) { + $old = $oldContent[$key] ?? null; + FileHelper::removeOldModelFilesFromHtml('', $old); + }); + } + + /** + * Get the field keys of wysiwygs or images field input and group them. + * + * @param array $oldContent + * @param array $newContent + * @return \Illuminate\Support\Collection + */ + protected static function getDynamicFieldKeys(array $oldContent, array $newContent, string $prefix): \Illuminate\Support\Collection + { + $filter = fn($value, $key) => Str::startsWith($key, $prefix); + $old = collect($oldContent)->filter($filter); + $new = collect($newContent)->filter($filter); + return $new->keys()->merge($old->keys())->unique(); + } +} diff --git a/app/Http/Controllers/Back/DynamicPageController.php b/app/Http/Controllers/Back/DynamicPageController.php index 816b8635..b5c3ca6c 100644 --- a/app/Http/Controllers/Back/DynamicPageController.php +++ b/app/Http/Controllers/Back/DynamicPageController.php @@ -105,7 +105,8 @@ class DynamicPageController extends Controller { return DB::transaction(function () use ($request) { $dynamicPageModel = new DynamicPage(); - $dynamicPageModel->fill($request->validated()); + + $dynamicPageModel->inertiaFill($request->validated()); if ($dynamicPageModel->saveOrFail()) { return redirect()->route('back.dynamic-pages.edit', ['dynamic_page' => $dynamicPageModel]) @@ -147,7 +148,7 @@ class DynamicPageController extends Controller DynamicPage $dynamic_page ): \Illuminate\Http\RedirectResponse { return DB::transaction(function () use ($request, $dynamic_page) { - $dynamic_page->fill($request->validated()); + $dynamic_page->inertiaFill($request->validated()); if ($dynamic_page->saveOrFail()) { return redirect()->route('back.dynamic-pages.edit', ['dynamic_page' => $dynamic_page]) diff --git a/app/Http/Controllers/Back/PageConfigController.php b/app/Http/Controllers/Back/PageConfigController.php index e5d56961..b1474eb8 100644 --- a/app/Http/Controllers/Back/PageConfigController.php +++ b/app/Http/Controllers/Back/PageConfigController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Back; use App\Http\Requests\Back\PageConfigs\StorePageConfigRequest; use App\Http\Requests\Back\PageConfigs\UpdatePageConfigRequest; +use App\Models\DynamicPage; use App\Models\PageConfig; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index d87fb0cb..05c62a09 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -48,6 +48,7 @@ class HandleInertiaRequests extends Middleware 'appEnv' => [ 'mode' => config('app.env'), 'name' => config('app.name'), + 'url' => config('app.url'), 'locale' => app()->getLocale(), 'locales' => config('app.locales'), 'fallbackLocale' => app()->getFallbackLocale(), diff --git a/app/Http/Requests/Back/UpdateDynamicFieldRequest.php b/app/Http/Requests/Back/UpdateDynamicFieldRequest.php index 6e8bc4b9..b220cdd0 100644 --- a/app/Http/Requests/Back/UpdateDynamicFieldRequest.php +++ b/app/Http/Requests/Back/UpdateDynamicFieldRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Back; use App\Enums\DynamicFieldTypes; use App\Models\PageConfig; +use App\Traits\Requests\HasInertiaPicture; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -11,6 +12,8 @@ use Uwil\LaravelCommon\Helpers\ToolboxHelper; abstract class UpdateDynamicFieldRequest extends FormRequest { + use HasInertiaPicture; + /** @var \App\Models\PageConfig|null $configModel */ private $configModel = null; @@ -35,6 +38,7 @@ abstract class UpdateDynamicFieldRequest extends FormRequest protected function prepareForValidation(): void { $this->configModel = PageConfig::query()->whereKey($this->input('config_id'))->firstOrFail(); + $this->mergeInertiaPicture('content'); } /** @@ -60,6 +64,7 @@ abstract class UpdateDynamicFieldRequest extends FormRequest $mergeRules = $this->compileValidate('required|string|min:15', $input); $mergeRules = collect($mergeRules) ->map(function ($rule) { + // TODO: check pour utiliser la rule wysiwyg length. if (\strpos($rule, 'max:') !== false) { $max = \intval(str_replace('max:', '', $rule)); return function ($attribute, $value, $fail) use ($max) { @@ -112,6 +117,12 @@ abstract class UpdateDynamicFieldRequest extends FormRequest $rules = array_merge($rules, [ $ruleKey => $this->compileValidate('required|email|min:3', $input) ]); + case DynamicFieldTypes::image->value: + $defaultRules = 'required|file|mimes:jpg,jpeg,png,pdf|max:5120|dimensions:width=100,height=100'; + $imgRules = $this->compileValidate($defaultRules, $input); + $rules = array_merge($rules, [ + $ruleKey => $this->pictureRules($ruleKey, implode("|", $imgRules)), + ]); break; } //end switch } //end foreach @@ -138,32 +149,22 @@ abstract class UpdateDynamicFieldRequest extends FormRequest /** * Merge native validations and custom validations. * - * @param string|array $inputTypeValidate + * @param string|array $defaultValidations * @param array $input * @return array - * @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh */ - private function compileValidate(string|array $inputTypeValidate, array $input): array + private function compileValidate(string|array $defaultValidations, array $input): array { - // phpcs:enable - if (\is_string($inputTypeValidate)) { - $inputTypeValidate = explode('|', $inputTypeValidate); - } - /** @var array|string */ - $inputValidate = \collect($input)->get('validation', $input); - $inputValidate = \is_string($inputValidate) ? explode('|', $inputValidate) : $inputValidate; - foreach ($inputValidate as $validate) { - if (!\in_array($validate, $inputTypeValidate)) { - $inputTypeValidate[] = $validate; - } - } + $newValidations = $this->mergeValidations($defaultValidations, $input); - $rules = \collect($inputTypeValidate)->unique()->sort(function ($valueA, $valueB) { + // Get unique rule and priorise important rules first. + $rules = \collect($newValidations)->unique()->sort(function ($valueA, $valueB) { $valueA = $this->ruleHasToComeFirst($valueA); $valueB = $this->ruleHasToComeFirst($valueB); return $valueA - $valueB; }); + // Conditions the rules. $hasRequire = $rules->first(fn($rule) => $rule === 'required') ? true : false; $hasRequireCond = $rules->first(fn($rule) => \strpos($rule, 'required_') !== false) ? true : false; $hasNullable = $rules->first(fn($rule) => \strpos($rule, 'nullable') !== false) ? true : false; @@ -173,9 +174,55 @@ abstract class UpdateDynamicFieldRequest extends FormRequest if ($hasRequire and ($hasRequireCond or $hasNullable or $hasSometime or $hasPresent)) { $rules = $rules->reject(fn($rule) => $rule === 'required'); } + return $rules->all(); } + /** + * Merge new validations into default validations. + * + * @param string|array $defaultValidations + * @param array $input + * @return array + */ + private function mergeValidations($defaultValidations, $input): array + { + if (\is_string($defaultValidations)) { + $defaultValidations = explode('|', $defaultValidations); + } + /** @var array|string $inputValidations */ + $inputValidations = \collect($input)->get('validation', $input); + $inputValidations = \is_string($inputValidations) ? explode('|', $inputValidations) : $inputValidations; + + foreach ($inputValidations as $validate) { + // Split a string by a special character. + $parts = explode(':', $validate, 2); + + // Check if there is a special character. + if (count($parts) > 1) { + [$ruleName, $ruleValue] = $parts; + + // Deletes any old rule that starts with “ruleName:”. + $defaultValidations = collect($defaultValidations) + ->reject(function ($existingRule) use ($ruleName) { + return is_string($existingRule) && str_starts_with($existingRule, sprintf('%s:', $ruleName)); + }) + ->values() + ->all(); + + // Add the new rule. + $defaultValidations[] = $validate; + } else { + // Check if rule without special character already exist. + if (!in_array($validate, $defaultValidations)) { + // Add the input rule. + $defaultValidations[] = $validate; + } + } + } + return $defaultValidations; + } + /** * Does the rule need to come first ? * diff --git a/app/Models/DynamicPage.php b/app/Models/DynamicPage.php index 935f7b90..e65af67f 100644 --- a/app/Models/DynamicPage.php +++ b/app/Models/DynamicPage.php @@ -3,12 +3,15 @@ namespace App\Models; use App\Contracts\TranslatableModel; +use App\Enums\DynamicFieldTypes; use App\Enums\DynamicPages\PageStatusEnum; +use App\Helpers\DynamicPageHelper; use App\Helpers\TranslationHelper; use App\Traits\Models\HasTranslations; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -69,6 +72,24 @@ class DynamicPage extends Model implements TranslatableModel 'content', ]; + public function inertiaFill(array $attributes) + { + if (!empty($attributes['content'])) { + $attributes['content'] = collect($attributes['content']) + ->map(function ($value, $key) { + if ( + Str::startsWith($key, DynamicFieldTypes::image->name . '_') && + $value instanceof UploadedFile + ) { + return FileHelper::storeFile($this, $value); + } + return $value; + }) + ->all(); + } + parent::fill($attributes); + } + /** * Perform any actions required after the model boots. * @@ -87,7 +108,7 @@ class DynamicPage extends Model implements TranslatableModel }); static::updated(function (self $model) { self::resetCache(); - self::removeImgs($model); + DynamicPageHelper::removeImgs($model); }); static::deleted(function () { self::resetCache(); @@ -105,7 +126,7 @@ class DynamicPage extends Model implements TranslatableModel }); static::forceDeleted(function (self $model) { self::resetCache(); - self::removeImgs($model); + DynamicPageHelper::removeImgs($model, true); }); } @@ -128,25 +149,6 @@ class DynamicPage extends Model implements TranslatableModel self::validateFallbackTranslations($model); } - /** - * Remove imgs from wysiwyg OR dynamic wysiwyg. - * - * @param self $model - * @return void - */ - public static function removeImgs(self $model): void - { - $locale = config('app.locale'); - $model = $model->load('config'); - $currentContent = json_decode($model->getAttributes()['content'], true); - $originalContent = $model->getOriginal('content'); - - $currentHtml = self::flattenContent($currentContent[$locale] ?? null); - $originalHtml = self::flattenContent($originalContent[$locale] ?? null); - - FileHelper::removeOldModelFilesFromHtml($currentHtml, $originalHtml); - } - /** * Concat all wysiwyg strings in only one. * diff --git a/app/Models/PageConfig.php b/app/Models/PageConfig.php index 4f7806b3..b0219a93 100644 --- a/app/Models/PageConfig.php +++ b/app/Models/PageConfig.php @@ -75,8 +75,11 @@ class PageConfig extends Model implements TranslatableModel static::deleted(function (self $model) { $model->pages()->delete(); }); - static::forceDeleted(function (self $model) { - $model->pages()->forceDelete(); + static::forceDeleting(function (self $model) { + $pages = $model->pages()->withTrashed()->get(); + $pages->each(function ($page) { + $page->forceDelete(); + }); }); } diff --git a/app/Traits/Requests/HasInertiaPicture.php b/app/Traits/Requests/HasInertiaPicture.php new file mode 100644 index 00000000..5c6220e8 --- /dev/null +++ b/app/Traits/Requests/HasInertiaPicture.php @@ -0,0 +1,64 @@ +convertedFiles) || empty($this->convertedFiles[$field])) { + return; + } + + $converted = $this->convertedFiles[$field]; // Ce qu'on veut merger + + // Si l'input du champ n'existe pas, on le crée avec converted + if (!$this->has($field)) { + $this->merge([$field => $converted]); + return; + } + + // Si l'input existe et que converted a aussi des données => merge récursif + $original = $this->input($field); + + // On vérifie que les deux sont bien des tableaux (sinon array_merge_recursive plantera dans le bueno) + if (!is_array($original)) { + $original = [$original]; + } + + if (!is_array($converted)) { + $converted = [$converted]; + } + + $merged = array_merge_recursive($original, $converted); + $this->merge([$field => $merged]); + } + + protected function pictureRules(string $field = "picture", string $rules) + { + if (is_string($this->$field) && (\request()->method() === "PUT")) { + $isNullable = \strpos($rules, 'nullable'); + $extensions = explode("|", explode("|mimes:", $rules)[1])[0]; + $allowedExtensions = explode(',', $extensions); // Ex: "jpg,png" → ['jpg', 'png'] + return [ + $isNullable ? 'nullable' : 'required', + 'string', + 'regex:/^storage\/modelfiles\/+[a-zA-Z0-9_]*\/+[0-9]{2}\/+[0-9]{2}\/+[0-9]{2}\/+[a-zA-Z0-9]{2}\/+[a-zA-Z0-9_-]*/', + function ($attribute, $value, $fail) use ($allowedExtensions) { + $extension = pathinfo($value, PATHINFO_EXTENSION); + if (!in_array(strtolower($extension), $allowedExtensions)) { + $fail("Le fichier doit avoir une extension valide : " . implode(', ', $allowedExtensions)); + } + } + ]; + } + return $rules; + } +} diff --git a/composer.json b/composer.json index cbb606f8..a3db6f03 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "spatie/laravel-translatable": "^6.11", "tightenco/ziggy": "^2.5", "uwil/laravel-backend": "^3.0", - "uwil/laravel-common": "^4.1.1" + "uwil/laravel-common": "^4.1" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 938afb48..7f7af251 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "40d7c92cb446269a04483548cf3ddc61", + "content-hash": "7b52ba13bb6f291b8488e22d72ccecbc", "packages": [ { "name": "24slides/laravel-saml2", @@ -1314,16 +1314,16 @@ }, { "name": "inertiajs/inertia-laravel", - "version": "v2.0.7", + "version": "v2.0.10", "source": { "type": "git", "url": "https://github.com/inertiajs/inertia-laravel.git", - "reference": "a08aea5a16fed187dcf815c9037b62cc544ab809" + "reference": "07da425d58a3a0e3ace9c296e67bd897a6e47009" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/a08aea5a16fed187dcf815c9037b62cc544ab809", - "reference": "a08aea5a16fed187dcf815c9037b62cc544ab809", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/07da425d58a3a0e3ace9c296e67bd897a6e47009", + "reference": "07da425d58a3a0e3ace9c296e67bd897a6e47009", "shasum": "" }, "require": { @@ -1378,9 +1378,9 @@ ], "support": { "issues": "https://github.com/inertiajs/inertia-laravel/issues", - "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.7" + "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.10" }, - "time": "2025-09-24T16:49:48+00:00" + "time": "2025-09-28T21:21:36+00:00" }, { "name": "intervention/gif", @@ -1650,16 +1650,16 @@ }, { "name": "laravel/framework", - "version": "v12.31.1", + "version": "v12.33.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "281b711710c245dd8275d73132e92635be3094df" + "reference": "124efc5f09d4668a4dc13f94a1018c524a58bcb1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/281b711710c245dd8275d73132e92635be3094df", - "reference": "281b711710c245dd8275d73132e92635be3094df", + "url": "https://api.github.com/repos/laravel/framework/zipball/124efc5f09d4668a4dc13f94a1018c524a58bcb1", + "reference": "124efc5f09d4668a4dc13f94a1018c524a58bcb1", "shasum": "" }, "require": { @@ -1687,7 +1687,6 @@ "monolog/monolog": "^3.0", "nesbot/carbon": "^3.8.4", "nunomaduro/termwind": "^2.0", - "phiki/phiki": "^2.0.0", "php": "^8.2", "psr/container": "^1.1.1|^2.0.1", "psr/log": "^1.0|^2.0|^3.0", @@ -1866,7 +1865,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-09-23T15:33:04+00:00" + "time": "2025-10-07T14:30:39+00:00" }, { "name": "laravel/prompts", @@ -1990,16 +1989,16 @@ }, { "name": "laravel/telescope", - "version": "v5.12.0", + "version": "v5.14.0", "source": { "type": "git", "url": "https://github.com/laravel/telescope.git", - "reference": "b4fb98b57f4aeb48b74f79aa7c684a20e302dacd" + "reference": "99e9487604e67424d07b83b5ab3401850d237685" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/b4fb98b57f4aeb48b74f79aa7c684a20e302dacd", - "reference": "b4fb98b57f4aeb48b74f79aa7c684a20e302dacd", + "url": "https://api.github.com/repos/laravel/telescope/zipball/99e9487604e67424d07b83b5ab3401850d237685", + "reference": "99e9487604e67424d07b83b5ab3401850d237685", "shasum": "" }, "require": { @@ -2053,9 +2052,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.12.0" + "source": "https://github.com/laravel/telescope/tree/v5.14.0" }, - "time": "2025-09-18T15:08:51+00:00" + "time": "2025-10-06T14:52:49+00:00" }, { "name": "laravel/tinker", @@ -2125,16 +2124,16 @@ }, { "name": "ldc/ldccrmv4client", - "version": "4.1.4", + "version": "4.1.5", "source": { "type": "git", "url": "git@lab.s-web.fr:ldc-web/projets/crm-client.git", - "reference": "9aa66a8b3f62c401b0fd43616ba5fd52fe15541a" + "reference": "9725ed12914d75c39f0ee66990a1bff6c77f457c" }, "dist": { "type": "zip", - "url": "https://lab.s-web.fr/api/v4/projects/26/packages/composer/archives/ldc/ldccrmv4client.zip?sha=9aa66a8b3f62c401b0fd43616ba5fd52fe15541a", - "reference": "9aa66a8b3f62c401b0fd43616ba5fd52fe15541a", + "url": "https://lab.s-web.fr/api/v4/projects/26/packages/composer/archives/ldc/ldccrmv4client.zip?sha=9725ed12914d75c39f0ee66990a1bff6c77f457c", + "reference": "9725ed12914d75c39f0ee66990a1bff6c77f457c", "shasum": "" }, "require": { @@ -2164,21 +2163,16 @@ "license": [ "MIT" ], - "authors": [ - { - "name": "Jérémy Munsch", - "email": "jeremy.munsch@ldc.fr", - "homepage": "https://jeremydev.ovh" - } - ], "description": "LDC CRM Client V4", - "homepage": "https://github.com/kwaadpepper", "keywords": [ "CRM", "Client", "LDC", "Laravel" - ] + ], + "support": { + "email": "digital@ldc.fr" + } }, { "name": "league/commonmark", @@ -3531,77 +3525,6 @@ ], "time": "2025-05-25T14:28:00+00:00" }, - { - "name": "phiki/phiki", - "version": "v2.0.4", - "source": { - "type": "git", - "url": "https://github.com/phikiphp/phiki.git", - "reference": "160785c50c01077780ab217e5808f00ab8f05a13" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phikiphp/phiki/zipball/160785c50c01077780ab217e5808f00ab8f05a13", - "reference": "160785c50c01077780ab217e5808f00ab8f05a13", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "league/commonmark": "^2.5.3", - "php": "^8.2", - "psr/simple-cache": "^3.0" - }, - "require-dev": { - "illuminate/support": "^11.45", - "laravel/pint": "^1.18.1", - "orchestra/testbench": "^9.15", - "pestphp/pest": "^3.5.1", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^7.1.6" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Phiki\\Adapters\\Laravel\\PhikiServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Phiki\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ryan Chandler", - "email": "support@ryangjchandler.co.uk", - "homepage": "https://ryangjchandler.co.uk", - "role": "Developer" - } - ], - "description": "Syntax highlighting using TextMate grammars in PHP.", - "support": { - "issues": "https://github.com/phikiphp/phiki/issues", - "source": "https://github.com/phikiphp/phiki/tree/v2.0.4" - }, - "funding": [ - { - "url": "https://github.com/sponsors/ryangjchandler", - "type": "github" - }, - { - "url": "https://buymeacoffee.com/ryangjchandler", - "type": "other" - } - ], - "time": "2025-09-20T17:21:02+00:00" - }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -4927,16 +4850,16 @@ }, { "name": "symfony/console", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7" + "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", - "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", + "url": "https://api.github.com/repos/symfony/console/zipball/2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", + "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", "shasum": "" }, "require": { @@ -5001,7 +4924,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.3" + "source": "https://github.com/symfony/console/tree/v7.3.4" }, "funding": [ { @@ -5021,7 +4944,7 @@ "type": "tidelift" } ], - "time": "2025-08-25T06:35:40+00:00" + "time": "2025-09-22T15:31:00+00:00" }, { "name": "symfony/css-selector", @@ -5157,16 +5080,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" + "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/99f81bc944ab8e5dae4f21b4ca9972698bbad0e4", + "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4", "shasum": "" }, "require": { @@ -5214,7 +5137,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.2" + "source": "https://github.com/symfony/error-handler/tree/v7.3.4" }, "funding": [ { @@ -5234,7 +5157,7 @@ "type": "tidelift" } ], - "time": "2025-07-07T08:17:57+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/event-dispatcher", @@ -5466,16 +5389,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00" + "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7475561ec27020196c49bb7c4f178d33d7d3dc00", - "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c061c7c18918b1b64268771aad04b40be41dd2e6", + "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6", "shasum": "" }, "require": { @@ -5525,7 +5448,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.3" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.4" }, "funding": [ { @@ -5545,20 +5468,20 @@ "type": "tidelift" } ], - "time": "2025-08-20T08:04:18+00:00" + "time": "2025-09-16T08:38:17+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b" + "reference": "b796dffea7821f035047235e076b60ca2446e3cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/72c304de37e1a1cec6d5d12b81187ebd4850a17b", - "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b796dffea7821f035047235e076b60ca2446e3cf", + "reference": "b796dffea7821f035047235e076b60ca2446e3cf", "shasum": "" }, "require": { @@ -5643,7 +5566,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.3" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.4" }, "funding": [ { @@ -5663,20 +5586,20 @@ "type": "tidelift" } ], - "time": "2025-08-29T08:23:45+00:00" + "time": "2025-09-27T12:32:17+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575" + "reference": "ab97ef2f7acf0216955f5845484235113047a31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a32f3f45f1990db8c4341d5122a7d3a381c7e575", - "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575", + "url": "https://api.github.com/repos/symfony/mailer/zipball/ab97ef2f7acf0216955f5845484235113047a31d", + "reference": "ab97ef2f7acf0216955f5845484235113047a31d", "shasum": "" }, "require": { @@ -5727,7 +5650,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.3" + "source": "https://github.com/symfony/mailer/tree/v7.3.4" }, "funding": [ { @@ -5747,20 +5670,20 @@ "type": "tidelift" } ], - "time": "2025-08-13T11:49:31+00:00" + "time": "2025-09-17T05:51:54+00:00" }, { "name": "symfony/mime", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" + "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", - "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35", + "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35", "shasum": "" }, "require": { @@ -5815,7 +5738,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.2" + "source": "https://github.com/symfony/mime/tree/v7.3.4" }, "funding": [ { @@ -5835,7 +5758,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-09-16T08:38:17+00:00" }, { "name": "symfony/options-resolver", @@ -6739,16 +6662,16 @@ }, { "name": "symfony/process", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "32241012d521e2e8a9d713adb0812bb773b907f1" + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/32241012d521e2e8a9d713adb0812bb773b907f1", - "reference": "32241012d521e2e8a9d713adb0812bb773b907f1", + "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", "shasum": "" }, "require": { @@ -6780,7 +6703,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.3" + "source": "https://github.com/symfony/process/tree/v7.3.4" }, "funding": [ { @@ -6800,7 +6723,7 @@ "type": "tidelift" } ], - "time": "2025-08-18T09:42:54+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -6887,16 +6810,16 @@ }, { "name": "symfony/routing", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" + "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", - "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "url": "https://api.github.com/repos/symfony/routing/zipball/8dc648e159e9bac02b703b9fbd937f19ba13d07c", + "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c", "shasum": "" }, "require": { @@ -6948,7 +6871,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.2" + "source": "https://github.com/symfony/routing/tree/v7.3.4" }, "funding": [ { @@ -6968,7 +6891,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/service-contracts", @@ -7055,16 +6978,16 @@ }, { "name": "symfony/string", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c" + "reference": "f96476035142921000338bad71e5247fbc138872" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", - "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", + "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", + "reference": "f96476035142921000338bad71e5247fbc138872", "shasum": "" }, "require": { @@ -7079,7 +7002,6 @@ }, "require-dev": { "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", "symfony/http-client": "^6.4|^7.0", "symfony/intl": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3.0", @@ -7122,7 +7044,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.3" + "source": "https://github.com/symfony/string/tree/v7.3.4" }, "funding": [ { @@ -7142,20 +7064,20 @@ "type": "tidelift" } ], - "time": "2025-08-25T06:35:40+00:00" + "time": "2025-09-11T14:36:48+00:00" }, { "name": "symfony/translation", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "e0837b4cbcef63c754d89a4806575cada743a38d" + "reference": "ec25870502d0c7072d086e8ffba1420c85965174" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/e0837b4cbcef63c754d89a4806575cada743a38d", - "reference": "e0837b4cbcef63c754d89a4806575cada743a38d", + "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174", + "reference": "ec25870502d0c7072d086e8ffba1420c85965174", "shasum": "" }, "require": { @@ -7222,7 +7144,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.3" + "source": "https://github.com/symfony/translation/tree/v7.3.4" }, "funding": [ { @@ -7242,7 +7164,7 @@ "type": "tidelift" } ], - "time": "2025-08-01T21:02:37+00:00" + "time": "2025-09-07T11:39:36+00:00" }, { "name": "symfony/translation-contracts", @@ -7398,16 +7320,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f" + "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/34d8d4c4b9597347306d1ec8eb4e1319b1e6986f", - "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", + "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", "shasum": "" }, "require": { @@ -7461,7 +7383,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.4" }, "funding": [ { @@ -7481,7 +7403,7 @@ "type": "tidelift" } ], - "time": "2025-08-13T11:49:31+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "tightenco/ziggy", @@ -7726,16 +7648,16 @@ }, { "name": "uwil/laravel-common", - "version": "4.1.1", + "version": "4.1.2", "source": { "type": "git", "url": "git@lab.s-web.fr:ldc-web/projets/laravel-common.git", - "reference": "7c08c4f545307bdba8bd09835b59982094b06e49" + "reference": "ae2ef93a05ae8fbeae1799ad12f9dc5172f50f60" }, "dist": { "type": "zip", - "url": "https://lab.s-web.fr/api/v4/projects/215/packages/composer/archives/uwil/laravel-common.zip?sha=7c08c4f545307bdba8bd09835b59982094b06e49", - "reference": "7c08c4f545307bdba8bd09835b59982094b06e49", + "url": "https://lab.s-web.fr/api/v4/projects/215/packages/composer/archives/uwil/laravel-common.zip?sha=ae2ef93a05ae8fbeae1799ad12f9dc5172f50f60", + "reference": "ae2ef93a05ae8fbeae1799ad12f9dc5172f50f60", "shasum": "" }, "require": { @@ -8966,16 +8888,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.29", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan-phar-composer-source.git", - "reference": "git" - }, + "version": "2.1.30", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d618573eed4a1b6b75e37b2e0b65ac65c885d88e", - "reference": "d618573eed4a1b6b75e37b2e0b65ac65c885d88e", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a4a7f159927983dd4f7c8020ed227d80b7f39d7d", + "reference": "a4a7f159927983dd4f7c8020ed227d80b7f39d7d", "shasum": "" }, "require": { @@ -9020,7 +8937,7 @@ "type": "github" } ], - "time": "2025-09-25T06:58:18+00:00" + "time": "2025-10-02T16:07:52+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9359,16 +9276,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.41", + "version": "11.5.42", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b42782bcb947d2c197aea42ce9714ee2d974b283" + "reference": "1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b42782bcb947d2c197aea42ce9714ee2d974b283", - "reference": "b42782bcb947d2c197aea42ce9714ee2d974b283", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c", + "reference": "1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c", "shasum": "" }, "require": { @@ -9440,7 +9357,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.41" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.42" }, "funding": [ { @@ -9464,7 +9381,7 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:32:10+00:00" + "time": "2025-09-28T12:09:13+00:00" }, { "name": "sebastian/cli-parser", @@ -10701,12 +10618,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.2" }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-dev": [], + "plugin-api-version": "2.3.0" } diff --git a/lang/en/back.php b/lang/en/back.php index f0d57e90..a711c69d 100644 --- a/lang/en/back.php +++ b/lang/en/back.php @@ -93,4 +93,39 @@ return [ "missing_translations" => "The translation of the :field for the language :locales is missing.|The translation of the :field for :locales languages are missing.", "archives" => "archives", + "validate" => "confirm", + "cancel" => "cancel", + "crop" => "crop", + "filter" => "filter|filters", + "image_cropper" => [ + "action_the_crop" => ":action the crop", + "toast" => [ + "too_big" => "Max size : :maxSize. This file is too big", + "no_file" => "no file selected !", + "forbidden_file" => "file not allowed.", + "too_many" => "the max file allowed exceeded (1)", + "defaultError" => "an error occured !" + ], + "validation" => [ + "rules_between" => "media must do :width px width and :height px height.", + "rules_weight" => "max size : :size", + "allowed_formats" => "allowed formats : :formats" + ], + "filters" => [ + 'zoomIn' => "zoom in", + "zoomOut" => "zoom out", + "zoom" => "zoom", + "rotation_forward" => "rotation forward", + "rotation_back" => "rotation back", + "rotation" => "rotation", + "grayscale" => "grayscale", + "sepia" => "sepia", + "saturation" => "saturation", + "contrast" => "contrast", + "brightness" => "brightness", + "reset" => "reset", + "vertical_flip" => "vertical flip", + "horizontal_flip" => "horizontal flip", + ] + ] ]; diff --git a/lang/fr/back.php b/lang/fr/back.php index 97c32a33..01384547 100644 --- a/lang/fr/back.php +++ b/lang/fr/back.php @@ -89,8 +89,45 @@ return [ en conséquence.", "config_save_warning_list" => "Voici la liste des :models concernés:|Voici la liste des :models concernées:", "config_archived" => "Cette configuration est actuellement archivée", + "config_complete_label" => "Label complet de l'input:", "unsupported_type" => "Type non supporté:", "missing_translations" => "La traduction du champs :field pour la langue :locales est manquante.|La traduction du champs :field pour les langues :locales sont manquantes.", "archives" => "archives", + "validate" => "valider", + "cancel" => "annuler", + "crop" => "recadrer", + "filter" => "filtre|filtres", + "image_cropper" => [ + "action_the_crop" => ":action le cadrage", + "toast" => [ + "too_big" => "taille maximale autorisée : :maxSize. Le fichier est trop volumineux" , + "no_file" => "aucun fichier sélectionné !", + "forbidden_file" => "fichier non autorisé.", + "too_many" => "le nombre de fichier autorisé est dépassé (1)", + "defaultError" => "une erreur est survenue !" + ], + "validation" => [ + "rules_between" => "le média doit faire :width px de large et :height px de haut.", + "rules_weight" => "taille max : :size", + "allowed_formats" => "formats autorisés : :formats", + "all_formats" => "tous" + ], + "filters" => [ + 'zoomIn' => "zoom avant", + "zoomOut" => "zoom arrière", + "zoom" => "zoom", + "rotation_forward" => "rotation avant", + "rotation_back" => "rotation arrière", + "rotation" => "rotation", + "grayscale" => "noir & Blanc", + "sepia" => "sépia", + "saturation" => "saturation", + "contrast" => "contraste", + "brightness" => "luminosité", + "reset" => "réinitialiser", + "vertical_flip" => "inverser verticalement", + "horizontal_flip" => "inverser horizontalement", + ] + ] ]; diff --git a/lang/fr/enums.php b/lang/fr/enums.php index ac050c9a..728f522d 100644 --- a/lang/fr/enums.php +++ b/lang/fr/enums.php @@ -18,6 +18,7 @@ return [ 'boolean' => 'Booléen', 'url' => 'Lien', 'email' => 'E-mail', + 'image' => 'Image' ], 'contactFormType' => [ 'collaborator' => [ diff --git a/package-lock.json b/package-lock.json index e1c96cef..bc222eef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "ldc", + "name": "srcs", "lockfileVersion": 3, "requires": true, "packages": { @@ -10,6 +10,8 @@ "@dnd-kit/utilities": "^3.2.2", "@inertiajs/react": "^2.0.17", "@ldc-web/cookie-notice": "^6.1.1", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", "@tanstack/react-table": "^8.21.3", "@types/pluralize": "^0.0.33", "@types/react": "^19.1.9", @@ -19,6 +21,7 @@ "laravel-vite-plugin": "^1.3.0", "lucide-react": "^0.503.0", "motion": "^12.23.12", + "react-dropzone": "^14.3.8", "reactjs-tiptap-editor": "^0.3.23", "slugify": "^1.6.6", "tailwind-merge": "^3.3.1", @@ -3289,6 +3292,30 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -3386,6 +3413,39 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -5947,6 +6007,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -8600,6 +8669,18 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -9950,7 +10031,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -10558,7 +10638,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -12307,7 +12386,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -12647,6 +12725,23 @@ "react": "^19.1.1" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-image-crop": { "version": "11.0.10", "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.10.tgz", @@ -12660,7 +12755,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-refresh": { diff --git a/package.json b/package.json index c8e7274b..c20532c3 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,8 @@ "@dnd-kit/utilities": "^3.2.2", "@inertiajs/react": "^2.0.17", "@ldc-web/cookie-notice": "^6.1.1", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", "@tanstack/react-table": "^8.21.3", "@types/pluralize": "^0.0.33", "@types/react": "^19.1.9", @@ -75,6 +77,7 @@ "laravel-vite-plugin": "^1.3.0", "lucide-react": "^0.503.0", "motion": "^12.23.12", + "react-dropzone": "^14.3.8", "reactjs-tiptap-editor": "^0.3.23", "slugify": "^1.6.6", "tailwind-merge": "^3.3.1", diff --git a/resources/ts/back/Components/DynamicInputs.tsx b/resources/ts/back/Components/DynamicInputs.tsx index c237bdb9..b8acb66c 100644 --- a/resources/ts/back/Components/DynamicInputs.tsx +++ b/resources/ts/back/Components/DynamicInputs.tsx @@ -8,12 +8,14 @@ import SimpleWysiwyg from "@/back/Components/Inputs/SimpleWysiwyg"; import { DynamicFieldTypes, DynamicField } from "@/back/types/global"; import str from "@/hooks/use-string"; import useTrans from "@/hooks/use-translations"; +import ImageCropper from "./Inputs/ImageCropper"; type DynamicInputsProps = { name: string; config: DynamicField[]; content: Record | null; - setData: (name: string, value: string | boolean) => void; + setData: (name: string, value: string | boolean | File | null) => void; + reset: (name?: string) => void; }; type MinMax = { @@ -26,6 +28,7 @@ export default function DynamicInputs({ config, content = {}, setData, + reset, }: DynamicInputsProps) { const enumDynamicFieldTypes = usePage().props.enumDynamicFieldTypes as DynamicFieldTypes; const transAssociateTo = useTrans("back.form_associate_to"); @@ -81,6 +84,47 @@ export default function DynamicInputs({ return validation.split("|").includes("required"); }; + /** + * Extracts a number value from a Laravel validation rule. + * + * Supports both: + * - Simple rules like "max:255" + * - Complex rules like "dimensions:width=350,height=250" + * + * @param validation Full validation string (e.g. "required|max:255|dimensions:width=350,height=250") + * @param ruleName Rule to look for (e.g. "max" or "dimensions") + * @param key Optional. If the rule contains key=value pairs (e.g. "width=350"), specify the key + * @returns number|null + */ + const extractLaravelValidationValue = ( + validation: string, + ruleName: string, + key?: string + ): string | null => { + const rules = validation.split("|"); + const rule = rules.find(r => r.startsWith(`${ruleName}:`)); + + if (!rule) return null; + + const [, rawValue] = rule.split(":"); + + if (key) { + // Complex case like dimensions:width=350,height=250 + const pairs = rawValue.split(","); + const match = pairs.find(pair => pair.startsWith(`${key}=`)); + if (match) { + const [, value] = match.split("="); + return value; + } + } else { + return rawValue; + } + + return null; + }; + + + return (
{Object.entries(config).map(([id, field]) => { @@ -161,6 +205,25 @@ export default function DynamicInputs({ setData={setData} /> ); + case enumDynamicFieldTypes.image.value: + return ( + + ); // Email input. case enumDynamicFieldTypes.email.value: return ( diff --git a/resources/ts/back/Components/Inputs/ImageCropper.tsx b/resources/ts/back/Components/Inputs/ImageCropper.tsx new file mode 100644 index 00000000..05c23a85 --- /dev/null +++ b/resources/ts/back/Components/Inputs/ImageCropper.tsx @@ -0,0 +1,1036 @@ +// * Packages +import { Dispatch, MouseEvent, SetStateAction, useCallback, useEffect, useRef, useState, type SyntheticEvent } from "react" +import { ChevronDownIcon, CropIcon, FlipHorizontalIcon, FlipVerticalIcon, RotateCcwIcon, RotateCwIcon, Trash, Trash2Icon, Upload, ZoomInIcon, ZoomOutIcon, RefreshCwIcon } from "lucide-react" +import ReactCrop, { + type Crop, + type PixelCrop, +} from "react-image-crop" +import { ErrorCode, FileWithPath, useDropzone } from "react-dropzone" +import "react-image-crop/dist/ReactCrop.css" +// * Components shadcn +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogTrigger, +} from "@/components/ui/dialog" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { Label } from "@/components/ui/label" +import { Slider } from "@/components/ui/slider" +// * Other +import useIsMobile from "@/hooks/use-mobile" +import useTrans from "@/hooks/use-translations" +import str from "@/hooks/use-string" +import { useToast } from "@/hooks/use-toast" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import InputError from "../InputError" +import { usePage } from "@inertiajs/react" +import { AppEnv } from "@/back/types/global" +import { route } from "ziggy-js" + + +export type FileWithPreview = FileWithPath & { + preview: string +} + +type ImageCropperProps = { + name: string; + value?: string | null, + setData: (name: string, value: File | string | null) => void; + label?: string; + required?: boolean; + validationRules?: { + width: string | null; + height: string | null; + } | null + maxFileSize: string | null; + allowedFormats: string | null; + reset: (name?: string) => void; + fileName?: string; +} + +interface ImageCropperComponentProps { + dialogOpen: boolean; + name: string; + value?: string | null; + setData: (name: string, value: File | string | null) => void; + setDialogOpen: Dispatch> + selectedFile: FileWithPreview | null + setSelectedFile: Dispatch> + validationRules: CropValidationRules + reset: (name?: string) => void; + setIsReset: (value: boolean) => void; + isReset: boolean; + url: string; + finalFileName: string; +} +interface CropValidationRules { + width: number + height: number +} + +export default function ImageCropper(props: ImageCropperProps) { + const { + value, + setData, + name = "defaultName", + label = "defaultLabel", + required = false, + maxFileSize, + validationRules, + allowedFormats, + reset, + fileName = 'image' + } = props; + + const { toast } = useToast(); + const { errors } = usePage().props; + const { url } = usePage().props.appEnv as AppEnv; + const [isReset, setIsReset] = useState(false); + const finalFileName = `${fileName}.png`; + const [selectedFile, setSelectedFile] = useState( + value + ? ({ + preview: `${url}/${value}` + } as unknown as FileWithPreview) + : null + ); + + const [isDialogOpen, setDialogOpen] = useState(false) + const [accept, setAccept] = useState({ + "image/*": [], + }); + const [extensions, setExtensions] = useState(); + + const localValidationRules = { + width: validationRules?.width ? parseInt(validationRules.width, 10) : 250, + height: validationRules?.height ? parseInt(validationRules?.height, 10) : 250, + }; + + const localMaxFileSize = maxFileSize ? (parseInt(maxFileSize, 10) * 1024) : 50000; + + // Fonction utilitaire pour formater la taille de fichier + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const buildDropzoneAccept = (): Record => { + if (!allowedFormats) return {}; + + const extensions = allowedFormats.split(","); + setExtensions(allowedFormats); + const mimeMap: Record = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + pdf: "application/pdf", + svg: "image/svg+xml", + webp: "image/webp", + }; + + const result: Record> = {}; + + extensions.forEach(ext => { + const cleanExt = ext.trim().toLowerCase(); + const mime = mimeMap[cleanExt]; + if (mime) { + if (!result[mime]) { + result[mime] = new Set(); + } + result[mime].add(`.${cleanExt}`); + } + }); + + // Convert Set to Array for React Dropzone + const accept: Record = {}; + for (const [mime, extSet] of Object.entries(result)) { + accept[mime] = Array.from(extSet); + } + setAccept(accept as any); + return accept; + }; + useEffect(() => { + buildDropzoneAccept() + }, []) + + /** This error message for the useDropZone can not be used directly in the hook because or react hook error, they have to be used as they actually are*/ + const dropErrorMessages = { + noFile: str(useTrans('back.image_cropper.toast.no_file')).ucFirst().value(), + tooLarge: str(useTrans('back.image_cropper.toast.too_big', { + params: { + maxSize: formatFileSize(localMaxFileSize) + } + })).ucFirst().value(), + forbidden: str(useTrans('back.image_cropper.toast.forbidden_file')).ucFirst().value(), + tooMany: str(useTrans('back.image_cropper.toast.too_many')).ucFirst().value(), + default: str(useTrans('back.image_cropper.toast.defaultError')).ucFirst().value(), + }; + + + const onDrop = useCallback( + (acceptedFiles: FileWithPath[]) => { + const file = acceptedFiles[0] + if (!file) { + toast({ + title: "Oups !", + description: dropErrorMessages.noFile + }) + return + } + const fileWithPreview = Object.assign(file, { + preview: URL.createObjectURL(file), + }) + + setSelectedFile(fileWithPreview) + setDialogOpen(true) + }, + [maxFileSize, formatFileSize], + ) + + + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + accept, + multiple: false, + maxSize: localMaxFileSize, + onDropRejected: (fileRejections) => { + const rejection = fileRejections[0] + const rejectionCode = rejection?.errors?.[0]?.code; + if (rejectionCode === ErrorCode.FileTooLarge) { + toast({ + title: 'Oups !', + description: `${dropErrorMessages.tooLarge} (${formatFileSize(rejection.file.size)}).` + }) + } else if (rejectionCode === ErrorCode.FileInvalidType) { + toast({ + title: 'Oups !', + description: dropErrorMessages.forbidden + }) + } else if (rejectionCode === ErrorCode.TooManyFiles) { + toast({ + title: 'Oups !', + description: dropErrorMessages.tooMany + }) + } else { + toast({ + title: 'Oups !', + description: dropErrorMessages.default + }) + } + } + }); + + return ( +
+ +
+ {selectedFile ? ( + + ) : ( +
+ +
+
+ )} +
+ + {extensions ? ( + str(useTrans('back.image_cropper.validation.allowed_formats', { + params: { + formats: extensions.toString() + } + })).ucFirst().value() + ) : ( + str(useTrans('back.image_cropper.validation.all_formats')).ucFirst().value() + )}.
+ {str(useTrans('back.image_cropper.validation.rules_weight', { + params: { + size: formatFileSize(localMaxFileSize).toString(), + } + })).ucFirst().value()} +
+ +
+ ) +} + + +function ImageCropperComponent({ + dialogOpen, + setDialogOpen, + selectedFile, + setSelectedFile, + validationRules, + setData, + name, + value, + reset, + setIsReset, + isReset, + url, + finalFileName, +}: ImageCropperComponentProps) { + + const aspect = 1 + const isMobile = useIsMobile() + const imgRef = useRef(null) + + const hasRouteParams = route().routeParams?.dynamic_page; + const isDuplicating = hasRouteParams ? (route().current('back.dynamic-pages.duplicate', {dynamic_page: route().routeParams.dynamic_page}) ?? false) : false; + // Zone of the crop in %. + const [cropZone, setCropZone] = useState() + const [croppedImage, setCroppedImage] = useState("") + const [rotation, setRotation] = useState(0) + const [flipX, setFlipX] = useState(false) + const [flipY, setFlipY] = useState(false) + const [zoom, setZoom] = useState(0) + const [scaleFactor, setScaleFactor] = useState(1) + const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }) + const [isDragging, setIsDragging] = useState(false) + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) + const [originalDimensions, setOriginalDimensions] = useState({ width: 0, height: 0 }) + + // États pour les filtres + const [brightness, setBrightness] = useState(100) + const [contrast, setContrast] = useState(100) + const [saturation, setSaturation] = useState(100) + const [sepia, setSepia] = useState(0) + const [grayscale, setGrayscale] = useState(0) + const [filtersOpen, setFiltersOpen] = useState(false) + + const maxContainerHeight = isMobile ? 350 : 550; + const maxContainerWidth = isMobile ? 450 : 900; + + /** + * Load image when open the dialog. + * @param e SyntheticEvent + * @return void + */ + function onImageLoad(e: SyntheticEvent): void { + const { naturalWidth: width, naturalHeight: height } = e.currentTarget + + // STOCKER les dimensions naturelles originales + setOriginalDimensions({ width, height }) + + // Calculer le facteur d'échelle pour l'image + const imageWidthRatio = maxContainerWidth / width + const imageHeightRatio = maxContainerHeight / height + const imageScaleFactor = Math.min(imageWidthRatio, imageHeightRatio, 1) // Ne pas agrandir l'image + + setScaleFactor(imageScaleFactor) + placeCropZone(width, height, imageScaleFactor) + } + + /** + * Generate imge from canvas with all crop options. + * @param image HTMLImageElement Ref. of the image tag. + * @param cropLocalization Crop localization in the image. + * @return string + */ + function getCroppedImg( + image: HTMLImageElement, + cropLocalization: PixelCrop + ): string { + const canvas = document.createElement("canvas") + + + // NOUVEAU CALCUL: l'image a une taille de base (scaleFactor) + zoom appliqué via transform + const baseDisplayedWidth = originalDimensions.width * scaleFactor + const baseDisplayedHeight = originalDimensions.height * scaleFactor + const effectiveZoom = 1 + zoom + + // Calculer l'échelle vers l'image naturelle + const scaleToNatural = image.naturalWidth / baseDisplayedWidth + + // Les coordonnées de crop doivent être ajustées pour le zoom transform + // Le zoom étant centré, on doit calculer le décalage + const zoomOffsetX = (baseDisplayedWidth * effectiveZoom - baseDisplayedWidth) / 2 + const zoomOffsetY = (baseDisplayedHeight * effectiveZoom - baseDisplayedHeight) / 2 + + // Ajuster les coordonnées par rapport à la position ET au zoom + const adjustedCropX = ((cropLocalization.x - imagePosition.x + zoomOffsetX) / effectiveZoom) * scaleToNatural + const adjustedCropY = ((cropLocalization.y - imagePosition.y + zoomOffsetY) / effectiveZoom) * scaleToNatural + const cropWidth = (cropLocalization.width / effectiveZoom) * scaleToNatural + const cropHeight = (cropLocalization.height / effectiveZoom) * scaleToNatural + + // Calculer l'angle en radians + const rad = (rotation * Math.PI) / 180 + + // Créer un canvas temporaire assez grand pour contenir toute la rotation + const tmpCanvas = document.createElement("canvas") + const tmpCtx = tmpCanvas.getContext("2d")! + tmpCanvas.width = image.naturalWidth + tmpCanvas.height = image.naturalHeight + + tmpCtx.save() + + // Appliquer les filtres CSS au contexte du canvas (sauf blur qui ne marche pas bien) + tmpCtx.filter = ` + brightness(${brightness}%) + contrast(${contrast}%) + saturate(${saturation}%) + sepia(${sepia}%) + grayscale(${grayscale}%) + ` + + // Placer le centre de rotation + tmpCtx.translate(tmpCanvas.width / 2, tmpCanvas.height / 2) + + // Appliquer seulement le flip (le scale est maintenant dans les dimensions) + tmpCtx.scale( + flipX ? -1 : 1, + flipY ? -1 : 1 + ) + + // Appliquer rotation + tmpCtx.rotate((flipX && flipY) ? rad : ((flipX || flipY) ? - rad : rad)) + + // Dessiner l'image au centre avec les filtres appliqués + tmpCtx.drawImage( + image, + -image.naturalWidth / 2, + -image.naturalHeight / 2 + ) + + tmpCtx.restore() + + + // Maintenant, extraire la zone cropée depuis le canvas temporaire + const imageData = tmpCtx.getImageData(adjustedCropX, adjustedCropY, cropWidth, cropHeight) + + // IMPORTANT: Redimensionner à la taille finale souhaitée (validationRules) + canvas.width = validationRules.width + canvas.height = validationRules.height + + const ctx = canvas.getContext("2d")! + + // Créer un canvas temporaire avec l'image cropée + const tempCanvas = document.createElement("canvas") + const tempCtx = tempCanvas.getContext("2d")! + tempCanvas.width = cropWidth + tempCanvas.height = cropHeight + tempCtx.putImageData(imageData, 0, 0) + + // Redimensionner l'image cropée aux dimensions finales souhaitées + ctx.drawImage( + tempCanvas, + 0, 0, cropWidth, cropHeight, // Source + 0, 0, validationRules.width, validationRules.height // Destination + ) + return canvas.toDataURL("image/png") + } + + function urlToBase64( + url: string, + callback: (base64: string | ArrayBuffer | null) => void, + errorCallback?: (error: any) => void + ): void { + fetch(url) + .then(response => response.blob()) + .then(blob => { + const reader = new FileReader(); + + reader.onloadend = () => callback(reader.result); + reader.readAsDataURL(blob); + }) + .catch(error => { + if (errorCallback) errorCallback(error); + }); + } + + + /** + * Generate file from the base64. + * @param dataUrl base64 string + * @param filename name of your file + * @return File + */ + function base64ToFile(dataUrl: string): File { + const arr = dataUrl.split(',') + const mimeMatch = arr[0].match(/:(.*?);/) + const mime = mimeMatch ? mimeMatch[1] : 'image/png' + const bstr = atob(arr[1]) + let n = bstr.length + const u8arr = new Uint8Array(n) + + while (n--) { + u8arr[n] = bstr.charCodeAt(n) + } + + return new File([u8arr], finalFileName, { type: mime }) + } + + + // Fonctions de gestion du drag + function handleMouseDown(e: MouseEvent) { + setIsDragging(true) + setDragStart({ + x: e.clientX - imagePosition.x, + y: e.clientY - imagePosition.y + }) + } + + function handleMouseMove(e: MouseEvent) { + if (!isDragging) return + + setImagePosition({ + x: e.clientX - dragStart.x, + y: e.clientY - dragStart.y + }) + } + + function handleMouseUp() { + setIsDragging(false) + } + + // Reset de la position et dimensions quand l'image change + useEffect(() => { + setImagePosition({ x: 0, y: 0 }) + setOriginalDimensions({ width: 0, height: 0 }) + }, [selectedFile]) + + useEffect(() => { + if(!value || !isDuplicating) return; + const fileBase = `${url}/${value}`; + urlToBase64( + fileBase, + (base64) => { + if (typeof base64 === 'string') { + const file = base64ToFile(base64) + setData(name, file) + } + + }, + (err) => { } + ); + }, []) + + /** + * Crop the image with all options. + */ + async function onCrop() { + try { + if (imgRef.current && cropZone) { + // Recalculer les coordonnées en pixels à partir de cropZone actuel + // cropZone est en % par rapport au container de ReactCrop + const cropX = (cropZone.x / 100) * maxContainerWidth + const cropY = (cropZone.y / 100) * maxContainerHeight + const cropWidth = (cropZone.width / 100) * maxContainerWidth + const cropHeight = (cropZone.height / 100) * maxContainerHeight + + // Créer l'objet cropLocalization mis à jour + const currentCropLocalization: PixelCrop = { + unit: 'px', + x: cropX, + y: cropY, + width: cropWidth, + height: cropHeight + } + + const finalImage = getCroppedImg(imgRef.current, currentCropLocalization) + setCroppedImage(finalImage) + const file = base64ToFile(finalImage) + if (!isReset) { + setData(name, file) + } else { + reset(name) + } + setDialogOpen(false) + } + } catch (error) { + alert("Something went wrong!") + } + } + + const placeCropZone = (width: number, height: number, scaleFactor: number) => { + const displayedWidth = width * scaleFactor + const displayedHeight = height * scaleFactor + + // Calculer l'offset pour centrer l'image dans le container + const centerOffsetX = (maxContainerWidth - displayedWidth) / 2 + const centerOffsetY = (maxContainerHeight - displayedHeight) / 2 + + // Initialiser la position centrée (seulement si imagePosition est à 0,0) + if (imagePosition.x === 0 && imagePosition.y === 0) { + setImagePosition({ x: centerOffsetX, y: centerOffsetY }) + } + + // Zone de crop simple : garder les dimensions d'origine, réduire seulement si ça dépasse le container + let finalCropWidth = validationRules.width + let finalCropHeight = validationRules.height + + // Si la zone dépasse le container, la réduire proportionnellement + if (finalCropWidth > maxContainerWidth * 0.9 || finalCropHeight > maxContainerHeight * 0.9) { + const containerWidthRatio = (maxContainerWidth * 0.9) / finalCropWidth + const containerHeightRatio = (maxContainerHeight * 0.9) / finalCropHeight + const reductionRatio = Math.min(containerWidthRatio, containerHeightRatio) + + finalCropWidth = validationRules.width * reductionRatio + finalCropHeight = validationRules.height * reductionRatio + } + + // Convertir en pourcentage par rapport au CONTAINER (pas à l'image) + const cropWidthPercent = (finalCropWidth / maxContainerWidth) * 100 + const cropHeightPercent = (finalCropHeight / maxContainerHeight) * 100 + + setCropZone(cropZone ?? { + unit: '%', + width: cropWidthPercent, + height: cropHeightPercent, + x: (100 - cropWidthPercent) / 2, // Centrer horizontalement + y: (100 - cropHeightPercent) / 2, // Centrer verticalement + }) + } + + // Fonction de reset pour revenir à l'état initial + function resetToInitialState() { + setRotation(0) + setFlipX(false) + setFlipY(false) + setZoom(0) + // Reset des filtres + setBrightness(100) + setContrast(100) + setSaturation(100) + setSepia(0) + setGrayscale(0) + if (value) setIsReset(true); + // Recalculer et forcer le centrage de l'image ET de la zone de crop + if (imgRef.current && originalDimensions.width > 0) { + const displayedWidth = originalDimensions.width * scaleFactor + const displayedHeight = originalDimensions.height * scaleFactor + + // Calculer l'offset pour centrer l'image dans le container + const centerOffsetX = (maxContainerWidth - displayedWidth) / 2 + const centerOffsetY = (maxContainerHeight - displayedHeight) / 2 + + // Forcer la position centrée + setImagePosition({ x: centerOffsetX, y: centerOffsetY }) + + // IMPORTANT: Recalculer la zone de crop centrée + let finalCropWidth = validationRules.width + let finalCropHeight = validationRules.height + + // Si la zone dépasse le container, la réduire proportionnellement + if (finalCropWidth > maxContainerWidth * 0.9 || finalCropHeight > maxContainerHeight * 0.9) { + const containerWidthRatio = (maxContainerWidth * 0.9) / finalCropWidth + const containerHeightRatio = (maxContainerHeight * 0.9) / finalCropHeight + const reductionRatio = Math.min(containerWidthRatio, containerHeightRatio) + + finalCropWidth = validationRules.width * reductionRatio + finalCropHeight = validationRules.height * reductionRatio + } + + // Convertir en pourcentage et centrer + const cropWidthPercent = (finalCropWidth / maxContainerWidth) * 100 + const cropHeightPercent = (finalCropHeight / maxContainerHeight) * 100 + + setCropZone({ + unit: '%', + width: cropWidthPercent, + height: cropHeightPercent, + x: (100 - cropWidthPercent) / 2, // Centrer horizontalement + y: (100 - cropHeightPercent) / 2, // Centrer verticalement + }) + } else { + // Si pas d'image, remettre à 0,0 + setImagePosition({ x: 0, y: 0 }) + } + } + + const otherOptions = [ + { + icon: FlipHorizontalIcon, + fn: () => setFlipX((prev) => !prev), + tooltip: 'horizontal_flip', + }, + { + icon: FlipVerticalIcon, + fn: () => setFlipY((prev) => !prev), + tooltip: 'vertical_flip', + }, + { + icon: RefreshCwIcon, + fn: resetToInitialState, + tooltip: 'reset', + }, + ] + + const imageFilterOptions = [ + { + label: () => `${str(useTrans('back.image_cropper.filters.brightness')).ucFirst().value()}: ${brightness}%`, + tooltip: 'brightness', + slider: { + min: 0, + max: 200, + step: 1, + value: [brightness], + onValueChange: (e: number[]) => { + setIsReset(false) + setBrightness(parseInt(e[0].toString())) + }, + } + }, + { + label: () => `${str(useTrans('back.image_cropper.filters.contrast')).ucFirst().value()}: ${contrast}%`, + tooltip: 'contrast', + slider: { + min: 0, + max: 200, + step: 1, + value: [contrast], + onValueChange: (e: number[]) => { + setContrast(parseInt(e[0].toString())) + setIsReset(false) + }, + } + }, + { + label: () => `${str(useTrans('back.image_cropper.filters.saturation')).ucFirst().value()}: ${saturation}%`, + tooltip: 'saturation', + slider: { + min: 0, + max: 200, + step: 1, + value: [saturation], + onValueChange: (e: number[]) => { + setSaturation(parseInt(e[0].toString())) + setIsReset(false) + }, + } + }, + { + label: () => `${str(useTrans('back.image_cropper.filters.sepia')).ucFirst().value()}: ${sepia}%`, + tooltip: 'sepia', + slider: { + min: 0, + max: 100, + step: 1, + value: [sepia], + onValueChange: (e: number[]) => { + setSepia(parseInt(e[0].toString())) + setIsReset(false) + }, + } + }, + { + label: () => `${str(useTrans('back.image_cropper.filters.grayscale')).ucFirst().value()}: ${grayscale}%`, + tooltip: 'grayscale', + slider: { + min: 0, + max: 100, + step: 1, + value: [grayscale], + onValueChange: (e: number[]) => { + setGrayscale(parseInt(e[0].toString())) + setIsReset(false) + }, + } + } + ]; + + const mainOptions = [ + { + label: () => `${str(useTrans('back.image_cropper.filters.rotation')).ucFirst().value()}: ${rotation}°`, + tooltip: 'rotation', + slider: { + min: -180, + max: 180, + step: 1, + value: [rotation], + onValueChange: (e: number[]) => { + setRotation(parseInt(e[0].toString())) + setIsReset(false) + }, + }, + buttons: [ + { + icon: RotateCcwIcon, + tooltip: 'rotation_back', + fn: () => setRotation((r) => r - 10), + }, + { + icon: RotateCwIcon, + tooltip: 'rotation_forward', + fn: () => setRotation((r) => r + 10), + }, + ], + + }, + { + label: () => `${str(useTrans('back.image_cropper.filters.zoom')).ucFirst().value()}: ${Math.round((1 + zoom) * 100)}%`, + tooltip: 'zoom', + slider: { + min: -0.99, + max: 500, + step: 0.1, + value: [Math.round((1 + zoom) * 100)], + onValueChange: (e: number[]) => { + setZoom((e[0] / 100) - 1) + setIsReset(false) + }, + }, + buttons: [ + { + icon: ZoomOutIcon, + tooltip: 'zoomOut', + fn: () => { + setZoom((value) => { + const newValue = value - 0.1; + return newValue < -0.99 ? -0.99 : parseFloat(newValue.toFixed(1)); + } + ) + setIsReset(false) + }, + }, + { + icon: ZoomInIcon, + tooltip: 'zoomIn', + fn: () => { + setZoom((value) => { + const newValue = value + 0.1; + return newValue > 4 ? 4 : parseFloat(newValue.toFixed(1)); + } + ) + setIsReset(false) + }, + }, + ], + } + ]; + return ( + + +
+
+ ImagePreview + {!selectedFile && ( +
+ +
+ )} +
+
+ + + + + + {str(useTrans('back.cancel')).ucFirst().value()} + + +
+ + {/* Cropper content */} +
+ setCropZone(percentCrop)} + onComplete={() => { }} + aspect={aspect} + className="w-full" + locked + > +
+ 0 ? `${originalDimensions.width * scaleFactor}px` : 'auto', + height: originalDimensions.height > 0 ? `${originalDimensions.height * scaleFactor}px` : 'auto', + transform: ` + scale(${1 + zoom}) + rotate(${rotation}deg) + scaleX(${flipX ? -1 : 1}) + scaleY(${flipY ? -1 : 1}) + `, + filter: ` + brightness(${brightness}%) + contrast(${contrast}%) + saturate(${saturation}%) + sepia(${sepia}%) + grayscale(${grayscale}%) + `, + transformOrigin: 'center center', + transition: isDragging ? 'none' : "transform 0.2s ease, filter 0.2s ease", + cursor: isDragging ? 'grabbing' : 'grab', + userSelect: 'none' + }} + draggable={false} + /> +
+
+
+
+ {mainOptions.map((option, index) => ( +
+
+ + + + + + + {str(useTrans(`back.image_cropper.filters.${option.tooltip}`)).ucFirst().value()} + + +
+
+ {option.buttons.map((option, btnIndex) => ( + + + + + + {str(useTrans(`back.image_cropper.filters.${option.tooltip}`)).ucFirst().value()} + + + ))} +
+
+ ))} + {/* FILTRES - Collapsible */} + + + + + +
+ {imageFilterOptions.map((option, index) => ( +
+ + + + + + + {str(useTrans(`back.image_cropper.filters.${option.tooltip}`)).ucFirst().value()} + + +
+ ))} +
+
+
+ + {/* Others actions */} +
+ {otherOptions.map((option, index) => { + return <> + + + + + + {str(useTrans(`back.image_cropper.filters.${option.tooltip}`)).ucFirst().value()} + + + + })} +
+
+ + + + + + + + {str(useTrans('back.image_cropper.action_the_crop', { params: { action: str(useTrans('back.cancel')).ucFirst().value() } })).ucFirst().value()} + + + + + + + + + + {str(useTrans('back.image_cropper.action_the_crop', { params: { action: str(useTrans('back.validate')).ucFirst().value() } })).ucFirst().value()} + + + +
+
+ ) +} diff --git a/resources/ts/back/Components/Inputs/SimpleNumber.tsx b/resources/ts/back/Components/Inputs/SimpleNumber.tsx index 35769de2..97bfcc14 100644 --- a/resources/ts/back/Components/Inputs/SimpleNumber.tsx +++ b/resources/ts/back/Components/Inputs/SimpleNumber.tsx @@ -30,13 +30,17 @@ export default function SimpleNumber({ label, id = name, step = 1, - limits = { min: 0, max: 1000 }, + limits, placeholder = "back.enter_number_here", required = true, disabled = false, setData, }: SimpleNumberProps) { - const safeValue = value >= limits.min && value <= limits.max ? value : limits.min; + const localLimits = { + min: limits?.min ? limits.min : 1, + max: limits?.max ? limits.max : 1000 + } + const safeValue = value >= localLimits.min && value <= localLimits.max ? value : localLimits.min; const { errors } = usePage().props; const [inputValue, setInputValue] = useState(safeValue); @@ -55,8 +59,8 @@ export default function SimpleNumber({ type="number" placeholder={useTrans(placeholder)} value={inputValue} - min={limits.min} - max={limits.max} + min={localLimits.min} + max={localLimits.max} step={step} onChange={(e) => { setInputValue(Number(e.target.value)); diff --git a/resources/ts/back/Components/Layout/AppSidebar.tsx b/resources/ts/back/Components/Layout/AppSidebar.tsx index 56ae39ee..c1ccbe8a 100644 --- a/resources/ts/back/Components/Layout/AppSidebar.tsx +++ b/resources/ts/back/Components/Layout/AppSidebar.tsx @@ -167,12 +167,12 @@ export default function AppSidebar() { icon: History, show: authPolicies.activityLogs.viewAny, }, - { - title: useTrans("back.telescope"), - link: route("telescope"), - icon: Telescope, - show: appEnv.mode === "local", - }, + // { + // title: useTrans("back.telescope"), + // link: route("telescope"), + // icon: Telescope, + // show: appEnv.mode === "local", + // }, ], }, ]; diff --git a/resources/ts/back/Components/PageConfigs/SortableField.tsx b/resources/ts/back/Components/PageConfigs/SortableField.tsx index d64fa58c..088e5002 100644 --- a/resources/ts/back/Components/PageConfigs/SortableField.tsx +++ b/resources/ts/back/Components/PageConfigs/SortableField.tsx @@ -1,5 +1,5 @@ // * Packages -import { TrashIcon, GripVerticalIcon } from "lucide-react"; +import { TrashIcon, GripVerticalIcon, InfoIcon, Copy } from "lucide-react"; import { usePage } from "@inertiajs/react"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -15,6 +15,9 @@ import EnumSelect from "@/back/Components/Inputs/EnumSelect"; import SimpleString from "@/back/Components/Inputs/SimpleString"; import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; import { DynamicField, DynamicFieldTypes } from "@/back/types/global"; +import useTrans from "@/hooks/use-translations"; +import str from "@/hooks/use-string"; +import { useEffect, useState } from "react"; type SortableFieldProps = { id: string; @@ -47,6 +50,31 @@ export default function SortableField({ transDragAndDrop, }: SortableFieldProps) { const enumDynamicFieldTypes = usePage().props.enumDynamicFieldTypes as DynamicFieldTypes; + const getEnumDynamicFieldType = (value: number) => { + return Object.values(enumDynamicFieldTypes).find((def) => def.value === value); + }; + + const enumCurrent = getEnumDynamicFieldType(field.type)?.name + "_"; + const splitAndGetEditableKey = (localeEditableKey: string): string => { + const baseKeyArray = localeEditableKey.split(enumCurrent); + return baseKeyArray[baseKeyArray.length - 1]; + } + const [editableKey, setEditableKey] = useState(splitAndGetEditableKey(field.key)) + const [isTypeChanged, setIsTypeChanged] = useState(false) + const completeKey = enumCurrent + editableKey + + useEffect(() => { + if (isTypeChanged) { + setEditableKey(splitAndGetEditableKey(editableKey)) + } + setIsTypeChanged(false) + }, [isTypeChanged]) + + + + useEffect(() => { + updateField(id, { key: completeKey }) + }, [fieldKey, editableKey, completeKey]) const { attributes, @@ -63,7 +91,9 @@ export default function SortableField({ opacity: isDragging ? 0.5 : 1, zIndex: isDragging ? 10 : 1, }; - + const copyToClipBoard = (data: string) => { + navigator.clipboard.writeText(data) + } return (
updateField(id, { label: value })} /> - updateField(id, { key: value })} - /> +
+ { + setEditableKey(value) + // updateField(id, { key: value }) + }} + /> +
+ + + + + +

{str(useTrans('back.config_complete_label')).ucFirst().value()}

+

{completeKey} +

+
+
+ +
+
+ setData={(_, value) => { + setIsTypeChanged(true) updateField(id, { type: Number(value) }) - } + }} required={required} disabled={disabled} /> diff --git a/resources/ts/back/Pages/DynamicPages/Form.tsx b/resources/ts/back/Pages/DynamicPages/Form.tsx index f887af3c..01cf66be 100644 --- a/resources/ts/back/Pages/DynamicPages/Form.tsx +++ b/resources/ts/back/Pages/DynamicPages/Form.tsx @@ -116,15 +116,20 @@ export default function Form({ const { toast } = useToast(); /** Form section */ - const { post, patch, setData, data } = useForm({ + const { post, setData, data, reset } = useForm({ + _method: method === Methods.POST ? 'post' : 'put', title: model?.title ? model?.title[locale]: "", content: model?.content ? model?.content[locale]: "", config_id: model?.config_id ?? configOptions[defaultConfigId].id, status: model?.status ?? enumDynamicPageStatus.draft.value, isLocked: model?.isLocked ?? false, }); - const routeOptions = () => { - return { + + const submit = (e: FormEvent) => { + 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"); + post(routeUrl, { preserveScroll: true, onSuccess: () => { toast({ @@ -139,21 +144,7 @@ export default function Form({ description: messages.descriptionError, }); }, - }; - }; - const submit = (e: FormEvent) => { - e.preventDefault(); - const routeUrl = route(`back.${modelRoute}.${action}`, model?.id); - switch (method) { - case Methods.POST: - post(routeUrl, routeOptions()); - break; - case Methods.PATCH: - patch(routeUrl, routeOptions()); - break; - default: - throw new Error("The method must be post or patch"); - } + }); }; useEffect(() => { @@ -289,6 +280,7 @@ export default function Form({ config={getConfig(data.config_id)} content={typeof data.content === "object" ? data.content : {}} setData={setData} + reset={reset} /> )} diff --git a/resources/ts/back/types/global.d.ts b/resources/ts/back/types/global.d.ts index f4db8caf..f6194c2a 100644 --- a/resources/ts/back/types/global.d.ts +++ b/resources/ts/back/types/global.d.ts @@ -6,6 +6,7 @@ import { ElementType } from "react"; type AppEnv = { mode: string; name: string; + url: string; locale: string; locales: string; fallbackLocale: string; diff --git a/resources/ts/components/ui/slider.tsx b/resources/ts/components/ui/slider.tsx new file mode 100644 index 00000000..09391e86 --- /dev/null +++ b/resources/ts/components/ui/slider.tsx @@ -0,0 +1,63 @@ +"use client" + +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "@/lib/utils" + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ) + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ) +} + +export { Slider }