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

@@ -0,0 +1,313 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
// Test credentials — unique to this spec to avoid cross-spec collisions
const ADMIN_EMAIL = 'e2e-branding-admin@example.com';
const ADMIN_PASSWORD = 'BrandingAdmin123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
// Minimal valid 1x1 transparent PNG for logo upload tests
const TEST_LOGO_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
'base64'
);
test.describe('Branding Visual Customization', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
// Create admin user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Clean up branding data from previous test runs
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_branding WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
{ encoding: 'utf-8' }
);
// Clean up logo files from previous test runs
execSync(
`docker compose -f "${composeFile}" exec -T php sh -c "rm -rf /app/public/uploads/logos/${TENANT_ID}" 2>&1`,
{ encoding: 'utf-8' }
);
});
// Helper to login as admin
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
/**
* Waits for the branding page to finish loading.
*
* After hydration, the page shows the card sections (logo + colors).
* Waiting for the heading and the first .card ensures the component
* is interactive and API data has been fetched.
*/
async function waitForPageLoaded(page: import('@playwright/test').Page) {
await expect(
page.getByRole('heading', { name: /identité visuelle/i })
).toBeVisible({ timeout: 15000 });
// Wait for at least one card section to appear (loading finished)
await expect(
page.locator('.card').first()
).toBeVisible({ timeout: 15000 });
}
// ============================================================================
// [P2] Page displays logo and color sections (AC1)
// ============================================================================
test('[P2] page affiche les sections logo et couleurs', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Title
await expect(
page.getByRole('heading', { name: /identité visuelle/i })
).toBeVisible();
// Subtitle
await expect(
page.getByText(/personnalisez le logo et les couleurs/i)
).toBeVisible();
// Logo section heading
await expect(
page.getByRole('heading', { name: /logo de l'établissement/i })
).toBeVisible();
// Format info
await expect(
page.getByText(/formats acceptés/i)
).toBeVisible();
// Logo placeholder (no logo initially)
await expect(
page.getByText(/aucun logo configuré/i)
).toBeVisible();
// Upload button
await expect(
page.getByText('Importer un logo')
).toBeVisible();
// Color section heading
await expect(
page.getByRole('heading', { name: /couleur principale/i })
).toBeVisible();
// Color picker and text input
await expect(page.locator('#primaryColorPicker')).toBeVisible();
await expect(page.locator('#primaryColor')).toBeVisible();
// Reset and save buttons
await expect(
page.getByRole('button', { name: /réinitialiser/i })
).toBeVisible();
await expect(
page.getByRole('button', { name: /enregistrer/i })
).toBeVisible();
});
// ============================================================================
// [P1] Changing color updates contrast indicator and preview (AC3)
// ============================================================================
test('[P1] modifier la couleur met à jour l\'indicateur de contraste et l\'aperçu', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
const colorInput = page.locator('#primaryColor');
// --- Dark blue: passes AA (ratio ~10.3) → "Lisible" ---
await colorInput.fill('#1E3A5F');
await expect(page.locator('.contrast-indicator.pass')).toBeVisible();
await expect(page.locator('.contrast-badge')).toContainText('Lisible');
await expect(page.locator('.preview-swatch').first()).toBeVisible();
await expect(page.locator('.preview-swatch').first()).toHaveCSS(
'background-color',
'rgb(30, 58, 95)'
);
// --- Yellow: fails AA completely (ratio ~1.07) → "Illisible" ---
await colorInput.fill('#FFFF00');
await expect(page.locator('.contrast-indicator.fail')).toBeVisible();
await expect(page.locator('.contrast-badge')).toContainText('Illisible');
// --- Dark yellow: passes AA Large only (ratio ~3.7) → "Attention" ---
await colorInput.fill('#8B8000');
await expect(page.locator('.contrast-indicator.warning')).toBeVisible();
await expect(page.locator('.contrast-badge')).toContainText('Attention');
});
// ============================================================================
// [P1] Saving colors applies CSS variables immediately (AC3, AC5)
// ============================================================================
test('[P1] enregistrer les couleurs applique les CSS variables immédiatement', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Set a dark blue color
await page.locator('#primaryColor').fill('#1E3A5F');
// Click save and wait for API response
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT'
);
await page.getByRole('button', { name: /enregistrer/i }).click();
await responsePromise;
// Success message
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
// CSS variables applied to document root
const accentPrimary = await page.evaluate(() =>
document.documentElement.style.getPropertyValue('--accent-primary')
);
expect(accentPrimary).toBe('#1E3A5F');
const btnPrimaryBg = await page.evaluate(() =>
document.documentElement.style.getPropertyValue('--btn-primary-bg')
);
expect(btnPrimaryBg).toBe('#1E3A5F');
// Save button should be disabled (no pending changes)
await expect(
page.getByRole('button', { name: /enregistrer/i })
).toBeDisabled();
});
// ============================================================================
// [P2] Upload logo displays preview (AC2)
// ============================================================================
test('[P2] upload logo affiche l\'aperçu', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Initially no logo
await expect(page.getByText(/aucun logo configuré/i)).toBeVisible();
// Trigger file chooser and upload the test PNG
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Importer un logo').click();
const fileChooser = await fileChooserPromise;
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'POST'
);
await fileChooser.setFiles({
name: 'logo.png',
mimeType: 'image/png',
buffer: TEST_LOGO_PNG
});
await responsePromise;
// Success message
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/logo mis à jour/i);
// Logo image is now visible
await expect(page.locator('.logo-image')).toBeVisible();
// "Changer le logo" and "Supprimer" buttons visible
await expect(page.getByText('Changer le logo')).toBeVisible();
await expect(
page.getByRole('button', { name: /supprimer/i })
).toBeVisible();
// Placeholder text is gone
await expect(page.getByText(/aucun logo configuré/i)).not.toBeVisible();
});
// ============================================================================
// [P2] Delete logo returns to no-logo state (AC2)
// ============================================================================
test('[P2] supprimer logo revient à l\'état sans logo', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Logo should be visible from previous test
await expect(page.locator('.logo-image')).toBeVisible();
// Accept the confirmation dialog, wait for DELETE response, then click
page.once('dialog', (dialog) => dialog.accept());
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'DELETE'
);
await page.getByRole('button', { name: /supprimer/i }).click();
await responsePromise;
// Success message
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/logo supprimé/i);
// Back to placeholder state
await expect(page.getByText(/aucun logo configuré/i)).toBeVisible();
await expect(page.getByText('Importer un logo')).toBeVisible();
});
// ============================================================================
// [P2] Reset restores default theme (AC4)
// ============================================================================
test('[P2] réinitialiser restaure le thème par défaut', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Color should be set from test 3
await expect(page.locator('#primaryColor')).toHaveValue('#1E3A5F');
// Accept the confirmation dialog, wait for PUT response, then click
page.once('dialog', (dialog) => dialog.accept());
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT'
);
await page.getByRole('button', { name: /réinitialiser/i }).click();
await responsePromise;
// Success message
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
// Color input is now empty
await expect(page.locator('#primaryColor')).toHaveValue('');
// CSS variables removed
const accentPrimary = await page.evaluate(() =>
document.documentElement.style.getPropertyValue('--accent-primary')
);
expect(accentPrimary).toBe('');
// Preview swatch should not be visible (no primary color set)
await expect(page.locator('.preview-swatch')).not.toBeVisible();
});
});

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`);
}

View File

@@ -138,7 +138,7 @@
font-size: 1.125rem;
font-weight: 600;
color: white;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
border: none;
border-radius: 0.5rem;
cursor: pointer;
@@ -147,7 +147,7 @@
}
.btn-primary:hover {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
}

View File

@@ -5,12 +5,14 @@
import { logout } from '$lib/auth/auth.svelte';
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
import { fetchRoles, getRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
import { fetchBranding, resetBranding, getLogoUrl } from '$features/branding/brandingStore.svelte';
let { children } = $props();
let isLoggingOut = $state(false);
let accessChecked = $state(false);
let hasAccess = $state(false);
let mobileMenuOpen = $state(false);
let logoUrl = $derived(getLogoUrl());
const ADMIN_ROLES = [
'ROLE_SUPER_ADMIN',
@@ -30,7 +32,8 @@
{ href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive },
{ href: '/admin/calendar', label: 'Calendrier', isActive: () => isCalendarActive },
{ href: '/admin/image-rights', label: 'Droit à l\'image', isActive: () => isImageRightsActive },
{ href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive }
{ href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive },
{ href: '/admin/branding', label: 'Identité visuelle', isActive: () => isBrandingActive }
];
// Load user roles and verify admin access
@@ -46,12 +49,14 @@
hasAccess = true;
accessChecked = true;
fetchBranding();
});
async function handleLogout() {
isLoggingOut = true;
try {
resetRoleContext();
resetBranding();
await logout();
} finally {
isLoggingOut = false;
@@ -84,6 +89,7 @@
const isCalendarActive = $derived(page.url.pathname.startsWith('/admin/calendar'));
const isImageRightsActive = $derived(page.url.pathname.startsWith('/admin/image-rights'));
const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
const isBrandingActive = $derived(page.url.pathname.startsWith('/admin/branding'));
const currentSectionLabel = $derived.by(() => {
const path = page.url.pathname;
@@ -138,6 +144,9 @@
<header class="admin-header">
<div class="header-content">
<button class="logo-button" onclick={goHome}>
{#if logoUrl}
<img src={logoUrl} alt="Logo de l'établissement" class="header-logo" />
{/if}
<span class="logo-text">Classeo</span>
</button>
@@ -186,6 +195,9 @@
aria-label="Menu de navigation"
>
<div class="mobile-drawer-header">
{#if logoUrl}
<img src={logoUrl} alt="Logo de l'établissement" class="header-logo" />
{/if}
<span class="logo-text">Classeo</span>
<button
class="mobile-close"
@@ -270,12 +282,22 @@
}
.logo-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem 0;
}
.header-logo {
height: 32px;
width: auto;
object-fit: contain;
border-radius: 0.25rem;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;

View File

@@ -772,7 +772,7 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
@@ -782,7 +782,7 @@
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {

View File

@@ -552,7 +552,7 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
@@ -562,7 +562,7 @@
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {

View File

@@ -0,0 +1,788 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth/auth.svelte';
import {
updateBranding,
getBranding,
type BrandingConfig
} from '$features/branding/brandingStore.svelte';
// State
let branding = $state<BrandingConfig | null>(null);
let isLoading = $state(true);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Color form state
let primaryColor = $state<string | null>(null);
let isSavingColors = $state(false);
// Logo state
let isUploadingLogo = $state(false);
let isDeletingLogo = $state(false);
let fileInput = $state<HTMLInputElement>();
// Contrast computation
const HEX_PATTERN = /^#[0-9A-Fa-f]{6}$/;
let isValidHex = $derived(primaryColor ? HEX_PATTERN.test(primaryColor) : false);
let contrastInfo = $derived.by(() => {
if (!primaryColor || !isValidHex) return null;
return computeContrast(primaryColor, '#FFFFFF');
});
// Track unsaved color changes — block save if contrast fails WCAG AA
let contrastBlocked = $derived(contrastInfo !== null && !contrastInfo.passesAA);
let hasColorChanges = $derived(
primaryColor !== (branding?.primaryColor ?? null) &&
(primaryColor === null || isValidHex) &&
!contrastBlocked
);
// Load branding on mount
$effect(() => {
loadBranding();
});
async function loadBranding() {
try {
isLoading = true;
error = null;
// Try store first
const cached = getBranding();
if (cached) {
branding = cached;
syncFormFromBranding(cached);
isLoading = false;
return;
}
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/school/branding`);
if (!response.ok) {
throw new Error('Impossible de charger la configuration du branding.');
}
const data: BrandingConfig = await response.json();
branding = data;
syncFormFromBranding(data);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
function syncFormFromBranding(config: BrandingConfig) {
primaryColor = config.primaryColor;
}
async function handleSaveColors() {
try {
isSavingColors = true;
error = null;
successMessage = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/school/branding`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
primaryColor,
secondaryColor: null,
accentColor: null
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData['hydra:description'] || errorData.message || 'Erreur lors de la sauvegarde.'
);
}
const data: BrandingConfig = await response.json();
branding = data;
syncFormFromBranding(data);
updateBranding(data);
successMessage = 'Couleurs mises à jour avec succès.';
setTimeout(() => (successMessage = null), 4000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur';
} finally {
isSavingColors = false;
}
}
async function handleUploadLogo() {
const file = fileInput?.files?.[0];
if (!file) return;
try {
isUploadingLogo = true;
error = null;
successMessage = null;
const formData = new window.FormData();
formData.append('file', file);
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/school/branding/logo`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData['hydra:description'] || errorData.message || 'Erreur lors de l\'upload.'
);
}
const data: BrandingConfig = await response.json();
branding = data;
syncFormFromBranding(data);
updateBranding(data);
successMessage = 'Logo mis à jour avec succès.';
setTimeout(() => (successMessage = null), 4000);
// Reset file input
if (fileInput) fileInput.value = '';
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur';
} finally {
isUploadingLogo = false;
}
}
async function handleDeleteLogo(skipConfirm = false) {
if (!skipConfirm && !window.confirm('Êtes-vous sûr de vouloir supprimer le logo ?')) return;
try {
isDeletingLogo = true;
error = null;
successMessage = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/school/branding/logo`, {
method: 'DELETE'
});
if (!response.ok && response.status !== 204) {
throw new Error('Erreur lors de la suppression du logo.');
}
if (branding) {
branding = { ...branding, logoUrl: null, logoUpdatedAt: null };
updateBranding(branding);
}
if (!skipConfirm) {
successMessage = 'Logo supprimé avec succès.';
setTimeout(() => (successMessage = null), 4000);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur';
} finally {
isDeletingLogo = false;
}
}
async function handleReset() {
if (!window.confirm('Réinitialiser toutes les personnalisations vers le thème par défaut ?')) return;
if (branding?.logoUrl) {
await handleDeleteLogo(true);
}
primaryColor = null;
await handleSaveColors();
}
// WCAG contrast computation
function computeContrast(
fg: string,
bg: string
): { ratio: number; passesAA: boolean; passesAALarge: boolean } {
const fgRgb = hexToRgb(fg);
const bgRgb = hexToRgb(bg);
if (!fgRgb || !bgRgb) return { ratio: 0, passesAA: false, passesAALarge: false };
const l1 = relativeLuminance(fgRgb);
const l2 = relativeLuminance(bgRgb);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
const ratio = (lighter + 0.05) / (darker + 0.05);
return {
ratio: Math.round(ratio * 100) / 100,
passesAA: ratio >= 4.5,
passesAALarge: ratio >= 3.0
};
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const match = hex.match(/^#([0-9A-Fa-f]{6})$/);
if (!match?.[1]) return null;
const hex6 = match[1];
return {
r: parseInt(hex6.substring(0, 2), 16),
g: parseInt(hex6.substring(2, 4), 16),
b: parseInt(hex6.substring(4, 6), 16)
};
}
function relativeLuminance(rgb: { r: number; g: number; b: number }): number {
const r = linearize(rgb.r / 255);
const g = linearize(rgb.g / 255);
const b = linearize(rgb.b / 255);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function linearize(v: number): number {
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
</script>
<svelte:head>
<title>Identité visuelle - Classeo</title>
</svelte:head>
<header class="page-header">
<div class="header-content">
<h1>Identité visuelle</h1>
<p class="subtitle">Personnalisez le logo et les couleurs de votre établissement.</p>
</div>
</header>
{#if error}
<div class="alert alert-error" role="alert">
<span>{error}</span>
<button class="alert-close" onclick={() => (error = null)}>&times;</button>
</div>
{/if}
{#if successMessage}
<div class="alert alert-success" role="status">
<span>{successMessage}</span>
<button class="alert-close" onclick={() => (successMessage = null)}>&times;</button>
</div>
{/if}
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement...</p>
</div>
{:else}
<!-- Logo Section -->
<section class="card">
<h2>Logo de l'établissement</h2>
<p class="section-description">
Formats acceptés : PNG, JPG. Taille maximale : 2 Mo. Le logo sera redimensionné automatiquement.
</p>
<div class="logo-section">
{#if branding?.logoUrl}
<div class="logo-preview">
<img src={branding.logoUrl} alt="Logo de l'établissement" class="logo-image" />
</div>
<div class="logo-actions">
<label class="btn-secondary btn-file">
Changer le logo
<input
type="file"
accept="image/png,image/jpeg"
bind:this={fileInput}
onchange={handleUploadLogo}
hidden
/>
</label>
<button
class="btn-danger"
onclick={() => handleDeleteLogo()}
disabled={isDeletingLogo}
>
{isDeletingLogo ? 'Suppression...' : 'Supprimer'}
</button>
</div>
{:else}
<div class="logo-placeholder">
<span class="logo-placeholder-icon">+</span>
<p>Aucun logo configuré</p>
</div>
<label class="btn-primary btn-file">
{#if isUploadingLogo}
Upload en cours...
{:else}
Importer un logo
{/if}
<input
type="file"
accept="image/png,image/jpeg"
bind:this={fileInput}
onchange={handleUploadLogo}
disabled={isUploadingLogo}
hidden
/>
</label>
{/if}
</div>
</section>
<!-- Colors Section -->
<section class="card">
<h2>Couleur principale</h2>
<p class="section-description">
Définissez la couleur de votre établissement. Elle sera appliquée aux boutons, à la navigation
et aux éléments actifs. Laissez vide pour utiliser le thème par défaut Classeo.
</p>
<div class="color-field">
<label for="primaryColor">Couleur</label>
<div class="color-input-group">
<input
type="color"
id="primaryColorPicker"
value={primaryColor ?? '#3B82F6'}
onchange={(e) => (primaryColor = e.currentTarget.value.toUpperCase())}
/>
<input
type="text"
id="primaryColor"
placeholder="#3B82F6"
value={primaryColor ?? ''}
oninput={(e) => {
const val = e.currentTarget.value;
primaryColor = val ? val.toUpperCase() : null;
}}
pattern="^#[0-9A-Fa-f]{6}$"
/>
{#if primaryColor}
<button
class="color-clear"
onclick={() => (primaryColor = null)}
title="Effacer"
>
&times;
</button>
{/if}
</div>
{#if contrastInfo}
<div
class="contrast-indicator"
class:pass={contrastInfo.passesAA}
class:warning={!contrastInfo.passesAA && contrastInfo.passesAALarge}
class:fail={!contrastInfo.passesAALarge}
>
{#if contrastInfo.passesAA}
<span class="contrast-badge">Lisible</span>
<span>Le texte blanc sur cette couleur est facile à lire.</span>
{:else if contrastInfo.passesAALarge}
<span class="contrast-badge">Attention</span>
<span>Le texte blanc est lisible en gros uniquement. Les petits textes seront difficiles à lire.</span>
{:else}
<span class="contrast-badge">Illisible</span>
<span>Cette couleur est trop claire : le texte blanc sur les boutons sera difficile à lire. Choisissez une couleur plus foncée.</span>
{/if}
</div>
{/if}
</div>
{#if primaryColor}
<div class="preview-section">
<h3>Aperçu</h3>
<div class="preview-bar">
<div class="preview-swatch" style="background-color: {primaryColor}">
<span style="color: white">Boutons</span>
</div>
<div class="preview-swatch preview-swatch-nav" style="border-color: {primaryColor}; color: {primaryColor}">
Navigation active
</div>
</div>
</div>
{/if}
<div class="form-actions">
<button class="btn-secondary" onclick={handleReset} disabled={isSavingColors}>
Réinitialiser
</button>
<button
class="btn-primary"
onclick={handleSaveColors}
disabled={isSavingColors || !hasColorChanges}
>
{isSavingColors ? 'Enregistrement...' : 'Enregistrer'}
</button>
</div>
</section>
{/if}
<style>
.page-header {
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.subtitle {
color: #6b7280;
font-size: 0.875rem;
margin: 0.25rem 0 0;
}
.card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card h2 {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.25rem;
}
.section-description {
color: #6b7280;
font-size: 0.875rem;
margin: 0 0 1.25rem;
}
/* Alerts */
.alert {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
}
.alert-close {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: inherit;
padding: 0 0.25rem;
}
/* Loading */
.loading-state {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Logo section */
.logo-section {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.logo-preview {
width: 120px;
height: 120px;
border: 2px solid #e5e7eb;
border-radius: 0.75rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
}
.logo-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.logo-placeholder {
width: 120px;
height: 120px;
border: 2px dashed #d1d5db;
border-radius: 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9ca3af;
gap: 0.25rem;
}
.logo-placeholder-icon {
font-size: 1.5rem;
font-weight: 300;
}
.logo-placeholder p {
font-size: 0.75rem;
margin: 0;
text-align: center;
}
.logo-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Colors */
.color-field {
margin-bottom: 1.5rem;
}
.color-field label {
display: block;
font-weight: 500;
color: #374151;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.color-input-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-input-group input[type='color'] {
width: 2.5rem;
height: 2.5rem;
padding: 2px;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
flex-shrink: 0;
}
.color-input-group input[type='text'] {
flex: 1;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
font-family: monospace;
}
.color-input-group input[type='text']:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.color-clear {
background: none;
border: none;
font-size: 1.25rem;
color: #9ca3af;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.color-clear:hover {
color: #ef4444;
}
/* Contrast indicator */
.contrast-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.75rem;
font-weight: 500;
}
.contrast-indicator.pass {
color: #166534;
}
.contrast-indicator.warning {
color: #92400e;
}
.contrast-indicator.fail {
color: #991b1b;
}
.contrast-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
}
.contrast-indicator.pass .contrast-badge {
background: #dcfce7;
color: #166534;
}
.contrast-indicator.warning .contrast-badge {
background: #fef3c7;
color: #92400e;
}
.contrast-indicator.fail .contrast-badge {
background: #fef2f2;
color: #991b1b;
}
/* Preview */
.preview-section {
margin-bottom: 1.5rem;
}
.preview-section h3 {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin: 0 0 0.75rem;
}
.preview-bar {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.preview-swatch {
display: flex;
align-items: center;
justify-content: center;
width: 120px;
height: 60px;
border-radius: 0.5rem;
font-size: 0.75rem;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.preview-swatch-nav {
background: #f8fafc;
border: 2px solid;
box-shadow: none;
}
/* Buttons */
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.btn-primary {
padding: 0.625rem 1.25rem;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.625rem 1.25rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-danger {
padding: 0.625rem 1.25rem;
background: white;
color: #dc2626;
border: 1px solid #fecaca;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-danger:hover:not(:disabled) {
background: #fef2f2;
}
.btn-danger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-file {
display: inline-flex;
align-items: center;
cursor: pointer;
}
</style>

View File

@@ -423,7 +423,7 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
@@ -433,7 +433,7 @@
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {

View File

@@ -570,7 +570,7 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
@@ -580,7 +580,7 @@
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {

View File

@@ -710,7 +710,7 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
@@ -720,7 +720,7 @@
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {

View File

@@ -521,7 +521,7 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
@@ -531,7 +531,7 @@
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {

View File

@@ -480,7 +480,7 @@
.btn-primary {
padding: 0.75rem 1.25rem;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
@@ -490,7 +490,7 @@
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {

View File

@@ -948,7 +948,7 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
@@ -958,7 +958,7 @@
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {

View File

@@ -4,9 +4,11 @@
import { isAuthenticated, refreshToken, logout } from '$lib/auth/auth.svelte';
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
import { fetchBranding, resetBranding, getLogoUrl } from '$features/branding/brandingStore.svelte';
let { children } = $props();
let isLoggingOut = $state(false);
let logoUrl = $derived(getLogoUrl());
// Load user roles on mount for multi-role context switching (FR5)
// Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode
@@ -17,6 +19,7 @@
if (!refreshed) return;
}
fetchRoles();
fetchBranding();
});
});
@@ -24,6 +27,7 @@
isLoggingOut = true;
try {
resetRoleContext();
resetBranding();
await logout();
} finally {
isLoggingOut = false;
@@ -43,6 +47,9 @@
<header class="dashboard-header">
<div class="header-content">
<button class="logo-button" onclick={goHome}>
{#if logoUrl}
<img src={logoUrl} alt="Logo de l'établissement" class="header-logo" />
{/if}
<span class="logo-text">Classeo</span>
</button>
<nav class="header-nav">
@@ -98,12 +105,22 @@
}
.logo-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem 0;
}
.header-logo {
height: 32px;
width: auto;
object-fit: contain;
border-radius: 0.25rem;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;

View File

@@ -172,7 +172,7 @@
}
.demo-controls button.active {
background: #3b82f6;
background: var(--btn-primary-bg, #3b82f6);
border-color: #3b82f6;
color: white;
}