gestion des chapitres modification et réoganisation

This commit is contained in:
2026-03-05 18:21:42 +01:00
parent d8ffc61b17
commit 907032b3a2
10 changed files with 198 additions and 10 deletions

Binary file not shown.

View File

@@ -6116,3 +6116,9 @@ FAM | META SEQ | SST SEQ | RANGE
0 | 00013985 | 00013984 SST | [=======================================================================] | 3aefa6fd5cf2deb4-f42f94001fcb5351 (0 MiB, fresh) 0 | 00013985 | 00013984 SST | [=======================================================================] | 3aefa6fd5cf2deb4-f42f94001fcb5351 (0 MiB, fresh)
1 | 00013986 | 00013982 SST | O | 3ffdfb3b7d50fcf1-3ffdfb3b7d50fcf1 (0 MiB, fresh) 1 | 00013986 | 00013982 SST | O | 3ffdfb3b7d50fcf1-3ffdfb3b7d50fcf1 (0 MiB, fresh)
2 | 00013987 | 00013983 SST | O | 3ffdfb3b7d50fcf1-3ffdfb3b7d50fcf1 (0 MiB, fresh) 2 | 00013987 | 00013983 SST | O | 3ffdfb3b7d50fcf1-3ffdfb3b7d50fcf1 (0 MiB, fresh)
Time 2026-03-05T17:21:32.7902721Z
Commit 00014935 4 keys in 16ms 322µs 500ns
FAM | META SEQ | SST SEQ | RANGE
0 | 00014933 | 00014932 SST | [=======================================================================] | 3aefa6fd5cf2deb4-f42f94001fcb5351 (0 MiB, fresh)
1 | 00014934 | 00014930 SST | O | 3ffdfb3b7d50fcf1-3ffdfb3b7d50fcf1 (0 MiB, fresh)
2 | 00014935 | 00014931 SST | O | 3ffdfb3b7d50fcf1-3ffdfb3b7d50fcf1 (0 MiB, fresh)

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,49 @@
export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const { id: projectId } = await params;
const body = await request.json();
const { chapters } = body as { chapters: { id: string, orderIndex: number }[] };
if (!Array.isArray(chapters)) {
return NextResponse.json({ error: 'Format invalide' }, { status: 400 });
}
// Verify ownership
const existing = await prisma.project.findFirst({
where: { id: projectId, userId: session.user.id },
});
if (!existing) {
return NextResponse.json({ error: 'Projet non trouvé' }, { status: 404 });
}
try {
// Bulk update chapters' orderIndex within a transaction
await prisma.$transaction(
chapters.map((chapter) =>
prisma.chapter.update({
where: { id: chapter.id, projectId },
data: { orderIndex: chapter.orderIndex },
})
)
);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Erreur lors de la réorganisation des chapitres:', error);
return NextResponse.json({ error: 'Erreur interne' }, { status: 500 });
}
}

View File

@@ -33,7 +33,8 @@ export default function ProjectLayout({ children }: { children: React.ReactNode
updateProject, updateChapter, addChapter, updateProject, updateChapter, addChapter,
createEntity, updateEntity, deleteEntity, createEntity, updateEntity, deleteEntity,
createIdea, updateIdea, deleteIdea, createIdea, updateIdea, deleteIdea,
deleteProject deleteProject,
reorderChapters
} = useProjects(user); } = useProjects(user);
const { chatHistory, isGenerating, sendMessage } = useChat(); const { chatHistory, isGenerating, sendMessage } = useChat();
@@ -79,6 +80,7 @@ export default function ProjectLayout({ children }: { children: React.ReactNode
); );
} }
// Handle early returns for non-existent projects
if (!project) { if (!project) {
return ( return (
<div className="h-screen w-full flex flex-col items-center justify-center bg-slate-900 text-white"> <div className="h-screen w-full flex flex-col items-center justify-center bg-slate-900 text-white">
@@ -129,6 +131,8 @@ export default function ProjectLayout({ children }: { children: React.ReactNode
onViewModeChange={handleViewModeChange} onViewModeChange={handleViewModeChange}
onChapterSelect={(id) => { setCurrentChapterId(id); router.push(`/project/${projectId}`); }} onChapterSelect={(id) => { setCurrentChapterId(id); router.push(`/project/${projectId}`); }}
onUpdateProject={(updates) => updateProject(projectId, updates)} onUpdateProject={(updates) => updateProject(projectId, updates)}
onUpdateChapter={(chapterId, data) => updateChapter(projectId, chapterId, data)}
onReorderChapters={(chapters) => reorderChapters(projectId, chapters)}
onAddChapter={async () => { onAddChapter={async () => {
const id = await addChapter(projectId, {}); const id = await addChapter(projectId, {});
if (id) { if (id) {

View File

@@ -43,8 +43,8 @@ interface AppRouterProps {
onUpdateProfile: (updates: Partial<UserProfile>) => void; onUpdateProfile: (updates: Partial<UserProfile>) => void;
onUpgradePlan: (plan: any) => void; onUpgradePlan: (plan: any) => void;
onSendMessage: (msg: string) => void; onSendMessage: (msg: string) => void;
onIncrementUsage: () => void;
onDeleteProject: (projectId: string) => void; onDeleteProject: (projectId: string) => void;
onReorderChapters: (projectId: string, chapters: { id: string, orderIndex: number }[]) => void;
} }
const AppRouter: React.FC<AppRouterProps> = (props) => { const AppRouter: React.FC<AppRouterProps> = (props) => {
@@ -139,6 +139,7 @@ const AppRouter: React.FC<AppRouterProps> = (props) => {
onViewModeChange={props.onViewModeChange} onViewModeChange={props.onViewModeChange}
onChapterSelect={(id) => { setCurrentChapterId(id); props.onViewModeChange('write'); }} onChapterSelect={(id) => { setCurrentChapterId(id); props.onViewModeChange('write'); }}
onUpdateProject={props.onUpdateProject} onUpdateProject={props.onUpdateProject}
onReorderChapters={(chapters) => props.onReorderChapters(project.id, chapters)}
onAddChapter={async () => { onAddChapter={async () => {
console.log("[AppRouter] onAddChapter triggered"); console.log("[AppRouter] onAddChapter triggered");
const id = await props.onAddChapter(); const id = await props.onAddChapter();

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { BookProject, UserProfile, ViewMode, ChatMessage } from '@/lib/types'; import { BookProject, UserProfile, ViewMode, ChatMessage, Chapter } from '@/lib/types';
import AIPanel from '@/components/AIPanel'; import AIPanel from '@/components/AIPanel';
import { Book, FileText, Globe, GitGraph, Lightbulb, Settings, Menu, ChevronRight, ChevronLeft, Share2, HelpCircle, LogOut, LayoutDashboard, User, Plus, Trash2, Wand2 } from 'lucide-react'; import { Book, FileText, Globe, GitGraph, Lightbulb, Settings, Menu, ChevronRight, ChevronLeft, Share2, HelpCircle, LogOut, LayoutDashboard, User, Plus, Trash2, Wand2 } from 'lucide-react';
import { useLanguage } from '@/providers/LanguageProvider'; import { useLanguage } from '@/providers/LanguageProvider';
@@ -18,6 +18,8 @@ interface EditorShellProps {
onViewModeChange: (mode: ViewMode) => void; onViewModeChange: (mode: ViewMode) => void;
onChapterSelect: (id: string) => void; onChapterSelect: (id: string) => void;
onUpdateProject: (updates: Partial<BookProject>) => void; onUpdateProject: (updates: Partial<BookProject>) => void;
onUpdateChapter?: (chapterId: string, updates: Partial<Chapter>) => void;
onReorderChapters?: (chapters: { id: string, orderIndex: number }[]) => void;
onAddChapter: () => Promise<void>; onAddChapter: () => Promise<void>;
onDeleteChapter: (id: string) => void; onDeleteChapter: (id: string) => void;
onLogout: () => void; onLogout: () => void;
@@ -32,10 +34,28 @@ const EditorShell: React.FC<EditorShellProps> = (props) => {
const { project, user, viewMode, currentChapterId, children } = props; const { project, user, viewMode, currentChapterId, children } = props;
const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isAiPanelOpen, setIsAiPanelOpen] = useState(true); const [isAiPanelOpen, setIsAiPanelOpen] = useState(true);
const [draggedChapterIdx, setDraggedChapterIdx] = useState<number | null>(null);
const [dragOverChapterIdx, setDragOverChapterIdx] = useState<number | null>(null);
const { t } = useLanguage(); const { t } = useLanguage();
const currentChapter = project.chapters.find(c => c.id === currentChapterId); const currentChapter = project.chapters.find(c => c.id === currentChapterId);
// Local state for debounced title input
const [localTitle, setLocalTitle] = useState(currentChapter?.title || "");
useEffect(() => {
// Only update local title from props if it differs from what's currently in state,
// to prevent overwriting while the user is typing, but still catch external updates (e.g. from DB load)
if (currentChapter?.title && currentChapter.title !== localTitle) {
setLocalTitle(currentChapter.title);
} else if (!currentChapter) {
setLocalTitle("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentChapter?.title, currentChapterId]);
const titleDebounceRef = useRef<NodeJS.Timeout | null>(null);
// Auto-close sidebars on mobile when navigating // Auto-close sidebars on mobile when navigating
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined' && window.innerWidth < 1024) { if (typeof window !== 'undefined' && window.innerWidth < 1024) {
@@ -81,14 +101,63 @@ const EditorShell: React.FC<EditorShellProps> = (props) => {
{t('nav.chapters')} <button onClick={props.onAddChapter} className="hover:text-blue-400"><Plus size={14} /></button> {t('nav.chapters')} <button onClick={props.onAddChapter} className="hover:text-blue-400"><Plus size={14} /></button>
</div> </div>
{project.chapters.map((chap, idx) => ( {project.chapters.map((chap, idx) => (
<div key={chap.id} className="group relative"> <div
key={chap.id}
className={`group relative transition-all duration-200 border-y-2 ${draggedChapterIdx === idx ? 'opacity-20 scale-95 border-transparent' :
dragOverChapterIdx === idx ? (
draggedChapterIdx !== null && draggedChapterIdx > idx
? 'border-t-blue-500 border-b-transparent bg-blue-900/20'
: 'border-b-blue-500 border-t-transparent bg-blue-900/20'
) : 'border-transparent'
}`}
draggable
onDragStart={(e) => {
setDraggedChapterIdx(idx);
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDragEnter={() => {
if (draggedChapterIdx !== null && draggedChapterIdx !== idx) {
setDragOverChapterIdx(idx);
}
}}
onDragLeave={() => {
// optional edge smoothing here
}}
onDragEnd={() => {
setDraggedChapterIdx(null);
setDragOverChapterIdx(null);
}}
onDrop={(e) => {
e.preventDefault();
if (draggedChapterIdx === null || draggedChapterIdx === idx) return;
const newChapters = [...project.chapters];
const [removed] = newChapters.splice(draggedChapterIdx, 1);
newChapters.splice(idx, 0, removed);
setDraggedChapterIdx(null);
setDragOverChapterIdx(null);
// Re-index chapters to match new visual order
const updatedChapters = newChapters.map((c, i) => ({ id: c.id, orderIndex: i }));
if (props.onReorderChapters) {
props.onReorderChapters(updatedChapters);
} else {
props.onUpdateProject({ chapters: newChapters.map((c, i) => ({ ...c, orderIndex: i })) });
}
}}
>
<button <button
onClick={() => props.onChapterSelect(chap.id)} 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'}`} className={`w-full text-left px-4 py-2 text-sm truncate transition-colors cursor-grab active:cursor-grabbing ${currentChapterId === chap.id && viewMode === 'write' ? 'bg-blue-900 text-white border-r-2 border-blue-400' : 'hover:bg-slate-800'} ${draggedChapterIdx !== null ? 'pointer-events-none' : ''}`}
> >
{idx + 1}. {chap.title} {idx + 1}. {chap.title}
</button> </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> <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 bg-slate-900/50 backdrop-blur rounded p-1"><Trash2 size={12} /></button>
</div> </div>
))} ))}
@@ -123,8 +192,28 @@ const EditorShell: React.FC<EditorShellProps> = (props) => {
{viewMode === 'write' ? ( {viewMode === 'write' ? (
<input <input
type="text" type="text"
value={currentChapter?.title || ""} value={localTitle}
onChange={(e) => props.onUpdateProject({ chapters: project.chapters.map(c => c.id === currentChapterId ? { ...c, title: e.target.value } : c) })} onChange={(e) => {
const newTitle = e.target.value;
setLocalTitle(newTitle);
if (currentChapterId.startsWith('placeholder-')) return;
// Local optimistic update via project for UI consistency
props.onUpdateProject({
chapters: project.chapters.map(c =>
c.id === currentChapterId ? { ...c, title: newTitle } : c
)
});
// Debounce the actual backend save
if (titleDebounceRef.current) clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = setTimeout(() => {
if (props.onUpdateChapter) {
props.onUpdateChapter(currentChapterId, { title: newTitle });
}
}, 1000);
}}
className="font-serif font-bold text-lg bg-transparent border-b border-transparent focus:border-blue-500 focus:outline-none" className="font-serif font-bold text-lg bg-transparent border-b border-transparent focus:border-blue-500 focus:outline-none"
/> />
) : ( ) : (

View File

@@ -213,6 +213,8 @@ export const useProjects = (user: UserProfile | null) => {
}; };
const updateChapter = async (projectId: string, chapterId: string, data: Partial<Chapter>) => { const updateChapter = async (projectId: string, chapterId: string, data: Partial<Chapter>) => {
if (chapterId.startsWith('placeholder-')) return;
setProjects(prev => prev.map(p => { setProjects(prev => prev.map(p => {
if (p.id !== projectId) return p; if (p.id !== projectId) return p;
return { return {
@@ -228,6 +230,34 @@ export const useProjects = (user: UserProfile | null) => {
} }
}; };
const reorderChapters = async (projectId: string, chaptersToReorder: { id: string, orderIndex: number }[]) => {
// Optimistic UI update
setProjects(prev => prev.map(p => {
if (p.id !== projectId) return p;
// Map the new order indexes to the existing chapters
const orderMap = new Map(chaptersToReorder.map(c => [c.id, c.orderIndex]));
const updatedChapters = p.chapters.map(c => {
if (orderMap.has(c.id)) {
return { ...c, orderIndex: orderMap.get(c.id)! };
}
return c;
});
// Sort them immediately so the UI doesn't jump on next render
updatedChapters.sort((a, b) => (a.orderIndex || 0) - (b.orderIndex || 0));
return { ...p, chapters: updatedChapters };
}));
try {
await api.chapters.reorder(projectId, chaptersToReorder);
} catch (err) {
console.error("Failed to reorder chapters", err);
// In a real app we might revert the state here on failure
}
};
const createEntity = async (projectId: string, type: EntityType, initialData?: Partial<Entity>) => { const createEntity = async (projectId: string, type: EntityType, initialData?: Partial<Entity>) => {
try { try {
const newEntity = await api.entities.create({ const newEntity = await api.entities.create({
@@ -363,6 +393,7 @@ export const useProjects = (user: UserProfile | null) => {
updateProject, updateProject,
addChapter, addChapter,
updateChapter, updateChapter,
reorderChapters,
createEntity, createEntity,
updateEntity, updateEntity,
deleteEntity, deleteEntity,

View File

@@ -113,6 +113,13 @@ const api = {
method: 'DELETE', method: 'DELETE',
}); });
}, },
async reorder(projectId: string, chapters: { id: string, orderIndex: number }[]) {
return api.request(`/projects/${projectId}/chapters/reorder`, {
method: 'PUT',
body: JSON.stringify({ chapters }),
});
},
}, },
// --- ENTITIES --- // --- ENTITIES ---

View File

@@ -53,6 +53,7 @@ export interface Chapter {
title: string; title: string;
content: string; content: string;
summary?: string; summary?: string;
orderIndex?: number;
} }
export type PlotNodeType = 'story' | 'dialogue' | 'action'; export type PlotNodeType = 'story' | 'dialogue' | 'action';