fix: strictly isolate trash view from normal views and prevent stale state leakage
This commit is contained in:
1 parent
c355e2dfe3
commit
507302e6a6
1 file changed
+73
-21
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { formatDistanceToNow, format } from 'date-fns';
|
import { formatDistanceToNow, format } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
@@ -222,6 +222,7 @@ export default function EvercloneWorkspace() {
|
|||||||
|
|
||||||
// Notes list & active note detail
|
// Notes list & active note detail
|
||||||
const [notes, setNotes] = useState<NoteSummary[]>([]);
|
const [notes, setNotes] = useState<NoteSummary[]>([]);
|
||||||
|
const listRequestIdRef = useRef<number>(0);
|
||||||
const [totalNotes, setTotalNotes] = useState<number>(0);
|
const [totalNotes, setTotalNotes] = useState<number>(0);
|
||||||
const [loadingList, setLoadingList] = useState<boolean>(true);
|
const [loadingList, setLoadingList] = useState<boolean>(true);
|
||||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||||
@@ -393,12 +394,14 @@ export default function EvercloneWorkspace() {
|
|||||||
|
|
||||||
const fetchNotesList = useCallback(
|
const fetchNotesList = useCallback(
|
||||||
async (preferSelectNoteId?: string) => {
|
async (preferSelectNoteId?: string) => {
|
||||||
|
const currentReqId = ++listRequestIdRef.current;
|
||||||
setLoadingList(true);
|
setLoadingList(true);
|
||||||
try {
|
try {
|
||||||
const sp = buildNoteQueryParams(0, 150);
|
const sp = buildNoteQueryParams(0, 150);
|
||||||
const res = await fetch(`/api/notes?${sp.toString()}`);
|
const res = await fetch(`/api/notes?${sp.toString()}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (currentReqId !== listRequestIdRef.current) return;
|
||||||
const loadedNotes: NoteSummary[] = data.notes || [];
|
const loadedNotes: NoteSummary[] = data.notes || [];
|
||||||
setNotes(loadedNotes);
|
setNotes(loadedNotes);
|
||||||
setTotalNotes(data.total || 0);
|
setTotalNotes(data.total || 0);
|
||||||
@@ -415,9 +418,13 @@ export default function EvercloneWorkspace() {
|
|||||||
setActiveNote(null);
|
setActiveNote(null);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch notes:', e);
|
if (currentReqId === listRequestIdRef.current) {
|
||||||
|
console.error('Failed to fetch notes:', e);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingList(false);
|
if (currentReqId === listRequestIdRef.current) {
|
||||||
|
setLoadingList(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[buildNoteQueryParams]
|
[buildNoteQueryParams]
|
||||||
@@ -425,17 +432,20 @@ export default function EvercloneWorkspace() {
|
|||||||
|
|
||||||
const fetchMoreNotes = useCallback(async () => {
|
const fetchMoreNotes = useCallback(async () => {
|
||||||
if (loadingMore || !hasMoreNotes || loadingList) return;
|
if (loadingMore || !hasMoreNotes || loadingList) return;
|
||||||
|
const currentReqId = listRequestIdRef.current;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const sp = buildNoteQueryParams(notes.length, 150);
|
const sp = buildNoteQueryParams(notes.length, 150);
|
||||||
const res = await fetch(`/api/notes?${sp.toString()}`);
|
const res = await fetch(`/api/notes?${sp.toString()}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (currentReqId !== listRequestIdRef.current) return;
|
||||||
const nextBatch: NoteSummary[] = data.notes || [];
|
const nextBatch: NoteSummary[] = data.notes || [];
|
||||||
if (nextBatch.length === 0) {
|
if (nextBatch.length === 0) {
|
||||||
setHasMoreNotes(false);
|
setHasMoreNotes(false);
|
||||||
} else {
|
} else {
|
||||||
setNotes((prev) => {
|
setNotes((prev) => {
|
||||||
|
if (currentReqId !== listRequestIdRef.current) return prev;
|
||||||
const existingIds = new Set(prev.map((n) => n.id));
|
const existingIds = new Set(prev.map((n) => n.id));
|
||||||
const unique = nextBatch.filter((n) => !existingIds.has(n.id));
|
const unique = nextBatch.filter((n) => !existingIds.has(n.id));
|
||||||
const combined = [...prev, ...unique];
|
const combined = [...prev, ...unique];
|
||||||
@@ -444,12 +454,54 @@ export default function EvercloneWorkspace() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch next page of notes:', e);
|
if (currentReqId === listRequestIdRef.current) {
|
||||||
|
console.error('Failed to fetch next page of notes:', e);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMore(false);
|
if (currentReqId === listRequestIdRef.current) {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [loadingMore, hasMoreNotes, loadingList, buildNoteQueryParams, notes.length]);
|
}, [loadingMore, hasMoreNotes, loadingList, buildNoteQueryParams, notes.length]);
|
||||||
|
|
||||||
|
// Safety filter: In Trash view, only trashed notes appear. In all other views, trashed notes never appear.
|
||||||
|
const displayedNotes = useMemo(() => {
|
||||||
|
if (activeView === 'trash') {
|
||||||
|
return notes.filter((n) => n.isTrashed);
|
||||||
|
}
|
||||||
|
return notes.filter((n) => !n.isTrashed);
|
||||||
|
}, [notes, activeView]);
|
||||||
|
|
||||||
|
// When switching between trash and non-trash views:
|
||||||
|
// Immediately reset notes and selection to prevent any stale state or lingering trash notes
|
||||||
|
const prevViewRef = useRef(activeView);
|
||||||
|
useEffect(() => {
|
||||||
|
const wasTrash = prevViewRef.current === 'trash';
|
||||||
|
const isTrash = activeView === 'trash';
|
||||||
|
prevViewRef.current = activeView;
|
||||||
|
if (wasTrash !== isTrash) {
|
||||||
|
listRequestIdRef.current++;
|
||||||
|
setNotes([]);
|
||||||
|
setSelectedNoteId('');
|
||||||
|
setActiveNote(null);
|
||||||
|
setHasMoreNotes(false);
|
||||||
|
setLoadingList(true);
|
||||||
|
}
|
||||||
|
}, [activeView]);
|
||||||
|
|
||||||
|
// If the active note's trashed state conflicts with activeView, clear editor selection
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeNote) {
|
||||||
|
if (activeView === 'trash' && !activeNote.isTrashed) {
|
||||||
|
setActiveNote(null);
|
||||||
|
setSelectedNoteId('');
|
||||||
|
} else if (activeView !== 'trash' && activeNote.isTrashed) {
|
||||||
|
setActiveNote(null);
|
||||||
|
setSelectedNoteId('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeView, activeNote]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSidebar();
|
fetchSidebar();
|
||||||
}, [fetchSidebar]);
|
}, [fetchSidebar]);
|
||||||
@@ -714,13 +766,13 @@ export default function EvercloneWorkspace() {
|
|||||||
|
|
||||||
const deletedId = activeNote.id;
|
const deletedId = activeNote.id;
|
||||||
// Determine the next note to select (either the next one in the list or the previous one)
|
// Determine the next note to select (either the next one in the list or the previous one)
|
||||||
const currentIndex = notes.findIndex((n) => n.id === deletedId);
|
const currentIndex = displayedNotes.findIndex((n) => n.id === deletedId);
|
||||||
let nextNoteId = '';
|
let nextNoteId = '';
|
||||||
if (currentIndex !== -1) {
|
if (currentIndex !== -1) {
|
||||||
if (currentIndex + 1 < notes.length) {
|
if (currentIndex + 1 < displayedNotes.length) {
|
||||||
nextNoteId = notes[currentIndex + 1].id;
|
nextNoteId = displayedNotes[currentIndex + 1].id;
|
||||||
} else if (currentIndex > 0) {
|
} else if (currentIndex > 0) {
|
||||||
nextNoteId = notes[currentIndex - 1].id;
|
nextNoteId = displayedNotes[currentIndex - 1].id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,22 +1019,22 @@ export default function EvercloneWorkspace() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Card grid rows: 2 cards per row
|
// Card grid rows: 2 cards per row
|
||||||
const cardGridRowCount = Math.ceil(notes.length / 2);
|
const cardGridRowCount = Math.ceil(displayedNotes.length / 2);
|
||||||
|
|
||||||
// Virtualizer for middle note list pane (handles Snippets, Compact list, and 2-column Card grid rows)
|
// Virtualizer for middle note list pane (handles Snippets, Compact list, and 2-column Card grid rows)
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: listMode === 'cards' ? cardGridRowCount : notes.length,
|
count: listMode === 'cards' ? cardGridRowCount : displayedNotes.length,
|
||||||
getScrollElement: () => listParentRef.current,
|
getScrollElement: () => listParentRef.current,
|
||||||
estimateSize: () =>
|
estimateSize: () =>
|
||||||
listMode === 'compact' ? 52 : listMode === 'cards' ? 240 : 112,
|
listMode === 'compact' ? 52 : listMode === 'cards' ? 240 : 112,
|
||||||
getItemKey: (index: number) => {
|
getItemKey: (index: number) => {
|
||||||
if (listMode === 'cards') {
|
if (listMode === 'cards') {
|
||||||
const start = index * 2;
|
const start = index * 2;
|
||||||
const n1 = notes[start]?.id || index;
|
const n1 = displayedNotes[start]?.id || index;
|
||||||
const n2 = notes[start + 1]?.id || '';
|
const n2 = displayedNotes[start + 1]?.id || '';
|
||||||
return `cards-${n1}-${n2}`;
|
return `cards-${n1}-${n2}`;
|
||||||
}
|
}
|
||||||
return `${listMode}-${notes[index]?.id || index}`;
|
return `${listMode}-${displayedNotes[index]?.id || index}`;
|
||||||
},
|
},
|
||||||
overscan: 8,
|
overscan: 8,
|
||||||
});
|
});
|
||||||
@@ -1005,7 +1057,7 @@ export default function EvercloneWorkspace() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!virtualItems.length || !hasMoreNotes || loadingMore || loadingList) return;
|
if (!virtualItems.length || !hasMoreNotes || loadingMore || loadingList) return;
|
||||||
const lastItem = virtualItems[virtualItems.length - 1];
|
const lastItem = virtualItems[virtualItems.length - 1];
|
||||||
const totalVirtual = listMode === 'cards' ? cardGridRowCount : notes.length;
|
const totalVirtual = listMode === 'cards' ? cardGridRowCount : displayedNotes.length;
|
||||||
if (lastItem && lastItem.index >= totalVirtual - 6) {
|
if (lastItem && lastItem.index >= totalVirtual - 6) {
|
||||||
fetchMoreNotes();
|
fetchMoreNotes();
|
||||||
}
|
}
|
||||||
@@ -1016,7 +1068,7 @@ export default function EvercloneWorkspace() {
|
|||||||
loadingList,
|
loadingList,
|
||||||
listMode,
|
listMode,
|
||||||
cardGridRowCount,
|
cardGridRowCount,
|
||||||
notes.length,
|
displayedNotes.length,
|
||||||
fetchMoreNotes,
|
fetchMoreNotes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1894,7 +1946,7 @@ export default function EvercloneWorkspace() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeView === 'trash' && notes.length > 0 && (
|
{activeView === 'trash' && displayedNotes.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleEmptyTrash}
|
onClick={handleEmptyTrash}
|
||||||
@@ -2356,9 +2408,9 @@ export default function EvercloneWorkspace() {
|
|||||||
|
|
||||||
{/* Note List Body: Card Grid vs Virtualized Snippet / Compact List */}
|
{/* Note List Body: Card Grid vs Virtualized Snippet / Compact List */}
|
||||||
<div ref={listParentRef} className="flex-1 overflow-y-scroll">
|
<div ref={listParentRef} className="flex-1 overflow-y-scroll">
|
||||||
{loadingList && notes.length === 0 ? (
|
{loadingList && displayedNotes.length === 0 ? (
|
||||||
<div className="p-8 text-center text-xs text-gray-400">Loading notes…</div>
|
<div className="p-8 text-center text-xs text-gray-400">Loading notes…</div>
|
||||||
) : notes.length === 0 ? (
|
) : displayedNotes.length === 0 ? (
|
||||||
<div className="p-8 text-center space-y-3">
|
<div className="p-8 text-center space-y-3">
|
||||||
<Sparkles className="w-8 h-8 text-gray-300 mx-auto" />
|
<Sparkles className="w-8 h-8 text-gray-300 mx-auto" />
|
||||||
<div className="text-sm font-medium text-gray-600">No notes found</div>
|
<div className="text-sm font-medium text-gray-600">No notes found</div>
|
||||||
@@ -2407,7 +2459,7 @@ export default function EvercloneWorkspace() {
|
|||||||
>
|
>
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const startIndex = virtualRow.index * 2;
|
const startIndex = virtualRow.index * 2;
|
||||||
const rowNotes = notes.slice(startIndex, startIndex + 2);
|
const rowNotes = displayedNotes.slice(startIndex, startIndex + 2);
|
||||||
if (rowNotes.length === 0) return null;
|
if (rowNotes.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2586,7 +2638,7 @@ export default function EvercloneWorkspace() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const n = notes[virtualRow.index];
|
const n = displayedNotes[virtualRow.index];
|
||||||
if (!n) return null;
|
if (!n) return null;
|
||||||
const isSelected = n.id === selectedNoteId;
|
const isSelected = n.id === selectedNoteId;
|
||||||
const firstImg = n.attachments.find((a) =>
|
const firstImg = n.attachments.find((a) =>
|
||||||
|
|||||||
Reference in new issue
Block a user