From 1456e4c04ab9218660e65e4bd9d6ad2d763bfa9a Mon Sep 17 00:00:00 2001 From: m2cci-bartetj Date: Thu, 16 Apr 2026 11:54:12 +0200 Subject: [PATCH] FIX FLEXIAPI-441 add color picker and logo uploader to space settings --- .../Controllers/Admin/SpaceController.php | 13 +- flexiapi/app/Services/LogoService.php | 12 ++ ...d_theme_color_and_logo_to_spaces_table.php | 24 +++ flexiapi/lang/fr.json | 7 +- flexiapi/public/css/form.css | 73 ++++++++- flexiapi/public/css/style.css | 4 +- flexiapi/public/scripts/colorPicker.js | 139 ++++++++++++++++++ .../views/admin/space/configuration.blade.php | 93 +++++++++--- .../resources/views/layouts/main.blade.php | 17 ++- 9 files changed, 345 insertions(+), 37 deletions(-) create mode 100644 flexiapi/app/Services/LogoService.php create mode 100644 flexiapi/database/migrations/2026_04_14_145641_add_theme_color_and_logo_to_spaces_table.php create mode 100644 flexiapi/public/scripts/colorPicker.js diff --git a/flexiapi/app/Http/Controllers/Admin/SpaceController.php b/flexiapi/app/Http/Controllers/Admin/SpaceController.php index 9ec7e7c..54ce575 100644 --- a/flexiapi/app/Http/Controllers/Admin/SpaceController.php +++ b/flexiapi/app/Http/Controllers/Admin/SpaceController.php @@ -24,12 +24,17 @@ use App\Http\Requests\Space\Create; use App\Space; use App\Rules\Ini; use App\Rules\Domain; +use App\Services\LogoService; use Illuminate\Http\Request; use Illuminate\Validation\Rule; class SpaceController extends Controller { + public function __construct(private LogoService $logoService) + { + } + public function index() { return view('admin.space.index', ['spaces' => Space::withCount('accounts')->orderBy('host')->get()]); @@ -156,9 +161,15 @@ class SpaceController extends Controller { $request->validate([ 'newsletter_registration_address' => 'nullable|email', - 'custom_provisioning_entries' => ['nullable', new Ini(Space::FORBIDDEN_KEYS)] + 'custom_provisioning_entries' => ['nullable', new Ini(Space::FORBIDDEN_KEYS)], + 'logo' => ['nullable', 'image', 'mimes:jpeg,png,webp,svg', 'max:2048'], ]); + if ($request->hasFile('logo')) { + $space->logo = $this->logoService->store($request->file('logo')); + } + + $space->theme_hue = $request->get('theme_hue'); $space->copyright_text = $request->get('copyright_text'); $space->intro_registration_text = $request->get('intro_registration_text'); $space->newsletter_registration_address = $request->get('newsletter_registration_address'); diff --git a/flexiapi/app/Services/LogoService.php b/flexiapi/app/Services/LogoService.php new file mode 100644 index 0000000..f143d7e --- /dev/null +++ b/flexiapi/app/Services/LogoService.php @@ -0,0 +1,12 @@ +store('img', 'public'); + } +} diff --git a/flexiapi/database/migrations/2026_04_14_145641_add_theme_color_and_logo_to_spaces_table.php b/flexiapi/database/migrations/2026_04_14_145641_add_theme_color_and_logo_to_spaces_table.php new file mode 100644 index 0000000..be99bf1 --- /dev/null +++ b/flexiapi/database/migrations/2026_04_14_145641_add_theme_color_and_logo_to_spaces_table.php @@ -0,0 +1,24 @@ +integer('theme_hue')->nullable(); + $table->string('logo')->nullable(); + }); + } + + public function down(): void + { + Schema::table('spaces', function (Blueprint $table) { + $table->dropColumn('theme_hue'); + $table->dropColumn('logo'); + }); + } +}; diff --git a/flexiapi/lang/fr.json b/flexiapi/lang/fr.json index 3b718f1..f8cb0e1 100644 --- a/flexiapi/lang/fr.json +++ b/flexiapi/lang/fr.json @@ -121,6 +121,7 @@ "Files": "Fichiers", "From": "Depuis", "Hello":"Bonjour", + "Hex" : "Hex", "Host": "Hôte", "I accept the Privacy Policy": "J'accepte la Politique de Confidentialité", "I accept the Terms and Conditions": "J'accepte les Conditions Générales", @@ -183,6 +184,7 @@ "Phone Countries": "Numéros Internationaux", "Phone number": "Numéro de téléphone", "Phone registration": "Inscription par téléphone", + "Platform customization": "Customisation de la plateforme", "Please enter the new email that you would like to link to your account.": "Veuillez entre l'adresse email que vous souhaitez lier à votre compte.", "Please enter the new phone number that you would like to link to your account.": "Veuillez entrer le numéro de téléphone que vous souhaitez lier à votre compte.", "Priority rule": "Rêgle prioritaire", @@ -215,9 +217,11 @@ "Scan the following QR Code using an authenticated device and wait a few seconds.": "Scanner le QR Code avec un appareil authentifié et attendez quelques secondes", "Search by username":"Rechercher par nom d'utilisateur", "Search": "Rechercher", + "Select a color": "Sélectionner une couleur", "Select a contacts list": "Sélectionner une liste de contact", "Select a domain": "Sélectionner un domaine", "Select a file": "Choisir un fichier", + "Select an image": "Choisir une image", "Send an email to the user to reset the password": "Envoyer un email à l'utilisateur pour réinitialiser son mot de passe", "Send an email to the user with provisioning information": "Envoyer un email à l'utilisateur avec les informations de déploiement", "Send": "Envoyer", @@ -293,8 +297,9 @@ "You can now continue your registration process in the application": "Vous pouvez maintenant continuer le processus d'inscription dans l'application", "You didn't receive the code?": "Vous n'avez pas reçu le code ?", "Your account recovery code":"Votre code de récupération de compte", + "Your color will be snapped to the nearest 500 shade" : "La couleur que vous entrez sera automatiquement remplacée par sa variante 500 la plus proche.", "Your password was updated properly.": "Votre mot de passe a été mis à jour.", "Your password" : "Votre mot de passe", "Your space :space is expiring in :count days": "Votre espace :space expire dans :count jours", "Your space has expired. Access to your interface is now disabled, and your users can no longer benefit from the service. To reactivate your space, please contact your account manager.": "Votre espace est arrivé à expiration. L’accès à votre interface est désormais désactivé, et vos utilisateurs ne peuvent plus bénéficier du service. Pour réactiver votre espace, veuillez contacter votre responsable de compte." -} \ No newline at end of file +} diff --git a/flexiapi/public/css/form.css b/flexiapi/public/css/form.css index 2a0b0fe..33142fe 100644 --- a/flexiapi/public/css/form.css +++ b/flexiapi/public/css/form.css @@ -183,13 +183,13 @@ form div .btn.oppose { right: 0; } -form div input, +form div input:not([type=range]), form div textarea, form div select { padding: 1rem 2rem; background-color: var(--grey-1); border-radius: 3rem; - border: 1px solid var(--grey-2); + border: 0.063rem solid var(--grey-2); font-size: 1.5rem; resize: vertical; } @@ -261,6 +261,7 @@ input[type=number].digit { height: 5rem; -moz-appearance: textfield; -webkit-appearance: textfield; + appearance:textfield; border-radius: 1.5rem; background-color: white; padding: 0.5rem; @@ -282,7 +283,7 @@ form div input[type=checkbox] { margin-right: 1rem; } -form div input:not([type=checkbox]):not([type=radio]):not([type="number"].digit):not(.btn), +form div input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type="number"].digit):not(.btn), form div textarea, form div select { margin-top: 2.5rem; @@ -338,6 +339,70 @@ form div textarea:invalid:not(:placeholder-shown)+label { color: var(--danger); } +/* Image and Logo Picker */ + +form div .picker { + display: flex; + align-items: center; + height: 11rem; + gap: 1.5rem; + margin-top: 1rem; +} + +form canvas, .picker .color-div { + aspect-ratio: 1/1; + height: 100%; + border: 0.063rem solid var(--grey-2); + border-radius: 1.25rem; +} + +form canvas { + background-color: var(--grey-1); +} + +.picker .color-div { + background-color: var(--main-5); +} + +form input[type=range] { + appearance: none; + margin-top: 2.5rem; + box-sizing: border-box; + width: 100%; + height: 1.5rem; + border-radius: 3rem; + overflow: visible; + background: linear-gradient( + to right, + hsl(0, 100%, 50%), + hsl(60, 100%, 50%), + hsl(120, 100%, 50%), + hsl(180, 100%, 50%), + hsl(240, 100%, 50%), + hsl(300, 100%, 50%), + hsl(360, 100%, 50%) + ); +} + +input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + border: 0.25rem solid var(--grey-1); + cursor: pointer; +} + +/* Mandatory for Mozilla Firefox */ +input[type=range]::-moz-range-thumb { + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + border: 0.25rem solid var(--grey-1); + cursor: pointer; +} + /* Checkbox element */ form div.checkbox { @@ -417,4 +482,4 @@ div.select[data-value=sip_uri] ~ div.togglable.sip_uri { form section.block div.checkbox:has(input:not(:checked)) ~ div { opacity: 0.25; pointer-events: none; -} \ No newline at end of file +} diff --git a/flexiapi/public/css/style.css b/flexiapi/public/css/style.css index c298209..b8e2fc2 100644 --- a/flexiapi/public/css/style.css +++ b/flexiapi/public/css/style.css @@ -300,7 +300,7 @@ header nav a#logo::before { width: 3rem; height: 3rem; padding: 1rem; - background-image: url('../img/logo.svg'); + background-image: var(--space-logo ,url('../img/logo.svg')); background-color: var(--main-5); background-size: 3rem; background-position: center; @@ -1118,4 +1118,4 @@ dialog p { dialog::backdrop { background-color: black; opacity: 0.5; -} \ No newline at end of file +} diff --git a/flexiapi/public/scripts/colorPicker.js b/flexiapi/public/scripts/colorPicker.js new file mode 100644 index 0000000..d3635ba --- /dev/null +++ b/flexiapi/public/scripts/colorPicker.js @@ -0,0 +1,139 @@ +// Color picker +function onColorSliderChange(input) { + const group = input.closest('.color-picker-group'); + const hue = input.value; + + group.querySelector('input[type=hidden]').value = hue; + group.querySelector('input[type=text]').value = hslToHex(hue, 100, 50); + group.querySelector('.color-div').style.backgroundColor = hslToHex(hue, 100, 50); +} + +function onColorInputChange(input) { + const hex = input.value.trim(); + if (!hex.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)) { + return; + } + + const group = input.closest('.color-picker-group'); + const colorHsl = hexToHsl(hex); + group.querySelector('input[type=hidden]').value = colorHsl; + group.querySelector('input[type=range]').value = colorHsl; + group.querySelector('input[type=text]').value = hslToHex(colorHsl.h, 100, 50); + group.querySelector('.color-div').style.backgroundColor = hslToHex(colorHsl.h, 100, 50); +} + + +function hslToHex(h, s, l) { + s /= 100; + l /= 100; + + const k = n => (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + const f = n => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))))); + + const toHex = v => v.toString(16).padStart(2, '0'); + return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`; +} + +function hexToHsl(hex) { + hex = hex.replace(/^#/, ''); + if (hex.length === 3) hex = hex.split('').map(c => c + c).join(''); + + const r = parseInt(hex.slice(0, 2), 16) / 255; + const g = parseInt(hex.slice(2, 4), 16) / 255; + const b = parseInt(hex.slice(4, 6), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + let h, s; + let l = (max + min) / 2; + + if (d === 0) { + h = s = 0; + } else { + s = d / (l > 0.5 ? 2 - max - min : max + min); + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; + case g: h = ((b - r) / d + 2) / 6; break; + case b: h = ((r - g) / d + 4) / 6; break; + } + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; +} + +// Image picker +function onImageLoad(input) { + const file = input.files[0]; + if (!file) return; + if (!file.type.match('image.*')) { + console.log("Error: not an image"); + imgInput.value = ""; + return; + } + + const url = URL.createObjectURL(file); + const img = new Image(); + + const group = input.closest('.image-picker-group'); + const canvas = group.querySelector('canvas'); + const ctx = canvas.getContext("2d"); + + img.onload = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawImageProp(ctx, img); + URL.revokeObjectURL(url); + + canvas.toBlob((blob) => { + const croppedFile = new File([blob], 'logo.png', { type: 'image/png' }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(croppedFile); + input.files = dataTransfer.files; // ← remplace le fichier original + }, 'image/png'); + }; + + + + img.src = url; +} + +function initImageLoader() { + document.querySelectorAll('.image-picker-group').forEach(group => { + const canvas = group.querySelector('canvas'); + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#4e6074"; + ctx.font = "20px Noto Sans"; + ctx.textBaseline = "middle"; + + const text = "Logo"; + const textWidth = ctx.measureText(text).width; + ctx.fillText(text, (canvas.width - textWidth) / 2, canvas.height / 2); + }); +} + +function drawImageProp(ctx, img, padding = 1) { + const canvas = ctx.canvas; + const maxW = canvas.width - padding * 2; + const maxH = canvas.height - padding * 2; + + const scale = Math.min(maxW / img.width, maxH / img.height); + + const nw = img.width * scale; + const nh = img.height * scale; + + const x = (canvas.width - nw) / 2; + const y = (canvas.height - nh) / 2; + + ctx.drawImage(img, x, y, nw, nh); +} + +document.addEventListener("DOMContentLoaded", initImageLoader); diff --git a/flexiapi/resources/views/admin/space/configuration.blade.php b/flexiapi/resources/views/admin/space/configuration.blade.php index aa538ec..fd4ca3c 100644 --- a/flexiapi/resources/views/admin/space/configuration.blade.php +++ b/flexiapi/resources/views/admin/space/configuration.blade.php @@ -17,12 +17,37 @@ @include('admin.space.head') @include('admin.space.tabs') -
+ @csrf @method('put') +

{{ __('Platform customization') }}

+ +
+ +
+ + + @include('parts.errors', ['name' => 'logo']) +
+
+ +
+ + +
+
+
+ + + +
+
+ {{ __('Your color will be snapped to the nearest 500 shade') }} +
+
@@ -37,21 +62,26 @@
- + - {{ __('An email will be sent to this email when someone join the newsletter') }} + {{ __('An email will be sent to this email when someone join the newsletter') }} @include('parts.errors', ['name' => 'newsletter_registration_address'])
- + - Will be used for informational purpose in the user panel and communication emails + Will be used for informational purpose in the user panel and communication + emails @include('parts.errors', ['name' => 'account_proxy_registrar_address'])
- accounts()->count() > 0)disabled @endif id="account_realm" placeholder="server.tld" value="{{ $space->account_realm }}"> + accounts()->count() > 0) disabled @endif id="account_realm" + placeholder="server.tld" value="{{ $space->account_realm }}"> A custom realm for the Space accounts @include('parts.errors', ['name' => 'account_realm']) @@ -63,31 +93,52 @@ {{ __('In ini format, will complete the other settings.') }} - {{ __('Use ; to comment, key="value" to declare a complex string.') }} {{ __('Checkout the cheatsheets to know how to format things correctly.') }} + {{ __('Use ; to comment, key="value" to declare a complex string.') }} {{ __('Checkout the cheatsheets to know how to format things correctly.') }} @include('parts.errors', ['name' => 'custom_provisioning_entries'])
- @include('parts.form.toggle', ['object' => $space, 'key' => 'custom_provisioning_overwrite_all', 'label' => __('Allow client settings to be overwritten by the provisioning ones')]) + @include('parts.form.toggle', [ + 'object' => $space, + 'key' => 'custom_provisioning_overwrite_all', + 'label' => __('Allow client settings to be overwritten by the provisioning ones'), + ])
- @include('parts.form.toggle', ['object' => $space, 'key' => 'provisioning_use_linphone_provisioning_header', 'label' => 'Enforce X-Linphone-Provisioning header']) + @include('parts.form.toggle', [ + 'object' => $space, + 'key' => 'provisioning_use_linphone_provisioning_header', + 'label' => 'Enforce X-Linphone-Provisioning header', + ])

{{ __('Features') }}

- @include('parts.form.toggle', ['object' => $space, 'key' => 'public_registration', 'label' => __('Public registration')]) -
-
$space, 'key' => 'phone_registration', 'label' => __('Phone registration')]) -
-
- @include('parts.form.toggle', ['object' => $space, 'key' => 'intercom_features', 'label' => __('Intercom features')]) + @include('parts.form.toggle', [ + 'object' => $space, + 'key' => 'public_registration', + 'label' => __('Public registration'), + ])
+
$space, + 'key' => 'phone_registration', + 'label' => __('Phone registration'), + ])
+
+ @include('parts.form.toggle', [ + 'object' => $space, + 'key' => 'intercom_features', + 'label' => __('Intercom features'), + ]) +
-
- -
+
+ +
+ @endsection diff --git a/flexiapi/resources/views/layouts/main.blade.php b/flexiapi/resources/views/layouts/main.blade.php index c820fd3..f85f9aa 100644 --- a/flexiapi/resources/views/layouts/main.blade.php +++ b/flexiapi/resources/views/layouts/main.blade.php @@ -26,7 +26,8 @@