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.
910 lines
20 KiB
Svelte
910 lines
20 KiB
Svelte
<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>
|