import { useState, useCallback, useEffect, useRef } from 'react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' import { designsApi, scanApi } from '@/api/client' import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' import type { Design } from '@/types' import { toast } from 'sonner' import { useLatestRelease } from '@/hooks/useLatestRelease' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' type SidebarView = 'canvas' | 'history' const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [ { kind: 'pending', icon: ScanLine, label: 'Pending Devices' }, { kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' }, ] interface ScanRun { id: string status: string kind?: string ranges: string[] devices_found: number started_at: string finished_at: string | null error: string | null } interface SidebarProps { onAddNode: () => void onAddGroupRect: () => void onAddText: () => void onScan: () => void onZigbeeImport: () => void onSave: () => void onOpenSettings: () => void forceView?: SidebarView onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void } export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, forceView, onOpenPending }: SidebarProps) { const [collapsed, setCollapsed] = useState(false) const [activeView, setActiveView] = useState(forceView ?? 'canvas') const [prevForceView, setPrevForceView] = useState(forceView) const logout = useAuthStore((s) => s.logout) const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null) const handleDesignSubmit = useCallback(async (data: DesignFormData) => { if (!designModal) return try { if (designModal.mode === 'create') { const res = await designsApi.create({ name: data.name, icon: data.icon }) addDesign(res.data) } else if (designModal.design) { const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon }) updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon }) } setDesignModal(null) } catch { toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas') } }, [designModal, addDesign, updateDesign]) const handleDesignDelete = useCallback(async (d: Design) => { if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return } if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return try { await designsApi.delete(d.id) removeDesign(d.id) toast.success('Canvas deleted') } catch { toast.error('Failed to delete canvas') } }, [designs.length, removeDesign]) // forceView acts as a one-shot trigger from parent; user clicks afterwards still control view. if (forceView !== prevForceView) { setPrevForceView(forceView) if (forceView) { setActiveView(forceView) setCollapsed(false) } } const { nodes, hasUnsavedChanges } = useCanvasStore() const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length const handleScan = useCallback(() => { onScan() }, [onScan]) return ( ) } function ScanHistoryPanel() { const [runs, setRuns] = useState([]) const [loading, setLoading] = useState(false) const prevRunsRef = useRef([]) const load = useCallback(async () => { setLoading(true) try { const res = await scanApi.runs() const next: ScanRun[] = res.data // Surface transitions and refresh dependent UI for (const run of next) { const prev = prevRunsRef.current.find((r) => r.id === run.id) if (prev?.status === 'running' && run.status === 'error') { toast.error(`Scan failed: ${run.error ?? 'unknown error'}`) } if (prev?.status === 'running' && run.status === 'done') { if (run.kind === 'zigbee') { toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`) } // Notify pending modal/canvas to refresh useCanvasStore.getState().notifyScanDeviceFound() } } prevRunsRef.current = next setRuns(next) } catch { toast.error('Failed to load scan history') } finally { setLoading(false) } }, []) // Initial load useEffect(() => { load() }, [load]) // Auto-refresh every 3s while any run is still running useEffect(() => { const hasRunning = runs.some((r) => r.status === 'running') if (!hasRunning) return const id = setInterval(load, 3000) return () => clearInterval(id) }, [runs, load]) const [stopping, setStopping] = useState(null) const handleStop = async (runId: string) => { setStopping(runId) try { await scanApi.stop(runId) toast.success('Scan stop requested') } catch { toast.error('Failed to stop scan') } finally { setStopping(null) } } const statusColor = (s: string) => s === 'done' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'error' ? '#f85149' : s === 'cancelled' ? '#8b949e' : '#8b949e' return (
History
{loading && runs.length === 0 && } {!loading && runs.length === 0 && (

No scans yet

)} {runs.map((r) => (
{r.status} {r.status === 'running' && } {r.kind === 'zigbee' ? 'ZIG' : 'IP'} {r.devices_found} found {r.status === 'running' && ( Stop scan )}
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
{r.ranges.length > 0 && (
{r.ranges.join(', ')}
)} {r.error && (
{r.error}
)}
))}
) } function VersionBadge() { const current = __APP_VERSION__ const { latest, hasUpdate } = useLatestRelease(current) return (
v{current} {hasUpdate && latest && ( ↑ v{latest.version} available )}
) } interface SidebarItemProps { icon: React.ElementType label: string collapsed: boolean active?: boolean badge?: boolean accent?: boolean onClick?: () => void } function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick }: SidebarItemProps) { const btn = ( ) if (collapsed) { return ( {btn} {label} ) } return btn }