authentification nocodebackend ok

This commit is contained in:
2026-02-08 16:12:25 +01:00
commit be5bd2b2bf
37 changed files with 9585 additions and 0 deletions

View File

@@ -0,0 +1,572 @@
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,
ChevronDown, ChevronUp, Layers
} from 'lucide-react';
export interface RichTextEditorHandle {
insertHtml: (html: string) => void;
}
interface RichTextEditorProps {
initialContent: string;
onChange?: (html: string) => void;
onSave?: (html: string) => void;
onSelectionChange?: (text: string) => void;
onAiTransform?: (text: string, mode: 'correct' | 'rewrite' | 'expand' | 'continue') => Promise<string>;
}
interface Version {
id: string;
timestamp: number;
type: string;
content: string; // Full HTML snapshot
snippet: string; // Selected text snippet before change
topOffset: number; // Y position relative to editor top
}
interface VersionGroup {
id: string;
topOffset: number;
versions: Version[];
}
const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({ 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 saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Context Menu State
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [isAiLoading, setIsAiLoading] = useState(false);
// History State
const [versions, setVersions] = useState<Version[]>([]);
const [showHistoryMargin, setShowHistoryMargin] = useState(true);
const [expandedGroupIds, setExpandedGroupIds] = useState<Set<string>>(new Set());
// Refs to track selection
const savedRange = useRef<Range | null>(null);
const lastCursorPosition = useRef<Range | null>(null);
// --- Helpers ---
// Group versions by proximity (within 60px) to stack them
const versionGroups = useMemo(() => {
const sortedVersions = [...versions].sort((a, b) => b.timestamp - a.timestamp);
const groups: VersionGroup[] = [];
sortedVersions.forEach(v => {
// Find an existing group close to this version
const existingGroup = groups.find(g => Math.abs(g.topOffset - v.topOffset) < 60);
if (existingGroup) {
existingGroup.versions.push(v);
// Keep the group timestamp sorted
existingGroup.versions.sort((a, b) => b.timestamp - a.timestamp);
} else {
groups.push({
id: `group-${v.id}`,
topOffset: v.topOffset,
versions: [v]
});
}
});
return groups;
}, [versions]);
const toggleGroup = (groupId: string) => {
const newSet = new Set(expandedGroupIds);
if (newSet.has(groupId)) {
newSet.delete(groupId);
} else {
newSet.add(groupId);
}
setExpandedGroupIds(newSet);
};
const getSelectionTopOffset = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && contentRef.current) {
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
// We need offset relative to the content container (contentRef)
// contentRef is the white page div.
const containerRect = contentRef.current.getBoundingClientRect();
return rect.top - containerRect.top;
}
return 0;
};
const saveVersion = (type: string, textSnippet: string) => {
if (!contentRef.current) return;
const topOffset = getSelectionTopOffset();
const newVersion: Version = {
id: Date.now().toString(),
timestamp: Date.now(),
type: type,
content: contentRef.current.innerHTML,
snippet: textSnippet.substring(0, 80) + (textSnippet.length > 80 ? '...' : ''),
topOffset
};
setVersions(prev => [newVersion, ...prev]);
setShowHistoryMargin(true);
};
const restoreVersion = (version: Version) => {
if (!contentRef.current) return;
if (confirm('Restaurer cette version ? Le contenu actuel sera remplacé.')) {
contentRef.current.innerHTML = version.content;
handleInput();
}
};
// --- Exposed Methods ---
useImperativeHandle(ref, () => ({
insertHtml: (text: string) => {
saveVersion('Insertion Chat', 'Insertion depuis le panneau IA');
contentRef.current?.focus();
const sel = window.getSelection();
if (lastCursorPosition.current) {
sel?.removeAllRanges();
sel?.addRange(lastCursorPosition.current);
} else if (contentRef.current) {
const range = document.createRange();
range.selectNodeContents(contentRef.current);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
}
const htmlToInsert = text.includes('<') ? text : text.replace(/\n/g, '<br>');
document.execCommand('insertHTML', false, htmlToInsert);
handleInput();
}
}));
// --- Effects ---
useEffect(() => {
if (contentRef.current && contentRef.current.innerHTML !== initialContent) {
// Only update if difference is significant to avoid cursor jumps on small re-renders?
// OR better: Only update if NOT focused?
if (!isFocused && Math.abs(contentRef.current.innerHTML.length - initialContent.length) > 5) {
contentRef.current.innerHTML = initialContent;
}
}
}, [initialContent, isFocused]);
// --- Event Handlers ---
const execCommand = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value);
handleInput();
contentRef.current?.focus();
};
const handleInput = () => {
if (contentRef.current) {
if (onChange) onChange(contentRef.current.innerHTML);
// Auto-Save Debounce
if (onSave) {
setSaveStatus('unsaved');
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(async () => {
setSaveStatus('saving');
await onSave(contentRef.current?.innerHTML || "");
setSaveStatus('saved');
}, 2000); // 2 seconds
}
}
};
const saveSelection = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && contentRef.current?.contains(sel.anchorNode)) {
lastCursorPosition.current = sel.getRangeAt(0).cloneRange();
}
};
const handleSelection = () => {
const selection = window.getSelection();
saveSelection();
if (selection && selection.toString().length > 0 && onSelectionChange) {
onSelectionChange(selection.toString());
} else if (onSelectionChange) {
onSelectionChange("");
}
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (contentRef.current?.contains(range.commonAncestorContainer)) {
savedRange.current = range.cloneRange();
setContextMenu({ x: e.clientX, y: e.clientY });
return;
}
}
savedRange.current = null;
setContextMenu({ x: e.clientX, y: e.clientY });
};
const handleAiAction = async (mode: 'correct' | 'rewrite' | 'expand' | 'continue') => {
if (!onAiTransform) return;
const range = savedRange.current;
const text = range?.toString() || "";
if (!text && mode !== 'continue') return;
const typeLabels: Record<string, string> = {
correct: 'Correction',
rewrite: 'Reformulation',
expand: 'Développement',
continue: 'Continuation'
};
saveVersion(typeLabels[mode], text || "Position curseur");
setIsAiLoading(true);
try {
const result = await onAiTransform(text, mode);
if (result) {
contentRef.current?.focus();
const sel = window.getSelection();
sel?.removeAllRanges();
if (range) {
sel?.addRange(range);
}
if (mode === 'continue') {
sel?.collapseToEnd();
document.execCommand('insertText', false, " " + result);
} else {
document.execCommand('insertText', false, result);
}
handleInput();
}
} catch (e) {
console.error("AI Action failed", e);
} finally {
setIsAiLoading(false);
setContextMenu(null);
}
};
const handleCopy = () => {
if (savedRange.current) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange.current);
document.execCommand('copy');
}
setContextMenu(null);
};
const handleSelectAll = () => {
contentRef.current?.focus();
document.execCommand('selectAll');
handleSelection();
setContextMenu(null);
}
const ToolbarButton = ({ icon: Icon, cmd, arg, label, onClick, isActive }: any) => (
<button
onMouseDown={(e) => {
if (onClick) {
e.preventDefault();
onClick();
} else {
e.preventDefault();
execCommand(cmd, arg);
}
}}
className={`p-1.5 rounded transition-colors ${isActive ? 'bg-indigo-100 text-indigo-700' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200'}`}
title={label}
>
<Icon size={18} />
</button>
);
const hasSelection = savedRange.current && !savedRange.current.collapsed;
return (
<div className="flex flex-col h-full bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden relative">
<style>{`
.editor-content:empty::before {
content: attr(data-placeholder);
color: #cbd5e1;
font-style: italic;
cursor: text;
}
`}</style>
{/* Toolbar */}
<div className="flex items-center gap-1 p-2 bg-slate-50 border-b border-slate-200 flex-wrap relative z-20 shadow-sm">
<ToolbarButton icon={Bold} cmd="bold" label="Gras" />
<ToolbarButton icon={Italic} cmd="italic" label="Italique" />
<ToolbarButton icon={Underline} cmd="underline" label="Souligné" />
<div className="w-px h-6 bg-slate-300 mx-1" />
<ToolbarButton icon={Heading1} cmd="formatBlock" arg="H1" label="Titre 1" />
<ToolbarButton icon={Heading2} cmd="formatBlock" arg="H2" label="Titre 2" />
<div className="w-px h-6 bg-slate-300 mx-1" />
<ToolbarButton icon={AlignLeft} cmd="justifyLeft" label="Aligner à gauche" />
<ToolbarButton icon={AlignCenter} cmd="justifyCenter" label="Centrer" />
<ToolbarButton icon={AlignRight} cmd="justifyRight" label="Aligner à droite" />
<div className="w-px h-6 bg-slate-300 mx-1" />
<ToolbarButton icon={List} cmd="insertUnorderedList" label="Liste" />
<div className="flex-1" />
{/* 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>}
</div>
<div className="w-px h-6 bg-slate-300 mx-1" />
<ToolbarButton
icon={History}
label="Marge d'historique"
onClick={() => setShowHistoryMargin(!showHistoryMargin)}
isActive={showHistoryMargin}
/>
</div>
{/* Main Container - Scrollable Area */}
<div
className="flex-1 overflow-y-auto relative bg-slate-100"
ref={scrollContainerRef}
>
<div className="flex justify-center relative min-h-full py-8">
{/* Editor Content Page */}
<div
ref={contentRef}
contentEditable
suppressContentEditableWarning
className="bg-white shadow-sm w-[800px] min-h-[1000px] p-12 outline-none font-serif text-lg leading-relaxed text-slate-900 editor-content"
onInput={handleInput}
onBlur={() => { setIsFocused(false); saveSelection(); }}
onFocus={() => setIsFocused(true)}
onKeyUp={saveSelection}
onMouseUp={saveSelection}
onSelect={handleSelection}
onClick={() => contentRef.current?.focus()}
onContextMenu={handleContextMenu}
data-placeholder="Commencez à écrire votre chef-d'œuvre... (Clic droit pour outils IA)"
/>
{/* History Track - Moving with the page */}
{showHistoryMargin && (
<div className="absolute left-[calc(50%+420px)] top-0 bottom-0 w-80 pt-8 pointer-events-none">
{/* Placeholder for empty history */}
{versionGroups.length === 0 && (
<div className="sticky top-10 text-center text-slate-300 p-4">
<History size={48} className="mx-auto mb-2 opacity-20" />
<p className="text-xs">L'historique des modifications IA apparaîtra ici, aligné avec votre texte.</p>
</div>
)}
{/* Render Groups */}
{versionGroups.map((group) => {
const isExpanded = expandedGroupIds.has(group.id);
const isStack = group.versions.length > 1;
const latest = group.versions[0];
return (
<div
key={group.id}
className="absolute w-72 pointer-events-auto transition-all duration-300 ease-in-out"
style={{ top: `${group.topOffset + 32}px` }} // +32 for padding
>
<div className={`relative bg-white rounded-lg border shadow-sm transition-all duration-200 ${isStack && !isExpanded ? 'border-indigo-200 shadow-md translate-x-1 translate-y-1' : 'border-slate-200'}`}>
{/* Stack Effect Background Card */}
{isStack && !isExpanded && (
<div className="absolute inset-0 bg-white border border-indigo-100 rounded-lg transform -translate-x-1 -translate-y-1 -z-10 shadow-sm" />
)}
{/* Main Card Header */}
<div
className="p-2 border-b border-slate-100 flex justify-between items-center bg-slate-50 rounded-t-lg cursor-pointer hover:bg-slate-100"
onClick={() => isStack && toggleGroup(group.id)}
>
<div className="flex items-center gap-2">
{isStack && (
<Layers size={14} className="text-indigo-500" />
)}
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wide ${latest.type.includes('Correction') ? 'bg-green-100 text-green-700' :
latest.type.includes('Insertion') ? 'bg-blue-100 text-blue-700' :
'bg-purple-100 text-purple-700'
}`}>
{latest.type}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-slate-400">
{new Date(latest.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{isStack && (
isExpanded ? <ChevronUp size={14} className="text-slate-400" /> : <ChevronDown size={14} className="text-slate-400" />
)}
</div>
</div>
{/* Card Content (Latest) */}
{!isExpanded && (
<div className="p-2">
<div className="text-xs text-slate-500 italic line-clamp-2">
"{latest.snippet}"
</div>
<button
onClick={() => restoreVersion(latest)}
className="mt-2 w-full flex items-center justify-center gap-1 text-[10px] bg-slate-50 hover:bg-indigo-50 text-slate-600 hover:text-indigo-700 py-1 rounded transition-colors"
>
<RotateCcw size={10} /> Restaurer
</button>
</div>
)}
{/* Expanded Stack View */}
{isExpanded && (
<div className="divide-y divide-slate-100 max-h-64 overflow-y-auto">
{group.versions.map((v, i) => (
<div key={v.id} className="p-2 bg-white hover:bg-slate-50 transition-colors">
<div className="flex justify-between items-center mb-1">
<span className="text-[10px] font-semibold text-slate-600">
{i === 0 ? 'Dernière version' : `Version -${i}`}
</span>
<span className="text-[9px] text-slate-400">
{new Date(v.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
<div className="text-xs text-slate-500 italic bg-slate-50 p-1.5 rounded mb-2 border border-slate-100">
"{v.snippet}"
</div>
<button
onClick={() => restoreVersion(v)}
className="w-full flex items-center justify-center gap-1 text-[10px] bg-white border border-slate-200 text-slate-600 hover:text-indigo-600 hover:border-indigo-200 py-1 rounded transition-colors"
>
<RotateCcw size={10} /> Restaurer cette version
</button>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Context Menu Overlay */}
{contextMenu && (
<>
<div
className="fixed inset-0 z-40 bg-transparent"
onClick={() => setContextMenu(null)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
/>
<div
className="fixed z-50 bg-white border border-slate-200 rounded-lg shadow-xl py-1 w-56 animate-in fade-in zoom-in-95 duration-100 flex flex-col"
style={{ top: Math.min(contextMenu.y, window.innerHeight - 200), left: Math.min(contextMenu.x, window.innerWidth - 224) }}
>
{isAiLoading ? (
<div className="flex flex-col items-center justify-center py-4 text-indigo-600 gap-2">
<Loader2 className="animate-spin" size={24} />
<span className="text-xs font-medium">L'IA travaille...</span>
</div>
) : (
<>
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
Outils IA
</div>
<button
onClick={() => handleAiAction('correct')}
disabled={!hasSelection}
className={`flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${!hasSelection ? 'text-slate-300 cursor-not-allowed' : 'text-slate-700 hover:bg-indigo-50 hover:text-indigo-700'}`}
>
<Check size={14} /> Corriger l'orthographe
</button>
<button
onClick={() => handleAiAction('rewrite')}
disabled={!hasSelection}
className={`flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${!hasSelection ? 'text-slate-300 cursor-not-allowed' : 'text-slate-700 hover:bg-indigo-50 hover:text-indigo-700'}`}
>
<RefreshCw size={14} /> Reformuler
</button>
<button
onClick={() => handleAiAction('expand')}
disabled={!hasSelection}
className={`flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${!hasSelection ? 'text-slate-300 cursor-not-allowed' : 'text-slate-700 hover:bg-indigo-50 hover:text-indigo-700'}`}
>
<Maximize2 size={14} /> Développer
</button>
<button
onClick={() => handleAiAction('continue')}
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-indigo-50 hover:text-indigo-700 text-left transition-colors"
>
<Wand2 size={14} /> Continuer l'écriture
</button>
<div className="h-px bg-slate-100 my-1" />
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
Édition
</div>
<button
onClick={handleCopy}
disabled={!hasSelection}
className={`flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${!hasSelection ? 'text-slate-300 cursor-not-allowed' : 'text-slate-700 hover:bg-slate-50'}`}
>
<Copy size={14} /> Copier
</button>
<button
onClick={handleSelectAll}
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 text-left transition-colors"
>
<MousePointerClick size={14} /> Tout sélectionner
</button>
</>
)}
</div>
</>
)}
</div>
);
});
export default RichTextEditor;