feat(Notes): implement manual ordering for notes, add drag-and-drop functionality, and update related components for reordering

This commit is contained in:
Julien Froidefond
2026-01-06 14:51:12 +01:00
parent 38ccaf8785
commit 2354a353d1
7 changed files with 332 additions and 189 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable Note - Add order column
ALTER TABLE "Note" ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0;

View File

@@ -145,6 +145,7 @@ model Note {
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 folderId String? // Dossier contenant la note
order Int @default(0) // Ordre manuel de la note dans son dossier
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)

31
src/actions/notes.ts Normal file
View File

@@ -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',
};
}
}

View File

@@ -14,6 +14,7 @@ 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'; import { getFolders } from '@/actions/folders';
import { reorderNotes } from '@/actions/notes';
interface NotesPageClientProps { interface NotesPageClientProps {
initialNotes: Note[]; initialNotes: Note[];
@@ -115,6 +116,40 @@ function NotesPageContent({
[selectedNote, notes] [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 => { const getNoteTitle = (content: string): string => {
// Extract title from first line, removing markdown headers // Extract title from first line, removing markdown headers
const firstLine = content.split('\n')[0] || ''; const firstLine = content.split('\n')[0] || '';
@@ -378,6 +413,7 @@ function NotesPageContent({
onSelectNote={handleSelectNote} onSelectNote={handleSelectNote}
onCreateNote={handleCreateNote} onCreateNote={handleCreateNote}
onDeleteNote={handleDeleteNote} onDeleteNote={handleDeleteNote}
onReorderNotes={handleReorderNotes}
selectedNoteId={selectedNote?.id} selectedNoteId={selectedNote?.id}
isLoading={false} isLoading={false}
availableTags={availableTags} availableTags={availableTags}

View File

@@ -13,6 +13,7 @@ import {
ChevronDown, ChevronDown,
} from 'lucide-react'; } from 'lucide-react';
import { createFolder, updateFolder, deleteFolder } from '@/actions/folders'; import { createFolder, updateFolder, deleteFolder } from '@/actions/folders';
import { extractEmojis } from '@/lib/task-emoji';
interface FoldersSidebarProps { interface FoldersSidebarProps {
folders: Folder[]; folders: Folder[];
@@ -119,6 +120,27 @@ function FolderItem({
<FolderIcon className="w-4 h-4 flex-shrink-0" /> <FolderIcon className="w-4 h-4 flex-shrink-0" />
)} )}
{folder.tagId &&
(() => {
const tag = availableTags.find((t) => t.id === folder.tagId);
if (tag) {
const emojis = extractEmojis(tag.name);
if (emojis.length > 0) {
return (
<span
className="text-base mr-1"
style={{
fontFamily:
'system-ui, -apple-system, "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji"',
}}
>
{emojis[0]}
</span>
);
}
}
return null;
})()}
<span className="flex-1 text-sm truncate">{folder.name}</span> <span className="flex-1 text-sm truncate">{folder.name}</span>
{folder.notesCount !== undefined && folder.notesCount > 0 && ( {folder.notesCount !== undefined && folder.notesCount > 0 && (

View File

@@ -1,37 +1,191 @@
'use client'; 'use client';
import { useState, useMemo } from 'react'; import { useState } from 'react';
import { Note } from '@/services/notes'; 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 { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale'; import { fr } from 'date-fns/locale';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { Tag } from '@/lib/types'; 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 { interface NotesListProps {
notes: Note[]; notes: Note[];
onSelectNote: (note: Note) => void; onSelectNote: (note: Note) => void;
onCreateNote: () => void; onCreateNote: () => void;
onDeleteNote: (noteId: string) => void; onDeleteNote: (noteId: string) => void;
onReorderNotes: (noteOrders: Array<{ id: string; order: number }>) => void;
selectedNoteId?: string; selectedNoteId?: string;
isLoading?: boolean; isLoading?: boolean;
availableTags?: Tag[]; 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 (
<div
ref={setNodeRef}
style={style}
className={`group relative p-3 rounded-lg cursor-pointer transition-all ${
isSelected
? 'bg-[var(--primary)]/20 border-l-4 border-[var(--primary)]'
: 'hover:bg-[var(--card)]/50 border-l-4 border-transparent'
}`}
onClick={onSelect}
>
<div className="flex items-start gap-2">
{/* Drag handle */}
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing mt-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<GripVertical className="w-4 h-4 text-[var(--muted-foreground)]" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<h3 className="font-medium text-sm text-[var(--foreground)] truncate">
{getNoteTitle(note.content)}
</h3>
{showDeleteConfirm === note.id ? (
<div className="flex gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onDelete(note.id, e);
}}
className="p-1 rounded-md bg-[var(--destructive)] text-white text-xs"
>
Confirmer
</button>
<button
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(null);
}}
className="p-1 rounded-md bg-[var(--muted)] text-[var(--foreground)] text-xs"
>
Annuler
</button>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(note.id);
}}
className="opacity-0 group-hover:opacity-100 p-1 rounded-md hover:bg-[var(--destructive)]/20 text-[var(--destructive)] transition-opacity"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
<div className="flex items-center justify-between gap-2 text-xs text-[var(--muted-foreground)]">
<div className="flex items-center gap-2">
<Calendar className="w-3 h-3" />
<span>
{formatDistanceToNow(new Date(note.updatedAt), {
addSuffix: true,
locale: fr,
})}
</span>
</div>
{note.tags && note.tags.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{note.tags.map((tagName) => (
<span
key={tagName}
className="px-2 py-0.5 rounded-full text-xs bg-[var(--primary)]/10 text-[var(--primary)] whitespace-nowrap"
>
{tagName}
</span>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
export function NotesList({ export function NotesList({
notes, notes,
onSelectNote, onSelectNote,
onCreateNote, onCreateNote,
onDeleteNote, onDeleteNote,
onReorderNotes,
selectedNoteId, selectedNoteId,
isLoading = false, isLoading = false,
availableTags = [],
}: NotesListProps) { }: NotesListProps) {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>( const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(
null null
); );
const [groupByTags, setGroupByTags] = useState(true);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Filter notes based on search query // Filter notes based on search query
const filteredNotes = notes.filter( const filteredNotes = notes.filter(
@@ -40,46 +194,25 @@ export function NotesList({
note.content.toLowerCase().includes(searchQuery.toLowerCase()) note.content.toLowerCase().includes(searchQuery.toLowerCase())
); );
// Group notes by tags const handleDragEnd = (event: DragEndEvent) => {
const groupedNotes = useMemo(() => { const { active, over } = event;
if (!groupByTags) {
return { 'Toutes les notes': filteredNotes }; 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 const reorderedNotes = arrayMove(filteredNotes, oldIndex, newIndex);
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);
}
});
// Trier les groupes par nom de tag // Calculer les nouveaux ordres
const sortedGroups: { [key: string]: Note[] } = {}; const noteOrders = reorderedNotes.map((note, index) => ({
Object.keys(groups) id: note.id,
.sort() order: index,
.forEach((key) => { }));
sortedGroups[key] = groups[key];
});
return sortedGroups; onReorderNotes(noteOrders);
}, [filteredNotes, groupByTags]);
const handleDeleteClick = (noteId: string, e: React.MouseEvent) => {
e.stopPropagation();
setShowDeleteConfirm(noteId);
}; };
const handleDeleteConfirm = (noteId: string) => { const handleDeleteConfirm = (noteId: string) => {
@@ -87,16 +220,6 @@ export function NotesList({
setShowDeleteConfirm(null); 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 ( return (
<div className="flex flex-col h-full bg-[var(--card)]/40 backdrop-blur-md border-r border-[var(--border)]/60 relative before:absolute before:inset-0 before:bg-gradient-to-br before:from-[color-mix(in_srgb,var(--primary)_8%,transparent)] before:via-[color-mix(in_srgb,var(--primary)_4%,transparent)] before:to-transparent before:opacity-80 before:pointer-events-none"> <div className="flex flex-col h-full bg-[var(--card)]/40 backdrop-blur-md border-r border-[var(--border)]/60 relative before:absolute before:inset-0 before:bg-gradient-to-br before:from-[color-mix(in_srgb,var(--primary)_8%,transparent)] before:via-[color-mix(in_srgb,var(--primary)_4%,transparent)] before:to-transparent before:opacity-80 before:pointer-events-none">
{/* Header */} {/* Header */}
@@ -110,37 +233,16 @@ export function NotesList({
Nouvelle note Nouvelle note
</button> </button>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"> {/* Search */}
{/* Search */} <div className="relative">
<div className="relative flex-1"> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted-foreground)]" />
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted-foreground)]" /> <input
<input type="text"
type="text" placeholder="Rechercher une note"
placeholder="Rechercher une note ou un tag" value={searchQuery}
value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e) => 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"
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" />
/>
</div>
<button
onClick={() => setGroupByTags(!groupByTags)}
className={`flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 border border-[var(--border)]/50 bg-[var(--card)]/40 backdrop-blur-sm sm:w-auto ${
groupByTags
? 'ring-1 ring-[var(--primary)]/30 bg-[var(--primary)]/15 text-[var(--primary)]'
: 'text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--card)]/60'
}`}
title={groupByTags ? 'Vue par liste' : 'Vue par tags'}
>
{groupByTags ? (
<Tags className="w-3 h-3" />
) : (
<List className="w-3 h-3" />
)}
<span className="tracking-wide uppercase">
{groupByTags ? 'Tags' : 'Liste'}
</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -156,113 +258,30 @@ export function NotesList({
{searchQuery ? 'Aucune note trouvée' : 'Aucune note pour le moment'} {searchQuery ? 'Aucune note trouvée' : 'Aucune note pour le moment'}
</div> </div>
) : ( ) : (
<div className="p-2 space-y-4"> <DndContext
{Object.entries(groupedNotes).map(([groupName, groupNotes]) => ( sensors={sensors}
<div key={groupName}> collisionDetection={closestCenter}
{/* Group Header */} onDragEnd={handleDragEnd}
<div className="px-3 py-2 bg-[var(--card)]/30 backdrop-blur-sm border-b border-[var(--border)]/30 rounded-t-lg"> >
<h3 className="text-xs font-mono font-medium text-[var(--primary)] uppercase tracking-wider"> <div className="p-2 space-y-1">
{groupName} ({groupNotes.length}) <SortableContext
</h3> items={filteredNotes.map((n) => n.id)}
</div> strategy={verticalListSortingStrategy}
>
{/* Group Notes */} {filteredNotes.map((note) => (
<div className="space-y-1"> <SortableNoteItem
{groupNotes.map((note) => ( key={note.id}
<div note={note}
key={note.id} isSelected={selectedNoteId === note.id}
draggable onSelect={() => onSelectNote(note)}
onDragStart={(e) => { onDelete={handleDeleteConfirm}
e.dataTransfer.setData('noteId', note.id); showDeleteConfirm={showDeleteConfirm}
e.dataTransfer.effectAllowed = 'move'; setShowDeleteConfirm={setShowDeleteConfirm}
}} />
onClick={() => onSelectNote(note)} ))}
className={`group relative p-3 cursor-pointer transition-all duration-200 backdrop-blur-sm ${ </SortableContext>
selectedNoteId === note.id </div>
? 'bg-[var(--primary)]/20 border border-[var(--primary)]/30 shadow-lg shadow-[var(--primary)]/10' </DndContext>
: 'bg-[var(--card)]/30 hover:bg-[var(--card)]/50 border border-[var(--border)]/40 hover:border-[var(--border)]/60 hover:shadow-md'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-[var(--foreground)] truncate mb-2">
{getNoteTitle(note.content)}
</h3>
{/* Tags - seulement si pas groupé par tags */}
{!groupByTags &&
note.tags &&
note.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={note.tags}
availableTags={availableTags}
maxTags={2}
size="sm"
showColors={true}
showDot={false}
/>
</div>
)}
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
<Calendar className="w-3 h-3" />
<span suppressHydrationWarning>
{formatDistanceToNow(new Date(note.updatedAt), {
addSuffix: true,
locale: fr,
})}
</span>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handleDeleteClick(note.id, e)}
className="p-1 hover:bg-[var(--destructive)]/10 rounded text-[var(--destructive)] transition-colors"
title="Supprimer"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{/* Delete Confirmation */}
{showDeleteConfirm === note.id && (
<div
className="absolute inset-0 rounded-lg p-3 backdrop-blur-md z-10"
style={{
backgroundColor:
'color-mix(in srgb, var(--destructive) 15%, var(--card))',
border:
'1px solid color-mix(in srgb, var(--destructive) 40%, var(--border))',
}}
>
<div className="text-sm font-medium text-[var(--foreground)] mb-2 line-clamp-1">
Supprimer &quot;{getNoteTitle(note.content)}&quot; ?
</div>
<div className="flex gap-2">
<button
onClick={() => handleDeleteConfirm(note.id)}
className="px-2 py-1 bg-[var(--destructive)] text-[var(--primary-foreground)] rounded text-xs hover:bg-[var(--destructive)]/90 transition-colors"
>
Supprimer
</button>
<button
onClick={handleDeleteCancel}
className="px-2 py-1 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-xs hover:bg-[var(--card-hover)] transition-colors"
>
Annuler
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -123,7 +123,7 @@ export class NotesService {
}, },
}, },
}, },
orderBy: { updatedAt: 'desc' }, orderBy: [{ order: 'asc' }, { updatedAt: 'desc' }],
}); });
return notes.map((note) => ({ 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<void> {
// 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 * Récupère les statistiques des notes d'un utilisateur
*/ */