- Unified localStorage key to 'theme-preference' across all components
- Fixed header.tsx using wrong localStorage key ('theme' instead of 'theme-preference')
- Added localStorage hybrid persistence for instant theme changes
- Removed router.refresh() which was causing stale data revert
- Replaced Blue theme with Sepia
- Consolidated auth() calls to prevent race conditions
- Updated UserSettingsData types to include all themes
546 lines
18 KiB
TypeScript
546 lines
18 KiB
TypeScript
'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';
|
|
|
|
interface MasonryGridProps {
|
|
notes: Note[];
|
|
onEdit?: (note: Note, readOnly?: boolean) => void;
|
|
}
|
|
|
|
interface MasonryItemProps {
|
|
note: Note;
|
|
onEdit: (note: Note, readOnly?: boolean) => void;
|
|
onResize: () => void;
|
|
onDragStart?: (noteId: string) => void;
|
|
onDragEnd?: () => void;
|
|
}
|
|
|
|
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragStart, onDragEnd }: MasonryItemProps) {
|
|
const resizeRef = useResizeObserver(onResize);
|
|
|
|
return (
|
|
<div
|
|
className="masonry-item absolute py-1"
|
|
data-id={note.id}
|
|
data-size={note.size}
|
|
data-draggable="true"
|
|
ref={resizeRef as any}
|
|
>
|
|
<div className="masonry-item-content relative">
|
|
<NoteCard
|
|
note={note}
|
|
onEdit={onEdit}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}, (prev, next) => {
|
|
// Custom comparison to avoid re-render on function prop changes if note data is same
|
|
return prev.note.id === next.note.id; // Removed isDragging comparison
|
|
});
|
|
|
|
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 lastDragEndTime = useRef<number>(0);
|
|
|
|
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
|
|
// Prevent opening note if it was just dragged (within 200ms)
|
|
if (Date.now() - lastDragEndTime.current < 200) {
|
|
return;
|
|
}
|
|
|
|
if (onEdit) {
|
|
onEdit(note, readOnly);
|
|
} else {
|
|
setEditingNote({ note, readOnly });
|
|
}
|
|
}, [onEdit]);
|
|
|
|
const pinnedGridRef = useRef<HTMLDivElement>(null);
|
|
const othersGridRef = useRef<HTMLDivElement>(null);
|
|
const pinnedMuuri = useRef<any>(null);
|
|
const othersMuuri = useRef<any>(null);
|
|
|
|
// Memoize filtered notes (order comes from array)
|
|
const pinnedNotes = useMemo(
|
|
() => notes.filter(n => n.isPinned),
|
|
[notes]
|
|
);
|
|
const othersNotes = useMemo(
|
|
() => notes.filter(n => !n.isPinned),
|
|
[notes]
|
|
);
|
|
|
|
const handleDragEnd = useCallback(async (grid: any) => {
|
|
// Record drag end time to prevent accidental clicks
|
|
lastDragEndTime.current = Date.now();
|
|
|
|
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 layoutTimeoutRef = useRef<NodeJS.Timeout>();
|
|
|
|
const refreshLayout = useCallback((_?: any) => {
|
|
if (layoutTimeoutRef.current) {
|
|
clearTimeout(layoutTimeoutRef.current);
|
|
}
|
|
|
|
layoutTimeoutRef.current = setTimeout(() => {
|
|
requestAnimationFrame(() => {
|
|
if (pinnedMuuri.current) {
|
|
pinnedMuuri.current.refreshItems().layout();
|
|
}
|
|
if (othersMuuri.current) {
|
|
othersMuuri.current.refreshItems().layout();
|
|
}
|
|
});
|
|
}, 100); // 100ms debounce
|
|
}, []);
|
|
|
|
// Ref for container to use with ResizeObserver
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Centralized function to apply item dimensions based on container width
|
|
const applyItemDimensions = useCallback((grid: any, containerWidth: number) => {
|
|
if (!grid) return;
|
|
|
|
const columns = calculateColumns(containerWidth);
|
|
const itemWidth = calculateItemWidth(containerWidth, columns);
|
|
|
|
const items = grid.getItems();
|
|
items.forEach((item: any) => {
|
|
const el = item.getElement();
|
|
if (el) {
|
|
el.style.width = `${itemWidth}px`;
|
|
// Height is auto - determined by content (Google Keep style)
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
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);
|
|
|
|
console.log(`[Masonry] Container width: ${containerWidth}px, Columns: ${columns}, Item width: ${itemWidth}px`);
|
|
|
|
// Calculate item dimensions based on note size
|
|
const getItemDimensions = (note: Note) => {
|
|
const baseWidth = itemWidth;
|
|
let baseHeight = 200; // Default medium height
|
|
|
|
switch (note.size) {
|
|
case 'small':
|
|
baseHeight = 150;
|
|
break;
|
|
case 'medium':
|
|
baseHeight = 200;
|
|
break;
|
|
case 'large':
|
|
baseHeight = 300;
|
|
break;
|
|
default:
|
|
baseHeight = 200;
|
|
}
|
|
|
|
return { width: baseWidth, height: baseHeight };
|
|
};
|
|
|
|
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, // REMOVED: Keep item in grid to prevent React conflict
|
|
dragStartPredicate: {
|
|
distance: 10,
|
|
delay: 0,
|
|
},
|
|
dragPlaceholder: {
|
|
enabled: true,
|
|
createElement: (item: any) => {
|
|
const el = item.getElement().cloneNode(true);
|
|
el.style.opacity = '0.4';
|
|
el.style.transform = 'scale(1.05)';
|
|
el.style.boxShadow = '0 20px 40px rgba(0, 0, 0, 0.3)';
|
|
el.classList.add('muuri-item-placeholder');
|
|
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
|
|
},
|
|
// IMPROVED: Configuration for drag release handling
|
|
dragRelease: {
|
|
duration: 300,
|
|
easing: 'cubic-bezier(0.25, 1, 0.5, 1)',
|
|
useDragContainer: false, // REMOVED: Keep item in grid
|
|
},
|
|
dragCssProps: {
|
|
touchAction: 'none',
|
|
userSelect: 'none',
|
|
userDrag: 'none',
|
|
tapHighlightColor: 'rgba(0, 0, 0, 0)',
|
|
touchCallout: 'none',
|
|
contentZooming: 'none',
|
|
},
|
|
// CRITICAL: Grid layout configuration for fixed width items
|
|
layoutDuration: 30, // Much faster layout for responsiveness
|
|
layoutEasing: 'ease-out',
|
|
// Use grid layout for better control over item placement
|
|
layout: {
|
|
// Enable true masonry layout - items can be different heights
|
|
fillGaps: true,
|
|
horizontal: false,
|
|
alignRight: false,
|
|
alignBottom: false,
|
|
rounding: false,
|
|
// Set fixed width and let height be determined by content
|
|
// This creates a Google Keep-like masonry layout
|
|
itemPositioning: {
|
|
onLayout: true,
|
|
onResize: true,
|
|
onInit: true,
|
|
},
|
|
},
|
|
dragSort: true,
|
|
dragSortInterval: 50,
|
|
// Enable drag and drop with proper drag handling
|
|
dragSortHeuristics: {
|
|
sortInterval: 0, // Zero interval for immediate sorting
|
|
minDragDistance: 5,
|
|
minBounceBackAngle: 1,
|
|
},
|
|
// Grid configuration for responsive columns
|
|
visibleStyles: {
|
|
opacity: '1',
|
|
transform: 'scale(1)',
|
|
},
|
|
hiddenStyles: {
|
|
opacity: '0',
|
|
transform: 'scale(0.5)',
|
|
},
|
|
};
|
|
|
|
// Initialize pinned grid
|
|
if (pinnedGridRef.current && !pinnedMuuri.current) {
|
|
// Set container width explicitly
|
|
pinnedGridRef.current.style.width = '100%';
|
|
pinnedGridRef.current.style.position = 'relative';
|
|
|
|
// Get all items in the pinned grid and set their dimensions
|
|
const pinnedItems = Array.from(pinnedGridRef.current.children);
|
|
pinnedItems.forEach((item) => {
|
|
const noteId = item.getAttribute('data-id');
|
|
const note = pinnedNotes.find(n => n.id === noteId);
|
|
if (note) {
|
|
const dims = getItemDimensions(note);
|
|
(item as HTMLElement).style.width = `${dims.width}px`;
|
|
// Don't set height - let content determine it like Google Keep
|
|
}
|
|
});
|
|
|
|
pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions)
|
|
.on('dragEnd', () => handleDragEnd(pinnedMuuri.current))
|
|
.on('dragStart', () => {
|
|
// Optional: visual feedback or state update
|
|
});
|
|
|
|
// Initial layout
|
|
requestAnimationFrame(() => {
|
|
pinnedMuuri.current?.refreshItems().layout();
|
|
});
|
|
}
|
|
|
|
// Initialize others grid
|
|
if (othersGridRef.current && !othersMuuri.current) {
|
|
// Set container width explicitly
|
|
othersGridRef.current.style.width = '100%';
|
|
othersGridRef.current.style.position = 'relative';
|
|
|
|
// Get all items in the others grid and set their dimensions
|
|
const othersItems = Array.from(othersGridRef.current.children);
|
|
othersItems.forEach((item) => {
|
|
const noteId = item.getAttribute('data-id');
|
|
const note = othersNotes.find(n => n.id === noteId);
|
|
if (note) {
|
|
const dims = getItemDimensions(note);
|
|
(item as HTMLElement).style.width = `${dims.width}px`;
|
|
// Don't set height - let content determine it like Google Keep
|
|
}
|
|
});
|
|
|
|
othersMuuri.current = new MuuriClass(othersGridRef.current, layoutOptions)
|
|
.on('dragEnd', () => handleDragEnd(othersMuuri.current));
|
|
|
|
// Initial layout
|
|
requestAnimationFrame(() => {
|
|
othersMuuri.current?.refreshItems().layout();
|
|
});
|
|
}
|
|
};
|
|
|
|
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
|
|
}, []);
|
|
|
|
// Synchronize items when notes change (e.g. searching, adding, removing)
|
|
useEffect(() => {
|
|
const syncGridItems = (
|
|
grid: any,
|
|
gridRef: React.RefObject<HTMLDivElement | null>,
|
|
notesArray: Note[]
|
|
) => {
|
|
if (!grid || !gridRef.current) return;
|
|
|
|
// Get container width for dimension calculation
|
|
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();
|
|
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 removed items (in Muuri but not in DOM)
|
|
const removedItems = muuriItems.filter((item: any) =>
|
|
!domElements.includes(item.getElement())
|
|
);
|
|
|
|
// Remove old items from Muuri
|
|
if (removedItems.length > 0) {
|
|
console.log(`[Masonry Sync] Removing ${removedItems.length} items`);
|
|
grid.remove(removedItems, { layout: false });
|
|
}
|
|
|
|
// Add new items to Muuri with correct width
|
|
if (newElements.length > 0) {
|
|
console.log(`[Masonry Sync] Adding ${newElements.length} new items`);
|
|
newElements.forEach(el => {
|
|
el.style.width = `${itemWidth}px`;
|
|
});
|
|
grid.add(newElements, { layout: false });
|
|
}
|
|
|
|
// Update all existing item widths
|
|
domElements.forEach(el => {
|
|
el.style.width = `${itemWidth}px`;
|
|
});
|
|
|
|
// Refresh and layout - CRITICAL: Always refresh items to catch content size changes
|
|
// Use requestAnimationFrame to ensure DOM has painted
|
|
requestAnimationFrame(() => {
|
|
grid.refreshItems().layout();
|
|
});
|
|
};
|
|
|
|
// Use setTimeout to ensure React has finished rendering DOM elements
|
|
const timeoutId = setTimeout(() => {
|
|
syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes);
|
|
syncGridItems(othersMuuri.current, othersGridRef, othersNotes);
|
|
}, 50); // Increased timeout slightly to ensure DOM stability
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}, [pinnedNotes, othersNotes]);
|
|
|
|
// Handle container resize with ResizeObserver for responsive layout
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
let resizeTimeout: NodeJS.Timeout;
|
|
|
|
const handleResize = (entries: ResizeObserverEntry[]) => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => {
|
|
const containerWidth = entries[0]?.contentRect.width || window.innerWidth - 32;
|
|
const columns = calculateColumns(containerWidth);
|
|
|
|
console.log(`[Masonry Resize] Width: ${containerWidth}px, Columns: ${columns}`);
|
|
|
|
// Apply dimensions to both grids using centralized function
|
|
applyItemDimensions(pinnedMuuri.current, containerWidth);
|
|
applyItemDimensions(othersMuuri.current, containerWidth);
|
|
|
|
// Refresh both grids with new layout
|
|
requestAnimationFrame(() => {
|
|
if (pinnedMuuri.current) {
|
|
pinnedMuuri.current.refreshItems().layout();
|
|
}
|
|
if (othersMuuri.current) {
|
|
othersMuuri.current.refreshItems().layout();
|
|
}
|
|
});
|
|
}, 150);
|
|
};
|
|
|
|
const observer = new ResizeObserver(handleResize);
|
|
observer.observe(containerRef.current);
|
|
|
|
// Initial layout calculation
|
|
if (containerRef.current) {
|
|
const initialWidth = containerRef.current.getBoundingClientRect().width || window.innerWidth - 32;
|
|
applyItemDimensions(pinnedMuuri.current, initialWidth);
|
|
applyItemDimensions(othersMuuri.current, initialWidth);
|
|
requestAnimationFrame(() => {
|
|
pinnedMuuri.current?.refreshItems().layout();
|
|
othersMuuri.current?.refreshItems().layout();
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
clearTimeout(resizeTimeout);
|
|
observer.disconnect();
|
|
};
|
|
}, [applyItemDimensions]);
|
|
|
|
return (
|
|
<div ref={containerRef} className="masonry-container">
|
|
{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>
|
|
<div ref={pinnedGridRef} className="relative min-h-[100px]">
|
|
{pinnedNotes.map(note => (
|
|
<MasonryItem
|
|
key={note.id}
|
|
note={note}
|
|
onEdit={handleEdit}
|
|
onResize={refreshLayout}
|
|
onDragStart={startDrag}
|
|
onDragEnd={endDrag}
|
|
/>
|
|
))}
|
|
</div>
|
|
</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>
|
|
)}
|
|
<div ref={othersGridRef} className="relative min-h-[100px]">
|
|
{othersNotes.map(note => (
|
|
<MasonryItem
|
|
key={note.id}
|
|
note={note}
|
|
onEdit={handleEdit}
|
|
onResize={refreshLayout}
|
|
onDragStart={startDrag}
|
|
onDragEnd={endDrag}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{editingNote && (
|
|
<NoteEditor
|
|
note={editingNote.note}
|
|
readOnly={editingNote.readOnly}
|
|
onClose={() => setEditingNote(null)}
|
|
/>
|
|
)}
|
|
|
|
<style jsx global>{`
|
|
.masonry-container {
|
|
width: 100%;
|
|
}
|
|
|
|
.masonry-item {
|
|
display: block;
|
|
position: absolute;
|
|
z-index: 1;
|
|
box-sizing: border-box;
|
|
}
|
|
.masonry-item.muuri-item-dragging {
|
|
z-index: 3;
|
|
opacity: 0.8;
|
|
}
|
|
.masonry-item.muuri-item-releasing {
|
|
z-index: 2;
|
|
}
|
|
.masonry-item.muuri-item-hidden {
|
|
z-index: 0;
|
|
}
|
|
.masonry-item-content {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
/* Ensure proper box-sizing for all elements in the grid */
|
|
.masonry-item *,
|
|
.masonry-item-content * {
|
|
box-sizing: border-box;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|