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:
@@ -139,7 +139,7 @@
|
||||
}
|
||||
|
||||
.pagination-page.active {
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
|
||||
.child-button.selected {
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
177
frontend/src/lib/features/branding/brandingStore.svelte.ts
Normal file
177
frontend/src/lib/features/branding/brandingStore.svelte.ts
Normal 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`);
|
||||
}
|
||||
Reference in New Issue
Block a user