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..."
|
||||
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..."
|
||||
exec node server.js
|
||||
@@ -16,6 +16,8 @@ export async function GET() {
|
||||
shortcutNotes,
|
||||
allNotesCount,
|
||||
trashCount,
|
||||
notebookCountsRaw,
|
||||
tagCountsRaw,
|
||||
] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -27,24 +29,10 @@ export async function GET() {
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
notes: { where: { isTrashed: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.tag.findMany({
|
||||
where: { userId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
notes: { where: { note: { isTrashed: false, userId } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.savedSearch.findMany({
|
||||
where: { userId },
|
||||
@@ -57,25 +45,49 @@ export async function GET() {
|
||||
}),
|
||||
prisma.note.count({ where: { userId, isTrashed: false } }),
|
||||
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({
|
||||
currentUser,
|
||||
users,
|
||||
stacks,
|
||||
notebooks: notebooks.map((nb) => ({
|
||||
...nb,
|
||||
noteCount: nb._count.notes,
|
||||
noteCount: notebookCountMap.get(nb.id) || 0,
|
||||
})),
|
||||
tags: tags.map((t) => ({
|
||||
...t,
|
||||
noteCount: t._count.notes,
|
||||
noteCount: tagCountMap.get(t.id) || 0,
|
||||
})),
|
||||
savedSearches,
|
||||
shortcuts: {
|
||||
notes: shortcutNotes,
|
||||
notebooks: notebooks.filter((n) => n.isShortcut),
|
||||
tags: tags.filter((t) => t.isShortcut),
|
||||
notebooks: notebooks.filter((n) => n.isShortcut).map(nb => ({ ...nb, noteCount: notebookCountMap.get(nb.id) || 0 })),
|
||||
tags: tags.filter((t) => t.isShortcut).map(t => ({ ...t, noteCount: tagCountMap.get(t.id) || 0 })),
|
||||
savedSearches: savedSearches.filter((s) => s.isShortcut),
|
||||
},
|
||||
counts: {
|
||||
|
||||
@@ -142,6 +142,8 @@ export default function EvercloneWorkspace() {
|
||||
const [notes, setNotes] = useState<NoteSummary[]>([]);
|
||||
const [totalNotes, setTotalNotes] = useState<number>(0);
|
||||
const [loadingList, setLoadingList] = useState<boolean>(true);
|
||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||
const [hasMoreNotes, setHasMoreNotes] = useState<boolean>(true);
|
||||
const [selectedNoteId, setSelectedNoteId] = useState<string>('');
|
||||
const [activeNote, setActiveNote] = useState<NoteDetail | null>(null);
|
||||
const [saveState, setSaveState] = useState<'saved' | 'saving' | 'dirty'>('saved');
|
||||
@@ -189,64 +191,41 @@ export default function EvercloneWorkspace() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNotesList = useCallback(
|
||||
async (preferSelectNoteId?: string) => {
|
||||
setLoadingList(true);
|
||||
try {
|
||||
const sp = new URLSearchParams();
|
||||
if (debouncedSearch.trim()) sp.set('q', debouncedSearch.trim());
|
||||
if (activeView === 'notebook' && selectedNotebookId) {
|
||||
sp.set('notebookId', selectedNotebookId);
|
||||
} 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);
|
||||
const buildNoteQueryParams = useCallback(
|
||||
(offset = 0, limit = 150) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (debouncedSearch.trim()) sp.set('q', debouncedSearch.trim());
|
||||
if (activeView === 'notebook' && selectedNotebookId) {
|
||||
sp.set('notebookId', selectedNotebookId);
|
||||
} 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', String(limit));
|
||||
sp.set('offset', String(offset));
|
||||
return sp;
|
||||
},
|
||||
[
|
||||
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(() => {
|
||||
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({
|
||||
count: notes.length,
|
||||
count: listMode === 'cards' ? cardGridRowCount : notes.length,
|
||||
getScrollElement: () => listParentRef.current,
|
||||
estimateSize: () =>
|
||||
listMode === 'compact' ? 52 : listMode === 'cards' ? 148 : 112,
|
||||
overscan: 10,
|
||||
listMode === 'compact' ? 52 : listMode === 'cards' ? 240 : 112,
|
||||
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
|
||||
const currentViewTitle = (() => {
|
||||
if (activeView === 'trash') return 'Trash';
|
||||
@@ -1584,6 +1645,13 @@ export default function EvercloneWorkspace() {
|
||||
</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 */}
|
||||
{showFilterPanel && (
|
||||
<div className="pt-2.5 border-t border-gray-200 space-y-3 text-xs">
|
||||
@@ -1984,159 +2052,186 @@ export default function EvercloneWorkspace() {
|
||||
)}
|
||||
</div>
|
||||
) : listMode === 'cards' ? (
|
||||
/* 2-COLUMN VISUAL CARD GRID VIEW */
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3">
|
||||
{notes.map((n) => {
|
||||
const isSelected = n.id === selectedNoteId;
|
||||
const firstImg = n.attachments.find((a) =>
|
||||
a.mimeType.startsWith('image/')
|
||||
);
|
||||
const firstPdf = n.attachments.find(
|
||||
(a) =>
|
||||
a.mimeType === 'application/pdf' ||
|
||||
a.filename.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
const effectiveThumbUrl =
|
||||
n.thumbnailUrl === 'none'
|
||||
? null
|
||||
: n.thumbnailUrl || (firstImg ? `/api/attachments/${firstImg.id}` : null);
|
||||
/* VIRTUALIZED 2-COLUMN VISUAL CARD GRID VIEW */
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * 2;
|
||||
const rowNotes = notes.slice(startIndex, startIndex + 2);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
draggable={!n.isTrashed}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/everclone-note-id', n.id);
|
||||
key={`card-row-${virtualRow.index}`}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
onClick={() => openNoteInEditor(n.id)}
|
||||
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'
|
||||
}`}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3"
|
||||
>
|
||||
{/* Top Hero Cover Image Banner (if present) */}
|
||||
{effectiveThumbUrl ? (
|
||||
<div className="group/thumb relative h-28 w-full bg-gray-100 border-b border-gray-100 overflow-hidden shrink-0">
|
||||
<img
|
||||
src={effectiveThumbUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover group-hover/card:scale-105 transition duration-300"
|
||||
/>
|
||||
{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">
|
||||
PDF
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
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
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{rowNotes.map((n) => {
|
||||
const isSelected = n.id === selectedNoteId;
|
||||
const firstImg = n.attachments.find((a) =>
|
||||
a.mimeType.startsWith('image/')
|
||||
);
|
||||
const firstPdf = n.attachments.find(
|
||||
(a) =>
|
||||
a.mimeType === 'application/pdf' ||
|
||||
a.filename.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
const effectiveThumbUrl =
|
||||
n.thumbnailUrl === 'none'
|
||||
? null
|
||||
: n.thumbnailUrl || (firstImg ? `/api/attachments/${firstImg.id}` : null);
|
||||
|
||||
{/* Card Content */}
|
||||
<div className="p-3 flex-1 flex flex-col justify-between space-y-2">
|
||||
<div>
|
||||
{/* Notebook Mover & Pin/Star Row */}
|
||||
<div className="flex items-center justify-between gap-1 text-[10px] text-gray-400 mb-1">
|
||||
<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'
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
draggable={!n.isTrashed}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/everclone-note-id', n.id);
|
||||
}}
|
||||
onClick={() => openNoteInEditor(n.id)}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
{/* Top Hero Cover Image Banner (if present) */}
|
||||
{effectiveThumbUrl ? (
|
||||
<div className="group/thumb relative h-28 w-full bg-gray-100 border-b border-gray-100 overflow-hidden shrink-0">
|
||||
<img
|
||||
src={effectiveThumbUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover group-hover/card:scale-105 transition duration-300"
|
||||
/>
|
||||
{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">
|
||||
PDF
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
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
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* Card Content */}
|
||||
<div className="p-3 flex-1 flex flex-col justify-between space-y-2">
|
||||
<div>
|
||||
{/* Notebook Mover & Pin/Star Row */}
|
||||
<div className="flex items-center justify-between gap-1 text-[10px] text-gray-400 mb-1">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user