From 431fb47498d5264ae82f364cc391738a39d6841f Mon Sep 17 00:00:00 2001 From: findthelorax Date: Fri, 17 Apr 2026 20:28:51 -0400 Subject: [PATCH 01/15] bug: fixed an issue with the label and a bezier path with multiple points --- .../edges/__tests__/waypointUtils.test.ts | 21 ++- .../src/components/canvas/edges/index.tsx | 9 +- .../components/canvas/edges/waypointUtils.ts | 166 ++++++++++++++++++ 3 files changed, 191 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts index a42a943..a9dadf5 100644 --- a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts +++ b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils' +import { buildWaypointPath, distToSegment, findInsertIndex, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils' describe('buildWaypointPath — bezier (default)', () => { it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => { @@ -173,3 +173,22 @@ describe('findInsertIndex', () => { expect(idx).toBe(2) }) }) + +describe('getWaypointLabelPosition', () => { + it('uses the routed midpoint for a symmetric bezier waypoint path', () => { + const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 100 }], 100, 0) + expect(point.x).toBeCloseTo(50, 0) + expect(point.y).toBeCloseTo(100, 0) + }) + + it('uses the routed midpoint for a smooth waypoint path', () => { + const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth') + expect(point.x).toBeCloseTo(50, 0) + expect(point.y).toBeCloseTo(50, 0) + }) + + it('falls back to the source point when the path is degenerate', () => { + const point = getWaypointLabelPosition(10, 20, [], 10, 20, 'smooth') + expect(point).toEqual({ x: 10, y: 20 }) + }) +}) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index fcb05ba..9ed771f 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' -import { buildWaypointPath, snap45, snap45both } from './waypointUtils' +import { buildWaypointPath, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] @@ -205,8 +205,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t ? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) : autoPath - const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX - const midY = (sourceY + targetY) / 2 + const labelPosition = hasWaypoints + ? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) + : { x: labelX, y: (sourceY + targetY) / 2 } const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeColors = theme.colors.edgeColors @@ -300,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
Waypoint, steps = 24): number { + let length = 0 + let prev = pointAt(0) + + for (let step = 1; step <= steps; step++) { + const next = pointAt(step / steps) + length += Math.hypot(next.x - prev.x, next.y - prev.y) + prev = next + } + + return length +} + +type PathSegment = { + length: number + pointAt: (t: number) => Waypoint +} + +function buildBezierSegments(pts: Waypoint[]): PathSegment[] { + if (pts.length < 2) return [] + + return pts.slice(0, -1).map((_, i) => { + const p0 = pts[Math.max(i - 1, 0)] + const p1 = pts[i] + const p2 = pts[i + 1] + const p3 = pts[Math.min(i + 2, pts.length - 1)] + const cp1 = { + x: p1.x + (p2.x - p0.x) / 6, + y: p1.y + (p2.y - p0.y) / 6, + } + const cp2 = { + x: p2.x - (p3.x - p1.x) / 6, + y: p2.y - (p3.y - p1.y) / 6, + } + const pointAt = (t: number) => interpolateCubic(p1, cp1, cp2, p2, t) + + return { + length: approximateLength(pointAt), + pointAt, + } + }) +} + +function buildSmoothSegments(pts: Waypoint[], radius = 8): PathSegment[] { + if (pts.length < 2) return [] + if (pts.length === 2) { + const pointAt = (t: number) => interpolateLine(pts[0], pts[1], t) + return [{ length: Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y), pointAt }] + } + + const segments: PathSegment[] = [] + let cursor = pts[0] + + for (let i = 1; i < pts.length - 1; i++) { + const prev = pts[i - 1] + const curr = pts[i] + const next = pts[i + 1] + + const dx1 = curr.x - prev.x + const dy1 = curr.y - prev.y + const len1 = Math.hypot(dx1, dy1) + + const dx2 = next.x - curr.x + const dy2 = next.y - curr.y + const len2 = Math.hypot(dx2, dy2) + + if (len1 < 1 || len2 < 1) { + const start = { x: cursor.x, y: cursor.y } + const end = { x: curr.x, y: curr.y } + const lineToCurr = (t: number) => interpolateLine(start, end, t) + segments.push({ + length: Math.hypot(curr.x - cursor.x, curr.y - cursor.y), + pointAt: lineToCurr, + }) + cursor = curr + continue + } + + const r = Math.min(radius, len1 / 2, len2 / 2) + const before = { + x: curr.x - (dx1 / len1) * r, + y: curr.y - (dy1 / len1) * r, + } + const after = { + x: curr.x + (dx2 / len2) * r, + y: curr.y + (dy2 / len2) * r, + } + + const lineStart = { x: cursor.x, y: cursor.y } + const lineEnd = { x: before.x, y: before.y } + const lineToBefore = (t: number) => interpolateLine(lineStart, lineEnd, t) + segments.push({ + length: Math.hypot(before.x - cursor.x, before.y - cursor.y), + pointAt: lineToBefore, + }) + + const curveAroundCorner = (t: number) => interpolateQuadratic(before, curr, after, t) + segments.push({ + length: approximateLength(curveAroundCorner), + pointAt: curveAroundCorner, + }) + + cursor = after + } + + const targetStart = { x: cursor.x, y: cursor.y } + const targetEnd = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y } + const lineToTarget = (t: number) => interpolateLine(targetStart, targetEnd, t) + segments.push({ + length: Math.hypot(pts[pts.length - 1].x - cursor.x, pts[pts.length - 1].y - cursor.y), + pointAt: lineToTarget, + }) + + return segments +} + +export function getWaypointLabelPosition( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + pathStyle: string = 'bezier', +): Waypoint { + const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + const segments = pathStyle === 'smooth' ? buildSmoothSegments(pts) : buildBezierSegments(pts) + + if (segments.length === 0) return pts[0] + + const totalLength = segments.reduce((sum, segment) => sum + segment.length, 0) + if (totalLength <= 0) return pts[Math.floor(pts.length / 2)] + + let remaining = totalLength / 2 + for (const segment of segments) { + if (remaining <= segment.length) { + const t = segment.length === 0 ? 0 : remaining / segment.length + return segment.pointAt(t) + } + remaining -= segment.length + } + + const lastSegment = segments[segments.length - 1] + return lastSegment.pointAt(1) +} + // ── 45° snapping ────────────────────────────────────────────────────────────── /** From cad3add223f53155d8ae4a29bc60f6dc13d67142 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Fri, 17 Apr 2026 20:28:51 -0400 Subject: [PATCH 02/15] bug: fixed an issue with the label and a bezier path with multiple points --- .../edges/__tests__/waypointUtils.test.ts | 21 ++- .../src/components/canvas/edges/index.tsx | 9 +- .../components/canvas/edges/waypointUtils.ts | 166 ++++++++++++++++++ 3 files changed, 191 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts index a42a943..a9dadf5 100644 --- a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts +++ b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils' +import { buildWaypointPath, distToSegment, findInsertIndex, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils' describe('buildWaypointPath — bezier (default)', () => { it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => { @@ -173,3 +173,22 @@ describe('findInsertIndex', () => { expect(idx).toBe(2) }) }) + +describe('getWaypointLabelPosition', () => { + it('uses the routed midpoint for a symmetric bezier waypoint path', () => { + const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 100 }], 100, 0) + expect(point.x).toBeCloseTo(50, 0) + expect(point.y).toBeCloseTo(100, 0) + }) + + it('uses the routed midpoint for a smooth waypoint path', () => { + const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth') + expect(point.x).toBeCloseTo(50, 0) + expect(point.y).toBeCloseTo(50, 0) + }) + + it('falls back to the source point when the path is degenerate', () => { + const point = getWaypointLabelPosition(10, 20, [], 10, 20, 'smooth') + expect(point).toEqual({ x: 10, y: 20 }) + }) +}) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index fcb05ba..9ed771f 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' -import { buildWaypointPath, snap45, snap45both } from './waypointUtils' +import { buildWaypointPath, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] @@ -205,8 +205,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t ? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) : autoPath - const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX - const midY = (sourceY + targetY) / 2 + const labelPosition = hasWaypoints + ? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) + : { x: labelX, y: (sourceY + targetY) / 2 } const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeColors = theme.colors.edgeColors @@ -300,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
Waypoint, steps = 24): number { + let length = 0 + let prev = pointAt(0) + + for (let step = 1; step <= steps; step++) { + const next = pointAt(step / steps) + length += Math.hypot(next.x - prev.x, next.y - prev.y) + prev = next + } + + return length +} + +type PathSegment = { + length: number + pointAt: (t: number) => Waypoint +} + +function buildBezierSegments(pts: Waypoint[]): PathSegment[] { + if (pts.length < 2) return [] + + return pts.slice(0, -1).map((_, i) => { + const p0 = pts[Math.max(i - 1, 0)] + const p1 = pts[i] + const p2 = pts[i + 1] + const p3 = pts[Math.min(i + 2, pts.length - 1)] + const cp1 = { + x: p1.x + (p2.x - p0.x) / 6, + y: p1.y + (p2.y - p0.y) / 6, + } + const cp2 = { + x: p2.x - (p3.x - p1.x) / 6, + y: p2.y - (p3.y - p1.y) / 6, + } + const pointAt = (t: number) => interpolateCubic(p1, cp1, cp2, p2, t) + + return { + length: approximateLength(pointAt), + pointAt, + } + }) +} + +function buildSmoothSegments(pts: Waypoint[], radius = 8): PathSegment[] { + if (pts.length < 2) return [] + if (pts.length === 2) { + const pointAt = (t: number) => interpolateLine(pts[0], pts[1], t) + return [{ length: Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y), pointAt }] + } + + const segments: PathSegment[] = [] + let cursor = pts[0] + + for (let i = 1; i < pts.length - 1; i++) { + const prev = pts[i - 1] + const curr = pts[i] + const next = pts[i + 1] + + const dx1 = curr.x - prev.x + const dy1 = curr.y - prev.y + const len1 = Math.hypot(dx1, dy1) + + const dx2 = next.x - curr.x + const dy2 = next.y - curr.y + const len2 = Math.hypot(dx2, dy2) + + if (len1 < 1 || len2 < 1) { + const start = { x: cursor.x, y: cursor.y } + const end = { x: curr.x, y: curr.y } + const lineToCurr = (t: number) => interpolateLine(start, end, t) + segments.push({ + length: Math.hypot(curr.x - cursor.x, curr.y - cursor.y), + pointAt: lineToCurr, + }) + cursor = curr + continue + } + + const r = Math.min(radius, len1 / 2, len2 / 2) + const before = { + x: curr.x - (dx1 / len1) * r, + y: curr.y - (dy1 / len1) * r, + } + const after = { + x: curr.x + (dx2 / len2) * r, + y: curr.y + (dy2 / len2) * r, + } + + const lineStart = { x: cursor.x, y: cursor.y } + const lineEnd = { x: before.x, y: before.y } + const lineToBefore = (t: number) => interpolateLine(lineStart, lineEnd, t) + segments.push({ + length: Math.hypot(before.x - cursor.x, before.y - cursor.y), + pointAt: lineToBefore, + }) + + const curveAroundCorner = (t: number) => interpolateQuadratic(before, curr, after, t) + segments.push({ + length: approximateLength(curveAroundCorner), + pointAt: curveAroundCorner, + }) + + cursor = after + } + + const targetStart = { x: cursor.x, y: cursor.y } + const targetEnd = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y } + const lineToTarget = (t: number) => interpolateLine(targetStart, targetEnd, t) + segments.push({ + length: Math.hypot(pts[pts.length - 1].x - cursor.x, pts[pts.length - 1].y - cursor.y), + pointAt: lineToTarget, + }) + + return segments +} + +export function getWaypointLabelPosition( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + pathStyle: string = 'bezier', +): Waypoint { + const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + const segments = pathStyle === 'smooth' ? buildSmoothSegments(pts) : buildBezierSegments(pts) + + if (segments.length === 0) return pts[0] + + const totalLength = segments.reduce((sum, segment) => sum + segment.length, 0) + if (totalLength <= 0) return pts[Math.floor(pts.length / 2)] + + let remaining = totalLength / 2 + for (const segment of segments) { + if (remaining <= segment.length) { + const t = segment.length === 0 ? 0 : remaining / segment.length + return segment.pointAt(t) + } + remaining -= segment.length + } + + const lastSegment = segments[segments.length - 1] + return lastSegment.pointAt(1) +} + // ── 45° snapping ────────────────────────────────────────────────────────────── /** From 9c035e2be2e317a62326ac0870a26f1d4977462b Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 23:23:50 -0400 Subject: [PATCH 03/15] adding in path geometry for the add point + --- .../edges/__tests__/waypointUtils.test.ts | 15 ++++++- .../src/components/canvas/edges/index.tsx | 8 ++-- .../components/canvas/edges/waypointUtils.ts | 44 +++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts index a9dadf5..ba9c253 100644 --- a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts +++ b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildWaypointPath, distToSegment, findInsertIndex, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils' +import { buildWaypointPath, distToSegment, findInsertIndex, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils' describe('buildWaypointPath — bezier (default)', () => { it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => { @@ -192,3 +192,16 @@ describe('getWaypointLabelPosition', () => { expect(point).toEqual({ x: 10, y: 20 }) }) }) + +describe('getAddWaypointHandlePosition', () => { + it('places bezier add handle on the rendered curved segment', () => { + const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 100 }], 100, 0, 0, 'bezier') + expect(point.x).toBeCloseTo(21.875, 3) + expect(point.y).toBeCloseTo(56.25, 3) + }) + + it('keeps smooth add handle at straight segment midpoint', () => { + const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 1, 'smooth') + expect(point).toEqual({ x: 50, y: 50 }) + }) +}) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 9ed771f..3a6c24d 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' -import { buildWaypointPath, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils' +import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] @@ -161,9 +161,9 @@ function segmentMidpoints( const isSmooth = pathStyle === 'smooth' return pts.slice(0, -1).map((a, i) => { - const b = pts[i + 1] - let mx = (a.x + b.x) / 2 - const my = (a.y + b.y) / 2 + const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle) + let mx = base.x + const my = base.y // For smooth style with no existing waypoints, bias the single + handle onto // the source handle axis so clicking it creates a perpendicular exit. diff --git a/frontend/src/components/canvas/edges/waypointUtils.ts b/frontend/src/components/canvas/edges/waypointUtils.ts index fa78c77..5b2107f 100644 --- a/frontend/src/components/canvas/edges/waypointUtils.ts +++ b/frontend/src/components/canvas/edges/waypointUtils.ts @@ -238,6 +238,50 @@ export function getWaypointLabelPosition( 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 ────────────────────────────────────────────────────────────── /** From 8626fb2ca49ac6f3f26f6e87083b8330a108f409 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 23:28:11 -0400 Subject: [PATCH 04/15] revert: restore workflow to upstream version --- .github/workflows/docker-publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fe0a4e8..600ae67 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,13 +15,13 @@ jobs: strategy: matrix: include: - - image: ghcr.io/${{ github.repository_owner }}/homelable-backend + - image: ghcr.io/pouzor/homelable-backend dockerfile: Dockerfile.backend build_args: "" - - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend + - image: ghcr.io/pouzor/homelable-frontend dockerfile: Dockerfile.frontend build_args: "" - - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend-standalone + - image: ghcr.io/pouzor/homelable-frontend-standalone dockerfile: Dockerfile.frontend build_args: "VITE_STANDALONE=true" From 31b5bc451523b68f3c6cfbadae34d7309c8af2a3 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Thu, 23 Apr 2026 15:59:46 -0400 Subject: [PATCH 05/15] fixed accessibility and keyboard navigation for edit node and connection modals --- frontend/src/components/modals/EdgeModal.tsx | 12 +++++++++--- frontend/src/components/modals/NodeModal.tsx | 20 ++++++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 664b12d..ea6a441 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -68,7 +68,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
set('check_method', v as CheckMethod)}> - + {CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]} @@ -271,7 +275,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' value={form.parent_id ?? 'none'} onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)} > - + {form.parent_id ? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)') @@ -301,6 +305,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' aria-checked={!!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" + tabIndex={0} + aria-label="Toggle container mode" style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }} > set('bottom_handles', parseInt(v ?? '1', 10))} > - + From 896cd4fa21f482194d8dcbd656e6c04039834297 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Thu, 23 Apr 2026 20:06:10 -0400 Subject: [PATCH 06/15] made a lot of accessibility fixes for the modalsincluding pointers, keyboard navigation and consistent border radius --- .github/workflows/docker-publish.yml | 6 +- frontend/src/components/modals/EdgeModal.tsx | 21 +++--- .../src/components/modals/GroupRectModal.tsx | 23 +++---- frontend/src/components/modals/NodeModal.tsx | 66 ++++++++++++------- .../modals/modal-interactive.module.css | 27 ++++++++ .../src/components/panels/DetailPanel.tsx | 10 +-- frontend/src/components/panels/Sidebar.tsx | 5 +- frontend/src/components/panels/Toolbar.tsx | 20 +++--- frontend/src/components/ui/dialog.tsx | 6 +- 9 files changed, 115 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/modals/modal-interactive.module.css diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fe0a4e8..600ae67 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,13 +15,13 @@ jobs: strategy: matrix: include: - - image: ghcr.io/${{ github.repository_owner }}/homelable-backend + - image: ghcr.io/pouzor/homelable-backend dockerfile: Dockerfile.backend build_args: "" - - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend + - image: ghcr.io/pouzor/homelable-frontend dockerfile: Dockerfile.frontend build_args: "" - - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend-standalone + - image: ghcr.io/pouzor/homelable-frontend-standalone dockerfile: Dockerfile.frontend build_args: "VITE_STANDALONE=true" diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index ea6a441..ec4fc56 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import modalStyles from './modal-interactive.module.css' import { RotateCcw } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -59,7 +60,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, return ( !o && onClose()}> - + {title} @@ -68,7 +69,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
set('font', v ?? 'inter')}> - + @@ -162,7 +163,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit type="button" title={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={{ background: isSelected ? '#00d4ff22' : '#21262d', border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, @@ -187,7 +188,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit key={value} type="button" 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={{ background: isSelected ? '#00d4ff22' : '#21262d', border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, @@ -248,7 +249,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit key={value} type="button" 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={{ background: isSelected ? '#00d4ff22' : '#21262d', border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, @@ -275,7 +276,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit type="button" title={label} 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={{ background: isSelected ? '#00d4ff22' : '#21262d', border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, @@ -301,7 +302,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit key={value} type="button" 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={{ background: isSelected ? '#00d4ff22' : '#21262d', border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, @@ -319,7 +320,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
set('type', v as NodeType)}> - + {NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]} @@ -137,7 +138,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
@@ -237,7 +238,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' value={form.ip ?? ''} onChange={(e) => set('ip', e.target.value)} placeholder="192.168.1.x, 2001:db8::1" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" + className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`} />
@@ -245,7 +246,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
- - - - +
+ ) +} + +// ── Edge type editor ───────────────────────────────────────────────────────── + +interface EdgeEditorProps { + edgeType: EdgeType + style: EdgeTypeStyle + onChange: (s: EdgeTypeStyle) => void + onApplyToExisting: () => void +} + +function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditorProps) { + const set = useCallback((k: K, v: EdgeTypeStyle[K]) => { + onChange({ ...style, [k]: v }) + }, [style, onChange]) + + return ( +
+
{EDGE_TYPE_LABELS[edgeType]}
+
+ set('color', v)} + onOpacityChange={(v) => set('opacity', v)} + /> +
+ +
+
+
Path style
+
+ {(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => ( + + ))} +
+
+ +
+
Animation
+ +
+
+ + +
+ ) +} + +// ── Main modal ─────────────────────────────────────────────────────────────── + +type Tab = 'nodes' | 'edges' +type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeType } | null + +interface CustomStyleModalProps { + open: boolean + onClose: () => void +} + +export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) { + const { customStyle, setCustomStyle } = useThemeStore() + const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore() + + const [tab, setTab] = useState('nodes') + const [selection, setSelection] = useState(null) + const [draft, setDraft] = useState(() => ({ + nodes: { ...customStyle.nodes }, + edges: { ...customStyle.edges }, + })) + + const handleOpen = (isOpen: boolean) => { + if (isOpen) { + // Reset draft to current saved customStyle on open + setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } }) + setSelection(null) + } else { + onClose() + } + } + + const getNodeStyle = (t: NodeType): NodeTypeStyle => + draft.nodes[t] ?? defaultNodeStyle(t) + + const getEdgeStyle = (t: EdgeType): EdgeTypeStyle => + draft.edges[t] ?? defaultEdgeStyle(t) + + const handleNodeChange = (t: NodeType, s: NodeTypeStyle) => + setDraft((d) => ({ ...d, nodes: { ...d.nodes, [t]: s } })) + + const handleEdgeChange = (t: EdgeType, s: EdgeTypeStyle) => + setDraft((d) => ({ ...d, edges: { ...d.edges, [t]: s } })) + + const handleApplyNodeType = (t: NodeType) => { + const style = getNodeStyle(t) + applyTypeNodeStyle(t, style) + toast.success(`Applied style to all ${NODE_TYPE_LABELS[t]} nodes`) + } + + const handleApplyEdgeType = (t: EdgeType) => { + const style = getEdgeStyle(t) + applyTypeEdgeStyle(t, style) + toast.success(`Applied style to all ${EDGE_TYPE_LABELS[t]} edges`) + } + + const handleSave = () => { + setCustomStyle(draft) + markUnsaved() + toast.success('Custom style saved — save your canvas to persist') + onClose() + } + + const handleApplyAll = () => { + setCustomStyle(draft) + applyAllCustomStyles(draft) + markUnsaved() + toast.success('Custom style applied to all nodes and edges') + onClose() + } + + const selectedNodeStyle = selection?.kind === 'node' ? getNodeStyle(selection.type) : null + const selectedEdgeStyle = selection?.kind === 'edge' ? getEdgeStyle(selection.type) : null + + return ( + + + + Custom Style Editor + + +
+ {/* Left panel — type list */} +
+ {/* Tabs */} +
+ {(['nodes', 'edges'] as Tab[]).map((t) => ( + + ))} +
+ + {/* Type list */} +
+ {tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => { + const Icon = NODE_ICONS[t] ?? Circle + const style = draft.nodes[t] + const isSelected = selection?.kind === 'node' && selection.type === t + const swatchColor = style + ? applyOpacity(style.borderColor, style.borderOpacity) + : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e' + + return ( + + ) + })} + + {tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => { + const style = draft.edges[t] + const isSelected = selection?.kind === 'edge' && selection.type === t + const swatchColor = style + ? applyOpacity(style.color, style.opacity) + : THEMES.default.colors.edgeColors[t] + + return ( + + ) + })} +
+
+ + {/* Right panel — editor */} +
+ {!selection && ( +
+ Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style +
+ )} + + {selection?.kind === 'node' && selectedNodeStyle && ( + handleNodeChange(selection.type, s)} + onApplyToExisting={() => handleApplyNodeType(selection.type)} + /> + )} + + {selection?.kind === 'edge' && selectedEdgeStyle && ( + handleEdgeChange(selection.type, s)} + onApplyToExisting={() => handleApplyEdgeType(selection.type)} + /> + )} +
+
+ + {/* Footer */} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/modals/ThemeModal.tsx b/frontend/src/components/modals/ThemeModal.tsx index 27aa58c..3100f27 100644 --- a/frontend/src/components/modals/ThemeModal.tsx +++ b/frontend/src/components/modals/ThemeModal.tsx @@ -1,11 +1,12 @@ import { useRef, useState, type KeyboardEvent } from 'react' import { toast } from 'sonner' -import { Check } from 'lucide-react' +import { Check, Pencil } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' +import { CustomStyleModal } from './CustomStyleModal' // Node-type accent colors to display as preview swatches const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const @@ -16,76 +17,106 @@ interface ThemeCardProps { onClick: () => void onKeyDown?: (event: KeyboardEvent) => void buttonRef?: (element: HTMLButtonElement | null) => void + onEdit?: () => void } -function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef }: ThemeCardProps) { +function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef, onEdit }: ThemeCardProps) { + const { customStyle } = useThemeStore() const preset = THEMES[themeId] const c = preset.colors + const isCustom = themeId === 'custom' + + // For custom theme, use defined node colors for preview swatches + const swatchColors = isCustom + ? PREVIEW_TYPES.map((t) => customStyle.nodes[t]?.borderColor ?? c.nodeAccents[t].border) + : PREVIEW_TYPES.map((t) => c.nodeAccents[t].border) + + const ethernetColor = isCustom + ? (customStyle.edges['ethernet']?.color ?? c.edgeColors.ethernet) + : c.edgeColors.ethernet + const wifiColor = isCustom + ? (customStyle.edges['wifi']?.color ?? c.edgeColors.wifi) + : c.edgeColors.wifi return ( - +
+ {preset.label} +
+
+ {preset.description} +
+ + + {/* Edit button — only for custom theme */} + {isCustom && onEdit && ( + + )} +
) } @@ -98,6 +129,7 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { const { activeTheme, setTheme } = useThemeStore() const { markUnsaved } = useCanvasStore() const cardRefs = useRef>([]) + const [customStyleOpen, setCustomStyleOpen] = useState(false) // Capture the theme that was active when the modal opened const [originalTheme] = useState(activeTheme) @@ -105,7 +137,6 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { const handleSelect = (id: ThemeId) => { setSelected(id) - // Live-preview the selected theme on the canvas setTheme(id) } @@ -137,65 +168,65 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { setTheme(selected) markUnsaved() onClose() - toast.info('Style applied — save your canvas to make it permanent', { - duration: 5000, - }) + toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 }) } const handleCancel = () => { - // Revert to the original theme setTheme(originalTheme) onClose() } return ( - { if (!o) handleCancel() }}> - - - Choose Canvas Style - + <> + { if (!o) handleCancel() }}> + + + Choose Canvas Style + -
- {THEME_ORDER.map((id, index) => ( -
- handleSelect(id)} - onKeyDown={handleCardKeyDown(index)} - buttonRef={(element) => { - cardRefs.current[index] = element - }} - /> -
- ))} -
+
+ {THEME_ORDER.map((id, index) => ( +
+ handleSelect(id)} + onKeyDown={handleCardKeyDown(index)} + buttonRef={(element) => { cardRefs.current[index] = element }} + onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined} + /> +
+ ))} +
-
- - -
-
-
+
+ + +
+
+
+ + setCustomStyleOpen(false)} /> + ) } diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 5e8c9ff..893ccc9 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -722,3 +722,98 @@ describe('canvasStore', () => { 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 = { id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } } + const e2: Edge = { 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 = { 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) + }) +}) diff --git a/frontend/src/stores/__tests__/themeStore.test.ts b/frontend/src/stores/__tests__/themeStore.test.ts index a8639de..ee07407 100644 --- a/frontend/src/stores/__tests__/themeStore.test.ts +++ b/frontend/src/stores/__tests__/themeStore.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach } from 'vitest' import { useThemeStore } from '@/stores/themeStore' +import type { CustomStyleDef } from '@/types' describe('themeStore', () => { beforeEach(() => { - useThemeStore.setState({ activeTheme: 'default' }) + useThemeStore.setState({ activeTheme: 'default', customStyle: { nodes: {}, edges: {} } }) }) it('starts with default theme', () => { @@ -15,8 +16,8 @@ describe('themeStore', () => { expect(useThemeStore.getState().activeTheme).toBe('matrix') }) - it('setTheme can switch between all presets', () => { - const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const + it('setTheme can switch between all presets including custom', () => { + const themes = ['default', 'dark', 'light', 'neon', 'matrix', 'custom'] as const for (const id of themes) { useThemeStore.getState().setTheme(id) expect(useThemeStore.getState().activeTheme).toBe(id) @@ -28,4 +29,26 @@ describe('themeStore', () => { useThemeStore.getState().setTheme('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({}) + }) }) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 5e1c043..f1af04a 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -9,9 +9,10 @@ import { applyEdgeChanges, addEdge, } 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 { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' +import { applyOpacity } from '@/utils/colorUtils' type HistoryEntry = { nodes: Node[]; edges: Edge[] } @@ -58,6 +59,9 @@ interface CanvasState { notifyScanDeviceFound: () => void hideIp: boolean toggleHideIp: () => void + applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void + applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void + applyAllCustomStyles: (def: CustomStyleDef) => void } export const useCanvasStore = create((set) => ({ @@ -468,4 +472,82 @@ export const useCanvasStore = create((set) => ({ }, 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 } + }), })) diff --git a/frontend/src/stores/themeStore.ts b/frontend/src/stores/themeStore.ts index edf4727..3e4e4dd 100644 --- a/frontend/src/stores/themeStore.ts +++ b/frontend/src/stores/themeStore.ts @@ -1,12 +1,17 @@ import { create } from 'zustand' import type { ThemeId } from '@/utils/themes' +import type { CustomStyleDef } from '@/types' interface ThemeState { activeTheme: ThemeId setTheme: (id: ThemeId) => void + customStyle: CustomStyleDef + setCustomStyle: (def: CustomStyleDef) => void } export const useThemeStore = create((set) => ({ activeTheme: 'default', setTheme: (id) => set({ activeTheme: id }), + customStyle: { nodes: {}, edges: {} }, + setCustomStyle: (def) => set({ customStyle: def }), })) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b4e0f59..b37ca34 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -150,3 +150,26 @@ export const EDGE_TYPE_LABELS: Record = { virtual: 'Virtual', 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> + edges: Partial> +} diff --git a/frontend/src/utils/colorUtils.ts b/frontend/src/utils/colorUtils.ts index 0e62436..dd6c8ea 100644 --- a/frontend/src/utils/colorUtils.ts +++ b/frontend/src/utils/colorUtils.ts @@ -27,3 +27,17 @@ export function rgbaToHex8(hex6: string, alpha: number): string { const alphaHex = alphaByte.toString(16).padStart(2, '0') 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})` +} diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index 7f1be3c..583aa29 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -1,6 +1,6 @@ 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 { // Per node-type accent (border + icon) @@ -315,7 +315,63 @@ export const THEMES: Record = { 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 -export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix'] +export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix', 'custom'] From 31b61904ac147803ec27755f8ce4879a2d2784e5 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 24 Apr 2026 02:17:03 +0200 Subject: [PATCH 09/15] fix: add setCustomStyle to useEffect dependency array --- frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe8a255..477f2e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -119,7 +119,7 @@ export default function App() { } }) .catch(() => loadCanvas(demoNodes, demoEdges)) - }, [isAuthenticated, loadCanvas, setTheme]) + }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // Keep refs for store actions so keydown handler is always up-to-date without re-registering const undoRef = useRef(undo) From 84235d81bf17a0e5845ffc9a9a6feb7422a19c56 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 24 Apr 2026 10:35:19 +0200 Subject: [PATCH 10/15] fix: show edge type label in select trigger after selection --- frontend/src/components/modals/EdgeModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 664b12d..e254dca 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -69,7 +69,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, set('ip', e.target.value)} placeholder="192.168.1.x, 2001:db8::1" className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`} /> + comma-separated
{/* Check method */} From 2cc97a6de9935818ce76d053f33cbf00ddc0fc59 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 24 Apr 2026 15:33:09 +0200 Subject: [PATCH 15/15] bump: version 1.12.0 --- VERSION | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 169f19b..32bd932 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.11.0 \ No newline at end of file +1.12.0 \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 599aab9..f179461 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.11.0", + "version": "1.12.0", "type": "module", "scripts": { "dev": "vite",