authentification nocodebackend ok
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
130
App.tsx
Normal 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
14
AuthContext.tsx
Normal 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
20
README.md
Normal 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
111
components/AIPanel.tsx
Normal 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
162
components/AppRouter.tsx
Normal 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
202
components/AuthPage.tsx
Normal 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
214
components/BookSettings.tsx
Normal 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
63
components/Checkout.tsx
Normal 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
154
components/Dashboard.tsx
Normal 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
246
components/ExportModal.tsx
Normal 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;
|
||||
42
components/FeaturesPage.tsx
Normal file
42
components/FeaturesPage.tsx
Normal 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
283
components/HelpModal.tsx
Normal 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
371
components/IdeaBoard.tsx
Normal 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;
|
||||
93
components/LandingPage.tsx
Normal file
93
components/LandingPage.tsx
Normal 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
105
components/LoginPage.tsx
Normal 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
59
components/Pricing.tsx
Normal 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;
|
||||
572
components/RichTextEditor.tsx
Normal file
572
components/RichTextEditor.tsx
Normal 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;
|
||||
680
components/StoryWorkflow.tsx
Normal file
680
components/StoryWorkflow.tsx
Normal 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;
|
||||
203
components/UserProfileSettings.tsx
Normal file
203
components/UserProfileSettings.tsx
Normal 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
722
components/WorldBuilder.tsx
Normal 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;
|
||||
132
components/layout/EditorShell.tsx
Normal file
132
components/layout/EditorShell.tsx
Normal 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
67
constants.ts
Normal 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
385
hooks.bak copy.ts
Normal 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
416
hooks.ts
Normal 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
109
index.html
Normal 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
15
index.tsx
Normal 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
5
metadata.json
Normal 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
2826
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
264
services/api.backup.ts
Normal 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
233
services/api.bak2.ts
Normal 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
235
services/api.ts
Normal 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
185
services/geminiService.ts
Normal 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
29
tsconfig.json
Normal 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
166
types.ts
Normal 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
23
vite.config.ts
Normal 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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user