diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c74c62b..73be230 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { EdgeModal } from '@/components/modals/EdgeModal' import { ScanConfigModal } from '@/components/modals/ScanConfigModal' import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' +import { TextModal, type TextFormData } from '@/components/modals/TextModal' import { ThemeModal } from '@/components/modals/ThemeModal' import { SearchModal } from '@/components/modals/SearchModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' @@ -39,7 +40,7 @@ const STANDALONE_STORAGE_KEY = 'homelable_canvas' const CONTAINER_MODE_TYPES = new Set(['proxmox', 'vm', 'lxc', 'docker_host']) export default function App() { - const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() + const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() @@ -61,6 +62,7 @@ export default function App() { const [shortcutsOpen, setShortcutsOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false) const [addGroupRectOpen, setAddGroupRectOpen] = useState(false) + const [addTextOpen, setAddTextOpen] = useState(false) const [editNodeId, setEditNodeId] = useState(null) const [pendingConnection, setPendingConnection] = useState(null) const [editEdgeId, setEditEdgeId] = useState(null) @@ -241,6 +243,62 @@ export default function App() { setEditingGroupRectId(null) }, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory]) + const handleAddText = useCallback((data: TextFormData) => { + snapshotHistory() + const id = generateUUID() + const newNode: Node = { + id, + type: 'text', + position: { x: 250, y: 250 }, + data: { + label: '', + type: 'text', + status: 'unknown', + services: [], + text_content: data.text, + custom_colors: { + border: data.border_color, + border_style: data.border_style, + border_width: data.border_width, + background: data.background_color, + text_color: data.text_color, + text_size: data.text_size, + font: data.font, + }, + }, + width: 200, + height: 60, + } + addNode(newNode) + }, [addNode, snapshotHistory]) + + const handleUpdateText = useCallback((data: TextFormData) => { + if (!editingTextId) return + snapshotHistory() + const existing = nodes.find((n) => n.id === editingTextId) + updateNode(editingTextId, { + text_content: data.text, + custom_colors: { + ...existing?.data.custom_colors, + border: data.border_color, + border_style: data.border_style, + border_width: data.border_width, + background: data.background_color, + text_color: data.text_color, + text_size: data.text_size, + font: data.font, + }, + }) + setEditingTextId(null) + }, [editingTextId, nodes, updateNode, setEditingTextId, snapshotHistory]) + + const handleDeleteText = useCallback(() => { + if (!editingTextId) return + snapshotHistory() + deleteNode(editingTextId) + setEditingTextId(null) + }, [editingTextId, deleteNode, setEditingTextId, snapshotHistory]) + const handleDeleteGroupRect = useCallback(() => { if (!editingGroupRectId) return snapshotHistory() @@ -443,6 +501,7 @@ export default function App() { setAddNodeOpen(true)} onAddGroupRect={() => setAddGroupRectOpen(true)} + onAddText={() => setAddTextOpen(true)} onScan={() => setScanConfigOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)} onSave={handleSave} @@ -583,6 +642,37 @@ export default function App() { title="Edit Zone" /> + setAddTextOpen(false)} + onSubmit={handleAddText} + title="Add Text" + /> + + setEditingTextId(null)} + onSubmit={handleUpdateText} + onDelete={handleDeleteText} + initial={(() => { + const n = editingTextId ? nodes.find((nd) => nd.id === editingTextId) : null + if (!n) return undefined + const rc = n.data.custom_colors ?? {} + return { + text: n.data.text_content ?? n.data.label ?? '', + font: rc.font ?? 'inter', + text_color: rc.text_color ?? '#e6edf3', + text_size: rc.text_size ?? 14, + border_color: rc.border ?? '#30363d', + border_style: (rc.border_style ?? 'none') as TextFormData['border_style'], + border_width: rc.border_width ?? 1, + background_color: rc.background ?? '#00000000', + } + })()} + title="Edit Text" + /> + {/* key forces re-mount on open so useState captures current theme as original */} = { + inter: 'Inter, sans-serif', + mono: '"JetBrains Mono", monospace', + serif: 'Georgia, serif', + sans: 'system-ui, sans-serif', +} + +export function TextNode({ id, data, selected }: NodeProps>) { + const setEditingTextId = useCanvasStore((s) => s.setEditingTextId) + + const rc = data.custom_colors ?? {} + const borderColor = rc.border ?? '#30363d' + const borderStyle = rc.border_style ?? 'none' + const borderWidth = rc.border_width ?? 1 + const backgroundColor = rc.background ?? 'transparent' + const textColor = rc.text_color ?? '#e6edf3' + const textSize: number = rc.text_size ?? 14 + const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter + + const content = data.text_content ?? data.label ?? '' + + return ( + <> + + +
{ + e.stopPropagation() + setEditingTextId(id) + }} + > + {content} +
+ + ) +} diff --git a/frontend/src/components/canvas/nodes/__tests__/TextNode.test.tsx b/frontend/src/components/canvas/nodes/__tests__/TextNode.test.tsx new file mode 100644 index 0000000..3bd67df --- /dev/null +++ b/frontend/src/components/canvas/nodes/__tests__/TextNode.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { ReactFlowProvider } from '@xyflow/react' +import { TextNode } from '../TextNode' +import { useCanvasStore } from '@/stores/canvasStore' +import type { NodeData } from '@/types' +import type { NodeProps, Node } from '@xyflow/react' + +function renderNode(data: Partial = {}) { + const fullData: NodeData = { + label: '', + type: 'text', + status: 'unknown', + services: [], + text_content: 'Hello', + ...data, + } + const props = { + id: 't1', + data: fullData, + selected: false, + type: 'text', + zIndex: 0, + isConnectable: true, + xPos: 0, + yPos: 0, + dragging: false, + deletable: true, + draggable: true, + selectable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + width: 200, + height: 60, + dragHandle: undefined, + parentId: undefined, + sourcePosition: undefined, + targetPosition: undefined, + } as unknown as NodeProps> + return render( + + + + ) +} + +describe('TextNode', () => { + beforeEach(() => { + useCanvasStore.setState({ editingTextId: null }) + }) + + it('renders text_content', () => { + const { getByText } = renderNode({ text_content: 'My label' }) + expect(getByText('My label')).toBeDefined() + }) + + it('falls back to label when text_content is missing', () => { + const { getByText } = renderNode({ text_content: undefined, label: 'Fallback' }) + expect(getByText('Fallback')).toBeDefined() + }) + + it('double-click sets editingTextId in store', () => { + const { getByText } = renderNode({ text_content: 'Edit me' }) + fireEvent.doubleClick(getByText('Edit me')) + expect(useCanvasStore.getState().editingTextId).toBe('t1') + }) +}) diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index c390c4a..34d25ec 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -2,6 +2,7 @@ import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcN import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { GroupRectNode } from './GroupRectNode' import { GroupNode } from './GroupNode' +import { TextNode } from './TextNode' export const nodeTypes = { isp: IspNode, @@ -24,6 +25,7 @@ export const nodeTypes = { generic: GenericNode, groupRect: GroupRectNode, group: GroupNode, + text: TextNode, zigbee_coordinator: ZigbeeCoordinatorNode, zigbee_router: ZigbeeRouterNode, zigbee_enddevice: ZigbeeEndDeviceNode, diff --git a/frontend/src/components/modals/TextModal.tsx b/frontend/src/components/modals/TextModal.tsx new file mode 100644 index 0000000..3e744c6 --- /dev/null +++ b/frontend/src/components/modals/TextModal.tsx @@ -0,0 +1,272 @@ +import { useState } from 'react' +import modalStyles from './modal-interactive.module.css' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils' + +export type TextBorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' + +export interface TextFormData { + text: string + font: string + text_color: string + text_size: number + border_color: string + border_style: TextBorderStyle + border_width: number + background_color: string +} + +const BORDER_STYLES: { value: TextBorderStyle; label: string; preview: string }[] = [ + { value: 'none', label: 'None', preview: ' ' }, + { value: 'solid', label: 'Solid', preview: '───' }, + { value: 'dashed', label: 'Dashed', preview: '╌╌╌' }, + { value: 'dotted', label: 'Dotted', preview: '···' }, + { value: 'double', label: 'Double', preview: '═══' }, +] + +const TEXT_SIZES: { value: number; label: string }[] = [ + { value: 10, label: '10' }, + { value: 12, label: '12' }, + { value: 14, label: '14' }, + { value: 18, label: '18' }, + { value: 24, label: '24' }, + { value: 32, label: '32' }, +] + +const BORDER_WIDTHS: { value: number; label: string }[] = [ + { value: 1, label: '1px' }, + { value: 2, label: '2px' }, + { value: 3, label: '3px' }, + { value: 4, label: '4px' }, + { value: 5, label: '5px' }, +] + +const FONTS = [ + { value: 'inter', label: 'Inter (sans-serif)' }, + { value: 'mono', label: 'JetBrains Mono' }, + { value: 'serif', label: 'Serif' }, + { value: 'sans', label: 'System Sans' }, +] + +const DEFAULT_FORM: TextFormData = { + text: '', + font: 'inter', + text_color: '#e6edf3', + text_size: 14, + border_color: '#30363d', + border_style: 'none', + border_width: 1, + background_color: '#00000000', +} + +interface TextModalProps { + open: boolean + onClose: () => void + onSubmit: (data: TextFormData) => void + onDelete?: () => void + initial?: Partial + title?: string +} + +export function TextModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Text' }: TextModalProps) { + const [form, setForm] = useState({ ...DEFAULT_FORM, ...initial }) + + const set = (key: K, value: TextFormData[K]) => + setForm((f) => ({ ...f, [key]: value })) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit(form) + onClose() + } + + const colorFields = [ + { key: 'text_color' as const, label: 'Text' }, + { key: 'border_color' as const, label: 'Border' }, + { key: 'background_color' as const, label: 'Background' }, + ] + + return ( + !o && onClose()}> + + + {title} + + +
+ {/* Text content */} +
+ +