feat(Notes): add folder management to notes, allowing notes to be categorized into folders, and update related components for folder selection and display
This commit is contained in:
0
prisma/data/dev.db
Normal file
0
prisma/data/dev.db
Normal file
@@ -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");
|
||||||
|
|
||||||
|
|
||||||
BIN
prisma/prisma/dev.db
Normal file
BIN
prisma/prisma/dev.db
Normal file
Binary file not shown.
@@ -25,6 +25,7 @@ model User {
|
|||||||
dailyCheckboxes DailyCheckbox[]
|
dailyCheckboxes DailyCheckbox[]
|
||||||
tasks Task[] @relation("TaskOwner")
|
tasks Task[] @relation("TaskOwner")
|
||||||
tags Tag[] @relation("TagOwner")
|
tags Tag[] @relation("TagOwner")
|
||||||
|
folders Folder[] @relation("FolderOwner")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -72,6 +73,7 @@ model Tag {
|
|||||||
taskTags TaskTag[]
|
taskTags TaskTag[]
|
||||||
primaryTasks Task[] @relation("PrimaryTag")
|
primaryTasks Task[] @relation("PrimaryTag")
|
||||||
noteTags NoteTag[]
|
noteTags NoteTag[]
|
||||||
|
folders Folder[]
|
||||||
|
|
||||||
@@unique([name, ownerId]) // Un nom de tag unique par utilisateur
|
@@unique([name, ownerId]) // Un nom de tag unique par utilisateur
|
||||||
@@map("tags")
|
@@map("tags")
|
||||||
@@ -142,13 +144,33 @@ model Note {
|
|||||||
content String // Markdown content
|
content String // Markdown content
|
||||||
userId String
|
userId String
|
||||||
taskId String? // Tâche associée à la note
|
taskId String? // Tâche associée à la note
|
||||||
|
folderId String? // Dossier contenant la note
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
task Task? @relation(fields: [taskId], references: [id])
|
task Task? @relation(fields: [taskId], references: [id])
|
||||||
|
folder Folder? @relation(fields: [folderId], references: [id])
|
||||||
noteTags NoteTag[]
|
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 {
|
model NoteTag {
|
||||||
noteId String
|
noteId String
|
||||||
tagId String
|
tagId String
|
||||||
|
|||||||
132
src/actions/folders.ts
Normal file
132
src/actions/folders.ts
Normal file
@@ -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<CreateFolderData, 'userId'>) {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,10 +51,17 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { title, content, taskId, tags } = body;
|
|
||||||
|
|
||||||
const resolvedParams = await params;
|
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(
|
const note = await notesService.updateNote(
|
||||||
resolvedParams.id,
|
resolvedParams.id,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
@@ -62,10 +69,13 @@ export async function PUT(
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
taskId,
|
taskId,
|
||||||
|
folderId,
|
||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('[API PUT /notes/:id] Note updated:', note);
|
||||||
|
|
||||||
return NextResponse.json({ note });
|
return NextResponse.json({ note });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating note:', error);
|
console.error('Error updating note:', error);
|
||||||
|
|||||||
@@ -2,24 +2,36 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Note } from '@/services/notes';
|
import { Note } from '@/services/notes';
|
||||||
|
import { Folder } from '@/services/folders';
|
||||||
import { Task } from '@/lib/types';
|
import { Task } from '@/lib/types';
|
||||||
import { notesClient } from '@/clients/notes';
|
import { notesClient } from '@/clients/notes';
|
||||||
import { NotesList } from '@/components/notes/NotesList';
|
import { NotesList } from '@/components/notes/NotesList';
|
||||||
import { MarkdownEditor } from '@/components/notes/MarkdownEditor';
|
import { MarkdownEditor } from '@/components/notes/MarkdownEditor';
|
||||||
|
import { FoldersSidebar } from '@/components/notes/FoldersSidebar';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { Card } from '@/components/ui';
|
import { Card } from '@/components/ui';
|
||||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { Tag } from '@/lib/types';
|
import { Tag } from '@/lib/types';
|
||||||
import { FileText, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { FileText, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { getFolders } from '@/actions/folders';
|
||||||
|
|
||||||
interface NotesPageClientProps {
|
interface NotesPageClientProps {
|
||||||
initialNotes: Note[];
|
initialNotes: Note[];
|
||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
|
initialFolders: Folder[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
function NotesPageContent({
|
||||||
|
initialNotes,
|
||||||
|
initialFolders,
|
||||||
|
}: {
|
||||||
|
initialNotes: Note[];
|
||||||
|
initialFolders: Folder[];
|
||||||
|
}) {
|
||||||
const [notes, setNotes] = useState<Note[]>(initialNotes);
|
const [notes, setNotes] = useState<Note[]>(initialNotes);
|
||||||
|
const [folders, setFolders] = useState<Folder[]>(initialFolders);
|
||||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||||
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||||
const [isNewNote, setIsNewNote] = useState(false);
|
const [isNewNote, setIsNewNote] = useState(false);
|
||||||
const { tags: availableTags } = useTasksContext();
|
const { tags: availableTags } = useTasksContext();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -58,6 +70,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
const newNote = await notesClient.createNote({
|
const newNote = await notesClient.createNote({
|
||||||
title: 'Nouvelle note',
|
title: 'Nouvelle note',
|
||||||
content: '# Nouvelle note\n\nCommencez à écrire...',
|
content: '# Nouvelle note\n\nCommencez à écrire...',
|
||||||
|
folderId: selectedFolderId || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setNotes((prev) => [newNote, ...prev]);
|
setNotes((prev) => [newNote, ...prev]);
|
||||||
@@ -68,7 +81,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
setError('Erreur lors de la création de la note');
|
setError('Erreur lors de la création de la note');
|
||||||
console.error('Error creating note:', err);
|
console.error('Error creating note:', err);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [selectedFolderId]);
|
||||||
|
|
||||||
const handleDeleteNote = useCallback(
|
const handleDeleteNote = useCallback(
|
||||||
async (noteId: string) => {
|
async (noteId: string) => {
|
||||||
@@ -123,6 +136,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
content: selectedNote.content,
|
content: selectedNote.content,
|
||||||
tags: selectedNote.tags,
|
tags: selectedNote.tags,
|
||||||
taskId: selectedNote.taskId,
|
taskId: selectedNote.taskId,
|
||||||
|
folderId: selectedNote.folderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour la liste des notes mais pas selectedNote pour éviter la perte de focus
|
// 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]
|
[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
|
// Auto-save quand les tags changent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasUnsavedChanges && selectedNote) {
|
if (hasUnsavedChanges && selectedNote) {
|
||||||
@@ -173,6 +225,86 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
}
|
}
|
||||||
}, [selectedNote, hasUnsavedChanges, handleSave]);
|
}, [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 (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)] flex flex-col">
|
<div className="min-h-screen bg-[var(--background)] flex flex-col">
|
||||||
<Header title="Notes" subtitle="Gestionnaire de notes markdown" />
|
<Header title="Notes" subtitle="Gestionnaire de notes markdown" />
|
||||||
@@ -182,9 +314,9 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
variant="glass"
|
variant="glass"
|
||||||
className="flex h-full rounded-2xl overflow-hidden"
|
className="flex h-full rounded-2xl overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Notes List Sidebar */}
|
{/* Combined Sidebar: Folders + Notes List */}
|
||||||
<div
|
<div
|
||||||
className={`${sidebarCollapsed ? 'w-12' : 'w-80'} flex-shrink-0 border-r border-[var(--border)] transition-all duration-300 ease-in-out`}
|
className={`${sidebarCollapsed ? 'w-12' : 'w-80'} flex-shrink-0 border-r border-[var(--border)] transition-all duration-300 ease-in-out flex flex-col`}
|
||||||
>
|
>
|
||||||
{sidebarCollapsed ? (
|
{sidebarCollapsed ? (
|
||||||
<div className="h-full flex flex-col items-center py-4">
|
<div className="h-full flex flex-col items-center py-4">
|
||||||
@@ -200,6 +332,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className={`${glassDivider} flex items-center justify-between p-3`}
|
className={`${glassDivider} flex items-center justify-between p-3`}
|
||||||
>
|
>
|
||||||
@@ -213,15 +346,31 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
<ChevronLeft className="w-3 h-3" />
|
<ChevronLeft className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<NotesList
|
|
||||||
notes={notes}
|
{/* Folders Section */}
|
||||||
onSelectNote={handleSelectNote}
|
<div className="flex-shrink-0 border-b border-[var(--border)]">
|
||||||
onCreateNote={handleCreateNote}
|
<FoldersSidebar
|
||||||
onDeleteNote={handleDeleteNote}
|
folders={folders}
|
||||||
selectedNoteId={selectedNote?.id}
|
selectedFolderId={selectedFolderId || undefined}
|
||||||
isLoading={false}
|
onSelectFolder={handleSelectFolder}
|
||||||
availableTags={availableTags}
|
onFoldersChange={handleFoldersChange}
|
||||||
/>
|
availableTags={availableTags}
|
||||||
|
onNoteDrop={handleNoteDrop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes List */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<NotesList
|
||||||
|
notes={filteredNotes}
|
||||||
|
onSelectNote={handleSelectNote}
|
||||||
|
onCreateNote={handleCreateNote}
|
||||||
|
onDeleteNote={handleDeleteNote}
|
||||||
|
selectedNoteId={selectedNote?.id}
|
||||||
|
isLoading={false}
|
||||||
|
availableTags={availableTags}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -250,6 +399,47 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
<h2 className="text-lg font-semibold text-[var(--foreground)] truncate">
|
<h2 className="text-lg font-semibold text-[var(--foreground)] truncate">
|
||||||
{getNoteTitle(selectedNote.content)}
|
{getNoteTitle(selectedNote.content)}
|
||||||
</h2>
|
</h2>
|
||||||
|
{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 ? (
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
📁 {currentFolder.name}
|
||||||
|
</span>
|
||||||
|
{folderTag && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${folderTag.color}20`,
|
||||||
|
color: folderTag.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{folderTag.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||||
{hasUnsavedChanges && (
|
{hasUnsavedChanges && (
|
||||||
@@ -279,6 +469,9 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
selectedTaskId={selectedNote.taskId}
|
selectedTaskId={selectedNote.taskId}
|
||||||
selectedTask={selectedNote.task}
|
selectedTask={selectedNote.task}
|
||||||
onTaskChange={handleTaskChange}
|
onTaskChange={handleTaskChange}
|
||||||
|
selectedFolderId={selectedNote.folderId}
|
||||||
|
availableFolders={folders}
|
||||||
|
onFolderChange={handleFolderChange}
|
||||||
onCreateNote={handleCreateNote}
|
onCreateNote={handleCreateNote}
|
||||||
onToggleSidebar={() =>
|
onToggleSidebar={() =>
|
||||||
setSidebarCollapsed(!sidebarCollapsed)
|
setSidebarCollapsed(!sidebarCollapsed)
|
||||||
@@ -316,10 +509,14 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
export function NotesPageClient({
|
export function NotesPageClient({
|
||||||
initialNotes,
|
initialNotes,
|
||||||
initialTags,
|
initialTags,
|
||||||
|
initialFolders,
|
||||||
}: NotesPageClientProps) {
|
}: NotesPageClientProps) {
|
||||||
return (
|
return (
|
||||||
<TasksProvider initialTasks={[]} initialTags={initialTags}>
|
<TasksProvider initialTasks={[]} initialTags={initialTags}>
|
||||||
<NotesPageContent initialNotes={initialNotes} />
|
<NotesPageContent
|
||||||
|
initialNotes={initialNotes}
|
||||||
|
initialFolders={initialFolders}
|
||||||
|
/>
|
||||||
</TasksProvider>
|
</TasksProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { NotesPageClient } from './NotesPageClient';
|
import { NotesPageClient } from './NotesPageClient';
|
||||||
import { notesService } from '@/services/notes';
|
import { notesService } from '@/services/notes';
|
||||||
|
import { foldersService } from '@/services/folders';
|
||||||
import { tagsService } from '@/services/task-management/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from '@/lib/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
|
// SSR - Récupération des données côté serveur
|
||||||
const initialNotes = await notesService.getNotes(session.user.id);
|
const initialNotes = await notesService.getNotes(session.user.id);
|
||||||
const initialTags = await tagsService.getTags(session.user.id);
|
const initialTags = await tagsService.getTags(session.user.id);
|
||||||
|
const initialFolders = await foldersService.getFolders(session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotesPageClient initialNotes={initialNotes} initialTags={initialTags} />
|
<NotesPageClient
|
||||||
|
initialNotes={initialNotes}
|
||||||
|
initialTags={initialTags}
|
||||||
|
initialFolders={initialFolders}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/clients/folders-client.ts
Normal file
84
src/clients/folders-client.ts
Normal file
@@ -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<Folder[]> {
|
||||||
|
const response = await this.get<FoldersResponse>('');
|
||||||
|
return response.folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un dossier par son ID
|
||||||
|
*/
|
||||||
|
async getFolderById(id: string): Promise<Folder> {
|
||||||
|
const response = await this.get<FolderResponse>(`/${id}`);
|
||||||
|
return response.folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un nouveau dossier
|
||||||
|
*/
|
||||||
|
async createFolder(data: CreateFolderData): Promise<Folder> {
|
||||||
|
const response = await this.post<FolderResponse>('', data);
|
||||||
|
return response.folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour un dossier existant
|
||||||
|
*/
|
||||||
|
async updateFolder(id: string, data: UpdateFolderData): Promise<Folder> {
|
||||||
|
const response = await this.put<FolderResponse>(`/${id}`, data);
|
||||||
|
return response.folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un dossier
|
||||||
|
*/
|
||||||
|
async deleteFolder(id: string): Promise<void> {
|
||||||
|
await this.delete(`/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réorganise l'ordre des dossiers
|
||||||
|
*/
|
||||||
|
async reorderFolders(
|
||||||
|
folderOrders: Array<{ id: string; order: number }>
|
||||||
|
): Promise<void> {
|
||||||
|
await this.post('/reorder', { folderOrders });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance singleton
|
||||||
|
export const foldersClient = new FoldersClient();
|
||||||
@@ -5,6 +5,7 @@ export interface CreateNoteData {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
taskId?: string; // Tâche associée à la note
|
taskId?: string; // Tâche associée à la note
|
||||||
|
folderId?: string; // Dossier contenant la note
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export interface UpdateNoteData {
|
|||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
taskId?: string; // Tâche associée à la note
|
taskId?: string; // Tâche associée à la note
|
||||||
|
folderId?: string | null; // Dossier contenant la note (null pour retirer du dossier)
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +68,14 @@ export class NotesClient extends HttpClient {
|
|||||||
* Met à jour une note existante
|
* Met à jour une note existante
|
||||||
*/
|
*/
|
||||||
async updateNote(id: string, data: UpdateNoteData): Promise<Note> {
|
async updateNote(id: string, data: UpdateNoteData): Promise<Note> {
|
||||||
|
console.log(
|
||||||
|
'[notesClient.updateNote] Updating note:',
|
||||||
|
id,
|
||||||
|
'with data:',
|
||||||
|
data
|
||||||
|
);
|
||||||
const response = await this.put<NoteResponse>(`/${id}`, data);
|
const response = await this.put<NoteResponse>(`/${id}`, data);
|
||||||
|
console.log('[notesClient.updateNote] Response:', response);
|
||||||
return response.note;
|
return response.note;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
405
src/components/notes/FoldersSidebar.tsx
Normal file
405
src/components/notes/FoldersSidebar.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-l-2 border-[var(--primary)]'
|
||||||
|
: isDragOver
|
||||||
|
? 'bg-[var(--primary)]/20 border-l-2 border-[var(--primary)] border-dashed'
|
||||||
|
: 'hover:bg-[var(--card)]/60 text-[var(--foreground)]'
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${level * 16 + 12}px` }}
|
||||||
|
onClick={() => onSelect(folder.id)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{hasChildren && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}}
|
||||||
|
className="p-0.5 hover:bg-[var(--card)]/80 rounded"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExpanded ? (
|
||||||
|
<FolderOpen className="w-4 h-4 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<FolderIcon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="flex-1 text-sm truncate">{folder.name}</span>
|
||||||
|
|
||||||
|
{folder.notesCount !== undefined && folder.notesCount > 0 && (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{folder.notesCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="p-1 hover:bg-[var(--primary)]/10 text-[var(--muted-foreground)] hover:text-[var(--primary)] rounded transition-colors"
|
||||||
|
title="Renommer"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="p-1 hover:bg-[var(--destructive)]/10 text-[var(--muted-foreground)] hover:text-[var(--destructive)] rounded transition-colors"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<div>
|
||||||
|
{folder.children!.map((child) => (
|
||||||
|
<FolderItem
|
||||||
|
key={child.id}
|
||||||
|
folder={child}
|
||||||
|
level={level + 1}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
availableTags={availableTags}
|
||||||
|
onNoteDrop={onNoteDrop}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FoldersSidebar({
|
||||||
|
folders,
|
||||||
|
selectedFolderId,
|
||||||
|
onSelectFolder,
|
||||||
|
onFoldersChange,
|
||||||
|
availableTags,
|
||||||
|
onNoteDrop,
|
||||||
|
}: FoldersSidebarProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
const [editingFolder, setEditingFolder] = useState<Folder | null>(null);
|
||||||
|
const [folderName, setFolderName] = useState('');
|
||||||
|
const [selectedTagId, setSelectedTagId] = useState<string>('');
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<h3 className="text-xs font-mono font-semibold text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Dossiers
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateFolder}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-1 rounded-lg bg-[var(--primary)]/10 hover:bg-[var(--primary)]/20 text-[var(--primary)] transition-colors disabled:opacity-50"
|
||||||
|
title="Nouveau dossier"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folders List */}
|
||||||
|
<div className="overflow-y-auto overflow-x-visible px-2 pb-2 max-h-[40vh]">
|
||||||
|
{/* All Notes */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 mb-1 ${
|
||||||
|
selectedFolderId === null
|
||||||
|
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-l-2 border-[var(--primary)]'
|
||||||
|
: 'hover:bg-[var(--card)]/60 text-[var(--foreground)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectFolder(null)}
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-sm">Toutes les notes</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uncategorized Notes */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 mb-2 ${
|
||||||
|
selectedFolderId === '__uncategorized__'
|
||||||
|
? 'bg-[var(--muted)]/10 text-[var(--muted-foreground)] border-l-2 border-[var(--muted)]'
|
||||||
|
: isDragOverAll
|
||||||
|
? 'bg-[var(--primary)]/20 border-l-2 border-[var(--primary)] border-dashed'
|
||||||
|
: 'hover:bg-[var(--card)]/60 text-[var(--muted-foreground)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => 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
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderIcon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-sm">Notes non classées</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folders Tree */}
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<FolderItem
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
level={0}
|
||||||
|
isSelected={selectedFolderId === folder.id}
|
||||||
|
onSelect={onSelectFolder}
|
||||||
|
onEdit={handleEditFolder}
|
||||||
|
onDelete={handleDeleteFolder}
|
||||||
|
availableTags={availableTags}
|
||||||
|
onNoteDrop={onNoteDrop}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
{showCreateDialog && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateDialog(false);
|
||||||
|
setEditingFolder(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-x-0 top-0 flex justify-center z-50 p-4 pt-20">
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-2xl w-full max-w-md p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-4">
|
||||||
|
{editingFolder ? 'Modifier le dossier' : 'Nouveau dossier'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
||||||
|
Nom du dossier
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={folderName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
||||||
|
Tag associé (optionnel)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedTagId}
|
||||||
|
onChange={(e) => setSelectedTagId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)] rounded-lg text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||||
|
>
|
||||||
|
<option value="">Aucun tag</option>
|
||||||
|
{availableTags.map((tag) => (
|
||||||
|
<option key={tag.id} value={tag.id}>
|
||||||
|
{tag.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateDialog(false);
|
||||||
|
setEditingFolder(null);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-[var(--foreground)] hover:bg-[var(--card-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveFolder}
|
||||||
|
disabled={!folderName.trim() || isPending}
|
||||||
|
className="flex-1 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:bg-[var(--primary)]/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isPending
|
||||||
|
? 'Enregistrement...'
|
||||||
|
: editingFolder
|
||||||
|
? 'Modifier'
|
||||||
|
: 'Créer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,20 @@ import rehypeRaw from 'rehype-raw';
|
|||||||
import rehypeSlug from 'rehype-slug';
|
import rehypeSlug from 'rehype-slug';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
import { Highlight, themes } from 'prism-react-renderer';
|
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 { TagInput } from '@/components/ui/TagInput';
|
||||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||||
import { TaskSelectorWithData } from '@/components/shared/TaskSelectorWithData';
|
import { TaskSelectorWithData } from '@/components/shared/TaskSelectorWithData';
|
||||||
import { MermaidRenderer } from '@/components/ui/MermaidRenderer';
|
import { MermaidRenderer } from '@/components/ui/MermaidRenderer';
|
||||||
import { Tag, Task } from '@/lib/types';
|
import { Tag, Task } from '@/lib/types';
|
||||||
|
import { Folder } from '@/services/folders';
|
||||||
import type { Components } from 'react-markdown';
|
import type { Components } from 'react-markdown';
|
||||||
|
|
||||||
// Fonction pour générer les composants Markdown réutilisables
|
// Fonction pour générer les composants Markdown réutilisables
|
||||||
@@ -286,6 +294,9 @@ interface MarkdownEditorProps {
|
|||||||
selectedTaskId?: string;
|
selectedTaskId?: string;
|
||||||
selectedTask?: Task | null; // Objet Task complet pour l'affichage
|
selectedTask?: Task | null; // Objet Task complet pour l'affichage
|
||||||
onTaskChange?: (task: Task | null) => void;
|
onTaskChange?: (task: Task | null) => void;
|
||||||
|
selectedFolderId?: string;
|
||||||
|
availableFolders?: Folder[];
|
||||||
|
onFolderChange?: (folderId: string | null) => void;
|
||||||
onCreateNote?: () => void;
|
onCreateNote?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
initialIsEditing?: boolean;
|
initialIsEditing?: boolean;
|
||||||
@@ -305,6 +316,9 @@ export function MarkdownEditor({
|
|||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
selectedTask,
|
selectedTask,
|
||||||
onTaskChange,
|
onTaskChange,
|
||||||
|
selectedFolderId,
|
||||||
|
availableFolders = [],
|
||||||
|
onFolderChange,
|
||||||
onCreateNote,
|
onCreateNote,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
initialIsEditing = false,
|
initialIsEditing = false,
|
||||||
@@ -653,6 +667,30 @@ export function MarkdownEditor({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onFolderChange && (
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<FolderIcon className="w-4 h-4 text-[var(--muted-foreground)]" />
|
||||||
|
<span className="text-sm font-medium text-[var(--foreground)]">
|
||||||
|
Dossier:
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={selectedFolderId || '__none__'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
onFolderChange(value === '__none__' ? null : value);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-3 py-1.5 text-sm bg-[var(--input)] border border-[var(--border)] rounded-lg text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||||
|
>
|
||||||
|
<option value="__none__">Aucun dossier</option>
|
||||||
|
{availableFolders.map((folder) => (
|
||||||
|
<option key={folder.id} value={folder.id}>
|
||||||
|
{folder.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -171,6 +171,11 @@ export function NotesList({
|
|||||||
{groupNotes.map((note) => (
|
{groupNotes.map((note) => (
|
||||||
<div
|
<div
|
||||||
key={note.id}
|
key={note.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData('noteId', note.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}}
|
||||||
onClick={() => onSelectNote(note)}
|
onClick={() => onSelectNote(note)}
|
||||||
className={`group relative p-3 cursor-pointer transition-all duration-200 backdrop-blur-sm ${
|
className={`group relative p-3 cursor-pointer transition-all duration-200 backdrop-blur-sm ${
|
||||||
selectedNoteId === note.id
|
selectedNoteId === note.id
|
||||||
|
|||||||
349
src/services/folders.ts
Normal file
349
src/services/folders.ts
Normal file
@@ -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<Folder[]> {
|
||||||
|
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<string, Folder>();
|
||||||
|
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<Folder | null> {
|
||||||
|
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<Folder> {
|
||||||
|
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<Folder> {
|
||||||
|
// 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<void> {
|
||||||
|
// 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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
// 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();
|
||||||
@@ -9,6 +9,7 @@ export interface Note {
|
|||||||
content: string;
|
content: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
taskId?: string; // Tâche associée à la note
|
taskId?: string; // Tâche associée à la note
|
||||||
|
folderId?: string; // Dossier contenant la note
|
||||||
task?: Task | null; // Objet Task complet
|
task?: Task | null; // Objet Task complet
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -20,6 +21,7 @@ export interface CreateNoteData {
|
|||||||
content: string;
|
content: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
taskId?: string; // Tâche associée à la note
|
taskId?: string; // Tâche associée à la note
|
||||||
|
folderId?: string; // Dossier contenant la note
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ export interface UpdateNoteData {
|
|||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
taskId?: string; // Tâche associée à la note
|
taskId?: string; // Tâche associée à la note
|
||||||
|
folderId?: string; // Dossier contenant la note
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ export class NotesService {
|
|||||||
return notes.map((note) => ({
|
return notes.map((note) => ({
|
||||||
...note,
|
...note,
|
||||||
taskId: note.taskId || undefined, // Convertir null en undefined
|
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
|
task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task
|
||||||
tags: note.noteTags.map((nt) => nt.tag.name),
|
tags: note.noteTags.map((nt) => nt.tag.name),
|
||||||
}));
|
}));
|
||||||
@@ -164,6 +168,7 @@ export class NotesService {
|
|||||||
return {
|
return {
|
||||||
...note,
|
...note,
|
||||||
taskId: note.taskId || undefined, // Convertir null en undefined
|
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
|
task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task
|
||||||
tags: note.noteTags.map((nt) => nt.tag.name),
|
tags: note.noteTags.map((nt) => nt.tag.name),
|
||||||
};
|
};
|
||||||
@@ -179,6 +184,7 @@ export class NotesService {
|
|||||||
content: data.content,
|
content: data.content,
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
taskId: data.taskId, // Ajouter le taskId
|
taskId: data.taskId, // Ajouter le taskId
|
||||||
|
folderId: data.folderId, // Ajouter le folderId
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
task: {
|
task: {
|
||||||
@@ -233,6 +239,7 @@ export class NotesService {
|
|||||||
return {
|
return {
|
||||||
...noteWithTags!,
|
...noteWithTags!,
|
||||||
taskId: noteWithTags!.taskId || undefined, // Convertir null en undefined
|
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
|
task: this.mapPrismaTaskToTask(noteWithTags!.task), // Mapper correctement l'objet Task
|
||||||
tags: noteWithTags!.noteTags.map((nt) => nt.tag.name),
|
tags: noteWithTags!.noteTags.map((nt) => nt.tag.name),
|
||||||
};
|
};
|
||||||
@@ -263,7 +270,8 @@ export class NotesService {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
taskId?: string;
|
taskId?: string | null;
|
||||||
|
folderId?: string | null;
|
||||||
noteTags?: {
|
noteTags?: {
|
||||||
deleteMany: Record<string, never>;
|
deleteMany: Record<string, never>;
|
||||||
create: Array<{
|
create: Array<{
|
||||||
@@ -287,7 +295,10 @@ export class NotesService {
|
|||||||
updateData.content = data.content;
|
updateData.content = data.content;
|
||||||
}
|
}
|
||||||
if (data.taskId !== undefined) {
|
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
|
// Gérer les tags si fournis
|
||||||
@@ -342,6 +353,7 @@ export class NotesService {
|
|||||||
return {
|
return {
|
||||||
...noteWithTags!,
|
...noteWithTags!,
|
||||||
taskId: noteWithTags!.taskId || undefined, // Convertir null en undefined
|
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
|
task: this.mapPrismaTaskToTask(noteWithTags!.task), // Mapper correctement l'objet Task
|
||||||
tags: noteWithTags!.noteTags.map((nt) => nt.tag.name),
|
tags: noteWithTags!.noteTags.map((nt) => nt.tag.name),
|
||||||
};
|
};
|
||||||
@@ -383,6 +395,7 @@ export class NotesService {
|
|||||||
return notes.map((note) => ({
|
return notes.map((note) => ({
|
||||||
...note,
|
...note,
|
||||||
taskId: note.taskId || undefined, // Convertir null en undefined
|
taskId: note.taskId || undefined, // Convertir null en undefined
|
||||||
|
folderId: note.folderId || undefined, // Convertir null en undefined
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user