feat: rename keep-notes to memento-note, migrate to PostgreSQL, fix MCP bugs
- Rename directory keep-notes -> memento-note with all code references - Prisma: SQLite -> PostgreSQL (both app and MCP server schemas) - Sync MCP schema with main app (add missing fields, relations, indexes) - Delete 17 SQLite migrations (clean slate for PostgreSQL) - Remove SQLite dependencies (@libsql/client, better-sqlite3, etc.) - Fix MCP server: hardcoded Windows DB paths -> DATABASE_URL env var - Fix MCP server: .dockerignore excluded index-sse.js (SSE mode broken) - MCP Dockerfile: node:20 -> node:22 - Docker Compose: add postgres service, remove SQLite volume - Generate favicon.ico, icon-192.png, icon-512.png, apple-icon.png - Update layout.tsx icons and manifest.json for PNG icons - Update all .env files for PostgreSQL - Rewrite README.md with updated sections - Remove mcp-server/node_modules and prisma/client-generated from git tracking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
437
memento-note/components/masonry-grid.tsx
Normal file
437
memento-note/components/masonry-grid.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
'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 (
|
||||
<div
|
||||
className="masonry-item absolute py-1"
|
||||
data-id={note.id}
|
||||
data-size={note.size}
|
||||
data-draggable="true"
|
||||
>
|
||||
<div className="masonry-item-content relative" ref={resizeRef as any}>
|
||||
<NoteCard
|
||||
note={note}
|
||||
onEdit={onEdit}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
isDragging={isDragging}
|
||||
onResize={onResize}
|
||||
onSizeChange={(newSize) => onNoteSizeChange(note.id, newSize)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<Note[]>(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<HTMLDivElement>(null);
|
||||
const othersGridRef = useRef<HTMLDivElement>(null);
|
||||
const pinnedMuuri = useRef<any>(null);
|
||||
const othersMuuri = useRef<any>(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<HTMLDivElement>(null);
|
||||
|
||||
// Synchronize items when notes change (e.g. searching, adding)
|
||||
useEffect(() => {
|
||||
if (!muuriReady) return;
|
||||
const syncGridItems = (grid: any, gridRef: React.RefObject<HTMLDivElement | null>, 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 (
|
||||
<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}
|
||||
onNoteSizeChange={handleNoteSizeChange}
|
||||
onDragStart={startDrag}
|
||||
onDragEnd={endDrag}
|
||||
isDragging={draggedNoteId === note.id}
|
||||
/>
|
||||
))}
|
||||
</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}
|
||||
onNoteSizeChange={handleNoteSizeChange}
|
||||
onDragStart={startDrag}
|
||||
onDragEnd={endDrag}
|
||||
isDragging={draggedNoteId === note.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingNote && (
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={() => setEditingNote(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user