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:
Julien Froidefond
2026-01-06 09:05:27 +01:00
parent 7ce8057c6b
commit 6c4c6992a9
15 changed files with 1314 additions and 21 deletions

132
src/actions/folders.ts Normal file
View 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',
};
}
}

View File

@@ -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);

View File

@@ -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<Note[]>(initialNotes);
const [folders, setFolders] = useState<Folder[]>(initialFolders);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [isNewNote, setIsNewNote] = useState(false);
const { tags: availableTags } = useTasksContext();
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-[var(--background)] flex flex-col">
<Header title="Notes" subtitle="Gestionnaire de notes markdown" />
@@ -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 */}
<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 ? (
<div className="h-full flex flex-col items-center py-4">
@@ -200,6 +332,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
</div>
) : (
<>
{/* Header */}
<div
className={`${glassDivider} flex items-center justify-between p-3`}
>
@@ -213,15 +346,31 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
<ChevronLeft className="w-3 h-3" />
</button>
</div>
<NotesList
notes={notes}
onSelectNote={handleSelectNote}
onCreateNote={handleCreateNote}
onDeleteNote={handleDeleteNote}
selectedNoteId={selectedNote?.id}
isLoading={false}
availableTags={availableTags}
/>
{/* Folders Section */}
<div className="flex-shrink-0 border-b border-[var(--border)]">
<FoldersSidebar
folders={folders}
selectedFolderId={selectedFolderId || undefined}
onSelectFolder={handleSelectFolder}
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>
@@ -250,6 +399,47 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
<h2 className="text-lg font-semibold text-[var(--foreground)] truncate">
{getNoteTitle(selectedNote.content)}
</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 className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
{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 (
<TasksProvider initialTasks={[]} initialTags={initialTags}>
<NotesPageContent initialNotes={initialNotes} />
<NotesPageContent
initialNotes={initialNotes}
initialFolders={initialFolders}
/>
</TasksProvider>
);
}

View File

@@ -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 (
<NotesPageClient initialNotes={initialNotes} initialTags={initialTags} />
<NotesPageClient
initialNotes={initialNotes}
initialTags={initialTags}
initialFolders={initialFolders}
/>
);
}

View 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();

View File

@@ -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<Note> {
console.log(
'[notesClient.updateNote] Updating note:',
id,
'with data:',
data
);
const response = await this.put<NoteResponse>(`/${id}`, data);
console.log('[notesClient.updateNote] Response:', response);
return response.note;
}

View 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>
);
}

View File

@@ -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({
/>
</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>
)}

View File

@@ -171,6 +171,11 @@ export function NotesList({
{groupNotes.map((note) => (
<div
key={note.id}
draggable
onDragStart={(e) => {
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

349
src/services/folders.ts Normal file
View 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();

View File

@@ -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<string, never>;
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
}));
}