feat(Notes): implement manual ordering for notes, add drag-and-drop functionality, and update related components for reordering
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable Note - Add order column
|
||||
ALTER TABLE "Note" ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
@@ -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)
|
||||
|
||||
31
src/actions/notes.ts
Normal file
31
src/actions/notes.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
<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>
|
||||
|
||||
{folder.notesCount !== undefined && folder.notesCount > 0 && (
|
||||
|
||||
@@ -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 (
|
||||
<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({
|
||||
notes,
|
||||
onSelectNote,
|
||||
onCreateNote,
|
||||
onDeleteNote,
|
||||
onReorderNotes,
|
||||
selectedNoteId,
|
||||
isLoading = false,
|
||||
availableTags = [],
|
||||
}: NotesListProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(
|
||||
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 (
|
||||
<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 */}
|
||||
@@ -110,37 +233,16 @@ export function NotesList({
|
||||
Nouvelle note
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
{/* Search */}
|
||||
<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)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher une note ou un tag"
|
||||
value={searchQuery}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted-foreground)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher une note"
|
||||
value={searchQuery}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,113 +258,30 @@ export function NotesList({
|
||||
{searchQuery ? 'Aucune note trouvée' : 'Aucune note pour le moment'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-4">
|
||||
{Object.entries(groupedNotes).map(([groupName, groupNotes]) => (
|
||||
<div key={groupName}>
|
||||
{/* Group Header */}
|
||||
<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">
|
||||
{groupName} ({groupNotes.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Group Notes */}
|
||||
<div className="space-y-1">
|
||||
{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
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<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 "{getNoteTitle(note.content)}" ?
|
||||
</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>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="p-2 space-y-1">
|
||||
<SortableContext
|
||||
items={filteredNotes.map((n) => n.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{filteredNotes.map((note) => (
|
||||
<SortableNoteItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
isSelected={selectedNoteId === note.id}
|
||||
onSelect={() => onSelectNote(note)}
|
||||
onDelete={handleDeleteConfirm}
|
||||
showDeleteConfirm={showDeleteConfirm}
|
||||
setShowDeleteConfirm={setShowDeleteConfirm}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user