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
-
@@ -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
*/