feat: Permettre la personnalisation du logo et de la couleur principale de l'établissement

Les administrateurs peuvent désormais configurer l'identité visuelle
de leur établissement : upload d'un logo (PNG/JPG, redimensionné
automatiquement via Imagick) et choix d'une couleur principale
appliquée aux boutons et à la navigation.

La couleur est validée côté client et serveur pour garantir la
conformité WCAG AA (contraste ≥ 4.5:1 sur fond blanc). Les
personnalisations sont injectées dynamiquement via CSS variables
et visibles immédiatement après sauvegarde.
This commit is contained in:
2026-02-20 19:35:43 +01:00
parent cfbe96ccf8
commit 6fd084063f
67 changed files with 4584 additions and 29 deletions

View File

@@ -139,7 +139,7 @@
}
.pagination-page.active {
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
border-color: #3b82f6;
color: white;
}

View File

@@ -438,7 +438,7 @@
.btn-primary {
padding: 0.75rem 1.5rem;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
@@ -448,6 +448,6 @@
}
.btn-primary:hover {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
</style>

View File

@@ -134,7 +134,7 @@
}
.child-button.selected {
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
border-color: #3b82f6;
color: white;
}

View File

@@ -71,6 +71,11 @@
<span class="action-label">Pédagogie</span>
<span class="action-hint">Mode de notation</span>
</a>
<a class="action-card" href="/admin/branding">
<span class="action-icon">🎨</span>
<span class="action-label">Identité visuelle</span>
<span class="action-hint">Logo et couleurs</span>
</a>
<div class="action-card disabled" aria-disabled="true">
<span class="action-icon">📤</span>
<span class="action-label">Importer des données</span>

View File

@@ -269,7 +269,7 @@
.replacement-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border-radius: 9999px;
font-size: 0.6875rem;

View File

@@ -265,7 +265,7 @@
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.375rem;
@@ -274,7 +274,7 @@
}
.btn-add:hover {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
.alert {
@@ -492,7 +492,7 @@
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.375rem;
@@ -501,7 +501,7 @@
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {

View File

@@ -0,0 +1,177 @@
import { browser } from '$app/environment';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth/auth.svelte';
/**
* Store réactif pour le branding de l'établissement.
*
* Charge la configuration branding depuis l'API et injecte
* les CSS variables correspondantes dans :root.
*
* @see FR83 - Configurer logo et couleurs établissement
* @see Story 2.13 - Personnalisation visuelle établissement
*/
export interface BrandingConfig {
schoolId: string;
logoUrl: string | null;
logoUpdatedAt: string | null;
primaryColor: string | null;
secondaryColor: string | null;
accentColor: string | null;
updatedAt: string;
}
// State
let branding = $state<BrandingConfig | null>(null);
let isLoading = $state(false);
let isFetched = $state(false);
const CSS_VAR_PREFIX = '--brand';
/**
* Charge le branding depuis l'API.
*/
export async function fetchBranding(): Promise<void> {
if (!browser || isFetched || isLoading) return;
isLoading = true;
try {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/school/branding`);
if (!response.ok) return;
const data: BrandingConfig = await response.json();
branding = data;
isFetched = true;
applyCssVariables(data);
} catch (error) {
console.error('[brandingStore] Failed to fetch branding:', error);
} finally {
isLoading = false;
}
}
/**
* Met à jour le branding local après une mutation.
*/
export function updateBranding(data: BrandingConfig): void {
branding = data;
applyCssVariables(data);
}
/**
* Retourne la configuration branding actuelle.
*/
export function getBranding(): BrandingConfig | null {
return branding;
}
/**
* Retourne l'URL du logo (réactif via $state).
*/
export function getLogoUrl(): string | null {
return branding?.logoUrl ?? null;
}
/**
* Retourne l'état de chargement.
*/
export function getBrandingLoading(): boolean {
return isLoading;
}
/**
* Réinitialise l'état (à appeler au logout).
*/
export function resetBranding(): void {
branding = null;
isFetched = false;
removeCssVariables();
}
/**
* Injecte les CSS variables dans :root.
*
* On surcharge les design tokens existants (--accent-primary, etc.)
* pour que tous les composants (header, nav, boutons actifs) adoptent
* automatiquement les couleurs de l'établissement.
*/
function applyCssVariables(config: BrandingConfig): void {
if (!browser) return;
const root = document.documentElement;
if (config.primaryColor) {
root.style.setProperty('--accent-primary', config.primaryColor);
root.style.setProperty('--accent-primary-light', hexToLight(config.primaryColor));
root.style.setProperty('--btn-primary-bg', config.primaryColor);
root.style.setProperty('--btn-primary-hover-bg', hexToDark(config.primaryColor));
} else {
root.style.removeProperty('--accent-primary');
root.style.removeProperty('--accent-primary-light');
root.style.removeProperty('--btn-primary-bg');
root.style.removeProperty('--btn-primary-hover-bg');
}
if (config.logoUrl) {
root.style.setProperty(`${CSS_VAR_PREFIX}-logo-url`, `url('${config.logoUrl}')`);
} else {
root.style.removeProperty(`${CSS_VAR_PREFIX}-logo-url`);
}
}
/**
* Génère une version claire d'une couleur hex (pour les fonds des éléments actifs).
* Mélange la couleur avec du blanc à 12% d'opacité.
*/
function hexToLight(hex: string): string {
const rgb = parseHex(hex);
if (!rgb) return '#e0f2fe';
const r = Math.round(255 - (255 - rgb.r) * 0.12);
const g = Math.round(255 - (255 - rgb.g) * 0.12);
const b = Math.round(255 - (255 - rgb.b) * 0.12);
return toHex(r, g, b);
}
/**
* Génère une version sombre d'une couleur hex (pour les états hover des boutons).
*/
function hexToDark(hex: string): string {
const rgb = parseHex(hex);
if (!rgb) return '#2563eb';
const r = Math.round(rgb.r * 0.85);
const g = Math.round(rgb.g * 0.85);
const b = Math.round(rgb.b * 0.85);
return toHex(r, g, b);
}
function parseHex(hex: string): { r: number; g: number; b: number } | null {
const match = hex.match(/^#([0-9A-Fa-f]{6})$/);
if (!match?.[1]) return null;
const h = match[1];
return {
r: parseInt(h.substring(0, 2), 16),
g: parseInt(h.substring(2, 4), 16),
b: parseInt(h.substring(4, 6), 16)
};
}
function toHex(r: number, g: number, b: number): string {
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
/**
* Retire les CSS variables du branding.
*/
function removeCssVariables(): void {
if (!browser) return;
const root = document.documentElement;
root.style.removeProperty('--accent-primary');
root.style.removeProperty('--accent-primary-light');
root.style.removeProperty('--btn-primary-bg');
root.style.removeProperty('--btn-primary-hover-bg');
root.style.removeProperty(`${CSS_VAR_PREFIX}-logo-url`);
}