Merge branch 'Pouzor:main' into main

This commit is contained in:
Brett Ferrante
2026-04-24 13:27:12 -04:00
committed by GitHub
29 changed files with 1371 additions and 207 deletions
+1 -1
View File
@@ -1 +1 @@
1.11.0 1.12.0
+4 -2
View File
@@ -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], nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges], edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport, viewport=viewport,
custom_style=state.custom_style if state else None,
) )
@@ -67,13 +68,14 @@ async def save_canvas(
else: else:
db.add(Edge(**edge_data.model_dump())) db.add(Edge(**edge_data.model_dump()))
# Upsert viewport # Upsert viewport + custom style
state = await db.get(CanvasState, 1) state = await db.get(CanvasState, 1)
if state: if state:
state.viewport = body.viewport state.viewport = body.viewport
state.custom_style = body.custom_style
state.saved_at = datetime.now(timezone.utc) state.saved_at = datetime.now(timezone.utc)
else: 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() await db.commit()
return {"saved": True} return {"saved": True}
+2
View File
@@ -84,6 +84,8 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON") 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) # Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
with suppress(OperationalError): with suppress(OperationalError):
rows = await conn.exec_driver_sql( rows = await conn.exec_driver_sql(
+1
View File
@@ -78,6 +78,7 @@ class CanvasState(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) 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) saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+2
View File
@@ -62,9 +62,11 @@ class CanvasSaveRequest(BaseModel):
nodes: list[NodeSave] = [] nodes: list[NodeSave] = []
edges: list[EdgeSave] = [] edges: list[EdgeSave] = []
viewport: dict[str, Any] = {} viewport: dict[str, Any] = {}
custom_style: dict[str, Any] | None = None
class CanvasStateResponse(BaseModel): class CanvasStateResponse(BaseModel):
nodes: list[NodeResponse] nodes: list[NodeResponse]
edges: list[EdgeResponse] edges: list[EdgeResponse]
viewport: dict[str, Any] viewport: dict[str, Any]
custom_style: dict[str, Any] | None = None
+39
View File
@@ -557,3 +557,42 @@ async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: di
edge = canvas["edges"][0] edge = canvas["edges"][0]
assert edge["label"] == "updated" assert edge["label"] == "updated"
assert edge["custom_color"] == "#ff0000" 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"]
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.11.0", "version": "1.12.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+9 -7
View File
@@ -29,7 +29,7 @@ import { useThemeStore } from '@/stores/themeStore'
import { canvasApi } from '@/api/client' import { canvasApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData' import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling' 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 = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas' 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 { 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<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme } = useThemeStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
useStatusPolling() useStatusPolling()
@@ -60,20 +60,20 @@ export default function App() {
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
try { try {
if (STANDALONE) { 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() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
return return
} }
const nodesToSave = nodes.map(serializeNode) const nodesToSave = nodes.map(serializeNode)
const edgesToSave = edges.map(serializeEdge) 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() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
} catch { } catch {
toast.error('Save failed') 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 // Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave) const handleSaveRef = useRef(handleSave)
@@ -85,8 +85,9 @@ export default function App() {
try { try {
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY) const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
if (saved) { 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 (theme_id) setTheme(theme_id)
if (custom_style) setCustomStyle(custom_style)
loadCanvas(savedNodes, savedEdges) loadCanvas(savedNodes, savedEdges)
} else { } else {
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
@@ -111,13 +112,14 @@ export default function App() {
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
const savedTheme = res.data.viewport?.theme_id const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme) if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
loadCanvas(rfNodes, rfEdges) loadCanvas(rfNodes, rfEdges)
} else { } else {
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
} }
}) })
.catch(() => 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 // Keep refs for store actions so keydown handler is always up-to-date without re-registering
const undoRef = useRef(undo) const undoRef = useRef(undo)
+1
View File
@@ -33,6 +33,7 @@ export const canvasApi = {
nodes: object[] nodes: object[]
edges: object[] edges: object[]
viewport: object viewport: object
custom_style?: object | null
}) => api.post('/canvas/save', payload), }) => api.post('/canvas/save', payload),
} }
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' 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)', () => { describe('buildWaypointPath — bezier (default)', () => {
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => { it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
@@ -173,3 +173,35 @@ describe('findInsertIndex', () => {
expect(idx).toBe(2) 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 })
})
})
@@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { THEMES } from '@/utils/themes' 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'] const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
@@ -161,9 +161,9 @@ function segmentMidpoints(
const isSmooth = pathStyle === 'smooth' const isSmooth = pathStyle === 'smooth'
return pts.slice(0, -1).map((a, i) => { return pts.slice(0, -1).map((a, i) => {
const b = pts[i + 1] const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle)
let mx = (a.x + b.x) / 2 let mx = base.x
const my = (a.y + b.y) / 2 const my = base.y
// For smooth style with no existing waypoints, bias the single + handle onto // For smooth style with no existing waypoints, bias the single + handle onto
// the source handle axis so clicking it creates a perpendicular exit. // 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) ? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
: autoPath : autoPath
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX const labelPosition = hasWaypoints
const midY = (sourceY + targetY) / 2 ? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
: { x: labelX, y: (sourceY + targetY) / 2 }
const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeType: EdgeType = data?.type ?? 'ethernet'
const edgeColors = theme.colors.edgeColors const edgeColors = theme.colors.edgeColors
@@ -300,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
<div <div
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded" className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
style={{ style={{
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`, transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
background: theme.colors.edgeLabelBackground, background: theme.colors.edgeLabelBackground,
color: theme.colors.edgeLabelColor, color: theme.colors.edgeLabelColor,
border: `1px solid ${theme.colors.edgeLabelBorder}`, border: `1px solid ${theme.colors.edgeLabelBorder}`,
@@ -72,6 +72,216 @@ export function buildWaypointPath(
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts) return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
} }
function interpolateLine(a: Waypoint, b: Waypoint, t: number): Waypoint {
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
}
}
function interpolateQuadratic(a: Waypoint, b: Waypoint, c: Waypoint, t: number): Waypoint {
const mt = 1 - t
return {
x: mt * mt * a.x + 2 * mt * t * b.x + t * t * c.x,
y: mt * mt * a.y + 2 * mt * t * b.y + t * t * c.y,
}
}
function interpolateCubic(a: Waypoint, b: Waypoint, c: Waypoint, d: Waypoint, t: number): Waypoint {
const mt = 1 - t
return {
x: mt * mt * mt * a.x + 3 * mt * mt * t * b.x + 3 * mt * t * t * c.x + t * t * t * d.x,
y: mt * mt * mt * a.y + 3 * mt * mt * t * b.y + 3 * mt * t * t * c.y + t * t * t * d.y,
}
}
function approximateLength(pointAt: (t: number) => 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 ────────────────────────────────────────────────────────────── // ── 45° snapping ──────────────────────────────────────────────────────────────
/** /**
@@ -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<string, LucideIcon> = {
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 (
<div className="flex items-center gap-3">
<span className="text-xs text-[#8b949e] w-20 shrink-0">{label}</span>
<input
type="color"
value={color}
onChange={(e) => onColorChange(e.target.value)}
className="w-7 h-7 rounded cursor-pointer border border-[#30363d] bg-transparent p-0.5"
/>
<div className="flex items-center gap-2 flex-1">
<input
type="range"
min={0}
max={1}
step={0.01}
value={opacity}
onChange={(e) => onOpacityChange(parseFloat(e.target.value))}
className="flex-1 h-1 accent-[#00d4ff]"
/>
<span className="text-xs text-[#8b949e] w-8 text-right">
{Math.round(opacity * 100)}%
</span>
</div>
<div
className="w-5 h-5 rounded border border-[#30363d] shrink-0"
style={{ background: applyOpacity(color, opacity) }}
/>
</div>
)
}
// ── 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 extends keyof NodeTypeStyle>(k: K, v: NodeTypeStyle[K]) => {
onChange({ ...style, [k]: v })
}, [style, onChange])
return (
<div className="flex flex-col gap-4">
<div className="text-sm font-semibold text-[#e6edf3]">{NODE_TYPE_LABELS[nodeType]}</div>
<div className="flex flex-col gap-3">
<ColorRow
label="Border"
color={style.borderColor}
opacity={style.borderOpacity}
onColorChange={(v) => set('borderColor', v)}
onOpacityChange={(v) => set('borderOpacity', v)}
/>
<ColorRow
label="Background"
color={style.bgColor}
opacity={style.bgOpacity}
onColorChange={(v) => set('bgColor', v)}
onOpacityChange={(v) => set('bgOpacity', v)}
/>
<ColorRow
label="Icon"
color={style.iconColor}
opacity={style.iconOpacity}
onColorChange={(v) => set('iconColor', v)}
onOpacityChange={(v) => set('iconOpacity', v)}
/>
</div>
<div className="border-t border-[#30363d] pt-3">
<div className="text-xs text-[#8b949e] mb-1">Default size</div>
<div className="text-xs text-[#8b949e]/60 mb-2">0 = auto (min 140 × 50 px, grows with content)</div>
<div className="flex gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-[#8b949e]">W</span>
<input
type="number"
min={0}
step={10}
value={style.width}
onChange={(e) => set('width', parseInt(e.target.value) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-[#8b949e]">H</span>
<input
type="number"
min={0}
step={10}
value={style.height}
onChange={(e) => set('height', parseInt(e.target.value) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
</div>
</div>
</div>
<Button
size="sm"
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onApplyToExisting}
>
Apply to existing {NODE_TYPE_LABELS[nodeType]} nodes
</Button>
</div>
)
}
// ── 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 extends keyof EdgeTypeStyle>(k: K, v: EdgeTypeStyle[K]) => {
onChange({ ...style, [k]: v })
}, [style, onChange])
return (
<div className="flex flex-col gap-4">
<div className="text-sm font-semibold text-[#e6edf3]">{EDGE_TYPE_LABELS[edgeType]}</div>
<div className="flex flex-col gap-3">
<ColorRow
label="Color"
color={style.color}
opacity={style.opacity}
onColorChange={(v) => set('color', v)}
onOpacityChange={(v) => set('opacity', v)}
/>
</div>
<div className="border-t border-[#30363d] pt-3 flex flex-col gap-3">
<div>
<div className="text-xs text-[#8b949e] mb-2">Path style</div>
<div className="flex gap-2">
{(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => (
<button
key={ps}
type="button"
onClick={() => set('pathStyle', ps)}
className="px-3 py-1 text-xs rounded border transition-colors"
style={{
borderColor: style.pathStyle === ps ? '#00d4ff' : '#30363d',
background: style.pathStyle === ps ? '#00d4ff22' : 'transparent',
color: style.pathStyle === ps ? '#00d4ff' : '#8b949e',
}}
>
{ps.charAt(0).toUpperCase() + ps.slice(1)}
</button>
))}
</div>
</div>
<div>
<div className="text-xs text-[#8b949e] mb-2">Animation</div>
<select
value={style.animated}
onChange={(e) => set('animated', e.target.value as EdgeTypeStyle['animated'])}
className="w-full h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
>
<option value="none">None</option>
<option value="basic">Basic</option>
<option value="flow">Flow</option>
<option value="snake">Snake</option>
</select>
</div>
</div>
<Button
size="sm"
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onApplyToExisting}
>
Apply to existing {EDGE_TYPE_LABELS[edgeType]} edges
</Button>
</div>
)
}
// ── 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<Tab>('nodes')
const [selection, setSelection] = useState<Selection>(null)
const [draft, setDraft] = useState<CustomStyleDef>(() => ({
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 (
<Dialog open={open} onOpenChange={handleOpen}>
<DialogContent className="bg-[#161b22] border-[#30363d] max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-5 pt-5 pb-3 border-b border-[#30363d]">
<DialogTitle className="text-sm font-semibold">Custom Style Editor</DialogTitle>
</DialogHeader>
<div className="flex flex-1 overflow-hidden min-h-0">
{/* Left panel — type list */}
<div className="w-52 shrink-0 border-r border-[#30363d] flex flex-col overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-[#30363d]">
{(['nodes', 'edges'] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => { setTab(t); setSelection(null) }}
className="flex-1 py-2 text-xs font-medium transition-colors"
style={{
borderBottom: tab === t ? '2px solid #00d4ff' : '2px solid transparent',
color: tab === t ? '#00d4ff' : '#8b949e',
}}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{/* Type list */}
<div className="flex-1 overflow-y-auto py-1">
{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 (
<button
key={t}
type="button"
onClick={() => setSelection({ kind: 'node', type: t })}
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
style={{
background: isSelected ? '#21262d' : 'transparent',
color: isSelected ? '#e6edf3' : '#8b949e',
}}
>
<Icon size={13} />
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: swatchColor }}
/>
</button>
)
})}
{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 (
<button
key={t}
type="button"
onClick={() => setSelection({ kind: 'edge', type: t })}
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
style={{
background: isSelected ? '#21262d' : 'transparent',
color: isSelected ? '#e6edf3' : '#8b949e',
}}
>
<span className="flex-1 truncate">{EDGE_TYPE_LABELS[t]}</span>
<span
className="w-8 h-1.5 rounded-full shrink-0"
style={{ background: swatchColor }}
/>
</button>
)
})}
</div>
</div>
{/* Right panel — editor */}
<div className="flex-1 overflow-y-auto p-5">
{!selection && (
<div className="flex items-center justify-center h-full text-xs text-[#8b949e]">
Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style
</div>
)}
{selection?.kind === 'node' && selectedNodeStyle && (
<NodeEditor
key={selection.type}
nodeType={selection.type}
style={selectedNodeStyle}
onChange={(s) => handleNodeChange(selection.type, s)}
onApplyToExisting={() => handleApplyNodeType(selection.type)}
/>
)}
{selection?.kind === 'edge' && selectedEdgeStyle && (
<EdgeEditor
key={selection.type}
edgeType={selection.type}
style={selectedEdgeStyle}
onChange={(s) => handleEdgeChange(selection.type, s)}
onApplyToExisting={() => handleApplyEdgeType(selection.type)}
/>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-between gap-2 px-5 py-3 border-t border-[#30363d]">
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={onClose}
>
Cancel
</Button>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="border-[#30363d] text-[#e6edf3] hover:bg-[#21262d]"
onClick={handleSave}
>
Save Custom Style
</Button>
<Button
type="button"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={handleApplyAll}
>
Apply All to Canvas
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+19 -12
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import modalStyles from './modal-interactive.module.css'
import { RotateCcw } from 'lucide-react' import { RotateCcw } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -68,8 +69,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Link Type</Label> <Label className="text-xs text-muted-foreground">Link Type</Label>
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}> <Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Edge type selector">
<SelectValue /> <SelectValue>{EDGE_TYPE_LABELS[type]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
{EDGE_TYPES.map(([value, label]) => ( {EDGE_TYPES.map(([value, label]) => (
@@ -89,7 +90,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
value={vlanId} value={vlanId}
onChange={(e) => setVlanId(e.target.value)} onChange={(e) => setVlanId(e.target.value)}
placeholder="e.g. 20" placeholder="e.g. 20"
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']}`}
/> />
</div> </div>
)} )}
@@ -100,19 +101,21 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. 1G, trunk..." placeholder="e.g. 1G, trunk..."
className="bg-[#21262d] border-[#30363d] text-sm h-8" className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Path Style</Label> <Label className="text-xs text-muted-foreground">Path Style</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]"> <div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
{(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => ( {(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => (
<button <button
key={style} key={style}
type="button" type="button"
onClick={() => setPathStyle(style)} onClick={() => setPathStyle(style)}
className="flex-1 py-1 text-xs capitalize transition-colors" className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
tabIndex={0}
aria-label={`Path style ${style}`}
style={{ style={{
background: pathStyle === style ? '#00d4ff22' : '#21262d', background: pathStyle === style ? '#00d4ff22' : '#21262d',
color: pathStyle === style ? '#00d4ff' : '#8b949e', color: pathStyle === style ? '#00d4ff' : '#8b949e',
@@ -127,13 +130,15 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Animation</Label> <Label className="text-xs text-muted-foreground">Animation</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]"> <div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => ( {(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
<button <button
key={mode} key={mode}
type="button" type="button"
onClick={() => setAnimation(mode)} onClick={() => setAnimation(mode)}
className="flex-1 py-1 text-xs capitalize transition-colors" className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
tabIndex={0}
aria-label={`Animation mode ${mode}`}
style={{ style={{
background: animation === mode ? '#00d4ff22' : '#21262d', background: animation === mode ? '#00d4ff22' : '#21262d',
color: animation === mode ? '#00d4ff' : '#8b949e', color: animation === mode ? '#00d4ff' : '#8b949e',
@@ -160,8 +165,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
)} )}
</div> </div>
<label <label
className="relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer" className={`relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }} style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }}
tabIndex={0}
aria-label="Edge color picker"
> >
<input <input
type="color" type="color"
@@ -189,13 +196,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<div className="flex justify-between gap-2 pt-1"> <div className="flex justify-between gap-2 pt-1">
{onDelete ? ( {onDelete ? (
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}> <Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer" onClick={handleDelete}>
Delete Delete
</Button> </Button>
) : <span />} ) : <span />}
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button> <Button type="button" variant="ghost" size="sm" className="cursor-pointer" onClick={onClose}>Cancel</Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"> <Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
{onDelete ? 'Save' : 'Connect'} {onDelete ? 'Save' : 'Connect'}
</Button> </Button>
</div> </div>
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import modalStyles from './modal-interactive.module.css'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -129,7 +130,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
value={form.label} value={form.label}
onChange={(e) => set('label', e.target.value)} onChange={(e) => set('label', e.target.value)}
placeholder="Zone name…" placeholder="Zone name…"
className="bg-[#21262d] border-[#30363d] text-sm h-8" className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
/> />
</div> </div>
@@ -137,7 +138,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Font</Label> <Label className="text-xs text-muted-foreground">Font</Label>
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}> <Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
@@ -162,7 +163,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
type="button" type="button"
title={value} title={value}
onClick={() => set('text_position', value)} onClick={() => set('text_position', value)}
className="h-8 rounded text-base transition-colors" className={`h-8 rounded text-base transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : '#21262d', background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -187,7 +188,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
key={value} key={value}
type="button" type="button"
onClick={() => set('label_position', value)} onClick={() => set('label_position', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors" className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : '#21262d', background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -248,7 +249,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
key={value} key={value}
type="button" type="button"
onClick={() => set('text_size', value)} onClick={() => set('text_size', value)}
className="flex items-center justify-center h-8 rounded transition-colors" className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : '#21262d', background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -275,7 +276,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
type="button" type="button"
title={label} title={label}
onClick={() => set('border_style', value)} onClick={() => set('border_style', value)}
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors" className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : '#21262d', background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -301,7 +302,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
key={value} key={value}
type="button" type="button"
onClick={() => set('border_width', value)} onClick={() => set('border_width', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors" className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : '#21262d', background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -319,7 +320,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label> <Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}> <Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']}`}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
@@ -338,17 +339,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" 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() }} onClick={() => { onDelete(); onClose() }}
> >
Delete Delete
</Button> </Button>
)} )}
<div className="flex gap-2 ml-auto"> <div className="flex gap-2 ml-auto">
<Button type="button" variant="ghost" size="sm" onClick={onClose}> <Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"> <Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
{title === 'Add Zone' ? 'Add' : 'Save'} {title === 'Add Zone' ? 'Add' : 'Save'}
</Button> </Button>
</div> </div>
+49 -26
View File
@@ -1,4 +1,5 @@
import { Fragment, createElement, useState } from 'react' import { Fragment, createElement, useState } from 'react'
import modalStyles from './modal-interactive.module.css'
import { RotateCcw, ChevronDown } from 'lucide-react' import { RotateCcw, ChevronDown } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -96,7 +97,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Type</Label> <Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}> <Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue> <SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
@@ -137,7 +138,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<button <button
type="button" type="button"
onClick={() => setIconPickerOpen((o) => !o)} onClick={() => setIconPickerOpen((o) => !o)}
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors w-full" className={`flex items-center justify-between gap-2 h-8 px-3 bg-[#21262d] border border-[#30363d] text-sm transition-colors w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}
aria-label="Icon picker trigger"
> >
<span className="flex items-center gap-2 min-w-0"> <span className="flex items-center gap-2 min-w-0">
{(() => { {(() => {
@@ -160,7 +162,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={iconSearch} value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)} onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…" placeholder="Search icons…"
className="bg-[#21262d] border-[#30363d] text-xs h-7" className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
autoFocus autoFocus
/> />
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto"> <div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
@@ -182,7 +184,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
type="button" type="button"
title={entry.label} title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }} onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className="flex items-center justify-center w-7 h-7 rounded transition-colors" className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
aria-label={`Select icon ${entry.label}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : 'transparent', background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent', border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
@@ -210,7 +213,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.label ?? ''} value={form.label ?? ''}
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }} onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
placeholder="My Server" placeholder="My Server"
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`} className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'} ${modalStyles['modal-radius']}`}
/> />
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>} {labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
</div> </div>
@@ -222,26 +225,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.hostname ?? ''} value={form.hostname ?? ''}
onChange={(e) => set('hostname', e.target.value)} onChange={(e) => set('hostname', e.target.value)}
placeholder="server.lan" placeholder="server.lan"
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']}`}
/> />
</div> </div>
{/* IP */} {/* IP */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label> <Label className="text-xs text-muted-foreground">IP Address</Label>
<Input <Input
value={form.ip ?? ''} value={form.ip ?? ''}
onChange={(e) => set('ip', e.target.value)} onChange={(e) => set('ip', e.target.value)}
placeholder="192.168.1.x, 2001:db8::1" 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']}`}
/> />
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
</div> </div>
{/* Check method */} {/* Check method */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Method</Label> <Label className="text-xs text-muted-foreground">Check Method</Label>
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}> <Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue> <SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
@@ -259,7 +263,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.check_target ?? ''} value={form.check_target ?? ''}
onChange={(e) => set('check_target', e.target.value)} onChange={(e) => set('check_target', e.target.value)}
placeholder="http://..." placeholder="http://..."
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']}`}
/> />
</div> </div>
@@ -271,7 +275,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.parent_id ?? 'none'} value={form.parent_id ?? 'none'}
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)} onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
> >
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
<SelectValue placeholder="None (standalone)"> <SelectValue placeholder="None (standalone)">
{form.parent_id {form.parent_id
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)') ? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
@@ -300,7 +304,9 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
role="switch" role="switch"
aria-checked={!!form.container_mode} aria-checked={!!form.container_mode}
onClick={() => set('container_mode', !form.container_mode)} onClick={() => set('container_mode', !form.container_mode)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none" className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
tabIndex={0}
aria-label="Toggle container mode"
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }} style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
> >
<span <span
@@ -333,9 +339,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return ( return (
<div key={key} className="flex flex-col gap-1 items-center"> <div key={key} className="flex flex-col gap-1 items-center">
<label <label
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all" className={`relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all ${modalStyles['modal-interactive']}`}
style={{ borderColor: isCustom ? currentValue : '#30363d' }} style={{ borderColor: isCustom ? currentValue : '#30363d' }}
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`} title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
tabIndex={0}
aria-label={`Color picker for ${key}`}
> >
<input <input
type="color" type="color"
@@ -363,7 +371,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={String(form.bottom_handles ?? 1)} value={String(form.bottom_handles ?? 1)}
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))} onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
> >
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Bottom connection points selector">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
@@ -383,22 +391,37 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.notes ?? ''} value={form.notes ?? ''}
onChange={(e) => set('notes', e.target.value)} onChange={(e) => set('notes', e.target.value)}
placeholder="Optional notes" placeholder="Optional notes"
className="bg-[#21262d] border-[#30363d] text-sm h-8" className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
/> />
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-1"> <div className="flex justify-between gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" onClick={onClose}> {/* Show delete button only for edit mode (not add) */}
Cancel {title !== 'Add Node' ? (
</Button> <Button
<Button type="button"
type="submit" variant="ghost"
size="sm" size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
> onClick={() => { if (window.confirm('Delete this node?')) onSubmit({ ...form, _delete: true }); onClose(); }}
{title === 'Add Node' ? 'Add' : 'Save'} style={{ minWidth: 64 }}
</Button> >
Delete
</Button>
) : <span />}
<div className="flex gap-2">
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer"
>
{title === 'Add Node' ? 'Add' : 'Save'}
</Button>
</div>
</div> </div>
</form> </form>
</DialogContent> </DialogContent>
+144 -113
View File
@@ -1,11 +1,12 @@
import { useRef, useState, type KeyboardEvent } from 'react' import { useRef, useState, type KeyboardEvent } from 'react'
import { toast } from 'sonner' 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes' import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { CustomStyleModal } from './CustomStyleModal'
// Node-type accent colors to display as preview swatches // Node-type accent colors to display as preview swatches
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
@@ -16,76 +17,106 @@ interface ThemeCardProps {
onClick: () => void onClick: () => void
onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void
buttonRef?: (element: HTMLButtonElement | null) => 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 preset = THEMES[themeId]
const c = preset.colors 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 ( return (
<button <div className="relative w-full h-full">
ref={buttonRef} <button
type="button" ref={buttonRef}
onClick={onClick} type="button"
onKeyDown={onKeyDown} onClick={onClick}
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col" onKeyDown={onKeyDown}
style={{ className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground, style={{
background: c.canvasBackground, borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none', background: c.canvasBackground,
}} boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
> }}
{/* Selected checkmark */}
{selected && (
<span
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
style={{ background: c.nodeAccents.isp.border }}
>
<Check size={10} style={{ color: c.canvasBackground }} />
</span>
)}
{/* Mini canvas preview */}
<div
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
> >
{/* Node accent dots */} {/* Selected checkmark */}
<div className="flex gap-1 items-center flex-wrap"> {selected && (
{PREVIEW_TYPES.map((type) => ( <span
<span className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
key={type} style={{ background: c.nodeAccents.isp.border }}
className="w-2.5 h-2.5 rounded-full shrink-0" >
style={{ backgroundColor: c.nodeAccents[type].border }} <Check size={10} style={{ color: c.canvasBackground }} />
/> </span>
))} )}
</div>
{/* Edge line */} {/* Mini canvas preview */}
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
{/* Wifi dashed line */}
<div <div
style={{ className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
height: 1, style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
width: '55%', >
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`, <div className="flex gap-1 items-center flex-wrap">
}} {swatchColors.map((color, i) => (
/> <span
</div> key={i}
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: color }}
/>
))}
</div>
<div style={{ height: 2, background: ethernetColor, width: '80%', borderRadius: 2 }} />
<div
style={{
height: 1,
width: '55%',
backgroundImage: `repeating-linear-gradient(90deg, ${wifiColor} 0 5px, transparent 5px 8px)`,
}}
/>
</div>
{/* Label */} <div
<div className="text-sm font-semibold leading-tight wrap-break-word"
className="text-sm font-semibold leading-tight wrap-break-word" style={{ color: c.nodeLabelColor }}
style={{ color: c.nodeLabelColor }} >
> {preset.label}
{preset.label} </div>
</div> <div
<div className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12"
className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12" style={{ color: c.nodeSubtextColor }}
style={{ color: c.nodeSubtextColor }} >
> {preset.description}
{preset.description} </div>
</div> </button>
</button>
{/* Edit button — only for custom theme */}
{isCustom && onEdit && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onEdit() }}
title="Edit custom style"
className="absolute bottom-2 right-2 flex items-center justify-center w-6 h-6 rounded-md transition-colors"
style={{
background: c.nodeCardBackground,
color: c.nodeLabelColor,
border: `1px solid ${c.handleBackground}`,
}}
>
<Pencil size={11} />
</button>
)}
</div>
) )
} }
@@ -98,6 +129,7 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
const { activeTheme, setTheme } = useThemeStore() const { activeTheme, setTheme } = useThemeStore()
const { markUnsaved } = useCanvasStore() const { markUnsaved } = useCanvasStore()
const cardRefs = useRef<Array<HTMLButtonElement | null>>([]) const cardRefs = useRef<Array<HTMLButtonElement | null>>([])
const [customStyleOpen, setCustomStyleOpen] = useState(false)
// Capture the theme that was active when the modal opened // Capture the theme that was active when the modal opened
const [originalTheme] = useState<ThemeId>(activeTheme) const [originalTheme] = useState<ThemeId>(activeTheme)
@@ -105,7 +137,6 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
const handleSelect = (id: ThemeId) => { const handleSelect = (id: ThemeId) => {
setSelected(id) setSelected(id)
// Live-preview the selected theme on the canvas
setTheme(id) setTheme(id)
} }
@@ -137,65 +168,65 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
setTheme(selected) setTheme(selected)
markUnsaved() markUnsaved()
onClose() onClose()
toast.info('Style applied — save your canvas to make it permanent', { toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 })
duration: 5000,
})
} }
const handleCancel = () => { const handleCancel = () => {
// Revert to the original theme
setTheme(originalTheme) setTheme(originalTheme)
onClose() onClose()
} }
return ( return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}> <>
<DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]"> <Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
<DialogHeader> <DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]">
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle> <DialogHeader>
</DialogHeader> <DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
</DialogHeader>
<div className="flex items-stretch flex-nowrap gap-3 py-1 overflow-x-auto overflow-y-hidden pb-2 pr-1"> <div className="flex items-stretch flex-nowrap gap-3 py-1 overflow-x-auto overflow-y-hidden pb-2 pr-1">
{THEME_ORDER.map((id, index) => ( {THEME_ORDER.map((id, index) => (
<div key={id} className="shrink-0 w-30 md:w-24 h-full"> <div key={id} className="shrink-0 w-30 md:w-24 h-full">
<ThemeCard <ThemeCard
themeId={id} themeId={id}
selected={selected === id} selected={selected === id}
onClick={() => handleSelect(id)} onClick={() => handleSelect(id)}
onKeyDown={handleCardKeyDown(index)} onKeyDown={handleCardKeyDown(index)}
buttonRef={(element) => { buttonRef={(element) => { cardRefs.current[index] = element }}
cardRefs.current[index] = element onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined}
}} />
/> </div>
</div> ))}
))} </div>
</div>
<div className="flex justify-end gap-2 pt-1"> <div className="flex justify-end gap-2 pt-1">
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
onClick={handleCancel} onClick={handleCancel}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm" size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
style={ style={
selected !== 'default' selected !== 'default'
? { background: THEMES[selected].colors.nodeAccents.isp.border } ? { background: THEMES[selected].colors.nodeAccents.isp.border }
: undefined : undefined
} }
onClick={handleApply} onClick={handleApply}
> >
Apply Style Apply Style
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<CustomStyleModal open={customStyleOpen} onClose={() => setCustomStyleOpen(false)} />
</>
) )
} }
@@ -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;
}
@@ -200,7 +200,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto"> <aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span> <span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors"> <button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
@@ -243,7 +243,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span> <span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
<button <button
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }} onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors" className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
> >
<Plus size={10} /> Add <Plus size={10} /> Add
</button> </button>
@@ -289,7 +289,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span> <span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"> <button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
<Plus size={10} /> Add <Plus size={10} /> Add
</button> </button>
</div> </div>
@@ -316,10 +316,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)} )}
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border"> <div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}> <Button size="sm" variant="secondary" className="flex-1 gap-1.5 cursor-pointer" onClick={() => onEdit(node.id)}>
<Edit size={14} /> Edit <Edit size={14} /> Edit
</Button> </Button>
<Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}> <Button size="sm" variant="destructive" className="gap-1.5 cursor-pointer" aria-label="Delete node" onClick={handleDelete}>
<Trash2 size={14} /> <Trash2 size={14} />
</Button> </Button>
</div> </div>
+3 -2
View File
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client' import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease' import { useLatestRelease } from '@/hooks/useLatestRelease'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
@@ -593,7 +594,7 @@ function ScanHistoryPanel() {
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div> <div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
)} )}
{r.error && ( {r.error && (
<div className="text-[#f85149] text-[10px] mt-1 leading-tight break-words whitespace-pre-wrap"> <div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
{r.error} {r.error}
</div> </div>
)} )}
@@ -749,7 +750,7 @@ function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onCl
const btn = ( const btn = (
<button <button
onClick={onClick} onClick={onClick}
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors ${ className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
active active
? 'bg-[#00d4ff]/10 text-[#00d4ff]' ? 'bg-[#00d4ff]/10 text-[#00d4ff]'
: accent : accent
+10 -10
View File
@@ -39,7 +39,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<div className="flex-1" /> <div className="flex-1" />
<Button <Button
size="sm" variant="ghost" size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30" className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onUndo} onClick={onUndo}
disabled={past.length === 0} disabled={past.length === 0}
title="Undo (Ctrl+Z)" title="Undo (Ctrl+Z)"
@@ -48,7 +48,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
</Button> </Button>
<Button <Button
size="sm" variant="ghost" size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30" className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onRedo} onClick={onRedo}
disabled={future.length === 0} disabled={future.length === 0}
title="Redo (Ctrl+Y)" title="Redo (Ctrl+Y)"
@@ -56,13 +56,13 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Redo2 size={14} /> <Redo2 size={14} />
</Button> </Button>
<div className="w-px h-4 bg-border mx-1" /> <div className="w-px h-4 bg-border mx-1" />
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
<LayoutDashboard size={14} /> Auto Layout <LayoutDashboard size={14} /> Auto Layout
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
<Palette size={14} /> Style <Palette size={14} /> Style
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={() => fileInputRef.current?.click()} title="Import from YAML"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
<Upload size={14} /> Import <Upload size={14} /> Import
</Button> </Button>
<input <input
@@ -72,21 +72,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
className="hidden" className="hidden"
onChange={handleFileChange} onChange={handleFileChange}
/> />
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportYaml} title="Export canvas as YAML">
<Download size={14} /> Export <Download size={14} /> Export
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Download canvas as PNG"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExport} title="Download canvas as PNG">
<FileDown size={14} /> PNG <FileDown size={14} /> PNG
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
<Table2 size={14} /> MD <Table2 size={14} /> MD
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
<HelpCircle size={14} /> <HelpCircle size={14} />
</Button> </Button>
<Button <Button
size="sm" size="sm"
className="gap-1.5 relative" className="gap-1.5 relative cursor-pointer border border-transparent hover:border-white"
style={{ style={{
background: hasUnsavedChanges ? '#00d4ff' : undefined, background: hasUnsavedChanges ? '#00d4ff' : undefined,
color: hasUnsavedChanges ? '#0d1117' : undefined, color: hasUnsavedChanges ? '#0d1117' : undefined,
+3 -3
View File
@@ -1,3 +1,4 @@
import modalStyles from '../modals/modal-interactive.module.css'
import * as React from "react" import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
@@ -63,13 +64,12 @@ function DialogContent({
render={ render={
<Button <Button
variant="ghost" variant="ghost"
className="absolute top-2 right-2" className={"absolute top-2 right-2 " + modalStyles['modal-close-pointer']}
size="icon-sm" size="icon-sm"
/> />
} }
> >
<XIcon <XIcon />
/>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
@@ -722,3 +722,98 @@ describe('canvasStore', () => {
expect(updated?.sourceHandle).toBe('bottom') expect(updated?.sourceHandle).toBe('bottom')
}) })
}) })
describe('canvasStore — custom style apply', () => {
beforeEach(() => {
useCanvasStore.setState({
nodes: [],
edges: [],
hasUnsavedChanges: false,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
past: [],
future: [],
clipboard: [],
})
})
const serverStyle = {
borderColor: '#ff0000',
borderOpacity: 1,
bgColor: '#111111',
bgOpacity: 1,
iconColor: '#ff0000',
iconOpacity: 1,
width: 220,
height: 90,
}
it('applyTypeNodeStyle updates matching nodes custom_colors', () => {
useCanvasStore.setState({
nodes: [makeNode('n1', { type: 'server' }), makeNode('n2', { type: 'proxmox' })],
edges: [],
})
useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle)
const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
const n2 = useCanvasStore.getState().nodes.find((n) => n.id === 'n2')!
expect(n1.data.custom_colors?.border).toBe('#ff0000')
expect(n1.width).toBe(220)
expect(n1.height).toBe(90)
expect(n2.data.custom_colors?.border).toBeUndefined()
})
it('applyTypeNodeStyle with opacity < 1 produces rgba', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { type: 'server' })], edges: [] })
useCanvasStore.getState().applyTypeNodeStyle('server', { ...serverStyle, borderOpacity: 0.5 })
const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
expect(n1.data.custom_colors?.border).toMatch(/^rgba\(/)
})
it('applyTypeNodeStyle marks canvas unsaved', () => {
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [] })
useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('applyTypeEdgeStyle updates matching edges', () => {
const e1: Edge<EdgeData> = { id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } }
const e2: Edge<EdgeData> = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } }
useCanvasStore.setState({ nodes: [], edges: [e1, e2] })
useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow' })
const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')!
expect(updated1.data?.custom_color).toBe('#00ff00')
expect(updated1.data?.path_style).toBe('smooth')
expect(updated1.data?.animated).toBe('flow')
expect(updated2.data?.custom_color).toBeUndefined()
})
it('applyAllCustomStyles applies all defined types', () => {
const proxmoxNode = makeNode('np', { type: 'proxmox' })
const serverNode = makeNode('ns', { type: 'server' })
const e1: Edge<EdgeData> = { id: 'e1', source: 'np', target: 'ns', type: 'ethernet', data: { type: 'ethernet' } }
useCanvasStore.setState({ nodes: [proxmoxNode, serverNode], edges: [e1] })
useCanvasStore.getState().applyAllCustomStyles({
nodes: {
proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 },
},
edges: {
ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none' },
},
})
const np = useCanvasStore.getState().nodes.find((n) => n.id === 'np')!
const ns = useCanvasStore.getState().nodes.find((n) => n.id === 'ns')!
const e = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
expect(np.data.custom_colors?.border).toBe('#ff6e00')
expect(ns.data.custom_colors?.border).toBeUndefined()
expect(e.data?.custom_color).toBe('#aabbcc')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
})
@@ -1,9 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import type { CustomStyleDef } from '@/types'
describe('themeStore', () => { describe('themeStore', () => {
beforeEach(() => { beforeEach(() => {
useThemeStore.setState({ activeTheme: 'default' }) useThemeStore.setState({ activeTheme: 'default', customStyle: { nodes: {}, edges: {} } })
}) })
it('starts with default theme', () => { it('starts with default theme', () => {
@@ -15,8 +16,8 @@ describe('themeStore', () => {
expect(useThemeStore.getState().activeTheme).toBe('matrix') expect(useThemeStore.getState().activeTheme).toBe('matrix')
}) })
it('setTheme can switch between all presets', () => { it('setTheme can switch between all presets including custom', () => {
const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const const themes = ['default', 'dark', 'light', 'neon', 'matrix', 'custom'] as const
for (const id of themes) { for (const id of themes) {
useThemeStore.getState().setTheme(id) useThemeStore.getState().setTheme(id)
expect(useThemeStore.getState().activeTheme).toBe(id) expect(useThemeStore.getState().activeTheme).toBe(id)
@@ -28,4 +29,26 @@ describe('themeStore', () => {
useThemeStore.getState().setTheme('default') useThemeStore.getState().setTheme('default')
expect(useThemeStore.getState().activeTheme).toBe('default') expect(useThemeStore.getState().activeTheme).toBe('default')
}) })
it('starts with empty customStyle', () => {
const { customStyle } = useThemeStore.getState()
expect(customStyle.nodes).toEqual({})
expect(customStyle.edges).toEqual({})
})
it('setCustomStyle replaces the entire definition', () => {
const def: CustomStyleDef = {
nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } },
edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none' } },
}
useThemeStore.getState().setCustomStyle(def)
expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000')
expect(useThemeStore.getState().customStyle.edges.ethernet?.color).toBe('#00ff00')
})
it('setCustomStyle with empty def clears styles', () => {
useThemeStore.getState().setCustomStyle({ nodes: { server: { borderColor: '#aaa', borderOpacity: 1, bgColor: '#000', bgOpacity: 1, iconColor: '#aaa', iconOpacity: 1, width: 0, height: 0 } }, edges: {} })
useThemeStore.getState().setCustomStyle({ nodes: {}, edges: {} })
expect(useThemeStore.getState().customStyle.nodes).toEqual({})
})
}) })
+83 -1
View File
@@ -9,9 +9,10 @@ import {
applyEdgeChanges, applyEdgeChanges,
addEdge, addEdge,
} from '@xyflow/react' } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] } type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
@@ -58,6 +59,9 @@ interface CanvasState {
notifyScanDeviceFound: () => void notifyScanDeviceFound: () => void
hideIp: boolean hideIp: boolean
toggleHideIp: () => void toggleHideIp: () => void
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
applyAllCustomStyles: (def: CustomStyleDef) => void
} }
export const useCanvasStore = create<CanvasState>((set) => ({ export const useCanvasStore = create<CanvasState>((set) => ({
@@ -468,4 +472,82 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}, },
clearFitViewPending: () => set({ fitViewPending: false }), clearFitViewPending: () => set({ fitViewPending: false }),
applyTypeNodeStyle: (nodeType, style) =>
set((state) => ({
nodes: state.nodes.map((n) => {
if (n.data.type !== nodeType) return n
return {
...n,
width: style.width > 0 ? style.width : n.width,
height: style.height > 0 ? style.height : n.height,
data: {
...n.data,
custom_colors: {
...n.data.custom_colors,
border: applyOpacity(style.borderColor, style.borderOpacity),
background: applyOpacity(style.bgColor, style.bgOpacity),
icon: applyOpacity(style.iconColor, style.iconOpacity),
},
},
}
}),
hasUnsavedChanges: true,
})),
applyTypeEdgeStyle: (edgeType, style) =>
set((state) => ({
edges: state.edges.map((e) => {
if ((e.data?.type ?? 'ethernet') !== edgeType) return e
return {
...e,
data: {
...e.data,
type: edgeType,
custom_color: applyOpacity(style.color, style.opacity),
path_style: style.pathStyle,
animated: style.animated,
} as EdgeData,
}
}),
hasUnsavedChanges: true,
})),
applyAllCustomStyles: (def) =>
set((state) => {
const nodes = state.nodes.map((n) => {
const style = def.nodes[n.data.type]
if (!style) return n
return {
...n,
width: style.width > 0 ? style.width : n.width,
height: style.height > 0 ? style.height : n.height,
data: {
...n.data,
custom_colors: {
...n.data.custom_colors,
border: applyOpacity(style.borderColor, style.borderOpacity),
background: applyOpacity(style.bgColor, style.bgOpacity),
icon: applyOpacity(style.iconColor, style.iconOpacity),
},
},
}
})
const edges = state.edges.map((e) => {
const edgeType = (e.data?.type ?? 'ethernet') as EdgeType
const style = def.edges[edgeType]
if (!style) return e
return {
...e,
data: {
...e.data,
type: edgeType,
custom_color: applyOpacity(style.color, style.opacity),
path_style: style.pathStyle,
animated: style.animated,
} as EdgeData,
}
})
return { nodes, edges, hasUnsavedChanges: true }
}),
})) }))
+5
View File
@@ -1,12 +1,17 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { ThemeId } from '@/utils/themes' import type { ThemeId } from '@/utils/themes'
import type { CustomStyleDef } from '@/types'
interface ThemeState { interface ThemeState {
activeTheme: ThemeId activeTheme: ThemeId
setTheme: (id: ThemeId) => void setTheme: (id: ThemeId) => void
customStyle: CustomStyleDef
setCustomStyle: (def: CustomStyleDef) => void
} }
export const useThemeStore = create<ThemeState>((set) => ({ export const useThemeStore = create<ThemeState>((set) => ({
activeTheme: 'default', activeTheme: 'default',
setTheme: (id) => set({ activeTheme: id }), setTheme: (id) => set({ activeTheme: id }),
customStyle: { nodes: {}, edges: {} },
setCustomStyle: (def) => set({ customStyle: def }),
})) }))
+23
View File
@@ -150,3 +150,26 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
virtual: 'Virtual', virtual: 'Virtual',
cluster: 'Cluster', cluster: 'Cluster',
} }
export interface NodeTypeStyle {
borderColor: string
borderOpacity: number
bgColor: string
bgOpacity: number
iconColor: string
iconOpacity: number
width: number
height: number
}
export interface EdgeTypeStyle {
color: string
opacity: number
pathStyle: EdgePathStyle
animated: 'none' | 'snake' | 'flow' | 'basic'
}
export interface CustomStyleDef {
nodes: Partial<Record<NodeType, NodeTypeStyle>>
edges: Partial<Record<EdgeType, EdgeTypeStyle>>
}
+14
View File
@@ -27,3 +27,17 @@ export function rgbaToHex8(hex6: string, alpha: number): string {
const alphaHex = alphaByte.toString(16).padStart(2, '0') const alphaHex = alphaByte.toString(16).padStart(2, '0')
return `${hex6}${alphaHex}` return `${hex6}${alphaHex}`
} }
/**
* Combine a hex color and opacity (01) into a CSS rgba() string.
* Returns the plain hex when opacity is 1.
*/
export function applyOpacity(hex: string, opacity: number): string {
if (opacity >= 1) return hex
let h = hex.replace('#', '')
if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2]
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${Math.round(opacity * 100) / 100})`
}
+58 -2
View File
@@ -1,6 +1,6 @@
import type { NodeType, EdgeType, NodeStatus } from '@/types' import type { NodeType, EdgeType, NodeStatus } from '@/types'
export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' | 'custom'
export interface ThemeColors { export interface ThemeColors {
// Per node-type accent (border + icon) // Per node-type accent (border + icon)
@@ -315,7 +315,63 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
reactFlowColorMode: 'dark', reactFlowColorMode: 'dark',
}, },
}, },
custom: {
id: 'custom',
label: 'Custom',
description: 'Your own colors per node and edge type',
colors: {
nodeAccents: {
isp: { border: '#00d4ff', icon: '#00d4ff' },
router: { border: '#00d4ff', icon: '#00d4ff' },
switch: { border: '#39d353', icon: '#39d353' },
server: { border: '#a855f7', icon: '#a855f7' },
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
vm: { border: '#a855f7', icon: '#a855f7' },
lxc: { border: '#00d4ff', icon: '#00d4ff' },
nas: { border: '#39d353', icon: '#39d353' },
iot: { border: '#e3b341', icon: '#e3b341' },
ap: { border: '#00d4ff', icon: '#00d4ff' },
camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
},
nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22',
nodeLabelColor: '#e6edf3',
nodeSubtextColor: '#8b949e',
statusColors: {
online: '#39d353',
offline: '#f85149',
pending: '#e3b341',
unknown: '#8b949e',
},
edgeColors: {
ethernet: '#30363d',
wifi: '#00d4ff',
iot: '#e3b341',
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
},
edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22',
edgeLabelColor: '#8b949e',
edgeLabelBorder: '#30363d',
canvasBackground: '#0d1117',
canvasDotColor: '#30363d',
handleBackground: '#30363d',
handleBorder: '#8b949e',
reactFlowColorMode: 'dark',
},
},
} }
// Ordered list for display in the modal // Ordered list for display in the modal
export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix'] export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix', 'custom']