gestion des chapitres modification et réoganisation
This commit is contained in:
BIN
.next/dev/cache/turbopack/23c46498/CURRENT
vendored
BIN
.next/dev/cache/turbopack/23c46498/CURRENT
vendored
Binary file not shown.
6
.next/dev/cache/turbopack/23c46498/LOG
vendored
6
.next/dev/cache/turbopack/23c46498/LOG
vendored
@@ -6116,3 +6116,9 @@ FAM | META SEQ | SST SEQ | RANGE
|
||||
0 | 00013985 | 00013984 SST | [=======================================================================] | 3aefa6fd5cf2deb4-f42f94001fcb5351 (0 MiB, fresh)
|
||||
1 | 00013986 | 00013982 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
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
49
src/app/api/projects/[id]/chapters/reorder/route.ts
Normal file
49
src/app/api/projects/[id]/chapters/reorder/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,8 @@ export default function ProjectLayout({ children }: { children: React.ReactNode
|
||||
updateProject, updateChapter, addChapter,
|
||||
createEntity, updateEntity, deleteEntity,
|
||||
createIdea, updateIdea, deleteIdea,
|
||||
deleteProject
|
||||
deleteProject,
|
||||
reorderChapters
|
||||
} = useProjects(user);
|
||||
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) {
|
||||
return (
|
||||
<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}
|
||||
onChapterSelect={(id) => { setCurrentChapterId(id); router.push(`/project/${projectId}`); }}
|
||||
onUpdateProject={(updates) => updateProject(projectId, updates)}
|
||||
onUpdateChapter={(chapterId, data) => updateChapter(projectId, chapterId, data)}
|
||||
onReorderChapters={(chapters) => reorderChapters(projectId, chapters)}
|
||||
onAddChapter={async () => {
|
||||
const id = await addChapter(projectId, {});
|
||||
if (id) {
|
||||
|
||||
@@ -43,8 +43,8 @@ interface AppRouterProps {
|
||||
onUpdateProfile: (updates: Partial<UserProfile>) => void;
|
||||
onUpgradePlan: (plan: any) => void;
|
||||
onSendMessage: (msg: string) => void;
|
||||
onIncrementUsage: () => void;
|
||||
onDeleteProject: (projectId: string) => void;
|
||||
onReorderChapters: (projectId: string, chapters: { id: string, orderIndex: number }[]) => void;
|
||||
}
|
||||
|
||||
const AppRouter: React.FC<AppRouterProps> = (props) => {
|
||||
@@ -139,6 +139,7 @@ const AppRouter: React.FC<AppRouterProps> = (props) => {
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onChapterSelect={(id) => { setCurrentChapterId(id); props.onViewModeChange('write'); }}
|
||||
onUpdateProject={props.onUpdateProject}
|
||||
onReorderChapters={(chapters) => props.onReorderChapters(project.id, chapters)}
|
||||
onAddChapter={async () => {
|
||||
console.log("[AppRouter] onAddChapter triggered");
|
||||
const id = await props.onAddChapter();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BookProject, UserProfile, ViewMode, ChatMessage } from '@/lib/types';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { BookProject, UserProfile, ViewMode, ChatMessage, Chapter } from '@/lib/types';
|
||||
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 { useLanguage } from '@/providers/LanguageProvider';
|
||||
@@ -18,6 +18,8 @@ interface EditorShellProps {
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
onChapterSelect: (id: string) => void;
|
||||
onUpdateProject: (updates: Partial<BookProject>) => void;
|
||||
onUpdateChapter?: (chapterId: string, updates: Partial<Chapter>) => void;
|
||||
onReorderChapters?: (chapters: { id: string, orderIndex: number }[]) => void;
|
||||
onAddChapter: () => Promise<void>;
|
||||
onDeleteChapter: (id: string) => void;
|
||||
onLogout: () => void;
|
||||
@@ -32,10 +34,28 @@ const EditorShell: React.FC<EditorShellProps> = (props) => {
|
||||
const { project, user, viewMode, currentChapterId, children } = props;
|
||||
const [isSidebarOpen, setIsSidebarOpen] = 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 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
|
||||
useEffect(() => {
|
||||
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>
|
||||
</div>
|
||||
{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
|
||||
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}
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -123,8 +192,28 @@ const EditorShell: React.FC<EditorShellProps> = (props) => {
|
||||
{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) })}
|
||||
value={localTitle}
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -213,6 +213,8 @@ export const useProjects = (user: UserProfile | null) => {
|
||||
};
|
||||
|
||||
const updateChapter = async (projectId: string, chapterId: string, data: Partial<Chapter>) => {
|
||||
if (chapterId.startsWith('placeholder-')) return;
|
||||
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== projectId) return p;
|
||||
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>) => {
|
||||
try {
|
||||
const newEntity = await api.entities.create({
|
||||
@@ -363,6 +393,7 @@ export const useProjects = (user: UserProfile | null) => {
|
||||
updateProject,
|
||||
addChapter,
|
||||
updateChapter,
|
||||
reorderChapters,
|
||||
createEntity,
|
||||
updateEntity,
|
||||
deleteEntity,
|
||||
|
||||
@@ -113,6 +113,13 @@ const api = {
|
||||
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 ---
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface Chapter {
|
||||
title: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
orderIndex?: number;
|
||||
}
|
||||
|
||||
export type PlotNodeType = 'story' | 'dialogue' | 'action';
|
||||
|
||||
Reference in New Issue
Block a user