Fix de la sauvegarde du RTE avec la possibilité de faire un localStorage

This commit is contained in:
2026-03-05 11:02:26 +01:00
parent 29469041e0
commit 585e608d8d
25 changed files with 1088 additions and 289 deletions

View File

@@ -16,6 +16,7 @@ export default function WritePage() {
return (
<RichTextEditor
ref={editorRef}
editorId={currentChapterId}
initialContent={currentChapter?.content || ''}
onSave={(html) => updateChapter(currentChapterId, { content: html })}
onAiTransform={async (text, mode) => {

View File

@@ -3,7 +3,7 @@
import React, { useRef, useEffect, useState, useImperativeHandle, forwardRef, useMemo } from 'react';
import {
Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, List, Heading1, Heading2,
Copy, Wand2, Check, RefreshCw, Maximize2, Loader2, MousePointerClick, History, RotateCcw,
Copy, Wand2, Check, CheckCheck, RefreshCw, Maximize2, Loader2, MousePointerClick, History, RotateCcw,
ChevronDown, ChevronUp, Layers
} from 'lucide-react';
@@ -12,6 +12,7 @@ export interface RichTextEditorHandle {
}
interface RichTextEditorProps {
editorId?: string; // Used to uniquely identify the draft in localStorage
initialContent: string;
onChange?: (html: string) => void;
onSave?: (html: string) => void;
@@ -34,13 +35,13 @@ interface VersionGroup {
versions: Version[];
}
const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({ initialContent, onChange, onSave, onSelectionChange, onAiTransform }, ref) => {
const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({ editorId, initialContent, onChange, onSave, onSelectionChange, onAiTransform }, ref) => {
const contentRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
// Auto-Save State
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved');
const [saveStatus, setSaveStatus] = useState<'saved_local' | 'saved_db' | 'saving' | 'unsaved'>('saved_db');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Track sync state to avoid autosave loopbacks wiping current edits
@@ -168,8 +169,21 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({
useEffect(() => {
if (!contentRef.current || initialContent === undefined) return;
let contentToLoad = initialContent;
let hasLocalDraft = false;
// Check localStorage for a newer draft
if (editorId) {
const localDraft = localStorage.getItem(`draft_${editorId}`);
if (localDraft && localDraft !== initialContent) {
contentToLoad = localDraft;
hasLocalDraft = true;
setSaveStatus('saved_local');
}
}
// 1. Si le contenu entrant est identique à ce qu'on a déjà, on ne touche à rien
if (initialContent === contentRef.current.innerHTML) return;
if (contentToLoad === contentRef.current.innerHTML) return;
// 2. LOGIQUE CRUCIALE : On ne met à jour le DOM que si :
// - L'éditeur est vide (premier chargement)
@@ -177,12 +191,12 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({
// - OU si l'utilisateur n'est PAS en train de focus l'éditeur
const isUserEditing = document.activeElement === contentRef.current;
if (!isUserEditing || (contentRef.current.innerHTML === "" && initialContent !== "")) {
contentRef.current.innerHTML = initialContent;
syncRef.current = initialContent;
latestContentRef.current = initialContent;
if (!isUserEditing || (contentRef.current.innerHTML === "" && contentToLoad !== "")) {
contentRef.current.innerHTML = contentToLoad;
syncRef.current = contentToLoad;
latestContentRef.current = contentToLoad;
}
}, [initialContent]);
}, [initialContent, editorId]);
// Flush pending save on unmount
useEffect(() => {
@@ -213,9 +227,16 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({
if (onChange) onChange(currentHtml);
// Auto-Save Debounce
if (onSave) {
// 1. Save locally immediately
if (editorId) {
localStorage.setItem(`draft_${editorId}`, currentHtml);
setSaveStatus('saved_local');
} else {
setSaveStatus('unsaved');
}
// 2. Auto-Save Debounce for DB
if (onSave) {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(async () => {
@@ -227,10 +248,15 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({
syncRef.current = htmlToSave;
try {
await onSave(htmlToSave);
setSaveStatus('saved_db');
if (editorId) {
// Once saved to DB, we can consider the local draft synced if we want,
// or just keep it there. It will be overwritten on next load.
}
} catch (err) {
console.error('Auto-save failed:', err);
setSaveStatus('saved_local'); // Revert to local save status on error
}
setSaveStatus('saved');
}, 2000); // 2 seconds
}
}
@@ -384,9 +410,10 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({
{/* Save Status Indicator */}
<div className="flex items-center gap-2 mr-4 text-xs font-medium text-slate-400">
{saveStatus === 'saving' && <><Loader2 size={12} className="animate-spin" /> Sauvegarde...</>}
{saveStatus === 'saved' && <><Check size={12} className="text-green-500" /> Sauvegardé</>}
{saveStatus === 'unsaved' && <span className="text-amber-500">Modifications non enregistrées...</span>}
{saveStatus === 'saving' && <><Loader2 size={12} className="animate-spin text-blue-500" /> <span className="text-blue-500 hidden sm:inline">Sauvegarde en cours...</span></>}
{saveStatus === 'saved_local' && <><Check size={14} className="text-green-500" /> <span className="text-green-500 hidden sm:inline">Brouillon local</span></>}
{saveStatus === 'saved_db' && <><CheckCheck size={14} className="text-emerald-600" /> <span className="text-emerald-600 hidden sm:inline">Sauvegardé</span></>}
{saveStatus === 'unsaved' && <span className="text-amber-500">Non sauvegardé...</span>}
</div>
<div className="w-px h-6 bg-slate-300 mx-1" />

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { UserProfile, UserPreferences } from '@/lib/types';
import { User, Settings, Globe, Shield, Bell, Save, Camera, Target, Flame, Layout } from 'lucide-react';
import { useLanguage } from '@/providers/LanguageProvider';
@@ -30,9 +30,38 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
bio: user.bio || '',
email: user.email,
theme: user.preferences.theme,
dailyWordGoal: user.preferences.dailyWordGoal
dailyWordGoal: user.preferences.dailyWordGoal,
customColors: user.preferences.customColors || {
bg: '#1a1b26',
panel: '#24283b',
text: '#c0caf5',
accent: '#7aa2f7'
}
});
// Handle Live Preview of Theme Changes
useEffect(() => {
const root = document.documentElement;
root.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-custom');
root.classList.add(`theme-${formData.theme}`);
if (formData.theme === 'custom') {
root.style.setProperty('--theme-bg', formData.customColors.bg);
root.style.setProperty('--theme-panel', formData.customColors.panel);
root.style.setProperty('--theme-text', formData.customColors.text);
root.style.setProperty('--theme-border', formData.customColors.text + '20');
root.style.setProperty('--theme-muted', formData.customColors.text + '99');
root.style.setProperty('--theme-accent', formData.customColors.accent);
} else {
root.style.removeProperty('--theme-bg');
root.style.removeProperty('--theme-panel');
root.style.removeProperty('--theme-text');
root.style.removeProperty('--theme-border');
root.style.removeProperty('--theme-muted');
root.style.removeProperty('--theme-accent');
}
}, [formData.theme, formData.customColors]);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -83,22 +112,29 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
preferences: {
...user.preferences,
theme: formData.theme,
dailyWordGoal: formData.dailyWordGoal
dailyWordGoal: formData.dailyWordGoal,
customColors: formData.customColors
}
});
localStorage.setItem('plumeia_theme', formData.theme);
if (formData.theme === 'custom') {
localStorage.setItem('plumeia_custom_colors', JSON.stringify(formData.customColors));
}
alert(t('profile.saved_success') || "Profil mis à jour !");
};
const isDark = formData.theme === 'dark';
const isSepia = formData.theme === 'sepia';
const isCustom = formData.theme === 'custom';
const themeOuterClass = isDark ? 'bg-slate-900 text-white' : isSepia ? 'bg-[#eaddc4] text-[#433422]' : 'bg-slate-50 text-slate-900';
const themeInnerClass = isDark ? 'bg-slate-800 border-slate-700' : isSepia ? 'bg-[#f4ecd8] border-[#dfcdae]' : 'bg-white border-slate-200';
const themeTextHeading = isDark ? 'text-white' : isSepia ? 'text-[#332616]' : 'text-slate-900';
const themeTextMuted = isDark ? 'text-slate-400' : isSepia ? 'text-[#735e44]' : 'text-slate-500';
const themeInputBg = isDark ? 'bg-slate-900 border-slate-700 text-white' : isSepia ? 'bg-[#fbf8f1] border-[#eaddc4] text-[#433422]' : 'bg-slate-50 border-slate-200 text-slate-900';
const themeTabActive = isDark ? 'bg-white text-slate-900 shadow-lg' : isSepia ? 'bg-[#5c4731] text-white shadow-lg' : 'bg-slate-900 text-white shadow-lg';
const themeTabInactive = isDark ? 'text-slate-400 hover:bg-slate-800 hover:text-white' : isSepia ? 'text-[#735e44] hover:bg-[#eaddc4] hover:text-[#332616]' : 'text-slate-500 hover:bg-white hover:text-slate-900';
const themeOuterClass = isCustom ? 'bg-theme-bg text-theme-text' : isDark ? 'bg-slate-900 text-white' : isSepia ? 'bg-[#eaddc4] text-[#433422]' : 'bg-slate-50 text-slate-900';
const themeInnerClass = isCustom ? 'bg-theme-panel border-theme-border' : isDark ? 'bg-slate-800 border-slate-700' : isSepia ? 'bg-[#f4ecd8] border-[#dfcdae]' : 'bg-white border-slate-200';
const themeTextHeading = isCustom ? 'text-theme-text' : isDark ? 'text-white' : isSepia ? 'text-[#332616]' : 'text-slate-900';
const themeTextMuted = isCustom ? 'text-theme-muted' : isDark ? 'text-slate-400' : isSepia ? 'text-[#735e44]' : 'text-slate-500';
const themeInputBg = isCustom ? 'bg-theme-bg border-theme-border text-theme-text' : isDark ? 'bg-slate-900 border-slate-700 text-white' : isSepia ? 'bg-[#fbf8f1] border-[#eaddc4] text-[#433422]' : 'bg-slate-50 border-slate-200 text-slate-900';
const themeTabActive = isCustom ? 'bg-theme-text text-theme-bg shadow-lg' : isDark ? 'bg-white text-slate-900 shadow-lg' : isSepia ? 'bg-[#5c4731] text-white shadow-lg' : 'bg-slate-900 text-white shadow-lg';
const themeTabInactive = isCustom ? 'text-theme-muted hover:bg-theme-panel hover:text-theme-text' : isDark ? 'text-slate-400 hover:bg-slate-800 hover:text-white' : isSepia ? 'text-[#735e44] hover:bg-[#eaddc4] hover:text-[#332616]' : 'text-slate-500 hover:bg-white hover:text-slate-900';
return (
<div className={`h-screen overflow-y-auto p-8 font-sans ${themeOuterClass}`}>
@@ -141,7 +177,7 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
<div className={`flex-1 rounded-2xl shadow-sm border p-8 ${themeInnerClass}`}>
{activeTab === 'profile' && (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className={`flex items-center gap-6 pb-8 border-b ${isDark ? 'border-slate-700' : isSepia ? 'border-[#dfcdae]' : 'border-slate-100'}`}>
<div className={`flex items-center gap-6 pb-8 border-b ${isCustom ? 'border-theme-border' : isDark ? 'border-slate-700' : isSepia ? 'border-[#dfcdae]' : 'border-slate-100'}`}>
<div className="relative group cursor-pointer" onClick={() => fileInputRef.current?.click()}>
<input
type="file"
@@ -150,7 +186,7 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
accept="image/*"
className="hidden"
/>
<img src={formData.avatar || 'https://via.placeholder.com/150'} className={`w-24 h-24 rounded-full object-cover border-4 shadow-md ${isDark ? 'border-slate-800' : isSepia ? 'border-[#f4ecd8]' : 'border-slate-50'}`} alt="Avatar" />
<img src={formData.avatar || 'https://via.placeholder.com/150'} className={`w-24 h-24 rounded-full object-cover border-4 shadow-md ${isCustom ? 'border-theme-panel' : isDark ? 'border-slate-800' : isSepia ? 'border-[#f4ecd8]' : 'border-slate-50'}`} alt="Avatar" />
<div className="absolute inset-0 bg-black/40 text-white rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity" title={t('profile.change_avatar')}>
<Camera size={20} />
</div>
@@ -212,18 +248,42 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
<label className={`text-xs font-black uppercase tracking-widest flex items-center gap-2 ${themeTextMuted}`}>
{t('profile.editor_theme')}
</label>
<div className="grid grid-cols-3 gap-3">
{['light', 'sepia', 'dark'].map((t) => (
<div className="grid grid-cols-4 gap-3">
{['light', 'sepia', 'dark', 'custom'].map((t) => (
<button
key={t}
onClick={() => setFormData({ ...formData, theme: t as any })}
className={`p-4 rounded-xl border-2 transition-all flex flex-col items-center gap-2 ${formData.theme === t ? 'border-blue-500 bg-blue-50 text-blue-700' : isDark ? 'border-slate-700 hover:border-slate-600' : isSepia ? 'border-[#dfcdae] hover:border-[#cfbd9e]' : 'border-slate-100 hover:border-slate-200'}`}
className={`p-4 rounded-xl border-2 transition-all flex flex-col items-center gap-2 ${formData.theme === t ? isCustom ? 'border-theme-text text-theme-text' : 'border-blue-500 bg-blue-50 text-blue-700' : isCustom ? 'border-theme-border text-theme-muted hover:border-theme-text' : isDark ? 'border-slate-700 hover:border-slate-600' : isSepia ? 'border-[#dfcdae] hover:border-[#cfbd9e]' : 'border-slate-100 hover:border-slate-200'}`}
>
<div className={`w-8 h-8 rounded-full border border-slate-200 ${t === 'light' ? 'bg-white' : t === 'sepia' ? 'bg-[#f4ecd8]' : 'bg-slate-900'}`} />
<div className={`w-8 h-8 rounded-full border border-slate-200 ${t === 'light' ? 'bg-white' : t === 'sepia' ? 'bg-[#f4ecd8]' : t === 'custom' ? 'bg-gradient-to-tr from-pink-500 via-purple-500 to-indigo-500 border-none' : 'bg-slate-900'}`} />
<span className={`text-[10px] font-bold uppercase ${formData.theme !== t ? themeTextMuted : ''}`}>{t}</span>
</button>
))}
</div>
{formData.theme === 'custom' && (
<div className={`mt-6 space-y-4 pt-4 border-t ${isCustom ? 'border-theme-border' : 'border-slate-100'}`}>
<h4 className={`text-sm font-bold ${themeTextHeading}`}>Couleurs Personnalisées</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className={`flex items-center justify-between p-3 rounded-xl border ${themeInputBg}`}>
<label className={`text-xs font-bold uppercase ${themeTextMuted}`}>Arrière-plan</label>
<input type="color" value={formData.customColors.bg} onChange={(e) => setFormData({ ...formData, customColors: { ...formData.customColors, bg: e.target.value } })} className="w-8 h-8 rounded shrink-0 cursor-pointer" />
</div>
<div className={`flex items-center justify-between p-3 rounded-xl border ${themeInputBg}`}>
<label className={`text-xs font-bold uppercase ${themeTextMuted}`}>Panneaux</label>
<input type="color" value={formData.customColors.panel} onChange={(e) => setFormData({ ...formData, customColors: { ...formData.customColors, panel: e.target.value } })} className="w-8 h-8 rounded shrink-0 cursor-pointer" />
</div>
<div className={`flex items-center justify-between p-3 rounded-xl border ${themeInputBg}`}>
<label className={`text-xs font-bold uppercase ${themeTextMuted}`}>Texte Principal</label>
<input type="color" value={formData.customColors.text} onChange={(e) => setFormData({ ...formData, customColors: { ...formData.customColors, text: e.target.value } })} className="w-8 h-8 rounded shrink-0 cursor-pointer" />
</div>
<div className={`flex items-center justify-between p-3 rounded-xl border ${themeInputBg}`}>
<label className={`text-xs font-bold uppercase ${themeTextMuted}`}>Détails / Accent</label>
<input type="color" value={formData.customColors.accent} onChange={(e) => setFormData({ ...formData, customColors: { ...formData.customColors, accent: e.target.value } })} className="w-8 h-8 rounded shrink-0 cursor-pointer" />
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -255,10 +315,10 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
</div>
)}
<div className={`mt-12 pt-8 border-t flex justify-end ${isDark ? 'border-slate-700' : isSepia ? 'border-[#dfcdae]' : 'border-slate-100'}`}>
<div className={`mt-12 pt-8 border-t flex justify-end ${isCustom ? 'border-theme-border' : isDark ? 'border-slate-700' : isSepia ? 'border-[#dfcdae]' : 'border-slate-100'}`}>
<button
onClick={handleSave}
className={`px-8 py-3 rounded-xl font-bold flex items-center gap-2 transition-all shadow-xl hover:shadow-blue-200 ${isDark ? 'bg-white text-slate-900 hover:bg-blue-500 hover:text-white' : isSepia ? 'bg-[#5c4731] text-white hover:bg-blue-600' : 'bg-slate-900 text-white hover:bg-blue-600'}`}
className={`px-8 py-3 rounded-xl font-bold flex items-center gap-2 transition-all shadow-xl hover:-translate-y-1 ${isCustom ? 'bg-theme-text text-theme-bg shadow-theme-border' : isDark ? 'bg-white text-slate-900 shadow-slate-900 hover:bg-slate-100' : isSepia ? 'bg-[#5c4731] text-white shadow-[#dfcdae] hover:bg-[#433422]' : 'bg-slate-900 text-white shadow-slate-200 hover:bg-slate-800'}`}
>
<Save size={18} /> {t('profile.save_changes')}
</button>

View File

@@ -155,7 +155,13 @@ export interface UserUsage {
}
export interface UserPreferences {
theme: 'light' | 'dark' | 'sepia';
theme: 'light' | 'dark' | 'sepia' | 'custom';
customColors?: {
bg: string;
panel: string;
text: string;
accent: string;
};
dailyWordGoal: number;
language: 'fr' | 'en';
}

View File

@@ -7,14 +7,50 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuthContext();
useEffect(() => {
if (!user) return;
// Read from localStorage to apply theme instantly across reloads
const savedTheme = localStorage.getItem('plumeia_theme');
const savedColorsStr = localStorage.getItem('plumeia_custom_colors');
const theme = user.preferences?.theme || 'light';
const theme = savedTheme || user?.preferences?.theme || 'light';
const root = document.documentElement;
root.classList.remove('theme-light', 'theme-dark', 'theme-sepia');
root.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-custom');
root.classList.add(`theme-${theme}`);
}, [user?.preferences?.theme]);
if (theme === 'custom') {
let colors = user?.preferences?.customColors || {
bg: '#ffffff',
panel: '#f8fafc',
text: '#0f172a',
accent: '#3b82f6'
};
if (savedColorsStr) {
try {
colors = JSON.parse(savedColorsStr);
} catch (e) {
console.error("Failed to parse custom colors", e);
}
}
root.style.setProperty('--theme-bg', colors.bg);
root.style.setProperty('--theme-panel', colors.panel);
root.style.setProperty('--theme-text', colors.text);
// To ensure UI remains legible, we compute a translucent border by default if not strictly provided
root.style.setProperty('--theme-border', colors.text + '20'); // 20% opacity of text color
root.style.setProperty('--theme-muted', colors.text + '99'); // 60% opacity of text color
root.style.setProperty('--theme-accent', colors.accent);
} else {
root.style.removeProperty('--theme-bg');
root.style.removeProperty('--theme-panel');
root.style.removeProperty('--theme-text');
root.style.removeProperty('--theme-border');
root.style.removeProperty('--theme-muted');
root.style.removeProperty('--theme-accent');
}
}, [user?.preferences?.theme, user?.preferences?.customColors]);
return <>{children}</>;
}