312 lines
9.8 KiB
TypeScript
312 lines
9.8 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, memo, useMemo, useRef } from 'react';
|
|
import {
|
|
DndContext,
|
|
DragEndEvent,
|
|
DragOverlay,
|
|
DragStartEvent,
|
|
PointerSensor,
|
|
TouchSensor,
|
|
closestCenter,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
SortableContext,
|
|
arrayMove,
|
|
rectSortingStrategy,
|
|
useSortable,
|
|
} from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import { Note } from '@/lib/types';
|
|
import { NoteCard } from './note-card';
|
|
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
|
import { useNotebookDrag } from '@/context/notebook-drag-context';
|
|
import { useLanguage } from '@/lib/i18n';
|
|
import { useCardSizeMode } from '@/hooks/use-card-size-mode';
|
|
import dynamic from 'next/dynamic';
|
|
import './masonry-grid.css';
|
|
|
|
// Lazy-load NoteEditor — uniquement chargé au clic
|
|
const NoteEditor = dynamic(
|
|
() => import('./note-editor').then(m => ({ default: m.NoteEditor })),
|
|
{ ssr: false }
|
|
);
|
|
|
|
interface MasonryGridProps {
|
|
notes: Note[];
|
|
onEdit?: (note: Note, readOnly?: boolean) => void;
|
|
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
|
|
isTrashView?: boolean;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// Sortable Note Item
|
|
// ─────────────────────────────────────────────
|
|
interface SortableNoteProps {
|
|
note: Note;
|
|
onEdit: (note: Note, readOnly?: boolean) => void;
|
|
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
|
onDragStartNote?: (noteId: string) => void;
|
|
onDragEndNote?: () => void;
|
|
isDragging?: boolean;
|
|
isOverlay?: boolean;
|
|
isTrashView?: boolean;
|
|
}
|
|
|
|
const SortableNoteItem = memo(function SortableNoteItem({
|
|
note,
|
|
onEdit,
|
|
onSizeChange,
|
|
onDragStartNote,
|
|
onDragEndNote,
|
|
isDragging,
|
|
isOverlay,
|
|
isTrashView,
|
|
}: SortableNoteProps) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging: isSortableDragging,
|
|
} = useSortable({ id: note.id });
|
|
|
|
const style: React.CSSProperties = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isSortableDragging && !isOverlay ? 0.3 : 1,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
{...attributes}
|
|
{...listeners}
|
|
className="masonry-sortable-item"
|
|
data-id={note.id}
|
|
data-size={note.size}
|
|
>
|
|
<NoteCard
|
|
note={note}
|
|
onEdit={onEdit}
|
|
onDragStart={onDragStartNote}
|
|
onDragEnd={onDragEndNote}
|
|
isDragging={isDragging}
|
|
isTrashView={isTrashView}
|
|
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
|
|
/>
|
|
</div>
|
|
);
|
|
})
|
|
|
|
// ─────────────────────────────────────────────
|
|
// Sortable Grid Section (pinned or others)
|
|
// ─────────────────────────────────────────────
|
|
interface SortableGridSectionProps {
|
|
notes: Note[];
|
|
onEdit: (note: Note, readOnly?: boolean) => void;
|
|
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
|
draggedNoteId: string | null;
|
|
onDragStartNote: (noteId: string) => void;
|
|
onDragEndNote: () => void;
|
|
isTrashView?: boolean;
|
|
}
|
|
|
|
const SortableGridSection = memo(function SortableGridSection({
|
|
notes,
|
|
onEdit,
|
|
onSizeChange,
|
|
draggedNoteId,
|
|
onDragStartNote,
|
|
onDragEndNote,
|
|
isTrashView,
|
|
}: SortableGridSectionProps) {
|
|
const ids = useMemo(() => notes.map(n => n.id), [notes]);
|
|
|
|
return (
|
|
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
|
<div className="masonry-css-grid">
|
|
{notes.map(note => (
|
|
<SortableNoteItem
|
|
key={note.id}
|
|
note={note}
|
|
onEdit={onEdit}
|
|
onSizeChange={onSizeChange}
|
|
onDragStartNote={onDragStartNote}
|
|
onDragEndNote={onDragEndNote}
|
|
isDragging={draggedNoteId === note.id}
|
|
isTrashView={isTrashView}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────
|
|
// Main MasonryGrid component
|
|
// ─────────────────────────────────────────────
|
|
export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: MasonryGridProps) {
|
|
const { t } = useLanguage();
|
|
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
|
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
|
const cardSizeMode = useCardSizeMode();
|
|
const isUniformMode = cardSizeMode === 'uniform';
|
|
|
|
// Local notes state for optimistic size/order updates
|
|
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
|
|
|
|
useEffect(() => {
|
|
setLocalNotes(prev => {
|
|
const prevIds = prev.map(n => n.id).join(',')
|
|
const incomingIds = notes.map(n => n.id).join(',')
|
|
if (prevIds === incomingIds) {
|
|
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
|
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
|
}
|
|
// Notes added/removed: full sync but preserve local sizes
|
|
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
|
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
|
})
|
|
}, [notes]);
|
|
|
|
const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]);
|
|
const othersNotes = useMemo(() => localNotes.filter(n => !n.isPinned), [localNotes]);
|
|
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const activeNote = useMemo(
|
|
() => localNotes.find(n => n.id === activeId) ?? null,
|
|
[localNotes, activeId]
|
|
);
|
|
|
|
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
|
|
if (onEdit) {
|
|
onEdit(note, readOnly);
|
|
} else {
|
|
setEditingNote({ note, readOnly });
|
|
}
|
|
}, [onEdit]);
|
|
|
|
const handleSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
|
|
setLocalNotes(prev => prev.map(n => n.id === noteId ? { ...n, size: newSize } : n));
|
|
onSizeChange?.(noteId, newSize);
|
|
}, [onSizeChange]);
|
|
|
|
// @dnd-kit sensors — pointer (desktop) + touch (mobile)
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 8 }, // Évite les activations accidentelles
|
|
}),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: { delay: 200, tolerance: 8 }, // Long-press sur mobile
|
|
})
|
|
);
|
|
|
|
const localNotesRef = useRef<Note[]>(localNotes)
|
|
useEffect(() => {
|
|
localNotesRef.current = localNotes
|
|
}, [localNotes])
|
|
|
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
setActiveId(event.active.id as string);
|
|
startDrag(event.active.id as string);
|
|
}, [startDrag]);
|
|
|
|
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
setActiveId(null);
|
|
endDrag();
|
|
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const reordered = arrayMove(
|
|
localNotesRef.current,
|
|
localNotesRef.current.findIndex(n => n.id === active.id),
|
|
localNotesRef.current.findIndex(n => n.id === over.id),
|
|
);
|
|
|
|
if (reordered.length === 0) return;
|
|
|
|
setLocalNotes(reordered);
|
|
// Persist order outside of setState to avoid "setState in render" warning
|
|
const ids = reordered.map(n => n.id);
|
|
updateFullOrderWithoutRevalidation(ids).catch(err => {
|
|
console.error('Failed to persist order:', err);
|
|
});
|
|
}, [endDrag]);
|
|
|
|
return (
|
|
<DndContext
|
|
id="masonry-dnd"
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="masonry-container" data-card-size-mode={cardSizeMode}>
|
|
{pinnedNotes.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
|
{t('notes.pinned')}
|
|
</h2>
|
|
<SortableGridSection
|
|
notes={pinnedNotes}
|
|
onEdit={handleEdit}
|
|
onSizeChange={handleSizeChange}
|
|
draggedNoteId={draggedNoteId}
|
|
onDragStartNote={startDrag}
|
|
onDragEndNote={endDrag}
|
|
isTrashView={isTrashView}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{othersNotes.length > 0 && (
|
|
<div>
|
|
{pinnedNotes.length > 0 && (
|
|
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
|
{t('notes.others')}
|
|
</h2>
|
|
)}
|
|
<SortableGridSection
|
|
notes={othersNotes}
|
|
onEdit={handleEdit}
|
|
onSizeChange={handleSizeChange}
|
|
draggedNoteId={draggedNoteId}
|
|
onDragStartNote={startDrag}
|
|
onDragEndNote={endDrag}
|
|
isTrashView={isTrashView}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* DragOverlay — montre une copie flottante pendant le drag */}
|
|
<DragOverlay>
|
|
{activeNote ? (
|
|
<div className="masonry-sortable-item masonry-drag-overlay" data-size={activeNote.size}>
|
|
<NoteCard
|
|
note={activeNote}
|
|
onEdit={handleEdit}
|
|
isDragging={true}
|
|
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</DragOverlay>
|
|
|
|
{editingNote && (
|
|
<NoteEditor
|
|
note={editingNote.note}
|
|
readOnly={editingNote.readOnly}
|
|
onClose={() => setEditingNote(null)}
|
|
/>
|
|
)}
|
|
</DndContext>
|
|
);
|
|
}
|