Add high-scale optimizations: pg_trgm GIN indexes, infinite scroll pagination, virtualized card grid, fast groupBy sidebar counts
This commit is contained in:
1 parent
7096631f1e
commit
e1a861faea
3 files changed
+350
-222
No files matched your search
@@ -4,5 +4,26 @@ set -e
|
|||||||
echo "Applying Prisma schema to PostgreSQL..."
|
echo "Applying Prisma schema to PostgreSQL..."
|
||||||
node ./node_modules/prisma/build/index.js db push --skip-generate --accept-data-loss
|
node ./node_modules/prisma/build/index.js db push --skip-generate --accept-data-loss
|
||||||
|
|
||||||
|
echo "Ensuring high-scale PostgreSQL pg_trgm GIN indexes..."
|
||||||
|
node -e '
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`CREATE EXTENSION IF NOT EXISTS pg_trgm;`);
|
||||||
|
await prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS note_title_trgm_idx ON "Note" USING gin ("title" gin_trgm_ops);`);
|
||||||
|
await prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS note_content_text_trgm_idx ON "Note" USING gin ("contentText" gin_trgm_ops);`);
|
||||||
|
await prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS attachment_extracted_text_trgm_idx ON "Attachment" USING gin ("extractedText" gin_trgm_ops);`);
|
||||||
|
await prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS tag_name_trgm_idx ON "Tag" USING gin ("name" gin_trgm_ops);`);
|
||||||
|
console.log("pg_trgm GIN indexes verified successfully.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Warning ensuring pg_trgm indexes:", err.message);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main();
|
||||||
|
'
|
||||||
|
|
||||||
echo "Starting Everclone server on port 3000..."
|
echo "Starting Everclone server on port 3000..."
|
||||||
exec node server.js
|
exec node server.js
|
||||||
@@ -16,6 +16,8 @@ export async function GET() {
|
|||||||
shortcutNotes,
|
shortcutNotes,
|
||||||
allNotesCount,
|
allNotesCount,
|
||||||
trashCount,
|
trashCount,
|
||||||
|
notebookCountsRaw,
|
||||||
|
tagCountsRaw,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
@@ -27,24 +29,10 @@ export async function GET() {
|
|||||||
prisma.notebook.findMany({
|
prisma.notebook.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
notes: { where: { isTrashed: false } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
prisma.tag.findMany({
|
prisma.tag.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
notes: { where: { note: { isTrashed: false, userId } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
prisma.savedSearch.findMany({
|
prisma.savedSearch.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -57,25 +45,49 @@ export async function GET() {
|
|||||||
}),
|
}),
|
||||||
prisma.note.count({ where: { userId, isTrashed: false } }),
|
prisma.note.count({ where: { userId, isTrashed: false } }),
|
||||||
prisma.note.count({ where: { userId, isTrashed: true } }),
|
prisma.note.count({ where: { userId, isTrashed: true } }),
|
||||||
|
// Fast single GROUP BY for notebook note counts
|
||||||
|
prisma.note.groupBy({
|
||||||
|
by: ['notebookId'],
|
||||||
|
where: { userId, isTrashed: false },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
// Fast single raw GROUP BY query for tag note counts
|
||||||
|
prisma.$queryRaw<Array<{ tagId: string; count: bigint }>>`
|
||||||
|
SELECT nt."tagId", COUNT(nt."noteId") as count
|
||||||
|
FROM "NoteTag" nt
|
||||||
|
JOIN "Note" n ON n.id = nt."noteId"
|
||||||
|
WHERE n."userId" = ${userId} AND n."isTrashed" = false
|
||||||
|
GROUP BY nt."tagId"
|
||||||
|
`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const notebookCountMap = new Map<string, number>();
|
||||||
|
for (const item of notebookCountsRaw) {
|
||||||
|
notebookCountMap.set(item.notebookId, item._count._all);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagCountMap = new Map<string, number>();
|
||||||
|
for (const item of tagCountsRaw) {
|
||||||
|
tagCountMap.set(item.tagId, Number(item.count));
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
currentUser,
|
currentUser,
|
||||||
users,
|
users,
|
||||||
stacks,
|
stacks,
|
||||||
notebooks: notebooks.map((nb) => ({
|
notebooks: notebooks.map((nb) => ({
|
||||||
...nb,
|
...nb,
|
||||||
noteCount: nb._count.notes,
|
noteCount: notebookCountMap.get(nb.id) || 0,
|
||||||
})),
|
})),
|
||||||
tags: tags.map((t) => ({
|
tags: tags.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
noteCount: t._count.notes,
|
noteCount: tagCountMap.get(t.id) || 0,
|
||||||
})),
|
})),
|
||||||
savedSearches,
|
savedSearches,
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
notes: shortcutNotes,
|
notes: shortcutNotes,
|
||||||
notebooks: notebooks.filter((n) => n.isShortcut),
|
notebooks: notebooks.filter((n) => n.isShortcut).map(nb => ({ ...nb, noteCount: notebookCountMap.get(nb.id) || 0 })),
|
||||||
tags: tags.filter((t) => t.isShortcut),
|
tags: tags.filter((t) => t.isShortcut).map(t => ({ ...t, noteCount: tagCountMap.get(t.id) || 0 })),
|
||||||
savedSearches: savedSearches.filter((s) => s.isShortcut),
|
savedSearches: savedSearches.filter((s) => s.isShortcut),
|
||||||
},
|
},
|
||||||
counts: {
|
counts: {
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export default function EvercloneWorkspace() {
|
|||||||
const [notes, setNotes] = useState<NoteSummary[]>([]);
|
const [notes, setNotes] = useState<NoteSummary[]>([]);
|
||||||
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 [hasMoreNotes, setHasMoreNotes] = useState<boolean>(true);
|
||||||
const [selectedNoteId, setSelectedNoteId] = useState<string>('');
|
const [selectedNoteId, setSelectedNoteId] = useState<string>('');
|
||||||
const [activeNote, setActiveNote] = useState<NoteDetail | null>(null);
|
const [activeNote, setActiveNote] = useState<NoteDetail | null>(null);
|
||||||
const [saveState, setSaveState] = useState<'saved' | 'saving' | 'dirty'>('saved');
|
const [saveState, setSaveState] = useState<'saved' | 'saving' | 'dirty'>('saved');
|
||||||
@@ -189,64 +191,41 @@ export default function EvercloneWorkspace() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchNotesList = useCallback(
|
const buildNoteQueryParams = useCallback(
|
||||||
async (preferSelectNoteId?: string) => {
|
(offset = 0, limit = 150) => {
|
||||||
setLoadingList(true);
|
const sp = new URLSearchParams();
|
||||||
try {
|
if (debouncedSearch.trim()) sp.set('q', debouncedSearch.trim());
|
||||||
const sp = new URLSearchParams();
|
if (activeView === 'notebook' && selectedNotebookId) {
|
||||||
if (debouncedSearch.trim()) sp.set('q', debouncedSearch.trim());
|
sp.set('notebookId', selectedNotebookId);
|
||||||
if (activeView === 'notebook' && selectedNotebookId) {
|
} else if (filterNotebookId) {
|
||||||
sp.set('notebookId', selectedNotebookId);
|
sp.set('notebookId', filterNotebookId);
|
||||||
} else if (filterNotebookId) {
|
|
||||||
sp.set('notebookId', filterNotebookId);
|
|
||||||
}
|
|
||||||
if (activeView === 'stack' && selectedStackId) {
|
|
||||||
sp.set('stackId', selectedStackId);
|
|
||||||
}
|
|
||||||
if (activeView === 'tag' && selectedTagId) {
|
|
||||||
sp.set('tagId', selectedTagId);
|
|
||||||
}
|
|
||||||
if (filterTagIds.length > 0) {
|
|
||||||
sp.set('tagIds', filterTagIds.join(','));
|
|
||||||
}
|
|
||||||
if (filterUntagged) sp.set('untagged', 'true');
|
|
||||||
if (filterHasImages) sp.set('hasImages', 'true');
|
|
||||||
if (filterHasAttachments) sp.set('hasAttachments', 'true');
|
|
||||||
if (filterHasTodo) sp.set('hasTodo', 'true');
|
|
||||||
if (filterHasWebClip) sp.set('hasWebClip', 'true');
|
|
||||||
if (filterOnlyPinned) sp.set('onlyPinned', 'true');
|
|
||||||
if (filterUpdatedDays > 0) sp.set('updatedSinceDays', String(filterUpdatedDays));
|
|
||||||
if (activeView === 'trash') {
|
|
||||||
sp.set('trashed', 'true');
|
|
||||||
} else if (activeView === 'shortcuts') {
|
|
||||||
sp.set('shortcut', 'true');
|
|
||||||
}
|
|
||||||
sp.set('sortBy', sortBy);
|
|
||||||
sp.set('sortDir', sortDir);
|
|
||||||
sp.set('limit', '1000');
|
|
||||||
|
|
||||||
const res = await fetch(`/api/notes?${sp.toString()}`);
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
const loadedNotes: NoteSummary[] = data.notes || [];
|
|
||||||
setNotes(loadedNotes);
|
|
||||||
setTotalNotes(data.total || 0);
|
|
||||||
|
|
||||||
if (preferSelectNoteId) {
|
|
||||||
setSelectedNoteId(preferSelectNoteId);
|
|
||||||
} else if (loadedNotes.length > 0) {
|
|
||||||
setSelectedNoteId((prev) =>
|
|
||||||
prev && loadedNotes.some((n) => n.id === prev) ? prev : loadedNotes[0].id
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setSelectedNoteId('');
|
|
||||||
setActiveNote(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch notes:', e);
|
|
||||||
} finally {
|
|
||||||
setLoadingList(false);
|
|
||||||
}
|
}
|
||||||
|
if (activeView === 'stack' && selectedStackId) {
|
||||||
|
sp.set('stackId', selectedStackId);
|
||||||
|
}
|
||||||
|
if (activeView === 'tag' && selectedTagId) {
|
||||||
|
sp.set('tagId', selectedTagId);
|
||||||
|
}
|
||||||
|
if (filterTagIds.length > 0) {
|
||||||
|
sp.set('tagIds', filterTagIds.join(','));
|
||||||
|
}
|
||||||
|
if (filterUntagged) sp.set('untagged', 'true');
|
||||||
|
if (filterHasImages) sp.set('hasImages', 'true');
|
||||||
|
if (filterHasAttachments) sp.set('hasAttachments', 'true');
|
||||||
|
if (filterHasTodo) sp.set('hasTodo', 'true');
|
||||||
|
if (filterHasWebClip) sp.set('hasWebClip', 'true');
|
||||||
|
if (filterOnlyPinned) sp.set('onlyPinned', 'true');
|
||||||
|
if (filterUpdatedDays > 0) sp.set('updatedSinceDays', String(filterUpdatedDays));
|
||||||
|
if (activeView === 'trash') {
|
||||||
|
sp.set('trashed', 'true');
|
||||||
|
} else if (activeView === 'shortcuts') {
|
||||||
|
sp.set('shortcut', 'true');
|
||||||
|
}
|
||||||
|
sp.set('sortBy', sortBy);
|
||||||
|
sp.set('sortDir', sortDir);
|
||||||
|
sp.set('limit', String(limit));
|
||||||
|
sp.set('offset', String(offset));
|
||||||
|
return sp;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
debouncedSearch,
|
debouncedSearch,
|
||||||
@@ -268,6 +247,65 @@ export default function EvercloneWorkspace() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fetchNotesList = useCallback(
|
||||||
|
async (preferSelectNoteId?: string) => {
|
||||||
|
setLoadingList(true);
|
||||||
|
try {
|
||||||
|
const sp = buildNoteQueryParams(0, 150);
|
||||||
|
const res = await fetch(`/api/notes?${sp.toString()}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const loadedNotes: NoteSummary[] = data.notes || [];
|
||||||
|
setNotes(loadedNotes);
|
||||||
|
setTotalNotes(data.total || 0);
|
||||||
|
setHasMoreNotes(loadedNotes.length < (data.total || 0));
|
||||||
|
|
||||||
|
if (preferSelectNoteId) {
|
||||||
|
setSelectedNoteId(preferSelectNoteId);
|
||||||
|
} else if (loadedNotes.length > 0) {
|
||||||
|
setSelectedNoteId((prev) =>
|
||||||
|
prev && loadedNotes.some((n) => n.id === prev) ? prev : loadedNotes[0].id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedNoteId('');
|
||||||
|
setActiveNote(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch notes:', e);
|
||||||
|
} finally {
|
||||||
|
setLoadingList(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[buildNoteQueryParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchMoreNotes = useCallback(async () => {
|
||||||
|
if (loadingMore || !hasMoreNotes || loadingList) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const sp = buildNoteQueryParams(notes.length, 150);
|
||||||
|
const res = await fetch(`/api/notes?${sp.toString()}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const nextBatch: NoteSummary[] = data.notes || [];
|
||||||
|
if (nextBatch.length === 0) {
|
||||||
|
setHasMoreNotes(false);
|
||||||
|
} else {
|
||||||
|
setNotes((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((n) => n.id));
|
||||||
|
const unique = nextBatch.filter((n) => !existingIds.has(n.id));
|
||||||
|
const combined = [...prev, ...unique];
|
||||||
|
setHasMoreNotes(combined.length < (data.total || 0));
|
||||||
|
return combined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch next page of notes:', e);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [loadingMore, hasMoreNotes, loadingList, buildNoteQueryParams, notes.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSidebar();
|
fetchSidebar();
|
||||||
}, [fetchSidebar]);
|
}, [fetchSidebar]);
|
||||||
@@ -628,15 +666,38 @@ export default function EvercloneWorkspace() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Virtualizer for middle note list pane
|
// Card grid rows: 2 cards per row
|
||||||
|
const cardGridRowCount = Math.ceil(notes.length / 2);
|
||||||
|
|
||||||
|
// Virtualizer for middle note list pane (handles Snippets, Compact list, and 2-column Card grid rows)
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: notes.length,
|
count: listMode === 'cards' ? cardGridRowCount : notes.length,
|
||||||
getScrollElement: () => listParentRef.current,
|
getScrollElement: () => listParentRef.current,
|
||||||
estimateSize: () =>
|
estimateSize: () =>
|
||||||
listMode === 'compact' ? 52 : listMode === 'cards' ? 148 : 112,
|
listMode === 'compact' ? 52 : listMode === 'cards' ? 240 : 112,
|
||||||
overscan: 10,
|
overscan: 8,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Infinite scroll trigger: when scrolling near bottom, fetch next page of notes
|
||||||
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!virtualItems.length || !hasMoreNotes || loadingMore || loadingList) return;
|
||||||
|
const lastItem = virtualItems[virtualItems.length - 1];
|
||||||
|
const totalVirtual = listMode === 'cards' ? cardGridRowCount : notes.length;
|
||||||
|
if (lastItem && lastItem.index >= totalVirtual - 6) {
|
||||||
|
fetchMoreNotes();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
virtualItems,
|
||||||
|
hasMoreNotes,
|
||||||
|
loadingMore,
|
||||||
|
loadingList,
|
||||||
|
listMode,
|
||||||
|
cardGridRowCount,
|
||||||
|
notes.length,
|
||||||
|
fetchMoreNotes,
|
||||||
|
]);
|
||||||
|
|
||||||
// Compute current view title
|
// Compute current view title
|
||||||
const currentViewTitle = (() => {
|
const currentViewTitle = (() => {
|
||||||
if (activeView === 'trash') return 'Trash';
|
if (activeView === 'trash') return 'Trash';
|
||||||
@@ -1584,6 +1645,13 @@ export default function EvercloneWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Loading indicator for next page */}
|
||||||
|
{loadingMore && (
|
||||||
|
<div className="py-1 px-3 bg-emerald-50 text-center text-[10px] text-evernote-green font-medium animate-pulse rounded">
|
||||||
|
Loading more notes…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Collapsible Filter Panel */}
|
{/* Collapsible Filter Panel */}
|
||||||
{showFilterPanel && (
|
{showFilterPanel && (
|
||||||
<div className="pt-2.5 border-t border-gray-200 space-y-3 text-xs">
|
<div className="pt-2.5 border-t border-gray-200 space-y-3 text-xs">
|
||||||
@@ -1984,159 +2052,186 @@ export default function EvercloneWorkspace() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : listMode === 'cards' ? (
|
) : listMode === 'cards' ? (
|
||||||
/* 2-COLUMN VISUAL CARD GRID VIEW */
|
/* VIRTUALIZED 2-COLUMN VISUAL CARD GRID VIEW */
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3">
|
<div
|
||||||
{notes.map((n) => {
|
style={{
|
||||||
const isSelected = n.id === selectedNoteId;
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
const firstImg = n.attachments.find((a) =>
|
width: '100%',
|
||||||
a.mimeType.startsWith('image/')
|
position: 'relative',
|
||||||
);
|
}}
|
||||||
const firstPdf = n.attachments.find(
|
>
|
||||||
(a) =>
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
a.mimeType === 'application/pdf' ||
|
const startIndex = virtualRow.index * 2;
|
||||||
a.filename.toLowerCase().endsWith('.pdf')
|
const rowNotes = notes.slice(startIndex, startIndex + 2);
|
||||||
);
|
|
||||||
const effectiveThumbUrl =
|
|
||||||
n.thumbnailUrl === 'none'
|
|
||||||
? null
|
|
||||||
: n.thumbnailUrl || (firstImg ? `/api/attachments/${firstImg.id}` : null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={n.id}
|
key={`card-row-${virtualRow.index}`}
|
||||||
draggable={!n.isTrashed}
|
data-index={virtualRow.index}
|
||||||
onDragStart={(e) => {
|
ref={rowVirtualizer.measureElement}
|
||||||
e.dataTransfer.setData('text/everclone-note-id', n.id);
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
onClick={() => openNoteInEditor(n.id)}
|
className="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3"
|
||||||
className={`group/card cursor-pointer rounded-xl border transition flex flex-col overflow-hidden shadow-xs ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-emerald-50/60 border-evernote-green ring-2 ring-evernote-green/30'
|
|
||||||
: 'bg-white border-gray-200/90 hover:border-gray-300 hover:shadow-md'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* Top Hero Cover Image Banner (if present) */}
|
{rowNotes.map((n) => {
|
||||||
{effectiveThumbUrl ? (
|
const isSelected = n.id === selectedNoteId;
|
||||||
<div className="group/thumb relative h-28 w-full bg-gray-100 border-b border-gray-100 overflow-hidden shrink-0">
|
const firstImg = n.attachments.find((a) =>
|
||||||
<img
|
a.mimeType.startsWith('image/')
|
||||||
src={effectiveThumbUrl}
|
);
|
||||||
alt=""
|
const firstPdf = n.attachments.find(
|
||||||
className="w-full h-full object-cover group-hover/card:scale-105 transition duration-300"
|
(a) =>
|
||||||
/>
|
a.mimeType === 'application/pdf' ||
|
||||||
{firstPdf && (
|
a.filename.toLowerCase().endsWith('.pdf')
|
||||||
<span className="absolute bottom-1.5 left-2 px-1.5 py-0.5 rounded bg-red-600/90 text-white text-[9px] font-bold uppercase tracking-wider shadow">
|
);
|
||||||
PDF
|
const effectiveThumbUrl =
|
||||||
</span>
|
n.thumbnailUrl === 'none'
|
||||||
)}
|
? null
|
||||||
<button
|
: n.thumbnailUrl || (firstImg ? `/api/attachments/${firstImg.id}` : null);
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedNoteId(n.id);
|
|
||||||
setThumbnailInitialUrl(effectiveThumbUrl);
|
|
||||||
setThumbnailEditorOpen(true);
|
|
||||||
}}
|
|
||||||
title="Edit or Crop Thumbnail"
|
|
||||||
className="opacity-0 group-hover/thumb:opacity-100 transition absolute top-2 right-2 p-1.5 bg-black/65 hover:bg-black/85 rounded-lg text-white shadow"
|
|
||||||
>
|
|
||||||
<Crop className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
n.thumbnailUrl !== 'none' &&
|
|
||||||
firstPdf && (
|
|
||||||
<PdfAutoThumbnail
|
|
||||||
noteId={n.id}
|
|
||||||
pdfAttachmentId={firstPdf.id}
|
|
||||||
mode="cards"
|
|
||||||
onEditThumbnail={(dataUrl) => {
|
|
||||||
setSelectedNoteId(n.id);
|
|
||||||
setThumbnailInitialUrl(dataUrl);
|
|
||||||
setThumbnailEditorOpen(true);
|
|
||||||
}}
|
|
||||||
onCachedThumbnail={(nid, dataUrl) => {
|
|
||||||
setNotes((prev) =>
|
|
||||||
prev.map((item) =>
|
|
||||||
item.id === nid ? { ...item, thumbnailUrl: dataUrl } : item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Card Content */}
|
return (
|
||||||
<div className="p-3 flex-1 flex flex-col justify-between space-y-2">
|
<div
|
||||||
<div>
|
key={n.id}
|
||||||
{/* Notebook Mover & Pin/Star Row */}
|
draggable={!n.isTrashed}
|
||||||
<div className="flex items-center justify-between gap-1 text-[10px] text-gray-400 mb-1">
|
onDragStart={(e) => {
|
||||||
<div
|
e.dataTransfer.setData('text/everclone-note-id', n.id);
|
||||||
onClick={(e) => e.stopPropagation()}
|
}}
|
||||||
className="inline-flex items-center gap-1 min-w-0 max-w-[140px] px-1.5 py-0.5 rounded bg-gray-100 hover:bg-emerald-50 text-gray-600 hover:text-evernote-green transition"
|
onClick={() => openNoteInEditor(n.id)}
|
||||||
title="Move note to another notebook"
|
className={`group/card cursor-pointer rounded-xl border transition flex flex-col overflow-hidden shadow-xs ${
|
||||||
>
|
isSelected
|
||||||
<Book className="w-2.5 h-2.5 shrink-0" />
|
? 'bg-emerald-50/60 border-evernote-green ring-2 ring-evernote-green/30'
|
||||||
<select
|
: 'bg-white border-gray-200/90 hover:border-gray-300 hover:shadow-md'
|
||||||
value={n.notebookId}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleMoveNoteToNotebook(n.id, e.target.value)
|
|
||||||
}
|
|
||||||
className="bg-transparent font-medium truncate focus:outline-none cursor-pointer w-full text-[10px]"
|
|
||||||
>
|
|
||||||
{sidebar.notebooks.map((nb) => (
|
|
||||||
<option key={nb.id} value={nb.id}>
|
|
||||||
{nb.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
{n.isPinned && (
|
|
||||||
<Pin className="w-3 h-3 text-evernote-green fill-current" />
|
|
||||||
)}
|
|
||||||
{n.isShortcut && (
|
|
||||||
<Star className="w-3 h-3 text-amber-400 fill-current" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-xs font-bold text-gray-900 line-clamp-2 leading-snug">
|
|
||||||
{n.title || 'Untitled Note'}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p
|
|
||||||
className={`text-[11px] text-gray-500 mt-1 leading-relaxed ${
|
|
||||||
effectiveThumbUrl ? 'line-clamp-2' : 'line-clamp-4'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{n.excerpt || 'No additional text'}
|
{/* Top Hero Cover Image Banner (if present) */}
|
||||||
</p>
|
{effectiveThumbUrl ? (
|
||||||
</div>
|
<div className="group/thumb relative h-28 w-full bg-gray-100 border-b border-gray-100 overflow-hidden shrink-0">
|
||||||
|
<img
|
||||||
{/* Card Footer */}
|
src={effectiveThumbUrl}
|
||||||
<div className="pt-2 border-t border-gray-100 flex items-center justify-between gap-1 text-[10px] text-gray-400">
|
alt=""
|
||||||
<span className="truncate">
|
className="w-full h-full object-cover group-hover/card:scale-105 transition duration-300"
|
||||||
{format(new Date(n.updatedAt), 'MMM d')}
|
/>
|
||||||
</span>
|
{firstPdf && (
|
||||||
|
<span className="absolute bottom-1.5 left-2 px-1.5 py-0.5 rounded bg-red-600/90 text-white text-[9px] font-bold uppercase tracking-wider shadow">
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
PDF
|
||||||
{n.attachments.length > 0 && (
|
</span>
|
||||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.2 bg-gray-100 text-gray-600 rounded">
|
)}
|
||||||
<Paperclip className="w-2.5 h-2.5" />
|
<button
|
||||||
{n.attachments.length}
|
type="button"
|
||||||
</span>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedNoteId(n.id);
|
||||||
|
setThumbnailInitialUrl(effectiveThumbUrl);
|
||||||
|
setThumbnailEditorOpen(true);
|
||||||
|
}}
|
||||||
|
title="Edit or Crop Thumbnail"
|
||||||
|
className="opacity-0 group-hover/thumb:opacity-100 transition absolute top-2 right-2 p-1.5 bg-black/65 hover:bg-black/85 rounded-lg text-white shadow"
|
||||||
|
>
|
||||||
|
<Crop className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
n.thumbnailUrl !== 'none' &&
|
||||||
|
firstPdf && (
|
||||||
|
<PdfAutoThumbnail
|
||||||
|
noteId={n.id}
|
||||||
|
pdfAttachmentId={firstPdf.id}
|
||||||
|
mode="cards"
|
||||||
|
onEditThumbnail={(dataUrl) => {
|
||||||
|
setSelectedNoteId(n.id);
|
||||||
|
setThumbnailInitialUrl(dataUrl);
|
||||||
|
setThumbnailEditorOpen(true);
|
||||||
|
}}
|
||||||
|
onCachedThumbnail={(nid, dataUrl) => {
|
||||||
|
setNotes((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === nid ? { ...item, thumbnailUrl: dataUrl } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{n.tags.slice(0, 1).map((t) => (
|
|
||||||
<span
|
{/* Card Content */}
|
||||||
key={t.id}
|
<div className="p-3 flex-1 flex flex-col justify-between space-y-2">
|
||||||
className="px-1.5 py-0.2 rounded-full bg-emerald-50 text-emerald-700 font-medium truncate max-w-[75px]"
|
<div>
|
||||||
>
|
{/* Notebook Mover & Pin/Star Row */}
|
||||||
#{t.name}
|
<div className="flex items-center justify-between gap-1 text-[10px] text-gray-400 mb-1">
|
||||||
</span>
|
<div
|
||||||
))}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1 min-w-0 max-w-[140px] px-1.5 py-0.5 rounded bg-gray-100 hover:bg-emerald-50 text-gray-600 hover:text-evernote-green transition"
|
||||||
|
title="Move note to another notebook"
|
||||||
|
>
|
||||||
|
<Book className="w-2.5 h-2.5 shrink-0" />
|
||||||
|
<select
|
||||||
|
value={n.notebookId}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleMoveNoteToNotebook(n.id, e.target.value)
|
||||||
|
}
|
||||||
|
className="bg-transparent font-medium truncate focus:outline-none cursor-pointer w-full text-[10px]"
|
||||||
|
>
|
||||||
|
{sidebar.notebooks.map((nb) => (
|
||||||
|
<option key={nb.id} value={nb.id}>
|
||||||
|
{nb.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{n.isPinned && (
|
||||||
|
<Pin className="w-3 h-3 text-evernote-green fill-current" />
|
||||||
|
)}
|
||||||
|
{n.isShortcut && (
|
||||||
|
<Star className="w-3 h-3 text-amber-400 fill-current" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xs font-bold text-gray-900 line-clamp-2 leading-snug">
|
||||||
|
{n.title || 'Untitled Note'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={`text-[11px] text-gray-500 mt-1 leading-relaxed ${
|
||||||
|
effectiveThumbUrl ? 'line-clamp-2' : 'line-clamp-4'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n.excerpt || 'No additional text'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Footer */}
|
||||||
|
<div className="pt-2 border-t border-gray-100 flex items-center justify-between gap-1 text-[10px] text-gray-400">
|
||||||
|
<span className="truncate">
|
||||||
|
{format(new Date(n.updatedAt), 'MMM d')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{n.attachments.length > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.2 bg-gray-100 text-gray-600 rounded">
|
||||||
|
<Paperclip className="w-2.5 h-2.5" />
|
||||||
|
{n.attachments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{n.tags.slice(0, 1).map((t) => (
|
||||||
|
<span
|
||||||
|
key={t.id}
|
||||||
|
className="px-1.5 py-0.2 rounded-full bg-emerald-50 text-emerald-700 font-medium truncate max-w-[75px]"
|
||||||
|
>
|
||||||
|
#{t.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in new issue
Block a user