diff --git a/frontend/src/components/admin/CategoryRow.tsx b/frontend/src/components/admin/CategoryRow.tsx index 69e82384..1bbec924 100644 --- a/frontend/src/components/admin/CategoryRow.tsx +++ b/frontend/src/components/admin/CategoryRow.tsx @@ -81,6 +81,7 @@ export function CategoryRow({ 'hover:bg-white/10 hover:text-white' )} title="Edit category" + aria-label="Edit category" > @@ -94,6 +95,7 @@ export function CategoryRow({ 'hover:bg-accent hover:text-accent-foreground' )} title="Archive category" + aria-label="Archive category" > @@ -106,6 +108,7 @@ export function CategoryRow({ 'hover:bg-accent hover:text-accent-foreground' )} title="Restore category" + aria-label="Restore category" > diff --git a/frontend/src/components/library/AddToFolderMenu.tsx b/frontend/src/components/library/AddToFolderMenu.tsx index e87d3755..fd199f74 100644 --- a/frontend/src/components/library/AddToFolderMenu.tsx +++ b/frontend/src/components/library/AddToFolderMenu.tsx @@ -93,6 +93,7 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp 'hover:bg-white/10 hover:text-white' )} title="Add to folder" + aria-label="Add to folder" > diff --git a/frontend/src/components/library/TreeGridView.tsx b/frontend/src/components/library/TreeGridView.tsx index b11deb53..4fb0836c 100644 --- a/frontend/src/components/library/TreeGridView.tsx +++ b/frontend/src/components/library/TreeGridView.tsx @@ -85,6 +85,7 @@ export function TreeGridView({ 'hover:bg-white/10 hover:text-white' )} title="Fork tree" + aria-label="Fork tree" > @@ -97,6 +98,7 @@ export function TreeGridView({ 'hover:bg-white/10 hover:text-white' )} title="Edit tree" + aria-label="Edit tree" > @@ -110,6 +112,7 @@ export function TreeGridView({ 'hover:bg-red-400/10 hover:text-red-400' )} title="Delete tree" + aria-label="Delete tree" > diff --git a/frontend/src/components/library/TreeListView.tsx b/frontend/src/components/library/TreeListView.tsx index f4dd9264..0916843f 100644 --- a/frontend/src/components/library/TreeListView.tsx +++ b/frontend/src/components/library/TreeListView.tsx @@ -88,6 +88,7 @@ export function TreeListView({ 'hover:bg-white/10 hover:text-white' )} title="Fork tree" + aria-label="Fork tree" > @@ -100,6 +101,7 @@ export function TreeListView({ 'hover:bg-white/10 hover:text-white' )} title="Edit tree" + aria-label="Edit tree" > diff --git a/frontend/src/components/library/TreeTableView.tsx b/frontend/src/components/library/TreeTableView.tsx index 5669d95d..cd23f5a5 100644 --- a/frontend/src/components/library/TreeTableView.tsx +++ b/frontend/src/components/library/TreeTableView.tsx @@ -192,6 +192,7 @@ export function TreeTableView({ 'hover:bg-white/10 hover:text-white' )} title="Fork tree" + aria-label="Fork tree" > @@ -204,6 +205,7 @@ export function TreeTableView({ 'hover:bg-white/10 hover:text-white' )} title="Edit tree" + aria-label="Edit tree" > diff --git a/frontend/src/components/library/ViewToggle.tsx b/frontend/src/components/library/ViewToggle.tsx index a6056622..20037a89 100644 --- a/frontend/src/components/library/ViewToggle.tsx +++ b/frontend/src/components/library/ViewToggle.tsx @@ -22,6 +22,7 @@ export function ViewToggle({ view, onChange, className }: ViewToggleProps) { : 'text-white/50 hover:bg-white/[0.06] hover:text-white' )} title="Grid view" + aria-label="Grid view" > @@ -35,6 +36,7 @@ export function ViewToggle({ view, onChange, className }: ViewToggleProps) { : 'text-white/50 hover:bg-white/[0.06] hover:text-white' )} title="List view" + aria-label="List view" > @@ -48,6 +50,7 @@ export function ViewToggle({ view, onChange, className }: ViewToggleProps) { : 'text-white/50 hover:bg-white/[0.06] hover:text-white' )} title="Table view" + aria-label="Table view" > diff --git a/frontend/src/components/session/ScratchpadSidebar.tsx b/frontend/src/components/session/ScratchpadSidebar.tsx index 3508b2f2..4cc8d206 100644 --- a/frontend/src/components/session/ScratchpadSidebar.tsx +++ b/frontend/src/components/session/ScratchpadSidebar.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { cn } from '@/lib/utils' +import { cn, safeGetItem, safeSetItem } from '@/lib/utils' import { MarkdownContent } from '@/components/ui/MarkdownContent' import { StickyNote, X, Eye, Pencil, Loader2 } from 'lucide-react' @@ -14,7 +14,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha const [content, setContent] = useState(initialContent) const [lastSaved, setLastSaved] = useState(initialContent) const [isCollapsed, setIsCollapsed] = useState(() => { - return localStorage.getItem('scratchpad-collapsed') !== 'false' + return safeGetItem('scratchpad-collapsed') !== 'false' }) const [isSaving, setIsSaving] = useState(false) const [showPreview, setShowPreview] = useState(false) @@ -43,7 +43,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha // Persist collapse state and notify parent useEffect(() => { - localStorage.setItem('scratchpad-collapsed', String(isCollapsed)) + safeSetItem('scratchpad-collapsed', String(isCollapsed)) onOpenChange?.(!isCollapsed) }, [isCollapsed, onOpenChange]) @@ -130,6 +130,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha isCollapsed ? 'opacity-100' : 'pointer-events-none opacity-0' )} title="Open scratchpad (Ctrl+/)" + aria-label="Open scratchpad (Ctrl+/)" > {hasUnsavedChanges && ( @@ -176,6 +177,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha onClick={() => setIsCollapsed(true)} className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white" title="Close scratchpad" + aria-label="Close scratchpad" > diff --git a/frontend/src/components/tree-editor/DynamicArrayField.tsx b/frontend/src/components/tree-editor/DynamicArrayField.tsx index 55860a89..7b3b00e0 100644 --- a/frontend/src/components/tree-editor/DynamicArrayField.tsx +++ b/frontend/src/components/tree-editor/DynamicArrayField.tsx @@ -53,6 +53,7 @@ export function DynamicArrayField({ disabled={index === 0} className="rounded p-0.5 text-white/50 hover:bg-white/[0.06] hover:text-white disabled:opacity-30" title="Move up" + aria-label="Move up" > @@ -62,6 +63,7 @@ export function DynamicArrayField({ disabled={index === items.length - 1} className="rounded p-0.5 text-white/50 hover:bg-white/[0.06] hover:text-white disabled:opacity-30" title="Move down" + aria-label="Move down" > @@ -78,6 +80,7 @@ export function DynamicArrayField({ onClick={() => onRemove(index)} className="mt-1 rounded p-1 text-white/50 hover:bg-red-400/20 hover:text-red-400" title="Remove" + aria-label="Remove" > diff --git a/frontend/src/components/tree-editor/NodeList.tsx b/frontend/src/components/tree-editor/NodeList.tsx index 3ac0e314..dddcfbb1 100644 --- a/frontend/src/components/tree-editor/NodeList.tsx +++ b/frontend/src/components/tree-editor/NodeList.tsx @@ -286,6 +286,7 @@ function NodeListItem({ onAddChild(node.id) }} title="Add child node" + aria-label="Add child node" className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground" > @@ -298,6 +299,7 @@ function NodeListItem({ onEdit(node) }} title="Edit node" + aria-label="Edit node" className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground" > @@ -311,6 +313,7 @@ function NodeListItem({ onDuplicate(node.id) }} title="Duplicate node" + aria-label="Duplicate node" className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground" > @@ -322,6 +325,7 @@ function NodeListItem({ onDelete(node.id) }} title="Delete node" + aria-label="Delete node" className="rounded p-1 text-muted-foreground hover:bg-destructive/20 hover:text-destructive" > diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index d32b0fe6..f7609093 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -4,3 +4,28 @@ import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** Safe localStorage access — returns null on error (e.g. private browsing) */ +export function safeGetItem(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +export function safeSetItem(key: string, value: string): void { + try { + localStorage.setItem(key, value) + } catch { + // Storage full or unavailable — silently fail + } +} + +export function safeRemoveItem(key: string): void { + try { + localStorage.removeItem(key) + } catch { + // Storage unavailable — silently fail + } +} diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index 54bee6f1..f847524f 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -10,7 +10,7 @@ import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout' import { ValidationSummary } from '@/components/tree-editor/ValidationSummary' import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' import { usePermissions } from '@/hooks/usePermissions' -import { cn } from '@/lib/utils' +import { cn, safeGetItem } from '@/lib/utils' import { toast } from '@/lib/toast' export function TreeEditorPage() { @@ -156,7 +156,7 @@ export function TreeEditorPage() { initNewTree() setTreeStatus('draft') // New trees start as draft // Check for draft after initializing - const draftExists = localStorage.getItem('tree-editor-draft') !== null + const draftExists = safeGetItem('tree-editor-draft') !== null if (draftExists) { setShowDraftPrompt(true) } diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index c8ea2cf5..9d355cfa 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -58,8 +58,16 @@ export function TreeLibraryPage() { } }, []) + // Load categories once on mount (they rarely change) useEffect(() => { - loadData() + categoriesApi.list() + .then(setCategories) + .catch((err) => console.error('Failed to load categories:', err)) + }, []) + + // Load trees when filters change + useEffect(() => { + loadTrees() }, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, showDrafts]) // Load folders on mount and listen for changes @@ -70,21 +78,17 @@ export function TreeLibraryPage() { return () => window.removeEventListener('folder-changed', handleFolderChange) }, [loadFolders]) - const loadData = async () => { + const loadTrees = async () => { setIsLoading(true) try { - const [treesData, categoriesData] = await Promise.all([ - treesApi.list({ - category_id: selectedCategoryId || undefined, - tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined, - folder_id: selectedFolderId || undefined, - sort_by: treeLibrarySortBy, - include_drafts: showDrafts || undefined, - }), - categoriesApi.list(), - ]) + const treesData = await treesApi.list({ + category_id: selectedCategoryId || undefined, + tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined, + folder_id: selectedFolderId || undefined, + sort_by: treeLibrarySortBy, + include_drafts: showDrafts || undefined, + }) setTrees(treesData) - setCategories(categoriesData) } catch (err) { toast.error('Failed to load trees') console.error(err) @@ -95,7 +99,7 @@ export function TreeLibraryPage() { const handleSearch = async () => { if (!searchQuery.trim()) { - loadData() + loadTrees() return } setIsLoading(true) @@ -413,7 +417,7 @@ export function TreeLibraryPage() { setFolderModalOpen(false) setNewFolderParentId(null) }} - onSave={loadData} + onSave={loadTrees} /> {/* Delete Confirmation */} diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index 5fe04a39..19ea936a 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -4,7 +4,7 @@ import { treesApi, sessionsApi } from '@/api' import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts' import { useCustomStepFlow } from '@/hooks/useCustomStepFlow' import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types' -import { cn } from '@/lib/utils' +import { cn, safeGetItem } from '@/lib/utils' import { MarkdownContent } from '@/components/ui/MarkdownContent' import { CustomStepModal } from '@/components/step-library/CustomStepModal' import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session' @@ -37,7 +37,7 @@ export function TreeNavigationPage() { // Scratchpad state const [scratchpadOpen, setScratchpadOpen] = useState(() => { - return localStorage.getItem('scratchpad-collapsed') === 'false' + return safeGetItem('scratchpad-collapsed') === 'false' }) const findNode = (nodeId: string, structure?: TreeStructure): TreeStructure | null => { diff --git a/frontend/src/store/treeEditorStore.ts b/frontend/src/store/treeEditorStore.ts index f9509954..6e810fb4 100644 --- a/frontend/src/store/treeEditorStore.ts +++ b/frontend/src/store/treeEditorStore.ts @@ -4,6 +4,7 @@ import { shallow } from 'zustand/shallow' import { immer } from 'zustand/middleware/immer' import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types' import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync' +import { safeGetItem, safeSetItem, safeRemoveItem } from '@/lib/utils' // Throttle helper: captures first call immediately, then throttles subsequent calls // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -304,7 +305,7 @@ export const useTreeEditorStore = create()( // Check for existing draft on init initNewTree: () => { - const hasDraft = localStorage.getItem(DRAFT_STORAGE_KEY) !== null + const hasDraft = safeGetItem(DRAFT_STORAGE_KEY) !== null set((state) => { state.treeId = null state.name = '' @@ -360,7 +361,7 @@ export const useTreeEditorStore = create()( }, loadDraft: () => { - const draftJson = localStorage.getItem(DRAFT_STORAGE_KEY) + const draftJson = safeGetItem(DRAFT_STORAGE_KEY) if (!draftJson) return false try { @@ -380,13 +381,13 @@ export const useTreeEditorStore = create()( }) return true } catch { - localStorage.removeItem(DRAFT_STORAGE_KEY) + safeRemoveItem(DRAFT_STORAGE_KEY) return false } }, discardDraft: () => { - localStorage.removeItem(DRAFT_STORAGE_KEY) + safeRemoveItem(DRAFT_STORAGE_KEY) set((state) => { state.hasDraft = false }) @@ -868,12 +869,12 @@ export const useTreeEditorStore = create()( treeStructure: state.treeStructure, savedAt: new Date().toISOString() } - localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)) + safeSetItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)) set((s) => { s.draftSavedAt = new Date() }) }, markSaved: () => { - localStorage.removeItem(DRAFT_STORAGE_KEY) + safeRemoveItem(DRAFT_STORAGE_KEY) set((state) => { state.isDirty = false state.lastSavedAt = new Date()