Files
Momento/Momento-main/momento/memento-note/components/masonry-grid.tsx
Sepehr Ramezani ed807d3b2a
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
Add safe database migration workflow and note history infrastructure.
This introduces guarded migrations with automatic backups, fixes note creation after DB reset, and wires snapshot/restore history across notes surfaces.
2026-04-28 17:14:26 +02:00

339 lines
11 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;
noteHistoryEnabled?: boolean;
onOpenHistory?: (note: Note) => void;
}
// ─────────────────────────────────────────────
// 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;
noteHistoryEnabled?: boolean;
onOpenHistory?: (note: Note) => void;
}
const SortableNoteItem = memo(function SortableNoteItem({
note,
onEdit,
onSizeChange,
onDragStartNote,
onDragEndNote,
isDragging,
isOverlay,
isTrashView,
noteHistoryEnabled,
onOpenHistory,
}: 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)}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</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;
noteHistoryEnabled?: boolean;
onOpenHistory?: (note: Note) => void;
}
const SortableGridSection = memo(function SortableGridSection({
notes,
onEdit,
onSizeChange,
draggedNoteId,
onDragStartNote,
onDragEndNote,
isTrashView,
noteHistoryEnabled,
onOpenHistory,
}: 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}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
))}
</div>
</SortableContext>
);
});
// ─────────────────────────────────────────────
// Main MasonryGrid component
// ─────────────────────────────────────────────
export function MasonryGrid({
notes,
onEdit,
onSizeChange,
isTrashView,
noteHistoryEnabled = false,
onOpenHistory,
}: 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}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</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}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</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)}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
) : null}
</DragOverlay>
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
</DndContext>
);
}