Add high-scale optimizations: pg_trgm GIN indexes, infinite scroll pagination, virtualized card grid, fast groupBy sidebar counts

This commit is contained in:
chrisf committed 2026-10-22 17:25:23 -06:00
1 parent 7096631f1e
commit e1a861faea
3 files changed
+350 -222

No files matched your search

+21
View File
@@ -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
+30 -18
View File
@@ -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: {
+299 -204
View File
@@ -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>
); );
})} })}