Merge branch 'develop' into trunk

This commit is contained in:
K Muller 2026-06-25 11:43:31 +02:00
commit 7219701f4b
9 changed files with 265 additions and 46 deletions

View file

@ -98,7 +98,8 @@ class UrlController extends Controller
'%s.%s',
Str::uuid(),
new \SplFileInfo(asset($url->logo))->getExtension()
)) :
)
) :
$url->logo;
$url->saveOrFail();
@ -156,16 +157,23 @@ class UrlController extends Controller
{
return DB::transaction(function () use ($request, $url) {
$oldUrl = $url->value;
$url->fill($request->validated());
$data = $request->validated();
// Reassign the logo in image settings to save only the path.
// Do it after the first saveOrFail to get the real logo path, not tmp.
$settings = json_decode($request->validated('image_settings'));
if (empty($settings->image)) {
$url->logo = null;
}
$url->fill($data);
$url->saveOrFail();
if ($oldUrl !== $url->value) {
$this->saveUrlHistory($url, $oldUrl);
}
// Reassign the logo in image settings to save only the path.
// Do it after the first saveOrFail to get the real logo path, not tmp.
$settings = json_decode($request->validated('image_settings'));
$settings->image = $url->logo;
$url->image_settings = $settings;

View file

@ -50,7 +50,7 @@ class SecureHeaders
"default-src 'none';",
"manifest-src 'self';",
// * CKEDITOR Specific https://ckeditor.com/docs/ckeditor5/latest/installation/advanced/csp.html
"connect-src 'self' blob: " . implode(' ', [
"connect-src 'self' blob: data: " . implode(' ', [
// * VITE DEV
config('app.debug') ? "wss://{$host}:4443" : '',
config('app.debug') ? "https://{$host}:4443" : '',

View file

@ -3,6 +3,7 @@
namespace App\Http\Requests\Bo\Urls;
use App\Enums\Users\RoleEnum;
use App\Traits\Requests\HasPictureWithSvgSupport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
@ -11,6 +12,7 @@ use Uwil\LaravelCommon\Traits\Requests\HasPicture;
class StoreUrlRequest extends FormRequest
{
use HasPicture;
use HasPictureWithSvgSupport;
/**
* Determine if the user is authorized to make this request.
@ -58,7 +60,7 @@ class StoreUrlRequest extends FormRequest
return \array_merge(
$rules,
$this->pictureRules('logo', 100, 100, 500, 500, true),
$this->pictureRulesWithSvgSupport('logo', 100, 100, 500, 500, true),
);
}
}

View file

@ -30,7 +30,7 @@ class UpdateUrlRequest extends StoreUrlRequest
return \array_merge(
$rules,
$this->pictureRules('logo', 100, 100, 500, 500, true),
$this->pictureRulesWithSvgSupport('logo', 100, 100, 500, 500, true),
);
}
}

View file

@ -0,0 +1,175 @@
<?php
namespace App\Traits\Requests;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\File;
trait HasPictureWithSvgSupport
{
/**
* Minimum width
*
* @var integer
*/
private $minWidth = 400;
/**
* Maximum width
*
* @var integer
*/
private $maxWidth = 1800;
/**
* Minimum height
*
* @var integer
*/
private $minHeight = 300;
/**
* Maximum height
*
* @var integer
*/
private $maxHeight = 1800;
/**
* Image rules
*
* @param string $name
* @param integer|null $minWidth
* @param integer|null $minHeight
* @param integer|null $maxWidth
* @param integer|null $maxHeight
* @param boolean $nullable
* @param integer|null $size
* @param string|null $requiredWith
* @param string|null $requiredWithout
* @return array
*/
public function pictureRulesWithSvgSupport(
string $name = 'picture',
?int $minWidth = null,
?int $minHeight = null,
?int $maxWidth = null,
?int $maxHeight = null,
bool $nullable = false,
?int $size = null,
?string $requiredWith = null,
?string $requiredWithout = null,
): array {
$dimensions = (object)[
'minWidth' => $minWidth ?? $this->minWidth,
'minHeight' => $minHeight ?? $this->minHeight,
'maxWidth' => $maxWidth ?? $this->maxWidth,
'maxHeight' => $maxHeight ?? $this->maxHeight
];
return [
$name => [
!$nullable ? 'required' : 'nullable',
(strlen($requiredWith)) ? 'required_with:' . $requiredWith : '',
(strlen($requiredWithout)) ? 'required_without:' . $requiredWithout : '',
($size) ? 'max:' . $size : '',
function (string $attribute, $value, callable $fail) use ($dimensions) {
self::validatePicture($attribute, $value, $fail, $dimensions);
}
]
];
}
/**
* Validate picture
*
* @param string $attribute
* @param string|null $value
* @param callable $fail
* @param object $sizes
* @return void
*/
public static function validatePicture(string $attribute, string|null $value, callable $fail, object $sizes): void
{
if (!($useStorage = Storage::exists($value)) && !File::exists($value)) {
$fail(trans(':attribute n\'existe pas dans le système de fichier', [
'attribute' => $attribute
]));
return;
}
$fileFullPath = self::getUploadedFileFullPath($value, $useStorage);
if (
!\in_array(self::getUploadedFileMimeType($value, $useStorage), [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/svg+xml'
])
) {
$fail(trans(':attribute doit être une image de type \'image/jpeg,image/jpg,image/png,image/gif\',image/svg\'', [
'attribute' => $attribute
]));
return;
}
if (self::getUploadedFileMimeType($value, $useStorage) !== "image/svg+xml") {
if (!($dimensions = \getimagesize($fileFullPath))) {
$fail(trans(':attribute impossible de valider les dimensions', [
'attribute' => $attribute
]));
} else {
$width = intval($dimensions[0]);
$height = intval($dimensions[1]);
$xMessage = trans(
":attribute la largeur doit être comprise entre {$sizes->minWidth}px et {$sizes->maxWidth}px"
);
if ($sizes->minWidth === $sizes->maxWidth) {
$xMessage = trans(":attribute la largeur doit être de {$sizes->minWidth}px");
}
$yMessage = trans(
":attribute la hauteur doit être comprise entre {$sizes->minHeight}px et {$sizes->maxHeight}px"
);
if ($sizes->minHeight === $sizes->maxHeight) {
$yMessage = trans(":attribute la hauteur doit être de {$sizes->minHeight}px");
}
if (($width < $sizes->minWidth) || ($width > $sizes->maxWidth)) {
$fail($xMessage);
}
if (($height < $sizes->minHeight) || ($height > $sizes->maxHeight)) {
$fail($yMessage);
}
} //end if
}
}
/**
* Get the file full path
*
* @param string $value
* @param boolean $useStorage
* @return string
*/
private static function getUploadedFileFullPath(string $value, bool $useStorage): string
{
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk();
return !$useStorage ? (realpath($value) ?: $value) : $disk->path($value);
}
/**
* Get the file mimetype
*
* @param string $value
* @param boolean $useStorage
* @return string|false
*/
private static function getUploadedFileMimeType(string $value, bool $useStorage)
{
return !$useStorage ? File::mimeType($value) : Storage::mimeType($value);
}
}

View file

@ -27,6 +27,7 @@ return [
'validate' => 'Validate',
'preview' => 'Preview',
'remove_picture' => 'Remove picture',
'svg_not_croppable' => 'SVGs do not require cropping'
],
'WordCounterComponent' => [
'words' => 'Words',

View file

@ -27,6 +27,7 @@ return [
'validate' => 'Valider',
'preview' => 'Prévisualisation',
'remove_picture' => 'Supprimer l\'image',
'svg_not_croppable' => 'Les SVG ne nécessitent pas de recadrage'
],
'WordCounterComponent' => [
'words' => 'Mots',

View file

@ -32,15 +32,15 @@
type="file"
class="d-none"
:name="parameters.name"
accept=".jpg,.jpeg,.gif,.png"
accept=".jpg,.jpeg,.gif,.png,.svg"
:aria-describedby="`Help${parameters.id}`"
>
<input
v-if="typeof fileOutput === 'string'"
v-if="typeof fileOutput === 'string' || fileOutput === null"
class="d-none"
:name="parameters.name"
type="text"
:value="fileOutput"
:value="typeof fileOutput === 'string' ? fileOutput : ''"
>
<!-- END OUTPUT HTML INPUT -->
<!-- FILENAME DISPLAY -->
@ -77,22 +77,24 @@
>
<FontAwesomeIcon icon="fa-solid fa-eye" />
</span>
</button>
<button
@click.prevent="prepareImageEditor()"
:disabled="!hasPicture"
:class="`btn ${!hasCroppedPicture ? 'btn-primary' : 'btn-success'}`"
type="button"
data-bs-toggle="modal"
:data-bs-target="`#Modal${parameters.id}`"
</button>
<span
data-bs-toggle="tooltip"
:title="isCurrentFileSvg
? trans.methods.__('vue.ImageInputComponent.svg_not_croppable')
: trans.methods.__('vue.ImageInputComponent.resize_source_picture')"
>
<span
data-bs-toggle="tooltip"
:title="trans.methods.__('vue.ImageInputComponent.resize_source_picture')"
<button
@click.prevent="prepareImageEditor()"
:disabled="!hasPicture || isCurrentFileSvg"
:class="`btn ${!hasCroppedPicture ? 'btn-primary' : 'btn-success'}`"
type="button"
data-bs-toggle="modal"
:data-bs-target="`#Modal${parameters.id}`"
>
<FontAwesomeIcon icon="fa-solid fa-crop" />
</span>
</button>
</button>
</span>
<span
v-if="hasCroppedPicture"
class="input-group-text text-success"
@ -427,6 +429,7 @@ import Cropper from "cropperjs";
import { computed, defineOptions, nextTick, onMounted, reactive, ref, useAttrs, watch } from "vue";
import { Tooltips } from "./../../modules/bsTooltips";
import trans from "./../../modules/trans";
import { Tooltip } from "bootstrap";
/** If string the it is an absolute file path. */
type FileOutputValue = File|string|null;
@ -555,6 +558,14 @@ const parameters = reactive<{
},
});
const isCurrentFileSvg = ref<boolean>(false);
/** Detect SVG by mime type or extension */
function isSvgFile(file: File): boolean {
return (
file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg")
);
}
/** Internal file we are working on. */
const fileParameters = reactive<{
file: File|null,
@ -666,7 +677,10 @@ watch(previousPropValue, () => {
}
cropperReset();
hasCroppedPicture.value = false;
fetchPictureFromPath(fileParameters.filePath)!.then((file) => useThis(file));
const fetchPromise = fetchPictureFromPath(fileParameters.filePath);
if (fetchPromise) {
fetchPromise.then((file) => useThis(file));
}
});
/** Crop parameters changes. */
watch(cropperParameters, (newValue: CropperParameters) => {
@ -700,6 +714,11 @@ watch(fileDataUrl, (newValue) => {
}
fileParameters.dimensions = {...parameters.dimensions};
});
watch(isCurrentFileSvg, () => {
nextTick(() => {
tooltips.value?.refreshTooltips();
});
});
// * METHODS
function assignFromProps(): void {
@ -777,10 +796,12 @@ function chooseAFile(): void {
/** Input file has changed. */
function pictureInputHasChanged(): void {
if (inputFileImage?.value?.files && inputFileImage!.value!.files[0]) {
const file = inputFileImage.value.files[0];
cropperReset();
hasCroppedPicture.value = false;
useThis(inputFileImage!.value!.files[0]);
fileOutput.value = inputFileImage!.value!.files[0];
isCurrentFileSvg.value = isSvgFile(file);
useThis(file);
fileOutput.value = file;
}
}
@ -793,17 +814,19 @@ function fetchPictureFromPath(filePath: string): Promise<File>|void {
const prefixedFilePath: string = filePath.replace(/^\/|^/, "/"),
stringFileName: string = prefixedFilePath.substring(prefixedFilePath.lastIndexOf("/")).replace(/^\/|^/, "");
fetch(prefixedFilePath)
.then((response) => response.blob())
.then((blob: Blob) => {
const file = new File([blob], stringFileName, {
type: "image/png",
lastModified: new Date().getTime(),
});
setFileAsDataUrl(file);
accept(file);
}).catch(() => deny("Failed to fetch picture"));
}));
fetch(prefixedFilePath)
.then((response) => response.blob())
.then((blob: Blob) => {
const isSvg = stringFileName.toLowerCase().endsWith(".svg");
isCurrentFileSvg.value = isSvg;
const file = new File([blob], stringFileName, {
type: isSvg ? "image/svg+xml" : blob.type || "image/png",
lastModified: new Date().getTime(),
});
setFileAsDataUrl(file);
accept(file);
}).catch(() => deny("Failed to fetch picture"));
}));
}
/** Persist a File added from input or while component init. */
@ -868,6 +891,7 @@ function resetComponent(): void {
fileDataUrl.value = null;
fileOutput.value = null;
inputFileImage.value!.value = "";
isCurrentFileSvg.value = false;
fileParameters.fileName = trans.methods.__("vue.ImageInputComponent.choose_a_file");
fileParameters.filePath = "";
fileParameters.file = null;

View file

@ -366,15 +366,14 @@
<template v-if="imageLoaded">
<ImageInputComponent
v-model="options.image"
:id="'logo'"
:name="'logo'"
:helper="'L\'image qui va servir de vignette au produit, doit être de 500px (largeur) par 500px (hauteur) maximum'"
:width="100"
:height="100"
:required="false"
:value="options.image"
@image-file="updatePicture()"
:value="logoStoredPath"
@image-file="updatePicture"
/>
</template>
<input
@ -428,8 +427,10 @@ export default defineComponent({
this.options = data.settings;
}
if(data.logo !== "") {
this.logoStoredPath = data.logo;
this.options.image = "/" + data.logo;
} else {
this.logoStoredPath = "";
this.options.image = "";
}
}
@ -444,15 +445,20 @@ export default defineComponent({
},
methods: {
download() {
this.qrCode.update(this.options);
this.qrCode.download({ extension: this.extension as FileExtension });
},
updatePicture() {
const el = document.getElementById("PreviewHelplogo")?.parentElement?.children[1] as HTMLElement;
if (el instanceof HTMLElement) {
this.options.image = el.getAttribute("src") as string;
updatePicture(file: File | null) {
if (!file) {
this.options.image = "";
this.qrCode.update(this.options);
return;
}
const reader = new FileReader();
reader.onload = () => {
this.options.image = String(reader.result);
this.qrCode.update(this.options);
};
reader.readAsDataURL(file);
},
changeColor(color: string) {
if (!this.isTransparent) {
@ -483,6 +489,7 @@ export default defineComponent({
urlInput: HTMLInputElement,
urlInputValue: string,
imageLoaded: boolean,
logoStoredPath: string,
options: {
type: DrawType;
width: number;
@ -528,6 +535,7 @@ export default defineComponent({
urlInput: document.getElementById(this.urlInputId) as HTMLInputElement,
urlInputValue: "",
imageLoaded: false,
logoStoredPath: "",
options: {
width: 300,
height: 300,