merge: resolve EdgeModal conflict — keep CSS module classes + SelectValue label fix
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
<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 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Edge type selector">
|
<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]) => (
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } 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
|
||||||
@@ -14,74 +15,108 @@ interface ThemeCardProps {
|
|||||||
themeId: ThemeId
|
themeId: ThemeId
|
||||||
selected: boolean
|
selected: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void
|
||||||
|
buttonRef?: (element: HTMLButtonElement | null) => void
|
||||||
|
onEdit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeCard({ themeId, selected, onClick }: 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">
|
||||||
type="button"
|
<button
|
||||||
onClick={onClick}
|
ref={buttonRef}
|
||||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
|
type="button"
|
||||||
style={{
|
onClick={onClick}
|
||||||
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
onKeyDown={onKeyDown}
|
||||||
background: c.canvasBackground,
|
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
|
||||||
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
style={{
|
||||||
}}
|
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||||
>
|
background: c.canvasBackground,
|
||||||
{/* Selected checkmark */}
|
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
||||||
{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-xs font-semibold leading-tight"
|
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-[10px] leading-snug mt-0.5 line-clamp-2"
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +128,8 @@ interface ThemeModalProps {
|
|||||||
export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
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 [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)
|
||||||
@@ -100,68 +137,96 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCardKeyDown = (index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
handleApply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const direction = event.key === 'ArrowRight' ? 1 : -1
|
||||||
|
const nextIndex = (index + direction + THEME_ORDER.length) % THEME_ORDER.length
|
||||||
|
const nextTheme = THEME_ORDER[nextIndex]
|
||||||
|
|
||||||
|
setSelected(nextTheme)
|
||||||
|
setTheme(nextTheme)
|
||||||
|
|
||||||
|
const nextCard = cardRefs.current[nextIndex]
|
||||||
|
if (!nextCard) return
|
||||||
|
|
||||||
|
nextCard.focus({ preventScroll: true })
|
||||||
|
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||||
|
}
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
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-[90vw] max-w-4xl">
|
<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="grid grid-cols-5 gap-3 py-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) => (
|
{THEME_ORDER.map((id, index) => (
|
||||||
<ThemeCard
|
<div key={id} className="shrink-0 w-30 md:w-24 h-full">
|
||||||
key={id}
|
<ThemeCard
|
||||||
themeId={id}
|
themeId={id}
|
||||||
selected={selected === id}
|
selected={selected === id}
|
||||||
onClick={() => handleSelect(id)}
|
onClick={() => handleSelect(id)}
|
||||||
/>
|
onKeyDown={handleCardKeyDown(index)}
|
||||||
))}
|
buttonRef={(element) => { cardRefs.current[index] = element }}
|
||||||
</div>
|
onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined}
|
||||||
|
/>
|
||||||
|
</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)} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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>>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (0–1) 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})`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
+13
-7
@@ -1,7 +1,8 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||||
|
from starlette.routing import Mount
|
||||||
|
|
||||||
from .auth import ApiKeyMiddleware
|
from .auth import ApiKeyMiddleware
|
||||||
from .backend_client import backend
|
from .backend_client import backend
|
||||||
@@ -28,15 +29,20 @@ async def lifespan(app: FastAPI):
|
|||||||
await backend.stop()
|
await backend.stop()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Homelable MCP", lifespan=lifespan)
|
# Mount the session manager as an ASGI sub-app instead of wrapping it in a
|
||||||
|
# FastAPI @app.api_route handler. Wrapping it in a route handler causes
|
||||||
|
# FastAPI to send http.response.start after the session manager has already
|
||||||
|
# started the response, raising `RuntimeError: Unexpected ASGI message
|
||||||
|
# 'http.response.start' sent, after response already completed` on every
|
||||||
|
# POST /mcp — which makes the server unreachable from any MCP client.
|
||||||
|
app = FastAPI(
|
||||||
|
title="Homelable MCP",
|
||||||
|
lifespan=lifespan,
|
||||||
|
routes=[Mount("/mcp", app=session_manager.handle_request)],
|
||||||
|
)
|
||||||
app.add_middleware(ApiKeyMiddleware)
|
app.add_middleware(ApiKeyMiddleware)
|
||||||
|
|
||||||
|
|
||||||
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
|
|
||||||
async def mcp_endpoint(request: Request):
|
|
||||||
await session_manager.handle_request(request.scope, request.receive, request._send)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
Reference in New Issue
Block a user