refact: unify js and css

This commit is contained in:
2025-08-17 05:48:27 +02:00
parent dcd88bd383
commit 3fd49d1351
16 changed files with 2924 additions and 247 deletions

View File

@@ -1,13 +1,15 @@
# 📚 Documentation Frontend - Notytex
> **Design System & Composants UI**
> **Version**: 2.0
> **Dernière mise à jour**: 7 août 2025
> **Version**: 2.1
> **Dernière mise à jour**: 16 août 2025
## 🎯 **Vue d'Ensemble**
Cette documentation couvre l'ensemble du **design system Notytex**, ses composants UI, et les bonnes pratiques pour maintenir une interface cohérente et moderne.
**Notytex 2.1** intègre désormais une **architecture JavaScript moderne unifiée** avec des modules ES6+, un système de gestion d'état réactif, et des composants optimisés pour les performances.
---
## 📁 **Organisation de la Documentation**
@@ -41,13 +43,16 @@ Cette documentation couvre l'ensemble du **design system Notytex**, ses composan
| Common Macros | Macros réutilisables (hero_section, buttons...) | 📋 |
| **Form Components** | **Champs de formulaire standardisés (voir CLASS_FORM.md)** | ✅ |
### ⚡ **Performance & Outils**
### ⚡ **JavaScript & Architecture**
| Document | Description | Statut |
| ---------------------- | ------------------------------------------- | ------ |
| **Architecture JavaScript** | **Système unifié ES6+ avec NotytexCore & modules** | ✅ |
| **Class Dashboard JS** | **Module dashboard moderne avec gestion d'état** | ✅ |
| **Council Preparation JS** | **Module conseil de classe avec mode focus** | ✅ |
| **Filter System JS** | **Gestionnaire de filtres réactif** | ✅ |
| Performance Guidelines | Optimisation frontend & best practices | 📋 |
| Testing Strategy | Tests visuels, accessibilité, cross-browser | 📋 |
| Build & Deploy | Process de build frontend | 📋 |
---
@@ -75,6 +80,50 @@ Cette documentation couvre l'ensemble du **design system Notytex**, ses composan
---
## ⚡ **Architecture JavaScript Moderne**
### **Système Unifié NotytexCore**
```javascript
// Architecture ES6+ avec modules dynamiques
NotytexCore.getInstance() // Singleton pattern
├── Modules dynamiques (loadPageModule())
├── Gestion d'état réactif (Proxy-based)
├── Système d'événements unifié
└── Utilitaires performance (debounce, throttle)
```
### **Modules Principaux**
| Module | Description | Fonctionnalités |
|--------|-------------|-----------------|
| **NotytexCore** | Cœur du système JS | État réactif, événements, modules |
| **ClassDashboard** | Dashboard classe | Stats temps réel, graphiques, filtres |
| **CouncilPreparation** | Conseil de classe | Mode focus, auto-save, navigation |
| **FilterManager** | Gestionnaire filtres | Filtres dynamiques, URL sync |
| **NotificationSystem** | Notifications | Toasts, alertes, confirmations |
| **ModalManager** | Modales | Overlay, gestion focus, accessible |
### **Patterns Utilisés**
```javascript
// Gestion d'état réactif
const state = new Proxy({}, {
set(target, property, value) {
// Auto-notification des observers
this.notifyObservers(property, value);
}
});
// Chargement dynamique
const module = await NotytexCore.loadModule('ClassDashboard', './ClassDashboard.js');
// Événements unifiés
NotytexCore.emit('stats:updated', { trimester: 2, data: stats });
```
---
## 🎨 **Design System en Bref**
### **Couleurs Principales**
@@ -117,6 +166,7 @@ xl: 1280px // Large desktop
### **✅ Complété (100%)**
**Interface & Composants UI :**
- Guide des bonnes pratiques générales
- Page des classes (refonte complète)
- **Formulaire de classe (création/modification complet)**
@@ -125,19 +175,38 @@ xl: 1280px // Large desktop
- Filtres des évaluations
- Cartes d'évaluation
**Architecture JavaScript :**
- **Système NotytexCore unifié (ES6+)**
- **ClassDashboard.js - Module dashboard avancé**
- **CouncilPreparation.js - Module conseil avec mode focus**
- **Gestionnaire de filtres réactif**
- **Système de notifications moderne**
- **Gestionnaire de modales accessible**
### **🔄 En cours (0-80%)**
- Design tokens centralisés
- Guidelines d'accessibilité
- Guidelines d'accessibilité
- Tests automatisés frontend
- **Documentation technique des modules JS** (architecture interne)
### **📋 À faire**
- Documentation page Dashboard
**Interface & Pages :**
- Documentation page Dashboard principal
- Documentation gestion des élèves
- Composants de formulaire
- Guide de performance
- Process de build/deploy
- Composants de formulaire (documentation complète)
**JavaScript & Performance :**
- Guide de performance frontend
- Optimisations Webpack/Vite
- Service Workers & Cache Strategy
- Bundle analysis & Tree shaking
**DevOps & Build :**
- Process de build/deploy automatisé
- Tests end-to-end avec Playwright
- Monitoring des métriques Web Vitals
---
@@ -145,6 +214,7 @@ xl: 1280px // Large desktop
### **Tests Rapides**
**Templates Jinja2 :**
```bash
# Validation syntaxe tous les templates
find templates/ -name "*.html" -exec echo "Testing {}" \;
@@ -164,13 +234,39 @@ with app.app_context():
"
```
**Modules JavaScript :**
```bash
# Validation syntaxe ES6+
node --check static/js/notytex-core.js
node --check static/js/ClassDashboard.js
node --check static/js/CouncilPreparation.js
# Test d'importation des modules
node -e "
import('./static/js/notytex-core.js')
.then(() => console.log('✅ NotytexCore module OK'))
.catch(err => console.log('❌ NotytexCore error:', err.message))
"
```
### **Tests Complets**
- **Syntaxe** : Tous templates Jinja2 valides
- **Responsive** : Tests sur tous breakpoints
**Frontend Statique :**
- **Syntaxe** : Tous templates Jinja2 valides ✅
- **Responsive** : Tests sur tous breakpoints ✅
- **Accessibilité** : Tests axe-core + validation manuelle
- **Cross-browser** : Chrome, Firefox, Safari, Edge
**JavaScript Modules :**
- **Syntaxe ES6+** : Tous modules validés sans erreurs ✅
- **Imports/Exports** : Résolution correcte des dépendances ✅
- **Gestion d'erreur** : Try/catch pour modules critiques ✅
- **Performance** : Lazy loading & debouncing implémentés ✅
**Intégration :**
- **Performance** : Métriques Lighthouse > 90
- **Functional Testing** : Tests E2E des flows critiques
- **Error Handling** : Fallbacks gracieux en cas d'échec JS
---

View File

@@ -171,11 +171,30 @@ def dashboard(id):
if not class_group:
abort(404)
# Récupérer aussi les statistiques pour injection dans le template
stats_data = {}
try:
# Construction des données comme dans l'API
quantity_stats = class_group.get_trimester_statistics(trimester)
domain_analysis = class_group.get_domain_analysis(trimester)
competence_analysis = class_group.get_competence_analysis(trimester)
class_results = class_group.get_class_results(trimester)
stats_data = {
'summary': quantity_stats,
'domains': domain_analysis,
'competences': competence_analysis,
'results': class_results
}
except Exception as e:
current_app.logger.warning(f'Erreur lors du chargement des stats dashboard: {e}')
current_app.logger.debug(f'Dashboard classe {id} affiché pour trimestre {trimester}')
return render_template('class_dashboard.html',
class_group=class_group,
selected_trimester=trimester)
selected_trimester=trimester,
stats_data=stats_data)
@bp.route('/<int:id>/stats')
@handle_db_errors

View File

@@ -103,6 +103,65 @@
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
/* Layout utilitaires uniformes */
.container-responsive {
@apply max-w-7xl mx-auto px-4 py-8;
}
.container-narrow {
@apply max-w-4xl mx-auto px-4 py-6;
}
.container-wide {
@apply max-w-full mx-auto px-6 py-8;
}
/* Spacing système unifié */
.space-section {
@apply space-y-8;
}
.space-elements {
@apply space-y-6;
}
.space-items {
@apply space-y-4;
}
.space-tight {
@apply space-y-3;
}
.space-compact {
@apply space-y-2;
}
/* Tailles de texte système */
.text-display {
@apply text-4xl font-bold leading-tight;
}
.text-heading {
@apply text-2xl font-bold leading-snug;
}
.text-subheading {
@apply text-xl font-semibold leading-normal;
}
.text-body {
@apply text-base leading-relaxed;
}
.text-caption {
@apply text-sm text-gray-600 leading-normal;
}
.text-micro {
@apply text-xs text-gray-500 leading-tight;
}
/* Gradients réutilisables */
.bg-gradient-primary {
background: var(--gradient-primary);
@@ -211,6 +270,48 @@
@apply block bg-white rounded-lg shadow hover:shadow-lg transition-all duration-300 p-6 transform hover:scale-105 group;
}
/* Grilles système uniformes */
.grid-responsive {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6;
}
.grid-actions {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4;
}
.grid-cards {
@apply grid grid-cols-1 lg:grid-cols-2 gap-6;
}
.grid-list {
@apply grid grid-cols-1 gap-4;
}
/* Layouts de page standardisés */
.page-layout {
@apply min-h-screen bg-gray-100;
}
.page-content {
@apply container-responsive space-section;
}
.page-section {
@apply bg-white rounded-xl shadow-lg overflow-hidden;
}
.page-header {
@apply px-6 py-4 border-b border-gray-200 flex items-center justify-between;
}
.page-body {
@apply p-6;
}
.page-footer {
@apply px-6 py-4 border-t border-gray-200 bg-gray-50;
}
/* Filtres */
.filter-section {
@apply bg-white rounded-lg shadow p-6;
@@ -233,7 +334,7 @@
ANIMATIONS ET EFFETS
======================================== */
/* Animation d'apparition */
/* Animations d'apparition standardisées */
@keyframes fadeInUp {
from {
opacity: 0;
@@ -245,10 +346,86 @@
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Classes d'animation standardisées */
.fade-in-up {
animation: fadeInUp 0.5s ease-out;
}
.fade-in-left {
animation: fadeInLeft 0.4s ease-out;
}
.fade-in-right {
animation: fadeInRight 0.4s ease-out;
}
.scale-in {
animation: scaleIn 0.3s ease-out;
}
/* Animations au scroll */
.animate-on-scroll {
opacity: 0;
transform: translateY(20px);
transition: all 0.6s ease-out;
}
.animate-on-scroll.in-view {
opacity: 1;
transform: translateY(0);
}
/* Micro-interactions */
.hover-lift {
transition: all var(--transition-normal);
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.hover-scale {
transition: transform var(--transition-normal);
}
.hover-scale:hover {
transform: scale(1.05);
}
/* Animation des messages flash */
@keyframes slideDown {
from {

View File

@@ -10,7 +10,7 @@ class ClassDashboard {
this.options = {
debounceTime: 300,
cacheTimeout: 5 * 60 * 1000, // 5 minutes
animationDuration: Notytex.config.transitions.normal,
animationDuration: 300, // ms
enableTouchGestures: true,
...options
};
@@ -44,6 +44,39 @@ class ClassDashboard {
this.init();
}
/**
* Utilitaires pour remplacer l'ancien système Notytex
*/
static utils = {
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
showToast(message, type = 'info', duration = 3000) {
// Implémentation simple de toast
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-4 py-2 rounded-lg text-white z-50 transition-opacity duration-300 ${
type === 'success' ? 'bg-green-500' :
type === 'error' ? 'bg-red-500' : 'bg-blue-500'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => document.body.removeChild(toast), 300);
}, duration);
}
};
/**
* Initialisation du dashboard
*/
@@ -122,8 +155,15 @@ class ClassDashboard {
* Charge les données initiales
*/
async loadInitialData() {
const trimester = this.state.currentTrimester;
return await this.fetchStats(trimester);
// Utiliser les données injectées si disponibles, sinon charger via AJAX
if (window.initialStatsData && Object.keys(window.initialStatsData).length > 0) {
this.updateStatsUI(window.initialStatsData);
return window.initialStatsData;
} else {
// Charger le trimestre par défaut ou depuis l'URL
const trimester = this.state.currentTrimester;
return await this.fetchStats(trimester);
}
}
/**
@@ -142,7 +182,7 @@ class ClassDashboard {
document.addEventListener('keydown', this.handleKeyboardNavigation.bind(this));
// Gestion du redimensionnement
const debouncedResize = Notytex.utils.debounce(
const debouncedResize = ClassDashboard.utils.debounce(
this.handleResize.bind(this),
this.options.debounceTime
);
@@ -224,7 +264,7 @@ class ClassDashboard {
// Notification de succès
const trimesterName = trimester ? `Trimestre ${trimester}` : 'Vue globale';
Notytex.utils.showToast(`${trimesterName} chargé`, 'success', 1500);
ClassDashboard.utils.showToast(`${trimesterName} chargé`, 'success', 1500);
}
/**
@@ -413,11 +453,17 @@ class ClassDashboard {
* Mise à jour de l'interface utilisateur avec les nouvelles données
*/
updateStatsUI(statsData) {
// Mise à jour des cards de domaines
this.updateDomainsCard(statsData.domains);
// Mise à jour des cards de domaines - vérifier la structure des données
if (statsData.domains) {
const domainsArray = statsData.domains.domains || statsData.domains;
this.updateDomainsCard(domainsArray);
}
// Mise à jour des cards de compétences
this.updateCompetencesCard(statsData.competences);
// Mise à jour des cards de compétences - vérifier la structure des données
if (statsData.competences) {
const competencesArray = statsData.competences.competences || statsData.competences;
this.updateCompetencesCard(competencesArray);
}
// Mise à jour des résultats
if (statsData.results) {
@@ -436,22 +482,25 @@ class ClassDashboard {
const card = document.querySelector('[data-stats-card="domains"]');
if (!card || !domainsData) return;
// S'assurer que c'est un tableau
const domains = Array.isArray(domainsData) ? domainsData : [];
// Mettre à jour le compteur
const countElement = card.querySelector('[data-domains-count]');
if (countElement) {
this.animateNumber(countElement, domainsData.length);
this.animateNumber(countElement, domains.length);
}
// Mettre à jour la liste
const container = card.querySelector('[data-domains-list]');
if (!container) return;
if (domainsData.length === 0) {
if (domains.length === 0) {
container.innerHTML = '<div class="text-center text-gray-500 text-sm py-4">Aucun domaine évalué</div>';
return;
}
container.innerHTML = domainsData.map(domain => `
container.innerHTML = domains.map(domain => `
<div class="bg-green-50 rounded-lg p-3 border border-green-100">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center">
@@ -474,22 +523,25 @@ class ClassDashboard {
const card = document.querySelector('[data-stats-card="competences"]');
if (!card || !competencesData) return;
// S'assurer que c'est un tableau
const competences = Array.isArray(competencesData) ? competencesData : [];
// Mettre à jour le compteur
const countElement = card.querySelector('[data-competences-count]');
if (countElement) {
this.animateNumber(countElement, competencesData.length);
this.animateNumber(countElement, competences.length);
}
// Mettre à jour la liste
const container = card.querySelector('[data-competences-list]');
if (!container) return;
if (competencesData.length === 0) {
if (competences.length === 0) {
container.innerHTML = '<div class="text-center text-gray-500 text-sm py-4">Aucune compétence évaluée</div>';
return;
}
container.innerHTML = competencesData.map(competence => `
container.innerHTML = competences.map(competence => `
<div class="bg-purple-50 rounded-lg p-3 border border-purple-100">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center">
@@ -1125,7 +1177,7 @@ class ClassDashboard {
this.elements.errorContainer.classList.remove('hidden');
}
Notytex.utils.showToast(message, 'error');
ClassDashboard.utils.showToast(message, 'error');
}
/**
@@ -1428,12 +1480,14 @@ class ClassDashboard {
threshold: 0.1
});
// Observer les cards non visibles
this.elements.statsCards.forEach(card => {
if (!this.isElementInViewport(card)) {
this.state.intersectionObserver.observe(card);
}
});
// Observer les cards non visibles - vérification de sécurité
if (this.elements.statsCards && this.elements.statsCards.length > 0) {
this.elements.statsCards.forEach(card => {
if (!this.isElementInViewport(card)) {
this.state.intersectionObserver.observe(card);
}
});
}
}
/**
@@ -1564,7 +1618,7 @@ class ClassDashboard {
});
}
} catch (error) {
console.log('Erreur lors du préchargement:', error);
// Erreur silencieuse du préchargement
}
}
}
@@ -1587,7 +1641,7 @@ class ClassDashboard {
}
});
} catch (error) {
console.log('Erreur lors du préchargement des détails:', error);
// Erreur silencieuse du préchargement des détails
}
}
@@ -1797,7 +1851,7 @@ class ClassDashboard {
// Recharger
await this.fetchStats(trimester);
Notytex.utils.showToast('Données actualisées', 'success');
ClassDashboard.utils.showToast('Données actualisées', 'success');
}
/**

View File

@@ -64,8 +64,6 @@ class CouncilPreparation {
// Setup advanced features
this.setupAdvancedFeatures();
console.log('✅ CouncilPreparation initialized successfully');
console.log('🎯 Focus Manager ready:', !!this.focusManager);
} catch (error) {
console.error('Erreur initialisation CouncilPreparation:', error);
@@ -539,7 +537,6 @@ class AutoSaveManager {
bindManualSaveButtons() {
// Boutons supprimés de l'interface - auto-sauvegarde uniquement
console.log('📝 Auto-sauvegarde activée - Pas de boutons manuels');
}
setupCharacterCounter(textarea, studentId) {
@@ -883,7 +880,6 @@ class FocusManager {
// Utiliser la délégation d'événement pour gérer plusieurs boutons
document.addEventListener('click', (e) => {
if (e.target.matches('[data-toggle-focus-mode]') || e.target.closest('[data-toggle-focus-mode]')) {
console.log('🎯 Bouton Mode Focus cliqué !');
e.preventDefault();
this.toggleFocusMode();
}
@@ -927,7 +923,6 @@ class FocusManager {
toggleFocusMode(forcedState = null) {
const newState = forcedState !== null ? forcedState : !this.parent.state.isFocusMode;
console.log(`🔄 Basculement vers mode: ${newState ? 'FOCUS' : 'LISTE'}`);
this.parent.state.isFocusMode = newState;
if (newState) {
@@ -951,7 +946,6 @@ class FocusManager {
// Sauvegarder l'état
localStorage.setItem('council-focus-mode', 'true');
console.log('✨ Mode focus activé - Focus sur première appréciation');
}
exitFocusMode() {
@@ -1100,14 +1094,12 @@ class FocusManager {
}
bindFocusStudentEvents(clonedStudent, studentId) {
console.log(`🔧 Attachement des événements pour l'élève ${studentId} en mode focus`);
// 1. Événements textarea avec synchronisation bidirectionnelle
const textarea = clonedStudent.querySelector(`[data-appreciation-textarea][data-student-id="${studentId}"]`);
if (textarea) {
// Handler de sauvegarde avec synchronisation
const saveHandler = this.parent.autoSaveManager.debounce(() => {
console.log(`💾 Sauvegarde en focus pour élève ${studentId}`);
this.saveFocusAppreciation(studentId, textarea.value);
}, this.parent.options.debounceTime);
@@ -1122,7 +1114,6 @@ class FocusManager {
// Événement blur avec sauvegarde immédiate
textarea.addEventListener('blur', () => {
if (this.parent.state.modifiedAppreciations.has(studentId)) {
console.log(`💾 Sauvegarde blur en focus pour élève ${studentId}`);
this.saveFocusAppreciation(studentId, textarea.value, true);
}
});
@@ -1132,14 +1123,12 @@ class FocusManager {
}
// 2. Boutons supprimés - auto-sauvegarde uniquement
console.log(`🔧 Mode focus: Auto-sauvegarde configurée pour élève ${studentId}`);
// 4. Gestion des barres de progression
this.setupProgressBars(clonedStudent);
}
ensureAllSectionsVisible(clonedStudent) {
console.log('🔍 Vérification de la visibilité de toutes les sections en mode focus');
// S'assurer que les sections compétences/domaines sont visibles
const competenceSection = clonedStudent.querySelector('.competence-domain-section');
@@ -1147,7 +1136,6 @@ class FocusManager {
competenceSection.style.display = 'block';
competenceSection.style.minHeight = '200px';
competenceSection.style.flexShrink = '0';
console.log('✅ Section compétences/domaines visible');
}
// S'assurer que les barres de progression sont configurées
@@ -1157,20 +1145,17 @@ class FocusManager {
});
// Section info supprimée - maintenant intégrée dans la zone d'appréciation
console.log('✅ Informations intégrées dans la zone d\'appréciation');
// S'assurer que les résultats d'évaluation sont visibles
const evaluationResults = clonedStudent.querySelector('.evaluation-results');
if (evaluationResults) {
evaluationResults.style.display = 'block';
console.log('✅ Section résultats d\'évaluation visible');
}
// S'assurer que la section progress-bars est visible
const progressBarsSection = clonedStudent.querySelector('.progress-bars');
if (progressBarsSection) {
progressBarsSection.style.display = 'block';
console.log('✅ Section barres de progression visible');
}
}
@@ -1568,7 +1553,6 @@ class FocusManager {
if (!originalStudent || !focusStudent) return;
// Synchroniser les valeurs des barres si nécessaire
console.log(`🔄 Synchronisation des barres de progression pour l'élève ${studentId}`);
}
async saveFocusAppreciation(studentId, appreciation, immediate = false) {
@@ -1591,7 +1575,6 @@ class FocusManager {
const result = await response.json();
if (response.ok && result.success) {
console.log(`✅ Sauvegarde réussie en focus pour élève ${studentId}`);
this.showFocusSavedState(studentId);
this.parent.state.modifiedAppreciations.delete(studentId);
@@ -1696,7 +1679,6 @@ class FocusManager {
setTimeout(() => {
const textarea = clonedStudent.querySelector('[data-appreciation-textarea]');
if (textarea) {
console.log('🎯 Focus automatique sur le textarea d\'appréciation');
textarea.focus();
// Positionner le curseur à la fin du texte existant
@@ -1738,7 +1720,6 @@ class FocusManager {
if (this.parent.state.focusCurrentIndex > 0) {
this.parent.state.focusCurrentIndex--;
this.showCurrentStudent();
console.log('⬅️ Navigation vers élève précédent avec focus sur appréciation');
}
}
@@ -1746,7 +1727,6 @@ class FocusManager {
if (this.parent.state.focusCurrentIndex < this.parent.state.filteredStudents.length - 1) {
this.parent.state.focusCurrentIndex++;
this.showCurrentStudent();
console.log('➡️ Navigation vers élève suivant avec focus sur appréciation');
}
}
@@ -1788,10 +1768,6 @@ class FocusManager {
// Debug des hauteurs
const containerHeight = focusContainer.offsetHeight;
const studentHeight = student.offsetHeight;
console.log(`🎯 Mode focus optimisé:`);
console.log(` Container height: ${containerHeight}px`);
console.log(` Student height: ${studentHeight}px`);
console.log(` Window height: ${window.innerHeight}px`);
}
preserveJsonDataBeforeCloning(originalStudent) {

View File

@@ -0,0 +1,515 @@
/**
* FILTER MANAGER - Système de filtrage unifié
* Remplace les multiples implémentations de filtres
*/
class FilterManager {
constructor(core, config = {}) {
this.core = core;
this.config = {
autoSubmit: true,
debounceTime: 300,
preserveState: true,
updateUrl: true,
...config
};
this.filters = new Map();
this.activeFilters = new Map();
this.callbacks = new Set();
}
init() {
this.core.components.set('filters', this);
this.setupEventListeners();
this.detectExistingFilters();
// Restaurer l'état depuis l'URL si configuré
if (this.config.preserveState) {
this.restoreFromUrl();
}
}
/**
* Configuration des événements
*/
setupEventListeners() {
// Délégation d'événements pour tous les filtres
document.addEventListener('change', this.handleFilterChange.bind(this));
document.addEventListener('click', this.handleFilterClick.bind(this));
document.addEventListener('input', this.core.utils.debounce(
this.handleFilterInput.bind(this),
this.config.debounceTime
));
// Écouter les resets
document.addEventListener('click', this.handleResetClick.bind(this));
}
/**
* Détecter les filtres existants dans le DOM
*/
detectExistingFilters() {
// Sélecteurs data-filter (ancien système)
this.core.utils.queryAll('[data-filter]').forEach(element => {
const filterName = element.getAttribute('data-filter');
this.registerFilter(filterName, {
element,
type: this.getFilterType(element),
autoDetected: true
});
});
// Nouveaux sélecteurs filter-btn
this.core.utils.queryAll('.filter-btn').forEach(element => {
const filterName = element.getAttribute('data-filter');
const filterValue = element.getAttribute('data-value');
if (filterName) {
this.registerFilter(filterName, {
element,
type: 'button',
value: filterValue,
autoDetected: true
});
}
});
// Champs de recherche
this.core.utils.queryAll('[data-search-filter]').forEach(element => {
const filterName = element.getAttribute('data-search-filter');
this.registerFilter(filterName, {
element,
type: 'search',
autoDetected: true
});
});
}
/**
* Enregistrer un filtre
*/
registerFilter(name, options = {}) {
const filter = {
name,
type: options.type || 'select',
element: options.element,
value: options.value || '',
multiple: options.multiple || false,
required: options.required || false,
transform: options.transform || (value => value),
validate: options.validate || (() => true),
...options
};
this.filters.set(name, filter);
// Marquer l'élément comme géré
if (filter.element) {
filter.element.setAttribute('data-filter-managed', 'true');
}
this.core.emit('filter:registered', { name, filter });
return filter;
}
/**
* Définir la valeur d'un filtre
*/
setFilter(name, value, options = {}) {
const filter = this.filters.get(name);
if (!filter) {
console.warn(`Filter "${name}" not found`);
return false;
}
// Validation
if (filter.validate && !filter.validate(value)) {
console.warn(`Invalid value for filter "${name}": ${value}`);
return false;
}
// Transformation
const transformedValue = filter.transform(value);
// Mise à jour de l'état
if (transformedValue === '' || transformedValue === null || transformedValue === undefined) {
this.activeFilters.delete(name);
} else {
this.activeFilters.set(name, transformedValue);
}
// Mise à jour de l'élément DOM
this.updateFilterElement(filter, transformedValue);
// Mise à jour des tags visuels
this.updateFilterTags();
// Auto-submit si configuré
if (this.config.autoSubmit && !options.silent) {
this.applyFilters();
}
this.core.emit('filter:changed', { name, value: transformedValue, filter });
return true;
}
/**
* Obtenir la valeur d'un filtre
*/
getFilter(name) {
return this.activeFilters.get(name) || '';
}
/**
* Obtenir tous les filtres actifs
*/
getActiveFilters() {
return Object.fromEntries(this.activeFilters);
}
/**
* Réinitialiser tous les filtres
*/
reset(options = {}) {
this.activeFilters.clear();
// Réinitialiser les éléments DOM
this.filters.forEach(filter => {
this.updateFilterElement(filter, '');
});
this.updateFilterTags();
if (this.config.autoSubmit && !options.silent) {
this.applyFilters();
}
this.core.emit('filters:reset');
}
/**
* Appliquer les filtres
*/
applyFilters() {
const filters = this.getActiveFilters();
// Mise à jour de l'URL si configuré
if (this.config.updateUrl) {
this.updateUrl(filters);
}
// Exécuter les callbacks
this.callbacks.forEach(callback => {
try {
callback(filters);
} catch (error) {
console.error('Filter callback error:', error);
}
});
// Émettre événement global
this.core.emit('filters:applied', { filters });
// Par défaut, recharger la page avec les nouveaux paramètres
if (this.callbacks.size === 0 && this.config.updateUrl) {
window.location.href = window.location.href;
}
}
/**
* Ajouter un callback d'application des filtres
*/
onApply(callback) {
this.callbacks.add(callback);
// Retourner fonction de désabonnement
return () => {
this.callbacks.delete(callback);
};
}
/**
* Restaurer les filtres depuis l'URL
*/
restoreFromUrl() {
const params = new URLSearchParams(window.location.search);
params.forEach((value, key) => {
// Mapper les noms de paramètres aux noms de filtres
const filterName = this.mapUrlParamToFilter(key);
if (filterName && this.filters.has(filterName)) {
this.setFilter(filterName, value, { silent: true });
}
});
}
/**
* Mettre à jour l'URL avec les filtres
*/
updateUrl(filters) {
const url = new URL(window.location);
const params = url.searchParams;
// Nettoyer les paramètres existants liés aux filtres
this.filters.forEach((filter, name) => {
const paramName = this.mapFilterToUrlParam(name);
params.delete(paramName);
});
// Ajouter les nouveaux paramètres
Object.entries(filters).forEach(([name, value]) => {
if (value !== '' && value !== null && value !== undefined) {
const paramName = this.mapFilterToUrlParam(name);
params.set(paramName, value);
}
});
// Mise à jour sans rechargement
window.history.replaceState(null, '', url.toString());
}
/**
* Gestionnaire de changement pour selects et inputs
*/
handleFilterChange(event) {
const element = event.target;
if (!element.hasAttribute('data-filter') || element.hasAttribute('data-filter-managed')) {
return;
}
const filterName = element.getAttribute('data-filter');
const value = this.getElementValue(element);
this.setFilter(filterName, value);
}
/**
* Gestionnaire de clic pour boutons de filtre
*/
handleFilterClick(event) {
const button = event.target.closest('.filter-btn');
if (!button) return;
event.preventDefault();
const filterName = button.getAttribute('data-filter');
const filterValue = button.getAttribute('data-value');
if (!filterName) return;
// Mise à jour des boutons visuels du même groupe
this.updateFilterButtonGroup(filterName, filterValue);
// Application du filtre
this.setFilter(filterName, filterValue);
}
/**
* Gestionnaire pour les champs de recherche
*/
handleFilterInput(event) {
const element = event.target;
if (!element.hasAttribute('data-search-filter')) return;
const filterName = element.getAttribute('data-search-filter');
const value = element.value.trim();
this.setFilter(filterName, value);
}
/**
* Gestionnaire de reset
*/
handleResetClick(event) {
const resetButton = event.target.closest('#reset-filters, [data-filter-reset]');
if (!resetButton) return;
event.preventDefault();
this.reset();
}
/**
* Mettre à jour l'élément DOM d'un filtre
*/
updateFilterElement(filter, value) {
if (!filter.element) return;
switch (filter.type) {
case 'select':
filter.element.value = value;
break;
case 'input':
case 'search':
filter.element.value = value;
break;
case 'checkbox':
filter.element.checked = !!value;
break;
case 'radio':
filter.element.checked = filter.element.value === value;
break;
}
}
/**
* Mettre à jour un groupe de boutons de filtre
*/
updateFilterButtonGroup(filterName, activeValue) {
const buttons = this.core.utils.queryAll(`[data-filter="${filterName}"].filter-btn`);
buttons.forEach(button => {
const buttonValue = button.getAttribute('data-value');
const isActive = buttonValue === activeValue;
// Mise à jour des classes CSS
if (isActive) {
button.classList.remove('bg-white', 'text-gray-600', 'border-gray-300');
// Appliquer le style approprié selon le type
if (filterName === 'correction') {
if (activeValue === 'not_started') {
button.classList.add('bg-red-500', 'text-white', 'border-red-500');
} else if (activeValue === 'incomplete') {
button.classList.add('bg-orange-500', 'text-white', 'border-orange-500');
} else if (activeValue === 'complete') {
button.classList.add('bg-green-500', 'text-white', 'border-green-500');
}
} else {
button.classList.add('bg-blue-600', 'text-white', 'border-blue-600');
}
} else {
// Style inactif
button.classList.remove('bg-blue-600', 'text-white', 'border-blue-600');
button.classList.remove('bg-red-500', 'bg-orange-500', 'bg-green-500');
button.classList.add('bg-white', 'text-gray-600', 'border-gray-300');
}
});
}
/**
* Mettre à jour les tags de filtres actifs
*/
updateFilterTags() {
const container = this.core.utils.query('#active-filter-tags');
if (!container) return;
container.innerHTML = '';
this.activeFilters.forEach((value, name) => {
const tag = this.core.utils.createElement('span', {
className: 'inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800'
});
const text = this.core.utils.createElement('span');
text.textContent = `${this.getFilterDisplayName(name)}: ${this.getValueDisplayName(name, value)}`;
const removeBtn = this.core.utils.createElement('button', {
className: 'ml-1 hover:text-blue-600',
onclick: () => this.setFilter(name, '')
});
removeBtn.innerHTML = '×';
tag.appendChild(text);
tag.appendChild(removeBtn);
container.appendChild(tag);
});
// Afficher/masquer le bouton reset
const resetButton = this.core.utils.query('#reset-filters');
if (resetButton) {
if (this.activeFilters.size > 0) {
resetButton.classList.remove('hidden');
} else {
resetButton.classList.add('hidden');
}
}
}
/**
* Utilitaires pour les types d'éléments
*/
getFilterType(element) {
const tagName = element.tagName.toLowerCase();
const type = element.type;
if (tagName === 'select') return 'select';
if (tagName === 'input') {
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
return 'input';
}
if (element.classList.contains('filter-btn')) return 'button';
return 'unknown';
}
getElementValue(element) {
const type = this.getFilterType(element);
switch (type) {
case 'checkbox':
return element.checked ? element.value || 'true' : '';
case 'radio':
return element.checked ? element.value : '';
default:
return element.value;
}
}
/**
* Mappage URL ↔ Filtres
*/
mapFilterToUrlParam(filterName) {
const mapping = {
'trimester': 'trimester',
'class': 'class_id',
'correction': 'correction',
'search': 'q'
};
return mapping[filterName] || filterName;
}
mapUrlParamToFilter(paramName) {
const reverseMapping = {
'trimester': 'trimester',
'class_id': 'class',
'correction': 'correction',
'q': 'search'
};
return reverseMapping[paramName] || paramName;
}
/**
* Noms d'affichage
*/
getFilterDisplayName(filterName) {
const names = {
'trimester': 'Trimestre',
'class': 'Classe',
'correction': 'État',
'search': 'Recherche'
};
return names[filterName] || filterName;
}
getValueDisplayName(filterName, value) {
const valueNames = {
'correction': {
'not_started': 'Non commencées',
'incomplete': 'En cours',
'complete': 'Terminées'
},
'trimester': {
'1': 'T1', '2': 'T2', '3': 'T3'
}
};
return valueNames[filterName]?.[value] || value;
}
}
export default FilterManager;

View File

@@ -0,0 +1,382 @@
/**
* MODAL MANAGER - Système de modales unifié
* Fait partie du design system Notytex
*/
class ModalManager {
constructor(core, config = {}) {
this.core = core;
this.config = {
backdrop: true,
keyboard: true,
focus: true,
...config
};
this.activeModals = new Set();
this.modalStack = [];
}
init() {
this.core.components.set('modals', this);
this.setupEventListeners();
// Auto-détecter les modales existantes
this.core.utils.queryAll('.modal').forEach(modal => {
this.registerModal(modal);
});
}
/**
* Configuration des événements
*/
setupEventListeners() {
// Gestion du clavier
if (this.config.keyboard) {
document.addEventListener('keydown', this.handleKeydown.bind(this));
}
// Gestion des clics sur backdrop
document.addEventListener('click', this.handleBackdropClick.bind(this));
// Détection automatique des triggers
document.addEventListener('click', this.handleTriggerClick.bind(this));
}
/**
* Enregistrer une modale existante
*/
registerModal(modalElement) {
const modalId = modalElement.id;
if (!modalId) {
console.warn('Modal sans ID détectée, ignorée');
return;
}
// Marquer comme gérée par le système
modalElement.setAttribute('data-modal-managed', 'true');
// Configuration ARIA
modalElement.setAttribute('role', 'dialog');
modalElement.setAttribute('aria-modal', 'true');
modalElement.setAttribute('aria-hidden', 'true');
return modalId;
}
/**
* Créer une modale programmatiquement
*/
create(options = {}) {
const modalId = options.id || `modal-${Date.now()}`;
const modal = this.core.utils.createElement('div', {
id: modalId,
className: 'modal fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50',
role: 'dialog',
'aria-modal': 'true',
'aria-hidden': 'true',
'aria-labelledby': options.titleId || `${modalId}-title`
});
const modalDialog = this.core.utils.createElement('div', {
className: 'flex items-center justify-center min-h-screen p-4'
});
const modalContent = this.core.utils.createElement('div', {
className: `modal-content bg-white rounded-xl shadow-xl transform transition-all duration-300 scale-95 ${options.size === 'large' ? 'max-w-4xl' : options.size === 'small' ? 'max-w-md' : 'max-w-2xl'} w-full`
});
// Header si fourni
if (options.title || options.header) {
const header = this.core.utils.createElement('div', {
className: 'modal-header px-6 py-4 border-b border-gray-200 flex items-center justify-between'
});
const title = this.core.utils.createElement('h3', {
id: options.titleId || `${modalId}-title`,
className: 'text-lg font-semibold text-gray-900'
});
if (options.title) {
title.textContent = options.title;
} else if (options.header) {
title.innerHTML = options.header;
}
const closeButton = this.core.utils.createElement('button', {
className: 'text-gray-400 hover:text-gray-600 transition-colors',
onclick: () => this.close(modalId)
});
closeButton.innerHTML = `
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
`;
header.appendChild(title);
header.appendChild(closeButton);
modalContent.appendChild(header);
}
// Body
const body = this.core.utils.createElement('div', {
className: 'modal-body p-6'
});
if (options.content) {
if (typeof options.content === 'string') {
body.innerHTML = options.content;
} else {
body.appendChild(options.content);
}
}
modalContent.appendChild(body);
// Footer si fourni
if (options.footer || options.actions) {
const footer = this.core.utils.createElement('div', {
className: 'modal-footer px-6 py-4 border-t border-gray-200 flex justify-end space-x-3'
});
if (options.actions) {
options.actions.forEach(action => {
const button = this.core.utils.createElement('button', {
className: `px-4 py-2 rounded-lg font-medium transition-colors ${this.getActionButtonClasses(action.type)}`,
onclick: action.handler
});
button.textContent = action.text;
footer.appendChild(button);
});
} else if (options.footer) {
footer.innerHTML = options.footer;
}
modalContent.appendChild(footer);
}
modalDialog.appendChild(modalContent);
modal.appendChild(modalDialog);
document.body.appendChild(modal);
this.registerModal(modal);
return modalId;
}
/**
* Ouvrir une modale
*/
open(modalId, options = {}) {
const modal = document.getElementById(modalId);
if (!modal) {
console.error(`Modal with ID "${modalId}" not found`);
return false;
}
// Fermer autres modales si non-stackable
if (!options.stackable && this.activeModals.size > 0) {
this.closeAll();
}
// Ajouter à la pile active
this.activeModals.add(modalId);
this.modalStack.push(modalId);
// Affichage
modal.classList.remove('hidden');
modal.classList.add('flex');
modal.setAttribute('aria-hidden', 'false');
// Animation d'entrée
const content = modal.querySelector('.modal-content');
if (content) {
requestAnimationFrame(() => {
content.classList.remove('scale-95');
content.classList.add('scale-100');
});
}
// Gestion du focus
if (this.config.focus) {
this.trapFocus(modal);
}
// Bloquer le scroll du body
document.body.classList.add('overflow-hidden');
// Émettre événement
this.core.emit('modal:opened', { modalId, modal });
return true;
}
/**
* Fermer une modale
*/
close(modalId) {
const modal = document.getElementById(modalId);
if (!modal || !this.activeModals.has(modalId)) {
return false;
}
// Animation de sortie
const content = modal.querySelector('.modal-content');
if (content) {
content.classList.remove('scale-100');
content.classList.add('scale-95');
}
// Masquage après animation
setTimeout(() => {
modal.classList.add('hidden');
modal.classList.remove('flex');
modal.setAttribute('aria-hidden', 'true');
}, 150);
// Retirer de la pile active
this.activeModals.delete(modalId);
const stackIndex = this.modalStack.indexOf(modalId);
if (stackIndex > -1) {
this.modalStack.splice(stackIndex, 1);
}
// Restaurer le scroll si aucune modale active
if (this.activeModals.size === 0) {
document.body.classList.remove('overflow-hidden');
}
// Émettre événement
this.core.emit('modal:closed', { modalId, modal });
return true;
}
/**
* Fermer toutes les modales
*/
closeAll() {
Array.from(this.activeModals).forEach(modalId => {
this.close(modalId);
});
}
/**
* Piégeage du focus dans la modale
*/
trapFocus(modal) {
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Focus initial
if (firstElement) {
firstElement.focus();
}
// Gestionnaire de tabulation
const handleTab = (event) => {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
};
modal.addEventListener('keydown', handleTab);
// Nettoyer à la fermeture
const cleanup = () => {
modal.removeEventListener('keydown', handleTab);
this.core.off('modal:closed', cleanup);
};
this.core.on('modal:closed', cleanup);
}
/**
* Gestionnaire de clics sur backdrop
*/
handleBackdropClick(event) {
if (!this.config.backdrop) return;
const modal = event.target.closest('.modal');
if (!modal || !this.activeModals.has(modal.id)) return;
// Vérifier si le clic est sur le backdrop (pas le contenu)
if (event.target === modal || event.target.classList.contains('modal-backdrop')) {
this.close(modal.id);
}
}
/**
* Gestionnaire de touches clavier
*/
handleKeydown(event) {
if (event.key === 'Escape' && this.modalStack.length > 0) {
const topModalId = this.modalStack[this.modalStack.length - 1];
this.close(topModalId);
}
}
/**
* Gestionnaire de clics sur triggers
*/
handleTriggerClick(event) {
const trigger = event.target.closest('[data-modal-target]');
if (!trigger) return;
event.preventDefault();
const targetId = trigger.getAttribute('data-modal-target');
const options = {
stackable: trigger.hasAttribute('data-modal-stackable')
};
this.open(targetId, options);
}
/**
* Classes CSS pour les boutons d'action
*/
getActionButtonClasses(type) {
const classes = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
success: 'bg-green-600 text-white hover:bg-green-700',
danger: 'bg-red-600 text-white hover:bg-red-700',
warning: 'bg-orange-600 text-white hover:bg-orange-700'
};
return classes[type] || classes.secondary;
}
/**
* Utilitaires publics
*/
isOpen(modalId) {
return this.activeModals.has(modalId);
}
getActiveModals() {
return Array.from(this.activeModals);
}
hasActiveModals() {
return this.activeModals.size > 0;
}
}
export default ModalManager;

View File

@@ -0,0 +1,297 @@
/**
* NOTIFICATION SYSTEM - Composant unifié pour toasts et alertes
* Fait partie du design system Notytex
*/
class NotificationSystem {
constructor(core, config = {}) {
this.core = core;
this.config = {
position: 'top-right',
duration: 3000,
maxVisible: 5,
animation: 'slide',
...config
};
this.notifications = new Map();
this.container = null;
this.nextId = 1;
}
init() {
this.createContainer();
this.core.components.set('notifications', this);
// Enregistrer dans le core pour accès global
this.core.on('notification:show', (event) => {
this.show(event.detail);
});
}
/**
* Créer le conteneur pour les notifications
*/
createContainer() {
if (this.container) return;
const positions = {
'top-right': 'fixed top-4 right-4 z-50',
'top-left': 'fixed top-4 left-4 z-50',
'bottom-right': 'fixed bottom-4 right-4 z-50',
'bottom-left': 'fixed bottom-4 left-4 z-50',
'top-center': 'fixed top-4 left-1/2 transform -translate-x-1/2 z-50',
'bottom-center': 'fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50'
};
this.container = this.core.utils.createElement('div', {
id: 'notytex-notifications',
className: `${positions[this.config.position]} space-y-2 pointer-events-none`
});
document.body.appendChild(this.container);
}
/**
* Afficher une notification
*/
show(options = {}) {
const notification = {
id: this.nextId++,
type: options.type || 'info',
title: options.title || '',
message: options.message || '',
duration: options.duration || this.config.duration,
actions: options.actions || [],
persistent: options.persistent || false,
...options
};
// Limiter le nombre de notifications visibles
if (this.notifications.size >= this.config.maxVisible) {
const oldestId = Array.from(this.notifications.keys())[0];
this.hide(oldestId);
}
this.notifications.set(notification.id, notification);
this.render(notification);
// Auto-masquage si non persistant
if (!notification.persistent && notification.duration > 0) {
setTimeout(() => {
this.hide(notification.id);
}, notification.duration);
}
return notification.id;
}
/**
* Afficher types spécifiques (méthodes de convenance)
*/
success(message, title = 'Succès', options = {}) {
return this.show({ type: 'success', message, title, ...options });
}
error(message, title = 'Erreur', options = {}) {
return this.show({ type: 'error', message, title, ...options });
}
warning(message, title = 'Attention', options = {}) {
return this.show({ type: 'warning', message, title, ...options });
}
info(message, title = 'Information', options = {}) {
return this.show({ type: 'info', message, title, ...options });
}
/**
* Masquer une notification
*/
hide(notificationId) {
const notification = this.notifications.get(notificationId);
if (!notification) return;
const element = document.getElementById(`notification-${notificationId}`);
if (element) {
// Animation de sortie
element.style.transition = 'all 300ms cubic-bezier(0.4, 0, 0.2, 1)';
element.style.transform = 'translateX(100%)';
element.style.opacity = '0';
setTimeout(() => {
if (element.parentNode) {
element.parentNode.removeChild(element);
}
}, 300);
}
this.notifications.delete(notificationId);
}
/**
* Masquer toutes les notifications
*/
hideAll() {
Array.from(this.notifications.keys()).forEach(id => {
this.hide(id);
});
}
/**
* Rendu d'une notification
*/
render(notification) {
const typeConfig = this.getTypeConfig(notification.type);
const element = this.core.utils.createElement('div', {
id: `notification-${notification.id}`,
className: `pointer-events-auto max-w-sm w-full ${typeConfig.bgColor} border ${typeConfig.borderColor} shadow-lg rounded-lg overflow-hidden transform transition-all duration-300 translate-x-full opacity-0`,
role: 'alert',
'aria-live': 'polite'
});
const content = this.core.utils.createElement('div', {
className: 'p-4'
});
const header = this.core.utils.createElement('div', {
className: 'flex items-start'
});
// Icône
const iconContainer = this.core.utils.createElement('div', {
className: 'flex-shrink-0'
});
iconContainer.innerHTML = typeConfig.icon;
// Contenu
const textContent = this.core.utils.createElement('div', {
className: 'ml-3 w-0 flex-1'
});
if (notification.title) {
const title = this.core.utils.createElement('p', {
className: `text-sm font-medium ${typeConfig.textColor}`
});
title.textContent = notification.title;
textContent.appendChild(title);
}
if (notification.message) {
const message = this.core.utils.createElement('p', {
className: `text-sm ${typeConfig.textColor} ${notification.title ? 'mt-1' : ''}`
});
message.textContent = notification.message;
textContent.appendChild(message);
}
// Actions
if (notification.actions && notification.actions.length > 0) {
const actionsContainer = this.core.utils.createElement('div', {
className: 'mt-3 flex space-x-2'
});
notification.actions.forEach(action => {
const button = this.core.utils.createElement('button', {
className: `text-sm font-medium ${typeConfig.actionColor} hover:${typeConfig.actionHoverColor} transition-colors`,
onclick: action.handler
});
button.textContent = action.text;
actionsContainer.appendChild(button);
});
textContent.appendChild(actionsContainer);
}
// Bouton de fermeture
const closeButton = this.core.utils.createElement('div', {
className: 'ml-4 flex-shrink-0 flex'
});
const closeBtn = this.core.utils.createElement('button', {
className: `rounded-md inline-flex ${typeConfig.textColor} hover:${typeConfig.closeHoverColor} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`,
onclick: () => this.hide(notification.id)
});
closeBtn.innerHTML = `
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
`;
closeButton.appendChild(closeBtn);
// Assemblage
header.appendChild(iconContainer);
header.appendChild(textContent);
header.appendChild(closeButton);
content.appendChild(header);
element.appendChild(content);
this.container.appendChild(element);
// Animation d'entrée
requestAnimationFrame(() => {
element.classList.remove('translate-x-full', 'opacity-0');
});
return element;
}
/**
* Configuration des types de notification
*/
getTypeConfig(type) {
const configs = {
success: {
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
textColor: 'text-green-800',
actionColor: 'text-green-600',
actionHoverColor: 'text-green-500',
closeHoverColor: 'text-green-500',
icon: `<svg class="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>`
},
error: {
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
textColor: 'text-red-800',
actionColor: 'text-red-600',
actionHoverColor: 'text-red-500',
closeHoverColor: 'text-red-500',
icon: `<svg class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>`
},
warning: {
bgColor: 'bg-orange-50',
borderColor: 'border-orange-200',
textColor: 'text-orange-800',
actionColor: 'text-orange-600',
actionHoverColor: 'text-orange-500',
closeHoverColor: 'text-orange-500',
icon: `<svg class="w-5 h-5 text-orange-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>`
},
info: {
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
textColor: 'text-blue-800',
actionColor: 'text-blue-600',
actionHoverColor: 'text-blue-500',
closeHoverColor: 'text-blue-500',
icon: `<svg class="w-5 h-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>`
}
};
return configs[type] || configs.info;
}
}
export default NotificationSystem;

496
static/js/notytex-core.js Normal file
View File

@@ -0,0 +1,496 @@
/**
* NOTYTEX CORE - Système JavaScript Unifié
* Architecture moderne ES6 pour l'application Notytex
* Version 2.0 - Refactoring complet pour uniformisation
*/
class NotytexCore {
constructor() {
this.modules = new Map();
this.components = new Map();
this.observers = new Map();
this.config = this.getDefaultConfig();
this.state = this.createReactiveState();
this.init();
}
/**
* Configuration par défaut unifiée
*/
getDefaultConfig() {
return {
// Design tokens JavaScript
designTokens: {
transitions: {
fast: 150,
normal: 300,
slow: 500,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)'
},
breakpoints: {
mobile: '(max-width: 767px)',
tablet: '(min-width: 768px) and (max-width: 1023px)',
desktop: '(min-width: 1024px)'
},
colors: {
primary: '#3b82f6',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444'
}
},
// Configuration des modules
modules: {
animations: { enabled: true, performanceMode: false },
filters: { autoSubmit: true, debounceTime: 300 },
notifications: { position: 'top-right', duration: 3000 },
modals: { backdrop: true, keyboard: true }
},
// Performance
performance: {
lazyLoading: true,
bundleOptimization: true,
cacheStrategy: 'aggressive'
}
};
}
/**
* Créer un état réactif avec Proxy
*/
createReactiveState() {
const state = {};
const self = this;
return new Proxy(state, {
set(target, property, value) {
const oldValue = target[property];
target[property] = value;
// Notifier les observateurs (vérifier que observers existe)
if (self.observers && self.observers.has(property)) {
self.observers.get(property).forEach(callback => {
callback(value, oldValue, property);
});
}
return true;
},
get(target, property) {
return target[property];
}
});
}
/**
* Initialisation du core
*/
init() {
this.setupEventListeners();
this.detectEnvironment();
this.loadCoreModules();
this.initializeAnimations();
// Déclenchement de l'événement de prêt
this.emit('core:ready');
}
/**
* Enregistrer un module
*/
registerModule(name, moduleClass, config = {}) {
try {
const moduleInstance = new moduleClass(this, config);
this.modules.set(name, moduleInstance);
// Auto-init si le module le support
if (typeof moduleInstance.init === 'function') {
moduleInstance.init();
}
this.emit('module:registered', { name, module: moduleInstance });
return moduleInstance;
} catch (error) {
console.error(`Failed to register module ${name}:`, error);
return null;
}
}
/**
* Obtenir un module
*/
getModule(name) {
return this.modules.get(name);
}
/**
* Chargement paresseux d'un module
*/
async loadModule(name, path) {
if (this.modules.has(name)) {
return this.modules.get(name);
}
try {
const moduleFile = await import(path);
const ModuleClass = moduleFile.default || moduleFile[name];
return this.registerModule(name, ModuleClass);
} catch (error) {
console.error(`Failed to load module ${name} from ${path}:`, error);
return null;
}
}
/**
* Gestionnaire d'état réactif
*/
setState(updates) {
Object.assign(this.state, updates);
}
getState(key) {
return key ? this.state[key] : this.state;
}
subscribe(key, callback) {
if (!this.observers.has(key)) {
this.observers.set(key, new Set());
}
this.observers.get(key).add(callback);
// Retourner fonction de désabonnement
return () => {
this.observers.get(key).delete(callback);
};
}
/**
* Système d'événements unifié
*/
emit(eventName, data = null) {
const event = new CustomEvent(eventName, {
detail: data,
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
return event;
}
on(eventName, callback) {
document.addEventListener(eventName, callback);
return () => document.removeEventListener(eventName, callback);
}
/**
* Utilitaires unifiés
*/
utils = {
// Debounce optimisé
debounce: (func, wait, immediate = false) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
timeout = null;
if (!immediate) func(...args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func(...args);
};
},
// Throttle pour performance
throttle: (func, limit) => {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
},
// Création d'éléments sécurisée
createElement: (tag, attributes = {}, children = []) => {
const element = document.createElement(tag);
Object.entries(attributes).forEach(([key, value]) => {
if (key === 'className') {
element.className = value;
} else if (key === 'innerHTML') {
element.innerHTML = value;
} else {
element.setAttribute(key, value);
}
});
children.forEach(child => {
if (typeof child === 'string') {
element.appendChild(document.createTextNode(child));
} else {
element.appendChild(child);
}
});
return element;
},
// Sélecteur sécurisé
query: (selector, context = document) => {
try {
return context.querySelector(selector);
} catch (error) {
console.warn(`Invalid selector: ${selector}`, error);
return null;
}
},
queryAll: (selector, context = document) => {
try {
return Array.from(context.querySelectorAll(selector));
} catch (error) {
console.warn(`Invalid selector: ${selector}`, error);
return [];
}
},
// Copie dans le presse-papiers
copyToClipboard: async (text) => {
try {
await navigator.clipboard.writeText(text);
NotytexCore.instance.components.get('notifications')?.show({
type: 'success',
message: 'Copié dans le presse-papiers'
});
return true;
} catch (error) {
console.error('Clipboard error:', error);
NotytexCore.instance.components.get('notifications')?.show({
type: 'error',
message: 'Erreur lors de la copie'
});
return false;
}
}
};
/**
* Détection de l'environnement
*/
detectEnvironment() {
this.state.environment = {
isMobile: window.matchMedia(this.config.designTokens.breakpoints.mobile).matches,
isTablet: window.matchMedia(this.config.designTokens.breakpoints.tablet).matches,
isDesktop: window.matchMedia(this.config.designTokens.breakpoints.desktop).matches,
hasTouch: 'ontouchstart' in window,
supportsClipboard: !!navigator.clipboard,
supportsIntersectionObserver: 'IntersectionObserver' in window,
prefersReducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches
};
// Observer les changements de breakpoints
Object.entries(this.config.designTokens.breakpoints).forEach(([name, query]) => {
const mediaQuery = window.matchMedia(query);
mediaQuery.addListener(() => {
this.state.environment[`is${name.charAt(0).toUpperCase() + name.slice(1)}`] = mediaQuery.matches;
this.emit('breakpoint:change', { name, matches: mediaQuery.matches });
});
});
}
/**
* Chargement des modules essentiels
*/
loadCoreModules() {
// Ces modules seront chargés automatiquement
const coreModules = [
'NotificationSystem',
'ModalManager',
'AnimationController',
'FilterManager'
];
// Pour l'instant, on enregistre des placeholders
// qui seront remplacés par les vrais modules
coreModules.forEach(moduleName => {
this.modules.set(moduleName.toLowerCase(), {
name: moduleName,
loaded: false,
placeholder: true
});
});
}
/**
* Initialisation des animations
*/
initializeAnimations() {
if (!this.config.modules.animations.enabled) return;
if (this.state.environment.prefersReducedMotion) return;
// Observer d'intersection pour animations au scroll
if (this.state.environment.supportsIntersectionObserver) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('in-view');
// Désactiver l'observation une fois animé
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
});
// Observer tous les éléments avec animation
this.utils.queryAll('.animate-on-scroll').forEach(el => {
observer.observe(el);
});
this.state.scrollObserver = observer;
}
}
/**
* Configuration des événements globaux
*/
setupEventListeners() {
// Gestion des clics globaux
document.addEventListener('click', this.handleGlobalClick.bind(this));
// Gestion des touches globales
document.addEventListener('keydown', this.handleGlobalKeydown.bind(this));
// Gestion des liens de confirmation
document.addEventListener('click', this.handleConfirmationLinks.bind(this));
// Gestion du resize window
window.addEventListener('resize',
this.utils.throttle(() => {
this.emit('window:resize', {
width: window.innerWidth,
height: window.innerHeight
});
}, 250)
);
}
/**
* Gestionnaire de clics globaux
*/
handleGlobalClick(event) {
// Fermeture des dropdowns
const openDropdowns = this.utils.queryAll('.dropdown-menu.show');
openDropdowns.forEach(dropdown => {
if (!dropdown.contains(event.target)) {
dropdown.classList.remove('show');
}
});
// Gestion des boutons de copie
const copyButton = event.target.closest('[data-copy]');
if (copyButton) {
event.preventDefault();
const textToCopy = copyButton.dataset.copy;
this.utils.copyToClipboard(textToCopy);
}
}
/**
* Gestionnaire de touches globales
*/
handleGlobalKeydown(event) {
// Fermeture des modales avec Escape
if (event.key === 'Escape') {
const openModal = this.utils.query('.modal:not(.hidden)');
if (openModal && this.components.has('modals')) {
this.components.get('modals').close(openModal.id);
}
}
}
/**
* Gestionnaire des liens de confirmation
*/
handleConfirmationLinks(event) {
const confirmLink = event.target.closest('[data-confirm]');
if (!confirmLink) return;
event.preventDefault();
const message = confirmLink.dataset.confirm;
const title = confirmLink.dataset.confirmTitle || 'Confirmation';
this.showConfirmation(message, title).then(confirmed => {
if (confirmed) {
if (confirmLink.tagName === 'A') {
window.location.href = confirmLink.href;
} else if (confirmLink.tagName === 'BUTTON') {
confirmLink.click();
}
}
});
}
/**
* Système de confirmation unifié
*/
showConfirmation(message, title = 'Confirmation') {
return new Promise((resolve) => {
// Si SweetAlert2 est disponible
if (typeof Swal !== 'undefined') {
Swal.fire({
title,
text: message,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Confirmer',
cancelButtonText: 'Annuler',
confirmButtonColor: this.config.designTokens.colors.danger,
cancelButtonColor: '#6b7280'
}).then((result) => {
resolve(result.isConfirmed);
});
} else {
// Fallback natif
resolve(confirm(`${title}\n\n${message}`));
}
});
}
/**
* API publique unifiée
*/
static getInstance() {
if (!NotytexCore.instance) {
NotytexCore.instance = new NotytexCore();
}
return NotytexCore.instance;
}
}
// Instance globale singleton
NotytexCore.instance = null;
// Export pour utilisation
window.NotytexCore = NotytexCore;
window.Notytex = NotytexCore.getInstance();
// Auto-initialisation
document.addEventListener('DOMContentLoaded', () => {
if (!NotytexCore.instance) {
window.Notytex = NotytexCore.getInstance();
}
});
export default NotytexCore;

View File

@@ -0,0 +1,469 @@
/**
* NOTYTEX UNIFIED - Point d'entrée unifié
* Charge et initialise tous les composants modernes
* Remplace l'ancien notytex.js
*/
import NotytexCore from './notytex-core.js';
import NotificationSystem from './components/notification-system.js';
import ModalManager from './components/modal-manager.js';
import FilterManager from './components/filter-manager.js';
/**
* Gestionnaire d'initialisation unifié
*/
class NotytexApp {
constructor() {
this.core = null;
this.initialized = false;
this.loadingPromise = null;
}
/**
* Initialisation asynchrone
*/
async init() {
if (this.initialized) return this.core;
if (this.loadingPromise) return this.loadingPromise;
this.loadingPromise = this.performInit();
return this.loadingPromise;
}
async performInit() {
try {
// 1. Initialiser le core
this.core = NotytexCore.getInstance();
// 2. Enregistrer les composants essentiels
await this.registerCoreComponents();
// 3. Initialiser les modules page-spécifiques
await this.initializePageModules();
// 4. Configuration des intégrations legacy
this.setupLegacyIntegration();
// 5. Optimisations performance
this.setupPerformanceOptimizations();
this.initialized = true;
// Émission de l'événement ready
this.core.emit('notytex:ready');
return this.core;
} catch (error) {
console.error('❌ Failed to initialize Notytex:', error);
throw error;
}
}
/**
* Enregistrement des composants essentiels
*/
async registerCoreComponents() {
const components = [
['notifications', NotificationSystem],
['modals', ModalManager],
['filters', FilterManager]
];
for (const [name, ComponentClass] of components) {
try {
this.core.registerModule(name, ComponentClass);
} catch (error) {
console.error(`✗ Failed to register ${name}:`, error);
}
}
}
/**
* Initialisation des modules spécifiques aux pages
*/
async initializePageModules() {
const currentPage = this.detectCurrentPage();
// Modules spécifiques par page
const pageModules = {
'assessments': ['ClassDashboard'],
'classes': ['ClassManagement'],
'grading': ['GradingInterface'],
'council': ['CouncilPreparation']
};
if (pageModules[currentPage]) {
for (const moduleName of pageModules[currentPage]) {
await this.loadPageModule(moduleName, currentPage);
}
}
// Modules universels (présents sur toutes les pages)
await this.initializeUniversalModules();
}
/**
* Détection de la page actuelle
*/
detectCurrentPage() {
const path = window.location.pathname;
const body = document.body;
// Détection par URL
if (path.includes('/assessments')) return 'assessments';
if (path.includes('/classes')) return 'classes';
if (path.includes('/grading')) return 'grading';
if (path.includes('/council')) return 'council';
if (path.includes('/students')) return 'students';
if (path.includes('/config')) return 'config';
// Détection par data attributes
if (body.hasAttribute('data-page')) {
return body.getAttribute('data-page');
}
// Détection par title ou meta
const title = document.title.toLowerCase();
if (title.includes('évaluation')) return 'assessments';
if (title.includes('classe')) return 'classes';
if (title.includes('note')) return 'grading';
return 'general';
}
/**
* Chargement des modules de page
*/
async loadPageModule(moduleName, pageName) {
try {
// Mapping des noms de modules aux fichiers
const moduleFiles = {
'ClassDashboard': './ClassDashboard.js',
'CouncilPreparation': './CouncilPreparation.js'
};
const filePath = moduleFiles[moduleName];
if (!filePath) {
console.warn(`Module file not found for ${moduleName}`);
return;
}
// Vérifier si le fichier existe (fallback)
try {
await this.core.loadModule(moduleName, filePath);
} catch (loadError) {
console.warn(`Module ${moduleName} not available, creating placeholder`);
this.createModulePlaceholder(moduleName);
}
} catch (error) {
console.error(`Failed to load page module ${moduleName}:`, error);
}
}
/**
* Initialisation des modules universels
*/
async initializeUniversalModules() {
// Animation au scroll pour tous les éléments
this.initializeScrollAnimations();
// Gestion des filtres sur toutes les pages avec filtres
this.initializePageFilters();
// Tooltips et popovers
this.initializeTooltips();
// Raccourcis clavier
this.initializeKeyboardShortcuts();
}
/**
* Animations au scroll
*/
initializeScrollAnimations() {
const elements = this.core.utils.queryAll('[data-animate]');
if (elements.length === 0) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const animationType = entry.target.dataset.animate || 'fade-in-up';
entry.target.classList.add(animationType);
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
});
elements.forEach(el => observer.observe(el));
}
/**
* Filtres de page
*/
initializePageFilters() {
const filterElements = this.core.utils.queryAll('[data-filter], .filter-btn');
if (filterElements.length === 0) return;
const filterManager = this.core.getModule('filters');
if (!filterManager) return;
// Configuration spécifique pour les pages de liste
filterManager.onApply((filters) => {
this.handleFilterApplication(filters);
});
}
/**
* Application des filtres avec feedback visuel
*/
handleFilterApplication(filters) {
// Afficher un indicateur de chargement
this.showFilterLoading();
// Mettre à jour le compteur si présent
this.updateFilterCounts(filters);
// Par défaut, recharger après un court délai pour l'UX
setTimeout(() => {
window.location.href = window.location.href;
}, 150);
}
/**
* Indicateur de chargement pour filtres
*/
showFilterLoading() {
const container = this.core.utils.query('#filtered-count');
if (container) {
container.innerHTML = '<span class="animate-pulse">...</span>';
}
}
/**
* Mise à jour des compteurs de filtres
*/
updateFilterCounts(filters) {
// Cette méthode peut être étendue pour faire des appels AJAX
// pour obtenir les compteurs en temps réel
}
/**
* Tooltips simples
*/
initializeTooltips() {
const tooltipElements = this.core.utils.queryAll('[title], [data-tooltip]');
tooltipElements.forEach(element => {
const tooltipText = element.getAttribute('data-tooltip') || element.getAttribute('title');
if (!tooltipText) return;
// Retirer le title natif pour éviter les doublons
element.removeAttribute('title');
let tooltip = null;
const showTooltip = (event) => {
tooltip = this.core.utils.createElement('div', {
className: 'absolute z-50 px-2 py-1 text-xs text-white bg-gray-900 rounded shadow-lg pointer-events-none',
style: 'top: -9999px; left: -9999px;'
});
tooltip.textContent = tooltipText;
document.body.appendChild(tooltip);
const rect = element.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const top = rect.top - tooltipRect.height - 5;
const left = rect.left + (rect.width - tooltipRect.width) / 2;
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
};
const hideTooltip = () => {
if (tooltip) {
tooltip.remove();
tooltip = null;
}
};
element.addEventListener('mouseenter', showTooltip);
element.addEventListener('mouseleave', hideTooltip);
element.addEventListener('focus', showTooltip);
element.addEventListener('blur', hideTooltip);
});
}
/**
* Raccourcis clavier
*/
initializeKeyboardShortcuts() {
document.addEventListener('keydown', (event) => {
// Ctrl/Cmd + K pour recherche rapide
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
const searchInput = this.core.utils.query('input[type="search"], [data-search-filter]');
if (searchInput) {
searchInput.focus();
}
}
// Échap pour fermer les overlays
if (event.key === 'Escape') {
// Fermer les dropdowns
this.core.utils.queryAll('.dropdown-menu.show').forEach(dropdown => {
dropdown.classList.remove('show');
});
}
});
}
/**
* Intégration avec le code legacy
*/
setupLegacyIntegration() {
// Maintenir la compatibilité avec l'ancien namespace
window.Notytex = {
// API de transition
utils: this.core.utils,
state: this.core.state,
emit: this.core.emit.bind(this.core),
on: this.core.on.bind(this.core),
// Composants
notifications: this.core.getModule('notifications'),
modals: this.core.getModule('modals'),
filters: this.core.getModule('filters'),
// Méthodes legacy
showToast: (message, type = 'info') => {
this.core.getModule('notifications')?.show({ message, type });
},
showModal: (modalId) => {
this.core.getModule('modals')?.open(modalId);
},
closeModal: (modalId) => {
this.core.getModule('modals')?.close(modalId);
}
};
// Maintenir les anciennes fonctions globales si utilisées
if (!window.showToast) {
window.showToast = window.Notytex.showToast;
}
}
/**
* Optimisations de performance
*/
setupPerformanceOptimizations() {
// Lazy loading des images
this.setupLazyLoading();
// Preload des liens importants
this.setupLinkPreloading();
// Monitoring des performances
this.setupPerformanceMonitoring();
}
/**
* Lazy loading des images
*/
setupLazyLoading() {
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
imageObserver.unobserve(img);
}
}
});
});
this.core.utils.queryAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
}
/**
* Preload des liens
*/
setupLinkPreloading() {
// Preload au hover pour les liens internes
const links = this.core.utils.queryAll('a[href^="/"], a[href^="./"]');
const preloadedUrls = new Set();
links.forEach(link => {
link.addEventListener('mouseenter', () => {
const href = link.href;
if (!preloadedUrls.has(href)) {
const preloadLink = document.createElement('link');
preloadLink.rel = 'prefetch';
preloadLink.href = href;
document.head.appendChild(preloadLink);
preloadedUrls.add(href);
}
}, { once: true });
});
}
/**
* Monitoring des performances
*/
setupPerformanceMonitoring() {
if ('PerformanceObserver' in window) {
// Observer les métriques de performance
const perfObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.entryType === 'measure') {
}
});
});
perfObserver.observe({ entryTypes: ['measure', 'navigation'] });
}
}
/**
* Créer un placeholder pour un module manquant
*/
createModulePlaceholder(moduleName) {
const placeholder = {
name: moduleName,
placeholder: true,
init: () => {},
destroy: () => {}
};
this.core.modules.set(moduleName.toLowerCase(), placeholder);
}
}
// Instance globale
const notytexApp = new NotytexApp();
// Auto-initialisation
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => notytexApp.init());
} else {
notytexApp.init();
}
// Export pour utilisation
export default notytexApp;

View File

@@ -22,7 +22,6 @@ class SimpleFilters {
// Initialiser l'état visuel
this.updateUI();
console.log('SimpleFilters initialized');
}
attachEvents() {

View File

@@ -119,8 +119,15 @@
</div>
</footer>
<!-- JavaScript Core -->
<script src="{{ url_for('static', filename='js/notytex.js') }}"></script>
<!-- JavaScript Unifié Moderne -->
<script type="module" src="{{ url_for('static', filename='js/notytex-unified.js') }}"></script>
<!-- Fallback pour anciens navigateurs -->
<script nomodule>
console.warn('Navigateur non supporté - Veuillez utiliser un navigateur moderne');
// Fallback vers l'ancien système si nécessaire
document.head.insertAdjacentHTML('beforeend', '<script src="{{ url_for("static", filename="js/notytex.js") }}"><\/script>');
</script>
<!-- JavaScript spécifique aux pages -->
{% block scripts %}{% endblock %}

View File

@@ -447,6 +447,10 @@
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Injection des données statistiques dans le JavaScript -->
<script>
window.initialStatsData = {{ stats_data|tojson|safe }};
</script>
<script src="{{ url_for('static', filename='js/ClassDashboard.js') }}"></script>
<style>
/* Fix pour éviter le clipping des hover effects sur cette page */

View File

@@ -1,156 +1,134 @@
{% extends "base.html" %}
{% from 'components/common/macros.html' import hero_section %}
{% from 'components/common/macros.html' import page_layout, content_section, empty_state %}
{% from 'components/class/class_card.html' import class_card %}
{% block title %}Classes - Gestion Scolaire{% endblock %}
{% block content %}
<div class="space-y-8">
{# Hero Section avec composant réutilisable #}
{% set meta_info = [
{
'icon': '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
'text': classes|length ~ ' classes actives'
},
{
'icon': '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>',
'text': 'Année scolaire 2024-2025'
}
] %}
{% set primary_action = {
'url': url_for('classes.new'),
{% call page_layout(
"Mes Classes 🏫",
"Gérez et organisez toutes vos classes",
[],
[{
'url': url_for('classes.new') if url_for else '#',
'text': 'Nouvelle classe',
'icon': '<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>'
} %}
{{ hero_section(
title="Mes Classes 🏫",
subtitle="Gérez et organisez toutes vos classes",
meta_info=meta_info,
primary_action=primary_action,
gradient_class="from-blue-600 to-green-600"
) }}
'icon': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>',
'variant': 'primary'
}]
) %}
{% if classes %}
<!-- Grille de classes avec composants -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for class in classes %}
{{ class_card(class) }}
{% endfor %}
</div>
{% else %}
<!-- État vide moderne -->
<div class="bg-white rounded-xl shadow-lg p-12 text-center">
<div class="w-24 h-24 bg-gradient-to-br from-blue-100 to-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-12 h-12 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% call content_section("Classes disponibles", classes|length ~ " classes actives") %}
{% if classes %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for class in classes %}
{{ class_card(class) }}
{% endfor %}
</div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Aucune classe créée</h3>
<p class="text-gray-600 mb-6 max-w-md mx-auto">
Commencez votre gestion scolaire en créant votre première classe.
Vous pourrez ensuite y ajouter des élèves et créer des évaluations.
</p>
<div class="space-y-4">
<a href="{{ url_for('classes.new') }}" class="inline-flex items-center bg-gradient-to-r from-blue-500 to-green-500 hover:from-blue-600 hover:to-green-600 text-white px-6 py-3 rounded-xl transition-all duration-300 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
Créer ma première classe
</a>
<div class="text-sm text-gray-500">
<p>💡 <strong>Astuce :</strong> Une classe peut contenir plusieurs élèves et être utilisée pour de nombreuses évaluations</p>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
{{ empty_state(
"Aucune classe créée",
"Commencez votre gestion scolaire en créant votre première classe. Vous pourrez ensuite y ajouter des élèves et créer des évaluations.",
'<svg class="w-12 h-12 text-blue-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
{
'url': url_for('classes.new') if url_for else '#',
'text': 'Créer ma première classe',
'icon': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>',
'variant': 'primary'
},
{
'text': 'Une classe peut contenir plusieurs élèves et être utilisée pour de nombreuses évaluations'
}
) }}
{% endif %}
{% endcall %}
<!-- Modal de confirmation de suppression -->
<div id="deleteModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full">
<div class="p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="w-12 h-12 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<!-- Modal de confirmation de suppression -->
<div id="deleteModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full">
<div class="p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="w-12 h-12 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-4">
<h3 class="text-lg font-medium text-gray-900">Supprimer la classe</h3>
<p class="mt-1 text-sm text-gray-600" id="deleteMessage">
Êtes-vous sûr de vouloir supprimer cette classe ?
</p>
</div>
</div>
<div class="ml-4">
<h3 class="text-lg font-medium text-gray-900">Supprimer la classe</h3>
<p class="mt-1 text-sm text-gray-600" id="deleteMessage">
Êtes-vous sûr de vouloir supprimer cette classe ?
</p>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button onclick="closeDeleteModal()" class="bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium transition-colors">
Annuler
</button>
<form id="deleteForm" method="POST" class="inline">
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
Supprimer définitivement
<div class="mt-6 flex justify-end space-x-3">
<button onclick="closeDeleteModal()" class="bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium transition-colors">
Annuler
</button>
</form>
<form id="deleteForm" method="POST" class="inline">
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
Supprimer définitivement
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
function confirmDeleteClass(classId, className, studentsCount, assessmentsCount) {
const modal = document.getElementById('deleteModal');
const message = document.getElementById('deleteMessage');
const form = document.getElementById('deleteForm');
// Configuration du message selon le contenu de la classe
if (studentsCount > 0 || assessmentsCount > 0) {
message.innerHTML = `
<strong>Impossible de supprimer la classe "${className}".</strong><br>
Elle contient <strong>${studentsCount} élève(s)</strong> et <strong>${assessmentsCount} évaluation(s)</strong>.<br>
Supprimez d'abord ces éléments pour pouvoir supprimer la classe.
`;
// Masquer le bouton de suppression et changer le titre
document.querySelector('#deleteForm button').style.display = 'none';
document.querySelector('#deleteModal h3').textContent = 'Suppression impossible';
} else {
message.innerHTML = `
Êtes-vous sûr de vouloir supprimer la classe <strong>"${className}"</strong> ?<br>
<span class="text-red-600">Cette action est irréversible.</span>
`;
// Réafficher le bouton de suppression et restaurer le titre
document.querySelector('#deleteForm button').style.display = 'inline';
document.querySelector('#deleteModal h3').textContent = 'Supprimer la classe';
}
// Configuration de l'action du formulaire
form.action = `/classes/${classId}/delete`;
// Afficher le modal
modal.classList.remove('hidden');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
}
// Fermeture au clic sur le fond
document.getElementById('deleteModal').addEventListener('click', function(e) {
if (e.target === this) {
closeDeleteModal();
}
});
// Fermeture avec la touche Échap
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeDeleteModal();
}
});
</script>
{% endcall %}
</div>
<script>
function confirmDeleteClass(classId, className, studentsCount, assessmentsCount) {
const modal = document.getElementById('deleteModal');
const message = document.getElementById('deleteMessage');
const form = document.getElementById('deleteForm');
// Configuration du message selon le contenu de la classe
if (studentsCount > 0 || assessmentsCount > 0) {
message.innerHTML = `
<strong>Impossible de supprimer la classe "${className}".</strong><br>
Elle contient <strong>${studentsCount} élève(s)</strong> et <strong>${assessmentsCount} évaluation(s)</strong>.<br>
Supprimez d'abord ces éléments pour pouvoir supprimer la classe.
`;
// Masquer le bouton de suppression et changer le titre
document.querySelector('#deleteForm button').style.display = 'none';
document.querySelector('#deleteModal h3').textContent = 'Suppression impossible';
} else {
message.innerHTML = `
Êtes-vous sûr de vouloir supprimer la classe <strong>"${className}"</strong> ?<br>
<span class="text-red-600">Cette action est irréversible.</span>
`;
// Réafficher le bouton de suppression et restaurer le titre
document.querySelector('#deleteForm button').style.display = 'inline';
document.querySelector('#deleteModal h3').textContent = 'Supprimer la classe';
}
// Configuration de l'action du formulaire
form.action = `/classes/${classId}/delete`;
// Afficher le modal
modal.classList.remove('hidden');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
}
// Fermeture au clic sur le fond
document.getElementById('deleteModal').addEventListener('click', function(e) {
if (e.target === this) {
closeDeleteModal();
}
});
// Fermeture avec la touche Échap
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeDeleteModal();
}
});
</script>
{% endblock %}

View File

@@ -212,6 +212,125 @@
</div>
{% endmacro %}
{# Macro pour page layout standardisé avec hero section #}
{% macro page_layout(title, subtitle=None, breadcrumbs=[], actions=[]) %}
{% if breadcrumbs %}
<nav class="flex mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm">
{% for crumb in breadcrumbs %}
<li class="flex items-center">
{% if not loop.last %}
<a href="{{ crumb.url }}" class="text-gray-500 hover:text-gray-700 transition-colors">
{{ crumb.text }}
</a>
<svg class="w-4 h-4 mx-2 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<span class="text-gray-900 font-medium">{{ crumb.text }}</span>
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
{% endif %}
<!-- Hero Section avec gradient -->
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl p-8 shadow-lg mb-8">
<div class="flex items-center justify-between">
<div class="flex-1">
<h1 class="text-4xl font-bold mb-2">{{ title }}</h1>
{% if subtitle %}
<p class="text-xl opacity-90 mb-1">{{ subtitle }}</p>
{% endif %}
<p class="text-sm opacity-75">{{ moment().format('dddd D MMMM YYYY') if moment else 'Lundi 16 août 2025' }}</p>
</div>
<div class="hidden md:block">
{% if actions %}
<div class="flex items-center space-x-3">
{% for action in actions %}
<a href="{{ action.url }}"
class="bg-white/20 hover:bg-white/30 text-white px-6 py-3 rounded-xl transition-all duration-300 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105 flex items-center">
{% if action.icon %}{{ action.icon|safe }}{% endif %}
{{ action.text }}
</a>
{% endfor %}
</div>
{% else %}
<div class="w-24 h-24 bg-white/20 rounded-full flex items-center justify-center">
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
{% endif %}
</div>
</div>
</div>
{{ caller() }}
{% endmacro %}
{# Macro pour section de contenu standardisée #}
{% macro content_section(title=None, description=None, actions=[], classes="") %}
<div class="bg-white rounded-xl shadow-lg overflow-hidden {{ classes }}">
{% if title %}
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">{{ title }}</h2>
{% if description %}
<p class="text-sm text-gray-600 mt-1">{{ description }}</p>
{% endif %}
</div>
{% if actions %}
<div class="flex items-center space-x-2">
{% for action in actions %}
<a href="{{ action.url }}"
class="text-blue-600 hover:text-blue-800 text-sm font-medium">
{% if action.icon %}{{ action.icon|safe }}{% endif %}
{{ action.text }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
<div class="p-6">
{{ caller() }}
</div>
</div>
{% endmacro %}
{# Macro pour état vide unifié #}
{% macro empty_state(title, description, icon, action=None, secondary_action=None) %}
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
<div class="p-6 text-center py-12">
<div class="w-24 h-24 bg-gradient-to-br from-gray-100 to-gray-200 rounded-full flex items-center justify-center mx-auto mb-6">
{{ icon|safe }}
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">{{ title }}</h3>
<p class="text-base text-gray-600 mb-6 max-w-md mx-auto">{{ description }}</p>
<div class="space-y-4">
{% if action %}
<a href="{{ action.url }}"
class="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold">
{% if action.icon %}{{ action.icon|safe }}{% endif %}
{{ action.text }}
</a>
{% endif %}
{% if secondary_action %}
<div class="text-sm text-gray-600">
<p><strong>💡 Astuce :</strong> {{ secondary_action.text }}</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endmacro %}
{# Macro pour filtres simplifiés et discrets #}
{% macro simple_filter_section(filters_config, current_values={}, total_items=0, filtered_items=0) %}
<div class="bg-gray-50 rounded-lg p-4 mb-6 border border-gray-200">
@@ -292,4 +411,130 @@
</div>
</div>
</div>
{% endmacro %}
{# Macro pour tableau standardisé #}
{% macro data_table(headers, rows, actions_column=False, zebra=True) %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
{% for header in headers %}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ header }}
</th>
{% endfor %}
{% if actions_column %}
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
{% endif %}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for row in rows %}
<tr class="{% if zebra and loop.index % 2 == 0 %}bg-gray-50{% endif %} hover:bg-blue-50 transition-colors">
{% for cell in row.cells %}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ cell|safe }}
</td>
{% endfor %}
{% if actions_column and row.actions %}
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex justify-end space-x-2">
{% for action in row.actions %}
<a href="{{ action.url }}"
class="text-{{ action.color|default('blue') }}-600 hover:text-{{ action.color|default('blue') }}-900 transition-colors">
{{ action.text }}
</a>
{% endfor %}
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endmacro %}
{# Macro pour liste d'éléments uniformisée #}
{% macro item_list(items, show_meta=True, show_actions=True) %}
<div class="space-tight">
{% for item in items %}
<div class="flex items-center justify-between p-4 bg-gray-50 hover:bg-blue-50 rounded-lg transition-colors group">
<a href="{{ item.url }}" class="flex items-center space-x-4 flex-1 min-w-0">
{% if item.avatar %}
<div class="w-10 h-10 {{ item.avatar.classes|default('bg-gradient-to-br from-blue-500 to-purple-600') }} rounded-full flex items-center justify-center text-white font-bold text-sm">
{{ item.avatar.content|safe }}
</div>
{% endif %}
<div class="min-w-0 flex-1">
<h3 class="text-sm font-semibold text-gray-900 group-hover:text-blue-700 transition-colors truncate">
{{ item.title }}
</h3>
{% if show_meta and item.meta %}
<div class="flex items-center text-xs text-gray-500 space-x-2 mt-1">
{% for meta in item.meta %}
<span class="flex items-center">
{% if meta.icon %}{{ meta.icon|safe }}{% endif %}
{{ meta.text }}
</span>
{% if not loop.last %}<span></span>{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</a>
{% if show_actions and item.indicators %}
<div class="flex items-center space-x-3 flex-shrink-0">
{% for indicator in item.indicators %}
{{ indicator|safe }}
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endmacro %}
{# Macro pour notifications/alertes uniformisées #}
{% macro alert(type, title, message, dismissible=True, icon=True) %}
{% set alert_classes = {
'info': 'bg-blue-50 border-blue-200 text-blue-800',
'success': 'bg-green-50 border-green-200 text-green-800',
'warning': 'bg-orange-50 border-orange-200 text-orange-800',
'error': 'bg-red-50 border-red-200 text-red-800'
} %}
{% set icons = {
'info': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>',
'success': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>',
'warning': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>',
'error': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>'
} %}
<div class="border rounded-lg p-4 {{ alert_classes[type] }} {% if dismissible %}relative{% endif %}" role="alert">
<div class="flex items-start">
{% if icon %}
<div class="flex-shrink-0 mr-3 mt-0.5">
{{ icons[type]|safe }}
</div>
{% endif %}
<div class="flex-1">
<h3 class="text-sm font-medium">{{ title }}</h3>
<p class="text-sm mt-1">{{ message }}</p>
</div>
{% if dismissible %}
<button type="button" class="flex-shrink-0 ml-3 hover:opacity-75 transition-opacity"
onclick="this.parentElement.remove()">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
{% endif %}
</div>
</div>
{% endmacro %}

View File

@@ -1,58 +1,19 @@
{% extends "base.html" %}
{% from 'components/common/macros.html' import page_layout, content_section, alert, stat_card, action_card %}
{% block title %}Accueil - Gestion Scolaire{% endblock %}
{% block content %}
<div class="space-y-8">
<!-- Notifications contextuelles -->
{% call page_layout("Bonjour ! 👋", "Prêt à gérer vos évaluations aujourd'hui ?") %}
<!-- Notifications contextuelles avec composant unifié -->
{% if total_assessments == 0 %}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-400 mt-0.5 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div>
<h3 class="text-sm font-medium text-blue-800">Bienvenue dans Notytex ! 🎉</h3>
<p class="text-sm text-blue-700 mt-1">
Commencez par créer votre première évaluation.
<a href="{{ url_for('assessments.new') }}" class="underline hover:no-underline font-medium">Créer maintenant</a>
</p>
</div>
</div>
</div>
{{ alert('info', 'Bienvenue dans Notytex ! 🎉', 'Commencez par créer votre première évaluation. <a href="' + url_for('assessments.new') + '" class="underline hover:no-underline font-medium">Créer maintenant</a>', false) }}
{% elif recent_assessments and recent_assessments|length >= 3 %}
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-start">
<svg class="w-5 h-5 text-green-400 mt-0.5 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<div>
<h3 class="text-sm font-medium text-green-800">Système bien utilisé ! 📈</h3>
<p class="text-sm text-green-700 mt-1">
Vous avez {{ total_assessments }} évaluations créées. Pensez à exporter vos données régulièrement.
</p>
</div>
</div>
</div>
{{ alert('success', 'Système bien utilisé ! 📈', 'Vous avez ' + total_assessments|string + ' évaluations créées. Pensez à exporter vos données régulièrement.', false) }}
{% endif %}
<!-- Hero Section avec accueil personnalisé -->
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl p-8 shadow-lg">
<div class="flex items-center justify-between">
<div>
<h1 class="text-4xl font-bold mb-2">Bonjour ! 👋</h1>
<p class="text-xl opacity-90 mb-1">Prêt à gérer vos évaluations aujourd'hui ?</p>
<p class="text-sm opacity-75">{{ moment().format('dddd D MMMM YYYY') if moment else 'Lundi 4 août 2025' }}</p>
</div>
<div class="hidden md:block">
<div class="w-24 h-24 bg-white/20 rounded-full flex items-center justify-center">
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Hero Section déjà gérée par page_layout -->
<!-- Statistiques enrichies et cliquables -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -341,4 +302,6 @@
</div>
</div>
</div>
{% endcall %}
</div>
{% endblock %}