base.html 228 lignes · 9290 octets
<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
    <script>
    (function() {
        var lm = document.cookie.match(/(?:^|;\s*)locale=([^;]*)/);
        if (lm && lm[1]) document.documentElement.lang = lm[1];
        var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
        var t = m ? m[1] : 'auto';
        if (t === 'dark') {
            document.documentElement.setAttribute('data-theme', 'dark');
        } else if (t === 'light') {
            document.documentElement.setAttribute('data-theme', 'light');
        } else {
            var d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
            document.documentElement.setAttribute('data-theme', d);
        }
    })();
    </script>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="{% if app_description != "" %}{{ app_description }}{% else %}{{ app_name }}{% endif %}">
    <!-- CSRF token exposed for JS clients; mirrored on <body hx-headers> for HTMX. -->
    <meta name="csrf-token" content="{{ csrf_token }}">
    <title>{% block title %}{{ page_title }} - {{ app_name }}{% endblock %}</title>
    
    <!-- Favicon -->
    <link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
    <link rel="icon" type="image/png" href="/static/images/favicon.png">
    <link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png">
    
    <!-- Tailwind CSS + DaisyUI -->
    <link href="/static/css/tailwind.css" rel="stylesheet">
    
    <!-- Custom CSS -->
    <link href="/static/css/custom.css" rel="stylesheet">
    
    <!-- HTMX (local) -->
    <script src="/static/js/htmx.min.js"></script>
    
    <!-- Alpine.js CSP build (no unsafe-eval). Components register on alpine:init. -->
    <script src="/static/js/alpine-components.js" defer></script>
    <script src="/static/js/alpine-csp.min.js" defer></script>
    
    {% block head_extra %}{% endblock %}
</head>
<body class="bg-base-100 min-h-screen flex flex-col" hx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}'>
    <!-- Navbar (fixe en haut) - Include in child templates that need it -->
    {% block navbar %}{% endblock %}
    
    <!-- Breadcrumb -->
    {% block breadcrumb %}
    {% endblock %}
    
    <!-- Main Layout: Sidebar + Content -->
    <div class="flex flex-1 overflow-hidden">
        <!-- Sidebar (gauche) -->
        {% block sidebar %}
        {% endblock %}
        
        <!-- Content Area (principal) -->
        <main class="flex-1 overflow-y-auto">
            <div class="container mx-auto px-4 py-6">
                {% block content %}
                <!-- Contenu spécifique de la page -->
                {% endblock %}
            </div>
        </main>
    </div>
    
    <!-- Footer (optionnel) -->
    {% block footer %}
    <footer class="bg-base-200 border-t border-base-300 py-4 mt-8">
        <div class="container mx-auto px-4 text-center text-base-content text-sm">
            <p>{{ app_name }} {{ app_version }}{% if app_domain != "" %} - {{ app_domain }}{% endif %}</p>
            {% if app_description != "" %}
            <p class="text-base-content/70 mt-1">{{ app_description }}</p>
            {% endif %}
            {% include "components/analytics_opt_out.html" %}
        </div>
    </footer>
    {% endblock %}
    
    <!-- Scripts additionnels -->
    {% block scripts %}{% endblock %}
    
    <!-- Alpine.js initialization check and fallback -->
    <script>
        // Vérifier que Alpine.js est chargé
        document.addEventListener('DOMContentLoaded', function() {
            // Attendre un peu pour qu'Alpine.js s'initialise
            setTimeout(function() {
                if (typeof Alpine === 'undefined') {
                    console.warn('Alpine.js n\'est pas chargé. Le dropdown utilisateur pourrait ne pas fonctionner.');
                }
            }, 100);
        });
        
        // Language selector functions (Feature 014)
        function getCookie(name) {
            const value = `; ${document.cookie}`;
            const parts = value.split(`; ${name}=`);
            if (parts.length === 2) return parts.pop().split(';').shift();
            return null;
        }
        
        function updateLanguageDisplay() {
            const locale = getCookie('locale') || 'fr';
            const langNames = {
                'fr': 'FR',
                'en': 'EN',
                'de': 'DE',
                'es': 'ES'
            };
            const displayName = langNames[locale] || 'FR';
            
            // Update desktop display
            const desktopDisplay = document.getElementById('navbar-lang-display');
            if (desktopDisplay) {
                desktopDisplay.textContent = displayName;
            }
            
            // Update mobile display
            const mobileDisplay = document.getElementById('navbar-lang-display-mobile');
            if (mobileDisplay) {
                mobileDisplay.textContent = displayName;
            }
            
            // Update active states
            ['fr', 'en', 'de', 'es'].forEach(code => {
                const desktopLink = document.getElementById('lang-' + code);
                const mobileLink = document.getElementById('lang-' + code + '-mobile');
                
                if (desktopLink) {
                    if (code === locale) {
                        desktopLink.classList.add('active');
                    } else {
                        desktopLink.classList.remove('active');
                    }
                }
                
                if (mobileLink) {
                    if (code === locale) {
                        mobileLink.classList.add('active');
                    } else {
                        mobileLink.classList.remove('active');
                    }
                }
            });
        }
        
        window.changeLanguage = function(locale) {
            if (!['fr', 'en', 'de', 'es'].includes(locale)) {
                return false;
            }
            
            document.cookie = 'locale=' + locale + '; path=/; max-age=31536000; SameSite=Lax';
            
            fetch('/settings/preferences', {
                method: 'PUT',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': '{{ csrf_token }}' },
                credentials: 'include',
                body: 'language=' + encodeURIComponent(locale)
            }).then(function() {
                window.location.reload();
            }).catch(function() {
                window.location.reload();
            });
            
            return false; // Prevent default link behavior
        };
        
        // Initialize language display on page load
        document.addEventListener('DOMContentLoaded', function() {
            updateLanguageDisplay();
        });

        // --- Auto-refresh JWT (Remember Me) ---
        // Silently refreshes the JWT access token before it expires,
        // so users with "remember me" keep their session alive.
        (function() {
            var refreshToken = localStorage.getItem('refresh_token');
            var rememberMe = localStorage.getItem('remember_me');
            if (!refreshToken || rememberMe !== 'true') return;

            // JWT lifetime in seconds (stored at login, default 15 min = 900s)
            var expiresIn = parseInt(localStorage.getItem('jwt_expires_in') || '900', 10);
            // Refresh 2 minutes before expiration (minimum 30s)
            var intervalMs = Math.max((expiresIn - 120), 30) * 1000;

            function doRefresh() {
                var token = localStorage.getItem('refresh_token');
                if (!token || localStorage.getItem('remember_me') !== 'true') return;

                fetch('/api/v1/auth/refresh', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    credentials: 'include',
                    body: JSON.stringify({ refresh_token: token })
                })
                .then(function(response) {
                    if (response.ok) return response.json();
                    throw new Error('refresh_failed');
                })
                .then(function(data) {
                    // Rotate refresh token
                    if (data.refresh_token) {
                        localStorage.setItem('refresh_token', data.refresh_token);
                    }
                    // Update interval if server reports different expires_in
                    if (data.expires_in) {
                        localStorage.setItem('jwt_expires_in', String(data.expires_in));
                    }
                })
                .catch(function() {
                    // Token expired or revoked — clean up and redirect to login
                    localStorage.removeItem('refresh_token');
                    localStorage.removeItem('remember_me');
                    localStorage.removeItem('jwt_expires_in');
                    window.location.href = '/login';
                });
            }

            setInterval(doRefresh, intervalMs);
        })();
    </script>
</body>
</html>