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,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 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",

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 {

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;

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')