From 2354a353d1f74f65b981db63467906268531fbc1 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Tue, 6 Jan 2026 14:51:12 +0100 Subject: [PATCH] feat(Notes): implement manual ordering for notes, add drag-and-drop functionality, and update related components for reordering --- .../migration.sql | 3 + prisma/schema.prisma | 1 + src/actions/notes.ts | 31 ++ src/app/notes/NotesPageClient.tsx | 36 ++ src/components/notes/FoldersSidebar.tsx | 22 + src/components/notes/NotesList.tsx | 395 +++++++++--------- src/services/notes.ts | 33 +- 7 files changed, 332 insertions(+), 189 deletions(-) create mode 100644 prisma/migrations/20260106000000_add_note_order/migration.sql create mode 100644 src/actions/notes.ts diff --git a/prisma/migrations/20260106000000_add_note_order/migration.sql b/prisma/migrations/20260106000000_add_note_order/migration.sql new file mode 100644 index 0000000..b00ddf1 --- /dev/null +++ b/prisma/migrations/20260106000000_add_note_order/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable Note - Add order column +ALTER TABLE "Note" ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fed280b..92501ec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -145,6 +145,7 @@ model Note { userId String taskId String? // Tâche associée à la note folderId String? // Dossier contenant la note + order Int @default(0) // Ordre manuel de la note dans son dossier createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/actions/notes.ts b/src/actions/notes.ts new file mode 100644 index 0000000..75f9cda --- /dev/null +++ b/src/actions/notes.ts @@ -0,0 +1,31 @@ +'use server'; + +import { notesService } from '@/services/notes'; +import { revalidatePath } from 'next/cache'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; + +/** + * Réordonne les notes + */ +export async function reorderNotes( + noteOrders: Array<{ id: string; order: number }> +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + await notesService.reorderNotes(session.user.id, noteOrders); + + revalidatePath('/notes'); + return { success: true }; + } catch (error) { + console.error('Error reordering notes:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue', + }; + } +} diff --git a/src/app/notes/NotesPageClient.tsx b/src/app/notes/NotesPageClient.tsx index 59d5b98..85d3acd 100644 --- a/src/app/notes/NotesPageClient.tsx +++ b/src/app/notes/NotesPageClient.tsx @@ -14,6 +14,7 @@ import { TasksProvider, useTasksContext } from '@/contexts/TasksContext'; import { Tag } from '@/lib/types'; import { FileText, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'; import { getFolders } from '@/actions/folders'; +import { reorderNotes } from '@/actions/notes'; interface NotesPageClientProps { initialNotes: Note[]; @@ -115,6 +116,40 @@ function NotesPageContent({ [selectedNote, notes] ); + const reloadNotes = useCallback(async () => { + try { + const updatedNotes = await notesClient.getNotes(); + setNotes(updatedNotes); + } catch (err) { + console.error('Error reloading notes:', err); + } + }, []); + + const handleReorderNotes = useCallback( + async (noteOrders: Array<{ id: string; order: number }>) => { + try { + // Optimistic update + const notesMap = new Map(notes.map((n) => [n.id, n])); + const reorderedNotes = noteOrders + .map((no) => notesMap.get(no.id)) + .filter((n): n is Note => n !== undefined); + setNotes(reorderedNotes); + + // Server update + const result = await reorderNotes(noteOrders); + if (!result.success) { + throw new Error(result.error); + } + } catch (err) { + setError('Erreur lors du réordonnancement des notes'); + console.error('Error reordering notes:', err); + // Reload notes on error + reloadNotes(); + } + }, + [notes, reloadNotes] + ); + const getNoteTitle = (content: string): string => { // Extract title from first line, removing markdown headers const firstLine = content.split('\n')[0] || ''; @@ -378,6 +413,7 @@ function NotesPageContent({ onSelectNote={handleSelectNote} onCreateNote={handleCreateNote} onDeleteNote={handleDeleteNote} + onReorderNotes={handleReorderNotes} selectedNoteId={selectedNote?.id} isLoading={false} availableTags={availableTags} diff --git a/src/components/notes/FoldersSidebar.tsx b/src/components/notes/FoldersSidebar.tsx index 6c7ed29..2a822f0 100644 --- a/src/components/notes/FoldersSidebar.tsx +++ b/src/components/notes/FoldersSidebar.tsx @@ -13,6 +13,7 @@ import { ChevronDown, } from 'lucide-react'; import { createFolder, updateFolder, deleteFolder } from '@/actions/folders'; +import { extractEmojis } from '@/lib/task-emoji'; interface FoldersSidebarProps { folders: Folder[]; @@ -119,6 +120,27 @@ function FolderItem({ )} + {folder.tagId && + (() => { + const tag = availableTags.find((t) => t.id === folder.tagId); + if (tag) { + const emojis = extractEmojis(tag.name); + if (emojis.length > 0) { + return ( + + {emojis[0]} + + ); + } + } + return null; + })()} {folder.name} {folder.notesCount !== undefined && folder.notesCount > 0 && ( diff --git a/src/components/notes/NotesList.tsx b/src/components/notes/NotesList.tsx index cdb1ecb..ae933fc 100644 --- a/src/components/notes/NotesList.tsx +++ b/src/components/notes/NotesList.tsx @@ -1,37 +1,191 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import { Note } from '@/services/notes'; -import { Search, Plus, Calendar, Trash2, Tags, List } from 'lucide-react'; +import { Search, Plus, Calendar, Trash2, GripVertical } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { fr } from 'date-fns/locale'; -import { TagDisplay } from '@/components/ui/TagDisplay'; import { Tag } from '@/lib/types'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; interface NotesListProps { notes: Note[]; onSelectNote: (note: Note) => void; onCreateNote: () => void; onDeleteNote: (noteId: string) => void; + onReorderNotes: (noteOrders: Array<{ id: string; order: number }>) => void; selectedNoteId?: string; isLoading?: boolean; availableTags?: Tag[]; } +// Extraire le titre du contenu markdown +function getNoteTitle(content: string): string { + const firstLine = content.split('\n')[0] || ''; + const title = firstLine + .replace(/^#{1,6}\s+/, '') // Remove markdown headers + .replace(/\*\*(.*?)\*\*/g, '$1') // Remove bold + .replace(/\*(.*?)\*/g, '$1') // Remove italic + .replace(/`(.*?)`/g, '$1') // Remove inline code + .trim(); + return title || 'Note sans titre'; +} + +// Composant pour un item de note sortable +function SortableNoteItem({ + note, + isSelected, + onSelect, + onDelete, + showDeleteConfirm, + setShowDeleteConfirm, +}: { + note: Note; + isSelected: boolean; + onSelect: () => void; + onDelete: (noteId: string, e: React.MouseEvent) => void; + showDeleteConfirm: string | null; + setShowDeleteConfirm: (id: string | null) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: note.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
+ {/* Drag handle */} +
+ +
+ +
+
+

+ {getNoteTitle(note.content)} +

+ {showDeleteConfirm === note.id ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ + + {formatDistanceToNow(new Date(note.updatedAt), { + addSuffix: true, + locale: fr, + })} + +
+ {note.tags && note.tags.length > 0 && ( +
+ {note.tags.map((tagName) => ( + + {tagName} + + ))} +
+ )} +
+
+
+
+ ); +} + export function NotesList({ notes, onSelectNote, onCreateNote, onDeleteNote, + onReorderNotes, selectedNoteId, isLoading = false, - availableTags = [], }: NotesListProps) { const [searchQuery, setSearchQuery] = useState(''); const [showDeleteConfirm, setShowDeleteConfirm] = useState( null ); - const [groupByTags, setGroupByTags] = useState(true); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); // Filter notes based on search query const filteredNotes = notes.filter( @@ -40,46 +194,25 @@ export function NotesList({ note.content.toLowerCase().includes(searchQuery.toLowerCase()) ); - // Group notes by tags - const groupedNotes = useMemo(() => { - if (!groupByTags) { - return { 'Toutes les notes': filteredNotes }; + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; } - const groups: { [key: string]: Note[] } = {}; + const oldIndex = filteredNotes.findIndex((note) => note.id === active.id); + const newIndex = filteredNotes.findIndex((note) => note.id === over.id); - // Notes avec tags - filteredNotes.forEach((note) => { - if (note.tags && note.tags.length > 0) { - note.tags.forEach((tag) => { - if (!groups[tag]) { - groups[tag] = []; - } - groups[tag].push(note); - }); - } else { - // Notes sans tags - if (!groups['Sans tags']) { - groups['Sans tags'] = []; - } - groups['Sans tags'].push(note); - } - }); + const reorderedNotes = arrayMove(filteredNotes, oldIndex, newIndex); - // Trier les groupes par nom de tag - const sortedGroups: { [key: string]: Note[] } = {}; - Object.keys(groups) - .sort() - .forEach((key) => { - sortedGroups[key] = groups[key]; - }); + // Calculer les nouveaux ordres + const noteOrders = reorderedNotes.map((note, index) => ({ + id: note.id, + order: index, + })); - return sortedGroups; - }, [filteredNotes, groupByTags]); - - const handleDeleteClick = (noteId: string, e: React.MouseEvent) => { - e.stopPropagation(); - setShowDeleteConfirm(noteId); + onReorderNotes(noteOrders); }; const handleDeleteConfirm = (noteId: string) => { @@ -87,16 +220,6 @@ export function NotesList({ setShowDeleteConfirm(null); }; - const handleDeleteCancel = () => { - setShowDeleteConfirm(null); - }; - - const getNoteTitle = (content: string): string => { - // Extract title from first line, removing markdown headers - const firstLine = content.split('\n')[0] || ''; - return firstLine.replace(/^#+\s*/, '').trim() || 'Sans titre'; - }; - return (
{/* Header */} @@ -110,37 +233,16 @@ export function NotesList({ Nouvelle note -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-[var(--border)]/60 rounded-md bg-[var(--card)]/40 backdrop-blur-sm text-[var(--foreground)] placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 focus:bg-[var(--card)]/60 transition-all duration-200" - /> -
- - + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-[var(--border)]/60 rounded-md bg-[var(--card)]/40 backdrop-blur-sm text-[var(--foreground)] placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 focus:bg-[var(--card)]/60 transition-all duration-200" + />
@@ -156,113 +258,30 @@ export function NotesList({ {searchQuery ? 'Aucune note trouvée' : 'Aucune note pour le moment'} ) : ( -
- {Object.entries(groupedNotes).map(([groupName, groupNotes]) => ( -
- {/* Group Header */} -
-

- {groupName} ({groupNotes.length}) -

-
- - {/* Group Notes */} -
- {groupNotes.map((note) => ( -
{ - e.dataTransfer.setData('noteId', note.id); - e.dataTransfer.effectAllowed = 'move'; - }} - onClick={() => onSelectNote(note)} - className={`group relative p-3 cursor-pointer transition-all duration-200 backdrop-blur-sm ${ - selectedNoteId === note.id - ? 'bg-[var(--primary)]/20 border border-[var(--primary)]/30 shadow-lg shadow-[var(--primary)]/10' - : 'bg-[var(--card)]/30 hover:bg-[var(--card)]/50 border border-[var(--border)]/40 hover:border-[var(--border)]/60 hover:shadow-md' - }`} - > -
-
-

- {getNoteTitle(note.content)} -

- - {/* Tags - seulement si pas groupé par tags */} - {!groupByTags && - note.tags && - note.tags.length > 0 && ( -
- -
- )} - -
- - - {formatDistanceToNow(new Date(note.updatedAt), { - addSuffix: true, - locale: fr, - })} - -
-
- -
- -
-
- - {/* Delete Confirmation */} - {showDeleteConfirm === note.id && ( -
-
- Supprimer "{getNoteTitle(note.content)}" ? -
-
- - -
-
- )} -
- ))} -
-
- ))} -
+ +
+ n.id)} + strategy={verticalListSortingStrategy} + > + {filteredNotes.map((note) => ( + onSelectNote(note)} + onDelete={handleDeleteConfirm} + showDeleteConfirm={showDeleteConfirm} + setShowDeleteConfirm={setShowDeleteConfirm} + /> + ))} + +
+
)} diff --git a/src/services/notes.ts b/src/services/notes.ts index f9c118e..5f00b90 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -123,7 +123,7 @@ export class NotesService { }, }, }, - orderBy: { updatedAt: 'desc' }, + orderBy: [{ order: 'asc' }, { updatedAt: 'desc' }], }); return notes.map((note) => ({ @@ -399,6 +399,37 @@ export class NotesService { })); } + /** + * Réordonne les notes + */ + async reorderNotes( + userId: string, + noteOrders: Array<{ id: string; order: number }> + ): Promise { + // Vérifier que toutes les notes appartiennent à l'utilisateur + const noteIds = noteOrders.map((n) => n.id); + const notes = await prisma.note.findMany({ + where: { + id: { in: noteIds }, + userId, + }, + }); + + if (notes.length !== noteIds.length) { + throw new Error('Some notes not found or access denied'); + } + + // Mettre à jour l'ordre + await Promise.all( + noteOrders.map((noteOrder) => + prisma.note.update({ + where: { id: noteOrder.id }, + data: { order: noteOrder.order }, + }) + ) + ); + } + /** * Récupère les statistiques des notes d'un utilisateur */