(null);
@@ -339,15 +275,11 @@ export function ExecutableCodeBlockNodeView({
{/* Code Content */}
-
+
{
- if (!editor || !note || !editor.view) return;
-
- const currentContent = editor.getHTML();
- if (note.content !== currentContent) {
- const editorHasFocus = editor.isFocused;
-
- if (!editorHasFocus) {
- const { from, to } = editor.state.selection;
-
- editor.commands.setContent(note.content || '', {
- emitUpdate: false,
- parseOptions: {
- preserveWhitespace: 'full',
- },
- });
- lastContentRef.current = note.content || '';
-
- const text = editor.state.doc.textContent;
- updateCounts(text);
-
- try {
- const docSize = editor.state.doc.content.size;
- if (from <= docSize && to <= docSize) {
- editor.commands.setTextSelection({ from, to });
+ if (!editor || !note) return;
+
+ try {
+ // Check if view and dom are available
+ if (!editor.view || !editor.view.dom) return;
+
+ const currentContent = editor.getHTML();
+ if (note.content !== currentContent) {
+ const editorHasFocus = editor.isFocused;
+
+ if (!editorHasFocus) {
+ const { from, to } = editor.state.selection;
+
+ editor.commands.setContent(note.content || '', {
+ emitUpdate: false,
+ parseOptions: {
+ preserveWhitespace: 'full',
+ },
+ });
+ lastContentRef.current = note.content || '';
+
+ const text = editor.state.doc.textContent;
+ updateCounts(text);
+
+ try {
+ const docSize = editor.state.doc.content.size;
+ if (from <= docSize && to <= docSize) {
+ editor.commands.setTextSelection({ from, to });
+ }
+ } catch {
+ // Ignore cursor restoration errors
}
- } catch {
- // Ignore cursor restoration errors
+ } else {
+ lastContentRef.current = currentContent;
}
- } else {
- lastContentRef.current = currentContent;
}
+ } catch {
+ // Silently ignore errors when editor is in transitional state
+ // This can happen during mount/unmount cycles
}
}, [note, editor, updateCounts, lastContentRef]);
// Initialize word count when editor is ready
useEffect(() => {
- if (!editor || !editor.view || !editor.view.dom) return;
+ if (!editor) return;
+
+ try {
+ if (!editor.view || !editor.view.dom) return;
- // Calculate initial word count
- const text = editor.state.doc.textContent;
- updateCounts(text);
+ // Calculate initial word count
+ const text = editor.state.doc.textContent;
+ updateCounts(text);
+ } catch {
+ // Silently ignore errors during initialization
+ }
}, [editor, updateCounts]);
// Track scroll percentage
useEffect(() => {
- if (!editor || !editor.view || !editor.view.dom) return;
+ if (!editor) return;
- const updateScrollPercentage = () => {
- const editorView = editor.view;
- if (!editorView || !editorView.dom) return;
+ try {
+ if (!editor.view || !editor.view.dom) return;
- const { scrollTop, scrollHeight, clientHeight } = editorView.dom;
- const maxScroll = scrollHeight - clientHeight;
+ const updateScrollPercentage = () => {
+ try {
+ if (!editor || !editor.view || !editor.view.dom) return;
- if (maxScroll <= 0) {
- setScrollPercentage(0);
- } else {
- const percentage = Math.round((scrollTop / maxScroll) * 100);
- setScrollPercentage(Math.min(100, Math.max(0, percentage)));
- }
- };
+ const editorView = editor.view;
+ if (!editorView || !editorView.dom) return;
- const editorElement = editor.view.dom;
- editorElement.addEventListener('scroll', updateScrollPercentage);
+ const { scrollTop, scrollHeight, clientHeight } = editorView.dom;
+ const maxScroll = scrollHeight - clientHeight;
- // Initial calculation
- updateScrollPercentage();
+ if (maxScroll <= 0) {
+ setScrollPercentage(0);
+ } else {
+ const percentage = Math.round((scrollTop / maxScroll) * 100);
+ setScrollPercentage(Math.min(100, Math.max(0, percentage)));
+ }
+ } catch {
+ // Silently ignore scroll errors
+ }
+ };
+
+ const editorElement = editor.view.dom;
+ editorElement.addEventListener('scroll', updateScrollPercentage);
+
+ // Initial calculation
+ updateScrollPercentage();
- return () => {
- editorElement.removeEventListener('scroll', updateScrollPercentage);
- };
+ return () => {
+ if (editorElement) {
+ editorElement.removeEventListener('scroll', updateScrollPercentage);
+ }
+ };
+ } catch {
+ // Silently ignore setup errors
+ }
}, [editor, setScrollPercentage]);
// Store the original font size when editor is first created
useEffect(() => {
- if (!editor || !editor.view || !editor.view.dom || baseFontSize) return;
+ if (!editor || baseFontSize) return;
+
+ try {
+ if (!editor.view || !editor.view.dom) return;
- const editorElement = editor.view.dom as HTMLElement;
- const computedStyle = window.getComputedStyle(editorElement);
- const originalFontSize = computedStyle.fontSize;
- setBaseFontSize(originalFontSize);
+ const editorElement = editor.view.dom as HTMLElement;
+ const computedStyle = window.getComputedStyle(editorElement);
+ const originalFontSize = computedStyle.fontSize;
+ setBaseFontSize(originalFontSize);
+ } catch {
+ // Silently ignore font size initialization errors
+ }
}, [editor, baseFontSize, setBaseFontSize]);
// Apply zoom level to editor
useEffect(() => {
- if (!editor || !editor.view || !editor.view.dom || !baseFontSize) return;
-
- const editorElement = editor.view.dom as HTMLElement;
-
- if (zoomLevel === 100) {
- // At 100%, use the original font size
- editorElement.style.fontSize = baseFontSize;
- } else {
- // Calculate the new font size based on the original
- const baseSize = parseFloat(baseFontSize);
- const scaleFactor = zoomLevel / 100;
- const newSize = baseSize * scaleFactor;
- editorElement.style.fontSize = `${newSize}px`;
+ if (!editor || !baseFontSize) return;
+
+ try {
+ if (!editor.view || !editor.view.dom) return;
+
+ const editorElement = editor.view.dom as HTMLElement;
+
+ if (zoomLevel === 100) {
+ // At 100%, use the original font size
+ editorElement.style.fontSize = baseFontSize;
+ } else {
+ // Calculate the new font size based on the original
+ const baseSize = parseFloat(baseFontSize);
+ const scaleFactor = zoomLevel / 100;
+ const newSize = baseSize * scaleFactor;
+ editorElement.style.fontSize = `${newSize}px`;
+ }
+ } catch {
+ // Silently ignore zoom level application errors
}
}, [editor, zoomLevel, baseFontSize]);
}
diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx
index 43c17ed..0addb0b 100644
--- a/src/components/layout/MainLayout.tsx
+++ b/src/components/layout/MainLayout.tsx
@@ -84,15 +84,22 @@ export default function MainLayout() {
const handleEmptyTrash = useCallback(async () => {
try {
- await api.emptyTrash();
- if (selectedNote && currentView === 'trash') {
+ // Clear selected note if it's in trash, regardless of current view
+ if (selectedNote?.deleted) {
setSelectedNote(null);
}
+
+ // Optimistically remove all trashed notes from UI immediately
+ setNotes(prevNotes => prevNotes.filter(note => !note.deleted));
+
+ await api.emptyTrash();
await refetch();
} catch (error) {
console.error('Failed to empty trash:', error);
+ // Refetch to restore correct state on error
+ await refetch();
}
- }, [selectedNote, currentView, setSelectedNote, refetch]);
+ }, [selectedNote, setSelectedNote, setNotes, refetch]);
const handleToggleFolderPanel = useCallback(() => {
if (!isMobile) {
diff --git a/src/components/notes/NotesPanel/NotesList.tsx b/src/components/notes/NotesPanel/NotesList.tsx
index b0edd57..3c937bc 100644
--- a/src/components/notes/NotesPanel/NotesList.tsx
+++ b/src/components/notes/NotesPanel/NotesList.tsx
@@ -1,17 +1,7 @@
import { useState } from 'react';
-import { Trash2, AlertTriangle } from 'lucide-react';
+import { Trash2 } from 'lucide-react';
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from '@/components/ui/alert-dialog.tsx';
import { Button } from '@/components/ui/button.tsx';
import type { Note, Folder } from '@/types/note.ts';
@@ -38,16 +28,15 @@ export default function NotesList({
emptyMessage,
folders,
}: NotesListProps) {
- const [showEmptyTrashDialog, setShowEmptyTrashDialog] = useState(false);
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
const handleEmptyTrash = async () => {
- if (!onEmptyTrash) return;
+ if (!onEmptyTrash || isEmptyingTrash) return;
setIsEmptyingTrash(true);
+
try {
await onEmptyTrash();
- setShowEmptyTrashDialog(false);
} catch (error) {
console.error('Failed to empty trash:', error);
} finally {
@@ -70,11 +59,18 @@ export default function NotesList({
@@ -98,47 +94,6 @@ export default function NotesList({
)}
-
-
-
-
-
-
- Empty Trash
-
-
- Are you sure you want to permanently delete all {notes.length}{' '}
- item{notes.length !== 1 ? 's' : ''} in the trash? This action
- cannot be undone.
-
-
-
-
- Cancel
-
- void handleEmptyTrash()}
- disabled={isEmptyingTrash}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isEmptyingTrash ? (
- <>
-
- Emptying...
- >
- ) : (
- <>
-
- Empty Trash
- >
- )}
-
-
-
-
>
);
}
diff --git a/src/contexts/MonacoThemeContext.tsx b/src/contexts/MonacoThemeContext.tsx
new file mode 100644
index 0000000..d895ae3
--- /dev/null
+++ b/src/contexts/MonacoThemeContext.tsx
@@ -0,0 +1,36 @@
+import { createContext, useContext, useState, type ReactNode } from 'react';
+
+type MonacoTheme = 'light' | 'dark';
+
+interface MonacoThemeContextType {
+ theme: MonacoTheme;
+ toggleTheme: () => void;
+ setTheme: (theme: MonacoTheme) => void;
+}
+
+const MonacoThemeContext = createContext(
+ undefined
+);
+
+export function MonacoThemeProvider({ children }: { children: ReactNode }) {
+ const [theme, setTheme] = useState('dark');
+
+ const toggleTheme = () => {
+ setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useMonacoTheme() {
+ const context = useContext(MonacoThemeContext);
+ if (context === undefined) {
+ throw new Error('useMonacoTheme must be used within a MonacoThemeProvider');
+ }
+ return context;
+}
diff --git a/src/hooks/useNotes.ts b/src/hooks/useNotes.ts
index bd5a40c..64ac257 100644
--- a/src/hooks/useNotes.ts
+++ b/src/hooks/useNotes.ts
@@ -29,20 +29,20 @@ const convertApiFolder = (apiFolder: ApiFolder): Folder => ({
// Helper function for safe date conversion
const safeConvertDates = (item: Note | Folder): void => {
- if (item.createdAt && !(item.createdAt instanceof Date)) {
- item.createdAt = new Date(item.createdAt as unknown as string);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const itemAny = item as any;
+
+ if (itemAny.createdAt && typeof itemAny.createdAt === 'string') {
+ item.createdAt = new Date(itemAny.createdAt);
}
// Note-specific properties
- if (
- 'updatedAt' in item &&
- item.updatedAt &&
- !(item.updatedAt instanceof Date)
- ) {
- item.updatedAt = new Date(item.updatedAt as unknown as string);
+ if ('updatedAt' in item && itemAny.updatedAt && typeof itemAny.updatedAt === 'string') {
+ (item as Note).updatedAt = new Date(itemAny.updatedAt);
}
- if ('hiddenAt' in item && item.hiddenAt && !(item.hiddenAt instanceof Date)) {
- item.hiddenAt = new Date(item.hiddenAt as unknown as string);
+
+ if ('hiddenAt' in item && itemAny.hiddenAt && typeof itemAny.hiddenAt === 'string') {
+ (item as Note).hiddenAt = new Date(itemAny.hiddenAt);
}
};
@@ -98,44 +98,36 @@ export function useNotes() {
// Define helper functions early for the sync handlers
const fetchAllFolders = useCallback(async (): Promise => {
- let allFolders: Folder[] = [];
- let page = 1;
- let hasMorePages = true;
-
- while (hasMorePages) {
- const foldersResponse = await retryWithBackoff(() =>
- api.getFolders({ page, limit: 50 })
- );
- const convertedFolders = foldersResponse.folders.map(convertApiFolder);
- allFolders = [...allFolders, ...convertedFolders];
-
- // Check if we have more pages using new pagination structure
- if (foldersResponse.pagination) {
- hasMorePages = page < foldersResponse.pagination.pages;
- } else {
- // Fallback - assume more pages if we got a full page
- hasMorePages = convertedFolders.length >= 50;
- }
-
- // Also check if we received fewer folders than the limit, which means we're on the last page
- if (convertedFolders.length < 50) {
- hasMorePages = false;
- }
+ // Fetch first page to determine total pages
+ const firstPageResponse = await retryWithBackoff(() =>
+ api.getFolders({ page: 1, limit: 50 })
+ );
+ const firstPageFolders = firstPageResponse.folders.map(convertApiFolder);
- page++;
+ // Determine total pages
+ const totalPages = firstPageResponse.pagination?.pages ?? 1;
- // Safety break to prevent infinite loops
- if (page > 50) {
- hasMorePages = false;
- }
+ // If only one page, return immediately
+ if (totalPages <= 1) {
+ return firstPageFolders;
+ }
- // Add small delay between requests to avoid rate limiting
- if (hasMorePages) {
- await new Promise((resolve) => setTimeout(resolve, 100));
- }
+ // Fetch remaining pages in parallel
+ const remainingPagePromises = [];
+ for (let page = 2; page <= Math.min(totalPages, 50); page++) {
+ remainingPagePromises.push(
+ retryWithBackoff(() => api.getFolders({ page, limit: 50 }))
+ .then(response => response.folders.map(convertApiFolder))
+ );
}
- return allFolders;
+ const remainingPages = await Promise.all(remainingPagePromises);
+
+ // Combine all pages
+ return [
+ ...firstPageFolders,
+ ...remainingPages.flat()
+ ];
}, []);
const refetchFolders = useCallback(async () => {
@@ -159,8 +151,9 @@ export function useNotes() {
});
// WebSocket integration for real-time sync
+ // Defer connection until after initial data load for better performance
const webSocket = useWebSocket({
- autoConnect: true,
+ autoConnect: false,
...syncHandlers,
onError: useCallback((error: Error | { message?: string }) => {
// Only show connection errors to users if they persist
@@ -223,44 +216,36 @@ export function useNotes() {
}, [clerkUser]);
const fetchAllNotes = useCallback(async (): Promise => {
- let allNotes: Note[] = [];
- let page = 1;
- let hasMorePages = true;
-
- while (hasMorePages) {
- const notesResponse = await retryWithBackoff(() =>
- api.getNotes({ page, limit: 50 })
- );
- const convertedNotes = notesResponse.notes.map(convertApiNote);
- allNotes = [...allNotes, ...convertedNotes];
-
- // Check if we have more pages using new pagination structure
- if (notesResponse.pagination) {
- hasMorePages = page < notesResponse.pagination.pages;
- } else {
- // Fallback - assume more pages if we got a full page
- hasMorePages = convertedNotes.length >= 50;
- }
-
- // Also check if we received fewer notes than the limit, which means we're on the last page
- if (convertedNotes.length < 50) {
- hasMorePages = false;
- }
+ // Fetch first page to determine total pages
+ const firstPageResponse = await retryWithBackoff(() =>
+ api.getNotes({ page: 1, limit: 50 })
+ );
+ const firstPageNotes = firstPageResponse.notes.map(convertApiNote);
- page++;
+ // Determine total pages
+ const totalPages = firstPageResponse.pagination?.pages ?? 1;
- // Safety break to prevent infinite loops
- if (page > 50) {
- hasMorePages = false;
- }
+ // If only one page, return immediately
+ if (totalPages <= 1) {
+ return firstPageNotes;
+ }
- // Add small delay between requests to avoid rate limiting
- if (hasMorePages) {
- await new Promise((resolve) => setTimeout(resolve, 100));
- }
+ // Fetch remaining pages in parallel
+ const remainingPagePromises = [];
+ for (let page = 2; page <= Math.min(totalPages, 50); page++) {
+ remainingPagePromises.push(
+ retryWithBackoff(() => api.getNotes({ page, limit: 50 }))
+ .then(response => response.notes.map(convertApiNote))
+ );
}
- return allNotes;
+ const remainingPages = await Promise.all(remainingPagePromises);
+
+ // Combine all pages
+ return [
+ ...firstPageNotes,
+ ...remainingPages.flat()
+ ];
}, []);
const loadData = useCallback(async () => {
@@ -278,33 +263,20 @@ export function useNotes() {
// Create a folder map for quick lookup
const folderMap = new Map(allFolders.map((f) => [f.id, f]));
- const notesWithAttachmentsAndFolders = await Promise.all(
- allNotes.map(async (note) => {
- try {
- const attachments = await fileService.getAttachments(note.id);
- // Embed folder data if note has a folderId
- const folder = note.folderId
- ? folderMap.get(note.folderId)
- : undefined;
- return { ...note, attachments, folder };
- } catch (error) {
- secureLogger.warn('Failed to load attachments for note', {
- noteId: '[REDACTED]',
- error,
- });
- const folder = note.folderId
- ? folderMap.get(note.folderId)
- : undefined;
- return { ...note, attachments: [], folder };
- }
- })
- );
+ // Defer attachment loading - only add folder data initially
+ // Attachments will be loaded on-demand when a note is selected
+ const notesWithFolders = allNotes.map((note) => {
+ const folder = note.folderId
+ ? folderMap.get(note.folderId)
+ : undefined;
+ return { ...note, attachments: [], folder };
+ });
- setNotes(notesWithAttachmentsAndFolders);
+ setNotes(notesWithFolders);
setSelectedNote((prev) => {
- if (prev === null && notesWithAttachmentsAndFolders.length > 0) {
- return notesWithAttachmentsAndFolders[0];
+ if (prev === null && notesWithFolders.length > 0) {
+ return notesWithFolders[0];
}
return prev;
});
@@ -347,6 +319,17 @@ export function useNotes() {
}
}, [encryptionReady, clerkUser, loadData]);
+ // Connect WebSocket after initial data load for better performance
+ useEffect(() => {
+ if (!loading && encryptionReady && notes.length > 0 && !webSocket.isConnected) {
+ // Delay connection slightly to ensure UI has rendered
+ const timer = setTimeout(() => {
+ webSocket.connect();
+ }, 100);
+ return () => clearTimeout(timer);
+ }
+ }, [loading, encryptionReady, notes.length, webSocket]);
+
// Notes filtering
const {
filteredNotes,
@@ -409,9 +392,53 @@ export function useNotes() {
// Auto-select a note when selectedNote becomes null and there are available notes
useEffect(() => {
if (!selectedNote && filteredNotes.length > 0) {
- setSelectedNote(filteredNotes[0]);
+ // Don't auto-select notes that are in trash unless explicitly viewing trash
+ const firstNonTrashedNote = filteredNotes.find(note => !note.deleted);
+ if (firstNonTrashedNote) {
+ setSelectedNote(firstNonTrashedNote);
+ } else if (currentView === 'trash') {
+ // Only select trash notes if we're explicitly viewing trash
+ setSelectedNote(filteredNotes[0]);
+ }
}
- }, [selectedNote, filteredNotes]);
+ }, [selectedNote, filteredNotes, currentView]);
+
+ // Load attachments on-demand when a note is selected
+ useEffect(() => {
+ if (!selectedNote?.id) return;
+
+ // Skip if attachments already loaded
+ if (selectedNote.attachments && selectedNote.attachments.length > 0) return;
+
+ const loadAttachments = async () => {
+ try {
+ const attachments = await fileService.getAttachments(selectedNote.id);
+
+ // Update the note in the notes list
+ setNotes((prev) =>
+ prev.map((note) =>
+ note.id === selectedNote.id
+ ? { ...note, attachments }
+ : note
+ )
+ );
+
+ // Update selected note
+ setSelectedNote((prev) =>
+ prev?.id === selectedNote.id
+ ? { ...prev, attachments }
+ : prev
+ );
+ } catch (error) {
+ secureLogger.warn('Failed to load attachments for selected note', {
+ noteId: '[REDACTED]',
+ error,
+ });
+ }
+ };
+
+ void loadAttachments();
+ }, [selectedNote?.id, selectedNote?.attachments, setNotes, setSelectedNote]);
const createFolder = async (
name: string,
diff --git a/src/main.tsx b/src/main.tsx
index d0c5528..d3ad77e 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,6 +1,7 @@
import { StrictMode } from 'react';
import { ClerkProvider } from '@clerk/clerk-react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createHead, UnheadProvider } from '@unhead/react/client';
import { createRoot } from 'react-dom/client';
@@ -27,16 +28,30 @@ if (!rootElement) {
const head = createHead();
+// Configure React Query client with optimized defaults
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 60 * 5, // Data stays fresh for 5 minutes
+ gcTime: 1000 * 60 * 30, // Cache kept for 30 minutes
+ refetchOnWindowFocus: false, // Don't refetch on window focus for better UX
+ retry: 1, // Retry failed requests once
+ },
+ },
+});
+
createRoot(rootElement).render(
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
diff --git a/vite.config.ts b/vite.config.ts
index 4c2954c..a6028dd 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,7 +6,7 @@ import { defineConfig, loadEnv } from 'vite';
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
-
+
return {
plugins: [react(), tailwindcss()],
resolve: {
@@ -14,6 +14,54 @@ export default defineConfig(({ mode }) => {
'@': path.resolve(__dirname, './src'),
},
},
+ build: {
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ // Monaco Editor - large code editor bundle
+ 'monaco-editor': ['monaco-editor', '@monaco-editor/react'],
+
+ // Tiptap core - rich text editor core
+ 'tiptap-core': ['@tiptap/react', '@tiptap/core', '@tiptap/starter-kit'],
+
+ // Tiptap extensions - split from core for better caching
+ 'tiptap-extensions': [
+ '@tiptap/extension-code-block-lowlight',
+ '@tiptap/extension-color',
+ '@tiptap/extension-dropcursor',
+ '@tiptap/extension-highlight',
+ '@tiptap/extension-horizontal-rule',
+ '@tiptap/extension-image',
+ '@tiptap/extension-link',
+ '@tiptap/extension-task-item',
+ '@tiptap/extension-task-list',
+ '@tiptap/extension-text-style',
+ '@tiptap/extension-underline',
+ ],
+
+ // Clerk authentication
+ 'clerk': ['@clerk/clerk-react'],
+
+ // Radix UI components
+ 'radix-ui': [
+ '@radix-ui/react-alert-dialog',
+ '@radix-ui/react-dialog',
+ '@radix-ui/react-dropdown-menu',
+ '@radix-ui/react-label',
+ '@radix-ui/react-scroll-area',
+ '@radix-ui/react-slot',
+ '@radix-ui/react-tabs',
+ ],
+
+ // React Query
+ 'react-query': ['@tanstack/react-query'],
+
+ // Syntax highlighting
+ 'syntax-highlight': ['highlight.js', 'lowlight'],
+ },
+ },
+ },
+ },
server: {
proxy: {
'/api': {