Merge branch 'feature/441-Customize-App-Theme' into 'master'

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

See merge request BC/public/flexisip-account-manager!683
This commit is contained in:
Timothée Jaussoin 2026-04-16 14:21:38 +00:00
commit 1023c858de
6 changed files with 280 additions and 25 deletions

View file

@ -159,6 +159,7 @@ class SpaceController extends Controller
'custom_provisioning_entries' => ['nullable', new Ini(Space::FORBIDDEN_KEYS)]
]);
$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');

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",
"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",
"Plateforme 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,6 +297,7 @@
"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",

View file

@ -183,7 +183,7 @@ form div .btn.oppose {
right: 0;
}
form div input,
form div input:not([type=range]),
form div textarea,
form div select {
padding: 1rem 2rem;
@ -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,75 @@ form div textarea:invalid:not(:placeholder-shown)+label {
color: var(--danger);
}
/* Logo Picker */
form canvas {
background-color: var(--grey-1);
border : 1px solid var(--grey-2);
border-radius: 20px;
width: 120px;
height: 120px;
}
/* Color Picker */
form div .color-picker {
display: flex;
align-items: center;
height: 110px;
gap: 10px;
padding-top: 10px;
}
.color-picker #color-div {
width: 50%;
height: 100%;
border : 1px solid var(--grey-2);
border-radius: 20px;
background-color: var(--main-5);
}
form input[type=range] {
appearance: none;
margin-top: 2.5rem;
box-sizing: border-box;
width: 100%;
height: 20px;
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: 20px;
height: 20px;
border-radius: 50%;
border: 4px solid var(--grey-1);
cursor: pointer;
}
/* Mandatory for Mozilla Firefox */
input[type=range]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
border: 4px solid var(--grey-1);
cursor: pointer;
}
/* Checkbox element */
form div.checkbox {

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

@ -0,0 +1,102 @@
// Color picker
let colorDiv = document.getElementById("color-div");
let colorInput = document.getElementById("color-input");
let themeHue = document.getElementById("theme_hue");
let colorSlider = document.getElementById("color-slider");
colorSlider.addEventListener("input", (e) => {
const hue = e.target.value;
themeHue.value = hue;
let color = hslToHex(hue,100, 50);
colorDiv.style.backgroundColor = color;
colorInput.value = color;
});
colorInput.addEventListener("change", (e) => {
const hex = e.target.value;
let colorHsl = hexToHsl(hex);
themeHue.value = colorHsl.h;
colorSlider.value = colorHsl.h;
let colorHex = hslToHex(colorHsl.h, 100, 50);
colorInput.value = colorHex;
colorDiv.style.backgroundColor = colorHex;
});
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),
};
}
// Logo Picker
let imgInput = document.getElementById("logo-input");
imgInput.addEventListener("change", (e) => {
const file = e.target.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
};
img.src = url;
});
const canvas = document.getElementById("logo-canvas");
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);

View file

@ -17,12 +17,39 @@
@include('admin.space.head')
@include('admin.space.tabs')
<form method="POST"
action="{{ route('admin.spaces.configuration.update', $space) }}"
accept-charset="UTF-8">
<form method="POST" action="{{ route('admin.spaces.configuration.update', $space) }}" accept-charset="UTF-8">
@csrf
@method('put')
<h3 class="large">{{ __('Plateforme customization') }}</h3>
<div>
<label for="logo-picker" style="font-weight: 700;">{{ __('Select an image') }} </label>
<div class="color-picker">
{{-- Gérer les types de fichiers accepter --}}
<input id="logo-input" name="logo" type="file" accept=".png">
@include('parts.errors', ['name' => 'logo'])
<canvas id="logo-canvas"></canvas>
</div>
</div>
<div>
<label for="color-slider" style="font-weight: 700;">{{ __('Select a color') }} </label>
<input name="theme_hue" id="theme_hue" type="text" style="display:none;" value="22">
<div class="color-picker">
<div id="color-div"></div>
<div>
<input id="color-input" type="text" value="#ff5e00">
<label for="color-input">{{ __('Hex') }}</label>
<input class="range" type="range" name="color-slider" id="color-slider" min="0" max="360"
value="22">
</div>
</div>
<span class="supporting"> {{ __('Your color will be snapped to the nearest 500 shade') }}</span>
</div>
<div class="large">
<textarea name="copyright_text" id="copyright_text">{{ $space->copyright_text }}</textarea>
<label for="copyright_text">{{ __('Copyright text') }}</label>
@ -37,21 +64,26 @@
</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>
<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'])
</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>
<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'])
</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>
<span class="supporting">A custom realm for the Space accounts</span>
@include('parts.errors', ['name' => 'account_realm'])
@ -63,31 +95,52 @@
<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>
<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'])
</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>
@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>
<h3 class="large">{{ __('Features') }}</h3>
<div>
@include('parts.form.toggle', ['object' => $space, 'key' => 'public_registration', 'label' => __('Public registration')])
</div>
<div
@include('parts.form.toggle', ['object' => $space, 'key' => 'phone_registration', 'label' => __('Phone registration')])
@include('parts.form.toggle', [
'object' => $space,
'key' => 'public_registration',
'label' => __('Public registration'),
])
</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')])
@include('parts.form.toggle', [
'object' => $space,
'key' => 'intercom_features',
'label' => __('Intercom features'),
])
</div>
<div class="large">
<input class="btn" type="submit" value="{{ __('Update') }}">
</div>
</form>
<script src="{{ asset('scripts/colorPicker.js') }}"></script>
@endsection