- TreeLibraryPage: split categories into a mount-only fetch so filter changes only re-fetch trees (not categories every time) - Add safeGetItem/safeSetItem/safeRemoveItem helpers in utils.ts to prevent crashes in private browsing or when storage is unavailable - Replace raw localStorage calls in ScratchpadSidebar, TreeNavigationPage, TreeEditorPage, and treeEditorStore with safe wrappers - Add aria-label to 20 icon-only buttons across 8 component files for screen reader accessibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
239 lines
8.1 KiB
TypeScript
239 lines
8.1 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
|
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
|
import { StickyNote, X, Eye, Pencil, Loader2 } from 'lucide-react'
|
|
|
|
interface ScratchpadSidebarProps {
|
|
sessionId: string
|
|
initialContent: string
|
|
onSave: (content: string) => Promise<void>
|
|
onOpenChange?: (isOpen: boolean) => void
|
|
}
|
|
|
|
export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenChange }: ScratchpadSidebarProps) {
|
|
const [content, setContent] = useState(initialContent)
|
|
const [lastSaved, setLastSaved] = useState(initialContent)
|
|
const [isCollapsed, setIsCollapsed] = useState(() => {
|
|
return safeGetItem('scratchpad-collapsed') !== 'false'
|
|
})
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [showPreview, setShowPreview] = useState(false)
|
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'unsaved' | 'saving' | 'saved' | 'error'>('idle')
|
|
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const fadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
const hasUnsavedChanges = content !== lastSaved
|
|
|
|
// Reset content when session changes
|
|
useEffect(() => {
|
|
setContent(initialContent)
|
|
setLastSaved(initialContent)
|
|
setSaveStatus('idle')
|
|
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Update save status based on state
|
|
useEffect(() => {
|
|
if (isSaving) {
|
|
setSaveStatus('saving')
|
|
} else if (hasUnsavedChanges) {
|
|
setSaveStatus('unsaved')
|
|
}
|
|
}, [isSaving, hasUnsavedChanges])
|
|
|
|
// Persist collapse state and notify parent
|
|
useEffect(() => {
|
|
safeSetItem('scratchpad-collapsed', String(isCollapsed))
|
|
onOpenChange?.(!isCollapsed)
|
|
}, [isCollapsed, onOpenChange])
|
|
|
|
// Keyboard shortcut: Ctrl+/ to toggle
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.ctrlKey && e.key === '/') {
|
|
e.preventDefault()
|
|
setIsCollapsed(prev => !prev)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [])
|
|
|
|
// beforeunload warning
|
|
useEffect(() => {
|
|
if (!hasUnsavedChanges) return
|
|
|
|
const handler = (e: BeforeUnloadEvent) => {
|
|
e.preventDefault()
|
|
}
|
|
window.addEventListener('beforeunload', handler)
|
|
return () => window.removeEventListener('beforeunload', handler)
|
|
}, [hasUnsavedChanges])
|
|
|
|
const doSave = useCallback(async (text: string) => {
|
|
setIsSaving(true)
|
|
try {
|
|
await onSave(text)
|
|
setLastSaved(text)
|
|
setSaveStatus('saved')
|
|
|
|
// Clear "Saved" indicator after 2 seconds
|
|
if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current)
|
|
fadeTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000)
|
|
} catch {
|
|
setSaveStatus('error')
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}, [onSave])
|
|
|
|
const handleChange = (value: string) => {
|
|
setContent(value)
|
|
|
|
// Cancel any pending debounce
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
|
|
// Schedule save after 1000ms of inactivity
|
|
debounceRef.current = setTimeout(() => {
|
|
if (value !== lastSaved) {
|
|
doSave(value)
|
|
}
|
|
}, 1000)
|
|
}
|
|
|
|
const handleBlur = () => {
|
|
// Cancel pending debounce and save immediately
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
if (content !== lastSaved) {
|
|
doSave(content)
|
|
}
|
|
}
|
|
|
|
// Cleanup timers on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<>
|
|
{/* Floating button (visible when collapsed) */}
|
|
<button
|
|
onClick={() => setIsCollapsed(false)}
|
|
className={cn(
|
|
'fixed right-2 top-1/2 z-40 -translate-y-1/2 rounded-md p-2.5',
|
|
'bg-[#0a0a0a] border border-white/[0.06] shadow-md',
|
|
'text-white/40 hover:bg-white/10 hover:text-white',
|
|
'transition-opacity duration-200',
|
|
isCollapsed ? 'opacity-100' : 'pointer-events-none opacity-0'
|
|
)}
|
|
title="Open scratchpad (Ctrl+/)"
|
|
aria-label="Open scratchpad (Ctrl+/)"
|
|
>
|
|
<StickyNote className="h-5 w-5" />
|
|
{hasUnsavedChanges && (
|
|
<div className="absolute -left-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-amber-500" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Mobile backdrop */}
|
|
{!isCollapsed && (
|
|
<div
|
|
className="fixed inset-0 z-30 bg-black/80 backdrop-blur-sm sm:hidden"
|
|
onClick={() => setIsCollapsed(true)}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
|
|
{/* Panel overlay */}
|
|
<div
|
|
className={cn(
|
|
'fixed z-40',
|
|
'inset-0 sm:inset-auto sm:right-2 sm:top-1/2 sm:-translate-y-1/2',
|
|
'flex w-full flex-col sm:h-[55vh] sm:w-[420px]',
|
|
'border-white/[0.06] bg-[#0a0a0a]/95 backdrop-blur-md shadow-xl sm:rounded-lg sm:border',
|
|
'transition-transform duration-200 ease-out',
|
|
isCollapsed ? 'translate-x-full' : 'translate-x-0'
|
|
)}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<StickyNote className="h-4 w-4 text-white/40" />
|
|
<span className="text-sm font-medium text-white">Scratchpad</span>
|
|
<span className="text-xs text-white/30">Ctrl+/</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
|
title={showPreview ? 'Edit' : 'Preview'}
|
|
>
|
|
{showPreview ? <Pencil className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
</button>
|
|
<button
|
|
onClick={() => setIsCollapsed(true)}
|
|
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
|
title="Close scratchpad"
|
|
aria-label="Close scratchpad"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-3">
|
|
{showPreview ? (
|
|
<div className="min-h-[100px]">
|
|
{content.trim() ? (
|
|
<MarkdownContent content={content} className="text-sm" />
|
|
) : (
|
|
<p className="text-sm italic text-white/40">Nothing to preview</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<textarea
|
|
value={content}
|
|
onChange={(e) => handleChange(e.target.value)}
|
|
onBlur={handleBlur}
|
|
placeholder={"Capture IPs, error codes, server names, user info...\n\nSupports markdown formatting."}
|
|
className={cn(
|
|
'h-full min-h-[200px] w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm',
|
|
'text-white placeholder:text-white/40',
|
|
'focus:outline-none focus:ring-0'
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Save Indicator */}
|
|
<div className="border-t border-white/[0.06] px-3 py-1.5">
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
{saveStatus === 'unsaved' && (
|
|
<span className="text-white/40">Unsaved changes</span>
|
|
)}
|
|
{saveStatus === 'saving' && (
|
|
<>
|
|
<Loader2 className="h-3 w-3 animate-spin text-white/40" />
|
|
<span className="text-white/40">Saving...</span>
|
|
</>
|
|
)}
|
|
{saveStatus === 'saved' && (
|
|
<span className="text-emerald-400">Saved</span>
|
|
)}
|
|
{saveStatus === 'error' && (
|
|
<span className="text-red-400">Save failed</span>
|
|
)}
|
|
{saveStatus === 'idle' && (
|
|
<span className="text-white/30">Markdown supported</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|