diff --git a/prisma/data/dev.db b/prisma/data/dev.db new file mode 100644 index 0000000..e69de29 diff --git a/prisma/migrations/20260105000000_add_folders_for_notes/migration.sql b/prisma/migrations/20260105000000_add_folders_for_notes/migration.sql new file mode 100644 index 0000000..482c78f --- /dev/null +++ b/prisma/migrations/20260105000000_add_folders_for_notes/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "folders" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tagId" TEXT, + "parentId" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "folders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "folders_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tags" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "folders_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "folders" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- AlterTable Note - Add folderId column +ALTER TABLE "Note" ADD COLUMN "folderId" TEXT; + +-- CreateIndex +CREATE INDEX "folders_userId_idx" ON "folders"("userId"); +CREATE INDEX "folders_parentId_idx" ON "folders"("parentId"); + + diff --git a/prisma/prisma/dev.db b/prisma/prisma/dev.db new file mode 100644 index 0000000..09919c0 Binary files /dev/null and b/prisma/prisma/dev.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b3756fa..fed280b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { dailyCheckboxes DailyCheckbox[] tasks Task[] @relation("TaskOwner") tags Tag[] @relation("TagOwner") + folders Folder[] @relation("FolderOwner") @@map("users") } @@ -72,6 +73,7 @@ model Tag { taskTags TaskTag[] primaryTasks Task[] @relation("PrimaryTag") noteTags NoteTag[] + folders Folder[] @@unique([name, ownerId]) // Un nom de tag unique par utilisateur @@map("tags") @@ -142,13 +144,33 @@ model Note { content String // Markdown content userId String taskId String? // Tâche associée à la note + folderId String? // Dossier contenant la note createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) task Task? @relation(fields: [taskId], references: [id]) + folder Folder? @relation(fields: [folderId], references: [id]) noteTags NoteTag[] } +model Folder { + id String @id @default(cuid()) + name String + userId String + tagId String? // Tag associé au dossier + parentId String? // Dossier parent pour sous-dossiers + order Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation("FolderOwner", fields: [userId], references: [id], onDelete: Cascade) + tag Tag? @relation(fields: [tagId], references: [id]) + parent Folder? @relation("FolderHierarchy", fields: [parentId], references: [id], onDelete: Cascade) + children Folder[] @relation("FolderHierarchy") + notes Note[] + + @@map("folders") +} + model NoteTag { noteId String tagId String diff --git a/src/actions/folders.ts b/src/actions/folders.ts new file mode 100644 index 0000000..d9fbba7 --- /dev/null +++ b/src/actions/folders.ts @@ -0,0 +1,132 @@ +'use server'; + +import { + foldersService, + CreateFolderData, + UpdateFolderData, +} from '@/services/folders'; +import { revalidatePath } from 'next/cache'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; + +/** + * Récupère tous les dossiers de l'utilisateur + */ +export async function getFolders() { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + const folders = await foldersService.getFolders(session.user.id); + return { success: true, data: folders }; + } catch (error) { + console.error('Error fetching folders:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue', + }; + } +} + +/** + * Crée un nouveau dossier + */ +export async function createFolder(data: Omit) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + const folder = await foldersService.createFolder({ + ...data, + userId: session.user.id, + }); + + revalidatePath('/notes'); + return { success: true, data: folder }; + } catch (error) { + console.error('Error creating folder:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue', + }; + } +} + +/** + * Met à jour un dossier + */ +export async function updateFolder(folderId: string, data: UpdateFolderData) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + const folder = await foldersService.updateFolder( + folderId, + session.user.id, + data + ); + + revalidatePath('/notes'); + return { success: true, data: folder }; + } catch (error) { + console.error('Error updating folder:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue', + }; + } +} + +/** + * Supprime un dossier + */ +export async function deleteFolder(folderId: string) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + await foldersService.deleteFolder(folderId, session.user.id); + + revalidatePath('/notes'); + return { success: true }; + } catch (error) { + console.error('Error deleting folder:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue', + }; + } +} + +/** + * Réorganise l'ordre des dossiers + */ +export async function reorderFolders( + folderOrders: Array<{ id: string; order: number }> +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + await foldersService.reorderFolders(session.user.id, folderOrders); + + revalidatePath('/notes'); + return { success: true }; + } catch (error) { + console.error('Error reordering folders:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue', + }; + } +} diff --git a/src/app/api/notes/[id]/route.ts b/src/app/api/notes/[id]/route.ts index 99957aa..cf364d5 100644 --- a/src/app/api/notes/[id]/route.ts +++ b/src/app/api/notes/[id]/route.ts @@ -51,10 +51,17 @@ export async function PUT( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const body = await request.json(); - const { title, content, taskId, tags } = body; - const resolvedParams = await params; + const body = await request.json(); + const { title, content, taskId, folderId, tags } = body; + + console.log( + '[API PUT /notes/:id] Updating note:', + resolvedParams.id, + 'with body:', + body + ); + const note = await notesService.updateNote( resolvedParams.id, session.user.id, @@ -62,10 +69,13 @@ export async function PUT( title, content, taskId, + folderId, tags, } ); + console.log('[API PUT /notes/:id] Note updated:', note); + return NextResponse.json({ note }); } catch (error) { console.error('Error updating note:', error); diff --git a/src/app/notes/NotesPageClient.tsx b/src/app/notes/NotesPageClient.tsx index 23ecbcd..89e63f3 100644 --- a/src/app/notes/NotesPageClient.tsx +++ b/src/app/notes/NotesPageClient.tsx @@ -2,24 +2,36 @@ import { useState, useEffect, useCallback } from 'react'; import { Note } from '@/services/notes'; +import { Folder } from '@/services/folders'; import { Task } from '@/lib/types'; import { notesClient } from '@/clients/notes'; import { NotesList } from '@/components/notes/NotesList'; import { MarkdownEditor } from '@/components/notes/MarkdownEditor'; +import { FoldersSidebar } from '@/components/notes/FoldersSidebar'; import { Header } from '@/components/ui/Header'; import { Card } from '@/components/ui'; import { TasksProvider, useTasksContext } from '@/contexts/TasksContext'; import { Tag } from '@/lib/types'; import { FileText, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'; +import { getFolders } from '@/actions/folders'; interface NotesPageClientProps { initialNotes: Note[]; initialTags: (Tag & { usage: number })[]; + initialFolders: Folder[]; } -function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { +function NotesPageContent({ + initialNotes, + initialFolders, +}: { + initialNotes: Note[]; + initialFolders: Folder[]; +}) { const [notes, setNotes] = useState(initialNotes); + const [folders, setFolders] = useState(initialFolders); const [selectedNote, setSelectedNote] = useState(null); + const [selectedFolderId, setSelectedFolderId] = useState(null); const [isNewNote, setIsNewNote] = useState(false); const { tags: availableTags } = useTasksContext(); const [error, setError] = useState(null); @@ -58,6 +70,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { const newNote = await notesClient.createNote({ title: 'Nouvelle note', content: '# Nouvelle note\n\nCommencez à écrire...', + folderId: selectedFolderId || undefined, }); setNotes((prev) => [newNote, ...prev]); @@ -68,7 +81,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { setError('Erreur lors de la création de la note'); console.error('Error creating note:', err); } - }, []); + }, [selectedFolderId]); const handleDeleteNote = useCallback( async (noteId: string) => { @@ -123,6 +136,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { content: selectedNote.content, tags: selectedNote.tags, taskId: selectedNote.taskId, + folderId: selectedNote.folderId, }); // Mettre à jour la liste des notes mais pas selectedNote pour éviter la perte de focus @@ -162,6 +176,44 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { [selectedNote] ); + const handleFolderChange = useCallback( + async (folderId: string | null) => { + if (!selectedNote) return; + + console.log( + '[handleFolderChange] Changing folder for note:', + selectedNote.id, + 'to folder:', + folderId + ); + + try { + // Sauvegarder immédiatement + const updateData: { folderId: string | null } = { + folderId: folderId, + }; + const updatedNote = await notesClient.updateNote( + selectedNote.id, + updateData + ); + + console.log('[handleFolderChange] Note updated:', updatedNote); + + // Mettre à jour la liste des notes + setNotes((prev) => + prev.map((note) => (note.id === selectedNote.id ? updatedNote : note)) + ); + + // Mettre à jour la note sélectionnée + setSelectedNote(updatedNote); + } catch (err) { + console.error('[handleFolderChange] Error:', err); + setError('Erreur lors du changement de dossier'); + } + }, + [selectedNote] + ); + // Auto-save quand les tags changent useEffect(() => { if (hasUnsavedChanges && selectedNote) { @@ -173,6 +225,86 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { } }, [selectedNote, hasUnsavedChanges, handleSave]); + // Filtrer les notes par dossier + const filteredNotes = + selectedFolderId === '__uncategorized__' + ? notes.filter((note) => !note.folderId) // Notes sans dossier + : selectedFolderId + ? notes.filter((note) => note.folderId === selectedFolderId) + : notes; // Toutes les notes + + // Gérer le changement de dossier + const handleSelectFolder = useCallback( + (folderId: string | null) => { + setSelectedFolderId(folderId); + + // Sélectionner automatiquement la première note du dossier + const folderNotes = + folderId === '__uncategorized__' + ? notes.filter((note) => !note.folderId) + : folderId + ? notes.filter((note) => note.folderId === folderId) + : notes; + + if (folderNotes.length > 0) { + setSelectedNote(folderNotes[0]); + } else { + setSelectedNote(null); + } + }, + [notes] + ); + + // Recharger les dossiers + const handleFoldersChange = useCallback(async () => { + try { + const result = await getFolders(); + if (result.success && result.data) { + setFolders(result.data); + } + } catch (err) { + console.error('Error fetching folders:', err); + } + }, []); + + // Gérer le drop d'une note sur un dossier + const handleNoteDrop = useCallback( + async (noteId: string, folderId: string | null) => { + console.log( + '[handleNoteDrop] Dropping note:', + noteId, + 'to folder:', + folderId + ); + + try { + const updateData: { folderId: string | null } = { + folderId: folderId, + }; + const updatedNote = await notesClient.updateNote(noteId, updateData); + + console.log('[handleNoteDrop] Note updated:', updatedNote); + + // Mettre à jour la liste des notes + setNotes((prev) => + prev.map((note) => (note.id === noteId ? updatedNote : note)) + ); + + // Si la note sélectionnée est celle qu'on déplace, la mettre à jour aussi + if (selectedNote?.id === noteId) { + setSelectedNote(updatedNote); + } + + // Recharger les dossiers pour mettre à jour les compteurs + handleFoldersChange(); + } catch (err) { + console.error('[handleNoteDrop] Error:', err); + setError('Erreur lors du déplacement de la note'); + } + }, + [selectedNote, handleFoldersChange] + ); + return (
@@ -182,9 +314,9 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { variant="glass" className="flex h-full rounded-2xl overflow-hidden" > - {/* Notes List Sidebar */} + {/* Combined Sidebar: Folders + Notes List */}
{sidebarCollapsed ? (
@@ -200,6 +332,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
) : ( <> + {/* Header */}
@@ -213,15 +346,31 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
- + + {/* Folders Section */} +
+ +
+ + {/* Notes List */} +
+ +
)}
@@ -250,6 +399,47 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {

{getNoteTitle(selectedNote.content)}

+ {selectedNote.folderId && + (() => { + const findFolder = ( + foldersList: typeof folders + ): (typeof folders)[0] | null => { + for (const folder of foldersList) { + if (folder.id === selectedNote.folderId) + return folder; + if (folder.children) { + const found = findFolder(folder.children); + if (found) return found; + } + } + return null; + }; + const currentFolder = findFolder(folders); + const folderTag = currentFolder?.tagId + ? availableTags.find( + (t) => t.id === currentFolder.tagId + ) + : null; + + return currentFolder ? ( +
+ + 📁 {currentFolder.name} + + {folderTag && ( +
+ {folderTag.name} +
+ )} +
+ ) : null; + })()}
{hasUnsavedChanges && ( @@ -279,6 +469,9 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { selectedTaskId={selectedNote.taskId} selectedTask={selectedNote.task} onTaskChange={handleTaskChange} + selectedFolderId={selectedNote.folderId} + availableFolders={folders} + onFolderChange={handleFolderChange} onCreateNote={handleCreateNote} onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed) @@ -316,10 +509,14 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { export function NotesPageClient({ initialNotes, initialTags, + initialFolders, }: NotesPageClientProps) { return ( - + ); } diff --git a/src/app/notes/page.tsx b/src/app/notes/page.tsx index 732b749..c0111aa 100644 --- a/src/app/notes/page.tsx +++ b/src/app/notes/page.tsx @@ -1,6 +1,7 @@ import { Metadata } from 'next'; import { NotesPageClient } from './NotesPageClient'; import { notesService } from '@/services/notes'; +import { foldersService } from '@/services/folders'; import { tagsService } from '@/services/task-management/tags'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; @@ -34,8 +35,13 @@ export default async function NotesPage() { // SSR - Récupération des données côté serveur const initialNotes = await notesService.getNotes(session.user.id); const initialTags = await tagsService.getTags(session.user.id); + const initialFolders = await foldersService.getFolders(session.user.id); return ( - + ); } diff --git a/src/clients/folders-client.ts b/src/clients/folders-client.ts new file mode 100644 index 0000000..0f21dcc --- /dev/null +++ b/src/clients/folders-client.ts @@ -0,0 +1,84 @@ +import { HttpClient } from '@/clients/base/http-client'; +import { Folder } from '@/services/folders'; + +export interface CreateFolderData { + name: string; + tagId?: string; + parentId?: string; + order?: number; +} + +export interface UpdateFolderData { + name?: string; + tagId?: string; + parentId?: string; + order?: number; +} + +export interface FoldersResponse { + folders: Folder[]; +} + +export interface FolderResponse { + folder: Folder; +} + +/** + * Client HTTP pour les dossiers + */ +export class FoldersClient extends HttpClient { + constructor() { + super('/api/folders'); + } + + /** + * Récupère tous les dossiers de l'utilisateur + */ + async getFolders(): Promise { + const response = await this.get(''); + return response.folders; + } + + /** + * Récupère un dossier par son ID + */ + async getFolderById(id: string): Promise { + const response = await this.get(`/${id}`); + return response.folder; + } + + /** + * Crée un nouveau dossier + */ + async createFolder(data: CreateFolderData): Promise { + const response = await this.post('', data); + return response.folder; + } + + /** + * Met à jour un dossier existant + */ + async updateFolder(id: string, data: UpdateFolderData): Promise { + const response = await this.put(`/${id}`, data); + return response.folder; + } + + /** + * Supprime un dossier + */ + async deleteFolder(id: string): Promise { + await this.delete(`/${id}`); + } + + /** + * Réorganise l'ordre des dossiers + */ + async reorderFolders( + folderOrders: Array<{ id: string; order: number }> + ): Promise { + await this.post('/reorder', { folderOrders }); + } +} + +// Instance singleton +export const foldersClient = new FoldersClient(); diff --git a/src/clients/notes.ts b/src/clients/notes.ts index 5a9a87f..32ac649 100644 --- a/src/clients/notes.ts +++ b/src/clients/notes.ts @@ -5,6 +5,7 @@ export interface CreateNoteData { title: string; content: string; taskId?: string; // Tâche associée à la note + folderId?: string; // Dossier contenant la note tags?: string[]; } @@ -12,6 +13,7 @@ export interface UpdateNoteData { title?: string; content?: string; taskId?: string; // Tâche associée à la note + folderId?: string | null; // Dossier contenant la note (null pour retirer du dossier) tags?: string[]; } @@ -66,7 +68,14 @@ export class NotesClient extends HttpClient { * Met à jour une note existante */ async updateNote(id: string, data: UpdateNoteData): Promise { + console.log( + '[notesClient.updateNote] Updating note:', + id, + 'with data:', + data + ); const response = await this.put(`/${id}`, data); + console.log('[notesClient.updateNote] Response:', response); return response.note; } diff --git a/src/components/notes/FoldersSidebar.tsx b/src/components/notes/FoldersSidebar.tsx new file mode 100644 index 0000000..a6ebd18 --- /dev/null +++ b/src/components/notes/FoldersSidebar.tsx @@ -0,0 +1,405 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { Folder } from '@/services/folders'; +import { Tag } from '@/lib/types'; +import { + Folder as FolderIcon, + FolderOpen, + Plus, + Edit2, + Trash2, + ChevronRight, + ChevronDown, + Tag as TagIcon, +} from 'lucide-react'; +import { createFolder, updateFolder, deleteFolder } from '@/actions/folders'; + +interface FoldersSidebarProps { + folders: Folder[]; + selectedFolderId?: string; + onSelectFolder: (folderId: string | null) => void; + onFoldersChange: () => void; + availableTags: Tag[]; + onNoteDrop?: (noteId: string, folderId: string | null) => void; +} + +interface FolderItemProps { + folder: Folder; + level: number; + isSelected: boolean; + onSelect: (folderId: string) => void; + onEdit: (folder: Folder) => void; + onDelete: (folderId: string) => void; + availableTags: Tag[]; + onNoteDrop?: (noteId: string, folderId: string) => void; +} + +function FolderItem({ + folder, + level, + isSelected, + onSelect, + onEdit, + onDelete, + availableTags, + onNoteDrop, +}: FolderItemProps) { + const [isExpanded, setIsExpanded] = useState(true); + const [isDragOver, setIsDragOver] = useState(false); + const hasChildren = folder.children && folder.children.length > 0; + const tag = availableTags.find((t) => t.id === folder.tagId); + + const handleEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + onEdit(folder); + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete(folder.id); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const noteId = e.dataTransfer.getData('noteId'); + if (noteId && onNoteDrop) { + onNoteDrop(noteId, folder.id); + } + }; + + return ( +
+
onSelect(folder.id)} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + {hasChildren && ( + + )} + + {isExpanded ? ( + + ) : ( + + )} + + {folder.name} + + {folder.notesCount !== undefined && folder.notesCount > 0 && ( + + {folder.notesCount} + + )} + +
+ + +
+
+ + {hasChildren && isExpanded && ( +
+ {folder.children!.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function FoldersSidebar({ + folders, + selectedFolderId, + onSelectFolder, + onFoldersChange, + availableTags, + onNoteDrop, +}: FoldersSidebarProps) { + const [isPending, startTransition] = useTransition(); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [editingFolder, setEditingFolder] = useState(null); + const [folderName, setFolderName] = useState(''); + const [selectedTagId, setSelectedTagId] = useState(''); + const [isDragOverAll, setIsDragOverAll] = useState(false); + + const handleCreateFolder = () => { + setFolderName(''); + setSelectedTagId(''); + setShowCreateDialog(true); + }; + + const handleEditFolder = (folder: Folder) => { + setEditingFolder(folder); + setFolderName(folder.name); + setSelectedTagId(folder.tagId || ''); + setShowCreateDialog(true); + }; + + const handleSaveFolder = () => { + if (!folderName.trim()) return; + + startTransition(async () => { + if (editingFolder) { + // Update existing folder + const result = await updateFolder(editingFolder.id, { + name: folderName, + tagId: selectedTagId || undefined, + }); + + if (result.success) { + onFoldersChange(); + setShowCreateDialog(false); + setEditingFolder(null); + } + } else { + // Create new folder + const result = await createFolder({ + name: folderName, + tagId: selectedTagId || undefined, + }); + + if (result.success) { + onFoldersChange(); + setShowCreateDialog(false); + } + } + }); + }; + + const handleDeleteFolder = (folderId: string) => { + if (!confirm('Êtes-vous sûr de vouloir supprimer ce dossier ?')) return; + + startTransition(async () => { + const result = await deleteFolder(folderId); + + if (result.success) { + if (selectedFolderId === folderId) { + onSelectFolder(null); + } + onFoldersChange(); + } + }); + }; + + return ( +
+ {/* Header */} +
+

+ Dossiers +

+ +
+ + {/* Folders List */} +
+ {/* All Notes */} +
onSelectFolder(null)} + > + + Toutes les notes +
+ + {/* Uncategorized Notes */} +
onSelectFolder('__uncategorized__')} + onDragOver={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOverAll(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOverAll(false); + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOverAll(false); + const noteId = e.dataTransfer.getData('noteId'); + if (noteId && onNoteDrop) { + onNoteDrop(noteId, null); // null = retirer du dossier + } + }} + > + + Notes non classées +
+ + {/* Folders Tree */} + {folders.map((folder) => ( + + ))} +
+ + {/* Create/Edit Dialog */} + {showCreateDialog && ( + <> +
{ + setShowCreateDialog(false); + setEditingFolder(null); + }} + /> +
+
+

+ {editingFolder ? 'Modifier le dossier' : 'Nouveau dossier'} +

+ +
+
+ + setFolderName(e.target.value)} + placeholder="Ex: Projets, Idées..." + className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)] rounded-lg text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]" + autoFocus + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+ + )} +
+ ); +} diff --git a/src/components/notes/MarkdownEditor.tsx b/src/components/notes/MarkdownEditor.tsx index 18a60eb..c9210c3 100644 --- a/src/components/notes/MarkdownEditor.tsx +++ b/src/components/notes/MarkdownEditor.tsx @@ -9,12 +9,20 @@ import rehypeRaw from 'rehype-raw'; import rehypeSlug from 'rehype-slug'; import rehypeSanitize from 'rehype-sanitize'; import { Highlight, themes } from 'prism-react-renderer'; -import { Eye, EyeOff, Edit3, X, CheckSquare2 } from 'lucide-react'; +import { + Eye, + EyeOff, + Edit3, + X, + CheckSquare2, + Folder as FolderIcon, +} from 'lucide-react'; import { TagInput } from '@/components/ui/TagInput'; import { TagDisplay } from '@/components/ui/TagDisplay'; import { TaskSelectorWithData } from '@/components/shared/TaskSelectorWithData'; import { MermaidRenderer } from '@/components/ui/MermaidRenderer'; import { Tag, Task } from '@/lib/types'; +import { Folder } from '@/services/folders'; import type { Components } from 'react-markdown'; // Fonction pour générer les composants Markdown réutilisables @@ -286,6 +294,9 @@ interface MarkdownEditorProps { selectedTaskId?: string; selectedTask?: Task | null; // Objet Task complet pour l'affichage onTaskChange?: (task: Task | null) => void; + selectedFolderId?: string; + availableFolders?: Folder[]; + onFolderChange?: (folderId: string | null) => void; onCreateNote?: () => void; onToggleSidebar?: () => void; initialIsEditing?: boolean; @@ -305,6 +316,9 @@ export function MarkdownEditor({ selectedTaskId, selectedTask, onTaskChange, + selectedFolderId, + availableFolders = [], + onFolderChange, onCreateNote, onToggleSidebar, initialIsEditing = false, @@ -653,6 +667,30 @@ export function MarkdownEditor({ />
)} + + {onFolderChange && ( +
+ + + Dossier: + + +
+ )}
)} diff --git a/src/components/notes/NotesList.tsx b/src/components/notes/NotesList.tsx index 5a36b06..cdb1ecb 100644 --- a/src/components/notes/NotesList.tsx +++ b/src/components/notes/NotesList.tsx @@ -171,6 +171,11 @@ export function NotesList({ {groupNotes.map((note) => (
{ + e.dataTransfer.setData('noteId', note.id); + e.dataTransfer.effectAllowed = 'move'; + }} onClick={() => onSelectNote(note)} className={`group relative p-3 cursor-pointer transition-all duration-200 backdrop-blur-sm ${ selectedNoteId === note.id diff --git a/src/services/folders.ts b/src/services/folders.ts new file mode 100644 index 0000000..e5dd7ed --- /dev/null +++ b/src/services/folders.ts @@ -0,0 +1,349 @@ +import { prisma } from '@/services/core/database'; + +export interface Folder { + id: string; + name: string; + userId: string; + tagId?: string; + parentId?: string; + order: number; + createdAt: Date; + updatedAt: Date; + tag?: { + id: string; + name: string; + color: string; + } | null; + children?: Folder[]; + notesCount?: number; +} + +export interface CreateFolderData { + name: string; + userId: string; + tagId?: string; + parentId?: string; + order?: number; +} + +export interface UpdateFolderData { + name?: string; + tagId?: string; + parentId?: string; + order?: number; +} + +/** + * Service pour la gestion des dossiers de notes + */ +export class FoldersService { + /** + * Récupère tous les dossiers d'un utilisateur avec leur hiérarchie + */ + async getFolders(userId: string): Promise { + const folders = await prisma.folder.findMany({ + where: { userId }, + include: { + tag: { + select: { + id: true, + name: true, + color: true, + }, + }, + _count: { + select: { + notes: true, + }, + }, + }, + orderBy: { order: 'asc' }, + }); + + // Construire l'arborescence + const folderMap = new Map(); + const rootFolders: Folder[] = []; + + // Première passe : créer tous les dossiers + folders.forEach((folder) => { + const folderData: Folder = { + id: folder.id, + name: folder.name, + userId: folder.userId, + tagId: folder.tagId || undefined, + parentId: folder.parentId || undefined, + order: folder.order, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + tag: folder.tag, + children: [], + notesCount: folder._count.notes, + }; + folderMap.set(folder.id, folderData); + }); + + // Deuxième passe : construire la hiérarchie + folderMap.forEach((folder) => { + if (folder.parentId) { + const parent = folderMap.get(folder.parentId); + if (parent) { + parent.children!.push(folder); + } + } else { + rootFolders.push(folder); + } + }); + + return rootFolders; + } + + /** + * Récupère un dossier par son ID + */ + async getFolderById( + folderId: string, + userId: string + ): Promise { + const folder = await prisma.folder.findFirst({ + where: { + id: folderId, + userId, + }, + include: { + tag: { + select: { + id: true, + name: true, + color: true, + }, + }, + _count: { + select: { + notes: true, + }, + }, + }, + }); + + if (!folder) return null; + + return { + id: folder.id, + name: folder.name, + userId: folder.userId, + tagId: folder.tagId || undefined, + parentId: folder.parentId || undefined, + order: folder.order, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + tag: folder.tag, + notesCount: folder._count.notes, + }; + } + + /** + * Crée un nouveau dossier + */ + async createFolder(data: CreateFolderData): Promise { + const folder = await prisma.folder.create({ + data: { + name: data.name, + userId: data.userId, + tagId: data.tagId, + parentId: data.parentId, + order: data.order ?? 0, + }, + include: { + tag: { + select: { + id: true, + name: true, + color: true, + }, + }, + _count: { + select: { + notes: true, + }, + }, + }, + }); + + return { + id: folder.id, + name: folder.name, + userId: folder.userId, + tagId: folder.tagId || undefined, + parentId: folder.parentId || undefined, + order: folder.order, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + tag: folder.tag, + notesCount: folder._count.notes, + }; + } + + /** + * Met à jour un dossier existant + */ + async updateFolder( + folderId: string, + userId: string, + data: UpdateFolderData + ): Promise { + // Vérifier que le dossier appartient à l'utilisateur + const existingFolder = await prisma.folder.findFirst({ + where: { + id: folderId, + userId, + }, + }); + + if (!existingFolder) { + throw new Error('Folder not found or access denied'); + } + + // Vérifier qu'on ne crée pas de boucle dans la hiérarchie + if (data.parentId) { + const isDescendant = await this.isDescendant( + folderId, + data.parentId, + userId + ); + if (isDescendant) { + throw new Error('Cannot move folder to its own descendant'); + } + } + + const folder = await prisma.folder.update({ + where: { id: folderId }, + data: { + name: data.name, + tagId: data.tagId, + parentId: data.parentId, + order: data.order, + updatedAt: new Date(), + }, + include: { + tag: { + select: { + id: true, + name: true, + color: true, + }, + }, + _count: { + select: { + notes: true, + }, + }, + }, + }); + + return { + id: folder.id, + name: folder.name, + userId: folder.userId, + tagId: folder.tagId || undefined, + parentId: folder.parentId || undefined, + order: folder.order, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + tag: folder.tag, + notesCount: folder._count.notes, + }; + } + + /** + * Supprime un dossier (et déplace ses notes vers le parent ou null) + */ + async deleteFolder(folderId: string, userId: string): Promise { + // Vérifier que le dossier appartient à l'utilisateur + const existingFolder = await prisma.folder.findFirst({ + where: { + id: folderId, + userId, + }, + }); + + if (!existingFolder) { + throw new Error('Folder not found or access denied'); + } + + // Déplacer les notes vers le parent du dossier supprimé + await prisma.note.updateMany({ + where: { folderId }, + data: { folderId: existingFolder.parentId }, + }); + + // Déplacer les sous-dossiers vers le parent du dossier supprimé + await prisma.folder.updateMany({ + where: { parentId: folderId }, + data: { parentId: existingFolder.parentId }, + }); + + // Supprimer le dossier + await prisma.folder.delete({ + where: { id: folderId }, + }); + } + + /** + * Vérifie si un dossier est un descendant d'un autre + */ + private async isDescendant( + folderId: string, + potentialAncestorId: string, + userId: string + ): Promise { + if (folderId === potentialAncestorId) return true; + + const folder = await prisma.folder.findFirst({ + where: { + id: potentialAncestorId, + userId, + }, + select: { + parentId: true, + }, + }); + + if (!folder || !folder.parentId) return false; + + return this.isDescendant(folderId, folder.parentId, userId); + } + + /** + * Réorganise l'ordre des dossiers + */ + async reorderFolders( + userId: string, + folderOrders: Array<{ id: string; order: number }> + ): Promise { + // Vérifier que tous les dossiers appartiennent à l'utilisateur + const folderIds = folderOrders.map((f) => f.id); + const folders = await prisma.folder.findMany({ + where: { + id: { in: folderIds }, + userId, + }, + }); + + if (folders.length !== folderIds.length) { + throw new Error('Some folders not found or access denied'); + } + + // Mettre à jour l'ordre + await Promise.all( + folderOrders.map((folderOrder) => + prisma.folder.update({ + where: { id: folderOrder.id }, + data: { order: folderOrder.order }, + }) + ) + ); + } +} + +// Instance singleton +export const foldersService = new FoldersService(); diff --git a/src/services/notes.ts b/src/services/notes.ts index 5edba66..f9c118e 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -9,6 +9,7 @@ export interface Note { content: string; userId: string; taskId?: string; // Tâche associée à la note + folderId?: string; // Dossier contenant la note task?: Task | null; // Objet Task complet createdAt: Date; updatedAt: Date; @@ -20,6 +21,7 @@ export interface CreateNoteData { content: string; userId: string; taskId?: string; // Tâche associée à la note + folderId?: string; // Dossier contenant la note tags?: string[]; } @@ -27,6 +29,7 @@ export interface UpdateNoteData { title?: string; content?: string; taskId?: string; // Tâche associée à la note + folderId?: string; // Dossier contenant la note tags?: string[]; } @@ -126,6 +129,7 @@ export class NotesService { return notes.map((note) => ({ ...note, taskId: note.taskId || undefined, // Convertir null en undefined + folderId: note.folderId || undefined, // Convertir null en undefined task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task tags: note.noteTags.map((nt) => nt.tag.name), })); @@ -164,6 +168,7 @@ export class NotesService { return { ...note, taskId: note.taskId || undefined, // Convertir null en undefined + folderId: note.folderId || undefined, // Convertir null en undefined task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task tags: note.noteTags.map((nt) => nt.tag.name), }; @@ -179,6 +184,7 @@ export class NotesService { content: data.content, userId: data.userId, taskId: data.taskId, // Ajouter le taskId + folderId: data.folderId, // Ajouter le folderId }, include: { task: { @@ -233,6 +239,7 @@ export class NotesService { return { ...noteWithTags!, taskId: noteWithTags!.taskId || undefined, // Convertir null en undefined + folderId: noteWithTags!.folderId || undefined, // Convertir null en undefined task: this.mapPrismaTaskToTask(noteWithTags!.task), // Mapper correctement l'objet Task tags: noteWithTags!.noteTags.map((nt) => nt.tag.name), }; @@ -263,7 +270,8 @@ export class NotesService { updatedAt: Date; title?: string; content?: string; - taskId?: string; + taskId?: string | null; + folderId?: string | null; noteTags?: { deleteMany: Record; create: Array<{ @@ -287,7 +295,10 @@ export class NotesService { updateData.content = data.content; } if (data.taskId !== undefined) { - updateData.taskId = data.taskId; + updateData.taskId = data.taskId || null; + } + if (data.folderId !== undefined) { + updateData.folderId = data.folderId || null; } // Gérer les tags si fournis @@ -342,6 +353,7 @@ export class NotesService { return { ...noteWithTags!, taskId: noteWithTags!.taskId || undefined, // Convertir null en undefined + folderId: noteWithTags!.folderId || undefined, // Convertir null en undefined task: this.mapPrismaTaskToTask(noteWithTags!.task), // Mapper correctement l'objet Task tags: noteWithTags!.noteTags.map((nt) => nt.tag.name), }; @@ -383,6 +395,7 @@ export class NotesService { return notes.map((note) => ({ ...note, taskId: note.taskId || undefined, // Convertir null en undefined + folderId: note.folderId || undefined, // Convertir null en undefined })); }