diff --git a/VERSION b/VERSION index 169f19b..32bd932 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.11.0 \ No newline at end of file +1.12.0 \ No newline at end of file diff --git a/backend/app/api/routes/canvas.py b/backend/app/api/routes/canvas.py index 4e841f6..cc7b342 100644 --- a/backend/app/api/routes/canvas.py +++ b/backend/app/api/routes/canvas.py @@ -25,6 +25,7 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c nodes=[NodeResponse.model_validate(n) for n in nodes], edges=[EdgeResponse.model_validate(e) for e in edges], viewport=viewport, + custom_style=state.custom_style if state else None, ) @@ -67,13 +68,14 @@ async def save_canvas( else: db.add(Edge(**edge_data.model_dump())) - # Upsert viewport + # Upsert viewport + custom style state = await db.get(CanvasState, 1) if state: state.viewport = body.viewport + state.custom_style = body.custom_style state.saved_at = datetime.now(timezone.utc) else: - db.add(CanvasState(id=1, viewport=body.viewport)) + db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style)) await db.commit() return {"saved": True} diff --git a/backend/app/db/database.py b/backend/app/db/database.py index f7b3ac2..73c1c61 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -84,6 +84,8 @@ async def init_db() -> None: await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON") # Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL) with suppress(OperationalError): rows = await conn.exec_driver_sql( diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 5c004fb..9eb0c88 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -78,6 +78,7 @@ class CanvasState(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 1eccd96..0715c4d 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -62,9 +62,11 @@ class CanvasSaveRequest(BaseModel): nodes: list[NodeSave] = [] edges: list[EdgeSave] = [] viewport: dict[str, Any] = {} + custom_style: dict[str, Any] | None = None class CanvasStateResponse(BaseModel): nodes: list[NodeResponse] edges: list[EdgeResponse] viewport: dict[str, Any] + custom_style: dict[str, Any] | None = None diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 3d7dadf..ac0ce1e 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -557,3 +557,42 @@ async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: di edge = canvas["edges"][0] assert edge["label"] == "updated" assert edge["custom_color"] == "#ff0000" + + +# ── custom_style ────────────────────────────────────────────────────────────── + +async def test_save_and_load_custom_style(client: AsyncClient, headers: dict): + custom_style = { + "nodes": { + "server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80}, + }, + "edges": { + "ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"}, + }, + } + payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style} + res = await client.post("/api/v1/canvas/save", json=payload, headers=headers) + assert res.status_code == 200 + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["custom_style"] is not None + assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000" + assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00" + + +async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict): + res = await client.get("/api/v1/canvas", headers=headers) + assert res.status_code == 200 + assert res.json()["custom_style"] is None + + +async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict): + style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}} + style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}} + + await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers) + await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert "proxmox" in canvas["custom_style"]["nodes"] + assert "server" not in canvas["custom_style"]["nodes"] diff --git a/frontend/package.json b/frontend/package.json index 599aab9..f179461 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.11.0", + "version": "1.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6b10007..477f2e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,7 +29,7 @@ import { useThemeStore } from '@/stores/themeStore' import { canvasApi } from '@/api/client' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' -import type { NodeData, EdgeData } from '@/types' +import type { NodeData, EdgeData, CustomStyleDef } from '@/types' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' @@ -39,7 +39,7 @@ 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 canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() - const { activeTheme, setTheme } = useThemeStore() + const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() useStatusPolling() @@ -60,20 +60,20 @@ export default function App() { const handleSave = useCallback(async () => { try { if (STANDALONE) { - localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme })) + localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle })) markSaved() toast.success('Canvas saved') return } const nodesToSave = nodes.map(serializeNode) const edgesToSave = edges.map(serializeEdge) - await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } }) + await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle }) markSaved() toast.success('Canvas saved') } catch { toast.error('Save failed') } - }, [nodes, edges, markSaved, activeTheme]) + }, [nodes, edges, markSaved, activeTheme, customStyle]) // Keep a ref so the keydown handler always calls the latest version const handleSaveRef = useRef(handleSave) @@ -85,8 +85,9 @@ export default function App() { try { const saved = localStorage.getItem(STANDALONE_STORAGE_KEY) if (saved) { - const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved) + const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved) if (theme_id) setTheme(theme_id) + if (custom_style) setCustomStyle(custom_style) loadCanvas(savedNodes, savedEdges) } else { loadCanvas(demoNodes, demoEdges) @@ -111,13 +112,14 @@ export default function App() { const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) const savedTheme = res.data.viewport?.theme_id if (savedTheme) setTheme(savedTheme) + if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) loadCanvas(rfNodes, rfEdges) } else { loadCanvas(demoNodes, demoEdges) } }) .catch(() => loadCanvas(demoNodes, demoEdges)) - }, [isAuthenticated, loadCanvas, setTheme]) + }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // Keep refs for store actions so keydown handler is always up-to-date without re-registering const undoRef = useRef(undo) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 62aed11..b0ccdce 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -33,6 +33,7 @@ export const canvasApi = { nodes: object[] edges: object[] viewport: object + custom_style?: object | null }) => api.post('/canvas/save', payload), } diff --git a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts index a42a943..ba9c253 100644 --- a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts +++ b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils' +import { buildWaypointPath, distToSegment, findInsertIndex, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils' describe('buildWaypointPath — bezier (default)', () => { it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => { @@ -173,3 +173,35 @@ describe('findInsertIndex', () => { expect(idx).toBe(2) }) }) + +describe('getWaypointLabelPosition', () => { + it('uses the routed midpoint for a symmetric bezier waypoint path', () => { + const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 100 }], 100, 0) + expect(point.x).toBeCloseTo(50, 0) + expect(point.y).toBeCloseTo(100, 0) + }) + + it('uses the routed midpoint for a smooth waypoint path', () => { + const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth') + expect(point.x).toBeCloseTo(50, 0) + expect(point.y).toBeCloseTo(50, 0) + }) + + it('falls back to the source point when the path is degenerate', () => { + const point = getWaypointLabelPosition(10, 20, [], 10, 20, 'smooth') + expect(point).toEqual({ x: 10, y: 20 }) + }) +}) + +describe('getAddWaypointHandlePosition', () => { + it('places bezier add handle on the rendered curved segment', () => { + const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 100 }], 100, 0, 0, 'bezier') + expect(point.x).toBeCloseTo(21.875, 3) + expect(point.y).toBeCloseTo(56.25, 3) + }) + + it('keeps smooth add handle at straight segment midpoint', () => { + const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 1, 'smooth') + expect(point).toEqual({ x: 50, y: 50 }) + }) +}) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index fcb05ba..3a6c24d 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' -import { buildWaypointPath, snap45, snap45both } from './waypointUtils' +import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] @@ -161,9 +161,9 @@ function segmentMidpoints( const isSmooth = pathStyle === 'smooth' return pts.slice(0, -1).map((a, i) => { - const b = pts[i + 1] - let mx = (a.x + b.x) / 2 - const my = (a.y + b.y) / 2 + const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle) + let mx = base.x + const my = base.y // For smooth style with no existing waypoints, bias the single + handle onto // the source handle axis so clicking it creates a perpendicular exit. @@ -205,8 +205,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t ? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) : autoPath - const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX - const midY = (sourceY + targetY) / 2 + const labelPosition = hasWaypoints + ? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) + : { x: labelX, y: (sourceY + targetY) / 2 } const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeColors = theme.colors.edgeColors @@ -300,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
Waypoint, steps = 24): number { + let length = 0 + let prev = pointAt(0) + + for (let step = 1; step <= steps; step++) { + const next = pointAt(step / steps) + length += Math.hypot(next.x - prev.x, next.y - prev.y) + prev = next + } + + return length +} + +type PathSegment = { + length: number + pointAt: (t: number) => Waypoint +} + +function buildBezierSegments(pts: Waypoint[]): PathSegment[] { + if (pts.length < 2) return [] + + return pts.slice(0, -1).map((_, i) => { + const p0 = pts[Math.max(i - 1, 0)] + const p1 = pts[i] + const p2 = pts[i + 1] + const p3 = pts[Math.min(i + 2, pts.length - 1)] + const cp1 = { + x: p1.x + (p2.x - p0.x) / 6, + y: p1.y + (p2.y - p0.y) / 6, + } + const cp2 = { + x: p2.x - (p3.x - p1.x) / 6, + y: p2.y - (p3.y - p1.y) / 6, + } + const pointAt = (t: number) => interpolateCubic(p1, cp1, cp2, p2, t) + + return { + length: approximateLength(pointAt), + pointAt, + } + }) +} + +function buildSmoothSegments(pts: Waypoint[], radius = 8): PathSegment[] { + if (pts.length < 2) return [] + if (pts.length === 2) { + const pointAt = (t: number) => interpolateLine(pts[0], pts[1], t) + return [{ length: Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y), pointAt }] + } + + const segments: PathSegment[] = [] + let cursor = pts[0] + + for (let i = 1; i < pts.length - 1; i++) { + const prev = pts[i - 1] + const curr = pts[i] + const next = pts[i + 1] + + const dx1 = curr.x - prev.x + const dy1 = curr.y - prev.y + const len1 = Math.hypot(dx1, dy1) + + const dx2 = next.x - curr.x + const dy2 = next.y - curr.y + const len2 = Math.hypot(dx2, dy2) + + if (len1 < 1 || len2 < 1) { + const start = { x: cursor.x, y: cursor.y } + const end = { x: curr.x, y: curr.y } + const lineToCurr = (t: number) => interpolateLine(start, end, t) + segments.push({ + length: Math.hypot(curr.x - cursor.x, curr.y - cursor.y), + pointAt: lineToCurr, + }) + cursor = curr + continue + } + + const r = Math.min(radius, len1 / 2, len2 / 2) + const before = { + x: curr.x - (dx1 / len1) * r, + y: curr.y - (dy1 / len1) * r, + } + const after = { + x: curr.x + (dx2 / len2) * r, + y: curr.y + (dy2 / len2) * r, + } + + const lineStart = { x: cursor.x, y: cursor.y } + const lineEnd = { x: before.x, y: before.y } + const lineToBefore = (t: number) => interpolateLine(lineStart, lineEnd, t) + segments.push({ + length: Math.hypot(before.x - cursor.x, before.y - cursor.y), + pointAt: lineToBefore, + }) + + const curveAroundCorner = (t: number) => interpolateQuadratic(before, curr, after, t) + segments.push({ + length: approximateLength(curveAroundCorner), + pointAt: curveAroundCorner, + }) + + cursor = after + } + + const targetStart = { x: cursor.x, y: cursor.y } + const targetEnd = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y } + const lineToTarget = (t: number) => interpolateLine(targetStart, targetEnd, t) + segments.push({ + length: Math.hypot(pts[pts.length - 1].x - cursor.x, pts[pts.length - 1].y - cursor.y), + pointAt: lineToTarget, + }) + + return segments +} + +export function getWaypointLabelPosition( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + pathStyle: string = 'bezier', +): Waypoint { + const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + const segments = pathStyle === 'smooth' ? buildSmoothSegments(pts) : buildBezierSegments(pts) + + if (segments.length === 0) return pts[0] + + const totalLength = segments.reduce((sum, segment) => sum + segment.length, 0) + if (totalLength <= 0) return pts[Math.floor(pts.length / 2)] + + let remaining = totalLength / 2 + for (const segment of segments) { + if (remaining <= segment.length) { + const t = segment.length === 0 ? 0 : remaining / segment.length + return segment.pointAt(t) + } + remaining -= segment.length + } + + const lastSegment = segments[segments.length - 1] + return lastSegment.pointAt(1) +} + +function getBezierSegmentPoint( + pts: Waypoint[], + insertIndex: number, + t: number, +): Waypoint { + const i = Math.max(0, Math.min(insertIndex, pts.length - 2)) + const p0 = pts[Math.max(i - 1, 0)] + const p1 = pts[i] + const p2 = pts[i + 1] + const p3 = pts[Math.min(i + 2, pts.length - 1)] + const cp1 = { + x: p1.x + (p2.x - p0.x) / 6, + y: p1.y + (p2.y - p0.y) / 6, + } + const cp2 = { + x: p2.x - (p3.x - p1.x) / 6, + y: p2.y - (p3.y - p1.y) / 6, + } + + return interpolateCubic(p1, cp1, cp2, p2, t) +} + +export function getAddWaypointHandlePosition( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + insertIndex: number, + pathStyle: string = 'bezier', +): Waypoint { + const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + + if (pts.length < 2) return { x: sourceX, y: sourceY } + + if (pathStyle !== 'smooth') { + return getBezierSegmentPoint(pts, insertIndex, 0.5) + } + + const i = Math.max(0, Math.min(insertIndex, pts.length - 2)) + return { + x: (pts[i].x + pts[i + 1].x) / 2, + y: (pts[i].y + pts[i + 1].y) / 2, + } +} + // ── 45° snapping ────────────────────────────────────────────────────────────── /** diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx new file mode 100644 index 0000000..a0e0cb3 --- /dev/null +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -0,0 +1,484 @@ +import { useState, useCallback } from 'react' +import { toast } from 'sonner' +import { + Globe, Router, Network, Server, Layers, Box, Container, HardDrive, + Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, + type LucideIcon, +} from 'lucide-react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useThemeStore } from '@/stores/themeStore' +import { useCanvasStore } from '@/stores/canvasStore' +import { THEMES } from '@/utils/themes' +import { applyOpacity } from '@/utils/colorUtils' +import type { + NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle, +} from '@/types' +import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types' + +// ── Node types exposed for custom style (skip groupRect/group) ─────────────── + +const EDITABLE_NODE_TYPES: NodeType[] = [ + 'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', + 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', + 'docker_container', 'generic', +] + +const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] + +const NODE_ICONS: Record = { + isp: Globe, router: Router, switch: Network, server: Server, proxmox: Layers, + vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi, + camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap, + docker_host: Anchor, docker_container: Package, generic: Circle, +} + +// ── Default style for a node type (from default theme) ───────────────────── + +function defaultNodeStyle(nodeType: NodeType): NodeTypeStyle { + const accent = THEMES.default.colors.nodeAccents[nodeType] ?? THEMES.default.colors.nodeAccents.generic + return { + borderColor: accent.border, + borderOpacity: 1, + bgColor: THEMES.default.colors.nodeCardBackground, + bgOpacity: 1, + iconColor: accent.icon, + iconOpacity: 1, + width: 0, + height: 0, + } +} + +function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle { + return { + color: THEMES.default.colors.edgeColors[edgeType], + opacity: 1, + pathStyle: 'bezier', + animated: 'none', + } +} + +// ── Color + opacity row ────────────────────────────────────────────────────── + +interface ColorRowProps { + label: string + color: string + opacity: number + onColorChange: (v: string) => void + onOpacityChange: (v: number) => void +} + +function ColorRow({ label, color, opacity, onColorChange, onOpacityChange }: ColorRowProps) { + return ( +
+ {label} + onColorChange(e.target.value)} + className="w-7 h-7 rounded cursor-pointer border border-[#30363d] bg-transparent p-0.5" + /> +
+ onOpacityChange(parseFloat(e.target.value))} + className="flex-1 h-1 accent-[#00d4ff]" + /> + + {Math.round(opacity * 100)}% + +
+
+
+ ) +} + +// ── Node type editor ───────────────────────────────────────────────────────── + +interface NodeEditorProps { + nodeType: NodeType + style: NodeTypeStyle + onChange: (s: NodeTypeStyle) => void + onApplyToExisting: () => void +} + +function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditorProps) { + const set = useCallback((k: K, v: NodeTypeStyle[K]) => { + onChange({ ...style, [k]: v }) + }, [style, onChange]) + + return ( +
+
{NODE_TYPE_LABELS[nodeType]}
+
+ set('borderColor', v)} + onOpacityChange={(v) => set('borderOpacity', v)} + /> + set('bgColor', v)} + onOpacityChange={(v) => set('bgOpacity', v)} + /> + set('iconColor', v)} + onOpacityChange={(v) => set('iconOpacity', v)} + /> +
+ +
+
Default size
+
0 = auto (min 140 × 50 px, grows with content)
+
+
+ W + set('width', parseInt(e.target.value) || 0)} + className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" + /> +
+
+ H + set('height', parseInt(e.target.value) || 0)} + className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" + /> +
+
+
+ + +
+ ) +} + +// ── Edge type editor ───────────────────────────────────────────────────────── + +interface EdgeEditorProps { + edgeType: EdgeType + style: EdgeTypeStyle + onChange: (s: EdgeTypeStyle) => void + onApplyToExisting: () => void +} + +function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditorProps) { + const set = useCallback((k: K, v: EdgeTypeStyle[K]) => { + onChange({ ...style, [k]: v }) + }, [style, onChange]) + + return ( +
+
{EDGE_TYPE_LABELS[edgeType]}
+
+ set('color', v)} + onOpacityChange={(v) => set('opacity', v)} + /> +
+ +
+
+
Path style
+
+ {(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => ( + + ))} +
+
+ +
+
Animation
+ +
+
+ + +
+ ) +} + +// ── Main modal ─────────────────────────────────────────────────────────────── + +type Tab = 'nodes' | 'edges' +type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeType } | null + +interface CustomStyleModalProps { + open: boolean + onClose: () => void +} + +export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) { + const { customStyle, setCustomStyle } = useThemeStore() + const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore() + + const [tab, setTab] = useState('nodes') + const [selection, setSelection] = useState(null) + const [draft, setDraft] = useState(() => ({ + nodes: { ...customStyle.nodes }, + edges: { ...customStyle.edges }, + })) + + const handleOpen = (isOpen: boolean) => { + if (isOpen) { + // Reset draft to current saved customStyle on open + setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } }) + setSelection(null) + } else { + onClose() + } + } + + const getNodeStyle = (t: NodeType): NodeTypeStyle => + draft.nodes[t] ?? defaultNodeStyle(t) + + const getEdgeStyle = (t: EdgeType): EdgeTypeStyle => + draft.edges[t] ?? defaultEdgeStyle(t) + + const handleNodeChange = (t: NodeType, s: NodeTypeStyle) => + setDraft((d) => ({ ...d, nodes: { ...d.nodes, [t]: s } })) + + const handleEdgeChange = (t: EdgeType, s: EdgeTypeStyle) => + setDraft((d) => ({ ...d, edges: { ...d.edges, [t]: s } })) + + const handleApplyNodeType = (t: NodeType) => { + const style = getNodeStyle(t) + applyTypeNodeStyle(t, style) + toast.success(`Applied style to all ${NODE_TYPE_LABELS[t]} nodes`) + } + + const handleApplyEdgeType = (t: EdgeType) => { + const style = getEdgeStyle(t) + applyTypeEdgeStyle(t, style) + toast.success(`Applied style to all ${EDGE_TYPE_LABELS[t]} edges`) + } + + const handleSave = () => { + setCustomStyle(draft) + markUnsaved() + toast.success('Custom style saved — save your canvas to persist') + onClose() + } + + const handleApplyAll = () => { + setCustomStyle(draft) + applyAllCustomStyles(draft) + markUnsaved() + toast.success('Custom style applied to all nodes and edges') + onClose() + } + + const selectedNodeStyle = selection?.kind === 'node' ? getNodeStyle(selection.type) : null + const selectedEdgeStyle = selection?.kind === 'edge' ? getEdgeStyle(selection.type) : null + + return ( + + + + Custom Style Editor + + +
+ {/* Left panel — type list */} +
+ {/* Tabs */} +
+ {(['nodes', 'edges'] as Tab[]).map((t) => ( + + ))} +
+ + {/* Type list */} +
+ {tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => { + const Icon = NODE_ICONS[t] ?? Circle + const style = draft.nodes[t] + const isSelected = selection?.kind === 'node' && selection.type === t + const swatchColor = style + ? applyOpacity(style.borderColor, style.borderOpacity) + : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e' + + return ( + + ) + })} + + {tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => { + const style = draft.edges[t] + const isSelected = selection?.kind === 'edge' && selection.type === t + const swatchColor = style + ? applyOpacity(style.color, style.opacity) + : THEMES.default.colors.edgeColors[t] + + return ( + + ) + })} +
+
+ + {/* Right panel — editor */} +
+ {!selection && ( +
+ Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style +
+ )} + + {selection?.kind === 'node' && selectedNodeStyle && ( + handleNodeChange(selection.type, s)} + onApplyToExisting={() => handleApplyNodeType(selection.type)} + /> + )} + + {selection?.kind === 'edge' && selectedEdgeStyle && ( + handleEdgeChange(selection.type, s)} + onApplyToExisting={() => handleApplyEdgeType(selection.type)} + /> + )} +
+
+ + {/* Footer */} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 664b12d..720572d 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import modalStyles from './modal-interactive.module.css' import { RotateCcw } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -68,8 +69,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
{onDelete ? ( - ) : }
- - +
diff --git a/frontend/src/components/modals/GroupRectModal.tsx b/frontend/src/components/modals/GroupRectModal.tsx index e698ce2..209b57b 100644 --- a/frontend/src/components/modals/GroupRectModal.tsx +++ b/frontend/src/components/modals/GroupRectModal.tsx @@ -1,4 +1,5 @@ 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 { Input } from '@/components/ui/input' @@ -129,7 +130,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit value={form.label} onChange={(e) => set('label', e.target.value)} placeholder="Zone name…" - className="bg-[#21262d] border-[#30363d] text-sm h-8" + className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`} />
@@ -137,7 +138,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
set('z_order', v !== null ? Number(v) : 1)}> - + @@ -338,17 +339,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit type="button" variant="ghost" size="sm" - className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" + className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer" onClick={() => { onDelete(); onClose() }} > Delete )}
- -
diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index f8cf3f1..622b51b 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -1,4 +1,5 @@ import { Fragment, createElement, useState } from 'react' +import modalStyles from './modal-interactive.module.css' import { RotateCcw, ChevronDown } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -96,7 +97,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
set('ip', e.target.value)} placeholder="192.168.1.x, 2001:db8::1" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" + className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`} /> + comma-separated
{/* Check method */}
set('bottom_handles', parseInt(v ?? '1', 10))} > - + @@ -383,22 +391,37 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' value={form.notes ?? ''} onChange={(e) => set('notes', e.target.value)} placeholder="Optional notes" - className="bg-[#21262d] border-[#30363d] text-sm h-8" + className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`} />
-
- - +
+ {/* Show delete button only for edit mode (not add) */} + {title !== 'Add Node' ? ( + + ) : } +
+ + +
diff --git a/frontend/src/components/modals/ThemeModal.tsx b/frontend/src/components/modals/ThemeModal.tsx index 27aa58c..3100f27 100644 --- a/frontend/src/components/modals/ThemeModal.tsx +++ b/frontend/src/components/modals/ThemeModal.tsx @@ -1,11 +1,12 @@ import { useRef, useState, type KeyboardEvent } from 'react' import { toast } from 'sonner' -import { Check } from 'lucide-react' +import { Check, Pencil } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' +import { CustomStyleModal } from './CustomStyleModal' // Node-type accent colors to display as preview swatches const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const @@ -16,76 +17,106 @@ interface ThemeCardProps { onClick: () => void onKeyDown?: (event: KeyboardEvent) => void buttonRef?: (element: HTMLButtonElement | null) => void + onEdit?: () => void } -function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef }: ThemeCardProps) { +function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef, onEdit }: ThemeCardProps) { + const { customStyle } = useThemeStore() const preset = THEMES[themeId] const c = preset.colors + const isCustom = themeId === 'custom' + + // For custom theme, use defined node colors for preview swatches + const swatchColors = isCustom + ? PREVIEW_TYPES.map((t) => customStyle.nodes[t]?.borderColor ?? c.nodeAccents[t].border) + : PREVIEW_TYPES.map((t) => c.nodeAccents[t].border) + + const ethernetColor = isCustom + ? (customStyle.edges['ethernet']?.color ?? c.edgeColors.ethernet) + : c.edgeColors.ethernet + const wifiColor = isCustom + ? (customStyle.edges['wifi']?.color ?? c.edgeColors.wifi) + : c.edgeColors.wifi return ( - +
+ {preset.label} +
+
+ {preset.description} +
+ + + {/* Edit button — only for custom theme */} + {isCustom && onEdit && ( + + )} +
) } @@ -98,6 +129,7 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { const { activeTheme, setTheme } = useThemeStore() const { markUnsaved } = useCanvasStore() const cardRefs = useRef>([]) + const [customStyleOpen, setCustomStyleOpen] = useState(false) // Capture the theme that was active when the modal opened const [originalTheme] = useState(activeTheme) @@ -105,7 +137,6 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { const handleSelect = (id: ThemeId) => { setSelected(id) - // Live-preview the selected theme on the canvas setTheme(id) } @@ -137,65 +168,65 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { setTheme(selected) markUnsaved() onClose() - toast.info('Style applied — save your canvas to make it permanent', { - duration: 5000, - }) + toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 }) } const handleCancel = () => { - // Revert to the original theme setTheme(originalTheme) onClose() } return ( - { if (!o) handleCancel() }}> - - - Choose Canvas Style - + <> + { if (!o) handleCancel() }}> + + + Choose Canvas Style + -
- {THEME_ORDER.map((id, index) => ( -
- handleSelect(id)} - onKeyDown={handleCardKeyDown(index)} - buttonRef={(element) => { - cardRefs.current[index] = element - }} - /> -
- ))} -
+
+ {THEME_ORDER.map((id, index) => ( +
+ handleSelect(id)} + onKeyDown={handleCardKeyDown(index)} + buttonRef={(element) => { cardRefs.current[index] = element }} + onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined} + /> +
+ ))} +
-
- - -
-
-
+
+ + +
+
+
+ + setCustomStyleOpen(false)} /> + ) } diff --git a/frontend/src/components/modals/modal-interactive.module.css b/frontend/src/components/modals/modal-interactive.module.css new file mode 100644 index 0000000..45650b5 --- /dev/null +++ b/frontend/src/components/modals/modal-interactive.module.css @@ -0,0 +1,27 @@ +/* SidebarItem pointer on hover */ +.sidebar-pointer:hover { + cursor: pointer !important; +} +/* Consistent border radius for all modal input/select/button elements */ +.modal-radius { + border-radius: 6px !important; +} + +/* Pointer cursor for close X */ +.modal-close-pointer:hover { + cursor: pointer !important; +} + +/* Subtle hover background for cancel button */ +.modal-cancel-hover:hover { + background: #21262d !important; +} +/* Shared hover/focus border effect for interactive modal elements */ +.modal-interactive { + transition: border-color 0.15s; +} +.modal-interactive:hover, +.modal-interactive:focus { + border-color: #8b949e !important; + outline: none; +} diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 6bc8c5a..b53a8ad 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -200,7 +200,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {