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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

130
App.tsx Normal file
View File

@@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { ViewMode } from './types';
import { useProjects, useChat } from './hooks';
import { AuthProvider, useAuthContext } from './AuthContext';
import AppRouter from './components/AppRouter';
import { Loader2, BookOpen } from 'lucide-react';
/**
* MainContent : Ce composant est "sous" le AuthProvider.
* Il peut donc utiliser useAuthContext() sans recevoir de 'null'.
*/
const MainContent: React.FC = () => {
const [viewMode, setViewMode] = useState<ViewMode>('landing');
// --- 1. DÉCLARATION DE TOUS LES HOOKS (IMPÉRATIF : TOUJOURS EN HAUT) ---
// On récupère l'état global partagé via le Contexte
const {
user,
login,
signup,
logout,
incrementUsage,
loading: authLoading
} = useAuthContext();
// On initialise les projets. Si user est null, useProjects retournera une liste vide.
const {
projects, currentProjectId, setCurrentProjectId,
createProject, updateProject, updateChapter, addChapter,
createEntity, updateEntity, deleteEntity
} = useProjects(user);
// On initialise le chat
const { chatHistory, isGenerating, sendMessage } = useChat();
// --- 2. LOGIQUE DE CALCUL ---
const currentProject = projects.find(p => p.id === currentProjectId);
const handleSendMessage = (msg: string) => {
if (currentProject && user) {
sendMessage(currentProject, 'global', msg, user, incrementUsage);
}
};
// --- 3. RENDU CONDITIONNEL (Seulement APRES les hooks) ---
// On attend que l'Auth + Profil soient chargés (tes 2 secondes d'attente)
if (authLoading) {
return (
<div className="h-screen w-full flex flex-col items-center justify-center bg-slate-900 text-white">
<div className="relative mb-8">
<Loader2 className="animate-spin text-blue-500" size={56} />
<div className="absolute inset-0 blur-2xl bg-blue-500/30 animate-pulse rounded-full" />
</div>
<div className="flex items-center gap-2 mb-2">
<BookOpen className="text-blue-500" size={24} />
<span className="text-2xl font-black tracking-tighter">PlumeIA</span>
</div>
<p className="text-slate-400 font-medium animate-pulse tracking-wide">
Synchronisation de votre espace créatif...
</p>
</div>
);
}
// --- 4. RENDU FINAL : AppRouter ---
return (
<AppRouter
// État utilisateur
user={user}
viewMode={viewMode}
onViewModeChange={setViewMode}
// Actions Auth
onLogin={login}
onSignup={signup}
onLogout={() => {
logout();
setViewMode('landing');
}}
// Gestion des Projets
projects={projects}
currentProjectId={currentProjectId}
onSelectProject={(id) => {
setCurrentProjectId(id);
setViewMode('write');
}}
onCreateProject={async () => {
const id = await createProject();
if (id) {
setCurrentProjectId(id);
setViewMode('write');
}
}}
onUpdateProject={updateProject}
// Gestion du contenu (Chapitres & Entités)
onUpdateChapter={updateChapter}
onAddChapter={addChapter}
onCreateEntity={createEntity}
onUpdateEntity={updateEntity}
onDeleteEntity={deleteEntity}
// Chat & IA
chatHistory={chatHistory}
isGenerating={isGenerating}
onSendMessage={handleSendMessage}
onIncrementUsage={incrementUsage}
// Callbacks divers
onUpdateProfile={() => console.log("Profil géré via useAuth")}
onUpgradePlan={() => window.open('/billing', '_blank')}
/>
);
};
/**
* Composant App : Point d'entrée de l'application.
* Il installe la "bulle" AuthProvider pour que tout le monde y ait accès.
*/
const App: React.FC = () => {
return (
<AuthProvider>
<MainContent />
</AuthProvider>
);
};
// EXPORT PAR DÉFAUT (Essentiel pour index.tsx)
export default App;

14
AuthContext.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React, { createContext, useContext } from 'react';
import { useAuth as useAuthHook } from './hooks';
const AuthContext = createContext<any>(null);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const auth = useAuthHook();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
};
// Ce hook permettra d'accéder à l'auth n'importe où
export function useAuthContext() {
return useContext(AuthContext);
}

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1F6iXw8LXUE67VTaem9IfxgM5UMR_dvMV
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

111
components/AIPanel.tsx Normal file
View File

@@ -0,0 +1,111 @@
import React, { useState, useEffect, useRef } from 'react';
import { Sparkles, Send, RefreshCw, BookOpen, Bot, ArrowLeft, BrainCircuit, Zap } from 'lucide-react';
import { ChatMessage, UserUsage } from '../types';
interface AIPanelProps {
chatHistory: ChatMessage[];
onSendMessage: (msg: string) => void;
onInsertText: (text: string) => void;
selectedText: string;
isGenerating: boolean;
usage?: UserUsage;
}
const AIPanel: React.FC<AIPanelProps> = ({ chatHistory, onSendMessage, onInsertText, selectedText, isGenerating, usage }) => {
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chatHistory, isGenerating]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isGenerating) return;
onSendMessage(input);
setInput("");
};
const isLimitReached = usage ? usage.aiActionsCurrent >= usage.aiActionsLimit : false;
return (
<div className="flex flex-col h-full bg-white border-l border-slate-200 shadow-xl w-80 lg:w-96">
{/* Header with Usage Counter */}
<div className="p-4 bg-indigo-600 text-white flex items-center justify-between shadow-md">
<div className="flex items-center gap-2">
<Sparkles size={20} className="animate-pulse" />
<h3 className="font-bold tracking-tight">Assistant IA</h3>
</div>
{usage && (
<div className="bg-indigo-900/50 px-2 py-1 rounded text-[10px] font-black flex items-center gap-1">
<Zap size={10} fill="currentColor" /> {usage.aiActionsCurrent} / {usage.aiActionsLimit === 999999 ? '∞' : usage.aiActionsLimit}
</div>
)}
</div>
{selectedText && (
<div className="bg-indigo-50 p-3 border-b border-indigo-100 text-xs text-indigo-800">
<div className="font-bold flex items-center gap-1 mb-1"><BookOpen size={12} /> Contexte :</div>
<div className="italic truncate opacity-80">"{selectedText.substring(0, 60)}..."</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50">
{chatHistory.length === 0 && (
<div className="text-center text-slate-400 mt-10">
<Bot size={48} className="mx-auto mb-2 opacity-50" />
<p className="text-sm">Bonjour ! Comment puis-je vous aider aujourd'hui ?</p>
{isLimitReached && (
<div className="mt-4 p-4 bg-red-50 border border-red-100 rounded-xl text-red-600 text-xs font-bold uppercase animate-pulse">
Limite atteinte ! Améliorez votre plan.
</div>
)}
</div>
)}
{chatHistory.map((msg) => (
<div key={msg.id} className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
<div className={`max-w-[85%] rounded-2xl p-4 text-sm shadow-sm ${msg.role === 'user' ? 'bg-indigo-600 text-white rounded-br-none' : 'bg-white text-slate-700 border border-slate-100 rounded-bl-none'}`}>
{msg.role === 'model' && msg.responseType === 'reflection' && (
<div className="flex items-center gap-1.5 text-[10px] font-black text-amber-600 mb-1.5 uppercase tracking-wide"><BrainCircuit size={12} /> Réflexion</div>
)}
<div className="whitespace-pre-wrap leading-relaxed">{msg.text}</div>
</div>
</div>
))}
{isGenerating && (
<div className="flex justify-start">
<div className="bg-white p-3 rounded-2xl rounded-bl-none shadow-sm border border-slate-100 flex items-center gap-2 text-xs text-slate-500">
<RefreshCw size={14} className="animate-spin" /> L'IA travaille...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-4 bg-white border-t border-slate-200">
<form onSubmit={handleSubmit} className="relative">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={isLimitReached ? "Limite atteinte..." : "Votre message..."}
className="w-full pl-4 pr-12 py-3 bg-slate-100 rounded-2xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-shadow disabled:opacity-50"
disabled={isGenerating || isLimitReached}
/>
<button
type="submit"
disabled={!input.trim() || isGenerating || isLimitReached}
className="absolute right-1.5 top-1.5 p-2 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 disabled:opacity-50 transition-colors shadow-md"
>
<Send size={18} />
</button>
</form>
</div>
</div>
);
};
export default AIPanel;

162
components/AppRouter.tsx Normal file
View File

@@ -0,0 +1,162 @@
import React, { useState, useRef } from 'react';
import { ViewMode, BookProject, UserProfile, Chapter, Entity } from '../types';
import LandingPage from './LandingPage';
import FeaturesPage from './FeaturesPage';
import Pricing from './Pricing';
import Checkout from './Checkout';
import AuthPage from './AuthPage';
import LoginPage from './LoginPage';
import Dashboard from './Dashboard';
import UserProfileSettings from './UserProfileSettings';
import ExportModal from './ExportModal';
import HelpModal from './HelpModal';
import EditorShell from './layout/EditorShell';
import RichTextEditor, { RichTextEditorHandle } from './RichTextEditor';
import WorldBuilder from './WorldBuilder';
import StoryWorkflow from './StoryWorkflow';
import IdeaBoard from './IdeaBoard';
import BookSettingsComponent from './BookSettings';
import { transformText } from '../services/geminiService';
interface AppRouterProps {
user: UserProfile | null;
projects: BookProject[];
currentProjectId: string | null;
viewMode: ViewMode;
chatHistory: any[];
isGenerating: boolean;
onLogin: (data: any) => Promise<any>;
onSignup: (data: any) => Promise<any>;
onLogout: () => void;
onViewModeChange: (mode: ViewMode) => void;
onSelectProject: (id: string) => void;
onCreateProject: () => void;
onUpdateProject: (updates: Partial<BookProject>) => void;
onUpdateChapter: (chapterId: string, updates: Partial<Chapter>) => void;
onAddChapter: () => Promise<string | null>;
onCreateEntity: (entity: Omit<Entity, 'id'>) => Promise<string | null>;
onUpdateEntity: (entityId: string, updates: Partial<Entity>) => void;
onDeleteEntity: (entityId: string) => void;
onUpdateProfile: (updates: Partial<UserProfile>) => void;
onUpgradePlan: (plan: any) => void;
onSendMessage: (msg: string) => void;
onIncrementUsage: () => void;
}
const AppRouter: React.FC<AppRouterProps> = (props) => {
const [currentChapterId, setCurrentChapterId] = useState<string>('');
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
const [targetEntityId, setTargetEntityId] = useState<string | null>(null);
const editorRef = useRef<RichTextEditorHandle>(null);
const { user, viewMode, currentProjectId, projects } = props;
const project = projects.find(p => p.id === currentProjectId);
console.log('props', props);
// DEBUG: Check all props
console.log("[AppRouter DEBUG] PROPS RECEIVED:", {
user,
userId: user?.id,
viewMode,
currentProjectId,
projectsCount: projects?.length,
fullProps: props
});
React.useEffect(() => {
if (project && (!currentChapterId || !project.chapters.some(c => c.id === currentChapterId))) {
setCurrentChapterId(project.chapters[0]?.id || '');
}
}, [currentProjectId, project]);
if (viewMode === 'landing') return <LandingPage onLogin={() => props.onViewModeChange('auth')} onFeatures={() => props.onViewModeChange('features')} onPricing={() => props.onViewModeChange('pricing')} />;
// Use new LoginPage for 'auth' view (Login)
if (viewMode === 'auth') return <LoginPage onSuccess={() => props.onViewModeChange('dashboard')} onRegister={() => props.onViewModeChange('signup')} />;
// Use existing AuthPage for 'signup' view (Register) - defaulted to signup mode inside AuthPage if possible,
// but AuthPage manages its own state. We can pass a prop if AuthPage supports it, or just let user toggle.
// Since AuthPage has internal state for mode, we might just render it.
// Ideally AuthPage should accept an initialMode prop. Let's check AuthPage again or just render it.
if (viewMode === 'signup') return <AuthPage onBack={() => props.onViewModeChange('landing')} onSuccess={() => props.onViewModeChange('dashboard')} initialMode='signup' />;
if (viewMode === 'features') return <FeaturesPage onBack={() => props.onViewModeChange(user ? 'dashboard' : 'landing')} />;
if (viewMode === 'pricing') return <Pricing currentPlan={user?.subscription.plan || 'free'} onBack={() => props.onViewModeChange(user ? 'dashboard' : 'landing')} onSelectPlan={() => user ? props.onViewModeChange('checkout') : props.onViewModeChange('auth')} />;
if (viewMode === 'checkout') return <Checkout onComplete={() => props.onUpgradePlan('pro')} onCancel={() => props.onViewModeChange('pricing')} />;
if (viewMode === 'dashboard' && user) return <Dashboard user={user} projects={projects} onSelect={props.onSelectProject} onCreate={props.onCreateProject} onLogout={props.onLogout} onPricing={() => props.onViewModeChange('pricing')} onProfile={() => props.onViewModeChange('profile')} />;
if (viewMode === 'profile' && user) return <UserProfileSettings user={user} onUpdate={props.onUpdateProfile} onBack={() => props.onViewModeChange('dashboard')} />;
console.log("[AppRouter] Render State:", { viewMode, hasUser: !!user, hasProject: !!project, currentProjectId, currentChapterId });
if (!project || !user) {
// If we are here, we are in a protected route ('write', 'world_building', etc.)
// BUT we don't have a project or user. This is an invalid state.
console.warn("[AppRouter] Fallthrough to NULL - displaying fallback");
return (
<div className="flex flex-col items-center justify-center h-screen bg-slate-100 text-slate-800">
<h2 className="text-xl font-bold mb-2">État Indéfini</h2>
<p>Mode: {viewMode}</p>
<p>Utilisateur: {user ? 'Connecté' : 'Non connecté'}</p>
<p>Projet sélectionné: {currentProjectId || 'Aucun'}</p>
<button className="mt-4 px-4 py-2 bg-blue-600 text-white rounded" onClick={() => props.onViewModeChange('landing')}>
Retour à l'accueil
</button>
</div>
);
}
const currentChapter = project.chapters.find(c => c.id === currentChapterId);
return (
<EditorShell
project={project}
user={user}
viewMode={viewMode}
currentChapterId={currentChapterId}
chatHistory={props.chatHistory}
isGenerating={props.isGenerating}
onViewModeChange={props.onViewModeChange}
onChapterSelect={(id) => { setCurrentChapterId(id); props.onViewModeChange('write'); }}
onUpdateProject={props.onUpdateProject}
onAddChapter={async () => {
const id = await props.onAddChapter();
if (id) setCurrentChapterId(id);
}}
onDeleteChapter={(id) => {
if (project.chapters.length > 1) {
const newChapters = project.chapters.filter(c => c.id !== id);
props.onUpdateProject({ chapters: newChapters });
if (currentChapterId === id) setCurrentChapterId(newChapters[0].id);
}
}}
onLogout={props.onLogout}
onSendMessage={props.onSendMessage}
onInsertText={(text) => editorRef.current?.insertHtml(text)}
onOpenExport={() => setIsExportModalOpen(true)}
onOpenHelp={() => setIsHelpModalOpen(true)}
>
<ExportModal isOpen={isExportModalOpen} onClose={() => setIsExportModalOpen(false)} project={project} onPrint={() => { }} />
<HelpModal isOpen={isHelpModalOpen} onClose={() => setIsHelpModalOpen(false)} viewMode={viewMode} />
{viewMode === 'write' && <RichTextEditor
ref={editorRef}
initialContent={currentChapter?.content || ""}
onSave={(html) => props.onUpdateChapter(currentChapterId, { content: html })}
onAiTransform={(text, mode) => transformText(text, mode, currentChapter?.content || "", user, props.onIncrementUsage)}
/>}
{viewMode === 'world_building' && <WorldBuilder
entities={project.entities}
onCreate={props.onCreateEntity}
onUpdate={props.onUpdateEntity}
onDelete={props.onDeleteEntity}
templates={project.templates || []}
onUpdateTemplates={(t) => props.onUpdateProject({ templates: t })}
initialSelectedId={targetEntityId}
/>}
{viewMode === 'ideas' && <IdeaBoard ideas={project.ideas || []} onUpdate={(i) => props.onUpdateProject({ ideas: i })} />}
{viewMode === 'workflow' && <StoryWorkflow data={project.workflow || { nodes: [], connections: [] }} onUpdate={(w) => props.onUpdateProject({ workflow: w })} entities={project.entities} onNavigateToEntity={(id) => { setTargetEntityId(id); props.onViewModeChange('world_building'); }} />}
{viewMode === 'settings' && <BookSettingsComponent project={project} onUpdate={props.onUpdateProject} />}
</EditorShell>
);
};
export default AppRouter;

202
components/AuthPage.tsx Normal file
View File

@@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react';
import { Mail, Lock, User, ArrowRight, Loader2, BookOpen, ShieldCheck } from 'lucide-react';
import { useAuth } from '../hooks';
interface AuthPageProps {
onBack: () => void;
onSuccess: () => void;
initialMode?: 'signin' | 'signup' | 'forgot';
}
const AuthPage: React.FC<AuthPageProps> = ({ onBack, onSuccess, initialMode = 'signin' }) => {
const [mode, setMode] = useState<'signin' | 'signup' | 'forgot'>(initialMode);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({ name: '', email: '', password: '' });
const [error, setError] = useState('');
// On récupère les fonctions de connexion directement du hook
const { user, login, signup } = useAuth();
// Redirection automatique dès que l'utilisateur est détecté dans l'état global
useEffect(() => {
if (user) {
onSuccess();
}
}, [user, onSuccess]);
const handleAdminLogin = async () => {
const adminData = { email: 'streaper2@gmail.com', password: 'Kency1313' };
setFormData({ name: 'Admin Plume', ...adminData });
setLoading(true);
setError('');
try {
const result = await login(adminData);
if (result?.error) setError(result.error);
} catch (e) {
setError('Erreur de connexion au service.');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
let result;
if (mode === 'signup') {
result = await signup(formData);
} else {
result = await login({ email: formData.email, password: formData.password });
}
if (result?.error) {
setError(result.error);
}
} catch (e) {
setError('Une erreur technique est survenue.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-slate-50 flex overflow-hidden font-sans text-slate-900">
{/* Panneau Latéral (Visible sur Desktop) */}
<div className="hidden lg:flex w-1/2 bg-slate-900 relative p-12 flex-col justify-between overflow-hidden">
<div className="absolute top-0 right-0 w-full h-full opacity-20 pointer-events-none">
<div className="absolute top-10 right-10 w-64 h-64 bg-blue-500 rounded-full blur-[120px]" />
<div className="absolute bottom-10 left-10 w-96 h-96 bg-indigo-500 rounded-full blur-[150px]" />
</div>
<div className="relative z-10 flex items-center gap-2 text-white text-2xl font-black">
<BookOpen className="text-blue-500" /> PlumeIA
</div>
<div className="relative z-10 max-w-lg">
<h2 className="text-5xl font-black text-white leading-tight mb-6">
L'endroit où vos <span className="text-blue-400">histoires</span> prennent vie.
</h2>
<p className="text-slate-400 text-lg leading-relaxed">
Rejoignez une communauté d'auteurs qui utilisent l'IA pour briser la page blanche.
</p>
</div>
<div className="relative z-10 text-slate-500 text-sm">
© 2024 PlumeIA Ecosystem.
</div>
</div>
{/* Formulaire */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-white overflow-y-auto">
<div className="w-full max-w-md animate-in fade-in slide-in-from-right-10 duration-500 py-8">
<div className="text-center mb-10">
<h1 className="text-3xl font-black text-slate-900 mb-2">
{mode === 'signin' ? 'Content de vous revoir' : mode === 'signup' ? "Commencer l'aventure" : 'Récupération'}
</h1>
<p className="text-slate-500">
{mode === 'signin' ? 'Entrez vos identifiants pour continuer.' : 'Créez votre compte gratuit en quelques secondes.'}
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-100 text-red-600 text-sm font-medium rounded-xl animate-in shake duration-300">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{mode === 'signup' && (
<div className="space-y-1">
<label className="text-xs font-black text-slate-500 uppercase tracking-widest ml-1">Nom complet</label>
<div className="relative">
<User className="absolute left-4 top-3.5 text-slate-400" size={18} />
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Marc Dupré"
className="w-full pl-12 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-medium"
/>
</div>
</div>
)}
<div className="space-y-1">
<label className="text-xs font-black text-slate-500 uppercase tracking-widest ml-1">Email</label>
<div className="relative">
<Mail className="absolute left-4 top-3.5 text-slate-400" size={18} />
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="votre@email.com"
className="w-full pl-12 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-medium"
/>
</div>
</div>
{mode !== 'forgot' && (
<div className="space-y-1">
<label className="text-xs font-black text-slate-500 uppercase tracking-widest ml-1">Mot de passe</label>
<div className="relative">
<Lock className="absolute left-4 top-3.5 text-slate-400" size={18} />
<input
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder=""
className="w-full pl-12 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-medium"
/>
</div>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-slate-900 text-white py-4 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-blue-600 transition-all shadow-xl disabled:opacity-50 mt-4"
>
{loading ? <Loader2 className="animate-spin" /> : (
<>{mode === 'signin' ? 'Se connecter' : mode === 'signup' ? 'Créer mon compte' : 'Envoyer'} <ArrowRight size={18} /></>
)}
</button>
</form>
{mode === 'signin' && (
<button
onClick={handleAdminLogin}
className="w-full mt-4 bg-amber-50 border border-amber-200 text-amber-800 py-3 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-amber-100 transition-all"
>
<ShieldCheck size={18} /> Connexion démo (Admin)
</button>
)}
<div className="mt-10 text-center">
<p className="text-sm text-slate-500">
{mode === 'signin' ? "Pas de compte ?" : "Déjà membre ?"}
<button
onClick={() => setMode(mode === 'signin' ? 'signup' : 'signin')}
className="ml-2 font-bold text-blue-600"
>
{mode === 'signin' ? "S'inscrire" : "Se connecter"}
</button>
</p>
</div>
<button onClick={onBack} className="mt-8 text-xs text-slate-300 w-full text-center hover:text-slate-500 transition-colors">
Revenir au site
</button>
</div>
</div>
</div>
);
};
export default AuthPage;

214
components/BookSettings.tsx Normal file
View File

@@ -0,0 +1,214 @@
import React, { useEffect, useState } from 'react';
import { BookProject, BookSettings } from '../types';
import { GENRES, TONES, POV_OPTIONS, TENSE_OPTIONS } from '../constants';
import { Settings, Book, Feather, Users, Clock, Target, Hash } from 'lucide-react';
interface BookSettingsProps {
project: BookProject;
onUpdate: (project: BookProject) => void;
}
const DEFAULT_SETTINGS: BookSettings = {
genre: '',
subGenre: '',
targetAudience: '',
tone: '',
pov: '',
tense: '',
synopsis: '',
themes: ''
};
const BookSettingsComponent: React.FC<BookSettingsProps> = ({ project, onUpdate }) => {
const [settings, setSettings] = useState<BookSettings>(project.settings || DEFAULT_SETTINGS);
useEffect(() => {
if (project.settings) {
setSettings(project.settings);
}
}, [project.settings]);
const handleChange = (key: keyof BookSettings, value: string) => {
const newSettings = { ...settings, [key]: value };
setSettings(newSettings);
onUpdate({ ...project, settings: newSettings });
};
const handleStyleGuideChange = (value: string) => {
onUpdate({ ...project, styleGuide: value });
};
return (
<div className="h-full bg-[#eef2ff] p-8 overflow-y-auto">
<div className="max-w-4xl mx-auto bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden">
<div className="bg-slate-900 text-white p-6 border-b border-slate-800 flex items-center gap-4">
<div className="bg-blue-600 p-3 rounded-lg">
<Settings size={24} />
</div>
<div>
<h2 className="text-2xl font-bold">Paramètres Généraux du Roman</h2>
<p className="text-slate-400 text-sm">Définissez l'identité, le ton et les règles de votre œuvre pour guider l'IA.</p>
</div>
</div>
<div className="p-8 space-y-8">
<section className="space-y-4">
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2 border-b border-slate-100 pb-2">
<Book size={18} className="text-blue-600" /> Informations de Base
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-semibold text-slate-600 mb-1">Titre du Roman</label>
<input
type="text"
value={project.title}
onChange={(e) => onUpdate({ ...project, title: e.target.value })}
className="w-full p-2.5 bg-[#eef2ff] border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none font-serif font-bold text-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-600 mb-1">Nom d'Auteur</label>
<input
type="text"
value={project.author}
onChange={(e) => onUpdate({ ...project, author: e.target.value })}
className="w-full p-2.5 bg-[#eef2ff] border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-600 mb-1">Synopsis Global</label>
<textarea
value={settings.synopsis}
onChange={(e) => handleChange('synopsis', e.target.value)}
className="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none h-24 text-sm bg-[#eef2ff]"
placeholder="De quoi parle votre histoire dans les grandes lignes ?"
/>
</div>
</section>
<section className="space-y-4">
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2 border-b border-slate-100 pb-2">
<Target size={18} className="text-red-500" /> Genre & Public
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-semibold text-slate-600 mb-1">Genre Principal</label>
<input
type="text"
list="genre-suggestions"
value={settings.genre}
onChange={(e) => handleChange('genre', e.target.value)}
className="w-full p-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none bg-[#eef2ff]"
placeholder="Ex: Fantasy"
/>
<datalist id="genre-suggestions">
{GENRES.map(g => <option key={g} value={g} />)}
</datalist>
</div>
<div>
<label className="block text-sm font-semibold text-slate-600 mb-1">Sous-Genre</label>
<input
type="text"
value={settings.subGenre || ''}
onChange={(e) => handleChange('subGenre', e.target.value)}
className="w-full p-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none bg-[#eef2ff]"
placeholder="Ex: Dark Fantasy"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-600 mb-1">Public Cible</label>
<input
type="text"
value={settings.targetAudience}
onChange={(e) => handleChange('targetAudience', e.target.value)}
className="w-full p-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none bg-[#eef2ff]"
placeholder="Ex: Jeune Adulte, Adulte..."
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-600 mb-1">Thèmes Clés</label>
<div className="relative">
<Hash size={14} className="absolute left-3 top-3 text-slate-400" />
<input
type="text"
value={settings.themes}
onChange={(e) => handleChange('themes', e.target.value)}
className="w-full pl-9 p-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none bg-[#eef2ff]"
placeholder="Ex: Vengeance, Rédemption, Voyage initiatique..."
/>
</div>
</div>
</section>
<section className="space-y-4">
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2 border-b border-slate-100 pb-2">
<Feather size={18} className="text-purple-600" /> Narration & Style
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-semibold text-slate-600 mb-1 flex items-center gap-1">
<Users size={14} /> Point de Vue (POV)
</label>
<select
value={settings.pov}
onChange={(e) => handleChange('pov', e.target.value)}
className="w-full p-2.5 bg-[#eef2ff] border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
>
<option value="">Sélectionner...</option>
{POV_OPTIONS.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-600 mb-1 flex items-center gap-1">
<Clock size={14} /> Temps du récit
</label>
<select
value={settings.tense}
onChange={(e) => handleChange('tense', e.target.value)}
className="w-full p-2.5 bg-[#eef2ff] border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
>
<option value="">Sélectionner...</option>
{TENSE_OPTIONS.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-600 mb-1">Ton Général</label>
<input
type="text"
list="tone-suggestions"
value={settings.tone}
onChange={(e) => handleChange('tone', e.target.value)}
className="w-full p-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none bg-[#eef2ff]"
placeholder="Ex: Sombre, Ironique..."
/>
<datalist id="tone-suggestions">
{TONES.map(t => <option key={t} value={t} />)}
</datalist>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-semibold text-slate-600 mb-1">
Guide de Style & Instructions IA (Prompt Système)
</label>
<p className="text-xs text-slate-400 mb-2">
Ces instructions seront envoyées à l'IA à chaque génération. Décrivez ici le style d'écriture désiré (ex: "phrases courtes", "vocabulaire soutenu", "beaucoup de métaphores").
</p>
<textarea
value={project.styleGuide || ''}
onChange={(e) => handleStyleGuideChange(e.target.value)}
className="w-full p-3 border border-indigo-100 bg-[#eef2ff] rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none h-32 text-sm font-mono text-slate-700"
placeholder="Ex: Utilise un style descriptif et sensoriel. Évite les adverbes. Le narrateur est cynique."
/>
</div>
</section>
</div>
</div>
</div>
);
};
export default BookSettingsComponent;

63
components/Checkout.tsx Normal file
View File

@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { CreditCard, Shield, Lock, ArrowRight, Loader2 } from 'lucide-react';
interface CheckoutProps {
onComplete: () => void;
onCancel: () => void;
}
const Checkout: React.FC<CheckoutProps> = ({ onComplete, onCancel }) => {
const [loading, setLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setTimeout(() => {
onComplete();
}, 2000);
};
return (
<div className="min-h-screen bg-[#eef2ff] flex items-center justify-center p-8">
<div className="bg-white rounded-3xl shadow-2xl flex flex-col md:flex-row max-w-4xl w-full overflow-hidden animate-in fade-in slide-in-from-bottom-10 duration-500">
<div className="w-full md:w-1/3 bg-slate-900 text-white p-8">
<h3 className="text-xl font-bold mb-8 flex items-center gap-2"><Lock size={18} className="text-blue-400" /> Commande</h3>
<div className="space-y-4">
<div className="flex justify-between text-sm"><span>Auteur Pro</span><span>12.00</span></div>
<div className="flex justify-between text-sm"><span>TVA (20%)</span><span>2.40</span></div>
<div className="h-px bg-slate-800 my-4" />
<div className="flex justify-between text-xl font-black"><span>Total</span><span className="text-blue-400">14.40</span></div>
</div>
</div>
<div className="flex-1 p-8 md:p-12">
<h2 className="text-2xl font-black text-slate-900 mb-8 text-center">Paiement Sécurisé</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-xs font-black text-slate-500 uppercase tracking-widest mb-2">Numéro de carte</label>
<div className="relative">
<input type="text" placeholder="4242 4242 4242 4242" className="w-full bg-[#eef2ff] border border-indigo-100 p-4 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-bold" />
<CreditCard className="absolute right-4 top-4 text-slate-400" />
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<input type="text" placeholder="MM / YY" className="w-full bg-[#eef2ff] border border-indigo-100 p-4 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-bold" />
<input type="text" placeholder="CVC" className="w-full bg-[#eef2ff] border border-indigo-100 p-4 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-bold" />
</div>
<button
disabled={loading}
className="w-full bg-blue-600 text-white py-5 rounded-2xl font-black text-lg hover:bg-blue-700 transition-all shadow-xl shadow-blue-200 flex items-center justify-center gap-3"
>
{loading ? <Loader2 className="animate-spin" /> : <>Confirmer le paiement <ArrowRight size={20} /></>}
</button>
<div className="flex items-center justify-center gap-2 text-[10px] text-slate-400 font-bold uppercase">
<Shield size={12} /> Traitement chiffré SSL 256-bits
</div>
</form>
</div>
</div>
</div>
);
};
export default Checkout;

154
components/Dashboard.tsx Normal file
View File

@@ -0,0 +1,154 @@
import React from 'react';
import { BookProject, UserProfile } from '../types';
import { Plus, Book, Clock, Star, ChevronRight, LogOut, LayoutDashboard, User, Target, Flame, Edit3 } from 'lucide-react';
interface DashboardProps {
user: UserProfile;
projects: BookProject[];
onSelect: (id: string) => void;
onCreate: () => void;
onLogout: () => void;
onPricing: () => void;
onProfile: () => void;
}
const Dashboard: React.FC<DashboardProps> = ({ user, projects, onSelect, onCreate, onLogout, onPricing, onProfile }) => {
return (
<div className="min-h-screen bg-[#eef2ff] p-8 font-sans">
<div className="max-w-6xl mx-auto space-y-8">
{/* User Card */}
<div className="flex flex-col md:flex-row justify-between items-center bg-white p-8 rounded-[2rem] shadow-sm border border-indigo-100 gap-6">
<div className="flex items-center gap-6">
<div className="relative">
<img src={user.avatar} className="w-20 h-20 rounded-full border-4 border-slate-50 shadow-lg object-cover" alt="Avatar" />
<div className="absolute -bottom-1 -right-1 bg-green-500 w-5 h-5 rounded-full border-4 border-white" />
</div>
<div>
<h2 className="text-3xl font-black text-slate-900">Bonjour, {user.name} 👋</h2>
<div className="flex items-center gap-3 mt-1">
<span className="px-3 py-1 rounded-full bg-indigo-100 text-indigo-700 text-[10px] uppercase font-black tracking-widest">{user.subscription.plan}</span>
<span className="text-slate-400 text-xs font-medium">Membre depuis le 24 janv.</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button onClick={onProfile} className="bg-slate-50 text-slate-700 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-slate-100 transition-all flex items-center gap-2 border border-slate-200">
<User size={18} /> Mon Profil
</button>
<button onClick={onLogout} className="p-3 text-slate-400 hover:text-red-500 rounded-full hover:bg-red-50 transition-colors"><LogOut size={20} /></button>
</div>
</div>
{/* Stats Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-3xl shadow-sm border border-indigo-50 flex items-center gap-4">
<div className="bg-orange-100 p-3 rounded-2xl text-orange-600"><Flame size={24} /></div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Série actuelle</p>
<p className="text-2xl font-black text-slate-900">{user.stats.writingStreak} Jours</p>
</div>
</div>
<div className="bg-white p-6 rounded-3xl shadow-sm border border-indigo-50 flex items-center gap-4">
<div className="bg-blue-100 p-3 rounded-2xl text-blue-600"><Edit3 size={24} /></div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Mots écrits</p>
<p className="text-2xl font-black text-slate-900">{user.stats.totalWordsWritten.toLocaleString()}</p>
</div>
</div>
<div className="bg-white p-6 rounded-3xl shadow-sm border border-indigo-50 flex items-center gap-4">
<div className="bg-indigo-100 p-3 rounded-2xl text-indigo-600"><Target size={24} /></div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Objectif du jour</p>
<p className="text-2xl font-black text-slate-900">{user.preferences.dailyWordGoal} Mots</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Project List */}
<div className="lg:col-span-2 space-y-4">
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-black text-slate-900">Mes Romans</h3>
<button
onClick={onCreate}
className="flex items-center gap-2 bg-blue-600 text-white px-6 py-3 rounded-2xl font-bold hover:bg-blue-700 transition-all shadow-xl shadow-blue-200"
>
<Plus size={20} /> Écrire un nouveau livre
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{projects.map(p => (
<div
key={p.id}
onClick={() => onSelect(p.id)}
className="bg-white p-8 rounded-[2.5rem] border border-indigo-50 shadow-sm hover:shadow-2xl hover:scale-[1.02] transition-all cursor-pointer group flex flex-col justify-between h-64"
>
<div>
<div className="bg-blue-50 w-12 h-12 rounded-2xl flex items-center justify-center text-blue-600 mb-6 group-hover:bg-blue-600 group-hover:text-white transition-colors">
<Book size={24} />
</div>
<h4 className="font-black text-slate-900 text-xl truncate mb-1">{p.title}</h4>
<p className="text-slate-400 text-sm">Dernière modification : {new Date(p.lastModified).toLocaleDateString()}</p>
</div>
<div className="flex justify-between items-center text-[10px] text-slate-400 font-black uppercase tracking-widest border-t border-slate-50 pt-4">
<span>{p.chapters.length} Chapitres</span>
<ChevronRight size={20} className="group-hover:text-blue-600 transition-transform group-hover:translate-x-1 duration-300" />
</div>
</div>
))}
{projects.length === 0 && (
<div className="col-span-2 py-24 bg-white rounded-[3rem] border-2 border-dashed border-indigo-100 flex flex-col items-center justify-center text-indigo-300">
<Book size={64} className="mb-6 opacity-20" />
<p className="font-bold text-lg">Prêt à commencer votre premier roman ?</p>
<button onClick={onCreate} className="mt-4 text-blue-600 font-bold hover:underline">Créer un projet maintenant</button>
</div>
)}
</div>
</div>
{/* Sidebar Stats & Plan */}
<div className="space-y-6">
<div className="bg-slate-900 text-white p-8 rounded-[2.5rem] shadow-xl relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/20 blur-[60px] -z-1" />
<h3 className="font-black text-xl mb-6 flex items-center gap-2"><Star size={20} className="text-yellow-400" /> Utilisation</h3>
<div className="space-y-8">
<div>
<div className="flex justify-between text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">
<span>Actions IA</span>
<span>{user.usage.aiActionsCurrent} / {user.usage.aiActionsLimit === 999999 ? '∞' : user.usage.aiActionsLimit}</span>
</div>
<div className="h-3 w-full bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-1000 shadow-[0_0_10px_rgba(59,130,246,0.5)]"
style={{ width: `${Math.min(100, (user.usage.aiActionsCurrent / user.usage.aiActionsLimit) * 100)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">
<span>Emplacements Roman</span>
<span>{projects.length} / {user.usage.projectsLimit}</span>
</div>
<div className="h-3 w-full bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-1000 shadow-[0_0_10px_rgba(99,102,241,0.5)]"
style={{ width: `${Math.min(100, (projects.length / user.usage.projectsLimit) * 100)}%` }}
/>
</div>
</div>
</div>
<button onClick={onPricing} className="w-full mt-10 bg-white/10 hover:bg-white/20 py-4 rounded-2xl text-sm font-bold transition-all">
Upgrade Plan
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

246
components/ExportModal.tsx Normal file
View File

@@ -0,0 +1,246 @@
import React, { useState } from 'react';
import { BookProject } from '../types';
import { FileText, FileType, Printer, X, Download, Book, FileJson } from 'lucide-react';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
project: BookProject;
onPrint: (options: { includeCover: boolean, includeTOC: boolean }) => void;
}
type ExportFormat = 'pdf' | 'word' | 'epub' | 'markdown';
type PageSize = 'A4' | 'A5' | 'Letter';
const ExportModal: React.FC<ExportModalProps> = ({ isOpen, onClose, project, onPrint }) => {
const [format, setFormat] = useState<ExportFormat>('pdf');
const [pageSize, setPageSize] = useState<PageSize>('A4');
const [includeCover, setIncludeCover] = useState(true);
const [includeTOC, setIncludeTOC] = useState(true);
if (!isOpen) return null;
const generateContentHTML = () => {
let html = `
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>${project.title}</title>
<style>
body { font-family: 'Times New Roman', serif; line-height: 1.5; color: #000; margin: 0; padding: 0; }
h1, h2, h3 { color: #000; page-break-after: avoid; }
.chapter { page-break-before: always; }
.title-page { text-align: center; page-break-after: always; display: flex; flex-direction: column; justify-content: center; height: 90vh; }
.toc { page-break-after: always; }
p { margin-bottom: 1em; text-align: justify; }
a { color: #000; text-decoration: none; }
ul { list-style-type: none; padding: 0; }
li { margin-bottom: 0.5em; }
</style>
</head>
<body>
`;
if (includeCover) {
html += `
<div class="title-page">
<h1 style="font-size: 3em; margin-bottom: 0.5em;">${project.title}</h1>
<h2 style="font-size: 1.5em; font-weight: normal;">${project.author}</h2>
</div>
`;
}
if (includeTOC) {
html += `<div class="toc"><h2>Table des Matières</h2><ul>`;
project.chapters.forEach((chap, idx) => {
html += `<li><a href="#chap-${idx}">${chap.title}</a></li>`;
});
html += `</ul></div>`;
}
project.chapters.forEach((chap, idx) => {
html += `
<div class="chapter" id="chap-${idx}">
<h2>${chap.title}</h2>
${chap.content}
</div>
`;
});
html += `</body></html>`;
return html;
};
const handleExport = () => {
const filename = project.title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
if (format === 'pdf') {
// Use the classic window print tool via parent callback
onPrint({ includeCover, includeTOC });
}
else if (format === 'word') {
// Export as HTML with specific Word namespaces -> interpreted as doc by Word
const content = generateContentHTML();
const blob = new Blob(['\ufeff', content], {
type: 'application/msword'
});
downloadBlob(blob, `${filename}.doc`);
}
else if (format === 'epub') {
// Export as a single XHTML file (Ebook ready)
const content = generateContentHTML();
const blob = new Blob([content], {
type: 'application/xhtml+xml'
});
downloadBlob(blob, `${filename}.xhtml`);
}
else if (format === 'markdown') {
let md = `# ${project.title}\nBy ${project.author}\n\n`;
project.chapters.forEach(c => {
// Very basic HTML to Text conversion
const text = c.content.replace(/<[^>]+>/g, '\n');
md += `## ${c.title}\n\n${text}\n\n---\n\n`;
});
const blob = new Blob([md], { type: 'text/markdown' });
downloadBlob(blob, `${filename}.md`);
}
};
const downloadBlob = (blob: Blob, name: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-in fade-in duration-200 no-print">
<div className="bg-white rounded-xl shadow-2xl w-[600px] overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="bg-slate-900 text-white p-6 flex justify-between items-center">
<div>
<h2 className="text-xl font-bold flex items-center gap-2">
<Download size={24} /> Exporter le livre
</h2>
<p className="text-slate-400 text-sm mt-1">{project.title}</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X size={24} />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto flex-1">
{/* Format Selection */}
<div className="grid grid-cols-2 gap-4 mb-8">
<button
onClick={() => setFormat('pdf')}
className={`p-4 rounded-lg border-2 flex flex-col items-center gap-3 transition-all ${format === 'pdf' ? 'border-blue-600 bg-blue-50 text-blue-800' : 'border-slate-200 hover:border-slate-300 text-slate-600'}`}
>
<Printer size={32} />
<div className="font-semibold">PDF (Impression)</div>
</button>
<button
onClick={() => setFormat('word')}
className={`p-4 rounded-lg border-2 flex flex-col items-center gap-3 transition-all ${format === 'word' ? 'border-blue-600 bg-blue-50 text-blue-800' : 'border-slate-200 hover:border-slate-300 text-slate-600'}`}
>
<FileText size={32} />
<div className="font-semibold">Microsoft Word</div>
</button>
<button
onClick={() => setFormat('epub')}
className={`p-4 rounded-lg border-2 flex flex-col items-center gap-3 transition-all ${format === 'epub' ? 'border-blue-600 bg-blue-50 text-blue-800' : 'border-slate-200 hover:border-slate-300 text-slate-600'}`}
>
<Book size={32} />
<div className="font-semibold">EPUB / Ebook</div>
</button>
<button
onClick={() => setFormat('markdown')}
className={`p-4 rounded-lg border-2 flex flex-col items-center gap-3 transition-all ${format === 'markdown' ? 'border-blue-600 bg-blue-50 text-blue-800' : 'border-slate-200 hover:border-slate-300 text-slate-600'}`}
>
<FileJson size={32} />
<div className="font-semibold">Markdown</div>
</button>
</div>
{/* Options Section */}
<div className="bg-slate-50 rounded-lg p-5 border border-slate-200">
<h3 className="text-sm font-bold text-slate-500 uppercase tracking-wider mb-4">
Paramètres d'exportation ({format.toUpperCase()})
</h3>
<div className="space-y-4">
{format === 'pdf' && (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<label className="text-slate-700 font-medium">Format du papier</label>
<span className="text-xs text-slate-400">Géré par l'imprimante (A4, A5...)</span>
</div>
<div className="bg-slate-200 px-3 py-1 rounded text-xs font-mono text-slate-600">Auto</div>
</div>
)}
<div className="flex items-center justify-between">
<label className="text-slate-700 font-medium cursor-pointer" htmlFor="cover">Inclure la page de titre</label>
<input
id="cover"
type="checkbox"
checked={includeCover}
onChange={(e) => setIncludeCover(e.target.checked)}
className="w-5 h-5 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-700 font-medium cursor-pointer" htmlFor="toc">Générer la table des matières</label>
<input
id="toc"
type="checkbox"
checked={includeTOC}
onChange={(e) => setIncludeTOC(e.target.checked)}
className="w-5 h-5 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
</div>
{format === 'epub' && (
<p className="text-xs text-amber-600 bg-amber-50 p-2 rounded mt-2">
Note: L'export EPUB génère un fichier XHTML optimisé prêt à être converti par Calibre ou Kindle Previewer.
</p>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-200 bg-slate-50 flex justify-end gap-3">
<button
onClick={onClose}
className="px-5 py-2 text-slate-600 hover:bg-slate-200 rounded-lg font-medium transition-colors"
>
Annuler
</button>
<button
onClick={handleExport}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium shadow-md transition-all flex items-center gap-2"
>
{format === 'pdf' ? <Printer size={18} /> : <Download size={18} />}
{format === 'pdf' ? 'Imprimer / Enregistrer PDF' : `Télécharger .${format === 'word' ? 'doc' : format === 'epub' ? 'xhtml' : 'md'}`}
</button>
</div>
</div>
</div>
);
};
export default ExportModal;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Sparkles, Feather, Globe, GitGraph, BookOpen, Download, Lightbulb, Zap, ArrowLeft } from 'lucide-react';
interface FeaturesPageProps {
onBack: () => void;
}
const FeaturesPage: React.FC<FeaturesPageProps> = ({ onBack }) => {
const features = [
{ title: "Assistant IA Co-Auteur", icon: Sparkles, desc: "Générez des paragraphes, brainstormez des idées et demandez conseil à une IA qui connaît votre univers." },
{ title: "Bible du Monde Vivante", icon: Globe, desc: "Gérez vos personnages, lieux et objets. L'IA les reconnaît et garde une cohérence absolue." },
{ title: "Story Workflow", icon: GitGraph, desc: "Visualisez votre intrigue sous forme de nœuds et gérez les embranchements de votre récit." },
{ title: "Boîte à Idées Kanban", icon: Lightbulb, desc: "Notez vos idées fugaces et transformez-les en chapitres quand vous êtes prêt." },
{ title: "Mise en page Pro", icon: BookOpen, desc: "Exportez au format PDF, Word ou EPUB avec une mise en page soignée et automatique." },
{ title: "Éditeur Riche", icon: Feather, desc: "Un traitement de texte complet avec mode focus et historique des modifications IA." }
];
return (
<div className="min-h-screen bg-[#eef2ff] py-20 px-8">
<div className="max-w-7xl mx-auto">
<button onClick={onBack} className="flex items-center gap-2 text-slate-500 hover:text-blue-600 mb-12 font-bold transition-colors">
<ArrowLeft size={20} /> Retour
</button>
<h1 className="text-5xl font-black text-slate-900 mb-12 text-center">Un univers d'outils pour votre créativité.</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{features.map((f, i) => (
<div key={i} className="bg-white p-8 rounded-3xl shadow-xl border border-indigo-50 hover:scale-105 transition-transform">
<div className="w-12 h-12 bg-indigo-100 rounded-2xl flex items-center justify-center text-indigo-600 mb-6">
<f.icon size={24} />
</div>
<h3 className="text-xl font-bold text-slate-900 mb-4">{f.title}</h3>
<p className="text-slate-600 leading-relaxed">{f.desc}</p>
</div>
))}
</div>
</div>
</div>
);
};
export default FeaturesPage;

283
components/HelpModal.tsx Normal file
View File

@@ -0,0 +1,283 @@
import React from 'react';
import { X, Keyboard, MousePointerClick, MessageCircle, Sparkles, GitGraph, BookOpen, Command, Globe, Layout, Settings, Lightbulb } from 'lucide-react';
import { ViewMode } from '../types';
interface HelpModalProps {
isOpen: boolean;
onClose: () => void;
viewMode: ViewMode;
}
const Kbd: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<kbd className="px-2 py-1 text-xs font-semibold text-slate-800 bg-slate-100 border border-slate-300 rounded-md shadow-[0px_2px_0px_0px_rgba(203,213,225,1)] mx-1 font-mono">
{children}
</kbd>
);
const HelpModal: React.FC<HelpModalProps> = ({ isOpen, onClose, viewMode }) => {
if (!isOpen) return null;
const renderContent = () => {
switch (viewMode) {
case 'ideas':
return (
<section className="mb-8">
<h3 className="text-lg font-bold text-yellow-600 flex items-center gap-2 border-b border-yellow-100 pb-2 mb-4">
<Lightbulb size={20} /> Boîte à Idées & Tâches
</h3>
<div className="text-sm text-slate-600 space-y-4">
<p>
Un espace de type Kanban pour ne rien oublier. Utilisez-le pour noter des idées fugaces, planifier des recherches ou lister les scènes à écrire.
</p>
<ul className="space-y-3">
<li className="flex items-start gap-2">
<MousePointerClick size={16} className="mt-0.5 shrink-0" />
<span>
<span className="font-semibold text-slate-800">Glisser-Déposer :</span> Déplacez les cartes d'une colonne à l'autre (À faire En cours Validé) pour suivre votre progression.
</span>
</li>
<li className="flex items-start gap-2">
<Layout size={16} className="mt-0.5 shrink-0" />
<span>
<span className="font-semibold text-slate-800">Catégories :</span> Utilisez les catégories (Intrigue, Personnage, Recherche) pour filtrer visuellement vos tâches grâce aux codes couleurs.
</span>
</li>
</ul>
</div>
</section>
);
case 'workflow':
return (
<>
{/* Workflow Section */}
<section className="mb-8">
<h3 className="text-lg font-bold text-indigo-700 flex items-center gap-2 border-b border-indigo-100 pb-2 mb-4">
<GitGraph size={20} /> Organisation Narrative
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm text-slate-600">
<ul className="space-y-3">
<li className="flex items-start gap-2">
<MousePointerClick size={16} className="mt-0.5 shrink-0" />
<span>
<span className="font-semibold text-slate-800">Sélection :</span> <Kbd>Ctrl</Kbd> + Clic pour sélectionner plusieurs cartes. Glissez pour déplacer tout un groupe.
</span>
</li>
<li className="flex items-start gap-2">
<Command size={16} className="mt-0.5 shrink-0" />
<span>
<span className="font-semibold text-slate-800">Copier / Coller :</span> <Kbd>Ctrl</Kbd> + <Kbd>C</Kbd> pour copier les nœuds sélectionnés, <Kbd>Ctrl</Kbd> + <Kbd>V</Kbd> pour coller.
</span>
</li>
<li className="flex items-start gap-2">
<Layout size={16} className="mt-0.5 shrink-0" />
<span>
<span className="font-semibold text-slate-800">Connexions :</span> Tirez depuis le cercle à droite d'une carte pour lier les événements.
</span>
</li>
</ul>
</div>
</section>
{/* Dialogue Intelligent */}
<section className="bg-blue-50 p-6 rounded-xl border border-blue-100 mb-8">
<h3 className="text-lg font-bold text-blue-800 flex items-center gap-2 border-b border-blue-200 pb-2 mb-4">
<MessageCircle size={20} /> Mode Dialogue (Workflow)
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
<div>
<div className="font-semibold text-slate-800 mb-1">Écriture Rapide</div>
<p className="text-slate-600 leading-relaxed mb-3">
Tapez un nom et <Kbd>Entrée</Kbd> : le formatage <code>Nom: </code> s'ajoute seul.
</p>
<p className="text-slate-600 leading-relaxed">
Dans un dialogue, <Kbd>Entrée</Kbd> change de ligne et <strong>devine le prochain interlocuteur</strong> automatiquement.
</p>
</div>
<div>
<div className="font-semibold text-slate-800 mb-1">Rotation & Insertion</div>
<p className="text-slate-600 leading-relaxed mb-3">
<Kbd>Tab</Kbd> permute instantanément entre les personnages présents dans la scène.
</p>
<p className="text-slate-600 leading-relaxed">
Utilisez <Kbd>@</Kbd> pour insérer un personnage, <Kbd>#</Kbd> pour un lieu.
</p>
</div>
</div>
</section>
</>
);
case 'world_building':
return (
<section className="mb-8">
<h3 className="text-lg font-bold text-green-700 flex items-center gap-2 border-b border-green-100 pb-2 mb-4">
<Globe size={20} /> Bible du Monde
</h3>
<div className="text-sm text-slate-600 space-y-4">
<p>
La bible du monde permet de centraliser toutes les informations sur vos personnages et lieux.
Ces informations sont <strong>lues par l'IA</strong> pour assurer la cohérence de l'histoire.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<div className="bg-slate-50 p-4 rounded-lg">
<h4 className="font-bold text-slate-800 mb-2">Modèles Personnalisés</h4>
<p>
Cliquez sur le bouton "Modèles" pour ajouter des champs spécifiques (ex: "Type de Magie", "Allégeance") à tous vos personnages ou lieux.
</p>
</div>
<div className="bg-slate-50 p-4 rounded-lg">
<h4 className="font-bold text-slate-800 mb-2">Contexte Automatique</h4>
<p>
Le champ "Contexte Narratif" se remplit automatiquement au fur et à mesure que vous écrivez votre histoire et que l'IA détecte l'évolution des personnages.
</p>
</div>
</div>
</div>
</section>
);
case 'settings':
return (
<section className="mb-8">
<h3 className="text-lg font-bold text-slate-700 flex items-center gap-2 border-b border-slate-100 pb-2 mb-4">
<Settings size={20} /> Paramètres du Livre
</h3>
<p className="text-sm text-slate-600 mb-4">
Ces réglages sont cruciaux pour l'Assistant IA. Ils définissent le "ton" de toutes les générations de texte.
</p>
<ul className="list-disc pl-5 space-y-2 text-sm text-slate-600">
<li><strong>Style Guide :</strong> Soyez précis sur le style (ex: "phrases courtes", "beaucoup de métaphores", "humour noir").</li>
<li><strong>POV (Point de Vue) :</strong> Définit si l'IA doit écrire en "Je" ou "Il/Elle".</li>
</ul>
</section>
);
case 'write':
default:
return (
<section className="mb-8">
<h3 className="text-lg font-bold text-amber-600 flex items-center gap-2 border-b border-amber-100 pb-2 mb-4">
<Sparkles size={20} /> Éditeur & Assistant IA
</h3>
<div className="space-y-4 text-sm text-slate-600">
<div className="bg-amber-50 p-4 rounded-lg border border-amber-100">
<h4 className="font-bold text-amber-800 mb-2">Menu Contextuel Intelligent</h4>
<p>Sélectionnez du texte et faites un <strong>clic droit</strong> pour :</p>
<ul className="grid grid-cols-2 gap-2 mt-2 pl-4">
<li className="flex items-center gap-2"><div className="w-1.5 h-1.5 rounded-full bg-amber-400"/>Corriger l'orthographe</li>
<li className="flex items-center gap-2"><div className="w-1.5 h-1.5 rounded-full bg-amber-400"/>Reformuler / Améliorer</li>
<li className="flex items-center gap-2"><div className="w-1.5 h-1.5 rounded-full bg-amber-400"/>Développer (Show, don't tell)</li>
<li className="flex items-center gap-2"><div className="w-1.5 h-1.5 rounded-full bg-amber-400"/>Continuer l'écriture</li>
</ul>
</div>
<p>
<span className="font-semibold text-slate-800">Historique des versions :</span> Activez la marge de droite (icône horloge) pour voir toutes les interventions de l'IA et revenir en arrière si nécessaire.
</p>
<p>
<span className="font-semibold text-slate-800">Chat Latéral :</span> Posez des questions sur votre histoire, demandez des résumés ou des idées de rebondissements. L'IA connaît le contexte de vos chapitres précédents et de vos fiches personnages.
</p>
<div className="mt-6 border-t border-slate-100 pt-4">
<h4 className="font-bold text-slate-700 mb-3 flex items-center gap-2">
<Keyboard size={16} /> Raccourcis Clavier (Éditeur)
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-slate-50 p-4 rounded-lg border border-slate-100">
<div className="space-y-3">
<div className="flex justify-between items-center text-xs">
<span className="text-slate-600">Mettre en Gras</span>
<span><Kbd>Ctrl</Kbd> + <Kbd>B</Kbd></span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-slate-600">Mettre en Italique</span>
<span><Kbd>Ctrl</Kbd> + <Kbd>I</Kbd></span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-slate-600">Souligner</span>
<span><Kbd>Ctrl</Kbd> + <Kbd>U</Kbd></span>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center text-xs">
<span className="text-slate-600">Tout sélectionner</span>
<span><Kbd>Ctrl</Kbd> + <Kbd>A</Kbd></span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-slate-600">Annuler</span>
<span><Kbd>Ctrl</Kbd> + <Kbd>Z</Kbd></span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-slate-600">Rétablir</span>
<span><Kbd>Ctrl</Kbd> + <Kbd>Shift</Kbd> + <Kbd>Z</Kbd></span>
</div>
</div>
</div>
</div>
</div>
</section>
);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white rounded-xl shadow-2xl w-[800px] max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="bg-slate-900 text-white p-6 flex justify-between items-center shrink-0">
<div>
<h2 className="text-xl font-bold flex items-center gap-2">
<BookOpen size={24} className="text-blue-400" /> Aide : {
viewMode === 'workflow' ? 'Workflow & Dialogues' :
viewMode === 'world_building' ? 'Bible du Monde' :
viewMode === 'settings' ? 'Paramètres' :
viewMode === 'ideas' ? 'Boîte à Idées' :
'Éditeur & IA'
}
</h2>
<p className="text-slate-400 text-sm mt-1">Astuces pour l'écran actuel.</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors p-2 hover:bg-slate-800 rounded-full">
<X size={24} />
</button>
</div>
{/* Content */}
<div className="overflow-y-auto p-8">
{/* Context Specific Content */}
{renderContent()}
{/* General Footer Section (Always visible) */}
<div className="border-t border-slate-100 pt-6 mt-6">
<h4 className="text-sm font-bold text-slate-500 uppercase tracking-wider mb-4">Raccourcis Généraux</h4>
<div className="grid grid-cols-2 gap-4 text-xs text-slate-600">
<div className="flex justify-between">
<span>Sauvegarde Automatique</span>
<span className="font-mono text-slate-400">Permanente</span>
</div>
<div className="flex justify-between">
<span>Menu Latéral</span>
<span>Clic sur le burger</span>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-200 bg-slate-50 flex justify-end">
<button
onClick={onClose}
className="px-6 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-900 transition-colors font-medium"
>
Fermer
</button>
</div>
</div>
</div>
);
};
export default HelpModal;

371
components/IdeaBoard.tsx Normal file
View File

@@ -0,0 +1,371 @@
import React, { useState } from 'react';
import { Idea, IdeaStatus, IdeaCategory } from '../types';
import { Plus, X, GripVertical, CheckCircle, Circle, Clock, Lightbulb, Search, Trash2, Edit3, Save } from 'lucide-react';
interface IdeaBoardProps {
ideas: Idea[];
onUpdate: (ideas: Idea[]) => void;
}
const CATEGORIES: Record<IdeaCategory, { label: string, color: string, icon: any }> = {
plot: { label: 'Intrigue', color: 'bg-rose-100 text-rose-800 border-rose-200', icon: Lightbulb },
character: { label: 'Personnage', color: 'bg-blue-100 text-blue-800 border-blue-200', icon: Search },
research: { label: 'Recherche', color: 'bg-amber-100 text-amber-800 border-amber-200', icon: Search },
todo: { label: 'À faire', color: 'bg-slate-100 text-slate-800 border-slate-200', icon: CheckCircle },
};
const STATUS_LABELS: Record<IdeaStatus, string> = {
todo: 'Idées / À faire',
progress: 'En cours',
done: 'Terminé / Validé'
};
const MAX_DESCRIPTION_LENGTH = 500;
const IdeaBoard: React.FC<IdeaBoardProps> = ({ ideas, onUpdate }) => {
const [newIdeaTitle, setNewIdeaTitle] = useState('');
const [newIdeaCategory, setNewIdeaCategory] = useState<IdeaCategory>('plot');
// Drag and Drop State
const [draggedIdeaId, setDraggedIdeaId] = useState<string | null>(null);
// Modal State for Edit/Quick Add
const [editingItem, setEditingItem] = useState<Partial<Idea> | null>(null);
// --- ACTIONS ---
const handleAddIdea = (e: React.FormEvent) => {
e.preventDefault();
if (!newIdeaTitle.trim()) return;
const newIdea: Idea = {
id: `idea-${Date.now()}`,
title: newIdeaTitle,
description: '',
category: newIdeaCategory,
status: 'todo',
createdAt: Date.now()
};
onUpdate([...ideas, newIdea]);
setNewIdeaTitle('');
};
const handleDelete = (id: string) => {
if(confirm("Supprimer cette carte ?")) {
onUpdate(ideas.filter(i => i.id !== id));
if (editingItem?.id === id) setEditingItem(null);
}
};
const handleSaveEdit = () => {
if (!editingItem || !editingItem.title?.trim()) return;
if (editingItem.id) {
// Update existing
onUpdate(ideas.map(i => i.id === editingItem.id ? { ...i, ...editingItem } as Idea : i));
} else {
// Create new from modal
const newIdea: Idea = {
id: `idea-${Date.now()}`,
title: editingItem.title || '',
description: editingItem.description || '',
category: editingItem.category || 'plot',
status: editingItem.status || 'todo',
createdAt: Date.now()
};
onUpdate([...ideas, newIdea]);
}
setEditingItem(null);
};
const openQuickAdd = (status: IdeaStatus) => {
setEditingItem({
title: '',
description: '',
category: 'plot',
status: status
});
};
const openEdit = (idea: Idea) => {
setEditingItem({ ...idea });
};
// --- DRAG HANDLERS ---
const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedIdeaId(id);
e.dataTransfer.effectAllowed = 'move';
};
const handleDrop = (e: React.DragEvent, status: IdeaStatus) => {
e.preventDefault();
if (draggedIdeaId) {
const updatedIdeas = ideas.map(idea =>
idea.id === draggedIdeaId ? { ...idea, status } : idea
);
onUpdate(updatedIdeas);
setDraggedIdeaId(null);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
// --- RENDERERS ---
const Column = ({ title, status, icon: Icon }: { title: string, status: IdeaStatus, icon: any }) => {
const columnIdeas = ideas.filter(i => i.status === status);
return (
<div
className="flex-1 bg-[#eef2ff] rounded-xl border border-indigo-100 flex flex-col h-full overflow-hidden transition-colors hover:border-blue-200"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, status)}
onDoubleClick={() => openQuickAdd(status)}
title="Double-cliquez dans le vide pour ajouter une carte ici"
>
{/* Column Header */}
<div className={`p-4 border-b border-indigo-200 flex justify-between items-center ${
status === 'todo' ? 'bg-[#eef2ff]' :
status === 'progress' ? 'bg-indigo-50' :
'bg-green-50'
}`}>
<div className="flex items-center gap-2 font-bold text-slate-700">
<Icon size={18} />
{title}
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); openQuickAdd(status); }}
className="p-1 hover:bg-white rounded-full text-slate-400 hover:text-blue-600 transition-colors"
>
<Plus size={16} />
</button>
<span className="text-xs font-semibold bg-white px-2 py-1 rounded-full border border-indigo-100 text-slate-500">
{columnIdeas.length}
</span>
</div>
</div>
{/* Column Body */}
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar">
{columnIdeas.map(idea => {
const truncatedDesc = idea.description.length > 300
? idea.description.substring(0, 300) + '...'
: idea.description;
return (
<div
key={idea.id}
draggable
onDragStart={(e) => handleDragStart(e, idea.id)}
onDoubleClick={(e) => {
e.stopPropagation(); // Prevent column double-click
openEdit(idea);
}}
className="bg-white p-3 rounded-lg shadow-sm border border-slate-200 cursor-grab active:cursor-grabbing hover:shadow-md hover:border-blue-300 transition-all group relative animate-in zoom-in-95 duration-200"
>
<div className="flex justify-between items-start mb-2">
<span className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded-full flex items-center gap-1 ${CATEGORIES[idea.category].color}`}>
{CATEGORIES[idea.category].label}
</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); openEdit(idea); }}
className="text-slate-300 hover:text-blue-500"
>
<Edit3 size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(idea.id); }}
className="text-slate-300 hover:text-red-500"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* CARD CONTENT */}
<div className="mb-2">
<h4 className="font-bold text-slate-800 text-sm mb-1 leading-tight">{idea.title}</h4>
{idea.description && (
<p className="text-xs text-slate-500 line-clamp-3 leading-relaxed" title={idea.description.length > 300 ? "Description tronquée (voir détail)" : undefined}>
{truncatedDesc}
</p>
)}
</div>
<div className="flex justify-between items-center text-xs text-slate-400 border-t border-slate-50 pt-2 mt-2">
<span className="flex items-center gap-1">
<Clock size={10} /> {new Date(idea.createdAt).toLocaleDateString()}
</span>
<GripVertical size={14} className="opacity-20" />
</div>
</div>
);
})}
{columnIdeas.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-slate-300 text-sm italic border-2 border-dashed border-indigo-200 rounded-lg m-1">
<span className="mb-2">Vide</span>
<span className="text-xs opacity-70">Double-cliquez pour ajouter</span>
</div>
)}
</div>
</div>
);
};
return (
<div className="flex flex-col h-full bg-white p-6 gap-6 relative">
{/* Header & Add Form (Top Bar) */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 bg-white p-4 rounded-xl border border-slate-200 shadow-sm shrink-0">
<div>
<h2 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
<Lightbulb className="text-yellow-500" /> Boîte à Idées
</h2>
<p className="text-slate-500 text-sm">Organisez vos tâches, idées de scènes et recherches.</p>
</div>
<form onSubmit={handleAddIdea} className="flex-1 w-full md:w-auto max-w-2xl flex gap-2">
<select
value={newIdeaCategory}
onChange={(e) => setNewIdeaCategory(e.target.value as IdeaCategory)}
className="bg-[#eef2ff] border border-indigo-200 text-slate-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2.5 outline-none"
>
{Object.entries(CATEGORIES).map(([key, val]) => (
<option key={key} value={key}>{val.label}</option>
))}
</select>
<input
type="text"
value={newIdeaTitle}
onChange={(e) => setNewIdeaTitle(e.target.value)}
placeholder="Titre de la nouvelle idée..."
className="flex-1 bg-[#eef2ff] border border-indigo-200 text-slate-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2.5 outline-none font-medium"
/>
<button
type="submit"
disabled={!newIdeaTitle.trim()}
className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 disabled:opacity-50 transition-colors flex items-center gap-2"
>
<Plus size={18} />
</button>
</form>
</div>
{/* Kanban Board */}
<div className="flex-1 grid grid-cols-1 md:grid-cols-3 gap-6 min-h-0">
<Column title="Idées / À faire" status="todo" icon={Circle} />
<Column title="En cours" status="progress" icon={Clock} />
<Column title="Terminé" status="done" icon={CheckCircle} />
</div>
{/* EDIT / QUICK ADD MODAL */}
{editingItem && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90%]">
<div className="bg-[#eef2ff] border-b border-indigo-100 p-4 flex justify-between items-center">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
{editingItem.id ? <Edit3 size={18}/> : <Plus size={18}/>}
{editingItem.id ? 'Éditer la carte' : 'Ajouter une carte'}
</h3>
<button onClick={() => setEditingItem(null)} className="text-slate-400 hover:text-slate-600">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4 overflow-y-auto">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Titre</label>
<input
type="text"
value={editingItem.title}
onChange={(e) => setEditingItem({...editingItem, title: e.target.value})}
className="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none font-bold text-slate-800"
placeholder="Titre de la tâche ou de l'idée..."
autoFocus
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Description</label>
<textarea
value={editingItem.description}
onChange={(e) => setEditingItem({...editingItem, description: e.target.value})}
maxLength={MAX_DESCRIPTION_LENGTH}
className="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none min-h-[120px] text-sm text-slate-600 leading-relaxed resize-none"
placeholder="Détails, notes, liens..."
/>
<div className={`text-right text-xs mt-1 transition-colors ${
(editingItem.description?.length || 0) >= MAX_DESCRIPTION_LENGTH ? 'text-red-500 font-bold' :
(editingItem.description?.length || 0) > MAX_DESCRIPTION_LENGTH * 0.9 ? 'text-orange-500' : 'text-slate-400'
}`}>
{editingItem.description?.length || 0} / {MAX_DESCRIPTION_LENGTH} caractères
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Catégorie</label>
<select
value={editingItem.category}
onChange={(e) => setEditingItem({...editingItem, category: e.target.value as IdeaCategory})}
className="w-full p-2 bg-white border border-slate-300 rounded-lg text-sm outline-none focus:border-blue-500"
>
{Object.entries(CATEGORIES).map(([key, val]) => (
<option key={key} value={key}>{val.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Statut</label>
<select
value={editingItem.status}
onChange={(e) => setEditingItem({...editingItem, status: e.target.value as IdeaStatus})}
className="w-full p-2 bg-white border border-slate-300 rounded-lg text-sm outline-none focus:border-blue-500"
>
{Object.entries(STATUS_LABELS).map(([key, val]) => (
<option key={key} value={key}>{val}</option>
))}
</select>
</div>
</div>
</div>
<div className="p-4 border-t border-slate-200 bg-[#eef2ff] flex justify-end gap-2 shrink-0">
{editingItem.id && (
<button
onClick={() => handleDelete(editingItem.id!)}
className="mr-auto text-red-500 hover:text-red-700 text-sm font-medium px-3 py-2"
>
Supprimer
</button>
)}
<button
onClick={() => setEditingItem(null)}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg text-sm font-medium"
>
Annuler
</button>
<button
onClick={handleSaveEdit}
disabled={!editingItem.title?.trim()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium shadow-sm disabled:opacity-50 flex items-center gap-2"
>
<Save size={16} /> Enregistrer
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default IdeaBoard;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { Book, Sparkles, Feather, Globe, ShieldCheck, Zap, ArrowRight, Star } from 'lucide-react';
interface LandingPageProps {
onLogin: () => void;
onPricing: () => void;
onFeatures: () => void;
}
const LandingPage: React.FC<LandingPageProps> = ({ onLogin, onPricing, onFeatures }) => {
return (
<div className="min-h-screen bg-[#eef2ff] font-sans selection:bg-blue-200">
{/* Navbar */}
<nav className="fixed top-0 w-full bg-white/80 backdrop-blur-md z-50 border-b border-indigo-100 px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="bg-blue-600 p-1.5 rounded-lg">
<Book className="text-white" size={24} />
</div>
<span className="text-xl font-black text-slate-900 tracking-tight">PlumeIA</span>
</div>
<div className="hidden md:flex items-center gap-8 text-sm font-medium text-slate-600">
<button onClick={onFeatures} className="hover:text-blue-600 transition-colors">Fonctionnalités</button>
<button onClick={onPricing} className="hover:text-blue-600 transition-colors">Tarifs</button>
<a href="#" className="hover:text-blue-600 transition-colors">Blog</a>
</div>
<div className="flex items-center gap-4">
<button onClick={onLogin} className="text-sm font-bold text-slate-700 hover:text-blue-600 px-4 py-2">Connexion</button>
<button onClick={onLogin} className="bg-slate-900 text-white px-5 py-2.5 rounded-full text-sm font-bold hover:bg-blue-600 transition-all shadow-lg hover:shadow-blue-200">Essai Gratuit</button>
</div>
</nav>
{/* Hero Section */}
<header className="pt-32 pb-20 px-8 max-w-7xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-white border border-indigo-100 px-4 py-2 rounded-full text-xs font-bold text-blue-600 mb-8 shadow-sm">
<Sparkles size={14} className="animate-pulse" /> NOUVEAUTÉ : GÉNÉRATION DE BIBLE DU MONDE PAR IA
</div>
<h1 className="text-5xl md:text-7xl font-black text-slate-900 leading-[1.1] mb-6">
L'écriture d'un roman, <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-indigo-500">augmentée par l'IA.</span>
</h1>
<p className="text-xl text-slate-600 max-w-2xl mx-auto mb-10 leading-relaxed">
PlumeIA est le premier éditeur intelligent qui comprend votre univers, vos personnages et votre style pour vous aider à franchir la page blanche.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button onClick={onLogin} className="w-full sm:w-auto bg-blue-600 text-white px-8 py-4 rounded-full text-lg font-bold hover:bg-blue-700 transition-all shadow-xl shadow-blue-200 flex items-center gap-2 justify-center">
Commencer mon livre <ArrowRight size={20} />
</button>
<button onClick={onFeatures} className="w-full sm:w-auto bg-white text-slate-900 border border-slate-200 px-8 py-4 rounded-full text-lg font-bold hover:bg-slate-50 transition-all">
Voir la démo
</button>
</div>
<div className="mt-20 relative">
<div className="absolute -inset-4 bg-gradient-to-r from-blue-500/20 to-indigo-500/20 blur-3xl -z-10 rounded-full" />
<div className="bg-white rounded-2xl shadow-2xl border border-indigo-100 p-2 overflow-hidden max-w-5xl mx-auto">
<img
src="https://images.unsplash.com/photo-1455390582262-044cdead277a?auto=format&fit=crop&q=80&w=2000"
alt="Editor Preview"
className="rounded-xl object-cover h-[500px] w-full"
/>
</div>
</div>
</header>
{/* Social Proof */}
<section className="bg-white py-24 px-8 border-y border-indigo-100">
<div className="max-w-7xl mx-auto text-center">
<h2 className="text-slate-400 text-sm font-bold uppercase tracking-widest mb-12">Utilisé par les auteurs de demain</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-12 items-center grayscale opacity-60">
<span className="text-3xl font-serif font-black italic">FantasyMag</span>
<span className="text-2xl font-sans font-bold">Writer's Hub</span>
<span className="text-3xl font-serif">L'Éditeur</span>
<span className="text-2xl font-sans font-black tracking-tight underline underline-offset-4 decoration-blue-500">Novelty</span>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-slate-900 text-slate-400 py-12 px-8 text-center">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-center gap-2 text-white mb-6">
<Book className="text-blue-500" size={24} />
<span className="text-xl font-bold">PlumeIA</span>
</div>
<p className="text-sm">© 2024 PlumeIA. Tous droits réservés.</p>
</div>
</footer>
</div>
);
};
export default LandingPage;

105
components/LoginPage.tsx Normal file
View File

@@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { useAuthContext } from '../AuthContext';
import { Loader2, AlertCircle, ArrowRight } from 'lucide-react';
interface LoginPageProps {
onSuccess: () => void;
onRegister: () => void;
}
const LoginPage: React.FC<LoginPageProps> = ({ onSuccess, onRegister }) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
// Use the global auth context
const { login } = useAuthContext();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const result = await login({ email, password });
if (result && result.error) {
setError(result.error);
setLoading(false);
} else {
onSuccess();
}
} catch (err) {
setError("Une erreur inattendue est survenue.");
setLoading(false);
}
}
return (
<div className="min-h-screen bg-slate-50 flex overflow-hidden font-sans text-slate-900 items-center justify-center p-4">
{/* Using styles similar to AuthPage for consistency */}
<div className="w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden p-8 animate-in fade-in zoom-in duration-300">
<div className="text-center mb-8">
<h1 className="text-3xl font-black text-slate-900 mb-2">Connexion</h1>
<p className="text-slate-500">Bienvenue ! Connectez-vous à votre compte</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-100 text-red-600 text-sm font-medium rounded-xl flex items-center gap-2 animate-in shake duration-300">
<AlertCircle size={18} />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-black text-slate-500 uppercase tracking-widest ml-1" htmlFor="email">Email</label>
<input
id="email"
type="email"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-medium transition-all"
placeholder="votre@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-1">
<label className="text-xs font-black text-slate-500 uppercase tracking-widest ml-1" htmlFor="password">Mot de passe</label>
<input
id="password"
type="password"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-medium transition-all"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button
type="submit"
className="w-full bg-slate-900 text-white py-4 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-blue-600 transition-all shadow-xl disabled:opacity-50 mt-6"
disabled={loading}
>
{loading ? <Loader2 className="animate-spin" /> : "Se connecter"} <ArrowRight size={18} />
</button>
</form>
<div className="mt-8 text-center text-sm text-slate-500">
Pas encore de compte ?{" "}
<button
onClick={onRegister}
className="font-bold text-blue-600 hover:text-blue-800 transition-colors ml-1"
>
Créer un compte
</button>
</div>
</div>
</div>
);
}
export default LoginPage;

59
components/Pricing.tsx Normal file
View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Check, ArrowLeft } from 'lucide-react';
import { PlanType } from '../types';
interface PricingProps {
currentPlan: PlanType;
onBack: () => void;
onSelectPlan: (plan: PlanType) => void;
}
const Pricing: React.FC<PricingProps> = ({ currentPlan, onBack, onSelectPlan }) => {
const plans = [
{ id: 'free', name: 'Gratuit', price: '0€', desc: 'Idéal pour découvrir PlumeIA.', features: ['10 actions IA / mois', '1 projet actif', 'Bible du monde simple'] },
{ id: 'pro', name: 'Auteur Pro', price: '12€', desc: 'Pour les écrivains sérieux.', features: ['500 actions IA / mois', 'Projets illimités', 'Export Word & EPUB', 'Support prioritaire'], popular: true },
{ id: 'master', name: 'Maître Plume', price: '29€', desc: 'Le summum de l\'écriture IA.', features: ['Actions IA illimitées', 'Accès Gemini 3 Pro', 'Bible du monde avancée', 'Outils de révision avancés'] },
];
return (
<div className="min-h-screen bg-[#eef2ff] py-20 px-8">
<div className="max-w-6xl mx-auto">
<button onClick={onBack} className="flex items-center gap-2 text-slate-500 hover:text-blue-600 mb-12 font-bold transition-colors">
<ArrowLeft size={20} /> Retour
</button>
<div className="text-center mb-16">
<h2 className="text-4xl font-black text-slate-900 mb-4">Choisissez votre destin d'écrivain.</h2>
<p className="text-slate-500">Passez au plan supérieur pour libérer toute la puissance de l'IA.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{plans.map((p) => (
<div key={p.id} className={`bg-white rounded-3xl p-8 border transition-all ${p.popular ? 'border-blue-500 shadow-2xl scale-105 z-10' : 'border-indigo-100 shadow-xl'}`}>
<div className="mb-8">
<h4 className="text-xl font-bold text-slate-900 mb-2">{p.name}</h4>
<div className="text-4xl font-black text-slate-900 mb-2">{p.price}<span className="text-sm font-normal text-slate-400">/mois</span></div>
<p className="text-sm text-slate-500">{p.desc}</p>
</div>
<ul className="space-y-4 mb-10">
{p.features.map((f, i) => (
<li key={i} className="flex items-center gap-3 text-sm text-slate-700">
<div className="text-blue-500 bg-blue-50 p-0.5 rounded-full"><Check size={14} /></div>
{f}
</li>
))}
</ul>
<button
onClick={() => onSelectPlan(p.id as PlanType)}
className={`w-full py-4 rounded-2xl font-black transition-all ${p.id === currentPlan ? 'bg-slate-100 text-slate-400 cursor-default' : p.popular ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-slate-900 text-white hover:bg-slate-800'}`}
>
{p.id === currentPlan ? 'Plan Actuel' : 'Sélectionner'}
</button>
</div>
))}
</div>
</div>
</div>
);
};
export default Pricing;

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;

View File

@@ -0,0 +1,680 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { WorkflowData, PlotNode, PlotConnection, PlotNodeType, Entity, EntityType } from '../types';
import { Plus, Trash2, ArrowRight, BookOpen, MessageCircle, Zap, Palette, Save, Link2 } from 'lucide-react';
interface StoryWorkflowProps {
data: WorkflowData;
onUpdate: (data: WorkflowData) => void;
entities: Entity[];
onNavigateToEntity: (entityId: string) => void;
}
const CARD_WIDTH = 260;
const CARD_HEIGHT = 220;
const INITIAL_COLORS = [
'#ffffff', // White
'#dbeafe', // Blue
'#dcfce7', // Green
'#fef9c3', // Yellow
'#fee2e2', // Red
'#f3e8ff', // Purple
];
const renderTextWithLinks = (text: string, entities: Entity[], onNavigate: (id: string) => void) => {
if (!text) return <span className="text-slate-400 italic">Description...</span>;
const parts: (string | React.ReactNode)[] = [text];
entities.forEach(entity => {
if (!entity.name) return;
const regex = new RegExp(`(${entity.name})`, 'gi');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (typeof part === 'string') {
const split = part.split(regex);
if (split.length > 1) {
const newParts = split.map((s, idx) => {
if (s.toLowerCase() === entity.name.toLowerCase()) {
return (
<span
key={`${entity.id}-${idx}`}
onClick={(e) => { e.stopPropagation(); onNavigate(entity.id); }}
className="text-indigo-600 hover:text-indigo-800 underline decoration-indigo-300 hover:decoration-indigo-600 cursor-pointer font-medium bg-indigo-50 px-0.5 rounded transition-all"
title={`Voir la fiche de ${entity.name}`}
>
{s}
</span>
);
}
return s;
});
parts.splice(i, 1, ...newParts);
i += newParts.length - 1;
}
}
}
});
return <>{parts}</>;
};
interface StoryNodeProps {
node: PlotNode;
isSelected: boolean;
isEditing: boolean;
activeColorPickerId: string | null;
entities: Entity[];
savedColors: string[];
onMouseDown: (e: React.MouseEvent, id: string) => void;
onMouseUp: (e: React.MouseEvent, id: string) => void;
onStartConnection: (e: React.MouseEvent, id: string) => void;
onUpdate: (id: string, updates: Partial<PlotNode>) => void;
onSetEditing: (id: string | null) => void;
onToggleColorPicker: (id: string) => void;
onSaveColor: (color: string) => void;
onNavigateToEntity: (id: string) => void;
onInputFocus: (e: React.FocusEvent) => void;
onInputCheckAutocomplete: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>, id: string, field: 'title'|'description') => void;
onKeyDownInInput: (e: React.KeyboardEvent, id: string) => void;
}
const StoryNode = React.memo(({
node, isSelected, isEditing, activeColorPickerId, entities, savedColors,
onMouseDown, onMouseUp, onStartConnection, onUpdate, onSetEditing,
onToggleColorPicker, onSaveColor, onNavigateToEntity,
onInputFocus, onInputCheckAutocomplete, onKeyDownInInput
}: StoryNodeProps) => {
const [showTypePicker, setShowTypePicker] = useState(false);
const richDescription = useMemo(() => {
return renderTextWithLinks(node.description, entities, onNavigateToEntity);
}, [node.description, entities, onNavigateToEntity]);
return (
<div
className={`absolute flex flex-col rounded-xl shadow-sm border transition-all z-10 group
${isSelected ? 'ring-2 ring-indigo-500 shadow-lg scale-[1.01]' : 'border-slate-200 hover:shadow-md'}
`}
style={{
transform: `translate3d(${node.x}px, ${node.y}px, 0)`,
width: CARD_WIDTH,
height: CARD_HEIGHT,
backgroundColor: node.color || '#ffffff',
willChange: 'transform'
}}
onMouseDown={(e) => onMouseDown(e, node.id)}
onMouseUp={(e) => onMouseUp(e, node.id)}
onMouseLeave={() => setShowTypePicker(false)}
>
<div className="h-1.5 rounded-t-xl bg-black/5 w-full cursor-grab active:cursor-grabbing" />
<div className="flex-1 px-4 pb-4 pt-2 flex flex-col overflow-hidden relative">
<div className="flex justify-between items-start mb-2 relative">
{isEditing ? (
<input
className="font-bold text-slate-800 bg-white/50 border-b border-indigo-400 outline-none w-full mr-6 text-sm p-1 rounded"
value={node.title}
onChange={(e) => onUpdate(node.id, { title: e.target.value })}
onFocus={onInputFocus}
autoFocus
/>
) : (
<div
className="font-bold text-slate-800 cursor-text truncate mr-6 text-sm"
onDoubleClick={() => onSetEditing(node.id)}
>
{node.title}
</div>
)}
<button
onClick={(e) => { e.stopPropagation(); onToggleColorPicker(node.id); }}
className="p-1 rounded-full hover:bg-black/10 text-slate-400 hover:text-indigo-600 transition-colors absolute right-0 top-0"
>
<Palette size={14} />
</button>
{activeColorPickerId === node.id && (
<div className="absolute right-[-10px] top-8 bg-white rounded-lg shadow-xl border border-slate-200 p-3 z-50 w-48 animate-in fade-in zoom-in-95 duration-100 cursor-default" onMouseDown={(e) => e.stopPropagation()}>
<div className="grid grid-cols-4 gap-2 mb-3">
{savedColors.map(color => (
<button
key={color}
onClick={() => onUpdate(node.id, { color })}
className={`w-8 h-8 rounded-full border border-slate-200 shadow-sm transition-transform hover:scale-110 ${node.color === color ? 'ring-2 ring-offset-1 ring-indigo-400' : ''}`}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-slate-300 shadow-inner">
<input
type="color"
className="absolute -top-2 -left-2 w-16 h-16 cursor-pointer"
value={node.color || '#ffffff'}
onChange={(e) => onUpdate(node.id, { color: e.target.value })}
/>
</div>
<button
onClick={() => onSaveColor(node.color || '#ffffff')}
className="text-[10px] font-bold text-indigo-600 hover:text-indigo-800 hover:underline flex-1 text-right"
>
+ SAUVER
</button>
</div>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar relative mb-4">
{isEditing ? (
<textarea
className={`w-full h-full bg-white/70 resize-none outline-none text-xs leading-relaxed p-2 rounded border border-indigo-100 shadow-inner ${node.type === 'dialogue' ? 'font-mono text-slate-700' : 'text-slate-600'}`}
placeholder={node.type === 'dialogue' ? "Héros: Salut !\nGuide: ..." : "Résumé de l'intrigue..."}
value={node.description}
onChange={(e) => onInputCheckAutocomplete(e, node.id, 'description')}
onKeyDown={(e) => onKeyDownInInput(e, node.id)}
onFocus={onInputFocus}
onBlur={() => onSetEditing(null)}
/>
) : (
<div
className={`w-full h-full text-xs text-slate-600 leading-relaxed p-1 cursor-text whitespace-pre-wrap ${node.type === 'dialogue' ? 'font-mono bg-indigo-50/30 rounded pl-2 border-l-2 border-indigo-200' : ''}`}
onClick={() => onSetEditing(node.id)}
>
{richDescription}
</div>
)}
</div>
<div className="absolute bottom-2 right-2 z-20">
{showTypePicker && (
<div className="absolute bottom-full mb-2 right-0 bg-white shadow-xl border border-slate-200 rounded-lg p-1 flex gap-1 animate-in zoom-in-95 duration-100 w-max" onMouseDown={(e) => e.stopPropagation()}>
<button
onClick={(e) => { e.stopPropagation(); onUpdate(node.id, { type: 'story' }); setShowTypePicker(false); }}
className={`p-1.5 rounded hover:bg-slate-100 ${node.type === 'story' ? 'bg-indigo-50 ring-1 ring-indigo-200' : ''}`}
title="Narration"
>
<BookOpen size={14} className="text-slate-500" />
</button>
<button
onClick={(e) => { e.stopPropagation(); onUpdate(node.id, { type: 'action' }); setShowTypePicker(false); }}
className={`p-1.5 rounded hover:bg-amber-50 ${node.type === 'action' ? 'bg-amber-50 ring-1 ring-amber-200' : ''}`}
title="Action"
>
<Zap size={14} className="text-amber-500" />
</button>
<button
onClick={(e) => { e.stopPropagation(); onUpdate(node.id, { type: 'dialogue' }); setShowTypePicker(false); }}
className={`p-1.5 rounded hover:bg-blue-50 ${node.type === 'dialogue' ? 'bg-blue-50 ring-1 ring-blue-200' : ''}`}
title="Dialogue"
>
<MessageCircle size={14} className="text-blue-500" />
</button>
</div>
)}
<button
className="p-1.5 rounded-full bg-white/70 hover:bg-white shadow-sm border border-slate-100 hover:border-indigo-200 transition-all opacity-80 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); setShowTypePicker(!showTypePicker); }}
>
{node.type === 'story' && <BookOpen size={14} className="text-slate-500" />}
{node.type === 'action' && <Zap size={14} className="text-amber-500" />}
{node.type === 'dialogue' && <MessageCircle size={14} className="text-blue-500" />}
</button>
</div>
</div>
<button
className="absolute -right-3 top-1/2 -translate-y-1/2 w-6 h-6 bg-white border border-slate-300 rounded-full flex items-center justify-center text-slate-400 hover:text-indigo-600 hover:border-indigo-500 shadow-sm opacity-0 group-hover:opacity-100 transition-all z-20"
onMouseDown={(e) => onStartConnection(e, node.id)}
>
<ArrowRight size={12} />
</button>
</div>
);
}, (prev, next) => {
return (
prev.node === next.node &&
prev.isSelected === next.isSelected &&
prev.isEditing === next.isEditing &&
prev.activeColorPickerId === next.activeColorPickerId &&
prev.entities === next.entities
);
});
interface SuggestionState {
active: boolean;
trigger: string;
query: string;
nodeId: string;
field: 'title' | 'description';
cursorIndex: number;
selectedIndex: number;
filteredEntities: Entity[];
}
const StoryWorkflow: React.FC<StoryWorkflowProps> = ({ data, onUpdate, entities, onNavigateToEntity }) => {
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef<number | null>(null);
const [internalNodes, setInternalNodes] = useState<PlotNode[]>(data.nodes);
const internalNodesRef = useRef(internalNodes);
useEffect(() => { internalNodesRef.current = internalNodes; }, [internalNodes]);
useEffect(() => {
setInternalNodes(data.nodes);
}, [data.nodes]);
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
const [activeSuggestion, setActiveSuggestion] = useState<SuggestionState | null>(null);
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const [savedColors, setSavedColors] = useState<string[]>(INITIAL_COLORS);
const [activeColorPickerId, setActiveColorPickerId] = useState<string | null>(null);
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
const [history, setHistory] = useState<WorkflowData[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [dragStartPositions, setDragStartPositions] = useState<Map<string, {x: number, y: number}>>(new Map());
const [dragStartMouse, setDragStartMouse] = useState({ x: 0, y: 0 });
const [connectingNodeId, setConnectingNodeId] = useState<string | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [scrollStart, setScrollStart] = useState({ x: 0, y: 0 });
const pushHistory = useCallback(() => {
setHistory(prev => {
const newHistory = [...prev, data];
if (newHistory.length > 20) return newHistory.slice(newHistory.length - 20);
return newHistory;
});
}, [data]);
const updateNode = useCallback((id: string, updates: Partial<PlotNode>) => {
const currentNodes = internalNodesRef.current;
onUpdate({
...data,
nodes: currentNodes.map(n => n.id === id ? { ...n, ...updates } : n)
});
}, [data, onUpdate]);
const handleInputFocus = useCallback((e: React.FocusEvent) => {
e.stopPropagation();
}, []);
const handleInputWithAutocomplete = useCallback((
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>,
nodeId: string,
field: 'title' | 'description'
) => {
const val = e.target.value;
updateNode(nodeId, { [field]: val });
const cursor = e.target.selectionStart || 0;
const textBeforeCursor = val.slice(0, cursor);
const match = textBeforeCursor.match(/([@#^])([^@#^\s]*)$/);
if (match) {
const trigger = match[1];
const query = match[2].toLowerCase();
const targetType = trigger === '@' ? EntityType.CHARACTER : trigger === '#' ? EntityType.LOCATION : EntityType.OBJECT;
const filtered = entities.filter(ent =>
ent.type === targetType &&
ent.name.toLowerCase().includes(query)
);
setActiveSuggestion({
active: true,
trigger,
query,
nodeId,
field,
cursorIndex: cursor,
selectedIndex: 0,
filteredEntities: filtered
});
} else {
setActiveSuggestion(null);
}
}, [updateNode, entities]);
const insertEntity = (entity: Entity) => {
if (!activeSuggestion) return;
const { nodeId, field, trigger, query } = activeSuggestion;
const node = internalNodesRef.current.find(n => n.id === nodeId);
if (!node) return;
const currentText = node[field] as string;
const cursor = activeSuggestion.cursorIndex;
const insertionLength = trigger.length + query.length;
const startIdx = cursor - insertionLength;
if (startIdx < 0) return;
const before = currentText.slice(0, startIdx);
const after = currentText.slice(cursor);
const isDialogue = node.type === 'dialogue' && activeSuggestion.trigger === '@';
const suffix = isDialogue ? ": " : " ";
updateNode(nodeId, { [field]: before + entity.name + suffix + after });
setActiveSuggestion(null);
};
const handleKeyDownInInput = useCallback((e: React.KeyboardEvent, nodeId: string) => {
if (activeSuggestion && activeSuggestion.nodeId === nodeId) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveSuggestion(prev => prev ? { ...prev, selectedIndex: (prev.selectedIndex + 1) % prev.filteredEntities.length } : null);
return;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveSuggestion(prev => prev ? { ...prev, selectedIndex: (prev.selectedIndex - 1 + prev.filteredEntities.length) % prev.filteredEntities.length } : null);
return;
} else if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
if (activeSuggestion.filteredEntities.length > 0) {
insertEntity(activeSuggestion.filteredEntities[activeSuggestion.selectedIndex]);
} else {
setActiveSuggestion(null);
}
return;
} else if (e.key === 'Escape') {
setActiveSuggestion(null);
return;
}
}
}, [activeSuggestion, entities, updateNode]);
const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
setActiveColorPickerId(null);
setSelectedNodeIds(prevSelected => {
const newSelection = new Set(prevSelected);
if (e.ctrlKey) {
if (newSelection.has(nodeId)) newSelection.delete(nodeId);
else newSelection.add(nodeId);
} else {
if (!newSelection.has(nodeId)) {
newSelection.clear();
newSelection.add(nodeId);
}
}
const finalDragIds = e.ctrlKey ? newSelection : (newSelection.has(nodeId) ? newSelection : new Set([nodeId]));
const startPositions = new Map<string, {x: number, y: number}>();
internalNodesRef.current.forEach(n => {
if (finalDragIds.has(n.id)) {
startPositions.set(n.id, { x: n.x, y: n.y });
}
});
setDragStartPositions(startPositions);
return newSelection;
});
setIsDragging(true);
setDragStartMouse({ x: e.clientX, y: e.clientY });
pushHistory();
}, [pushHistory]);
const startConnection = useCallback((e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
pushHistory();
setConnectingNodeId(nodeId);
}, [pushHistory]);
const finishConnection = useCallback((e: React.MouseEvent, targetId: string) => {
if (connectingNodeId && connectingNodeId !== targetId) {
const exists = data.connections.some(c => c.source === connectingNodeId && c.target === targetId);
if (!exists) {
const newConn: PlotConnection = {
id: `conn-${Date.now()}`,
source: connectingNodeId,
target: targetId
};
onUpdate({
...data,
nodes: internalNodesRef.current,
connections: [...data.connections, newConn]
});
}
}
setConnectingNodeId(null);
}, [data, onUpdate, connectingNodeId]);
const handleToggleColorPicker = useCallback((id: string) => {
setActiveColorPickerId(prev => prev === id ? null : id);
}, []);
const handleSaveColor = useCallback((color: string) => {
setSavedColors(prev => !prev.includes(color) ? [...prev, color] : prev);
}, []);
const handleMouseMove = (e: React.MouseEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const clientX = e.clientX;
const clientY = e.clientY;
if (isPanning && containerRef.current) {
const dx = clientX - panStart.x;
const dy = clientY - panStart.y;
containerRef.current.scrollLeft = scrollStart.x - dx;
containerRef.current.scrollTop = scrollStart.y - dy;
return;
}
const scrollLeft = containerRef.current?.scrollLeft || 0;
const scrollTop = containerRef.current?.scrollTop || 0;
setMousePos({ x: clientX - rect.left + scrollLeft, y: clientY - rect.top + scrollTop });
if (isDragging) {
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
const dx = clientX - dragStartMouse.x;
const dy = clientY - dragStartMouse.y;
setInternalNodes(prevNodes => prevNodes.map(node => {
const startPos = dragStartPositions.get(node.id);
if (startPos) return { ...node, x: startPos.x + dx, y: startPos.y + dy };
return node;
}));
rafRef.current = null;
});
}
};
const handleMouseUp = () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (isDragging) onUpdate({ ...data, nodes: internalNodesRef.current });
setIsDragging(false);
setIsPanning(false);
setConnectingNodeId(null);
};
const handleCanvasMouseDown = (e: React.MouseEvent) => {
if (!e.ctrlKey) setSelectedNodeIds(new Set());
setActiveSuggestion(null);
setActiveColorPickerId(null);
setEditingNodeId(null);
setIsPanning(true);
setPanStart({ x: e.clientX, y: e.clientY });
if (containerRef.current) {
setScrollStart({ x: containerRef.current.scrollLeft, y: containerRef.current.scrollTop });
}
};
const handleCanvasDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left + (containerRef.current?.scrollLeft || 0) - CARD_WIDTH / 2;
const y = e.clientY - rect.top + (containerRef.current?.scrollTop || 0) - CARD_HEIGHT / 2;
pushHistory();
const newNode: PlotNode = {
id: `node-${Date.now()}`,
x,
y,
title: 'Nouvel événement',
description: '',
color: INITIAL_COLORS[0],
type: 'story'
};
onUpdate({ ...data, nodes: [...internalNodesRef.current, newNode] });
setSelectedNodeIds(new Set([newNode.id]));
setEditingNodeId(newNode.id);
};
const handleDeleteSelected = () => {
if (selectedNodeIds.size === 0) return;
pushHistory();
const newNodes = internalNodes.filter(n => !selectedNodeIds.has(n.id));
const newConnections = data.connections.filter(c => !selectedNodeIds.has(c.source) && !selectedNodeIds.has(c.target));
onUpdate({ nodes: newNodes, connections: newConnections });
setSelectedNodeIds(new Set());
};
const handleAddNodeCenter = () => {
pushHistory();
const scrollLeft = containerRef.current?.scrollLeft || 0;
const scrollTop = containerRef.current?.scrollTop || 0;
const clientWidth = containerRef.current?.clientWidth || 800;
const clientHeight = containerRef.current?.clientHeight || 600;
const newNode: PlotNode = {
id: `node-${Date.now()}`,
x: scrollLeft + clientWidth / 2 - CARD_WIDTH / 2,
y: scrollTop + clientHeight / 2 - CARD_HEIGHT / 2,
title: 'Nouveau point d\'intrigue',
description: '',
color: INITIAL_COLORS[0],
type: 'story'
};
onUpdate({ ...data, nodes: [...internalNodesRef.current, newNode] });
setSelectedNodeIds(new Set([newNode.id]));
setEditingNodeId(newNode.id);
};
return (
<div className="h-full flex flex-col overflow-hidden bg-[#eef2ff] relative">
<div className="h-12 bg-white border-b border-indigo-100 flex items-center justify-between px-4 z-10 shadow-sm shrink-0">
<div className="flex items-center gap-2">
<button onClick={handleAddNodeCenter} className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-xs font-bold transition-all shadow-md shadow-indigo-100">
<Plus size={14} /> AJOUTER NŒUD
</button>
<div className="w-px h-6 bg-slate-100 mx-2" />
<div className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
{selectedNodeIds.size > 0 ? `${selectedNodeIds.size} SÉLECTIONNÉ(S)` : 'Double-cliquez sur le canvas pour créer'}
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={handleDeleteSelected} disabled={selectedNodeIds.size === 0} className="p-2 text-red-500 hover:bg-red-50 rounded-lg disabled:opacity-30 transition-colors" title="Supprimer">
<Trash2 size={16} />
</button>
</div>
</div>
<div
ref={containerRef}
className="flex-1 overflow-auto relative cursor-grab active:cursor-grabbing bg-[#eef2ff]"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDoubleClick={handleCanvasDoubleClick}
style={{
backgroundImage: 'radial-gradient(#d1d5db 1px, transparent 1px)',
backgroundSize: '24px 24px'
}}
>
<svg className="absolute top-0 left-0 w-[4000px] h-[4000px] pointer-events-none z-0">
{data.connections.map(conn => {
const source = internalNodes.find(n => n.id === conn.source);
const target = internalNodes.find(n => n.id === conn.target);
if (!source || !target) return null;
const startX = source.x + CARD_WIDTH / 2;
const startY = source.y + CARD_HEIGHT / 2;
const endX = target.x + CARD_WIDTH / 2;
const endY = target.y + CARD_HEIGHT / 2;
return (
<line key={conn.id} x1={startX} y1={startY} x2={endX} y2={endY} stroke="#cbd5e1" strokeWidth="2" markerEnd="url(#arrowhead)" />
);
})}
{connectingNodeId && (
<line
x1={(internalNodes.find(n => n.id === connectingNodeId)?.x || 0) + CARD_WIDTH/2}
y1={(internalNodes.find(n => n.id === connectingNodeId)?.y || 0) + CARD_HEIGHT/2}
x2={mousePos.x} y2={mousePos.y}
stroke="#6366f1" strokeWidth="2" strokeDasharray="5,5" markerEnd="url(#arrowhead-blue)"
/>
)}
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
<path d="M0,0 L0,7 L10,3.5 Z" fill="#cbd5e1" />
</marker>
<marker id="arrowhead-blue" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<path d="M0,0 L0,7 L10,3.5 Z" fill="#6366f1" />
</marker>
</defs>
</svg>
{internalNodes.map(node => (
<StoryNode
key={node.id}
node={node}
isSelected={selectedNodeIds.has(node.id)}
isEditing={editingNodeId === node.id}
activeColorPickerId={activeColorPickerId}
entities={entities}
savedColors={savedColors}
onMouseDown={handleNodeMouseDown}
onMouseUp={finishConnection}
onStartConnection={startConnection}
onUpdate={updateNode}
onSetEditing={setEditingNodeId}
onToggleColorPicker={handleToggleColorPicker}
onSaveColor={handleSaveColor}
onNavigateToEntity={onNavigateToEntity}
onInputFocus={handleInputFocus}
onInputCheckAutocomplete={handleInputWithAutocomplete}
onKeyDownInInput={handleKeyDownInInput}
/>
))}
</div>
{activeSuggestion && (
<div className="fixed z-50 bg-white rounded-xl shadow-2xl border border-indigo-100 w-64 max-h-48 overflow-y-auto" style={{ left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }}>
<div className="px-3 py-2 bg-indigo-600 text-white text-[10px] font-black uppercase tracking-widest">
Insérer {activeSuggestion.trigger === '@' ? 'Personnage' : activeSuggestion.trigger === '#' ? 'Lieu' : 'Objet'}
</div>
<div className="divide-y divide-slate-50">
{activeSuggestion.filteredEntities.length > 0 ? (
activeSuggestion.filteredEntities.map((ent, idx) => (
<button
key={ent.id}
className={`w-full text-left px-4 py-3 text-xs flex items-center gap-3 hover:bg-indigo-50 transition-colors ${idx === activeSuggestion.selectedIndex ? 'bg-indigo-50 text-indigo-700 font-bold' : 'text-slate-700'}`}
onClick={() => insertEntity(ent)}
>
{ent.name}
</button>
))
) : (
<div className="p-4 text-xs text-slate-400 italic text-center">Aucun résultat</div>
)}
</div>
</div>
)}
</div>
);
};
export default StoryWorkflow;

View File

@@ -0,0 +1,203 @@
import React, { useState } from 'react';
import { UserProfile, UserPreferences } from '../types';
import { User, Settings, Globe, Shield, Bell, Save, Camera, Target, Flame, Layout } from 'lucide-react';
interface UserProfileSettingsProps {
user: UserProfile;
onUpdate: (updates: Partial<UserProfile>) => void;
onBack: () => void;
}
const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdate, onBack }) => {
// DEBUG: Check props
console.log("[UserProfileSettings DEBUG] PROPS RECEIVED:", {
user,
userId: user?.id,
hasOnUpdate: !!onUpdate,
hasOnBack: !!onBack
});
const [activeTab, setActiveTab] = useState<'profile' | 'preferences' | 'account'>('profile');
const [formData, setFormData] = useState({
name: user.name,
bio: user.bio || '',
email: user.email,
theme: user.preferences.theme,
dailyWordGoal: user.preferences.dailyWordGoal
});
const handleSave = () => {
onUpdate({
name: formData.name,
bio: formData.bio,
email: formData.email,
preferences: {
...user.preferences,
theme: formData.theme,
dailyWordGoal: formData.dailyWordGoal
}
});
alert("Profil mis à jour !");
};
return (
<div className="h-full bg-slate-50 overflow-y-auto p-8 font-sans">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-10">
<div>
<h1 className="text-3xl font-black text-slate-900">Mon Compte</h1>
<p className="text-slate-500">Gérez vos informations personnelles et préférences d'écriture.</p>
</div>
<button onClick={onBack} className="bg-white border border-slate-200 px-4 py-2 rounded-lg text-sm font-bold hover:bg-slate-50 transition-colors">Fermer</button>
</div>
<div className="flex flex-col md:flex-row gap-8">
{/* Sidebar Navigation */}
<div className="w-full md:w-64 space-y-1">
<button
onClick={() => setActiveTab('profile')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all ${activeTab === 'profile' ? 'bg-slate-900 text-white shadow-lg' : 'text-slate-500 hover:bg-white hover:text-slate-900'}`}
>
<User size={18} /> Profil Public
</button>
<button
onClick={() => setActiveTab('preferences')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all ${activeTab === 'preferences' ? 'bg-slate-900 text-white shadow-lg' : 'text-slate-500 hover:bg-white hover:text-slate-900'}`}
>
<Layout size={18} /> Interface & Écriture
</button>
<button
onClick={() => setActiveTab('account')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all ${activeTab === 'account' ? 'bg-slate-900 text-white shadow-lg' : 'text-slate-500 hover:bg-white hover:text-slate-900'}`}
>
<Shield size={18} /> Sécurité & Plan
</button>
</div>
{/* Main Content Pane */}
<div className="flex-1 bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
{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 border-slate-100">
<div className="relative group">
<img src={user.avatar} className="w-24 h-24 rounded-full object-cover border-4 border-slate-50 shadow-md" alt="Avatar" />
<button className="absolute inset-0 bg-black/40 text-white rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
<Camera size={20} />
</button>
</div>
<div>
<h3 className="font-bold text-slate-900 text-lg">{user.name}</h3>
<p className="text-slate-400 text-sm">Membre depuis Janvier 2024</p>
<div className="mt-2 flex gap-4">
<div className="flex items-center gap-1.5 text-xs font-bold text-orange-500">
<Flame size={14} fill="currentColor" /> {user.stats.writingStreak} jours de streak
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-6">
<div className="space-y-1">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest">Nom affiché</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest">Bio / Citation inspirante</label>
<textarea
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 h-24 resize-none"
placeholder="Partagez quelques mots sur votre style..."
/>
</div>
</div>
</div>
)}
{activeTab === 'preferences' && (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="grid grid-cols-1 gap-8">
<div className="space-y-3">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
<Target size={14} /> Objectif quotidien de mots
</label>
<div className="flex items-center gap-4">
<input
type="range" min="0" max="5000" step="100"
value={formData.dailyWordGoal}
onChange={(e) => setFormData({ ...formData, dailyWordGoal: parseInt(e.target.value) })}
className="flex-1 accent-blue-600"
/>
<span className="font-mono font-bold text-blue-600 bg-blue-50 px-3 py-1 rounded-lg">{formData.dailyWordGoal}</span>
</div>
</div>
<div className="space-y-3">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
Thème de l'éditeur
</label>
<div className="grid grid-cols-3 gap-3">
{['light', 'sepia', 'dark'].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' : 'border-slate-100 hover:border-slate-200 text-slate-500'}`}
>
<div className={`w-8 h-8 rounded-full border border-slate-200 ${t === 'light' ? 'bg-white' : t === 'sepia' ? 'bg-[#f4ecd8]' : 'bg-slate-900'}`} />
<span className="text-[10px] font-bold uppercase">{t}</span>
</button>
))}
</div>
</div>
</div>
</div>
)}
{activeTab === 'account' && (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl flex justify-between items-center">
<div>
<h4 className="font-bold text-blue-900">Plan {user.subscription.plan.toUpperCase()}</h4>
<p className="text-xs text-blue-700">Prochaine facturation le 15 Mars 2024</p>
</div>
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg text-xs font-bold hover:bg-blue-700 shadow-md shadow-blue-200">Gérer</button>
</div>
<div className="space-y-1">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest">Email du compte</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="pt-4">
<button className="text-red-500 text-sm font-bold hover:underline">Supprimer mon compte définitivement</button>
</div>
</div>
)}
<div className="mt-12 pt-8 border-t border-slate-100 flex justify-end">
<button
onClick={handleSave}
className="bg-slate-900 text-white px-8 py-3 rounded-xl font-bold flex items-center gap-2 hover:bg-blue-600 transition-all shadow-xl hover:shadow-blue-200"
>
<Save size={18} /> Sauvegarder les modifications
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default UserProfileSettings;

722
components/WorldBuilder.tsx Normal file
View File

@@ -0,0 +1,722 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Entity, EntityType, CharacterAttributes, EntityTemplate, CustomFieldDefinition, CustomFieldType } from '../types';
import { Plus, Trash2, Save, X, Sparkles, User, Activity, Brain, Ruler, Settings, Layout, List, ToggleLeft } from 'lucide-react';
import { ENTITY_ICONS, ENTITY_COLORS, HAIR_COLORS, EYE_COLORS, ARCHETYPES } from '../constants';
interface WorldBuilderProps {
entities: Entity[];
onCreate: (entity: Omit<Entity, 'id'>) => Promise<string | null>;
onUpdate: (id: string, updates: Partial<Entity>) => void;
onDelete: (id: string) => void;
templates: EntityTemplate[];
onUpdateTemplates: (templates: EntityTemplate[]) => void;
initialSelectedId?: string | null;
}
const DEFAULT_CHAR_ATTRIBUTES: CharacterAttributes = {
age: 30,
height: 175,
hair: 'Brun',
eyes: 'Marron',
archetype: 'Le Héros',
role: 'support',
personality: {
spectrumIntrovertExtravert: 50,
spectrumEmotionalRational: 50,
spectrumChaoticLawful: 50,
},
physicalQuirk: '',
behavioralQuirk: ''
};
const WorldBuilder: React.FC<WorldBuilderProps> = ({ entities, onCreate, onUpdate, onDelete, templates, onUpdateTemplates, initialSelectedId }) => {
const [editingId, setEditingId] = useState<string | null>(null);
const [tempEntity, setTempEntity] = useState<Entity | null>(null);
const [mode, setMode] = useState<'entities' | 'templates'>('entities');
// Template Editor State
const [activeTemplateType, setActiveTemplateType] = useState<EntityType>(EntityType.CHARACTER);
// Handle external navigation request (deep link)
useEffect(() => {
if (initialSelectedId) {
const entity = entities.find(e => e.id === initialSelectedId);
if (entity) {
handleEdit(entity);
setMode('entities');
}
}
}, [initialSelectedId, entities]);
// Dynamic Archetypes List
const allArchetypes = useMemo(() => {
const existing = entities
.filter(e => e.type === EntityType.CHARACTER && e.attributes?.archetype)
.map(e => e.attributes!.archetype);
return Array.from(new Set([...ARCHETYPES, ...existing])).sort();
}, [entities]);
// --- ENTITY ACTIONS ---
const handleAdd = (type: EntityType) => {
const newEntity: Entity = {
id: Date.now().toString(), // Helper ID for UI
type,
name: '',
description: '',
details: '',
storyContext: '',
attributes: type === EntityType.CHARACTER ? { ...DEFAULT_CHAR_ATTRIBUTES } : undefined,
customValues: {}
};
setTempEntity(newEntity);
setEditingId('NEW');
};
const handleEdit = (entity: Entity) => {
// Ensure attributes exist if it's a character (backward compatibility)
const entityToEdit = { ...entity };
if (entity.type === EntityType.CHARACTER && !entity.attributes) {
entityToEdit.attributes = { ...DEFAULT_CHAR_ATTRIBUTES };
}
if (!entity.customValues) {
entityToEdit.customValues = {};
}
setTempEntity(entityToEdit);
setEditingId(entity.id);
};
const handleSave = async () => {
if (!tempEntity || !tempEntity.name) return;
if (editingId === 'NEW') {
const { id, ...entityData } = tempEntity;
await onCreate(entityData);
} else {
onUpdate(tempEntity.id, tempEntity);
}
setEditingId(null);
setTempEntity(null);
};
const handleDelete = (id: string) => {
if (confirm('Supprimer cet élément ?')) {
onDelete(id);
if (editingId === id) {
setEditingId(null);
setTempEntity(null);
}
}
};
const updateAttribute = (key: keyof CharacterAttributes, value: any) => {
if (tempEntity && tempEntity.attributes) {
setTempEntity({
...tempEntity,
attributes: { ...tempEntity.attributes, [key]: value }
});
}
};
const updatePersonality = (key: keyof CharacterAttributes['personality'], value: number) => {
if (tempEntity && tempEntity.attributes) {
setTempEntity({
...tempEntity,
attributes: {
...tempEntity.attributes,
personality: { ...tempEntity.attributes.personality, [key]: value }
}
});
}
};
const updateCustomValue = (fieldId: string, value: any) => {
if (tempEntity) {
setTempEntity({
...tempEntity,
customValues: {
...tempEntity.customValues,
[fieldId]: value
}
});
}
};
// --- TEMPLATE ACTIONS ---
const addCustomField = (type: EntityType) => {
const newField: CustomFieldDefinition = {
id: `field-${Date.now()}`,
label: 'Nouveau Champ',
type: 'text',
placeholder: ''
};
// Correct immutable update
const updatedTemplates = templates.map(t => {
if (t.entityType === type) {
return {
...t,
fields: [...t.fields, newField]
};
}
return t;
});
// If template didn't exist (unlikely given App.tsx init, but safe)
if (!updatedTemplates.some(t => t.entityType === type)) {
updatedTemplates.push({ entityType: type, fields: [newField] });
}
onUpdateTemplates(updatedTemplates);
};
const updateCustomField = (type: EntityType, fieldId: string, updates: Partial<CustomFieldDefinition>) => {
const updatedTemplates = templates.map(t => {
if (t.entityType !== type) return t;
return {
...t,
fields: t.fields.map(f => f.id === fieldId ? { ...f, ...updates } : f)
};
});
onUpdateTemplates(updatedTemplates);
};
const deleteCustomField = (type: EntityType, fieldId: string) => {
const updatedTemplates = templates.map(t => {
if (t.entityType !== type) return t;
return {
...t,
fields: t.fields.filter(f => f.id !== fieldId)
};
});
onUpdateTemplates(updatedTemplates);
};
const filterByType = (type: EntityType) => entities.filter(e => e.type === type);
// --- RENDER HELPERS ---
const renderCharacterEditor = () => {
if (!tempEntity?.attributes) return null;
const attrs = tempEntity.attributes;
return (
<div className="space-y-8 border-t border-slate-100 pt-6 mt-4">
{/* SECTION 1: ROLE & ARCHETYPE */}
<div className="bg-[#eef2ff] p-4 rounded-lg border border-indigo-100">
<h3 className="text-sm font-bold text-slate-700 uppercase mb-4 flex items-center gap-2">
<User size={16} /> Identité Narrative
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-500 mb-2">Archétype</label>
<input
type="text"
list="archetype-suggestions"
value={attrs.archetype}
onChange={(e) => updateAttribute('archetype', e.target.value)}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-blue-500"
placeholder="Ex: Le Héros, Le Sage..."
/>
<datalist id="archetype-suggestions">
{allArchetypes.map(a => <option key={a} value={a} />)}
</datalist>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 mb-2">Rôle dans l'histoire</label>
<div className="flex gap-2 flex-wrap">
{[
{ val: 'protagonist', label: 'Protagoniste' },
{ val: 'antagonist', label: 'Antagoniste' },
{ val: 'support', label: 'Secondaire' },
{ val: 'extra', label: 'Figurant' }
].map(opt => (
<label key={opt.val} className={`cursor-pointer px-3 py-1.5 rounded text-xs border transition-colors ${attrs.role === opt.val ? 'bg-indigo-100 border-indigo-300 text-indigo-700 font-bold' : 'bg-[#eef2ff] border-slate-200 text-slate-600 hover:bg-slate-100'}`}>
<input
type="radio"
name="role"
value={opt.val}
checked={attrs.role === opt.val}
onChange={() => updateAttribute('role', opt.val)}
className="hidden"
/>
{opt.label}
</label>
))}
</div>
</div>
</div>
</div>
{/* SECTION 2: PHYSIQUE */}
<div className="bg-[#eef2ff] p-4 rounded-lg border border-indigo-100">
<h3 className="text-sm font-bold text-slate-700 uppercase mb-4 flex items-center gap-2">
<Ruler size={16} /> Apparence Physique
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-6">
<div>
<div className="flex justify-between text-xs mb-1">
<label className="font-semibold text-slate-600">Âge (ans)</label>
</div>
<div className="flex items-center gap-3">
<input
type="range" min="1" max="100"
value={Math.min(attrs.age, 100)}
onChange={(e) => updateAttribute('age', parseInt(e.target.value))}
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<input
type="number"
value={attrs.age}
onChange={(e) => updateAttribute('age', parseInt(e.target.value))}
className="w-20 p-1 text-right text-sm border border-slate-300 rounded font-mono text-indigo-700 bg-[#eef2ff] focus:border-indigo-500 outline-none"
/>
</div>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<label className="font-semibold text-slate-600">Taille (cm)</label>
</div>
<div className="flex items-center gap-3">
<input
type="range" min="50" max="250"
value={Math.min(attrs.height, 250)}
onChange={(e) => updateAttribute('height', parseInt(e.target.value))}
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<input
type="number"
value={attrs.height}
onChange={(e) => updateAttribute('height', parseInt(e.target.value))}
className="w-20 p-1 text-right text-sm border border-slate-300 rounded font-mono text-indigo-700 bg-[#eef2ff] focus:border-indigo-500 outline-none"
/>
</div>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Cheveux</label>
<select
value={attrs.hair}
onChange={(e) => updateAttribute('hair', e.target.value)}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm"
>
{HAIR_COLORS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Yeux</label>
<select
value={attrs.eyes}
onChange={(e) => updateAttribute('eyes', e.target.value)}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm"
>
{EYE_COLORS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Signe distinctif</label>
<input
type="text"
value={attrs.physicalQuirk}
onChange={(e) => updateAttribute('physicalQuirk', e.target.value)}
placeholder="Cicatrice, tatouage..."
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-indigo-400"
/>
</div>
</div>
</div>
</div>
{/* SECTION 3: PSYCHOLOGIE */}
<div className="bg-[#eef2ff] p-4 rounded-lg border border-indigo-100">
<h3 className="text-sm font-bold text-slate-700 uppercase mb-4 flex items-center gap-2">
<Brain size={16} /> Psychologie & Comportement
</h3>
<div className="space-y-6">
<div className="space-y-4 px-2">
<div className="relative pt-1">
<div className="flex justify-between text-[10px] uppercase font-bold text-slate-500 mb-1">
<span>Introverti</span>
<span>Extraverti</span>
</div>
<input
type="range" min="0" max="100"
value={attrs.personality.spectrumIntrovertExtravert}
onChange={(e) => updatePersonality('spectrumIntrovertExtravert', parseInt(e.target.value))}
className="w-full h-2 bg-gradient-to-r from-slate-300 via-indigo-200 to-slate-300 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
<div className="relative pt-1">
<div className="flex justify-between text-[10px] uppercase font-bold text-slate-500 mb-1">
<span>Émotionnel</span>
<span>Rationnel</span>
</div>
<input
type="range" min="0" max="100"
value={attrs.personality.spectrumEmotionalRational}
onChange={(e) => updatePersonality('spectrumEmotionalRational', parseInt(e.target.value))}
className="w-full h-2 bg-gradient-to-r from-red-200 via-purple-200 to-blue-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
<div className="relative pt-1">
<div className="flex justify-between text-[10px] uppercase font-bold text-slate-500 mb-1">
<span>Chaotique</span>
<span>Loyal</span>
</div>
<input
type="range" min="0" max="100"
value={attrs.personality.spectrumChaoticLawful}
onChange={(e) => updatePersonality('spectrumChaoticLawful', parseInt(e.target.value))}
className="w-full h-2 bg-gradient-to-r from-orange-200 via-yellow-100 to-green-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
</div>
<div className="border-t border-slate-200 pt-4">
<label className="block text-xs font-semibold text-slate-500 mb-1">Toc ou habitude comportementale</label>
<input
type="text"
value={attrs.behavioralQuirk}
onChange={(e) => updateAttribute('behavioralQuirk', e.target.value)}
placeholder="Joue avec sa bague, bégaie quand il ment..."
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-indigo-400"
/>
</div>
</div>
</div>
</div>
);
};
const renderCustomFieldsEditor = () => {
const currentTemplate = templates.find(t => t.entityType === tempEntity?.type);
if (!currentTemplate || currentTemplate.fields.length === 0) return null;
return (
<div className="bg-[#eef2ff] p-4 rounded-lg border border-indigo-100 mt-6">
<h3 className="text-sm font-bold text-slate-700 uppercase mb-4 flex items-center gap-2">
<List size={16} /> Champs Personnalisés
</h3>
<div className="grid grid-cols-1 gap-4">
{currentTemplate.fields.map(field => {
const value = tempEntity?.customValues?.[field.id] ?? '';
return (
<div key={field.id}>
<label className="block text-xs font-semibold text-slate-500 mb-1">{field.label}</label>
{field.type === 'textarea' ? (
<textarea
value={value}
onChange={(e) => updateCustomValue(field.id, e.target.value)}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-indigo-400"
placeholder={field.placeholder}
/>
) : field.type === 'select' ? (
<select
value={value}
onChange={(e) => updateCustomValue(field.id, e.target.value)}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-indigo-400"
>
<option value="">Sélectionner...</option>
{field.options?.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : field.type === 'boolean' ? (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!value}
onChange={(e) => updateCustomValue(field.id, e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"
/>
<span className="text-sm text-slate-700">Activé / Oui</span>
</label>
) : (
<input
type={field.type === 'number' ? 'number' : 'text'}
value={value}
onChange={(e) => updateCustomValue(field.id, e.target.value)}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-indigo-400"
placeholder={field.placeholder}
/>
)}
</div>
);
})}
</div>
</div>
);
};
const renderTemplateManager = () => {
const template = templates.find(t => t.entityType === activeTemplateType) || { entityType: activeTemplateType, fields: [] };
return (
<div className="flex-1 bg-white rounded-xl shadow-lg border border-slate-200 p-8 overflow-y-auto">
<div className="flex justify-between items-start mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
<Layout size={24} className="text-indigo-600" /> Éditeur de Modèles
</h2>
<p className="text-slate-500 text-sm mt-1">
Configurez les champs personnalisés pour chaque type de fiche.
</p>
</div>
<button onClick={() => setMode('entities')} className="p-2 text-slate-500 hover:bg-slate-100 rounded-full">
<X size={20} />
</button>
</div>
<div className="flex gap-2 mb-8 border-b border-slate-200 pb-1">
{Object.values(EntityType).map(type => (
<button
key={type}
onClick={() => setActiveTemplateType(type)}
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTemplateType === type
? 'bg-indigo-50 text-indigo-700 border-b-2 border-indigo-600'
: 'text-slate-500 hover:text-slate-800 hover:bg-slate-50'
}`}
>
{type}
</button>
))}
</div>
<div className="space-y-4">
{template.fields.map((field, idx) => (
<div key={field.id} className="bg-[#eef2ff] border border-indigo-100 rounded-lg p-4 flex gap-4 items-start group">
<div className="flex-1 grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Nom du champ</label>
<input
type="text"
value={field.label}
onChange={(e) => updateCustomField(activeTemplateType, field.id, { label: e.target.value })}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Type</label>
<select
value={field.type}
onChange={(e) => updateCustomField(activeTemplateType, field.id, { type: e.target.value as CustomFieldType })}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm"
>
<option value="text">Texte court</option>
<option value="textarea">Texte long</option>
<option value="number">Nombre</option>
<option value="boolean">Case à cocher</option>
<option value="select">Liste déroulante</option>
</select>
</div>
{field.type === 'select' && (
<div className="col-span-2">
<label className="block text-xs font-semibold text-slate-500 mb-1">Options (séparées par des virgules)</label>
<input
type="text"
value={field.options?.join(',') || ''}
onChange={(e) => updateCustomField(activeTemplateType, field.id, { options: e.target.value.split(',').map(s => s.trim()) })}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm"
placeholder="Option A, Option B, Option C"
/>
</div>
)}
</div>
<button
onClick={() => deleteCustomField(activeTemplateType, field.id)}
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded mt-5"
>
<Trash2 size={16} />
</button>
</div>
))}
<button
onClick={() => addCustomField(activeTemplateType)}
className="w-full py-3 border-2 border-dashed border-slate-300 rounded-lg text-slate-500 hover:border-indigo-400 hover:text-indigo-600 hover:bg-indigo-50 transition-all flex items-center justify-center gap-2"
>
<Plus size={20} /> Ajouter un champ
</button>
</div>
</div>
);
};
if (mode === 'templates') {
return (
<div className="flex h-full gap-6 p-6 bg-[#eef2ff]">
<div className="w-1/3 opacity-50 pointer-events-none filter blur-[1px]">
<div className="bg-white rounded-lg p-6 shadow-sm border border-slate-200">
<h3 className="font-bold text-slate-700 mb-4">Aperçu Fiches</h3>
<div className="space-y-2">
<div className="h-10 bg-indigo-50 rounded"></div>
<div className="h-10 bg-indigo-50 rounded"></div>
<div className="h-10 bg-indigo-50 rounded"></div>
</div>
</div>
</div>
{renderTemplateManager()}
</div>
);
}
return (
<div className="flex h-full gap-6 p-6 bg-[#eef2ff]">
<div className="w-1/3 flex flex-col gap-4">
<div className="flex justify-between items-center px-1">
<h2 className="text-lg font-bold text-slate-700">Explorateur</h2>
<button
onClick={() => setMode('templates')}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-100 text-indigo-700 hover:bg-indigo-200 rounded text-xs font-medium transition-colors"
title="Gérer les modèles de fiches"
>
<Settings size={14} /> Modèles
</button>
</div>
<div className="space-y-6 overflow-y-auto pr-2 pb-4 flex-1">
{Object.values(EntityType).map(type => (
<div key={type} className="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
<div className="bg-indigo-50 p-3 border-b border-indigo-100 flex justify-between items-center">
<h3 className="font-semibold text-slate-700 flex items-center gap-2">
<span>{ENTITY_ICONS[type]}</span> {type}s
</h3>
<button
onClick={() => handleAdd(type)}
className="p-1 hover:bg-indigo-100 rounded text-indigo-600 transition-colors"
>
<Plus size={16} />
</button>
</div>
<div className="divide-y divide-slate-100">
{filterByType(type).length === 0 && (
<p className="p-4 text-sm text-slate-400 italic text-center">Aucun élément</p>
)}
{filterByType(type).map(entity => (
<div
key={entity.id}
onClick={() => handleEdit(entity)}
className={`p-3 cursor-pointer hover:bg-blue-50 transition-colors flex justify-between group ${editingId === entity.id ? 'bg-blue-50 border-l-4 border-blue-500' : ''}`}
>
<div>
<div className="font-medium text-slate-800">{entity.name}</div>
<div className="text-xs text-slate-500 truncate">{entity.description}</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(entity.id); }}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
))}
</div>
</div>
<div className="flex-1 bg-white rounded-xl shadow-lg border border-slate-200 p-8 overflow-y-auto">
{editingId && tempEntity ? (
<div className="space-y-6 animate-in fade-in duration-200">
<div className="flex justify-between items-start">
<div className="space-y-1">
<span className={`inline-block px-2 py-1 rounded text-xs font-bold uppercase tracking-wider ${ENTITY_COLORS[tempEntity.type]}`}>
{tempEntity.type}
</span>
<h2 className="text-2xl font-bold text-slate-800">
{tempEntity.type === EntityType.CHARACTER ? 'Fiche Personnage' : 'Édition de la fiche'}
</h2>
</div>
<div className="flex gap-2">
<button onClick={() => setEditingId(null)} className="p-2 text-slate-500 hover:bg-slate-100 rounded-full">
<X size={20} />
</button>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nom</label>
<input
type="text"
value={tempEntity.name}
onChange={e => setTempEntity({ ...tempEntity, name: e.target.value })}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none font-serif text-lg"
placeholder="Ex: Gandalf le Gris"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Description Courte (pour l'IA)</label>
<textarea
value={tempEntity.description}
onChange={e => setTempEntity({ ...tempEntity, description: e.target.value })}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none text-sm h-20"
placeholder="Un magicien puissant qui guide la communauté..."
/>
</div>
{tempEntity.type === EntityType.CHARACTER && renderCharacterEditor()}
{renderCustomFieldsEditor()}
<div className="mt-6 border-t border-slate-100 pt-6">
<div>
<label className="block text-sm font-medium text-indigo-700 mb-1 flex items-center gap-2">
<Sparkles size={14} /> Contexte Narratif (Auto-généré)
</label>
<textarea
value={tempEntity.storyContext || ''}
onChange={e => setTempEntity({ ...tempEntity, storyContext: e.target.value })}
className="w-full p-2 border border-indigo-200 bg-indigo-50 rounded focus:ring-2 focus:ring-blue-500 outline-none text-sm h-24 italic text-slate-600"
placeholder="Les événements vécus par ce personnage apparaîtront ici..."
/>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-slate-700 mb-1">Notes & Biographie Complète</label>
<textarea
value={tempEntity.details}
onChange={e => setTempEntity({ ...tempEntity, details: e.target.value })}
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none h-48 font-serif"
placeholder="Histoire détaillée, secrets, origines..."
/>
</div>
</div>
<div className="pt-4 flex justify-end">
<button
onClick={handleSave}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 transition-colors shadow-md"
>
<Save size={18} />
Enregistrer la fiche
</button>
</div>
</div>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-slate-400">
<div className="text-6xl mb-4 opacity-20">🌍</div>
<p className="text-lg">Sélectionnez ou créez une fiche pour commencer.</p>
<p className="text-sm">Ces informations aideront l'IA à rester cohérente.</p>
</div>
)}
</div>
</div>
);
};
export default WorldBuilder;

View File

@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { BookProject, UserProfile, ViewMode, ChatMessage } from '../../types';
import AIPanel from '../AIPanel';
import { Book, FileText, Globe, GitGraph, Lightbulb, Settings, Menu, ChevronRight, ChevronLeft, Share2, HelpCircle, LogOut, LayoutDashboard, User, Plus, Trash2 } from 'lucide-react';
interface EditorShellProps {
project: BookProject;
user: UserProfile;
viewMode: ViewMode;
currentChapterId: string;
chatHistory: ChatMessage[];
isGenerating: boolean;
onViewModeChange: (mode: ViewMode) => void;
onChapterSelect: (id: string) => void;
onUpdateProject: (updates: Partial<BookProject>) => void;
onAddChapter: () => void;
onDeleteChapter: (id: string) => void;
onLogout: () => void;
onSendMessage: (msg: string) => void;
onInsertText: (text: string) => void;
onOpenExport: () => void;
onOpenHelp: () => void;
children: React.ReactNode;
}
const EditorShell: React.FC<EditorShellProps> = (props) => {
const { project, user, viewMode, currentChapterId, children } = props;
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isAiPanelOpen, setIsAiPanelOpen] = useState(true);
const currentChapter = project.chapters.find(c => c.id === currentChapterId);
return (
<div className={`flex h-screen overflow-hidden no-print ${user.preferences.theme === 'dark' ? 'bg-slate-900 text-white' : user.preferences.theme === 'sepia' ? 'bg-[#f4ecd8]' : 'bg-[#eef2ff]'}`}>
{/* SIDEBAR */}
<aside className={`${isSidebarOpen ? 'w-64' : 'w-0'} bg-slate-900 text-slate-300 flex-shrink-0 transition-all duration-300 overflow-hidden flex flex-col border-r border-slate-800`}>
<div className="p-4 border-b border-slate-700">
<h1 className="text-white font-bold flex items-center gap-2 mb-4 cursor-pointer" onClick={() => props.onViewModeChange('dashboard')}>
<Book className="text-blue-400" /> PlumeIA
</h1>
<input
type="text"
value={project.title}
onChange={(e) => props.onUpdateProject({ title: e.target.value })}
className="w-full bg-transparent font-serif font-bold text-white text-lg mb-1 focus:outline-none focus:border-b focus:border-blue-500 truncate"
placeholder="Titre du livre"
/>
<button onClick={() => props.onViewModeChange('dashboard')} className="w-full flex items-center gap-2 text-xs hover:bg-slate-800 p-2 rounded transition-colors text-slate-400">
<LayoutDashboard size={14} /> Retour au Dashboard
</button>
</div>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-4 py-2 text-xs font-semibold text-slate-500 uppercase flex justify-between items-center">
Chapitres <button onClick={props.onAddChapter} className="hover:text-blue-400"><Plus size={14} /></button>
</div>
{project.chapters.map((chap, idx) => (
<div key={chap.id} className="group relative">
<button
onClick={() => props.onChapterSelect(chap.id)}
className={`w-full text-left px-4 py-2 text-sm truncate transition-colors ${currentChapterId === chap.id && viewMode === 'write' ? 'bg-blue-900 text-white border-r-2 border-blue-400' : 'hover:bg-slate-800'}`}
>
{idx + 1}. {chap.title}
</button>
<button onClick={() => props.onDeleteChapter(chap.id)} className="absolute right-2 top-2 text-slate-600 hover:text-red-400 opacity-0 group-hover:opacity-100"><Trash2 size={14} /></button>
</div>
))}
<div className="mt-6 px-4 py-2 text-xs font-semibold text-slate-500 uppercase">Outils & Bible</div>
<button onClick={() => props.onViewModeChange('write')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'write' ? 'bg-blue-900 text-white' : 'hover:bg-slate-800'}`}><FileText size={16} /> Éditeur</button>
<button onClick={() => props.onViewModeChange('world_building')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'world_building' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><Globe size={16} /> Bible du Monde</button>
<button onClick={() => props.onViewModeChange('workflow')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'workflow' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><GitGraph size={16} /> Workflow</button>
<button onClick={() => props.onViewModeChange('ideas')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'ideas' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><Lightbulb size={16} /> Boîte à Idées</button>
<button onClick={() => props.onViewModeChange('settings')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'settings' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><Settings size={16} /> Paramètres</button>
</div>
<div className="p-4 border-t border-slate-800">
<div className="bg-slate-800 rounded-lg p-3 mb-4">
<div className="flex justify-between text-[10px] text-slate-400 uppercase font-bold mb-1">
<span>Actions IA</span>
<span>{user.usage.aiActionsCurrent} / {user.usage.aiActionsLimit === 999999 ? '∞' : user.usage.aiActionsLimit}</span>
</div>
<div className="h-1.5 w-full bg-slate-700 rounded-full overflow-hidden">
<div className="h-full bg-blue-500" style={{ width: `${Math.min(100, (user.usage.aiActionsCurrent / user.usage.aiActionsLimit) * 100)}%` }} />
</div>
</div>
<button onClick={() => props.onViewModeChange('profile')} className="w-full flex items-center gap-2 px-3 py-2 text-xs text-slate-400 hover:bg-slate-800 rounded mb-2"><User size={14} /> Mon Compte</button>
<button onClick={props.onLogout} className="w-full flex items-center gap-2 px-3 py-2 text-xs text-red-400 hover:bg-red-900/20 rounded"><LogOut size={14} /> Déconnexion</button>
</div>
</aside>
{/* MAIN CONTENT */}
<div className="flex-1 flex flex-col h-full overflow-hidden">
<header className="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-4 shadow-sm z-10 text-slate-800">
<div className="flex items-center gap-4">
<button onClick={() => setIsSidebarOpen(!isSidebarOpen)} className="text-slate-500 hover:text-slate-800"><Menu size={20} /></button>
{viewMode === 'write' ? (
<input
type="text"
value={currentChapter?.title || ""}
onChange={(e) => props.onUpdateProject({ chapters: project.chapters.map(c => c.id === currentChapterId ? {...c, title: e.target.value} : c) })}
className="font-serif font-bold text-lg bg-transparent border-b border-transparent focus:border-blue-500 focus:outline-none"
/>
) : (
<span className="font-bold uppercase tracking-widest text-xs">{viewMode}</span>
)}
</div>
<div className="flex items-center gap-3">
<button onClick={props.onOpenExport} className="bg-blue-600 text-white px-4 py-1.5 rounded-lg text-sm font-medium hover:bg-blue-700 flex items-center gap-2"><Share2 size={16} /> Publier</button>
<button onClick={props.onOpenHelp} className="p-2 text-slate-400 hover:text-blue-600 rounded-full"><HelpCircle size={20} /></button>
<button onClick={() => setIsAiPanelOpen(!isAiPanelOpen)} className={`p-2 rounded-full ${isAiPanelOpen ? 'bg-indigo-100 text-indigo-600' : 'text-slate-500 hover:bg-slate-100'}`}>
{isAiPanelOpen ? <ChevronRight size={20} /> : <ChevronLeft size={20} />}
</button>
</div>
</header>
<main className="flex-1 overflow-hidden relative">
{children}
</main>
</div>
{/* AI PANEL */}
<div className={`${isAiPanelOpen ? 'w-80 lg:w-96' : 'w-0'} transition-all duration-300 flex-shrink-0 h-full border-l border-slate-200 relative`}>
{isAiPanelOpen && <AIPanel chatHistory={props.chatHistory} onSendMessage={props.onSendMessage} onInsertText={props.onInsertText} selectedText="" isGenerating={props.isGenerating} usage={user.usage} />}
</div>
</div>
);
};
export default EditorShell;

67
constants.ts Normal file
View File

@@ -0,0 +1,67 @@
import { EntityType } from "./types";
export const DEFAULT_BOOK_TITLE = "Nouveau Roman";
export const DEFAULT_AUTHOR = "Auteur Inconnu";
export const INITIAL_CHAPTER = {
id: 'chap-1',
title: 'Chapitre 1',
content: '<p>Il était une fois...</p>',
summary: 'Début de l\'histoire.'
};
export const ENTITY_ICONS: Record<EntityType, string> = {
[EntityType.CHARACTER]: '👤',
[EntityType.LOCATION]: '🏰',
[EntityType.OBJECT]: '🗝️',
[EntityType.NOTE]: '📝',
};
// Colors for tags
export const ENTITY_COLORS: Record<EntityType, string> = {
[EntityType.CHARACTER]: 'bg-blue-100 text-blue-800 border-blue-200',
[EntityType.LOCATION]: 'bg-green-100 text-green-800 border-green-200',
[EntityType.OBJECT]: 'bg-amber-100 text-amber-800 border-amber-200',
[EntityType.NOTE]: 'bg-gray-100 text-gray-800 border-gray-200',
};
// --- Character Creation Lists ---
export const HAIR_COLORS = [
"Brun", "Noir", "Blond", "Roux", "Auburn", "Gris", "Blanc", "Châtain", "Chauve", "Teinture (Bleu/Rose/Etc)"
];
export const EYE_COLORS = [
"Marron", "Bleu", "Vert", "Noisette", "Gris", "Noir", "Vairons", "Ambre"
];
export const ARCHETYPES = [
"Le Héros", "L'Ombre / Le Méchant", "Le Mentor", "Le Gardien du Seuil",
"Le Shapeshifter (Changeforme)", "Le Trickster (Farceur)", "L'Allié", "L'Élu",
"Le Rebelle", "Le Séducteur", "Le Sage", "Le Guerrier", "L'Innocent"
];
// --- Book Settings Lists ---
export const GENRES = [
"Fantasy", "Science-Fiction", "Thriller / Polar", "Romance", "Historique",
"Horreur", "Aventure", "Contemporain", "Jeunesse / Young Adult", "Dystopie"
];
export const TONES = [
"Sombre & Sérieux", "Léger & Humoristique", "Épique & Grandiose",
"Mélancolique", "Mystérieux", "Optimiste", "Cynique", "Romantique"
];
export const POV_OPTIONS = [
"1ère personne (Je)",
"3ème personne (Limitée au protagoniste)",
"3ème personne (Omnisciente)",
"Multi-points de vue (Alterné)"
];
export const TENSE_OPTIONS = [
"Passé (Passé simple / Imparfait)",
"Présent de narration"
];

385
hooks.bak copy.ts Normal file
View File

@@ -0,0 +1,385 @@
import { useState, useCallback, useEffect } from 'react';
import { UserProfile, BookProject, Chapter, ChatMessage, PlanType, Entity, Idea, WorkflowData } from './types';
import { INITIAL_CHAPTER } from './constants';
import { generateStoryContent } from './services/geminiService';
import { authService, dataService } from './services/api.bak2';
export function useAuth() {
const [user, setUser] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const checkSession = useCallback(async (injectedUser: any = null) => {
setLoading(true); // On affiche l'écran de chargement
console.log("[useAuth]checkSession ==> Démarrage de la connexion complète...");
try {
// 1. Vérification de la session Auth
const sessionUser = injectedUser || await authService.getSession();
if (sessionUser) {
console.log("[useAuth] 1/2 Session validée. Récupération du profil...");
// 2. Récupération du profil (BLOQUANT avec await)
// On attend les 2 secondes ici pour avoir une transition propre
let profile = await dataService.getProfile(sessionUser.id);
// 3. Si le profil n'existe pas, on le crée avant de continuer
if (!profile) {
console.log("[useAuth] Profil absent, création en cours...");
const createResult = await dataService.createItem('profiles', {
user_id: sessionUser.id,
email: sessionUser.email,
full_name: sessionUser.name || 'Nouvel Écrivain'
// ... autres champs par défaut
});
// On simule l'objet profil pour éviter un second appel API
profile = { user_id: sessionUser.id, email: sessionUser.email, full_name: sessionUser.name };
}
// 4. On remplit l'état final
setUser({
id: sessionUser.id,
email: sessionUser.email,
name: profile.full_name || sessionUser.name,
avatar: profile.avatar_url || `https://i.pravatar.cc/150?u=${sessionUser.email}`,
subscription: { plan: profile.subscription_plan || 'free', startDate: 0, status: 'active' },
usage: {
aiActionsCurrent: profile.ai_actions_current || 0,
aiActionsLimit: profile.ai_actions_limit || 10,
projectsLimit: profile.projects_limit || 1
},
preferences: { theme: profile.theme || 'light', dailyWordGoal: 500, language: 'fr' },
stats: { totalWordsWritten: 0, writingStreak: 0, lastWriteDate: 0 },
bio: profile.bio || ""
});
console.log("[useAuth] 2/2 Profil récupéré. Accès au Dashboard.");
} else {
console.log("[useAuth] 2/2 Profil non récupéré. Redirection vers la page de connexion.");
setUser(null);
}
} catch (err) {
console.error("[useAuth] Erreur lors de la connexion:", err);
setUser(null);
} finally {
// C'est seulement ici que l'écran de chargement disparaît
setLoading(false);
}
}, []);
useEffect(() => { checkSession(); }, [checkSession]);
const login = async (data: any) => {
// UI Feedback immédiat si nécessaire
setLoading(true);
// Appel API
const result = await authService.signIn(data.email, data.password);
console.log("[useAuth] Résultat de la connexion:", result);
if (result && !result.error) {
// Utilisation directe de la réponse API pour connecter l'utilisateur
// Cela évite d'attendre le round-trip réseau de getSession
const userObj = result.user || (result.id ? result : null);
if (userObj) {
console.log("[useAuth] Login réussi, injection immédiate.");
await checkSession(userObj);
// Note: checkSession est maintenant optimisé pour ne pas re-bloquer
} else {
await checkSession();
}
} else {
// En cas d'erreur, on arrête le chargement pour afficher le message
setLoading(false);
}
return result;
};
const signup = async (data: any) => {
setLoading(true);
const result = await authService.signUp(data.email, data.password, data.name);
if (result && !result.error) {
const userObj = result.user || (result.id ? result : null);
// Injection immédiate comme pour le login
await checkSession(userObj);
} else {
setLoading(false);
}
return result;
};
const logout = async () => {
setLoading(true);
await authService.signOut();
setUser(null);
setLoading(false);
};
const incrementUsage = async () => {
if (user) {
setUser(prev => prev ? {
...prev,
usage: { ...prev.usage, aiActionsCurrent: prev.usage.aiActionsCurrent + 1 }
} : null);
}
};
return { user, login, signup, logout, incrementUsage, loading };
}
export function useProjects(user: UserProfile | null) {
const [projects, setProjects] = useState<BookProject[]>([]);
const [currentProjectId, setCurrentProjectId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// DEBUG: Check if user is present
useEffect(() => {
console.log("[useProjects DEBUG] Hook initialized/updated with user:", user);
}, [user]);
const fetchProjects = useCallback(async () => {
if (!user) {
console.log("[useProjects DEBUG] fetchProjects skipped: No user");
return;
}
console.log("[useProjects DEBUG] fetchProjects starting for user:", user.id);
setLoading(true);
try {
const data = await dataService.getProjects(user.id);
console.log("[useProjects DEBUG] Projects fetched:", data);
setProjects(data.map((p: any) => ({
id: p.id.toString(),
title: p.title,
author: p.author,
lastModified: Date.now(),
settings: {
genre: p.genre,
subGenre: p.sub_genre,
targetAudience: p.target_audience,
tone: p.tone,
pov: p.pov,
tense: p.tense,
synopsis: p.synopsis,
themes: p.themes
},
styleGuide: p.style_guide,
chapters: [],
entities: [],
ideas: [],
workflow: { nodes: [], connections: [] }
})));
} catch (e) {
console.error("[useProjects] Erreur chargement projets:", e);
} finally {
setLoading(false);
}
}, [user]);
useEffect(() => { fetchProjects(); }, [fetchProjects]);
const fetchProjectDetails = async (projectId: string) => {
const id = parseInt(projectId);
try {
const [chapters, entities, ideas, workflows] = await Promise.all([
dataService.getRelatedData('chapters', id),
dataService.getRelatedData('entities', id),
dataService.getRelatedData('ideas', id),
dataService.getRelatedData('workflows', id)
]);
setProjects(prev => prev.map(p => p.id === projectId ? {
...p,
chapters: chapters.map((c: any) => ({ ...c, id: c.id.toString() })),
entities: entities.map((e: any) => ({
...e,
id: e.id.toString(),
customValues: e.custom_values ? JSON.parse(e.custom_values) : {},
attributes: e.attributes ? (typeof e.attributes === 'string' ? JSON.parse(e.attributes) : e.attributes) : undefined
})),
ideas: ideas.map((i: any) => ({ ...i, id: i.id.toString() })),
workflow: workflows[0] ? {
nodes: JSON.parse(workflows[0].nodes || '[]'),
connections: JSON.parse(workflows[0].connections || '[]')
} : { nodes: [], connections: [] }
} : p));
} catch (e) {
console.error("[useProjects] Erreur détails projet:", e);
}
};
const createProject = async () => {
if (!user) return null;
const result = await dataService.createProject({
user_id: user.id,
title: "Nouveau Roman",
author: user.name
});
if (result.status === 'success') {
await fetchProjects();
return result.id.toString();
}
return null;
};
const addChapter = async () => {
if (!currentProjectId) return null;
const projectId = parseInt(currentProjectId);
const result = await dataService.createItem('chapters', {
project_id: projectId,
title: "Nouveau Chapitre",
content: "<p>Écrivez ici votre prochain chapitre...</p>",
summary: ""
});
if (result.status === 'success' && result.id) {
await fetchProjectDetails(currentProjectId);
return result.id.toString();
}
return null;
};
const createEntity = async (entity: Omit<Entity, 'id'>) => {
if (!currentProjectId) return null;
const projectId = parseInt(currentProjectId);
const dbPayload = {
project_id: projectId,
type: entity.type,
name: entity.name,
description: entity.description,
details: entity.details,
story_context: entity.storyContext,
attributes: JSON.stringify(entity.attributes || {}),
custom_values: JSON.stringify(entity.customValues || {})
};
const result = await dataService.createItem('entities', dbPayload);
if (result.status === 'success') {
const newId = result.id.toString();
const newEntity = { ...entity, id: newId };
setProjects(prev => prev.map(p => p.id === currentProjectId ? {
...p,
entities: [...p.entities, newEntity]
} : p));
return newId;
}
return null;
};
const updateEntity = async (entityId: string, updates: Partial<Entity>) => {
if (!currentProjectId) return;
const pId = parseInt(currentProjectId);
const eId = parseInt(entityId);
const dbPayload: any = {};
if (updates.name !== undefined) dbPayload.name = updates.name;
if (updates.description !== undefined) dbPayload.description = updates.description;
if (updates.details !== undefined) dbPayload.details = updates.details;
if (updates.storyContext !== undefined) dbPayload.story_context = updates.storyContext;
if (updates.attributes !== undefined) dbPayload.attributes = JSON.stringify(updates.attributes);
if (updates.customValues !== undefined) dbPayload.custom_values = JSON.stringify(updates.customValues);
if (Object.keys(dbPayload).length > 0) {
await dataService.updateItem('entities', eId, dbPayload);
}
setProjects(prev => prev.map(p => p.id === currentProjectId ? {
...p,
entities: p.entities.map(e => e.id === entityId ? { ...e, ...updates } : e)
} : p));
};
const deleteEntity = async (entityId: string) => {
if (!currentProjectId) return;
const eId = parseInt(entityId);
await dataService.deleteItem('entities', eId);
setProjects(prev => prev.map(p => p.id === currentProjectId ? {
...p,
entities: p.entities.filter(e => e.id !== entityId)
} : p));
};
const updateProject = async (updates: Partial<BookProject>) => {
if (!currentProjectId) return;
const id = parseInt(currentProjectId);
const ncbData: any = {};
if (updates.title) ncbData.title = updates.title;
if (updates.author) ncbData.author = updates.author;
if (updates.settings) {
ncbData.genre = updates.settings.genre;
ncbData.sub_genre = updates.settings.subGenre;
ncbData.synopsis = updates.settings.synopsis;
ncbData.tone = updates.settings.tone;
ncbData.pov = updates.settings.pov;
ncbData.tense = updates.settings.tense;
}
// Note: workflow, ideas, templates are not yet persisted via updateProject fully if complex types
// Using separate updaters is better.
if (Object.keys(ncbData).length > 0) {
await dataService.updateItem('projects', id, ncbData);
}
setProjects(prev => prev.map(p => p.id === currentProjectId ? { ...p, ...updates } as BookProject : p));
};
const updateChapter = async (chapterId: string, updates: Partial<Chapter>) => {
if (!currentProjectId) return;
const pId = parseInt(currentProjectId);
const cId = parseInt(chapterId);
// Call API
await dataService.updateItem('chapters', cId, updates);
// Update Local State
setProjects(prev => prev.map(p => p.id === currentProjectId ? {
...p,
chapters: p.chapters.map(c => c.id === chapterId ? { ...c, ...updates } : c)
} : p));
};
return {
projects, currentProjectId, setCurrentProjectId: (id: string | null) => {
setCurrentProjectId(id);
if (id) fetchProjectDetails(id);
},
createProject, updateProject, updateChapter, addChapter,
createEntity, updateEntity, deleteEntity,
loading
};
}
export function useChat() {
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const sendMessage = async (project: BookProject, chapterId: string, msg: string, user: UserProfile, onUsed: () => void) => {
const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', text: msg };
setChatHistory(prev => [...prev, userMsg]);
setIsGenerating(true);
try {
const response = await generateStoryContent(project, chapterId, msg, user, onUsed);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'model',
text: response.text,
responseType: response.type
};
setChatHistory(prev => [...prev, aiMsg]);
} catch (error) {
console.error(error);
} finally {
setIsGenerating(false);
}
};
return { chatHistory, isGenerating, sendMessage };
}

416
hooks.ts Normal file
View File

@@ -0,0 +1,416 @@
import { useState, useCallback, useEffect } from 'react';
import { UserProfile, BookProject, Chapter, ChatMessage, PlanType, Entity, Idea, WorkflowData } from './types';
import { INITIAL_CHAPTER } from './constants';
import { generateStoryContent } from './services/geminiService';
import { authService, dataService } from './services/api';
// --- UTILS ---
const safeJSON = (data: any, fallback: any = {}) => {
if (typeof data === 'object' && data !== null) return data;
try {
return data ? JSON.parse(data) : fallback;
} catch (e) {
console.error("JSON Parse Error:", e);
return fallback;
}
};
// --- AUTH HOOK ---
export function useAuth() {
const [user, setUser] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const checkSession = useCallback(async (injectedUser: any = null) => {
setLoading(true);
console.log("[useAuth] Starting session check...");
try {
// 1. Get Session from Auth API
const sessionUser = injectedUser || await authService.getSession();
if (sessionUser) {
console.log("[useAuth] Session valid:", sessionUser.id);
// 2. Get Profile from Data API
let profile = await dataService.getProfile(sessionUser.id);
// 3. Create Profile if missing
if (!profile) {
console.log("[useAuth] Profile missing, creating default...");
const createResult = await dataService.createItem('profiles', {
user_id: sessionUser.id,
email: sessionUser.email,
full_name: sessionUser.name || 'Author',
ai_actions_limit: 10,
projects_limit: 1,
subscription_plan: 'free',
theme: 'light'
});
// Optimistic profile for immediate UI render
profile = {
user_id: sessionUser.id,
email: sessionUser.email,
full_name: sessionUser.name,
ai_actions_limit: 10,
projects_limit: 1,
subscription_plan: 'free'
};
}
// 4. Map DB Profile to Frontend Type
const userProfile: UserProfile = {
id: sessionUser.id,
email: sessionUser.email,
name: profile.full_name || sessionUser.name || 'Writer',
avatar: profile.avatar_url || `https://i.pravatar.cc/150?u=${sessionUser.email}`,
bio: profile.bio || "",
subscription: {
plan: (profile.subscription_plan as PlanType) || 'free',
startDate: 0,
status: 'active'
},
usage: {
aiActionsCurrent: profile.ai_actions_current || 0,
aiActionsLimit: profile.ai_actions_limit || 10,
projectsLimit: profile.projects_limit || 1
},
preferences: {
theme: profile.theme || 'light',
dailyWordGoal: profile.daily_word_goal || 500,
language: profile.language || 'fr'
},
stats: {
totalWordsWritten: profile.total_words_written || 0,
writingStreak: profile.writing_streak || 0,
lastWriteDate: profile.last_write_date ? new Date(profile.last_write_date).getTime() : 0
}
};
setUser(userProfile);
console.log("[useAuth] User fully loaded.");
} else {
console.log("[useAuth] No active session.");
setUser(null);
}
} catch (err) {
console.error("[useAuth] Error:", err);
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { checkSession(); }, [checkSession]);
const login = async (data: any) => {
setLoading(true);
const result = await authService.signIn(data.email, data.password);
if (result && !result.error) {
// Optimistic session check with returned user data
const userObj = result.user || (result.id ? result : null);
await checkSession(userObj);
} else {
setLoading(false);
}
return result;
};
const signup = async (data: any) => {
setLoading(true);
const result = await authService.signUp(data.email, data.password, data.name);
if (result && !result.error) {
const userObj = result.user || result;
await checkSession(userObj);
} else {
setLoading(false);
}
return result;
};
const logout = async () => {
setLoading(true);
await authService.signOut();
setUser(null);
setLoading(false);
};
const incrementUsage = async () => {
// In a real app with RLS, this should be done via a secure backend function
// or by the AI service itself.
// For now we optimistically update UI.
if (user) {
setUser(prev => prev ? {
...prev,
usage: { ...prev.usage, aiActionsCurrent: prev.usage.aiActionsCurrent + 1 }
} : null);
}
};
return { user, login, signup, logout, incrementUsage, loading };
}
// --- PROJECTS HOOK ---
export function useProjects(user: UserProfile | null) {
const [projects, setProjects] = useState<BookProject[]>([]);
const [currentProjectId, setCurrentProjectId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Initial Fetch
const fetchProjects = useCallback(async () => {
if (!user) return;
setLoading(true);
try {
const dbProjects = await dataService.getProjects(user.id);
const mappedProjects = dbProjects.map((p: any) => ({
id: p.id.toString(),
title: p.title,
author: p.author || user.name,
lastModified: Date.now(), // No proper modify date in schema yet
settings: {
genre: p.genre,
subGenre: p.sub_genre,
targetAudience: p.target_audience,
tone: p.tone,
pov: p.pov,
tense: p.tense,
synopsis: p.synopsis,
themes: p.themes
},
styleGuide: p.style_guide,
// Empty init, full load happens on selection
chapters: [],
entities: [],
ideas: [],
workflow: { nodes: [], connections: [] }
}));
setProjects(mappedProjects);
} catch (e) {
console.error("[useProjects] Fetch error:", e);
} finally {
setLoading(false);
}
}, [user]);
useEffect(() => { fetchProjects(); }, [fetchProjects]);
// Deep Fetch Triggered on Project Selection
const fetchProjectDetails = async (projectId: string) => {
const id = parseInt(projectId);
try {
const [chapters, entities, ideas, workflows] = await Promise.all([
dataService.getRelatedData('chapters', id),
dataService.getRelatedData('entities', id),
dataService.getRelatedData('ideas', id),
dataService.getRelatedData('workflows', id)
]);
setProjects(prev => prev.map(p => p.id === projectId ? {
...p,
chapters: chapters.map((c: any) => ({
...c,
id: c.id.toString()
})),
entities: entities.map((e: any) => ({
...e,
id: e.id.toString(),
// JSON Columns parsing
customValues: safeJSON(e.custom_values),
attributes: safeJSON(e.attributes),
storyContext: e.story_context
})),
ideas: ideas.map((i: any) => ({
...i,
id: i.id.toString()
})),
workflow: workflows[0] ? {
nodes: safeJSON(workflows[0].nodes, []),
connections: safeJSON(workflows[0].connections, [])
} : { nodes: [], connections: [] }
} : p));
} catch (e) {
console.error("[useProjects] Details error:", e);
}
};
// --- CRUD OPERATIONS ---
const createProject = async () => {
if (!user) return null;
const result = await dataService.createProject({
user_id: user.id,
title: "Nouveau Roman",
author: user.name,
// Default values
genre: "Fiction",
pov: "First Person",
tense: "Past"
});
if (result.status === 'success') {
await fetchProjects(); // Refresh list
return result.id.toString();
}
return null;
};
const updateProject = async (updates: Partial<BookProject>) => {
if (!currentProjectId) return;
const id = parseInt(currentProjectId);
// Map Frontend updates to DB Columns
const ncbData: any = {};
if (updates.title) ncbData.title = updates.title;
if (updates.author) ncbData.author = updates.author;
if (updates.settings) {
ncbData.genre = updates.settings.genre;
ncbData.sub_genre = updates.settings.subGenre;
ncbData.synopsis = updates.settings.synopsis;
ncbData.tone = updates.settings.tone;
ncbData.pov = updates.settings.pov;
ncbData.tense = updates.settings.tense;
ncbData.target_audience = updates.settings.targetAudience;
}
if (updates.styleGuide) ncbData.style_guide = updates.styleGuide;
if (Object.keys(ncbData).length > 0) {
await dataService.updateItem('projects', id, ncbData);
}
// Optimistic Update
setProjects(prev => prev.map(p => p.id === currentProjectId ? { ...p, ...updates } as BookProject : p));
};
const addChapter = async () => {
if (!currentProjectId) return null;
const projectId = parseInt(currentProjectId);
const result = await dataService.createItem('chapters', {
project_id: projectId,
title: "Nouveau Chapitre",
content: "<p>Contenu...</p>",
summary: ""
});
if (result.status === 'success') {
await fetchProjectDetails(currentProjectId);
return result.id.toString();
}
return null;
};
const updateChapter = async (chapterId: string, updates: Partial<Chapter>) => {
if (!currentProjectId) return;
await dataService.updateItem('chapters', parseInt(chapterId), updates);
setProjects(prev => prev.map(p => p.id === currentProjectId ? {
...p,
chapters: p.chapters.map(c => c.id === chapterId ? { ...c, ...updates } : c)
} : p));
};
const createEntity = async (entity: Omit<Entity, 'id'>) => {
if (!currentProjectId) return null;
const dbPayload = {
project_id: parseInt(currentProjectId),
type: entity.type,
name: entity.name,
description: entity.description,
details: entity.details,
story_context: entity.storyContext,
// Handled as strings in JSON columns
attributes: JSON.stringify(entity.attributes || {}),
custom_values: JSON.stringify(entity.customValues || {})
};
const result = await dataService.createItem('entities', dbPayload);
if (result.status === 'success') {
const newId = result.id.toString();
// Update local
setProjects(prev => prev.map(p => p.id === currentProjectId ? {
...p,
entities: [...p.entities, { ...entity, id: newId }]
} : p));
return newId;
}
return null;
};
const updateEntity = async (entityId: string, updates: Partial<Entity>) => {
if (!currentProjectId) return;
const dbPayload: any = {};
// Map specific fields
if (updates.name) dbPayload.name = updates.name;
if (updates.description) dbPayload.description = updates.description;
if (updates.details) dbPayload.details = updates.details;
if (updates.storyContext) dbPayload.story_context = updates.storyContext;
if (updates.attributes) dbPayload.attributes = JSON.stringify(updates.attributes);
if (updates.customValues) dbPayload.custom_values = JSON.stringify(updates.customValues);
await dataService.updateItem('entities', parseInt(entityId), dbPayload);
setProjects(prev => prev.map(p => p.id === currentProjectId ? {
...p,
entities: p.entities.map(e => e.id === entityId ? { ...e, ...updates } : e)
} : p));
};
const deleteEntity = async (entityId: string) => {
if (!currentProjectId) return;
await dataService.deleteItem('entities', parseInt(entityId));
setProjects(prev => prev.map(p => p.id === currentProjectId ? {
...p,
entities: p.entities.filter(e => e.id !== entityId)
} : p));
};
return {
projects,
currentProjectId,
setCurrentProjectId: (id: string | null) => {
setCurrentProjectId(id);
if (id) fetchProjectDetails(id);
},
createProject, updateProject, updateChapter, addChapter,
createEntity, updateEntity, deleteEntity,
loading
};
}
// --- CHAT HOOK ---
export function useChat() {
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const sendMessage = async (project: BookProject, chapterId: string, msg: string, user: UserProfile, onUsed: () => void) => {
const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', text: msg };
setChatHistory(prev => [...prev, userMsg]);
setIsGenerating(true);
try {
const response = await generateStoryContent(project, chapterId, msg, user, onUsed);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'model',
text: response.text,
responseType: response.type
};
setChatHistory(prev => [...prev, aiMsg]);
} catch (error) {
console.error(error);
} finally {
setIsGenerating(false);
}
};
return { chatHistory, isGenerating, sendMessage };
}

109
index.html Normal file
View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlumeIA - Éditeur Intelligent</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
serif: ['Merriweather', 'serif'],
},
colors: {
paper: '#fcfbf7', // Warm paper color
}
}
}
}
</script>
<style>
@media print {
@page {
margin: 2cm;
size: auto;
}
html, body {
height: auto !important;
overflow: visible !important;
margin: 0 !important;
padding: 0 !important;
background: white !important;
color: black !important;
}
#root {
height: auto !important;
overflow: visible !important;
display: block !important;
position: relative !important;
}
.no-print { display: none !important; }
.print-only { display: block !important; }
/* Force page breaks */
.break-before-page { page-break-before: always; break-before: page; }
.break-after-page { page-break-after: always; break-after: page; }
/* Typography adjustments */
p {
text-align: justify;
widows: 3;
orphans: 3;
color: black !important;
}
h1, h2, h3, h4 {
color: black !important;
page-break-after: avoid;
}
a { text-decoration: none; color: black !important; }
}
.editor-content:empty:before {
content: attr(placeholder);
color: #9ca3af;
pointer-events: none;
}
/* Custom scrollbar for webkit */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
<script type="importmap">
{
"imports": {
"react/": "https://esm.sh/react@^19.2.4/",
"react": "https://esm.sh/react@^19.2.4",
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
"@google/genai": "https://esm.sh/@google/genai@^1.38.0",
"lucide-react": "https://esm.sh/lucide-react@^0.563.0"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-gray-100 text-slate-800 h-screen overflow-hidden">
<div id="root" class="h-full flex flex-col"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "PlumeIA - Éditeur de Livre Intelligent",
"description": "Un éditeur de livre complet avec gestion de monde (personnages, lieux), éditeur de texte riche et assistant IA contextuel pour l'aide à l'écriture.",
"requestFramePermissions": []
}

2826
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "plumeia---éditeur-de-livre-intelligent",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "^1.38.0",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

264
services/api.backup.ts Normal file
View File

@@ -0,0 +1,264 @@
import { BookProject, Chapter, Entity, Idea, WorkflowData, UserProfile } from '../types';
// Configuration NoCodeBackend
const AUTH_API_ROOT = 'https://app.nocodebackend.com/api/user-auth';
const DATA_API_ROOT = 'https://openapi.nocodebackend.com';
const INSTANCE_ID = '54770_plumeia_db';
// WARNING: SECRET_KEY restored for debugging purposes (createItem failed without it).
// Ideally, RLS should allow user creation. If not, this key is needed.
const SECRET_KEY = 'c11b7be982d6f369b71a74a7735caf8c2d25d6b09bb5a6ac52d12d49bff3acf1';
const mcp_server = "ncb_67f8e246c8e4e18598918212035ba6b309d6cac9a6aec919";
/**
* Récupère les headers avec gestion du token de session
* @param forceUserToken Si true, on n'utilise pas la SECRET_KEY en fallback (pour l'auth)
*/
const getHeaders = (isAuthAction = false) => {
const token = localStorage.getItem('ncb_session_token');
const headers: any = {
'Content-Type': 'application/json',
'X-Database-Instance': INSTANCE_ID
};
// RLS requires the User Token in the Authorization header
if (token && !isAuthAction) {
headers['Authorization'] = `Bearer ${token}`;
} else {
// FALLBACK: Use Secret Key if no token is available (e.g., initial profile creation)
headers['Authorization'] = `Bearer ${SECRET_KEY}`;
}
return headers;
};
/**
* Service d'authentification NoCodeBackend
*/
export const authService = {
async getSession() {
console.log("[Auth] getSession");
const token = localStorage.getItem('ncb_session_token');
if (!token) return null;
console.log("[Auth] getSession token:", token);
try {
const res = await fetch(`${AUTH_API_ROOT}/get-session`, {
method: 'GET',
headers: getHeaders(),
credentials: 'include'
});
const data = await res.json();
console.log("[Auth] getSession data:", data);
if (!res.ok || !data) { // Vérifie que data existe
if (res.status === 401) localStorage.removeItem('ncb_session_token');
return null;
}
// Utilise le chaînage optionnel ?.
return data?.user || (data?.id ? data : null);
} catch (err) {
console.error("[Auth] Erreur critique getSession:", err);
return null;
}
},
async signIn(email: string, password: string) {
console.log("[Auth] Tentative de connexion pour:", email);
try {
const res = await fetch(`${AUTH_API_ROOT}/sign-in/email`, {
method: 'POST',
headers: getHeaders(true),
body: JSON.stringify({ email, password }),
credentials: 'include'
});
const data = await res.json();
console.log("[Auth] signIn: Payload reçu:", data);
if (!res.ok) {
return { error: data.message || 'Identifiants incorrects' };
}
// DEBUG: Trace exact token extraction
console.log("[Auth] Token extraction attempt:", {
'user.token': data.user?.token,
'token': data.token,
'session.token': data.session?.token,
'auth_token': data.auth_token,
'accessToken': data.accessToken,
'_token': data._token
});
const token = data.user?.token || data.token || data.session?.token || data.auth_token || data.accessToken || data._token;
console.log("[Auth] Final extracted token:", token);
if (token) {
localStorage.setItem('ncb_session_token', token);
} else {
console.error("[Auth] WARNING: No token found in response!");
}
return data;
} catch (err) {
console.error("[Auth] Erreur réseau sign-in:", err);
return { error: 'Erreur réseau lors de la connexion' };
}
},
async signUp(email: string, password: string, name: string) {
try {
const res = await fetch(`${AUTH_API_ROOT}/sign-up/email`, {
method: 'POST',
headers: getHeaders(true),
body: JSON.stringify({ email, password, name }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) {
return { error: data.message || "Erreur lors de la création du compte" };
}
const token = data.user?.token || data.token || data.session?.token || data.auth_token || data.accessToken;
if (token) {
localStorage.setItem('ncb_session_token', token);
}
return data;
} catch (err) {
return { error: "Erreur réseau lors de l'inscription" };
}
},
async signOut() {
try {
await fetch(`${AUTH_API_ROOT}/sign-out`, {
method: 'POST',
headers: getHeaders(true),
body: JSON.stringify({}),
credentials: 'include'
});
} catch (err) {
console.error("[Auth] Erreur sign-out:", err);
} finally {
localStorage.removeItem('ncb_session_token');
}
}
};
export const dataService = {
async getProfile(userId: string) {
try {
const encodedId = encodeURIComponent(userId);
const url = `${DATA_API_ROOT}/read/profiles?Instance=${INSTANCE_ID}&user_id=${encodedId}`;
const res = await fetch(url, { headers: getHeaders(), credentials: 'include' });
const json = await res.json();
if (!res.ok) {
console.error("[Data] Erreur lecture profil API:", json);
return null;
}
return json.data?.[0] || null;
} catch (err) {
console.error("[Data] Erreur lecture profil:", err);
return null;
}
},
async getProjects(userId: string) {
try {
const encodedId = encodeURIComponent(userId);
const url = `${DATA_API_ROOT}/read/projects?Instance=${INSTANCE_ID}&user_id=${encodedId}`;
const res = await fetch(url, { headers: getHeaders(), credentials: 'include' });
const json = await res.json();
return json.data || [];
} catch (err) {
console.error("[Data] Erreur lecture projets:", err);
return [];
}
},
async createProject(projectData: any) {
try {
const url = `${DATA_API_ROOT}/create/projects?Instance=${INSTANCE_ID}`;
const res = await fetch(url, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(projectData),
credentials: 'include'
});
const data = await res.json();
return data;
} catch (err) {
console.error("[Data] Erreur création projet:", err);
return { status: 'error' };
}
},
async getRelatedData(table: string, projectId: number) {
try {
const url = `${DATA_API_ROOT}/read/${table}?Instance=${INSTANCE_ID}&project_id=${projectId}`;
const res = await fetch(url, { headers: getHeaders(), credentials: 'include' });
const json = await res.json();
return json.data || [];
} catch (err) {
console.error(`[Data] Erreur lecture ${table}:`, err);
return [];
}
},
async createItem(table: string, data: any) {
try {
console.log(`[Data] Création item dans ${table}...`, data);
const url = `${DATA_API_ROOT}/create/${table}?Instance=${INSTANCE_ID}`;
const res = await fetch(url, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data),
credentials: 'include'
});
const result = await res.json();
if (!res.ok) {
console.error(`[Data] Erreur API création ${table}:`, result);
}
return result;
} catch (err) {
console.error(`[Data] Erreur réseau création item ${table}:`, err);
return { status: 'error', message: err };
}
},
async updateItem(table: string, id: number, data: any) {
try {
const url = `${DATA_API_ROOT}/update/${table}/${id}?Instance=${INSTANCE_ID}`;
await fetch(url, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data),
credentials: 'include'
});
} catch (err) {
console.error(`[Data] Erreur mise à jour item ${table}:`, err);
}
},
async deleteItem(table: string, id: number) {
try {
const url = `${DATA_API_ROOT}/delete/${table}/${id}?Instance=${INSTANCE_ID}`;
await fetch(url, {
method: 'DELETE',
headers: getHeaders(),
credentials: 'include'
});
} catch (err) {
console.error(`[Data] Erreur suppression item ${table}:`, err);
}
}
};

233
services/api.bak2.ts Normal file
View File

@@ -0,0 +1,233 @@
import { BookProject, Chapter, Entity, Idea, WorkflowData, UserProfile } from '../types';
// --- CONFIGURATION ---
const AUTH_API_ROOT = 'https://app.nocodebackend.com/api/user-auth';
// Use the logic from the guide: Data API is at app.nocodebackend.com/api/data
const DATA_API_ROOT = 'https://app.nocodebackend.com/api/data';
const INSTANCE_ID = '54770_plumeia_db';
// --- HELPERS ---
/**
* Retrieves headers with the Session Token from localStorage.
* Used for both Auth (when authenticated) and Data requests.
*/
const getHeaders = () => {
const token = localStorage.getItem('ncb_session_token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Database-Instance': INSTANCE_ID
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
};
/**
* Extracts and stores the session token from the Auth Response.
* Handles various token field names found in NCB responses.
*/
const handleAuthResponse = async (res: Response) => {
const data = await res.json();
console.log("[API] Auth Response:", data);
if (!res.ok) {
throw new Error(data.message || 'Authentication failed');
}
// extract token
const token =
data.user?.token ||
data.token ||
data.session?.token ||
data.auth_token ||
data.accessToken ||
data._token;
if (token) {
console.log("[API] Token extracted and saved:", token);
localStorage.setItem('ncb_session_token', token);
} else {
console.warn("[API] No token found in successful auth response!");
}
return data;
};
// --- AUTH SERVICE ---
export const authService = {
async getSession() {
const token = localStorage.getItem('ncb_session_token');
if (!token) return null;
try {
const res = await fetch(`${AUTH_API_ROOT}/get-session?Instance=${INSTANCE_ID}`, {
method: 'GET',
headers: getHeaders()
});
if (!res.ok) {
// If 401, token is invalid
if (res.status === 401) {
localStorage.removeItem('ncb_session_token');
}
return null;
}
const data = await res.json();
return data.user || data;
} catch (err) {
console.error("[Auth] getSession error:", err);
return null;
}
},
async signIn(email: string, password: string) {
try {
const res = await fetch(`${AUTH_API_ROOT}/sign-in/email?Instance=${INSTANCE_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Database-Instance': INSTANCE_ID
},
body: JSON.stringify({ email, password })
});
return await handleAuthResponse(res);
} catch (err: any) {
console.error("[Auth] signIn error:", err);
return { error: err.message || 'Connection failed' };
}
},
async signUp(email: string, password: string, name: string) {
try {
const res = await fetch(`${AUTH_API_ROOT}/sign-up/email?Instance=${INSTANCE_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Database-Instance': INSTANCE_ID
},
body: JSON.stringify({ email, password, name })
});
return await handleAuthResponse(res);
} catch (err: any) {
return { error: err.message || 'Registration failed' };
}
},
async signOut() {
try {
await fetch(`${AUTH_API_ROOT}/sign-out?Instance=${INSTANCE_ID}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
console.error("[Auth] signOut error:", err);
} finally {
localStorage.removeItem('ncb_session_token');
}
}
};
// --- DATA SERVICE ---
export const dataService = {
async getProfile(userId: string) {
try {
// Use standard read endpoint
const encodedId = encodeURIComponent(userId);
const url = `${DATA_API_ROOT}/read/profiles?Instance=${INSTANCE_ID}&user_id=${encodedId}`;
const res = await fetch(url, { headers: getHeaders() });
const json = await res.json();
return json.data?.[0] || null;
} catch (err) {
console.error("[Data] getProfile error:", err);
return null;
}
},
async getProjects(userId: string) {
try {
const encodedId = encodeURIComponent(userId);
const url = `${DATA_API_ROOT}/read/projects?Instance=${INSTANCE_ID}&user_id=${encodedId}`;
const res = await fetch(url, { headers: getHeaders() });
const json = await res.json();
return json.data || [];
} catch (err) {
console.error("[Data] getProjects error:", err);
return [];
}
},
async createProject(projectData: any) {
return this.createItem('projects', projectData);
},
async getRelatedData(table: string, projectId: number) {
try {
const url = `${DATA_API_ROOT}/read/${table}?Instance=${INSTANCE_ID}&project_id=${projectId}`;
const res = await fetch(url, { headers: getHeaders() });
const json = await res.json();
return json.data || [];
} catch (err) {
console.error(`[Data] getRelatedData ${table} error:`, err);
return [];
}
},
async createItem(table: string, data: any) {
try {
console.log(`[Data] Creating item in ${table}...`, data);
const url = `${DATA_API_ROOT}/create/${table}?Instance=${INSTANCE_ID}`;
const res = await fetch(url, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data)
});
const result = await res.json();
if (!res.ok) {
console.error(`[Data] Create ${table} failed:`, result);
return { status: 'error', message: result.message || 'Creation failed' };
}
return { status: 'success', ...result };
} catch (err) {
console.error(`[Data] Create ${table} network error:`, err);
return { status: 'error', message: err };
}
},
async updateItem(table: string, id: number, data: any) {
try {
const url = `${DATA_API_ROOT}/update/${table}/${id}?Instance=${INSTANCE_ID}`;
await fetch(url, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data)
});
} catch (err) {
console.error(`[Data] Update ${table} error:`, err);
}
},
async deleteItem(table: string, id: number) {
try {
const url = `${DATA_API_ROOT}/delete/${table}/${id}?Instance=${INSTANCE_ID}`;
await fetch(url, {
method: 'DELETE',
headers: getHeaders()
});
} catch (err) {
console.error(`[Data] Delete ${table} error:`, err);
}
}
};

235
services/api.ts Normal file
View File

@@ -0,0 +1,235 @@
import { BookProject, Chapter, Entity, Idea, WorkflowData, UserProfile } from '../types';
// --- CONFIGURATION ---
const AUTH_API_ROOT = 'https://app.nocodebackend.com/api/user-auth';
const DATA_API_ROOT = 'https://app.nocodebackend.com/api/data';
const INSTANCE_ID = '54770_plumeia_db';
// --- HELPERS ---
const getHeaders = () => {
const token = localStorage.getItem('ncb_session_token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Database-Instance': INSTANCE_ID
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
};
const handleAuthResponse = async (res: Response) => {
const data = await res.json();
console.log("[API] Auth Response:", data);
if (!res.ok) {
throw new Error(data.message || 'Authentication failed');
}
// Token extraction strategy based on NCB patterns
const token =
data.user?.token ||
data.token ||
data.session?.token ||
data.auth_token ||
data.accessToken ||
data._token;
if (token) {
console.log("[API] Token extracted and saved:", token);
localStorage.setItem('ncb_session_token', token);
} else {
console.warn("[API] No token found in successful auth response!");
}
return data;
};
// --- AUTH SERVICE ---
export const authService = {
async getSession() {
const token = localStorage.getItem('ncb_session_token');
if (!token) return null;
try {
const res = await fetch(`${AUTH_API_ROOT}/get-session?Instance=${INSTANCE_ID}`, {
method: 'GET',
headers: getHeaders()
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('ncb_session_token');
}
return null;
}
const data = await res.json();
return data.user || data;
} catch (err) {
console.error("[Auth] getSession error:", err);
return null;
}
},
async signIn(email: string, password: string) {
try {
// Force X-Database-Instance header as URL param is insufficient for some NCB versions
const res = await fetch(`${AUTH_API_ROOT}/sign-in/email?Instance=${INSTANCE_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Database-Instance': INSTANCE_ID
},
body: JSON.stringify({ email, password })
});
return await handleAuthResponse(res);
} catch (err: any) {
console.error("[Auth] signIn error:", err);
return { error: err.message || 'Connection failed' };
}
},
async signUp(email: string, password: string, name: string) {
try {
const res = await fetch(`${AUTH_API_ROOT}/sign-up/email?Instance=${INSTANCE_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Database-Instance': INSTANCE_ID
},
body: JSON.stringify({ email, password, name })
});
return await handleAuthResponse(res);
} catch (err: any) {
return { error: err.message || 'Registration failed' };
}
},
async signOut() {
try {
await fetch(`${AUTH_API_ROOT}/sign-out?Instance=${INSTANCE_ID}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
console.error("[Auth] signOut error:", err);
} finally {
localStorage.removeItem('ncb_session_token');
}
}
};
// --- DATA SERVICE ---
export const dataService = {
// -- PROFILES --
async getProfile(userId: string) {
try {
const encodedId = encodeURIComponent(userId);
const url = `${DATA_API_ROOT}/read/profiles?Instance=${INSTANCE_ID}&user_id=${encodedId}`;
const res = await fetch(url, { headers: getHeaders() });
const json = await res.json();
return json.data?.[0] || null;
} catch (err) {
console.error("[Data] getProfile error:", err);
return null;
}
},
async createProfile(profileData: any) {
return this.createItem('profiles', profileData);
},
// -- PROJECTS --
async getProjects(userId: string) {
try {
const encodedId = encodeURIComponent(userId);
const url = `${DATA_API_ROOT}/read/projects?Instance=${INSTANCE_ID}&user_id=${encodedId}`;
const res = await fetch(url, { headers: getHeaders() });
const json = await res.json();
return json.data || [];
} catch (err) {
console.error("[Data] getProjects error:", err);
return [];
}
},
async createProject(projectData: Partial<BookProject> & { user_id: string }) {
// Map Frontend types to Database Columns explicitly if needed, but names match mostly
// DB: title, author, genre, sub_genre, target_audience, tone, pov, tense, synopsis, themes, style_guide
return this.createItem('projects', projectData);
},
// -- GENERIC CRUD --
async getRelatedData(table: string, projectId: number) {
try {
const url = `${DATA_API_ROOT}/read/${table}?Instance=${INSTANCE_ID}&project_id=${projectId}`;
const res = await fetch(url, { headers: getHeaders() });
const json = await res.json();
return json.data || [];
} catch (err) {
console.error(`[Data] getRelatedData ${table} error:`, err);
return [];
}
},
async createItem(table: string, data: any) {
try {
console.log(`[Data] Creating item in ${table}...`, data);
const url = `${DATA_API_ROOT}/create/${table}?Instance=${INSTANCE_ID}`;
const res = await fetch(url, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data),
credentials: 'include'
});
const result = await res.json();
if (!res.ok) {
console.error(`[Data] Create ${table} failed:`, result);
return { status: 'error', message: result.message || 'Creation failed' };
}
return { status: 'success', ...result };
} catch (err) {
console.error(`[Data] Create ${table} network error:`, err);
return { status: 'error', message: err };
}
},
async updateItem(table: string, id: number, data: any) {
try {
const url = `${DATA_API_ROOT}/update/${table}/${id}?Instance=${INSTANCE_ID}`;
await fetch(url, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data)
});
} catch (err) {
console.error(`[Data] Update ${table} error:`, err);
}
},
async deleteItem(table: string, id: number) {
try {
const url = `${DATA_API_ROOT}/delete/${table}/${id}?Instance=${INSTANCE_ID}`;
await fetch(url, {
method: 'DELETE',
headers: getHeaders()
});
} catch (err) {
console.error(`[Data] Delete ${table} error:`, err);
}
}
};

185
services/geminiService.ts Normal file
View File

@@ -0,0 +1,185 @@
import { GoogleGenAI, Type } from "@google/genai";
import { BookProject, Chapter, Entity, UserProfile } from "../types";
const truncate = (str: string, length: number) => {
if (!str) return "";
return str.length > length ? str.substring(0, length) + "..." : str;
};
const checkUsage = (user: UserProfile) => {
if (user.subscription.plan === 'master') return true;
return user.usage.aiActionsCurrent < user.usage.aiActionsLimit;
};
const buildContextPrompt = (project: BookProject, currentChapterId: string, instruction: string) => {
const currentChapterIndex = project.chapters.findIndex(c => c.id === currentChapterId);
const previousSummaries = project.chapters
.slice(0, currentChapterIndex)
.map((c, i) => `Chapitre ${i + 1} (${c.title}): ${c.summary || truncate(c.content.replace(/<[^>]*>?/gm, ''), 200)}`)
.join('\n');
const entitiesContext = project.entities
.map(e => {
const base = `[${e.type}] ${e.name}: ${truncate(e.description, 150)}`;
const context = e.storyContext ? `\n - VÉCU/ÉVOLUTION DANS L'HISTOIRE: ${truncate(e.storyContext, 500)}` : '';
return base + context;
})
.join('\n');
const ideasContext = (project.ideas || [])
.map(i => {
const statusMap: Record<string, string> = { todo: 'À FAIRE', progress: 'EN COURS', done: 'TERMINÉ' };
return `[IDÉE - ${statusMap[i.status]}] ${i.title}: ${truncate(i.description, 100)}`;
})
.join('\n');
const currentContent = project.chapters[currentChapterIndex]?.content.replace(/<[^>]*>?/gm, '') || "";
const s = project.settings;
const settingsPrompt = s ? `
PARAMÈTRES DU ROMAN:
- Genre: ${s.genre} ${s.subGenre ? `(${s.subGenre})` : ''}
- Public: ${s.targetAudience}
- Ton: ${s.tone}
- Narration: ${s.pov}
- Temps: ${s.tense}
- Thèmes: ${s.themes}
- Synopsis Global: ${truncate(s.synopsis || '', 500)}
` : "";
return `
Tu es un assistant éditorial expert et un co-auteur créatif.
L'utilisateur écrit un livre intitulé "${project.title}".
${settingsPrompt}
CONTEXTE DE L'HISTOIRE (Résumé des chapitres précédents):
${previousSummaries || "Aucun chapitre précédent."}
BIBLE DU MONDE (Personnages et Lieux):
${entitiesContext || "Aucune fiche créée."}
BOÎTE À IDÉES & NOTES (Pistes de l'auteur):
${ideasContext || "Aucune note."}
CHAPITRE ACTUEL (Texte brut):
${truncate(currentContent, 3000)}
STYLE D'ÉCRITURE SPÉCIFIQUE (Instruction de l'auteur):
${project.styleGuide || "Standard, neutre."}
TA MISSION:
${instruction}
`;
};
export const generateStoryContent = async (
project: BookProject,
currentChapterId: string,
userPrompt: string,
user: UserProfile,
onSuccess: () => void
): Promise<{ text: string, type: 'draft' | 'reflection' }> => {
if (!checkUsage(user)) {
return { text: "Limite d'actions IA atteinte pour ce mois. Passez au plan Pro !", type: 'reflection' };
}
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const finalPrompt = buildContextPrompt(project, currentChapterId, userPrompt);
// Pro/Master plan users get better models (simulated here)
const modelName = user.subscription.plan === 'master' ? 'gemini-3-pro-preview' : 'gemini-3-flash-preview';
const response = await ai.models.generateContent({
model: modelName,
contents: finalPrompt,
config: {
temperature: 0.7,
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
responseType: {
type: Type.STRING,
enum: ["draft", "reflection"]
},
content: {
type: Type.STRING
}
}
}
}
});
onSuccess();
const result = JSON.parse(response.text || "{}");
return {
text: result.content || "Erreur de génération.",
type: result.responseType || "reflection"
};
} catch (error) {
console.error("AI Generation Error:", error);
return { text: "Erreur lors de la communication avec l'IA.", type: 'reflection' };
}
};
export const updateEntityContexts = async (text: string, entities: Entity[]): Promise<Entity[]> => {
if (!text || entities.length === 0) return entities;
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const entityList = entities.map(e => ({ id: e.id, name: e.name }));
const prompt = `Analyse ce texte et résume brièvement l'évolution ou les actions de ces personnages/lieux: ${JSON.stringify(entityList)}\nTexte: "${truncate(text, 1000)}"`;
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
entityId: { type: Type.STRING },
summary: { type: Type.STRING }
}
}
}
}
});
const updates = JSON.parse(response.text || "[]") as {entityId: string, summary: string}[];
if (updates.length === 0) return entities;
return entities.map(entity => {
const update = updates.find(u => u.entityId === entity.id);
if (update) {
const oldContext = entity.storyContext || "";
return { ...entity, storyContext: truncate(oldContext + " | " + update.summary, 1000) };
}
return entity;
});
} catch (e) { return entities; }
};
export const transformText = async (
text: string,
mode: 'correct' | 'rewrite' | 'expand' | 'continue',
context: string,
user: UserProfile,
onSuccess: () => void
): Promise<string> => {
if (!checkUsage(user)) return "Limite d'actions IA atteinte.";
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
let prompt = `Action: ${mode}. Texte: ${text}. Contexte: ${truncate(context, 1000)}. Renvoie juste le texte transformé.`;
const response = await ai.models.generateContent({ model: 'gemini-3-flash-preview', contents: prompt });
onSuccess();
return response.text?.trim() || text;
} catch (e) { return text; }
};
export const analyzeStyle = async (text: string) => "Style analysé";
export const summarizeText = async (text: string) => "Résumé généré";

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

166
types.ts Normal file
View File

@@ -0,0 +1,166 @@
export enum EntityType {
CHARACTER = 'Personnage',
LOCATION = 'Lieu',
OBJECT = 'Objet',
NOTE = 'Note'
}
export interface CharacterAttributes {
age: number;
height: number;
hair: string;
eyes: string;
archetype: string;
role: 'protagonist' | 'antagonist' | 'support' | 'extra';
personality: {
spectrumIntrovertExtravert: number;
spectrumEmotionalRational: number;
spectrumChaoticLawful: number;
};
physicalQuirk: string;
behavioralQuirk: string;
}
export type CustomFieldType = 'text' | 'textarea' | 'number' | 'boolean' | 'select';
export interface CustomFieldDefinition {
id: string;
label: string;
type: CustomFieldType;
options?: string[];
placeholder?: string;
}
export interface EntityTemplate {
entityType: EntityType;
fields: CustomFieldDefinition[];
}
export interface Entity {
id: string;
type: EntityType;
name: string;
description: string;
details: string;
storyContext?: string;
attributes?: CharacterAttributes;
customValues?: Record<string, any>;
}
export interface Chapter {
id: string;
title: string;
content: string;
summary?: string;
}
export type PlotNodeType = 'story' | 'dialogue' | 'action';
export interface PlotNode {
id: string;
x: number;
y: number;
title: string;
description: string;
color: string;
type?: PlotNodeType;
}
export interface PlotConnection {
id: string;
source: string;
target: string;
}
export interface WorkflowData {
nodes: PlotNode[];
connections: PlotConnection[];
}
export type IdeaStatus = 'todo' | 'progress' | 'done';
export type IdeaCategory = 'plot' | 'character' | 'research' | 'todo';
export interface Idea {
id: string;
title: string;
description: string;
status: IdeaStatus;
category: IdeaCategory;
createdAt: number;
}
export interface BookSettings {
genre: string;
subGenre?: string;
targetAudience: string;
tone: string;
pov: string;
tense: string;
synopsis: string;
themes: string;
}
export interface BookProject {
id: string;
title: string;
author: string;
lastModified: number;
settings?: BookSettings;
chapters: Chapter[];
entities: Entity[];
workflow?: WorkflowData;
templates?: EntityTemplate[];
styleGuide?: string;
ideas?: Idea[];
}
export interface ChatMessage {
id: string;
role: 'user' | 'model';
text: string;
responseType?: 'draft' | 'reflection';
isLoading?: boolean;
}
// --- SAAS TYPES ---
export type PlanType = 'free' | 'pro' | 'master';
export interface Subscription {
plan: PlanType;
startDate: number;
status: 'active' | 'canceled' | 'past_due';
}
export interface UserUsage {
aiActionsCurrent: number;
aiActionsLimit: number;
projectsLimit: number;
}
export interface UserPreferences {
theme: 'light' | 'dark' | 'sepia';
dailyWordGoal: number;
language: 'fr' | 'en';
}
export interface UserStats {
totalWordsWritten: number;
writingStreak: number;
lastWriteDate: number;
}
export interface UserProfile {
id: string;
email: string;
name: string;
avatar?: string;
bio?: string;
subscription: Subscription;
usage: UserUsage;
preferences: UserPreferences;
stats: UserStats;
}
export type ViewMode = 'write' | 'world_building' | 'workflow' | 'settings' | 'preview' | 'ideas' | 'landing' | 'features' | 'pricing' | 'checkout' | 'dashboard' | 'auth' | 'signup' | 'profile';

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});