From 3fd49d135140b696ed6b04cfbdbfad0b86b5b516 Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Sun, 17 Aug 2025 05:48:27 +0200 Subject: [PATCH] refact: unify js and css --- docs/frontend/README.md | 118 ++++- routes/classes.py | 21 +- static/css/design-system.css | 179 ++++++- static/js/ClassDashboard.js | 104 +++- static/js/CouncilPreparation.js | 24 - static/js/components/filter-manager.js | 515 ++++++++++++++++++++ static/js/components/modal-manager.js | 382 +++++++++++++++ static/js/components/notification-system.js | 297 +++++++++++ static/js/notytex-core.js | 496 +++++++++++++++++++ static/js/notytex-unified.js | 469 ++++++++++++++++++ static/js/simple-filters.js | 1 - templates/base.html | 11 +- templates/class_dashboard.html | 4 + templates/classes.html | 252 +++++----- templates/components/common/macros.html | 245 ++++++++++ templates/index.html | 53 +- 16 files changed, 2924 insertions(+), 247 deletions(-) create mode 100644 static/js/components/filter-manager.js create mode 100644 static/js/components/modal-manager.js create mode 100644 static/js/components/notification-system.js create mode 100644 static/js/notytex-core.js create mode 100644 static/js/notytex-unified.js diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 0840856..d92b146 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -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 --- diff --git a/routes/classes.py b/routes/classes.py index ec8fb53..c88fa2a 100644 --- a/routes/classes.py +++ b/routes/classes.py @@ -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('//stats') @handle_db_errors diff --git a/static/css/design-system.css b/static/css/design-system.css index c77cb8e..46504ee 100644 --- a/static/css/design-system.css +++ b/static/css/design-system.css @@ -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 { diff --git a/static/js/ClassDashboard.js b/static/js/ClassDashboard.js index ae9cd3b..8087cce 100644 --- a/static/js/ClassDashboard.js +++ b/static/js/ClassDashboard.js @@ -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 = '
Aucun domaine évalué
'; return; } - container.innerHTML = domainsData.map(domain => ` + container.innerHTML = domains.map(domain => `
@@ -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 = '
Aucune compétence évaluée
'; return; } - container.innerHTML = competencesData.map(competence => ` + container.innerHTML = competences.map(competence => `
@@ -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'); } /** diff --git a/static/js/CouncilPreparation.js b/static/js/CouncilPreparation.js index 9413331..00e75c6 100644 --- a/static/js/CouncilPreparation.js +++ b/static/js/CouncilPreparation.js @@ -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) { diff --git a/static/js/components/filter-manager.js b/static/js/components/filter-manager.js new file mode 100644 index 0000000..e8594ca --- /dev/null +++ b/static/js/components/filter-manager.js @@ -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; \ No newline at end of file diff --git a/static/js/components/modal-manager.js b/static/js/components/modal-manager.js new file mode 100644 index 0000000..074c4a4 --- /dev/null +++ b/static/js/components/modal-manager.js @@ -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 = ` + + + + `; + + 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; \ No newline at end of file diff --git a/static/js/components/notification-system.js b/static/js/components/notification-system.js new file mode 100644 index 0000000..61074c5 --- /dev/null +++ b/static/js/components/notification-system.js @@ -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 = ` + + + + `; + + 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: ` + + ` + }, + 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: ` + + ` + }, + 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: ` + + ` + }, + 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: ` + + ` + } + }; + + return configs[type] || configs.info; + } +} + +export default NotificationSystem; \ No newline at end of file diff --git a/static/js/notytex-core.js b/static/js/notytex-core.js new file mode 100644 index 0000000..0f8ae1d --- /dev/null +++ b/static/js/notytex-core.js @@ -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; \ No newline at end of file diff --git a/static/js/notytex-unified.js b/static/js/notytex-unified.js new file mode 100644 index 0000000..e93d9a8 --- /dev/null +++ b/static/js/notytex-unified.js @@ -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 = '...'; + } + } + + /** + * 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; \ No newline at end of file diff --git a/static/js/simple-filters.js b/static/js/simple-filters.js index 5ddb286..59e1aa5 100644 --- a/static/js/simple-filters.js +++ b/static/js/simple-filters.js @@ -22,7 +22,6 @@ class SimpleFilters { // Initialiser l'état visuel this.updateUI(); - console.log('SimpleFilters initialized'); } attachEvents() { diff --git a/templates/base.html b/templates/base.html index ab4b514..8f28580 100644 --- a/templates/base.html +++ b/templates/base.html @@ -119,8 +119,15 @@
- - + + + + + {% block scripts %}{% endblock %} diff --git a/templates/class_dashboard.html b/templates/class_dashboard.html index af10ff4..93b7f7a 100644 --- a/templates/class_dashboard.html +++ b/templates/class_dashboard.html @@ -447,6 +447,10 @@ {% block head %} + +