feat: customizable link color per edge
- Add custom_color field to EdgeData type, Edge DB model and all schemas - HomelableEdge applies custom_color as stroke override (before selected highlight) - EDGE_DEFAULT_COLORS extracted to utils/edgeColors.ts (react-refresh compliant) - EdgeModal: color picker row showing effective color (custom or type default) with hex value, Reset button when custom color is active - Auto-migration adds custom_color column to edges table
This commit is contained in:
@@ -25,6 +25,8 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT")
|
||||||
|
|
||||||
|
|
||||||
async def get_db():
|
async def get_db():
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class Edge(Base):
|
|||||||
label: Mapped[str | None] = mapped_column(String)
|
label: Mapped[str | None] = mapped_column(String)
|
||||||
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
speed: Mapped[str | None] = mapped_column(String)
|
speed: Mapped[str | None] = mapped_column(String)
|
||||||
|
custom_color: Mapped[str | None] = mapped_column(String)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class EdgeSave(BaseModel):
|
|||||||
label: str | None = None
|
label: str | None = None
|
||||||
vlan_id: int | None = None
|
vlan_id: int | None = None
|
||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
|
custom_color: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CanvasSaveRequest(BaseModel):
|
class CanvasSaveRequest(BaseModel):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class EdgeBase(BaseModel):
|
|||||||
label: str | None = None
|
label: str | None = None
|
||||||
vlan_id: int | None = None
|
vlan_id: int | None = None
|
||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
|
custom_color: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class EdgeCreate(EdgeBase):
|
class EdgeCreate(EdgeBase):
|
||||||
@@ -21,6 +22,7 @@ class EdgeUpdate(BaseModel):
|
|||||||
label: str | None = None
|
label: str | None = None
|
||||||
vlan_id: int | None = None
|
vlan_id: int | None = None
|
||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
|
custom_color: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class EdgeResponse(EdgeBase):
|
class EdgeResponse(EdgeBase):
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default function App() {
|
|||||||
label: e.data?.label ?? null,
|
label: e.data?.label ?? null,
|
||||||
vlan_id: e.data?.vlan_id ?? null,
|
vlan_id: e.data?.vlan_id ?? null,
|
||||||
speed: e.data?.speed ?? null,
|
speed: e.data?.speed ?? null,
|
||||||
|
custom_color: e.data?.custom_color ?? null,
|
||||||
}))
|
}))
|
||||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: {} })
|
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: {} })
|
||||||
markSaved()
|
markSaved()
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePo
|
|||||||
const [edgePath, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition })
|
const [edgePath, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition })
|
||||||
|
|
||||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||||
|
const customColor = data?.custom_color as string | undefined
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
...EDGE_STYLES[edgeType],
|
...EDGE_STYLES[edgeType],
|
||||||
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
|
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
|
||||||
|
...(customColor ? { stroke: customColor } : {}),
|
||||||
...(selected ? { stroke: '#00d4ff', filter: 'drop-shadow(0 0 4px #00d4ff88)' } : {}),
|
...(selected ? { stroke: '#00d4ff', filter: 'drop-shadow(0 0 4px #00d4ff88)' } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from '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'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { EDGE_TYPE_LABELS, type EdgeData, type EdgeType } from '@/types'
|
import { EDGE_TYPE_LABELS, type EdgeData, type EdgeType } from '@/types'
|
||||||
|
import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
||||||
|
|
||||||
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
||||||
|
|
||||||
@@ -21,6 +23,9 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
|
const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
|
||||||
const [label, setLabel] = useState(initial?.label ?? '')
|
const [label, setLabel] = useState(initial?.label ?? '')
|
||||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||||
|
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||||
|
|
||||||
|
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -28,6 +33,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
type,
|
type,
|
||||||
label: label || undefined,
|
label: label || undefined,
|
||||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||||
|
custom_color: customColor,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -84,6 +90,37 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Color</Label>
|
||||||
|
{customColor && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCustomColor(undefined)}
|
||||||
|
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw size={10} /> Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
className="relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer"
|
||||||
|
style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={effectiveColor}
|
||||||
|
onChange={(e) => setCustomColor(e.target.value)}
|
||||||
|
className="absolute opacity-0 w-0 h-0"
|
||||||
|
/>
|
||||||
|
<div className="w-4 h-4 rounded-sm shrink-0 border border-white/10" style={{ background: effectiveColor }} />
|
||||||
|
<span className="font-mono text-xs" style={{ color: customColor ? effectiveColor : '#8b949e' }}>
|
||||||
|
{effectiveColor}
|
||||||
|
</span>
|
||||||
|
{!customColor && <span className="text-[10px] text-muted-foreground/50 ml-auto">default</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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" onClick={handleDelete}>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
label?: string
|
label?: string
|
||||||
vlan_id?: number
|
vlan_id?: number
|
||||||
speed?: string
|
speed?: string
|
||||||
|
custom_color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { EdgeType } from '@/types'
|
||||||
|
|
||||||
|
export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
|
||||||
|
ethernet: '#30363d',
|
||||||
|
wifi: '#00d4ff',
|
||||||
|
iot: '#e3b341',
|
||||||
|
vlan: '#00d4ff',
|
||||||
|
virtual: '#8b949e',
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user