sauvegarde boite a idée

This commit is contained in:
2026-03-05 12:47:36 +01:00
parent 585e608d8d
commit d004281e05
9 changed files with 239 additions and 135 deletions

View File

@@ -4,12 +4,15 @@ import IdeaBoard from '@/components/IdeaBoard';
import { useProjectContext } from '@/providers/ProjectProvider';
export default function IdeasPage() {
const { project, updateProject } = useProjectContext();
const { project, projectId, createIdea, updateIdea, deleteIdea } = useProjectContext();
return (
<IdeaBoard
projectId={projectId}
ideas={project.ideas || []}
onUpdate={(ideas) => updateProject({ ideas })}
onCreate={(data) => createIdea(projectId, data)}
onUpdateIdea={(id, data) => updateIdea(projectId, id, data)}
onDelete={(id) => deleteIdea(projectId, id)}
/>
);
}

View File

@@ -31,7 +31,9 @@ export default function ProjectLayout({ children }: { children: React.ReactNode
const {
projects, setCurrentProjectId,
updateProject, updateChapter, addChapter,
createEntity, updateEntity, deleteEntity, deleteProject
createEntity, updateEntity, deleteEntity,
createIdea, updateIdea, deleteIdea,
deleteProject
} = useProjects(user);
const { chatHistory, isGenerating, sendMessage } = useChat();
@@ -111,6 +113,9 @@ export default function ProjectLayout({ children }: { children: React.ReactNode
createEntity: (type, data) => createEntity(projectId, type, data),
updateEntity: (entityId, data) => updateEntity(projectId, entityId, data),
deleteEntity: (entityId) => deleteEntity(projectId, entityId),
createIdea: (projectId, data) => createIdea(projectId, data),
updateIdea: (projectId, ideaId, data) => updateIdea(projectId, ideaId, data),
deleteIdea: (projectId, ideaId) => deleteIdea(projectId, ideaId),
deleteProject: () => deleteProject(projectId),
incrementUsage,
}}>

View File

@@ -1,14 +1,17 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Idea, IdeaStatus, IdeaCategory } from '@/lib/types';
import { Plus, X, GripVertical, CheckCircle, Circle, Clock, Lightbulb, Search, Trash2, Edit3, Save } from 'lucide-react';
import { Plus, X, GripVertical, CheckCircle, Circle, Clock, Lightbulb, Search, Trash2, Edit3, Save, Check, CheckCheck, Loader2 } from 'lucide-react';
import { useLanguage } from '@/providers/LanguageProvider';
import { TranslationKey } from '@/lib/i18n/translations';
interface IdeaBoardProps {
projectId: string;
ideas: Idea[];
onUpdate: (ideas: Idea[]) => void;
onCreate: (data: Partial<Idea>) => Promise<string>;
onUpdateIdea: (id: string, data: Partial<Idea>) => Promise<void>;
onDelete: (id: string) => Promise<void>;
}
const CATEGORIES: Record<IdeaCategory, { labelKey: string, color: string, icon: any }> = {
@@ -26,10 +29,26 @@ const STATUS_LABELS: Record<IdeaStatus, string> = {
const MAX_DESCRIPTION_LENGTH = 500;
const IdeaBoard: React.FC<IdeaBoardProps> = ({ ideas, onUpdate }) => {
const IdeaBoard: React.FC<IdeaBoardProps> = ({ projectId, ideas, onCreate, onUpdateIdea, onDelete }) => {
const { t } = useLanguage();
const [newIdeaTitle, setNewIdeaTitle] = useState('');
const [newIdeaCategory, setNewIdeaCategory] = useState<IdeaCategory>('plot');
const [localIdeas, setLocalIdeas] = useState<Idea[]>(ideas);
// Auto-Save State
const [saveStatus, setSaveStatus] = useState<'saved_local' | 'saved_db' | 'saving' | 'unsaved'>('saved_db');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Sync from parent
useEffect(() => {
setLocalIdeas(ideas);
}, [ideas]);
const saveLocally = (updated: Idea[]) => {
setLocalIdeas(updated);
localStorage.setItem(`ideas_${projectId}`, JSON.stringify(updated));
setSaveStatus('saved_local');
};
// Drag and Drop State
const [draggedIdeaId, setDraggedIdeaId] = useState<string | null>(null);
@@ -39,12 +58,12 @@ const IdeaBoard: React.FC<IdeaBoardProps> = ({ ideas, onUpdate }) => {
// --- ACTIONS ---
const handleAddIdea = (e: React.FormEvent) => {
const handleAddIdea = async (e: React.FormEvent) => {
e.preventDefault();
if (!newIdeaTitle.trim()) return;
const newIdea: Idea = {
id: `idea-${Date.now()}`,
id: `temp-${Date.now()}`,
title: newIdeaTitle,
description: '',
category: newIdeaCategory,
@@ -52,36 +71,86 @@ const IdeaBoard: React.FC<IdeaBoardProps> = ({ ideas, onUpdate }) => {
createdAt: Date.now()
};
onUpdate([...ideas, newIdea]);
saveLocally([...localIdeas, newIdea]);
setNewIdeaTitle('');
};
setSaveStatus('saving');
const handleDelete = (id: string) => {
if (confirm(t('ideaboard.delete') + " ?")) {
onUpdate(ideas.filter(i => i.id !== id));
if (editingItem?.id === id) setEditingItem(null);
try {
await onCreate({
title: newIdea.title,
category: newIdea.category,
status: newIdea.status,
});
setSaveStatus('saved_db');
} catch (err) {
setSaveStatus('saved_local');
}
};
const handleSaveEdit = () => {
const handleDelete = async (id: string) => {
if (confirm(t('ideaboard.delete') + " ?")) {
saveLocally(localIdeas.filter(i => i.id !== id));
if (editingItem?.id === id) setEditingItem(null);
setSaveStatus('saving');
try {
if (!id.startsWith('temp-')) {
await onDelete(id);
}
setSaveStatus('saved_db');
} catch (err) {
setSaveStatus('saved_local');
}
}
};
const handleSaveEdit = async () => {
if (!editingItem || !editingItem.title?.trim()) return;
if (editingItem.id) {
let isNew = !editingItem.id || editingItem.id.startsWith('temp-');
if (!isNew) {
// Update existing
onUpdate(ideas.map(i => i.id === editingItem.id ? { ...i, ...editingItem } as Idea : i));
saveLocally(localIdeas.map(i => i.id === editingItem.id ? { ...i, ...editingItem } as Idea : i));
setEditingItem(null);
setSaveStatus('saving');
try {
await onUpdateIdea(editingItem.id!, {
title: editingItem.title,
description: editingItem.description,
category: editingItem.category,
status: editingItem.status
});
setSaveStatus('saved_db');
} catch (err) {
setSaveStatus('saved_local');
}
} else {
// Create new from modal
const newIdea: Idea = {
id: `idea-${Date.now()}`,
id: `temp-${Date.now()}`,
title: editingItem.title || '',
description: editingItem.description || '',
category: editingItem.category || 'plot',
status: editingItem.status || 'todo',
createdAt: Date.now()
};
onUpdate([...ideas, newIdea]);
saveLocally([...localIdeas, newIdea]);
setEditingItem(null);
setSaveStatus('saving');
try {
await onCreate({
title: newIdea.title,
description: newIdea.description,
category: newIdea.category,
status: newIdea.status,
});
setSaveStatus('saved_db');
} catch (err) {
setSaveStatus('saved_local');
}
}
setEditingItem(null);
};
const openQuickAdd = (status: IdeaStatus) => {
@@ -104,14 +173,24 @@ const IdeaBoard: React.FC<IdeaBoardProps> = ({ ideas, onUpdate }) => {
e.dataTransfer.effectAllowed = 'move';
};
const handleDrop = (e: React.DragEvent, status: IdeaStatus) => {
const handleDrop = async (e: React.DragEvent, status: IdeaStatus) => {
e.preventDefault();
if (draggedIdeaId) {
const updatedIdeas = ideas.map(idea =>
saveLocally(localIdeas.map(idea =>
idea.id === draggedIdeaId ? { ...idea, status } : idea
);
onUpdate(updatedIdeas);
));
const idToUpdate = draggedIdeaId;
setDraggedIdeaId(null);
if (!idToUpdate.startsWith('temp-')) {
setSaveStatus('saving');
try {
await onUpdateIdea(idToUpdate, { status });
setSaveStatus('saved_db');
} catch (err) {
setSaveStatus('saved_local');
}
}
}
};
@@ -123,7 +202,7 @@ const IdeaBoard: React.FC<IdeaBoardProps> = ({ ideas, onUpdate }) => {
// --- RENDERERS ---
const Column = ({ title, status, icon: Icon }: { title: string, status: IdeaStatus, icon: any }) => {
const columnIdeas = ideas.filter(i => i.status === status);
const columnIdeas = localIdeas.filter(i => i.status === status);
return (
<div
@@ -227,38 +306,56 @@ const IdeaBoard: React.FC<IdeaBoardProps> = ({ ideas, onUpdate }) => {
{/* Header & Add Form (Top Bar) */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 bg-theme-panel p-4 rounded-xl border border-theme-border shadow-sm shrink-0 transition-colors duration-300">
<div>
<h2 className="text-2xl font-bold text-theme-text flex items-center gap-2">
<Lightbulb className="text-yellow-500" /> {t('ideaboard.title')}
</h2>
<p className="text-theme-muted text-sm">{t('ideaboard.desc')}</p>
<div className="flex justify-between items-center w-full md:w-auto">
<div>
<h2 className="text-xl font-bold text-theme-text flex items-center gap-2">
<Lightbulb className="text-yellow-500" /> {t('ideaboard.title')}
</h2>
<p className="text-theme-muted text-xs">{t('ideaboard.desc')}</p>
</div>
{/* Status Indicator for Mobile */}
<div className="md:hidden flex items-center gap-2 text-xs font-medium text-slate-400">
{saveStatus === 'saving' && <><Loader2 size={12} className="animate-spin text-blue-500" /></>}
{saveStatus === 'saved_local' && <><Check size={14} className="text-green-500" /></>}
{saveStatus === 'saved_db' && <><CheckCheck size={14} className="text-emerald-600" /></>}
</div>
</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-theme-bg border border-theme-border text-theme-text text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2.5 outline-none transition-colors duration-300"
>
{Object.entries(CATEGORIES).map(([key, val]) => (
<option key={key} value={key}>{t(val.labelKey as TranslationKey)}</option>
))}
</select>
<input
type="text"
value={newIdeaTitle}
onChange={(e) => setNewIdeaTitle(e.target.value)}
placeholder={t('ideaboard.add_idea')}
className="flex-1 bg-theme-bg border border-theme-border text-theme-text text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2.5 outline-none font-medium transition-colors duration-300"
/>
<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 className="flex items-center gap-4 w-full md:w-auto flex-1 justify-end">
{/* Status Indicator for Desktop */}
<div className="hidden md:flex items-center gap-2 text-xs font-medium text-slate-400">
{saveStatus === 'saving' && <><Loader2 size={12} className="animate-spin text-blue-500" /> <span className="text-blue-500">Sauvegarde...</span></>}
{saveStatus === 'saved_local' && <><Check size={14} className="text-green-500" /> <span className="text-green-500">Brouillon local</span></>}
{saveStatus === 'saved_db' && <><CheckCheck size={14} className="text-emerald-600" /> <span className="text-emerald-600">Sauvegardé</span></>}
{saveStatus === 'unsaved' && <span className="text-amber-500">Non sauvegardé...</span>}
</div>
<form onSubmit={handleAddIdea} className="flex-1 w-full md:w-auto max-w-lg flex gap-2">
<select
value={newIdeaCategory}
onChange={(e) => setNewIdeaCategory(e.target.value as IdeaCategory)}
className="bg-theme-bg border border-theme-border text-theme-text text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2.5 outline-none transition-colors duration-300"
>
{Object.entries(CATEGORIES).map(([key, val]) => (
<option key={key} value={key}>{t(val.labelKey as TranslationKey)}</option>
))}
</select>
<input
type="text"
value={newIdeaTitle}
onChange={(e) => setNewIdeaTitle(e.target.value)}
placeholder={t('ideaboard.add_idea')}
className="flex-1 bg-theme-bg border border-theme-border text-theme-text text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2.5 outline-none font-medium transition-colors duration-300"
/>
<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>
</div>
{/* Kanban Board */}

View File

@@ -7,6 +7,7 @@ import {
Entity,
EntityType,
UserProfile,
Idea
} from '@/lib/types';
import api from '@/lib/api';
import {
@@ -293,6 +294,67 @@ export const useProjects = (user: UserProfile | null) => {
}
};
const createIdea = async (projectId: string, data: Partial<Idea>) => {
try {
const newIdea = await api.ideas.create({
projectId,
title: data.title || 'Nouveau',
description: data.description || '',
status: data.status || 'todo',
category: data.category || 'plot',
});
setProjects(prev => prev.map(p => {
if (p.id !== projectId) return p;
return {
...p,
ideas: [...(p.ideas || []), {
...newIdea,
createdAt: new Date(newIdea.createdAt).getTime()
}]
};
}));
return newIdea.id;
} catch (err) {
console.error("Failed to create idea", err);
throw err;
}
};
const updateIdea = async (projectId: string, ideaId: string, data: Partial<Idea>) => {
setProjects(prev => prev.map(p => {
if (p.id !== projectId) return p;
return {
...p,
ideas: (p.ideas || []).map(i => i.id === ideaId ? { ...i, ...data } : i)
};
}));
try {
await api.ideas.update(ideaId, data);
} catch (err) {
console.error("Failed to update idea", err);
throw err;
}
};
const deleteIdea = async (projectId: string, ideaId: string) => {
setProjects(prev => prev.map(p => {
if (p.id !== projectId) return p;
return {
...p,
ideas: (p.ideas || []).filter(i => i.id !== ideaId)
};
}));
try {
await api.ideas.delete(ideaId);
} catch (err) {
console.error("Failed to delete idea", err);
throw err;
}
};
return {
projects,
currentProjectId,
@@ -304,6 +366,9 @@ export const useProjects = (user: UserProfile | null) => {
createEntity,
updateEntity,
deleteEntity,
createIdea,
updateIdea,
deleteIdea,
deleteProject: async (projectId: string) => {
try {
// Cascade delete is handled by Prisma, just delete the project

View File

@@ -1,7 +1,7 @@
'use client';
import React, { createContext, useContext } from 'react';
import { BookProject, UserProfile, Entity, EntityType } from '@/lib/types';
import { BookProject, UserProfile, Entity, EntityType, Idea } from '@/lib/types';
interface ProjectContextType {
project: BookProject;
@@ -14,6 +14,9 @@ interface ProjectContextType {
createEntity: (type: EntityType, initialData?: Partial<Entity>) => Promise<string>;
updateEntity: (entityId: string, data: Partial<Entity>) => Promise<void>;
deleteEntity: (entityId: string) => Promise<void>;
createIdea: (projectId: string, data: Partial<Idea>) => Promise<string>;
updateIdea: (projectId: string, ideaId: string, data: Partial<Idea>) => Promise<void>;
deleteIdea: (projectId: string, ideaId: string) => Promise<void>;
deleteProject: () => Promise<void>;
incrementUsage: () => void;
}