Files
Classeo/frontend/src/routes/admin/subjects/+page.svelte
Mathias STRASSER 6fd084063f 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.
2026-02-20 19:35:43 +01:00

910 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { untrack } from 'svelte';
// Types
interface Subject {
id: string;
name: string;
code: string;
color: string | null;
description: string | null;
status: string;
teacherCount: number | null;
classCount: number | null;
createdAt: string;
updatedAt: string;
}
// Couleurs prédéfinies (suggestions UI)
const SUGGESTED_COLORS = [
{ label: 'Bleu (Mathématiques)', value: '#3B82F6' },
{ label: 'Rouge (Français)', value: '#EF4444' },
{ label: 'Orange (Histoire-Géo)', value: '#F59E0B' },
{ label: 'Vert (Sciences)', value: '#10B981' },
{ label: 'Indigo (Anglais)', value: '#6366F1' },
{ label: 'Rose (EPS)', value: '#EC4899' },
{ label: 'Violet (Arts)', value: '#8B5CF6' },
{ label: 'Gris (Autre)', value: '#6B7280' }
];
// State
let subjects = $state<Subject[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let showCreateModal = $state(false);
let showDeleteModal = $state(false);
let subjectToDelete = $state<Subject | null>(null);
// Pagination & Search
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
let totalItems = $state(0);
const itemsPerPage = 30;
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
// Form state
let newSubjectName = $state('');
let newSubjectCode = $state('');
let newSubjectColor = $state<string | null>(null);
let isSubmitting = $state(false);
let isDeleting = $state(false);
// Load subjects on mount
let loadAbortController: AbortController | null = null;
$effect(() => {
untrack(() => loadSubjects());
});
async function loadSubjects() {
loadAbortController?.abort();
const controller = new AbortController();
loadAbortController = controller;
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage));
if (searchTerm) params.set('search', searchTerm);
const url = `${apiUrl}/subjects?${params.toString()}`;
const response = await authenticatedFetch(url, { signal: controller.signal });
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error('Erreur lors du chargement des matières');
}
const data = await response.json();
subjects = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? subjects.length;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : 'Erreur inconnue';
subjects = [];
totalItems = 0;
} finally {
if (!controller.signal.aborted) {
isLoading = false;
}
}
}
function updateUrl() {
const params = new URLSearchParams();
if (currentPage > 1) params.set('page', String(currentPage));
if (searchTerm) params.set('search', searchTerm);
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
}
function handleSearch(value: string) {
searchTerm = value;
currentPage = 1;
updateUrl();
loadSubjects();
}
function handlePageChange(newPage: number) {
currentPage = newPage;
updateUrl();
loadSubjects();
}
async function handleCreateSubject() {
if (!newSubjectName.trim() || !newSubjectCode.trim()) return;
try {
isSubmitting = true;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/subjects`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: newSubjectName.trim(),
code: newSubjectCode.trim().toUpperCase(),
color: newSubjectColor
})
});
if (!response.ok) {
let errorMessage = `Erreur lors de la création (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
}
// Reload subjects and close modal
await loadSubjects();
closeModal();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la création';
} finally {
isSubmitting = false;
}
}
function openDeleteModal(subject: Subject) {
subjectToDelete = subject;
showDeleteModal = true;
}
function closeDeleteModal() {
showDeleteModal = false;
subjectToDelete = null;
}
async function handleConfirmDelete() {
if (!subjectToDelete) return;
try {
isDeleting = true;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/subjects/${subjectToDelete.id}`, {
method: 'DELETE'
});
if (!response.ok) {
let errorMessage = `Erreur lors de la suppression (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
}
closeDeleteModal();
await loadSubjects();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la suppression';
} finally {
isDeleting = false;
}
}
function openCreateModal() {
showCreateModal = true;
newSubjectName = '';
newSubjectCode = '';
newSubjectColor = null;
}
function closeModal() {
showCreateModal = false;
}
function navigateToEdit(subjectId: string) {
goto(`/admin/subjects/${subjectId}`);
}
function getColorStyle(color: string | null): string {
if (!color) return '';
return `background-color: ${color}; color: ${getContrastColor(color)}`;
}
function getContrastColor(hexColor: string): string {
// Simple luminance check for contrast
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#FFFFFF';
}
</script>
<svelte:head>
<title>Gestion des matières - Classeo</title>
</svelte:head>
<div class="subjects-page">
<header class="page-header">
<div class="header-content">
<h1>Gestion des matières</h1>
<p class="subtitle">Créez et gérez les matières enseignées dans votre établissement</p>
</div>
<button class="btn-primary" onclick={openCreateModal}>
<span class="btn-icon">+</span>
Nouvelle matière
</button>
</header>
{#if error}
<div class="alert alert-error">
<span class="alert-icon">⚠️</span>
{error}
<button class="alert-close" onclick={() => (error = null)}>×</button>
</div>
{/if}
<SearchInput
value={searchTerm}
onSearch={handleSearch}
placeholder="Rechercher par nom, code..."
/>
{#if isLoading}
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
<p>Chargement des matières...</p>
</div>
{:else if subjects.length === 0}
<div class="empty-state">
<span class="empty-icon">📚</span>
{#if searchTerm}
<h2>Aucun résultat</h2>
<p>Aucune matière ne correspond à votre recherche</p>
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
{:else}
<h2>Aucune matière</h2>
<p>Commencez par créer votre première matière</p>
<button class="btn-primary" onclick={openCreateModal}>Créer une matière</button>
{/if}
</div>
{:else}
<div class="subjects-grid">
{#each subjects as subject (subject.id)}
<div class="subject-card">
<div class="subject-header">
{#if subject.color}
<span class="subject-color-badge" style={getColorStyle(subject.color)}>
{subject.code}
</span>
{:else}
<span class="subject-code">{subject.code}</span>
{/if}
<h3 class="subject-name">{subject.name}</h3>
</div>
<div class="subject-stats">
<span class="stat-item" title="Enseignants affectés">
<span class="stat-icon">👨‍🏫</span>
{subject.teacherCount ?? 0}
</span>
<span class="stat-item" title="Classes associées">
<span class="stat-icon">🏫</span>
{subject.classCount ?? 0}
</span>
<span class="stat-item status-{subject.status}">
{subject.status === 'active' ? 'Active' : 'Archivée'}
</span>
</div>
<div class="subject-actions">
<button class="btn-secondary btn-sm" onclick={() => navigateToEdit(subject.id)}>
Modifier
</button>
<button class="btn-danger btn-sm" onclick={() => openDeleteModal(subject)}>
Supprimer
</button>
</div>
</div>
{/each}
</div>
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
</div>
<!-- Create Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeModal(); }}
>
<header class="modal-header">
<h2 id="modal-title">Nouvelle matière</h2>
<button class="modal-close" onclick={closeModal} aria-label="Fermer">×</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleCreateSubject();
}}
>
<div class="form-group">
<label for="subject-name">Nom de la matière *</label>
<input
type="text"
id="subject-name"
bind:value={newSubjectName}
placeholder="ex: Mathématiques"
required
minlength="2"
maxlength="100"
/>
</div>
<div class="form-group">
<label for="subject-code">Code court *</label>
<input
type="text"
id="subject-code"
bind:value={newSubjectCode}
placeholder="ex: MATH"
required
minlength="2"
maxlength="10"
/>
<small class="form-hint">2 à 10 caractères (lettres et chiffres uniquement)</small>
</div>
<div class="form-group">
<label for="subject-color">Couleur</label>
<div class="color-picker-group">
<div class="color-swatches">
{#each SUGGESTED_COLORS as colorOption}
<button
type="button"
class="color-swatch"
class:selected={newSubjectColor === colorOption.value}
style="background-color: {colorOption.value}"
onclick={() => (newSubjectColor = colorOption.value)}
title={colorOption.label}
></button>
{/each}
<button
type="button"
class="color-swatch color-none"
class:selected={newSubjectColor === null}
onclick={() => (newSubjectColor = null)}
title="Aucune couleur"
>
</button>
</div>
<input
type="color"
id="subject-color"
value={newSubjectColor ?? '#6B7280'}
onchange={(e) => (newSubjectColor = e.currentTarget.value)}
/>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeModal} disabled={isSubmitting}>
Annuler
</button>
<button
type="submit"
class="btn-primary"
disabled={isSubmitting || !newSubjectName.trim() || !newSubjectCode.trim()}
>
{#if isSubmitting}
Création...
{:else}
Créer la matière
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if showDeleteModal && subjectToDelete}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
<div
class="modal modal-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
>
<header class="modal-header modal-header-danger">
<h2 id="delete-modal-title">Supprimer la matière</h2>
<button class="modal-close" onclick={closeDeleteModal} aria-label="Fermer">×</button>
</header>
<div class="modal-body">
<p id="delete-modal-description">
Êtes-vous sûr de vouloir supprimer la matière <strong>{subjectToDelete.name}</strong> ({subjectToDelete.code})
?
</p>
<p class="delete-warning">Cette action est irréversible.</p>
</div>
<div class="modal-actions">
<button
type="button"
class="btn-secondary"
onclick={closeDeleteModal}
disabled={isDeleting}
>
Annuler
</button>
<button type="button" class="btn-danger" onclick={handleConfirmDelete} disabled={isDeleting}>
{#if isDeleting}
Suppression...
{:else}
Supprimer
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
.subjects-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.header-content h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
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.5rem 1rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-danger {
padding: 0.5rem 1rem;
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-danger:hover {
background: #fee2e2;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.btn-icon {
font-size: 1.25rem;
line-height: 1;
}
.alert {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.alert-icon {
flex-shrink: 0;
}
.alert-close {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
opacity: 0.6;
}
.alert-close:hover {
opacity: 1;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
background: white;
border-radius: 0.75rem;
border: 2px dashed #e5e7eb;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: #1f2937;
}
.empty-state p {
margin: 0 0 1.5rem;
color: #6b7280;
}
.subjects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.subject-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
transition: box-shadow 0.2s;
}
.subject-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.subject-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.subject-name {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.subject-code {
padding: 0.25rem 0.5rem;
background: #f3f4f6;
color: #374151;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
font-family: monospace;
}
.subject-color-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
font-family: monospace;
}
.subject-stats {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.stat-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: #6b7280;
}
.stat-icon {
font-size: 0.875rem;
}
.status-active {
color: #059669;
}
.status-archived {
color: #6b7280;
}
.subject-actions {
display: flex;
gap: 0.5rem;
margin-top: auto;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
}
.modal {
background: white;
border-radius: 0.75rem;
width: 100%;
max-width: 28rem;
max-height: 90vh;
overflow: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.modal-close {
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #6b7280;
cursor: pointer;
}
.modal-close:hover {
color: #1f2937;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-group input[type='text'] {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.color-picker-group {
display: flex;
align-items: center;
gap: 1rem;
}
.color-swatches {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.color-swatch {
width: 2rem;
height: 2rem;
border: 2px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
}
.color-swatch:hover {
transform: scale(1.1);
}
.color-swatch.selected {
border-color: #1f2937;
box-shadow: 0 0 0 2px white, 0 0 0 4px #3b82f6;
}
.color-none {
background: #f3f4f6;
color: #6b7280;
font-size: 0.875rem;
font-weight: bold;
}
input[type='color'] {
width: 3rem;
height: 2rem;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
/* Delete confirmation modal */
.modal-confirm {
max-width: 24rem;
}
.modal-confirm .modal-actions {
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.modal-header-danger {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.modal-header-danger h2 {
color: #dc2626;
}
.delete-warning {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
</style>