242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export type InteractionMode = 'select' | 'pan'
|
|
|
|
interface DiagramHeaderProps {
|
|
name: string
|
|
clientName: string | null
|
|
isDirty: boolean
|
|
isSaving: boolean
|
|
lastSavedAt: Date | null
|
|
diagramId: string | null
|
|
onNameChange: (name: string) => void
|
|
onSave: () => void
|
|
onExportPng: () => void
|
|
onExportSvg: () => void
|
|
onExportPdf: () => void
|
|
onExportJson: () => void
|
|
onUndo: () => void
|
|
onRedo: () => void
|
|
canUndo: boolean
|
|
canRedo: boolean
|
|
interactionMode: InteractionMode
|
|
onModeChange: (mode: InteractionMode) => void
|
|
}
|
|
|
|
export function DiagramHeader({
|
|
name,
|
|
clientName,
|
|
isDirty,
|
|
isSaving,
|
|
lastSavedAt,
|
|
diagramId,
|
|
onNameChange,
|
|
onSave,
|
|
onExportPng,
|
|
onExportSvg,
|
|
onExportPdf,
|
|
onExportJson,
|
|
onUndo,
|
|
onRedo,
|
|
canUndo,
|
|
canRedo,
|
|
interactionMode,
|
|
onModeChange,
|
|
}: DiagramHeaderProps) {
|
|
const navigate = useNavigate()
|
|
const [editing, setEditing] = useState(false)
|
|
const [editValue, setEditValue] = useState(name)
|
|
const [showExportMenu, setShowExportMenu] = useState(false)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const exportMenuRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (editing && inputRef.current) {
|
|
inputRef.current.focus()
|
|
inputRef.current.select()
|
|
}
|
|
}, [editing])
|
|
|
|
useEffect(() => {
|
|
setEditValue(name)
|
|
}, [name])
|
|
|
|
useEffect(() => {
|
|
if (!showExportMenu) return
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (exportMenuRef.current && !exportMenuRef.current.contains(e.target as HTMLElement)) {
|
|
setShowExportMenu(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [showExportMenu])
|
|
|
|
const handleConfirmName = useCallback(() => {
|
|
setEditing(false)
|
|
if (editValue.trim() && editValue !== name) {
|
|
onNameChange(editValue.trim())
|
|
} else {
|
|
setEditValue(name)
|
|
}
|
|
}, [editValue, name, onNameChange])
|
|
|
|
const formatLastSaved = () => {
|
|
if (!lastSavedAt) return null
|
|
// eslint-disable-next-line react-hooks/purity
|
|
const diff = Date.now() - lastSavedAt.getTime()
|
|
if (diff < 60_000) return 'Saved just now'
|
|
const mins = Math.floor(diff / 60_000)
|
|
return `Saved ${mins}m ago`
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-14 items-center gap-3 border-b border-default bg-card px-4">
|
|
<button
|
|
onClick={() => navigate('/network-diagrams')}
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary"
|
|
>
|
|
<ChevronLeft size={16} />
|
|
Network Maps
|
|
</button>
|
|
|
|
<div className="mx-2 h-5 w-px bg-border-default" />
|
|
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={onUndo}
|
|
disabled={!canUndo}
|
|
title="Undo (Ctrl+Z)"
|
|
className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<Undo2 size={16} />
|
|
</button>
|
|
<button
|
|
onClick={onRedo}
|
|
disabled={!canRedo}
|
|
title="Redo (Ctrl+Y)"
|
|
className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<Redo2 size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mx-2 h-5 w-px bg-border-default" />
|
|
|
|
{/* Interaction mode toggle */}
|
|
<div className="flex items-center rounded border border-default overflow-hidden">
|
|
<button
|
|
onClick={() => onModeChange('select')}
|
|
title="Select (V)"
|
|
className={cn(
|
|
'p-1.5 transition-colors',
|
|
interactionMode === 'select'
|
|
? 'bg-elevated text-primary'
|
|
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
|
|
)}
|
|
>
|
|
<MousePointer2 size={15} />
|
|
</button>
|
|
<button
|
|
onClick={() => onModeChange('pan')}
|
|
title="Pan (H)"
|
|
className={cn(
|
|
'p-1.5 transition-colors',
|
|
interactionMode === 'pan'
|
|
? 'bg-elevated text-primary'
|
|
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
|
|
)}
|
|
>
|
|
<Hand size={15} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mx-2 h-5 w-px bg-border-default" />
|
|
|
|
{editing ? (
|
|
<input
|
|
ref={inputRef}
|
|
value={editValue}
|
|
onChange={e => setEditValue(e.target.value)}
|
|
onBlur={handleConfirmName}
|
|
onKeyDown={e => { if (e.key === 'Enter') handleConfirmName(); if (e.key === 'Escape') { setEditing(false); setEditValue(name) } }}
|
|
className="rounded border border-accent bg-input px-2 py-1 text-sm font-heading font-semibold text-heading focus:outline-none"
|
|
/>
|
|
) : (
|
|
<button
|
|
onClick={() => setEditing(true)}
|
|
className="text-sm font-heading font-semibold text-heading hover:text-accent"
|
|
>
|
|
{name || 'Untitled Diagram'}
|
|
</button>
|
|
)}
|
|
|
|
{clientName && (
|
|
<span className="rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
|
|
{clientName}
|
|
</span>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
|
|
{isDirty && !isSaving ? (
|
|
<span className="text-[10px] text-amber-400">Unsaved changes</span>
|
|
) : lastSavedAt ? (
|
|
<span className="text-[10px] text-muted-foreground">{formatLastSaved()}</span>
|
|
) : null}
|
|
|
|
<button
|
|
onClick={onSave}
|
|
disabled={isSaving}
|
|
className="flex items-center gap-1.5 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
|
|
>
|
|
<Save size={14} />
|
|
{isSaving ? 'Saving...' : 'Save'}
|
|
</button>
|
|
|
|
<div className="relative" ref={exportMenuRef}>
|
|
<button
|
|
onClick={() => setShowExportMenu(prev => !prev)}
|
|
className="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-primary hover:border-hover"
|
|
>
|
|
<Download size={14} />
|
|
Export
|
|
</button>
|
|
{showExportMenu && (
|
|
<div className="absolute right-0 top-full z-50 mt-1 w-40 rounded border border-default bg-card py-1 shadow-lg">
|
|
<button
|
|
onClick={() => { onExportPng(); setShowExportMenu(false) }}
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
|
>
|
|
<Image size={12} /> Export PNG
|
|
</button>
|
|
<button
|
|
onClick={() => { onExportSvg(); setShowExportMenu(false) }}
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
|
>
|
|
<FileCode size={12} /> Export SVG
|
|
</button>
|
|
<button
|
|
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
|
>
|
|
<FileText size={12} /> Export PDF
|
|
</button>
|
|
{diagramId && (
|
|
<button
|
|
onClick={() => { onExportJson(); setShowExportMenu(false) }}
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
|
>
|
|
<FileJson size={12} /> Export JSON
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|