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`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
788
frontend/src/routes/admin/branding/+page.svelte
Normal file
788
frontend/src/routes/admin/branding/+page.svelte
Normal 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)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="alert alert-success" role="status">
|
||||
<span>{successMessage}</span>
|
||||
<button class="alert-close" onclick={() => (successMessage = null)}>×</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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
}
|
||||
|
||||
.demo-controls button.active {
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user