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
|
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
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 { 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}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,38 +233,17 @@ 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 flex-1">
|
<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)]" />
|
<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 ou un tag"
|
placeholder="Rechercher une note"
|
||||||
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>
|
</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>
|
||||||
|
|
||||||
@@ -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) => (
|
|
||||||
<div
|
|
||||||
key={note.id}
|
key={note.id}
|
||||||
draggable
|
note={note}
|
||||||
onDragStart={(e) => {
|
isSelected={selectedNoteId === note.id}
|
||||||
e.dataTransfer.setData('noteId', note.id);
|
onSelect={() => onSelectNote(note)}
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
onDelete={handleDeleteConfirm}
|
||||||
}}
|
showDeleteConfirm={showDeleteConfirm}
|
||||||
onClick={() => onSelectNote(note)}
|
setShowDeleteConfirm={setShowDeleteConfirm}
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
|
</SortableContext>
|
||||||
</div>
|
</div>
|
||||||
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user