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,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. 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,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 {
@ -417,4 +487,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;
}
}

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')])
</div>
<div>
@include('parts.form.toggle', ['object' => $space, 'key' => 'intercom_features', 'label' => __('Intercom features')])
@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'),
])
</div>
<div class="large">
<input class="btn" type="submit" value="{{ __('Update') }}">
</div>
<div class="large">
<input class="btn" type="submit" value="{{ __('Update') }}">
</div>
</form>
<script src="{{ asset('scripts/colorPicker.js') }}"></script>
@endsection