DEV Community

Cover image for Forget bundlers: Here is a Full-Stack Todo App in a Single HTML File
ArtyProg
ArtyProg

Posted on

Forget bundlers: Here is a Full-Stack Todo App in a Single HTML File

Every day, we're told we need more. More libraries, more frameworks, more complex build tools just to get started. We're sold a mountain of dependencies—Webpack, Babel, Vite, Node.js—before we've even written a single line of our app's logic.

But what if that's a story we don't have to buy into?

What if I told you that your favorite code editor and a single HTML file are all you need to build a truly complex, high-performance application? I'm not talking about a simple counter. I'm talking about this fully-featured Todo application with:

  • authentication,
  • client-side routing,
  • granular,
  • user-specific persistent storage.

This entire application, all 1,500+ lines of it, lives in one HTML file. There is no npm install, no package.json, no bundler. It just runs.

This is the story of how a different kind of framework—JurisJS—makes this possible by getting out of your way and letting you build.

You can test the demo here : TodoApp

<!DOCTYPE html> <html lang="fr"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Juris Todo App - Routeur, Auth & Stockage</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } :root { --orange: #ff6b35; --orange-light: #ff8c5a; --orange-dark: #e55a2b; --orange-pale: #fff4f1; --primary: #ff6b35; --primary-dark: #e55a2b; --primary-light: #ff8c5a; --success: #10b981; --warning: #f59e0b; --danger: #ef4444; --gray-50: #fafafa; --gray-100: #f5f5f5; --gray-200: #e5e5e5; --gray-300: #d4d4d4; --gray-400: #a3a3a3; --gray-500: #6b7280; --gray-600: #525252; --gray-700: #404040; --gray-800: #262626; --gray-900: #171717; --white: #ffffff; --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); --border-radius: 0.5rem; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Inter', sans-serif; line-height: 1.6; color: var(--gray-900); background: var(--white); font-size: 16px; } /* Layout Components */ .app-container { min-height: 100vh; display: flex; flex-direction: column; } .header { background: var(--white); border-bottom: 1px solid var(--gray-200); padding: 1rem 0; position: sticky; top: 0; z-index: 100; } .header-content { max-width: 1200px; margin: 0 auto; padding: 0 1rem; display: flex; justify-content: space-between; align-items: center; } .logo { font-size: 1.5rem; font-weight: 700; color: var(--orange); text-decoration: none; } .nav { display: flex; gap: 1rem; align-items: center; } .nav-link { color: var(--gray-600); text-decoration: none; padding: 0.5rem 1rem; border-radius: var(--border-radius); } .nav-link:hover { background: var(--gray-100); color: var(--orange); } .nav-link.active { background: var(--orange); color: var(--white); } .main-content { flex: 1; max-width: 1200px; margin: 0 auto; padding: 2rem 1rem; width: 100%; } /* Form Components */ .form-container { max-width: 400px; margin: 2rem auto; background: var(--white); padding: 2rem; border-radius: var(--border-radius); box-shadow: var(--shadow-lg); } .form-title { font-size: 1.5rem; font-weight: 600; text-align: center; margin-bottom: 2rem; color: var(--gray-900); } .form-group { margin-bottom: 1rem; } .form-label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--gray-700); } .form-input { width: 100%; padding: 0.75rem; border: 1px solid var(--gray-300); border-radius: var(--border-radius); font-size: 1rem; } .form-input:focus { outline: none; border-color: var(--orange); box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1); } .form-input.error { border-color: var(--danger); } .form-error { color: var(--danger); font-size: 0.875rem; margin-top: 0.25rem; } .btn { padding: 0.75rem 1.5rem; border: none; border-radius: var(--border-radius); font-size: 1rem; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-block; text-align: center; touch-action: manipulation; user-select: none; -webkit-tap-highlight-color: transparent; } .btn-primary { background: var(--orange); color: var(--white); } .btn-primary:hover { background: var(--orange-dark); } .btn-secondary { background: var(--gray-200); color: var(--gray-800); } .btn-secondary:hover { background: var(--gray-300); } .btn-danger { background: var(--danger); color: var(--white); } .btn-success { background: var(--success); color: var(--white); } .btn-full { width: 100%; } /* Todo Components */ .todo-dashboard { display: grid; gap: 2rem; } .todo-lists { background: var(--white); border-radius: var(--border-radius); padding: 1.5rem; box-shadow: var(--shadow); } .section-title { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; color: var(--gray-900); } .list-item { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border: 1px solid var(--gray-200); border-radius: var(--border-radius); margin-bottom: 0.5rem; cursor: pointer; } .list-item:hover { border-color: var(--orange); background: var(--gray-50); } .list-item.active { border-color: var(--orange); background: var(--orange-pale); } .list-info { flex: 1; } .list-name { font-weight: 500; color: var(--gray-900); } .list-count { color: var(--gray-500); font-size: 0.875rem; } .list-actions { display: flex; gap: 0.5rem; } .btn-small { padding: 0.25rem 0.5rem; font-size: 0.75rem; } .todo-form { background: var(--white); border-radius: var(--border-radius); padding: 1.5rem; box-shadow: var(--shadow); margin-bottom: 2rem; } .todo-input { display: flex; gap: 0.5rem; } .todo-input input { flex: 1; } .todo-list { background: var(--white); border-radius: var(--border-radius); padding: 1.5rem; box-shadow: var(--shadow); } .todo-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 0; border-bottom: 1px solid var(--gray-100); } .todo-item:last-child { border-bottom: none; } .todo-checkbox { width: 1.25rem; height: 1.25rem; cursor: pointer; } .todo-text { flex: 1; color: var(--gray-700); } .todo-text.completed { text-decoration: line-through; color: var(--gray-400); } .todo-actions { display: flex; gap: 0.5rem; } .filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; } .filter-btn { padding: 0.5rem 1rem; border: 1px solid var(--gray-300); background: var(--white); color: var(--gray-600); border-radius: var(--border-radius); cursor: pointer; } .filter-btn.active { background: var(--orange); color: var(--white); border-color: var(--orange); } /* Responsive Design */ @media (min-width: 768px) { .todo-dashboard { grid-template-columns: 450px 1fr; } .main-content { padding: 3rem 2rem; } } /* Loading & Empty States */ .loading { text-align: center; padding: 2rem; color: var(--gray-500); } .empty-state { text-align: center; padding: 3rem; color: var(--gray-500); } .empty-state h3 { margin-bottom: 1rem; color: var(--gray-700); } /* Utility Classes */ .text-center { text-align: center; } .text-sm { font-size: 0.875rem; } .text-danger { color: var(--danger); } .text-success { color: var(--success); } .mt-1 { margin-top: 0.25rem; } .mt-2 { margin-top: 0.5rem; } .mb-2 { margin-bottom: 0.5rem; } .hidden { display: none; } /* Toast notifications */ .toast { position: fixed; top: 1rem; right: 1rem; background: var(--white); border: 1px solid var(--gray-200); border-radius: var(--border-radius); padding: 1rem; box-shadow: var(--shadow-lg); z-index: 1000; } .toast.success { border-left: 4px solid var(--success); } .toast.error { border-left: 4px solid var(--danger); } </style> </head> <body> <div id="app"></div> <script src="https://unpkg.com/juris"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script> <script> // NOTE: The internal logic and console logs of the managers are kept in English // for easier debugging by developers, as is common practice. Only user-facing // text in UI components and alerts is translated. const StatePersistenceManager = (props, context) => { const { getState, setState, subscribe } = context; // Enhanced configuration with timing controls const config = { domains: props.domains || [], excludeDomains: props.excludeDomains || ['temp', 'cache', 'session', 'geolocation', 'persistence'], keyPrefix: props.keyPrefix || 'app_state_', debounceMs: props.debounceMs || 1000, debug: props.debug || false, autoDetectNewDomains: props.autoDetectNewDomains || true, watchIntervalMs: props.watchIntervalMs || 5000, // Timing controls for layout shift prevention aggressiveRestore: props.aggressiveRestore !== false, // Default: true restoreDelay: props.restoreDelay || 0, priorityDomains: props.priorityDomains || [], // Restore these first earlyRestoreTimeout: props.earlyRestoreTimeout || 50, // Granular domain timing domainRestoreConfig: props.domainRestoreConfig || {}, // Save timing controls immediateSave: props.immediateSave || [], // Save changes immediately criticalSave: props.criticalSave || [], // Save with shorter debounce criticalDebounceMs: props.criticalDebounceMs || 200, // User-specific domains (requires authentication) userSpecificDomains: props.userSpecificDomains || [], userIdPath: props.userIdPath || 'auth.user.id', // Path to user ID in state requireAuth: props.requireAuth || false // Whether user-specific domains require auth }; // Internal state let saveTimers = new Map(); let isRestoring = false; let domainSubscriptions = new Map(); let domainWatcher = null; let restoreQueue = []; let isProcessingRestoreQueue = false; return { hooks: { onRegister: () => { console.log('💾 StatePersistenceManager initializing with timing controls...'); // Initialize persistence state - DIRECT INJECTION if (context.juris && context.juris.stateManager && context.juris.stateManager.state) { context.juris.stateManager.state.persistence = { isEnabled: true, lastSave: null, lastRestore: null, errors: [], stats: { domainsTracked: 0, totalSaves: 0, totalRestores: 0, priorityRestores: 0, delayedRestores: 0 } }; } else { // Fallback if direct access not available setState('persistence.isEnabled', true); setState('persistence.lastSave', null); setState('persistence.lastRestore', null); setState('persistence.errors', []); setState('persistence.stats', { domainsTracked: 0, totalSaves: 0, totalRestores: 0, priorityRestores: 0, delayedRestores: 0 }); } // Restore state with timing controls if (config.aggressiveRestore) { restoreAllDomainsWithTiming(); } else { setTimeout(() => restoreAllDomainsWithTiming(), config.restoreDelay); } // Setup monitoring after early restore setTimeout(() => { setupDomainMonitoring(); if (config.autoDetectNewDomains) { setupDomainWatcher(); } }, Math.max(config.earlyRestoreTimeout, 100)); // Setup cross-tab sync window.addEventListener('storage', handleStorageEvent); window.addEventListener('beforeunload', saveAllTrackedDomains); console.log('✅ StatePersistenceManager ready with timing controls'); }, onUnregister: () => { console.log('💾 StatePersistenceManager cleanup'); // Save all before cleanup saveAllTrackedDomains(); // Clear all timers saveTimers.forEach(timer => clearTimeout(timer)); saveTimers.clear(); if (domainWatcher) { clearInterval(domainWatcher); domainWatcher = null; } // Unsubscribe from all domains domainSubscriptions.forEach(unsubscribe => unsubscribe()); domainSubscriptions.clear(); // Remove storage listener window.removeEventListener('storage', handleStorageEvent); window.removeEventListener('beforeunload', saveAllTrackedDomains); console.log('✅ StatePersistenceManager cleaned up'); } }, api: { saveDomain: (domain, immediate = false) => saveDomain(domain, immediate), saveAllDomains: () => saveAllTrackedDomains(), restoreDomain: (domain) => restoreDomain(domain), restoreAllDomains: () => restoreAllDomainsWithTiming(), addDomain: (domain) => addDomainTracking(domain), removeDomain: (domain) => removeDomainTracking(domain), clearDomain: (domain) => clearDomainStorage(domain), clearAllStorage: () => clearAllStorage(), getStorageStats: () => getStorageStats(), getTrackedDomains: () => Array.from(domainSubscriptions.keys()), exportState: () => exportState(), importState: (data) => importState(data), refreshDomainDetection: () => setupDomainMonitoring(), forceImmediateSave: (domain) => saveDomain(domain, true), restorePriorityDomains: () => restorePriorityDomains(), getRestoreQueue: () => [...restoreQueue], updateTimingConfig: (newConfig) => Object.assign(config, newConfig), getConfig: () => ({ ...config }) // Get current configuration } }; // Enhanced restore with timing controls function restoreAllDomainsWithTiming() { log('📂 Starting timed restore sequence...'); // Get all stored domains by scanning localStorage const storedDomains = []; const keyPrefix = config.keyPrefix; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(keyPrefix)) { // Extract domain from key let remainder = key.substring(keyPrefix.length); // Split by underscore and take first part as domain let domain = remainder.split('_')[0]; // Only include if it's a valid domain and not already included if (domain && !storedDomains.includes(domain)) { storedDomains.push(domain); } } } log(`📂 Found stored domains: [${storedDomains.join(', ')}]`); if (storedDomains.length === 0) { log('📂 No stored domains found'); // DIRECT INJECTION for lastRestore if (context.juris && context.juris.stateManager && context.juris.stateManager.state && context.juris.stateManager.state.persistence) { context.juris.stateManager.state.persistence.lastRestore = Date.now(); } return { restored: [], failed: [] }; } // Sort domains by priority and timing configuration const sortedDomains = storedDomains.sort((a, b) => { const configA = config.domainRestoreConfig[a] || { priority: 999, delay: 0 }; const configB = config.domainRestoreConfig[b] || { priority: 999, delay: 0 }; if (configA.priority !== configB.priority) { return configA.priority - configB.priority; } return configA.delay - configB.delay; }); log(`📂 Restore order: [${sortedDomains.join(', ')}]`); // Process domains with their configured timing let cumulativeDelay = 0; const results = { restored: [], failed: [], priority: [], delayed: [] }; sortedDomains.forEach((domain, index) => { const domainConfig = config.domainRestoreConfig[domain] || { priority: 999, delay: 0, aggressive: false }; if (domainConfig.aggressive || config.priorityDomains.includes(domain)) { // Immediate restore for aggressive/priority domains if (restoreDomain(domain)) { results.restored.push(domain); results.priority.push(domain); // DIRECT INJECTION for stats if (context.juris && context.juris.stateManager && context.juris.stateManager.state && context.juris.stateManager.state.persistence) { context.juris.stateManager.state.persistence.stats.priorityRestores = (context.juris.stateManager.state.persistence.stats.priorityRestores || 0) + 1; } } else { results.failed.push(domain); } log(`⚡ Priority restore completed for ${domain} immediately`); } else { // Delayed restore for non-critical domains const restoreDelay = cumulativeDelay + domainConfig.delay; setTimeout(() => { if (restoreDomain(domain)) { results.restored.push(domain); results.delayed.push(domain); // DIRECT INJECTION for stats if (context.juris && context.juris.stateManager && context.juris.stateManager.state && context.juris.stateManager.state.persistence) { context.juris.stateManager.state.persistence.stats.delayedRestores = (context.juris.stateManager.state.persistence.stats.delayedRestores || 0) + 1; } } else { results.failed.push(domain); } }, restoreDelay); log(`⏳ Delayed restore scheduled for ${domain} at ${restoreDelay}ms`); cumulativeDelay += domainConfig.delay; } }); // Update final restore timestamp const finalDelay = Math.max(cumulativeDelay, config.earlyRestoreTimeout); setTimeout(() => { // DIRECT INJECTION for lastRestore if (context.juris && context.juris.stateManager && context.juris.stateManager.state && context.juris.stateManager.state.persistence) { context.juris.stateManager.state.persistence.lastRestore = Date.now(); } log(`📂 Restore sequence completed. Priority: ${results.priority.length}, Delayed: ${results.delayed.length}, Failed: ${results.failed.length}`); }, finalDelay); return results; } function restorePriorityDomains() { log('⚡ Restoring priority domains only...'); const priorityResults = { restored: [], failed: [] }; config.priorityDomains.forEach(domain => { if (restoreDomain(domain)) { priorityResults.restored.push(domain); } else { priorityResults.failed.push(domain); } }); log(`⚡ Priority restore completed: [${priorityResults.restored.join(', ')}]`); return priorityResults; } function setupDomainMonitoring() { log('🔍 Setting up domain monitoring...'); const allState = context.juris.stateManager.state; const availableDomains = Object.keys(allState); log(`🔍 Available domains in state:`, availableDomains); // Clear existing subscriptions domainSubscriptions.forEach(unsubscribe => unsubscribe()); domainSubscriptions.clear(); // Determine which domains to track let domainsToTrack = []; if (config.domains.length > 0) { domainsToTrack = config.domains.filter(domain => { const exists = availableDomains.includes(domain); if (!exists) { log(`⚠️ Configured domain '${domain}' not found in state`); } return exists && !config.excludeDomains.includes(domain); }); } else { domainsToTrack = availableDomains.filter(domain => !config.excludeDomains.includes(domain) ); } log(`📊 Domains to track: [${domainsToTrack.join(', ')}]`); // Track each domain domainsToTrack.forEach(domain => { addDomainTracking(domain); }); // Update stats - DIRECT INJECTION if (context.juris && context.juris.stateManager && context.juris.stateManager.state && context.juris.stateManager.state.persistence) { context.juris.stateManager.state.persistence.stats.domainsTracked = domainSubscriptions.size; } log(`✅ Now tracking ${domainSubscriptions.size} domains: [${Array.from(domainSubscriptions.keys()).join(', ')}]`); } function setupDomainWatcher() { domainWatcher = setInterval(() => { const currentDomains = Object.keys(context.juris.stateManager.state); const trackedDomains = Array.from(domainSubscriptions.keys()); const newDomains = currentDomains.filter(domain => !trackedDomains.includes(domain) && !config.excludeDomains.includes(domain) && (config.domains.length === 0 || config.domains.includes(domain)) ); if (newDomains.length > 0) { log(`🆕 Detected new domains: [${newDomains.join(', ')}]`); newDomains.forEach(domain => addDomainTracking(domain)); // DIRECT INJECTION for stats if (context.juris && context.juris.stateManager && context.juris.stateManager.state && context.juris.stateManager.state.persistence) { context.juris.stateManager.state.persistence.stats.domainsTracked = domainSubscriptions.size; } } }, config.watchIntervalMs); log(`👀 Domain watcher started (checking every ${config.watchIntervalMs}ms)`); } function addDomainTracking(domain) { if (domainSubscriptions.has(domain)) { log(`⚠️ Domain ${domain} already being tracked`); return false; } try { const testValue = getState(domain); log(`🔍 Testing domain '${domain}': ${testValue !== undefined ? 'exists' : 'undefined'}`); // Use internal subscription const unsubscribe = context.juris.stateManager.subscribeInternal(domain, () => { if (!isRestoring) { const currentValue = getState(domain); log(`🔄 State change detected in domain: ${domain}`, { currentValue }); debouncedSave(domain, currentValue); } }); domainSubscriptions.set(domain, unsubscribe); log(`➕ Added tracking for domain: ${domain}`); return true; } catch (error) { logError(`Failed to add tracking for domain ${domain}:`, error); return false; } } function removeDomainTracking(domain) { const unsubscribe = domainSubscriptions.get(domain); if (unsubscribe) { unsubscribe(); domainSubscriptions.delete(domain); log(`➖ Removed tracking for domain: ${domain}`); return true; } log(`⚠️ Domain ${domain} was not being tracked`); return false; } function debouncedSave(domain, value) { // Clear existing timer if (saveTimers.has(domain)) { clearTimeout(saveTimers.get(domain)); } // Determine save timing based on domain configuration let saveDelay = config.debounceMs; if (config.immediateSave.includes(domain)) { // Immediate save for critical domains saveDomain(domain, true); return; } else if (config.criticalSave.includes(domain)) { // Faster save for critical domains saveDelay = config.criticalDebounceMs; } const timer = setTimeout(() => { saveDomain(domain, false); saveTimers.delete(domain); }, saveDelay); saveTimers.set(domain, timer); const saveType = config.criticalSave.includes(domain) ? 'CRITICAL' : 'NORMAL'; log(`⏰ ${saveType} save scheduled for domain: ${domain} in ${saveDelay}ms`); } function saveDomain(domain, immediate = false) { try { const value = getState(domain); if (value === undefined || value === null) { log(`⚠️ Skipping save for undefined domain: ${domain}`); return false; } const dataPackage = { value: value, timestamp: Date.now(), domain: domain, immediate: immediate }; // Check if domain requires user-specific storage const isUserSpecific = config.userSpecificDomains.includes(domain); const currentUserId = getUserId(); if (isUserSpecific && config.requireAuth && !currentUserId) { log(`⚠️ Skipping save for user-specific domain '${domain}' - no authenticated user`); return false; } // Build storage key let storageKey = config.keyPrefix + domain; if (isUserSpecific && currentUserId) { storageKey = `${storageKey}_${currentUserId}`; } localStorage.setItem(storageKey, JSON.stringify(dataPackage)); // Update statistics - DIRECT INJECTION if (context.juris && context.juris.stateManager && context.juris.stateManager.state && context.juris.stateManager.state.persistence) { const stats = context.juris.stateManager.state.persistence.stats; stats.totalSaves = (stats.totalSaves || 0) + 1; context.juris.stateManager.state.persistence.lastSave = { domain, timestamp: Date.now(), size: JSON.stringify(dataPackage).length, immediate }; } const saveType = immediate ? 'IMMEDIATE' : 'DEBOUNCED'; log(`💾 ${saveType} saved domain: ${domain} (${JSON.stringify(dataPackage).length} bytes) key: ${storageKey}`); return true; } catch (error) { logError(`Failed to save domain ${domain}:`, error); return false; } } function restoreDomain(domain) { try { const isUserSpecific = config.userSpecificDomains.includes(domain); const currentUserId = getUserId(); // Build storage key let storageKey = config.keyPrefix + domain; if (isUserSpecific && currentUserId) { storageKey = `${storageKey}_${currentUserId}`; } const stored = localStorage.getItem(storageKey); if (!stored) { log(`📂 No stored data for domain: ${domain} (key: ${storageKey})`); return false; } const data = JSON.parse(stored); // DIRECT INJECTION - Restore directly to state without triggering subscriptions isRestoring = true; // Direct injection into the state manager's internal state if (context.juris && context.juris.stateManager && context.juris.stateManager.state) { context.juris.stateManager.state[domain] = data.value; log(`📂 DIRECT INJECT: ${domain} directly injected into state`); } else { // Fallback to setState if direct access not available setState(domain, data.value); log(`📂 FALLBACK: ${domain} restored via setState`); } isRestoring = false; // Update statistics - DIRECT INJECTION if (context.juris && context.juris.stateManager && context.juris.stateManager.state && context.juris.stateManager.state.persistence) { const stats = context.juris.stateManager.state.persistence.stats; stats.totalRestores = (stats.totalRestores || 0) + 1; context.juris.stateManager.state.persistence.lastRestore = { domain, timestamp: Date.now(), dataTimestamp: data.timestamp }; } log(`📂 Restored domain: ${domain} (saved ${new Date(data.timestamp).toLocaleString()})`); return true; } catch (error) { logError(`Failed to restore domain ${domain}:`, error); return false; } } function saveAllTrackedDomains() { log('💾 Saving all tracked domains...'); const savedDomains = []; const failedDomains = []; domainSubscriptions.forEach((unsubscribe, domain) => { if (saveDomain(domain, true)) { savedDomains.push(domain); } else { failedDomains.push(domain); } }); log(`💾 Saved ${savedDomains.length} domains: [${savedDomains.join(', ')}]`); if (failedDomains.length > 0) { logError(`❌ Failed to save ${failedDomains.length} domains: [${failedDomains.join(', ')}]`); } return { saved: savedDomains, failed: failedDomains }; } function handleStorageEvent(event) { if (!event.key || !event.key.startsWith(config.keyPrefix)) { return; } // Extract domain from key let remainder = event.key.substring(config.keyPrefix.length); let domain = remainder.split('_')[0]; if (domainSubscriptions.has(domain)) { log(`🔄 Storage changed externally for domain: ${domain}`); if (event.newValue) { // Parse and directly inject the new value try { const data = JSON.parse(event.newValue); isRestoring = true; // DIRECT INJECTION for cross-tab sync if (context.juris && context.juris.stateManager && context.juris.stateManager.state) { context.juris.stateManager.state[domain] = data.value; log(`🔄 DIRECT INJECT: ${domain} synced from external tab`); } else { setState(domain, data.value); log(`🔄 FALLBACK: ${domain} synced via setState`); } isRestoring = false; } catch (error) { logError(`Failed to parse external change for ${domain}:`, error); } } else { // Value was deleted externally isRestoring = true; if (context.juris && context.juris.stateManager && context.juris.stateManager.state) { delete context.juris.stateManager.state[domain]; log(`🔄 DIRECT DELETE: ${domain} removed from state`); } else { setState(domain, undefined); log(`🔄 FALLBACK DELETE: ${domain} removed via setState`); } isRestoring = false; } } } function clearDomainStorage(domain) { try { const isUserSpecific = config.userSpecificDomains.includes(domain); const currentUserId = getUserId(); let storageKey = config.keyPrefix + domain; if (isUserSpecific && currentUserId) { storageKey = `${storageKey}_${currentUserId}`; } localStorage.removeItem(storageKey); log(`🗑️ Cleared storage for domain: ${domain} (key: ${storageKey})`); return true; } catch (error) { logError(`Failed to clear storage for domain ${domain}:`, error); return false; } } function clearAllStorage() { try { const keysToRemove = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(config.keyPrefix)) { keysToRemove.push(key); } } keysToRemove.forEach(key => localStorage.removeItem(key)); log(`🗑️ Cleared ${keysToRemove.length} storage entries`); return true; } catch (error) { logError('Failed to clear all storage:', error); return false; } } function getStorageStats() { let totalSize = 0; let entryCount = 0; const domains = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(config.keyPrefix)) { const value = localStorage.getItem(key); totalSize += key.length + (value ? value.length : 0); entryCount++; // Extract domain from key let remainder = key.substring(config.keyPrefix.length); let domain = remainder.split('_')[0]; domains.push(domain); } } return { totalSize, entryCount, domains: [...new Set(domains)], // Remove duplicates trackedDomains: Array.from(domainSubscriptions.keys()), config: { aggressiveRestore: config.aggressiveRestore, priorityDomains: config.priorityDomains, immediateSave: config.immediateSave, criticalSave: config.criticalSave, keyPrefix: config.keyPrefix, userSpecificDomains: config.userSpecificDomains } }; } function exportState() { const exportData = { timestamp: Date.now(), domains: {} }; domainSubscriptions.forEach((unsubscribe, domain) => { exportData.domains[domain] = getState(domain); }); return exportData; } function importState(data) { try { if (!data.domains) { throw new Error('Invalid import data format'); } isRestoring = true; Object.entries(data.domains).forEach(([domain, value]) => { // DIRECT INJECTION for import as well if (context.juris && context.juris.stateManager && context.juris.stateManager.state) { context.juris.stateManager.state[domain] = value; log(`📥 DIRECT INJECT: ${domain} imported directly into state`); } else { setState(domain, value); log(`📥 FALLBACK: ${domain} imported via setState`); } saveDomain(domain, true); }); isRestoring = false; log(`📥 Imported ${Object.keys(data.domains).length} domains`); return true; } catch (error) { isRestoring = false; logError('Import failed:', error); return false; } } // Helper function to get user ID from configurable path function getUserId() { try { const pathParts = config.userIdPath.split('.'); let value = context.juris.stateManager.state; for (const part of pathParts) { if (value && typeof value === 'object') { value = value[part]; } else { return null; } } return value; } catch (error) { log(`⚠️ Failed to get user ID from path: ${config.userIdPath}`, error); return null; } } function log(message, ...args) { if (config.debug) { console.log(`💾 [StatePersistence] ${message}`, ...args); } } function logError(message, error = null) { console.error(`💾 [StatePersistence] ${message}`, error); // DIRECT INJECTION for errors if (context.juris && context.juris.stateManager && context.juris.stateManager.state && context.juris.stateManager.state.persistence) { const errors = context.juris.stateManager.state.persistence.errors; errors.push({ message, error: error ? error.message : null, timestamp: Date.now() }); if (errors.length > 10) { errors.splice(0, errors.length - 10); } } } }; // URL State Sync with Route Guards const UrlStateSync = (props, context) => { const { getState, setState } = context; const routeGuards = { '/': ['authenticated'], '/lists': ['authenticated'], '/profile': ['authenticated'], '/settings': ['authenticated'] }; return { hooks: { onRegister: () => { console.log('🧭 UrlStateSync initializing...'); handleUrlChange(); window.addEventListener('hashchange', handleUrlChange); window.addEventListener('popstate', handleUrlChange); } } }; async function handleUrlChange() { const hash = window.location.hash.substring(1) || '/'; const segments = parseSegments(hash); // Check route guards const guardResult = await checkRouteAccess(hash); if (guardResult.allowed) { setState('url.path', hash); setState('url.segments', segments); console.log('🧭 URL updated:', hash); } else { console.log('🚫 Route access denied, redirecting to:', guardResult.redirect); window.location.hash = guardResult.redirect; } } async function checkRouteAccess(path) { const guards = getRouteGuards(path); const isAuthenticated = getState('auth.isLoggedIn', false); if (guards.includes('authenticated') && !isAuthenticated) { return { allowed: false, redirect: '/login' }; } return { allowed: true }; } function getRouteGuards(path) { if (routeGuards[path]) return routeGuards[path]; const segments = path.split('/').filter(Boolean); for (let i = segments.length - 1; i > 0; i--) { const parentPath = '/' + segments.slice(0, i).join('/'); if (routeGuards[parentPath]) return routeGuards[parentPath]; } return []; } function parseSegments(path) { const parts = path.split('/').filter(Boolean); return { full: path, parts: parts, base: parts[0] || '', sub: parts[1] || '', id: parts[2] || '' }; } }; // Authentication Manager const AuthManager = (props, context) => { const { getState, setState } = context; return { hooks: { onRegister: () => { console.log('🔐 AuthManager initializing...'); checkExistingAuth(); } }, api: { login: async (email, password) => { setState('auth.loading', true); setState('auth.error', null); try { await new Promise(resolve => setTimeout(resolve, 1000)); const users = getState('storage.users', []); const user = users.find(u => u.email === email && u.password === hashPassword(password)); if (!user) { throw new Error('Email ou mot de passe invalide'); } const token = generateToken(); setState('auth.user', user); setState('auth.token', token); setState('auth.isLoggedIn', true); // Store in localStorage localStorage.setItem('todo_auth_token', token); localStorage.setItem('todo_auth_user', JSON.stringify(user)); console.log('✅ Login successful'); // Trigger user-specific data restore after login setTimeout(() => { context.headless.StatePersistenceManager.restoreAllDomains(); }, 100); return { success: true }; } catch (error) { setState('auth.error', error.message); console.error('❌ Login failed:', error.message); return { success: false, error: error.message }; } finally { setState('auth.loading', false); } }, register: async (email, password, name) => { setState('auth.loading', true); setState('auth.error', null); try { await new Promise(resolve => setTimeout(resolve, 1000)); const users = getState('storage.users', []); if (users.find(u => u.email === email)) { throw new Error('Cet email existe déjà'); } const newUser = { id: Date.now().toString(), email, password: hashPassword(password), name, createdAt: new Date().toISOString() }; const updatedUsers = [...users, newUser]; setState('storage.users', updatedUsers); console.log('✅ Registration successful'); return { success: true }; } catch (error) { setState('auth.error', error.message); console.error('❌ Registration failed:', error.message); return { success: false, error: error.message }; } finally { setState('auth.loading', false); } }, logout: () => { // Save current state before logout context.headless.StatePersistenceManager.saveAllDomains(); setState('auth.user', null); setState('auth.token', null); setState('auth.isLoggedIn', false); // Clear user-specific state setState('ui.selectedListId', null); setState('todos.lists', []); setState('todos.items', {}); setState('todos.filter', 'all'); localStorage.removeItem('todo_auth_token'); localStorage.removeItem('todo_auth_user'); window.location.hash = '/login'; console.log('👋 Logged out'); } } }; function checkExistingAuth() { const token = localStorage.getItem('todo_auth_token'); const userStr = localStorage.getItem('todo_auth_user'); if (token && userStr) { try { const user = JSON.parse(userStr); setState('auth.user', user); setState('auth.token', token); setState('auth.isLoggedIn', true); console.log('🔄 Restored authentication'); // Trigger user-specific data restore after auth restore setTimeout(() => { context.headless.StatePersistenceManager.restoreAllDomains(); }, 100); } catch (error) { console.error('❌ Failed to restore auth:', error); localStorage.removeItem('todo_auth_token'); localStorage.removeItem('todo_auth_user'); } } } function hashPassword(password) { return CryptoJS.SHA256(password).toString(); } function generateToken() { return CryptoJS.lib.WordArray.random(32).toString(); } }; // Enhanced Todo Manager const TodoManager = (props, context) => { const { getState, setState, subscribe } = context; return { hooks: { onRegister: () => { console.log('📝 TodoManager initializing...'); loadUserTodos(); // Subscribe to auth changes subscribe('auth.user', (user) => { if (user) { loadUserTodos(); } else { setState('todos', { lists: [], items: {}, filters: {} }); setState('ui.selectedListId', null); } }); } }, api: { createList: (name) => { const user = getState('auth.user'); if (!user) return; const newList = { id: Date.now().toString(), name, userId: user.id, createdAt: new Date().toISOString() }; const lists = getState('todos.lists', []); setState('todos.lists', [...lists, newList]); // Auto-select the new list setState('ui.selectedListId', newList.id); saveTodos(); console.log('✅ List created:', name); }, deleteList: (listId) => { const lists = getState('todos.lists', []); const items = getState('todos.items', {}); // Remove list const updatedLists = lists.filter(list => list.id !== listId); setState('todos.lists', updatedLists); // Remove all items in this list const updatedItems = { ...items }; delete updatedItems[listId]; setState('todos.items', updatedItems); // Clear selection if this list was selected const selectedId = getState('ui.selectedListId'); if (selectedId === listId) { // Auto-select the first remaining list or null const newSelectedId = updatedLists.length > 0 ? updatedLists[0].id : null; setState('ui.selectedListId', newSelectedId); } saveTodos(); console.log('🗑️ List deleted:', listId); }, createTodo: (listId, text) => { const newTodo = { id: Date.now().toString(), text, completed: false, createdAt: new Date().toISOString() }; const items = getState('todos.items', {}); const listItems = items[listId] || []; setState(`todos.items.${listId}`, [...listItems, newTodo]); saveTodos(); console.log('✅ Todo created:', text); }, toggleTodo: (listId, todoId) => { const items = getState(`todos.items.${listId}`, []); const updatedItems = items.map(item => item.id === todoId ? { ...item, completed: !item.completed } : item ); setState(`todos.items.${listId}`, updatedItems); saveTodos(); }, deleteTodo: (listId, todoId) => { const items = getState(`todos.items.${listId}`, []); const updatedItems = items.filter(item => item.id !== todoId); setState(`todos.items.${listId}`, updatedItems); saveTodos(); }, setFilter: (filter) => { setState('todos.filter', filter); }, selectList: (listId) => { setState('ui.selectedListId', listId); console.log('📋 Selected list:', listId); } } }; function loadUserTodos() { const user = getState('auth.user'); if (!user) return; const stored = localStorage.getItem(`todo_data_${user.id}`); if (stored) { try { const data = JSON.parse(stored); setState('todos.lists', data.lists || []); setState('todos.items', data.items || {}); console.log('📚 Todos loaded for user:', user.email); } catch (error) { console.error('❌ Failed to load todos:', error); } } } function saveTodos() { const user = getState('auth.user'); if (!user) return; const data = { lists: getState('todos.lists', []), items: getState('todos.items', {}), lastSaved: new Date().toISOString() }; localStorage.setItem(`todo_data_${user.id}`, JSON.stringify(data)); console.log('💾 Todos saved'); } }; // ==================== UI COMPONENTS (with French translations) ==================== // App Layout const AppLayout = (props, context) => { const { getState } = context; return { render: () => ({ div: { className: 'app-container', children: () => { const isLoggedIn = getState('auth.isLoggedIn', false); const currentPath = getState('url.path', '/'); if (!isLoggedIn && !['/login', '/register'].includes(currentPath)) { return [{ AuthLayout: {} }]; } if (['/login', '/register'].includes(currentPath)) { return [{ AuthLayout: {} }]; } return [ { AppHeader: {} }, { MainContent: {} } ]; } } }) }; }; // App Header const AppHeader = (props, context) => { const { getState } = context; return { render: () => ({ header: { className: 'header', children: [{ div: { className: 'header-content', children: [ { a: { className: 'logo', href: '#/', text: '📝 Todo App ', onclick: (e) => { e.preventDefault(); window.location.hash = '/'; } } }, { nav: { className: 'nav', children: [ { a: { className: () => { const path = getState('url.path', '/'); return path === '/' ? 'nav-link active' : 'nav-link'; }, href: '#/', text: 'Tableau de bord', onclick: (e) => { e.preventDefault(); window.location.hash = '/'; } } }, { a: { className: () => { const path = getState('url.path', '/'); return path === '/profile' ? 'nav-link active' : 'nav-link'; }, href: '#/profile', text: 'Profil', onclick: (e) => { e.preventDefault(); window.location.hash = '/profile'; } } }, { button: { className: 'btn btn-secondary btn-small', text: 'Déconnexion', onclick: () => context.headless.AuthManager.logout() } } ] } } ] } }] } }) }; }; // Auth Layout const AuthLayout = (props, context) => { const { getState } = context; return { render: () => ({ div: { className: 'app-container', children: [{ main: { className: 'main-content', children: () => { const path = getState('url.path', '/'); if (path === '/register') { return [{ RegisterForm: {} }]; } return [{ LoginForm: {} }]; } } }] } }) }; }; // Login Form const LoginForm = (props, context) => { const { getState, setState, headless } = context; return { render: () => ({ div: { className: 'form-container fade-in', children: [ { h2: { className: 'form-title', text: 'Bon retour !' } }, { form: { onsubmit: async (e) => { e.preventDefault(); const email = getState('auth.form.email', ''); const password = getState('auth.form.password', ''); const result = await headless.AuthManager.login(email, password); if (result.success) { window.location.hash = '/'; } }, children: [ { div: { className: 'form-group', children: [ { label: { className: 'form-label', text: 'Email' } }, { input: { type: 'email', className: 'form-input', placeholder: 'Saisissez votre email', value: () => getState('auth.form.email', ''), oninput: (e) => setState('auth.form.email', e.target.value), required: true } } ] } }, { div: { className: 'form-group', children: [ { label: { className: 'form-label', text: 'Mot de passe' } }, { input: { type: 'password', className: 'form-input', placeholder: 'Saisissez votre mot de passe', value: () => getState('auth.form.password', ''), oninput: (e) => setState('auth.form.password', e.target.value), required: true } } ] } }, { div: { className: () => getState('auth.error') ? 'form-error' : 'hidden', text: () => getState('auth.error', '') } }, { button: { type: 'submit', className: 'btn btn-primary btn-full', disabled: () => getState('auth.loading', false), text: () => getState('auth.loading') ? 'Connexion...' : 'Se connecter' } }, { div: { className: 'text-center mt-2', children: [ { span: { text: "Pas encore de compte ? " } }, { a: { href: '#/register', text: 'Inscrivez-vous', onclick: (e) => { e.preventDefault(); window.location.hash = '/register'; } } } ] } } ] } } ] } }) }; }; // Register Form const RegisterForm = (props, context) => { const { getState, setState, headless } = context; return { render: () => ({ div: { className: 'form-container fade-in', children: [ { h2: { className: 'form-title', text: 'Créer un compte' } }, { form: { onsubmit: async (e) => { e.preventDefault(); const name = getState('auth.form.name', ''); const email = getState('auth.form.email', ''); const password = getState('auth.form.password', ''); const result = await headless.AuthManager.register(email, password, name); if (result.success) { setState('auth.form', {}); window.location.hash = '/login'; } }, children: [ { div: { className: 'form-group', children: [ { label: { className: 'form-label', text: 'Nom complet' } }, { input: { type: 'text', className: 'form-input', placeholder: 'Saisissez votre nom complet', value: () => getState('auth.form.name', ''), oninput: (e) => setState('auth.form.name', e.target.value), required: true } } ] } }, { div: { className: 'form-group', children: [ { label: { className: 'form-label', text: 'Email' } }, { input: { type: 'email', className: 'form-input', placeholder: 'Saisissez votre email', value: () => getState('auth.form.email', ''), oninput: (e) => setState('auth.form.email', e.target.value), required: true } } ] } }, { div: { className: 'form-group', children: [ { label: { className: 'form-label', text: 'Mot de passe' } }, { input: { type: 'password', className: 'form-input', placeholder: 'Créez un mot de passe', value: () => getState('auth.form.password', ''), oninput: (e) => setState('auth.form.password', e.target.value), required: true } } ] } }, { div: { className: () => getState('auth.error') ? 'form-error' : 'hidden', text: () => getState('auth.error', '') } }, { button: { type: 'submit', className: 'btn btn-primary btn-full', disabled: () => getState('auth.loading', false), text: () => getState('auth.loading') ? 'Création du compte...' : 'Créer un compte' } }, { div: { className: 'text-center mt-2', children: [ { span: { text: "Vous avez déjà un compte ? " } }, { a: { href: '#/login', text: 'Connectez-vous', onclick: (e) => { e.preventDefault(); window.location.hash = '/login'; } } } ] } } ] } } ] } }) }; }; // Main Content const MainContent = (props, context) => { const { getState } = context; return { render: () => ({ main: { className: 'main-content', children: () => { const segments = getState('url.segments', { base: '' }); switch (segments.base) { case '': return [{ TodoDashboard: {} }]; case 'lists': if (segments.sub) { return [{ TodoListDetail: { listId: segments.sub } }]; } return [{ TodoDashboard: {} }]; case 'profile': return [{ UserProfile: {} }]; case 'settings': return [{ AppSettings: {} }]; default: return [{ NotFound: {} }]; } } } }) }; }; // Todo Dashboard const TodoDashboard = (props, context) => { const { getState, setState, headless } = context; return { render: () => ({ div: { className: 'todo-dashboard fade-in', children: [ { div: { className: 'todo-lists', children: [ { div: { className: 'section-title', text: 'Vos listes de tâches' } }, { div: { className: 'todo-form', children: [{ form: { onsubmit: (e) => { e.preventDefault(); const name = getState('ui.newListName', '').trim(); if (name) { headless.TodoManager.createList(name); setState('ui.newListName', ''); } }, children: [{ div: { className: 'todo-input', children: [ { input: { type: 'text', style: { flex: '0.75' }, className: 'form-input', placeholder: 'Saisir le nom de la liste', value: () => getState('ui.newListName', ''), oninput: (e) => setState('ui.newListName', e.target.value) } }, { button: { style: { flex: 0.25 }, type: 'submit', className: 'btn btn-primary', text: 'Ajouter' } } ] } }] } }] } }, { div: { children: () => { const lists = getState('todos.lists', []); if (lists.length === 0) { return [{ div: { className: 'empty-state', children: [ { h3: { text: 'Aucune liste pour le moment' } }, { p: { text: 'Créez votre première liste de tâches pour commencer !' } } ] } }]; } return lists.map(list => ({ TodoListItem: { list, key: list.id } })); } } } ] } }, { div: { className: 'todo-list', children: () => { const selectedListId = getState('ui.selectedListId'); const lists = getState('todos.lists', []); const selectedList = lists.find(l => l.id === selectedListId); if (!selectedList) { return [{ div: { className: 'empty-state', children: [ { h3: { text: 'Sélectionnez une liste' } }, { p: { text: 'Choisissez une liste dans la barre latérale pour voir et gérer vos tâches.' } } ] } }]; } return [{ TodoListDetail: { listId: selectedListId } }]; } } } ] } }) }; }; // Todo List Item const TodoListItem = (props, context) => { const { getState, setState, headless } = context; const { list } = props; return { render: () => ({ div: { className: () => { const selectedId = getState('ui.selectedListId'); return selectedId === list.id ? 'list-item active' : 'list-item'; }, onclick: () => headless.TodoManager.selectList(list.id), children: [ { div: { className: 'list-info', children: [ { div: { className: 'list-name', text: list.name } }, { div: { className: 'list-count', text: () => { const items = getState(`todos.items.${list.id}`, []); const completed = items.filter(item => item.completed).length; return `${completed}/${items.length} terminées`; } } } ] } }, { div: { className: 'list-actions', children: [{ button: { className: 'btn btn-danger btn-small', text: '🗑️', onclick: (e) => { e.stopPropagation(); if (confirm(`Supprimer "${list.name}" ?`)) { headless.TodoManager.deleteList(list.id); } } } }] } } ] } }) }; }; // Todo List Detail const TodoListDetail = (props, context) => { const { getState, setState, headless } = context; const { listId } = props; return { render: () => ({ div: { className: 'fade-in', children: [ { div: { className: 'section-title', text: () => { const lists = getState('todos.lists', []); const list = lists.find(l => l.id === listId); return list ? list.name : 'Liste de tâches'; } } }, { div: { className: 'todo-form', children: [{ form: { onsubmit: (e) => { e.preventDefault(); const text = getState('ui.newTodoText', '').trim(); if (text) { headless.TodoManager.createTodo(listId, text); setState('ui.newTodoText', ''); } }, children: [{ div: { className: 'todo-input', children: [ { input: { type: 'text', className: 'form-input', placeholder: 'Ajouter une nouvelle tâche...', value: () => getState('ui.newTodoText', ''), oninput: (e) => setState('ui.newTodoText', e.target.value) } }, { button: { type: 'submit', className: 'btn btn-primary', text: 'Ajouter' } } ] } }] } }] } }, { div: { className: 'filters', children: [ { button: { className: () => { const filter = getState('todos.filter', 'all'); return filter === 'all' ? 'filter-btn active' : 'filter-btn'; }, text: 'Toutes', onclick: () => headless.TodoManager.setFilter('all') } }, { button: { className: () => { const filter = getState('todos.filter', 'all'); return filter === 'active' ? 'filter-btn active' : 'filter-btn'; }, text: 'Actives', onclick: () => headless.TodoManager.setFilter('active') } }, { button: { className: () => { const filter = getState('todos.filter', 'all'); return filter === 'completed' ? 'filter-btn active' : 'filter-btn'; }, text: 'Terminées', onclick: () => headless.TodoManager.setFilter('completed') } } ] } }, { div: { children: () => { const items = getState(`todos.items.${listId}`, []); const filter = getState('todos.filter', 'all'); let filteredItems = items; if (filter === 'active') { filteredItems = items.filter(item => !item.completed); } else if (filter === 'completed') { filteredItems = items.filter(item => item.completed); } if (filteredItems.length === 0) { return [{ div: { className: 'empty-state', children: [ { h3: { text: () => { const filter = getState('todos.filter', 'all'); if (filter === 'active') return 'Aucune tâche active'; if (filter === 'completed') return 'Aucune tâche terminée'; return 'Aucune tâche pour le moment'; } } }, { p: { text: 'Ajoutez votre première tâche ci-dessus !' } } ] } }]; } return filteredItems.map(item => ({ TodoItem: { listId, item, key: item.id } })); } } } ] } }) }; }; // Todo Item const TodoItem = (props, context) => { const { headless } = context; const { listId, item } = props; return { render: () => ({ div: { className: 'todo-item', children: [ { input: { type: 'checkbox', className: 'todo-checkbox', checked: item.completed, onchange: () => headless.TodoManager.toggleTodo(listId, item.id) } }, { span: { className: item.completed ? 'todo-text completed' : 'todo-text', text: item.text } }, { div: { className: 'todo-actions', children: [{ button: { className: 'btn btn-danger btn-small', text: '🗑️', onclick: () => { if (confirm('Supprimer cette tâche ?')) { headless.TodoManager.deleteTodo(listId, item.id); } } } }] } } ] } }) }; }; // User Profile const UserProfile = (props, context) => { const { getState } = context; return { render: () => ({ div: { className: 'fade-in', children: [{ div: { className: 'form-container', children: [ { h2: { className: 'form-title', text: 'Profil' } }, { div: { className: 'form-group', children: [ { label: { className: 'form-label', text: 'Nom' } }, { div: { className: 'form-input', style: { background: 'var(--gray-100)', border: 'none', color: 'var(--gray-700)' }, text: () => getState('auth.user.name', 'Non disponible') } } ] } }, { div: { className: 'form-group', children: [ { label: { className: 'form-label', text: 'Membre depuis' } }, { div: { className: 'form-input', style: { background: 'var(--gray-100)', border: 'none', color: 'var(--gray-700)' }, text: () => { const createdAt = getState('auth.user.createdAt'); return createdAt ? new Date(createdAt).toLocaleDateString() : 'Non disponible'; } } } ] } }, { div: { className: 'form-group', children: [ { label: { className: 'form-label', text: 'Statistiques' } }, { div: { style: { background: 'var(--gray-50)', padding: '1rem', borderRadius: 'var(--border-radius)', border: '1px solid var(--gray-200)' }, children: [ { div: { style: { marginBottom: '0.5rem' }, children: [ { span: { style: { fontWeight: '500' }, text: 'Total des listes : ' } }, { span: { text: () => getState('todos.lists', []).length.toString() } } ] } }, { div: { style: { marginBottom: '0.5rem' }, children: [ { span: { style: { fontWeight: '500' }, text: 'Total des tâches : ' } }, { span: { text: () => { const items = getState('todos.items', {}); const total = Object.values(items).reduce((sum, list) => sum + list.length, 0); return total.toString(); } } } ] } }, { div: { children: [ { span: { style: { fontWeight: '500' }, text: 'Terminées : ' } }, { span: { text: () => { const items = getState('todos.items', {}); const completed = Object.values(items) .flat() .filter(item => item.completed).length; return completed.toString(); } } } ] } } ] } } ] } } ] } }] } }) }; }; // App Settings const AppSettings = (props, context) => { const { getState, setState, headless } = context; return { render: () => ({ div: { className: 'fade-in', children: [{ div: { className: 'form-container', children: [ { h2: { className: 'form-title', text: 'Paramètres' } }, { div: { className: 'form-group', children: [ { label: { className: 'form-label', text: 'Filtre par défaut' } }, { select: { className: 'form-input', value: () => getState('settings.defaultFilter', 'all'), onchange: (e) => setState('settings.defaultFilter', e.target.value), children: [ { option: { value: 'all', text: 'Toutes les tâches' } }, { option: { value: 'active', text: 'Tâches actives' } }, { option: { value: 'completed', text: 'Tâches terminées' } } ] } } ] } }, { div: { className: 'form-group', children: [ { label: { className: 'form-label', children: [ { input: { type: 'checkbox', checked: () => getState('settings.autoSave', true), onchange: (e) => setState('settings.autoSave', e.target.checked), style: { marginRight: '0.5rem' } } }, { span: { text: 'Sauvegarde automatique' } } ] } } ] } }, { div: { className: 'form-group', children: [ { label: { className: 'form-label', text: 'Statistiques de Persistance' } }, { div: { style: { background: 'var(--gray-50)', padding: '1rem', borderRadius: 'var(--border-radius)', border: '1px solid var(--gray-200)' }, children: () => { const stats = getState('persistence.stats', {}); return [ { div: { style: { marginBottom: '0.5rem' }, children: [ { span: { style: { fontWeight: '500' }, text: 'Domaines Suivis : ' } }, { span: { text: (stats.domainsTracked || 0).toString() } } ] } }, { div: { style: { marginBottom: '0.5rem' }, children: [ { span: { style: { fontWeight: '500' }, text: 'Total Sauvegardes : ' } }, { span: { text: (stats.totalSaves || 0).toString() } } ] } }, { div: { children: [ { span: { style: { fontWeight: '500' }, text: 'Total Restaurations : ' } }, { span: { text: (stats.totalRestores || 0).toString() } } ] } } ]; } } } ] } }, { div: { className: 'form-group', children: [ { button: { className: 'btn btn-secondary btn-full', style: { marginBottom: '0.5rem' }, text: 'Exporter toutes les données', onclick: () => { try { const exportData = headless.StatePersistenceManager.exportState(); const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `todo-app-sauvegarde-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert('Données exportées avec succès !'); } catch (error) { alert("L'exportation a échoué : " + error.message); } } } } ] } }, { div: { className: 'form-group', children: [ { button: { className: 'btn btn-danger btn-full', text: 'Effacer toutes les données', onclick: () => { if (confirm('Ceci supprimera toutes vos listes et données. Cette action est irréversible. Êtes-vous sûr(e) ?')) { const user = getState('auth.user'); if (user) { // Clear old todo data localStorage.removeItem(`todo_data_${user.id}`); // Clear new persistence data headless.StatePersistenceManager.clearAllStorage(); // Reset state setState('todos.lists', []); setState('todos.items', {}); setState('ui.selectedListId', null); alert('Toutes les données ont été effacées.'); } } } } } ] } } ] } }] } }) }; }; // Not Found Page const NotFound = (props, context) => { return { render: () => ({ div: { className: 'empty-state fade-in', children: [ { h3: { text: '404 - Page non trouvée' } }, { p: { text: "La page que vous cherchez n'existe pas." } }, { a: { href: '#/', className: 'btn btn-primary', text: 'Aller au Tableau de bord', onclick: (e) => { e.preventDefault(); window.location.hash = '/'; } } } ] } }) }; }; // ==================== APPLICATION INITIALIZATION ==================== const juris = new Juris({ components: { AppLayout, AppHeader, AuthLayout, LoginForm, RegisterForm, MainContent, TodoDashboard, TodoListItem, TodoListDetail, TodoItem, UserProfile, AppSettings, NotFound }, headlessComponents: { AuthManager: { fn: AuthManager, options: { autoInit: true } }, UrlStateSync: { fn: UrlStateSync, options: { autoInit: true } }, TodoManager: { fn: TodoManager, options: { autoInit: true } }, StatePersistenceManager: { fn: StatePersistenceManager, options: { autoInit: true, debug: true, // Enable debug logging domains: ['ui', 'todos', 'settings', 'storage'], // Specify domains to track priorityDomains: ['ui'], // UI gets priority restore for selected list immediateSave: ['ui'], // Save UI changes immediately (like selected list) criticalSave: ['todos'], // Save todos with faster debounce aggressiveRestore: true, // Restore immediately on startup keyPrefix: 'todo_app_state_', // User-specific domains will have user ID appended automatically domainRestoreConfig: { ui: { priority: 1, delay: 0, aggressive: true }, // Restore selected list first todos: { priority: 2, delay: 0, aggressive: true }, settings: { priority: 3, delay: 100, aggressive: false }, storage: { priority: 4, delay: 200, aggressive: false } } } } }, layout: { div: { children: [{ AppLayout: {} }] } }, states: { url: { path: '/', segments: { full: '/', parts: [], base: '', sub: '', id: '' } }, auth: { user: null, token: null, isLoggedIn: false, loading: false, error: null, form: {} }, todos: { lists: [], items: {}, filter: 'all' }, ui: { selectedListId: '', // This will now be persisted! newListName: '', newTodoText: '' }, storage: { users: [], settings: {} }, settings: { defaultFilter: 'all', autoSave: true } } }); // Start the application juris.render('#app'); // Auto-navigate to login if no hash if (!window.location.hash) { // Don't force login redirect, let auth check handle it } // Global access for debugging window.juris = juris; console.log('🚀 Todo App est prête !'); console.log('📱 Routes disponibles :'); console.log(' - #/login (public)'); console.log(' - #/register (public)'); console.log(' - #/ (protégé - tableau de bord)'); console.log(' - #/profile (protégé)'); console.log(' - #/settings (protégé)'); console.log('💾 Les données persistent dans localStorage'); console.log('🔐 Authentification avec gardes de route'); console.log('🧭 Routage basé sur l\'état'); </script> </body> </html> 
Enter fullscreen mode Exit fullscreen mode

Top comments (0)