feat: implement dark mode with theme toggle and persistence

This commit is contained in:
Reviflow Bot committed 2026-02-05 21:26:09 +01:00
1 parent 7a816c8230
commit e7e5382de4
7 files changed
+163 -69

No files matched your search

+3
View File
@@ -14,6 +14,7 @@ import { ParentalGate } from './features/auth/ParentalGate';
import { ProfileSelectionPage } from './features/auth/ProfileSelectionPage';
import { ParentHubPage } from './pages/ParentHubPage';
import { HistoryPage } from './features/quiz/HistoryPage';
import { useThemeEffect } from './stores/useTheme';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -74,6 +75,8 @@ function ParentalProtectedRoute({ children }: { children: React.ReactNode }) {
}
function App() {
useThemeEffect();
return (
<BrowserRouter>
<Routes>
+21
View File
@@ -0,0 +1,21 @@
import { useTheme } from '../stores/useTheme';
import { Sun, Moon } from 'lucide-react';
export function ThemeToggle() {
const { isDark, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-bold transition-all ${isDark
? 'bg-slate-800 text-yellow-400 border-slate-700 hover:bg-slate-700'
: 'bg-white text-indigo-900 border-gray-200 hover:bg-gray-50'
}`}
title={isDark ? "Passer en mode clair" : "Passer en mode sombre"}
aria-label={isDark ? "Passer en mode clair" : "Passer en mode sombre"}
>
{isDark ? <Moon className="w-4 h-4 fill-current" /> : <Sun className="w-4 h-4 fill-current" />}
<span className="hidden sm:inline">{isDark ? 'Sombre' : 'Clair'}</span>
</button>
);
}
+20 -4
View File
@@ -11,16 +11,32 @@
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Theme Tokens */
--bg-primary: #FDFCF8;
--bg-secondary: #F8FAFC;
--bg-card: #FFFFFF;
--text-primary: #1E293B;
--text-secondary: #64748B;
--border-color: #E2E8F0;
}
.dark {
--bg-primary: #0F172A; /* slate-900 */
--bg-secondary: #020617; /* slate-950 */
--bg-card: #1E293B; /* slate-800 */
--text-primary: #F1F5F9; /* slate-100 */
--text-secondary: #94A3B8; /* slate-400 */
--border-color: #334155; /* slate-700 */
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background-color: #FDFCF8;
/* cream */
color: #1E293B;
/* slate-800 */
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
@font-face {
+51 -45
View File
@@ -9,6 +9,7 @@ import { RecentRevisionsWidget } from '../features/dashboard/RecentRevisionsWidg
import { LogOut, Flame } from 'lucide-react';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ThemeToggle } from '../components/ThemeToggle';
// --- Avatar Configuration (Original Fun-Emoji Style) ---
const AVATAR_SEEDS = [
@@ -347,7 +348,7 @@ export default function DashboardPage() {
const currentAvatarUrl = activeLearner?.avatar_url;
return (
<div className="min-h-screen bg-[#f8fafc] flex flex-col font-sans text-gray-900 overflow-x-hidden">
<div className="min-h-screen bg-[#f8fafc] dark:bg-slate-900 flex flex-col font-sans text-gray-900 dark:text-gray-100 transition-colors duration-300 overflow-x-hidden">
<AvatarSelector isOpen={isAvatarPickerOpen} onClose={() => setIsAvatarPickerOpen(false)} />
<BadgeGalleryModal
isOpen={isBadgeGalleryOpen}
@@ -355,71 +356,76 @@ export default function DashboardPage() {
unlockedBadges={unlockedBadgeCodes}
/>
{/* Navigation */}
<nav className="bg-white/80 backdrop-blur-md border-b border-gray-200 sticky top-0 z-40">
<nav className="bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border-b border-gray-200 dark:border-slate-800 sticky top-0 z-40 transition-colors duration-300">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center gap-6">
<Link to="/dashboard" className="flex items-center gap-3">
<img src="/logo.png" alt="Reviflow" className="w-8 h-8 rounded-lg shadow-sm object-cover bg-white" />
<span className="text-lg font-bold tracking-tight text-gray-900">Reviflow</span>
<span className="text-lg font-bold tracking-tight text-gray-900 dark:text-white">Reviflow</span>
</Link>
<div className="hidden sm:flex items-center space-x-1">
<Link to="/dashboard" className="px-3 py-2 text-gray-900 bg-gray-100/50 rounded-md text-sm font-medium">
<Link to="/dashboard" className="px-3 py-2 text-gray-900 dark:text-white bg-gray-100/50 dark:bg-slate-800/50 rounded-md text-sm font-medium">
Tableau de bord
</Link>
{user?.role === 'parent' && (
<>
<Link to="/parent-hub" className="px-3 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-50 rounded-md text-sm font-medium transition-colors">
Espace Parent
</Link>
</>
)}
{user?.role === 'parent' && (
<>
<Link to="/parent-hub" className="px-3 py-2 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-slate-800 rounded-md text-sm font-medium transition-colors">
Espace Parent
</Link>
</>
)}
</div>
</div>
</div>
{/* Center: Date/Time */}
<div className="hidden lg:flex flex-col items-center justify-center">
<span className="text-sm font-black text-gray-900 capitalize">
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
</span>
<span className="text-xs font-bold text-indigo-500 bg-indigo-50 px-2 py-0.5 rounded-full">
{new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
{/* Center: Date/Time */}
<div className="hidden lg:flex flex-col items-center justify-center">
<span className="text-sm font-black text-gray-900 dark:text-gray-100 capitalize">
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
</span>
<span className="text-xs font-bold text-indigo-500 bg-indigo-50 dark:bg-indigo-900/30 dark:text-indigo-400 px-2 py-0.5 rounded-full">
{new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="flex items-center gap-4">
{/* Dyslexia Toggle */}
<DyslexiaToggle />
<div className="flex items-center gap-4">
{/* Theme Toggle */}
<ThemeToggle />
<div className="h-6 w-px bg-gray-200 mx-1"></div>
{/* Dyslexia Toggle */}
<DyslexiaToggle />
{/* Profile Info */}
<div className="flex items-center gap-3">
<div className="hidden md:block text-right">
<p className="text-sm font-bold text-gray-900 leading-none">
{activeLearner?.first_name || user?.first_name}
</p>
<div className="h-6 w-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
{/* Profile Info */}
<div className="flex items-center gap-3">
<div className="hidden md:block text-right">
<p className="text-sm font-bold text-gray-900 dark:text-white leading-none">
{activeLearner?.first_name || user?.first_name}
</p>
</div>
<button
onClick={() => user?.role === 'learner' && setIsAvatarPickerOpen(true)}
className="outline-none"
disabled={user?.role !== 'learner'}
>
<AvatarDisplay url={currentAvatarUrl || null} />
</button>
</div>
<button
onClick={() => user?.role === 'learner' && setIsAvatarPickerOpen(true)}
className="outline-none"
disabled={user?.role !== 'learner'}
onClick={logout}
className="p-2 text-gray-400 hover:text-red-900 transition-colors"
title="Se déconnecter"
>
<AvatarDisplay url={currentAvatarUrl || null} />
<LogOut className="w-4 h-4 ml-1" />
</button>
</div>
<button
onClick={logout}
className="p-2 text-gray-400 hover:text-red-900 transition-colors"
title="Se déconnecter"
>
<LogOut className="w-4 h-4 ml-1" />
</button>
</div>
</div>
</div>
</nav>
{/* Main Content */}
@@ -431,7 +437,7 @@ export default function DashboardPage() {
<div>
<div className="flex flex-wrap gap-2 mb-2">
{activeLearner && (activeLearner.streak_current || 0) > 0 && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 bg-orange-50 border border-orange-100 rounded-full text-orange-700">
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 border border-orange-100 dark:border-orange-800 rounded-full text-orange-700 dark:text-orange-400">
<Flame className="w-3 h-3 fill-orange-500 text-orange-500" />
<span className="text-[10px] font-bold uppercase tracking-wide">
{activeLearner.streak_current} jours
@@ -439,10 +445,10 @@ export default function DashboardPage() {
</div>
)}
</div>
<h2 className="text-3xl font-bold text-gray-900 tracking-tight">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
Bonjour {activeLearner?.first_name}.
</h2>
<p className="text-gray-500 mt-1">Prêt à avancer sur tes objectifs ?</p>
<p className="text-gray-500 dark:text-gray-400 mt-1">Prêt à avancer sur tes objectifs ?</p>
</div>
<div className="flex gap-3">
+24 -20
View File
@@ -5,6 +5,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { api } from '../lib/api';
import { MasteryWidget } from '../features/dashboard/MasteryWidget';
import { ActivityTimeline } from '../features/dashboard/ActivityTimeline';
import { ThemeToggle } from '../components/ThemeToggle';
export const ParentHubPage = () => {
const [isCreating, setIsCreating] = useState(false);
@@ -70,9 +71,9 @@ export const ParentHubPage = () => {
};
return (
<div className="min-h-screen bg-[#FDFCF8] flex flex-col">
<div className="min-h-screen bg-[#FDFCF8] dark:bg-slate-900 flex flex-col transition-colors duration-300">
{/* Navigation */}
<nav className="bg-white border-b border-gray-100 sticky top-0 z-40 shadow-sm/5">
<nav className="bg-white dark:bg-slate-900 border-b border-gray-100 dark:border-slate-800 sticky top-0 z-40 shadow-sm/5">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
@@ -82,7 +83,7 @@ export const ParentHubPage = () => {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<span className="text-xl font-black tracking-tight text-gray-900">Reviflow</span>
<span className="text-xl font-black tracking-tight text-gray-900 dark:text-white">Reviflow</span>
</Link>
<div className="hidden sm:ml-8 sm:flex sm:space-x-4">
@@ -90,7 +91,7 @@ export const ParentHubPage = () => {
<Users className="w-4 h-4" />
Espace Parent
</Link>
<Link to="/settings" className="flex items-center gap-2 px-3 py-2 text-gray-500 hover:text-indigo-600 hover:bg-gray-50 rounded-lg text-sm font-medium transition-all">
<Link to="/settings" className="flex items-center gap-2 px-3 py-2 text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-slate-800 rounded-lg text-sm font-medium transition-all">
<SettingsIcon className="w-4 h-4" />
Paramètres
</Link>
@@ -98,9 +99,12 @@ export const ParentHubPage = () => {
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 pl-4 border-l border-gray-100">
{/* Theme Toggle */}
<ThemeToggle />
<div className="flex items-center gap-3 pl-4 border-l border-gray-100 dark:border-gray-800">
<div className="text-right">
<p className="text-sm font-bold text-gray-900 leading-none mb-1">{user?.first_name || 'Parent'}</p>
<p className="text-sm font-bold text-gray-900 dark:text-white leading-none mb-1">{user?.first_name || 'Parent'}</p>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Administrateur</p>
</div>
</div>
@@ -117,50 +121,50 @@ export const ParentHubPage = () => {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
<header className="mb-10">
<h1 className="text-4xl font-black text-slate-900 tracking-tight">Bonjour, {user?.first_name || 'Parent'} ! 👋</h1>
<p className="text-slate-500 mt-2 text-lg font-medium">Suivez les progrès de vos apprentis en toute sérénité.</p>
<h1 className="text-4xl font-black text-slate-900 dark:text-white tracking-tight">Bonjour, {user?.first_name || 'Parent'} ! 👋</h1>
<p className="text-slate-500 dark:text-slate-400 mt-2 text-lg font-medium">Suivez les progrès de vos apprentis en toute sérénité.</p>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<div className="bg-white p-6 rounded-[2rem] shadow-xl shadow-slate-200/50 border border-slate-50">
<div className="bg-white dark:bg-slate-800 p-6 rounded-[2rem] shadow-xl shadow-slate-200/50 dark:shadow-slate-900/50 border border-slate-50 dark:border-slate-700">
<div className="flex items-center space-x-4">
<div className="p-4 bg-indigo-50 rounded-2xl text-indigo-600">
<Users className="h-6 w-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Apprentis</p>
<p className="text-3xl font-black text-slate-900">{learnerProfiles.length}</p>
<p className="text-3xl font-black text-slate-900 dark:text-white">{learnerProfiles.length}</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] shadow-xl shadow-slate-200/50 border border-slate-50">
<div className="bg-white dark:bg-slate-800 p-6 rounded-[2rem] shadow-xl shadow-slate-200/50 dark:shadow-slate-900/50 border border-slate-50 dark:border-slate-700">
<div className="flex items-center space-x-4">
<div className="p-4 bg-amber-50 rounded-2xl text-amber-600">
<TrendingUp className="h-6 w-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Jetons AI</p>
<p className="text-3xl font-black text-slate-900">{stats.total_tokens.toLocaleString()}</p>
<p className="text-3xl font-black text-slate-900 dark:text-white">{stats.total_tokens.toLocaleString()}</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] shadow-xl shadow-slate-200/50 border border-slate-50">
<div className="bg-white dark:bg-slate-800 p-6 rounded-[2rem] shadow-xl shadow-slate-200/50 dark:shadow-slate-900/50 border border-slate-50 dark:border-slate-700">
<div className="flex items-center space-x-4">
<div className="p-4 bg-emerald-50 rounded-2xl text-emerald-600">
<div className="h-6 w-6 font-black flex items-center justify-center">€</div>
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Coût estimé</p>
<p className="text-3xl font-black text-slate-900">{stats.total_cost.toFixed(2)}</p>
<p className="text-3xl font-black text-slate-900 dark:text-white">{stats.total_cost.toFixed(2)}</p>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-black text-slate-900 tracking-tight border-b-4 border-indigo-100 pb-1">Mes Apprentis</h2>
<h2 className="text-2xl font-black text-slate-900 dark:text-white tracking-tight border-b-4 border-indigo-100 dark:border-indigo-900 pb-1">Mes Apprentis</h2>
<button
onClick={() => setIsCreating(true)}
className="inline-flex items-center px-6 py-3 bg-indigo-600 text-white font-bold rounded-2xl hover:bg-indigo-700 transition-all shadow-xl shadow-indigo-100 hover:-translate-y-1 active:scale-95"
@@ -172,7 +176,7 @@ export const ParentHubPage = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{learnerProfiles.map(child => (
<div key={child.id} className="bg-white rounded-[2.5rem] p-8 shadow-xl shadow-slate-200/50 border border-slate-50 hover:shadow-2xl hover:shadow-indigo-100 transition-all group">
<div key={child.id} className="bg-white dark:bg-slate-800 rounded-[2.5rem] p-8 shadow-xl shadow-slate-200/50 dark:shadow-slate-900/50 border border-slate-50 dark:border-slate-700 hover:shadow-2xl hover:shadow-indigo-100 dark:hover:shadow-indigo-900/20 transition-all group">
<div className="flex items-center space-x-5 mb-6">
<div className="h-16 w-16 rounded-[1.5rem] bg-indigo-50 border-2 border-white shadow-md flex items-center justify-center text-2xl font-black text-indigo-400 overflow-hidden group-hover:scale-110 transition-transform">
{child.avatar_url && child.avatar_url.startsWith('http') ? (
@@ -182,11 +186,11 @@ export const ParentHubPage = () => {
)}
</div>
<div>
<h3 className="text-xl font-black text-slate-900">{child.first_name}</h3>
<h3 className="text-xl font-black text-slate-900 dark:text-white">{child.first_name}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Niveau {child.level || 1}</p>
</div>
</div>
<div className="space-y-4 pt-6 border-t border-slate-100">
<div className="space-y-4 pt-6 border-t border-slate-100 dark:border-slate-700">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-slate-500">Série actuelle</span>
<span className="px-3 py-1 bg-orange-50 text-orange-600 text-xs font-black rounded-full border border-orange-100">
@@ -195,7 +199,7 @@ export const ParentHubPage = () => {
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-slate-500 font-medium">Expérience</span>
<span className="font-bold text-slate-900">{child.xp || 0} XP</span>
<span className="font-bold text-slate-900 dark:text-slate-200">{child.xp || 0} XP</span>
</div>
<button
onClick={() => setSelectedChildId(child.id)}
@@ -323,7 +327,7 @@ export const ParentHubPage = () => {
)}
</main>
<footer className="mt-auto py-12 border-t border-slate-100 bg-white">
<footer className="mt-auto py-12 border-t border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="max-w-7xl mx-auto px-8 flex flex-col md:flex-row justify-between items-center gap-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-200 rounded-lg"></div>
+43
View File
@@ -0,0 +1,43 @@
import { create } from 'zustand';
import { useEffect } from 'react';
interface ThemeState {
isDark: boolean;
toggleTheme: () => void;
setTheme: (isDark: boolean) => void;
}
export const useTheme = create<ThemeState>((set) => {
// 1. Check local storage or system preference on initialization
const stored = localStorage.getItem('theme-mode');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialIsDark = stored ? stored === 'dark' : systemPrefersDark;
return {
isDark: initialIsDark,
toggleTheme: () => set((state) => {
const newIsDark = !state.isDark;
localStorage.setItem('theme-mode', newIsDark ? 'dark' : 'light');
return { isDark: newIsDark };
}),
setTheme: (isDark) => set(() => {
localStorage.setItem('theme-mode', isDark ? 'dark' : 'light');
return { isDark };
})
};
});
// Hook to handle the side effect of applying the class to HTML/Body
export function useThemeEffect() {
const isDark = useTheme((state) => state.isDark);
useEffect(() => {
const root = window.document.documentElement;
if (isDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, [isDark]);
}
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",