FIX FLEXIAPI-441 add color picker and logo uploader to space settings

This commit is contained in:
m2cci-bartetj 2026-04-16 11:54:12 +02:00
parent 40eab70d11
commit 1456e4c04a
9 changed files with 345 additions and 37 deletions

View file

@ -24,12 +24,17 @@ use App\Http\Requests\Space\Create;
use App\Space; use App\Space;
use App\Rules\Ini; use App\Rules\Ini;
use App\Rules\Domain; use App\Rules\Domain;
use App\Services\LogoService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
class SpaceController extends Controller class SpaceController extends Controller
{ {
public function __construct(private LogoService $logoService)
{
}
public function index() public function index()
{ {
return view('admin.space.index', ['spaces' => Space::withCount('accounts')->orderBy('host')->get()]); return view('admin.space.index', ['spaces' => Space::withCount('accounts')->orderBy('host')->get()]);
@ -156,9 +161,15 @@ class SpaceController extends Controller
{ {
$request->validate([ $request->validate([
'newsletter_registration_address' => 'nullable|email', '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->copyright_text = $request->get('copyright_text');
$space->intro_registration_text = $request->get('intro_registration_text'); $space->intro_registration_text = $request->get('intro_registration_text');
$space->newsletter_registration_address = $request->get('newsletter_registration_address'); $space->newsletter_registration_address = $request->get('newsletter_registration_address');

View file

@ -0,0 +1,12 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
class LogoService
{
public function store(UploadedFile $file): string
{
return $file->store('img', 'public');
}
}

View file

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('spaces', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -121,6 +121,7 @@
"Files": "Fichiers", "Files": "Fichiers",
"From": "Depuis", "From": "Depuis",
"Hello":"Bonjour", "Hello":"Bonjour",
"Hex" : "Hex",
"Host": "Hôte", "Host": "Hôte",
"I accept the Privacy Policy": "J'accepte la Politique de Confidentialité", "I accept the Privacy Policy": "J'accepte la Politique de Confidentialité",
"I accept the Terms and Conditions": "J'accepte les Conditions Générales", "I accept the Terms and Conditions": "J'accepte les Conditions Générales",
@ -183,6 +184,7 @@
"Phone Countries": "Numéros Internationaux", "Phone Countries": "Numéros Internationaux",
"Phone number": "Numéro de téléphone", "Phone number": "Numéro de téléphone",
"Phone registration": "Inscription par 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 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.", "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", "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", "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 by username":"Rechercher par nom d'utilisateur",
"Search": "Rechercher", "Search": "Rechercher",
"Select a color": "Sélectionner une couleur",
"Select a contacts list": "Sélectionner une liste de contact", "Select a contacts list": "Sélectionner une liste de contact",
"Select a domain": "Sélectionner un domaine", "Select a domain": "Sélectionner un domaine",
"Select a file": "Choisir un fichier", "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 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 an email to the user with provisioning information": "Envoyer un email à l'utilisateur avec les informations de déploiement",
"Send": "Envoyer", "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 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 ?", "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 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 was updated properly.": "Votre mot de passe a été mis à jour.",
"Your password" : "Votre mot de passe", "Your password" : "Votre mot de passe",
"Your space :space is expiring in :count days": "Votre espace :space expire dans :count jours", "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. Laccè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." "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. Laccè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."
} }

View file

@ -183,13 +183,13 @@ form div .btn.oppose {
right: 0; right: 0;
} }
form div input, form div input:not([type=range]),
form div textarea, form div textarea,
form div select { form div select {
padding: 1rem 2rem; padding: 1rem 2rem;
background-color: var(--grey-1); background-color: var(--grey-1);
border-radius: 3rem; border-radius: 3rem;
border: 1px solid var(--grey-2); border: 0.063rem solid var(--grey-2);
font-size: 1.5rem; font-size: 1.5rem;
resize: vertical; resize: vertical;
} }
@ -261,6 +261,7 @@ input[type=number].digit {
height: 5rem; height: 5rem;
-moz-appearance: textfield; -moz-appearance: textfield;
-webkit-appearance: textfield; -webkit-appearance: textfield;
appearance:textfield;
border-radius: 1.5rem; border-radius: 1.5rem;
background-color: white; background-color: white;
padding: 0.5rem; padding: 0.5rem;
@ -282,7 +283,7 @@ form div input[type=checkbox] {
margin-right: 1rem; 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 textarea,
form div select { form div select {
margin-top: 2.5rem; margin-top: 2.5rem;
@ -338,6 +339,70 @@ form div textarea:invalid:not(:placeholder-shown)+label {
color: var(--danger); 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 */ /* Checkbox element */
form div.checkbox { 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 { form section.block div.checkbox:has(input:not(:checked)) ~ div {
opacity: 0.25; opacity: 0.25;
pointer-events: none; pointer-events: none;
} }

View file

@ -300,7 +300,7 @@ header nav a#logo::before {
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
padding: 1rem; padding: 1rem;
background-image: url('../img/logo.svg'); background-image: var(--space-logo ,url('../img/logo.svg'));
background-color: var(--main-5); background-color: var(--main-5);
background-size: 3rem; background-size: 3rem;
background-position: center; background-position: center;
@ -1118,4 +1118,4 @@ dialog p {
dialog::backdrop { dialog::backdrop {
background-color: black; background-color: black;
opacity: 0.5; opacity: 0.5;
} }

139
flexiapi/public/scripts/colorPicker.js vendored Normal file
View file

@ -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);

View file

@ -17,12 +17,37 @@
@include('admin.space.head') @include('admin.space.head')
@include('admin.space.tabs') @include('admin.space.tabs')
<form method="POST" <form method="POST" action="{{ route('admin.spaces.configuration.update', $space) }}" accept-charset="UTF-8"
action="{{ route('admin.spaces.configuration.update', $space) }}" enctype="multipart/form-data">
accept-charset="UTF-8">
@csrf @csrf
@method('put') @method('put')
<h3 class="large">{{ __('Platform customization') }}</h3>
<div class="image-picker-group">
<label for="logo" style="font-weight: 700;">{{ __('Select an image') }} </label>
<div class="picker">
<canvas></canvas>
<input name="logo" type="file" accept="image/*" onchange="onImageLoad(this)">
@include('parts.errors', ['name' => 'logo'])
</div>
</div>
<div class="color-picker-group">
<label for="color-slider" style="font-weight: 700;">{{ __('Select a color') }} </label>
<input name="theme_hue" type="hidden" value="22">
<div class="picker">
<div class="color-div"></div>
<div>
<input type="text" value="#ff5e00" onblur="onColorInputChange(this)">
<label for="color-input">{{ __('Hex') }}</label>
<input class="range" type="range" min="0" max="360" value="22"
oninput="onColorSliderChange(this)">
</div>
</div>
<span class="supporting"> {{ __('Your color will be snapped to the nearest 500 shade') }}</span>
</div>
<div class="large"> <div class="large">
<textarea name="copyright_text" id="copyright_text">{{ $space->copyright_text }}</textarea> <textarea name="copyright_text" id="copyright_text">{{ $space->copyright_text }}</textarea>
<label for="copyright_text">{{ __('Copyright text') }}</label> <label for="copyright_text">{{ __('Copyright text') }}</label>
@ -37,21 +62,26 @@
</div> </div>
<div> <div>
<input name="newsletter_registration_address" id="newsletter_registration_address" placeholder="email@server.tld" type="email" value="{{ $space->newsletter_registration_address }}"> <input name="newsletter_registration_address" id="newsletter_registration_address"
placeholder="email@server.tld" type="email" value="{{ $space->newsletter_registration_address }}">
<label for="newsletter_registration_address">{{ __('Newsletter registration email address') }}</label> <label for="newsletter_registration_address">{{ __('Newsletter registration email address') }}</label>
<span class="supporting">{{ __('An email will be sent to this email when someone join the newsletter') }}</span> <span
class="supporting">{{ __('An email will be sent to this email when someone join the newsletter') }}</span>
@include('parts.errors', ['name' => 'newsletter_registration_address']) @include('parts.errors', ['name' => 'newsletter_registration_address'])
</div> </div>
<div> <div>
<input name="account_proxy_registrar_address" id="account_proxy_registrar_address" placeholder="server.tld" value="{{ $space->account_proxy_registrar_address }}"> <input name="account_proxy_registrar_address" id="account_proxy_registrar_address" placeholder="server.tld"
value="{{ $space->account_proxy_registrar_address }}">
<label for="account_proxy_registrar_address">Account proxy registrar address</label> <label for="account_proxy_registrar_address">Account proxy registrar address</label>
<span class="supporting">Will be used for informational purpose in the user panel and communication emails</span> <span class="supporting">Will be used for informational purpose in the user panel and communication
emails</span>
@include('parts.errors', ['name' => 'account_proxy_registrar_address']) @include('parts.errors', ['name' => 'account_proxy_registrar_address'])
</div> </div>
<div> <div>
<input name="account_realm" @if ($space->accounts()->count() > 0)disabled @endif id="account_realm" placeholder="server.tld" value="{{ $space->account_realm }}"> <input name="account_realm" @if ($space->accounts()->count() > 0) disabled @endif id="account_realm"
placeholder="server.tld" value="{{ $space->account_realm }}">
<label for="account_realm">Account realm</label> <label for="account_realm">Account realm</label>
<span class="supporting">A custom realm for the Space accounts</span> <span class="supporting">A custom realm for the Space accounts</span>
@include('parts.errors', ['name' => 'account_realm']) @include('parts.errors', ['name' => 'account_realm'])
@ -63,31 +93,52 @@
<textarea style="min-height: 200px;" name="custom_provisioning_entries" id="custom_provisioning_entries">{{ $space->custom_provisioning_entries }}</textarea> <textarea style="min-height: 200px;" name="custom_provisioning_entries" id="custom_provisioning_entries">{{ $space->custom_provisioning_entries }}</textarea>
<label for="custom_provisioning_entries">{{ __('Custom entries') }}</label> <label for="custom_provisioning_entries">{{ __('Custom entries') }}</label>
<span class="supporting">{{ __('In ini format, will complete the other settings.') }}</span> <span class="supporting">{{ __('In ini format, will complete the other settings.') }}</span>
<span class="supporting">{{ __('Use ; to comment, key="value" to declare a complex string.') }} <a target="_blank" href="https://cheatsheets.zip/ini.html">{{ __('Checkout the cheatsheets to know how to format things correctly.') }}</a></span> <span class="supporting">{{ __('Use ; to comment, key="value" to declare a complex string.') }} <a
target="_blank"
href="https://cheatsheets.zip/ini.html">{{ __('Checkout the cheatsheets to know how to format things correctly.') }}</a></span>
@include('parts.errors', ['name' => 'custom_provisioning_entries']) @include('parts.errors', ['name' => 'custom_provisioning_entries'])
</div> </div>
<div> <div>
@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'),
])
</div> </div>
<div> <div>
@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',
])
</div> </div>
<h3 class="large">{{ __('Features') }}</h3> <h3 class="large">{{ __('Features') }}</h3>
<div> <div>
@include('parts.form.toggle', ['object' => $space, 'key' => 'public_registration', 'label' => __('Public registration')]) @include('parts.form.toggle', [
</div> 'object' => $space,
<div 'key' => 'public_registration',
@include('parts.form.toggle', ['object' => $space, 'key' => 'phone_registration', 'label' => __('Phone registration')]) 'label' => __('Public registration'),
</div> ])
<div>
@include('parts.form.toggle', ['object' => $space, 'key' => 'intercom_features', 'label' => __('Intercom features')])
</div> </div>
<div @include('parts.form.toggle', [
'object' => $space,
'key' => 'phone_registration',
'label' => __('Phone registration'),
]) </div>
<div>
@include('parts.form.toggle', [
'object' => $space,
'key' => 'intercom_features',
'label' => __('Intercom features'),
])
</div>
<div class="large"> <div class="large">
<input class="btn" type="submit" value="{{ __('Update') }}"> <input class="btn" type="submit" value="{{ __('Update') }}">
</div> </div>
</form> </form>
<script src="{{ asset('scripts/colorPicker.js') }}"></script>
@endsection @endsection

View file

@ -26,7 +26,8 @@
<body class="@if (isset($welcome) && $welcome) welcome @endif"> <body class="@if (isset($welcome) && $welcome) welcome @endif">
<header> <header>
<nav> <nav>
<a id="logo" href="{{ route('account.home') }}"><span <a id="logo" href="{{ route('account.home') }}"
@if ($space->logo) style="--space-logo: url('{{ asset('storage/' . $space->logo) }}')" @endif><span
class="on_desktop">{{ space()->name }}</span></a> class="on_desktop">{{ space()->name }}</span></a>
@if (!isset($welcome) || $welcome == false) @if (!isset($welcome) || $welcome == false)
@ -67,13 +68,13 @@
@if (!isset($welcome) || $welcome == false) @if (!isset($welcome) || $welcome == false)
<section @if (isset($grid) && $grid) class="grid" @endif> <section @if (isset($grid) && $grid) class="grid" @endif>
@hasSection('breadcrumb') @hasSection('breadcrumb')
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
@yield('breadcrumb') @yield('breadcrumb')
</ol> </ol>
</nav> </nav>
@endif @endif
@endif @endif
@include('parts.errors') @include('parts.errors')