'use client' import { useState, useEffect, useRef, useCallback, memo, useMemo } from 'react'; import { Note } from '@/lib/types'; import { NoteCard } from './note-card'; import { NoteEditor } from './note-editor'; import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes'; import { useResizeObserver } from '@/hooks/use-resize-observer'; import { useNotebookDrag } from '@/context/notebook-drag-context'; import { useLanguage } from '@/lib/i18n'; import { DEFAULT_LAYOUT, calculateColumns, calculateItemWidth, isMobileViewport } from '@/config/masonry-layout'; import './masonry-grid.css'; // Force rebuild: Spacing update verification interface MasonryGridProps { notes: Note[]; onEdit?: (note: Note, readOnly?: boolean) => void; } interface MasonryItemProps { note: Note; onEdit: (note: Note, readOnly?: boolean) => void; onResize: () => void; onNoteSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void; onDragStart?: (noteId: string) => void; onDragEnd?: () => void; isDragging?: boolean; } const MasonryItem = function MasonryItem({ note, onEdit, onResize, onNoteSizeChange, onDragStart, onDragEnd, isDragging }: MasonryItemProps) { const resizeRef = useResizeObserver(onResize); useEffect(() => { onResize(); const timer = setTimeout(onResize, 300); return () => clearTimeout(timer); }, [note.size, onResize]); return (
onNoteSizeChange(note.id, newSize)} />
); }; export function MasonryGrid({ notes, onEdit }: MasonryGridProps) { const { t } = useLanguage(); const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null); const { startDrag, endDrag, draggedNoteId } = useNotebookDrag(); const [muuriReady, setMuuriReady] = useState(false); // Local state for notes with dynamic size updates // This allows size changes to propagate immediately without waiting for server const [localNotes, setLocalNotes] = useState(notes); // Sync localNotes when parent notes prop changes useEffect(() => { setLocalNotes(notes); }, [notes]); // Callback for when a note's size changes - update local state immediately const handleNoteSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => { setLocalNotes(prevNotes => prevNotes.map(n => n.id === noteId ? { ...n, size: newSize } : n) ); }, []); const handleEdit = useCallback((note: Note, readOnly?: boolean) => { if (onEdit) { onEdit(note, readOnly); } else { setEditingNote({ note, readOnly }); } }, [onEdit]); const pinnedGridRef = useRef(null); const othersGridRef = useRef(null); const pinnedMuuri = useRef(null); const othersMuuri = useRef(null); // Memoize filtered notes from localNotes (which includes dynamic size updates) const pinnedNotes = useMemo( () => localNotes.filter(n => n.isPinned), [localNotes] ); const othersNotes = useMemo( () => localNotes.filter(n => !n.isPinned), [localNotes] ); const handleDragEnd = useCallback(async (grid: any) => { if (!grid) return; const items = grid.getItems(); const ids = items .map((item: any) => item.getElement()?.getAttribute('data-id')) .filter((id: any): id is string => !!id); try { // Save order to database WITHOUT triggering a full page refresh // Muuri has already updated the visual layout await updateFullOrderWithoutRevalidation(ids); } catch (error) { console.error('Failed to persist order:', error); } }, []); const refreshLayout = useCallback(() => { requestAnimationFrame(() => { if (pinnedMuuri.current) { pinnedMuuri.current.refreshItems().layout(); } if (othersMuuri.current) { othersMuuri.current.refreshItems().layout(); } }); }, []); const applyItemDimensions = useCallback((grid: any, containerWidth: number) => { if (!grid) return; // Calculate columns and item width based on container width const columns = calculateColumns(containerWidth); const baseItemWidth = calculateItemWidth(containerWidth, columns); const items = grid.getItems(); items.forEach((item: any) => { const el = item.getElement(); if (el) { const size = el.getAttribute('data-size') || 'small'; let width = baseItemWidth; if (columns >= 2 && size === 'medium') { width = Math.min(baseItemWidth * 1.5, containerWidth); } else if (columns >= 2 && size === 'large') { width = Math.min(baseItemWidth * 2, containerWidth); } el.style.width = `${width}px`; } }); }, []); // Initialize Muuri grids once on mount and sync when needed useEffect(() => { let isMounted = true; let muuriInitialized = false; const initMuuri = async () => { // Prevent duplicate initialization if (muuriInitialized) return; muuriInitialized = true; // Import web-animations-js polyfill await import('web-animations-js'); // Dynamic import of Muuri to avoid SSR window error const MuuriClass = (await import('muuri')).default; if (!isMounted) return; // Detect if we are on a touch device (mobile behavior) const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; const isMobileWidth = window.innerWidth < 768; const isMobile = isTouchDevice || isMobileWidth; // Get container width for responsive calculation const containerWidth = window.innerWidth - 32; // Subtract padding const columns = calculateColumns(containerWidth); const itemWidth = calculateItemWidth(containerWidth, columns); const layoutOptions = { dragEnabled: true, // Use drag handle for mobile devices to allow smooth scrolling // On desktop, whole card is draggable (no handle needed) dragHandle: isMobile ? '.muuri-drag-handle' : undefined, dragContainer: document.body, dragStartPredicate: { distance: 10, delay: 0, }, dragPlaceholder: { enabled: true, createElement: (item: any) => { const el = item.getElement().cloneNode(true); // Styles are now handled purely by CSS (.muuri-item-placeholder) // to avoid inline style conflicts and "grayed out/tilted" look return el; }, }, dragAutoScroll: { targets: [window], speed: (item: any, target: any, intersection: any) => { return intersection * 30; // Faster auto-scroll for better UX }, threshold: 50, // Start auto-scroll earlier (50px from edge) smoothStop: true, // Smooth deceleration }, // LAYOUT OPTIONS - Configure masonry grid behavior // These options are critical for proper masonry layout with different item sizes layoutDuration: 300, layoutEasing: 'cubic-bezier(0.25, 1, 0.5, 1)', fillGaps: true, horizontal: false, alignRight: false, alignBottom: false, rounding: false, // CRITICAL: Enable true masonry layout for different item sizes layout: { fillGaps: true, horizontal: false, alignRight: false, alignBottom: false, rounding: false, }, }; // Initialize pinned grid if (pinnedGridRef.current && !pinnedMuuri.current) { pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions) .on('dragEnd', () => handleDragEnd(pinnedMuuri.current)); applyItemDimensions(pinnedMuuri.current, containerWidth); pinnedMuuri.current.refreshItems().layout(); } // Initialize others grid if (othersGridRef.current && !othersMuuri.current) { othersMuuri.current = new MuuriClass(othersGridRef.current, layoutOptions) .on('dragEnd', () => handleDragEnd(othersMuuri.current)); applyItemDimensions(othersMuuri.current, containerWidth); othersMuuri.current.refreshItems().layout(); } // Signal that Muuri is ready so sync/resize effects can run setMuuriReady(true); }; initMuuri(); return () => { isMounted = false; pinnedMuuri.current?.destroy(); othersMuuri.current?.destroy(); pinnedMuuri.current = null; othersMuuri.current = null; }; // Only run once on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Container ref for ResizeObserver const containerRef = useRef(null); // Synchronize items when notes change (e.g. searching, adding) useEffect(() => { if (!muuriReady) return; const syncGridItems = (grid: any, gridRef: React.RefObject, notesArray: Note[]) => { if (!grid || !gridRef.current) return; const containerWidth = containerRef.current?.getBoundingClientRect().width || window.innerWidth - 32; const columns = calculateColumns(containerWidth); const itemWidth = calculateItemWidth(containerWidth, columns); // Get current DOM elements and Muuri items const domElements = Array.from(gridRef.current.children) as HTMLElement[]; const muuriItems = grid.getItems(); // Map Muuri items to their elements for comparison const muuriElements = muuriItems.map((item: any) => item.getElement()); // Find new elements to add (in DOM but not in Muuri) const newElements = domElements.filter(el => !muuriElements.includes(el)); // Find elements to remove (in Muuri but not in DOM) const removedItems = muuriItems.filter((item: any) => !domElements.includes(item.getElement()) ); // Remove old items if (removedItems.length > 0) { grid.remove(removedItems, { layout: false }); } // Add new items with correct width based on size if (newElements.length > 0) { newElements.forEach(el => { const size = el.getAttribute('data-size') || 'small'; let width = itemWidth; if (columns >= 2 && size === 'medium') { width = Math.min(itemWidth * 1.5, containerWidth); } else if (columns >= 2 && size === 'large') { width = Math.min(itemWidth * 2, containerWidth); } el.style.width = `${width}px`; }); grid.add(newElements, { layout: false }); } // Update all item widths to ensure consistency (size-aware) domElements.forEach(el => { const size = el.getAttribute('data-size') || 'small'; let width = itemWidth; if (columns >= 2 && size === 'medium') { width = Math.min(itemWidth * 1.5, containerWidth); } else if (columns >= 2 && size === 'large') { width = Math.min(itemWidth * 2, containerWidth); } el.style.width = `${width}px`; }); // Refresh and layout grid.refreshItems().layout(); }; // Use requestAnimationFrame to ensure DOM is updated before syncing requestAnimationFrame(() => { syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes); syncGridItems(othersMuuri.current, othersGridRef, othersNotes); // CRITICAL: Force a second layout after CSS transitions (padding/height changes) complete // NoteCard has a 200ms transition. We wait 300ms to be safe. setTimeout(() => { if (pinnedMuuri.current) pinnedMuuri.current.refreshItems().layout(); if (othersMuuri.current) othersMuuri.current.refreshItems().layout(); }, 300); }); }, [pinnedNotes, othersNotes, muuriReady]); // Re-run when notes change or Muuri becomes ready // Handle container resize to update responsive layout useEffect(() => { if (!containerRef.current || (!pinnedMuuri.current && !othersMuuri.current)) return; let resizeTimeout: NodeJS.Timeout; const handleResize = (entries: ResizeObserverEntry[]) => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { // Get precise width from ResizeObserver const containerWidth = entries[0]?.contentRect.width || window.innerWidth - 32; const columns = calculateColumns(containerWidth); // Apply dimensions to both grids applyItemDimensions(pinnedMuuri.current, containerWidth); applyItemDimensions(othersMuuri.current, containerWidth); // Refresh layouts requestAnimationFrame(() => { pinnedMuuri.current?.refreshItems().layout(); othersMuuri.current?.refreshItems().layout(); }); }, 150); // Debounce }; const observer = new ResizeObserver(handleResize); observer.observe(containerRef.current); // Initial layout check if (containerRef.current) { handleResize([{ contentRect: containerRef.current.getBoundingClientRect() } as ResizeObserverEntry]); } return () => { clearTimeout(resizeTimeout); observer.disconnect(); }; }, [applyItemDimensions, muuriReady]); return (
{pinnedNotes.length > 0 && (

{t('notes.pinned')}

{pinnedNotes.map(note => ( ))}
)} {othersNotes.length > 0 && (
{pinnedNotes.length > 0 && (

{t('notes.others')}

)}
{othersNotes.map(note => ( ))}
)} {editingNote && ( setEditingNote(null)} /> )}
); }