Files
resolutionflow/frontend/src/components/session/ScratchpadSidebar.tsx
Michael Chihlas 303a558432 refactor: replace hardcoded hex values with Tailwind semantic tokens
3,200+ hardcoded color values replaced with CSS variable-backed
Tailwind classes (bg-card, text-foreground, border-border, etc.).
Enables light mode via CSS variable swap. Only syntax highlighting
colors and intentional one-offs remain hardcoded (~15 values).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 04:34:35 -04:00

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-card border border-border shadow-md',
'text-muted-foreground hover:bg-accent hover:text-foreground',
'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-xs 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-border bg-card/95 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-border px-3 py-2">
<div className="flex items-center gap-2">
<StickyNote className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">Scratchpad</span>
<span className="text-xs text-muted-foreground">Ctrl+/</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowPreview(!showPreview)}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
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-muted-foreground hover:bg-accent hover:text-foreground"
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-muted-foreground">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-foreground placeholder:text-muted-foreground',
'focus:outline-hidden focus:ring-0'
)}
/>
)}
</div>
{/* Save Indicator */}
<div className="border-t border-border px-3 py-1.5">
<div className="flex items-center gap-1.5 text-xs">
{saveStatus === 'unsaved' && (
<span className="text-muted-foreground">Unsaved changes</span>
)}
{saveStatus === 'saving' && (
<>
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">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-muted-foreground">Markdown supported</span>
)}
</div>
</div>
</div>
</>
)
}