fix: unify theme system - fix theme switching persistence

- 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
This commit is contained in:
2026-01-18 22:33:41 +01:00
parent ef60dafd73
commit ddb67ba9e5
306 changed files with 59580 additions and 6063 deletions

View File

@@ -8,6 +8,8 @@ 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[];
@@ -20,30 +22,17 @@ interface MasonryItemProps {
onResize: () => void;
onDragStart?: (noteId: string) => void;
onDragEnd?: () => void;
isDragging?: boolean;
}
function getSizeClasses(size: string = 'small') {
switch (size) {
case 'medium':
return 'w-full sm:w-full lg:w-2/3 xl:w-2/4 2xl:w-2/5';
case 'large':
return 'w-full';
case 'small':
default:
return 'w-full sm:w-1/2 lg:w-1/3 xl:w-1/4 2xl:w-1/5';
}
}
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragStart, onDragEnd, isDragging }: MasonryItemProps) {
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragStart, onDragEnd }: MasonryItemProps) {
const resizeRef = useResizeObserver(onResize);
const sizeClasses = getSizeClasses(note.size);
return (
<div
className={`masonry-item absolute p-2 ${sizeClasses}`}
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">
@@ -52,14 +41,13 @@ const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragSt
onEdit={onEdit}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
isDragging={isDragging}
/>
</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 && prev.note.order === next.note.order && prev.isDragging === next.isDragging;
return prev.note.id === next.note.id; // Removed isDragging comparison
});
export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
@@ -67,8 +55,14 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
// Use external onEdit if provided, otherwise use internal state
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 {
@@ -81,36 +75,20 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
const pinnedMuuri = useRef<any>(null);
const othersMuuri = useRef<any>(null);
// Memoize filtered and sorted notes to avoid recalculation on every render
// Memoize filtered notes (order comes from array)
const pinnedNotes = useMemo(
() => notes.filter(n => n.isPinned).sort((a, b) => a.order - b.order),
() => notes.filter(n => n.isPinned),
[notes]
);
const othersNotes = useMemo(
() => notes.filter(n => !n.isPinned).sort((a, b) => a.order - b.order),
() => notes.filter(n => !n.isPinned),
[notes]
);
// CRITICAL: Sync editingNote when underlying note changes (e.g., after moving to notebook)
// This ensures the NoteEditor gets the updated note with the new notebookId
useEffect(() => {
if (!editingNote) return;
// Find the updated version of the currently edited note in the notes array
const updatedNote = notes.find(n => n.id === editingNote.note.id);
if (updatedNote) {
// Check if any key properties changed (especially notebookId)
const notebookIdChanged = updatedNote.notebookId !== editingNote.note.notebookId;
if (notebookIdChanged) {
// Update the editingNote with the new data
setEditingNote(prev => prev ? { ...prev, note: updatedNote } : null);
}
}
}, [notes, editingNote]);
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();
@@ -119,27 +97,53 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
.filter((id: any): id is string => !!id);
try {
// Save order to database WITHOUT revalidating the page
// Muuri has already updated the visual layout, so we don't need to reload
// 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(() => {
// Use requestAnimationFrame for smoother updates
requestAnimationFrame(() => {
if (pinnedMuuri.current) {
pinnedMuuri.current.refreshItems().layout();
}
if (othersMuuri.current) {
othersMuuri.current.refreshItems().layout();
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)
}
});
}, []);
// Initialize Muuri grids once on mount and sync when needed
useEffect(() => {
let isMounted = true;
let muuriInitialized = false;
@@ -161,12 +165,41 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
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,
// dragContainer: document.body, // REMOVED: Keep item in grid to prevent React conflict
dragStartPredicate: {
distance: 10,
delay: 0,
@@ -175,28 +208,128 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
enabled: true,
createElement: (item: any) => {
const el = item.getElement().cloneNode(true);
el.style.opacity = '0.5';
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 * 20;
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('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();
});
}
};
@@ -213,20 +346,121 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Synchronize items when notes change (e.g. searching, adding)
// Synchronize items when notes change (e.g. searching, adding, removing)
useEffect(() => {
requestAnimationFrame(() => {
if (pinnedMuuri.current) {
pinnedMuuri.current.refreshItems().layout();
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 });
}
if (othersMuuri.current) {
othersMuuri.current.refreshItems().layout();
// 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 });
}
});
}, [notes]);
// 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 className="masonry-container">
<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>
@@ -239,7 +473,6 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
onResize={refreshLayout}
onDragStart={startDrag}
onDragEnd={endDrag}
isDragging={draggedNoteId === note.id}
/>
))}
</div>
@@ -260,7 +493,6 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
onResize={refreshLayout}
onDragStart={startDrag}
onDragEnd={endDrag}
isDragging={draggedNoteId === note.id}
/>
))}
</div>
@@ -276,13 +508,19 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
)}
<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;
@@ -294,6 +532,12 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
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>