From 2adb5b04c93f1a7ff6bb51f8df39e8cc95557e04 Mon Sep 17 00:00:00 2001 From: Rui Costa Date: Thu, 16 Oct 2025 17:11:37 -0400 Subject: [PATCH] fix(notes): improve NEW badge UX and add star button loading state - Reduce NEW badge size (9px font, minimal padding) with right spacing - Remove badge on any title change instead of only from defaults - Fix cross-tab sync for badge removal via WebSocket - Update notes list when manually refreshing note from editor - Add delayed spinner (500ms) to star button in editor panel - Expose setNotes from useNotes hook for manual refresh updates --- .../editor/hooks/useEditorEffects.ts | 10 +-- src/components/editor/index.tsx | 20 ++++- src/components/layout/MainLayout.tsx | 39 ++++++++- src/components/notes/NotesPanel/NoteCard.tsx | 5 ++ src/components/notes/NotesPanel/index.tsx | 31 +++---- src/hooks/useNotes.ts | 80 +++++++++++-------- src/hooks/useNotesOperations.ts | 68 ++++++++++++++-- src/hooks/useNotesSync.ts | 11 ++- src/lib/api/api.ts | 15 ++-- src/types/layout.ts | 3 + src/types/note.ts | 2 + 11 files changed, 205 insertions(+), 79 deletions(-) diff --git a/src/components/editor/hooks/useEditorEffects.ts b/src/components/editor/hooks/useEditorEffects.ts index 4c8aea5..579f96a 100644 --- a/src/components/editor/hooks/useEditorEffects.ts +++ b/src/components/editor/hooks/useEditorEffects.ts @@ -63,7 +63,7 @@ export function useEditorEffects({ // Initialize word count when editor is ready useEffect(() => { - if (!editor || !editor.view) return; + if (!editor || !editor.view || !editor.view.dom) return; // Calculate initial word count const text = editor.state.doc.textContent; @@ -72,11 +72,11 @@ export function useEditorEffects({ // Track scroll percentage useEffect(() => { - if (!editor || !editor.view) return; + if (!editor || !editor.view || !editor.view.dom) return; const updateScrollPercentage = () => { const editorView = editor.view; - if (!editorView.dom) return; + if (!editorView || !editorView.dom) return; const { scrollTop, scrollHeight, clientHeight } = editorView.dom; const maxScroll = scrollHeight - clientHeight; @@ -102,7 +102,7 @@ export function useEditorEffects({ // Store the original font size when editor is first created useEffect(() => { - if (!editor || !editor.view || baseFontSize) return; + if (!editor || !editor.view || !editor.view.dom || baseFontSize) return; const editorElement = editor.view.dom as HTMLElement; const computedStyle = window.getComputedStyle(editorElement); @@ -112,7 +112,7 @@ export function useEditorEffects({ // Apply zoom level to editor useEffect(() => { - if (!editor || !editor.view || !baseFontSize) return; + if (!editor || !editor.view || !editor.view.dom || !baseFontSize) return; const editorElement = editor.view.dom as HTMLElement; diff --git a/src/components/editor/index.tsx b/src/components/editor/index.tsx index c7ba6d9..fee4620 100644 --- a/src/components/editor/index.tsx +++ b/src/components/editor/index.tsx @@ -70,7 +70,9 @@ interface NoteEditorProps { onDeleteNote: (noteId: string) => void; onArchiveNote: (noteId: string) => void; onToggleStar: (noteId: string) => void; + starringStar?: boolean; onHideNote: (noteId: string) => void; + hidingNote?: boolean; onUnhideNote: (noteId: string) => void; onRefreshNote?: (noteId: string) => void; userId?: string; @@ -92,7 +94,9 @@ export default function Index({ onDeleteNote, onArchiveNote, onToggleStar, + starringStar = false, onHideNote, + hidingNote = false, onUnhideNote, onRefreshNote, userId = 'current-user', @@ -671,10 +675,15 @@ export default function Index({ note.starred ? 'text-yellow-500' : 'text-muted-foreground' } title={note.starred ? 'Remove from starred' : 'Add to starred'} + disabled={starringStar} > - + {starringStar ? ( +
+ ) : ( + + )} diff --git a/src/hooks/useNotes.ts b/src/hooks/useNotes.ts index ed72d6c..740f8c9 100644 --- a/src/hooks/useNotes.ts +++ b/src/hooks/useNotes.ts @@ -29,24 +29,24 @@ const convertApiFolder = (apiFolder: ApiFolder): Folder => ({ // Helper function for safe date conversion const safeConvertDates = (item: Note | Folder): void => { - if (item.createdAt && typeof item.createdAt === 'string') { - item.createdAt = new Date(item.createdAt); + if (item.createdAt && !(item.createdAt instanceof Date)) { + item.createdAt = new Date(item.createdAt as unknown as string); } // Note-specific properties if ( 'updatedAt' in item && item.updatedAt && - typeof item.updatedAt === 'string' + !(item.updatedAt instanceof Date) ) { - item.updatedAt = new Date(item.updatedAt); + item.updatedAt = new Date(item.updatedAt as unknown as string); } if ( 'hiddenAt' in item && item.hiddenAt && - typeof item.hiddenAt === 'string' + !(item.hiddenAt instanceof Date) ) { - item.hiddenAt = new Date(item.hiddenAt); + item.hiddenAt = new Date(item.hiddenAt as unknown as string); } }; @@ -113,13 +113,9 @@ export function useNotes() { const convertedFolders = foldersResponse.folders.map(convertApiFolder); allFolders = [...allFolders, ...convertedFolders]; - // Check if we have more pages - handle different API response structures - if (foldersResponse.total !== undefined && foldersResponse.limit !== undefined) { - // Use API total if available - const totalPages = Math.ceil( - foldersResponse.total / foldersResponse.limit - ); - hasMorePages = page < totalPages; + // 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; @@ -208,8 +204,14 @@ export function useNotes() { error.message.includes('Token provider not set') ) { // Don't throw error, just continue without API call + secureLogger.warn('Token provider not ready, continuing without API call'); } else { - throw error; + // Re-throw for outer catch to handle + secureLogger.error('Failed to get current user', error); + setError('Failed to initialize user account'); + setEncryptionReady(false); + setLoading(false); + return; } } } @@ -234,11 +236,9 @@ export function useNotes() { const convertedNotes = notesResponse.notes.map(convertApiNote); allNotes = [...allNotes, ...convertedNotes]; - // Check if we have more pages - handle different API response structures - if (notesResponse.total !== undefined && notesResponse.limit !== undefined) { - // Use API total if available - const totalPages = Math.ceil(notesResponse.total / notesResponse.limit); - hasMorePages = page < totalPages; + // 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; @@ -366,7 +366,15 @@ export function useNotes() { }); // Notes operations - const notesOperations = useNotesOperations({ + const { + createNote: notesOpsCreateNote, + creatingNote, + hideNote: notesOpsHideNote, + hidingNote, + toggleStar: notesOpsToggleStar, + starringStar, + ...restNotesOperations + } = useNotesOperations({ folders, selectedNote, selectedFolder, @@ -412,7 +420,7 @@ export function useNotes() { color?: string, parentId?: string ) => { - const newFolder = await notesOperations.createFolder(name, color, parentId); + const newFolder = await restNotesOperations.createFolder(name, color, parentId); if (parentId) { setExpandedFolders((prev) => new Set([...prev, parentId])); @@ -423,13 +431,13 @@ export function useNotes() { const updateNote = useCallback( async (noteId: string, updates: Partial) => { - await notesOperations.updateNote(noteId, updates); + await restNotesOperations.updateNote(noteId, updates); }, - [notesOperations] + [restNotesOperations] ); const deleteNote = async (noteId: string) => { - await notesOperations.deleteNote(noteId); + await restNotesOperations.deleteNote(noteId); if (selectedNote?.id === noteId) { const remainingNotes = filteredNotes.filter((note) => note.id !== noteId); @@ -438,7 +446,7 @@ export function useNotes() { }; const archiveNote = async (noteId: string) => { - await notesOperations.archiveNote(noteId); + await restNotesOperations.archiveNote(noteId); if (selectedNote?.id === noteId) { const remainingNotes = filteredNotes.filter((note) => note.id !== noteId); @@ -447,7 +455,7 @@ export function useNotes() { }; const updateFolder = async (folderId: string, updates: Partial) => { - const updatedFolder = await notesOperations.updateFolder(folderId, updates); + const updatedFolder = await restNotesOperations.updateFolder(folderId, updates); if (selectedFolder?.id === folderId) { setSelectedFolder(updatedFolder); @@ -513,7 +521,7 @@ export function useNotes() { }; const permanentlyDeleteNote = (noteId: string) => { - notesOperations.permanentlyDeleteNote(noteId); + restNotesOperations.permanentlyDeleteNote(noteId); if (selectedNote?.id === noteId) { const remainingNotes = filteredNotes.filter((note) => note.id !== noteId); @@ -522,7 +530,7 @@ export function useNotes() { }; const moveNoteToFolder = async (noteId: string, folderId: string | null) => { - await notesOperations.moveNoteToFolder(noteId, folderId); + await restNotesOperations.moveNoteToFolder(noteId, folderId); }; return { @@ -541,18 +549,21 @@ export function useNotes() { archivedCount, trashedCount, hiddenCount, - createNote: notesOperations.createNote, + createNote: notesOpsCreateNote, + creatingNote, createFolder, updateNote, updateFolder, deleteNote, - deleteFolder: notesOperations.deleteFolder, + deleteFolder: restNotesOperations.deleteFolder, reorderFolders, - toggleStar: notesOperations.toggleStar, + toggleStar: notesOpsToggleStar, + starringStar, archiveNote, - restoreNote: notesOperations.restoreNote, - hideNote: notesOperations.hideNote, - unhideNote: notesOperations.unhideNote, + restoreNote: restNotesOperations.restoreNote, + hideNote: notesOpsHideNote, + hidingNote, + unhideNote: restNotesOperations.unhideNote, permanentlyDeleteNote, moveNoteToFolder, toggleFolderExpansion, @@ -560,6 +571,7 @@ export function useNotes() { setSelectedFolder, setCurrentView, setSearchQuery, + setNotes, setFolders, refetch: loadData, reinitialize: async () => { diff --git a/src/hooks/useNotesOperations.ts b/src/hooks/useNotesOperations.ts index 8f5bdac..ff58300 100644 --- a/src/hooks/useNotesOperations.ts +++ b/src/hooks/useNotesOperations.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { api } from '@/lib/api/api'; import type { Note, Folder } from '@/types/note'; import type { UseWebSocketReturn } from './useWebSocket'; @@ -37,6 +37,9 @@ export function useNotesOperations({ getDescendantIds, }: UseNotesOperationsParams) { const saveTimeoutsRef = useRef>(new Map()); + const [creatingNote, setCreatingNote] = useState(false); + const [hidingNote, setHidingNote] = useState(false); + const [starringStar, setStarringStar] = useState(false); // Note CRUD Operations const createNote = useCallback( @@ -44,14 +47,24 @@ export function useNotesOperations({ folderId?: string, templateContent?: { title: string; content: string } ) => { + let showSpinner = false; + const spinnerTimeout = setTimeout(() => { + showSpinner = true; + setCreatingNote(true); + }, 500); + try { if (!encryptionReady) { - throw new SecureError( + const secureError = new SecureError( 'Note creation attempted without encryption ready', 'Encryption not ready. Please wait a moment and try again.', 'CRYPTO_001', 'medium' ); + logSecureError(secureError, 'useNotesOperations.createNote'); + secureLogger.error('Note creation failed - encryption not ready'); + setError(secureError.userMessage); + throw secureError; } const noteFolderId = folderId ?? selectedFolder?.id ?? null; @@ -69,6 +82,7 @@ export function useNotesOperations({ const noteWithFolder = convertApiNote(newNote); noteWithFolder.folder = folder; + noteWithFolder.isNew = true; // Mark as new setNotes((prev) => [noteWithFolder, ...prev]); setSelectedNote(noteWithFolder); @@ -85,6 +99,11 @@ export function useNotesOperations({ secureLogger.error('Note creation failed', error); setError(secureError.userMessage); throw secureError; + } finally { + clearTimeout(spinnerTimeout); + if (showSpinner) { + setCreatingNote(false); + } } }, [ @@ -173,10 +192,13 @@ export function useNotesOperations({ const newFolder = updatedNote.folderId ? folders.find((f) => f.id === updatedNote.folderId) : undefined; + + // Remove isNew badge if title was changed return { ...updatedNote, attachments: note.attachments, folder: newFolder, + isNew: updates.title !== undefined ? false : note.isNew, }; }) ); @@ -185,10 +207,13 @@ export function useNotesOperations({ const newFolder = updatedNote.folderId ? folders.find((f) => f.id === updatedNote.folderId) : undefined; + + // Remove isNew badge if title was changed setSelectedNote({ ...updatedNote, attachments: selectedNote.attachments, folder: newFolder, + isNew: updates.title !== undefined ? false : selectedNote.isNew, }); } @@ -215,12 +240,17 @@ export function useNotesOperations({ !encryptionReady && (updates.title !== undefined || updates.content !== undefined) ) { - throw new SecureError( + const secureError = new SecureError( 'Note update attempted without encryption ready', 'Encryption not ready. Please wait a moment and try again.', 'CRYPTO_001', 'medium' ); + logSecureError(secureError, 'useNotesOperations.updateNote.delayed'); + secureLogger.error('Failed to update note (delayed) - encryption not ready'); + setError('Failed to encrypt note changes. Please try again.'); + void loadData(); + return; } // API call for server persistence (follows WebSocket update) @@ -239,11 +269,7 @@ export function useNotesOperations({ const secureError = sanitizeError(error, 'Failed to update note'); logSecureError(secureError, 'useNotesOperations.updateNote.delayed'); secureLogger.error('Failed to update note (delayed):', error); - if (error instanceof SecureError && error.code === 'CRYPTO_001') { - setError('Failed to encrypt note changes. Please try again.'); - } else { - setError(secureError.userMessage); - } + setError(secureError.userMessage); void loadData(); } }, 500); // Reduced from 1500ms to 500ms for faster sync @@ -255,6 +281,7 @@ export function useNotesOperations({ encryptionReady, selectedNote?.id, selectedNote?.attachments, + selectedNote?.isNew, folders, loadData, convertApiNote, @@ -287,6 +314,12 @@ export function useNotesOperations({ // Note action operations const toggleStar = useCallback( async (noteId: string) => { + let showSpinner = false; + const spinnerTimeout = setTimeout(() => { + showSpinner = true; + setStarringStar(true); + }, 500); + try { const apiNote = await api.toggleStarNote(noteId); const updatedNote = convertApiNote(apiNote); @@ -320,6 +353,11 @@ export function useNotesOperations({ logSecureError(secureError, 'useNotesOperations.toggleStar'); secureLogger.error('Failed to toggle star:', error); setError(secureError.userMessage); + } finally { + clearTimeout(spinnerTimeout); + if (showSpinner) { + setStarringStar(false); + } } }, [ @@ -371,6 +409,12 @@ export function useNotesOperations({ const hideNote = useCallback( async (noteId: string) => { + let showSpinner = false; + const spinnerTimeout = setTimeout(() => { + showSpinner = true; + setHidingNote(true); + }, 500); + try { const apiNote = await api.hideNote(noteId); const hiddenNote = convertApiNote(apiNote); @@ -407,6 +451,11 @@ export function useNotesOperations({ logSecureError(secureError, 'useNotesOperations.hideNote'); secureLogger.error('Failed to hide note:', error); setError(secureError.userMessage); + } finally { + clearTimeout(spinnerTimeout); + if (showSpinner) { + setHidingNote(false); + } } }, [ @@ -609,12 +658,15 @@ export function useNotesOperations({ return { createNote, + creatingNote, updateNote, deleteNote, toggleStar, + starringStar, archiveNote, restoreNote, hideNote, + hidingNote, unhideNote, moveNoteToFolder, permanentlyDeleteNote, diff --git a/src/hooks/useNotesSync.ts b/src/hooks/useNotesSync.ts index a0123e6..34d94b8 100644 --- a/src/hooks/useNotesSync.ts +++ b/src/hooks/useNotesSync.ts @@ -40,10 +40,14 @@ export function useNotesSync({ // Update folder reference if folderId changed if (changes.folderId !== undefined) { - const newFolder = updatedNote.folderId + updatedNote.folder = updatedNote.folderId ? folders.find((f) => f.id === updatedNote.folderId) : undefined; - updatedNote.folder = newFolder; + } + + // Remove isNew badge if title was changed (cross-tab sync) + if (changes.title !== undefined) { + updatedNote.isNew = false; } return updatedNote; @@ -129,8 +133,7 @@ export function useNotesSync({ // Find and attach folder data if note has folderId if (newNote.folderId) { - const folder = folders.find((f) => f.id === newNote.folderId); - newNote.folder = folder; + newNote.folder = folders.find((f) => f.id === newNote.folderId); } setNotes((prevNotes) => { diff --git a/src/lib/api/api.ts b/src/lib/api/api.ts index 0187c23..897dd49 100644 --- a/src/lib/api/api.ts +++ b/src/lib/api/api.ts @@ -73,18 +73,21 @@ export interface ApiUser { usage?: ApiUserUsage; } -export interface NotesResponse { - notes: ApiNote[]; - total: number; +export interface Pagination { page: number; limit: number; + total: number; + pages: number; +} + +export interface NotesResponse { + notes: ApiNote[]; + pagination: Pagination; } export interface FoldersResponse { folders: ApiFolder[]; - total: number; - page: number; - limit: number; + pagination: Pagination; } class ClerkEncryptedApiService { diff --git a/src/types/layout.ts b/src/types/layout.ts index d81a9a2..5b2182a 100644 --- a/src/types/layout.ts +++ b/src/types/layout.ts @@ -43,6 +43,7 @@ export interface FilesPanelProps { onCreateNote: (templateContent?: { title: string; content: string }) => void; onToggleFolderPanel: () => void; onEmptyTrash: () => Promise; + creatingNote?: boolean; isMobile?: boolean; onClose?: () => void; } @@ -54,7 +55,9 @@ export interface EditorProps { onDeleteNote: (noteId: string) => Promise; onArchiveNote: (noteId: string) => Promise; onToggleStar: (noteId: string) => Promise; + starringStar?: boolean; onHideNote: (noteId: string) => Promise; + hidingNote?: boolean; onUnhideNote: (noteId: string) => Promise; onRefreshNote?: (noteId: string) => Promise; userId?: string; diff --git a/src/types/note.ts b/src/types/note.ts index b0b377a..8cff84d 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -23,6 +23,8 @@ export interface Note { hidden: boolean; hiddenAt: Date | null; attachments?: FileAttachment[]; + attachmentCount?: number; // Number of file attachments (from API) + isNew?: boolean; // Client-side flag to show "NEW" badge } export interface Folder {