mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(canvas): fullscreen Railway-style workspace canvas
Rebuild the project canvas as a full-viewport graph instead of a boxed 66vh panel. Slim workspace top bar now holds the view tabs, project meta and actions; the canvas fills all remaining space. - Floating glass controls: env filter + group pill (top-left), zoom out / live zoom % / zoom in / fit / tidy pill (bottom-left), dark MiniMap (bottom-right), hint chip, demo badge. - Inspector panel: selecting a node opens a right-side glass panel with status, image, address, inferred connections incl. variable reasons, and deploy/restart/stop/delete actions. Esc or pane click closes. - Auto-layout: dependency-depth layered layout (consumers left, datastores right) for first-visit placement and the Tidy action. Unpositioned services park right of existing bounds, not at (0,0). - Nodes: fixed 240px width (was 210 vs 220-280 CSS mismatch), per-type icons/colors incl. cron, spinner icon while deploying, hover lift. - Edges: lime bezier strokes, animated dashes while either endpoint is deploying, hover glow updated from stale purple to accent. - Groups: off-brand crimson replaced with accent tokens, double-click title to rename inline. - Keyboard: F fit, 0 reset zoom, +/- zoom, Esc deselect. Marquee select on Shift-drag. Double-click zoom disabled, dead connection drags off. - Mobile: icon-only tabs, minimap/hint hidden under md. - Retake docs/screenshots/canvas.png + site copy (1600x900 demo). Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
84b102a2c6
commit
e57f0470eb
9 files changed
+807
-313
No files matched your search
@@ -1,11 +1,14 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
Background,
|
Background,
|
||||||
|
BackgroundVariant,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
|
MiniMap,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
|
useViewport,
|
||||||
type Edge,
|
type Edge,
|
||||||
type Node,
|
type Node,
|
||||||
type NodeTypes,
|
type NodeTypes,
|
||||||
@@ -29,13 +32,14 @@ import {
|
|||||||
type ProjectCanvasMetadata,
|
type ProjectCanvasMetadata,
|
||||||
} from '../model';
|
} from '../model';
|
||||||
import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
||||||
|
import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
|
||||||
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
|
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
|
||||||
|
import { serviceTypeColor, serviceTypeIcon } from './service-visuals';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Layers,
|
Layers,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
RotateCcw,
|
Minus,
|
||||||
Box,
|
|
||||||
Link2,
|
Link2,
|
||||||
Play,
|
Play,
|
||||||
Square,
|
Square,
|
||||||
@@ -43,6 +47,9 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Globe,
|
Globe,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
Wand2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type CanvasProps = {
|
type CanvasProps = {
|
||||||
@@ -51,6 +58,7 @@ type CanvasProps = {
|
|||||||
variablesByService: Record<string, ServiceVariable[]>;
|
variablesByService: Record<string, ServiceVariable[]>;
|
||||||
onAddService: () => void;
|
onAddService: () => void;
|
||||||
onOpenService: (serviceId: string) => void;
|
onOpenService: (serviceId: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
|
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
|
||||||
@@ -58,10 +66,9 @@ type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
|
|||||||
type CanvasNode = ServiceCanvasNode | GroupCanvasNode;
|
type CanvasNode = ServiceCanvasNode | GroupCanvasNode;
|
||||||
type CanvasEdge = Edge<{ reasons: string[] }>;
|
type CanvasEdge = Edge<{ reasons: string[] }>;
|
||||||
|
|
||||||
const SERVICE_NODE_WIDTH = 210;
|
|
||||||
const SERVICE_NODE_HEIGHT = 96;
|
|
||||||
const GROUP_DEFAULT_WIDTH = 340;
|
const GROUP_DEFAULT_WIDTH = 340;
|
||||||
const GROUP_DEFAULT_HEIGHT = 230;
|
const GROUP_DEFAULT_HEIGHT = 230;
|
||||||
|
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
|
||||||
|
|
||||||
const nodeTypes: NodeTypes = {
|
const nodeTypes: NodeTypes = {
|
||||||
serviceNode: ServiceNode,
|
serviceNode: ServiceNode,
|
||||||
@@ -74,12 +81,17 @@ type ContextMenuState = {
|
|||||||
y: number;
|
y: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] {
|
function toFlowNodes(
|
||||||
|
metadata: ProjectCanvasMetadata,
|
||||||
|
services: ServiceEntity[],
|
||||||
|
onOpenService: CanvasProps['onOpenService'],
|
||||||
|
onRenameGroup: (groupId: string, title: string) => void,
|
||||||
|
): CanvasNode[] {
|
||||||
const groups = metadata.groups.map(
|
const groups = metadata.groups.map(
|
||||||
(group): GroupCanvasNode => ({
|
(group): GroupCanvasNode => ({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
type: 'groupNode',
|
type: 'groupNode',
|
||||||
data: { title: group.title },
|
data: { title: group.title, onRename: onRenameGroup },
|
||||||
position: group.position,
|
position: group.position,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
@@ -111,7 +123,7 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[],
|
|||||||
draggable: true,
|
draggable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
style: {
|
style: {
|
||||||
width: SERVICE_NODE_WIDTH,
|
width: NODE_WIDTH,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -122,6 +134,7 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[],
|
|||||||
function toFlowEdges(
|
function toFlowEdges(
|
||||||
links: ReturnType<typeof inferAutoConnections>,
|
links: ReturnType<typeof inferAutoConnections>,
|
||||||
positionOf: (serviceId: string) => { x: number; y: number } | undefined,
|
positionOf: (serviceId: string) => { x: number; y: number } | undefined,
|
||||||
|
statusOf: (serviceId: string) => string | undefined,
|
||||||
): CanvasEdge[] {
|
): CanvasEdge[] {
|
||||||
return links.map((link) => {
|
return links.map((link) => {
|
||||||
const source = positionOf(link.edge.sourceServiceId);
|
const source = positionOf(link.edge.sourceServiceId);
|
||||||
@@ -129,6 +142,9 @@ function toFlowEdges(
|
|||||||
// Choose handle sides by relative position so edges flow forward
|
// Choose handle sides by relative position so edges flow forward
|
||||||
// instead of looping back through the canvas.
|
// instead of looping back through the canvas.
|
||||||
const forward = !source || !target || source.x <= target.x;
|
const forward = !source || !target || source.x <= target.x;
|
||||||
|
const live =
|
||||||
|
TRANSIENT_STATUSES.has(statusOf(link.edge.sourceServiceId) ?? '') ||
|
||||||
|
TRANSIENT_STATUSES.has(statusOf(link.edge.targetServiceId) ?? '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: link.edge.id,
|
id: link.edge.id,
|
||||||
@@ -136,19 +152,19 @@ function toFlowEdges(
|
|||||||
target: link.edge.targetServiceId,
|
target: link.edge.targetServiceId,
|
||||||
sourceHandle: forward ? 's-r' : 's-l',
|
sourceHandle: forward ? 's-r' : 's-l',
|
||||||
targetHandle: forward ? 't-l' : 't-r',
|
targetHandle: forward ? 't-l' : 't-r',
|
||||||
animated: false,
|
animated: live,
|
||||||
data: {
|
data: {
|
||||||
reasons: link.reasons,
|
reasons: link.reasons,
|
||||||
},
|
},
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
color: 'var(--accent-secondary)',
|
color: 'rgba(180, 227, 74, 0.55)',
|
||||||
width: 14,
|
width: 13,
|
||||||
height: 14,
|
height: 13,
|
||||||
},
|
},
|
||||||
style: {
|
style: {
|
||||||
stroke: 'var(--accent-primary)',
|
stroke: 'rgba(180, 227, 74, 0.42)',
|
||||||
strokeWidth: 2,
|
strokeWidth: 1.6,
|
||||||
},
|
},
|
||||||
className: 'edge-premium',
|
className: 'edge-premium',
|
||||||
};
|
};
|
||||||
@@ -190,7 +206,58 @@ function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: nu
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService }: CanvasProps) {
|
function ZoomControls({ onTidy }: { onTidy: () => void }) {
|
||||||
|
const { zoom } = useViewport();
|
||||||
|
const { zoomIn, zoomOut, fitView, setViewport } = useReactFlow();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="canvas-pill flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomOut({ duration: 160 })}
|
||||||
|
className="canvas-pill-btn"
|
||||||
|
title="Zoom out (-)"
|
||||||
|
>
|
||||||
|
<Minus size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
|
||||||
|
className="canvas-pill-btn mono w-12 text-[11px]"
|
||||||
|
title="Reset zoom (0)"
|
||||||
|
>
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomIn({ duration: 160 })}
|
||||||
|
className="canvas-pill-btn"
|
||||||
|
title="Zoom in (+)"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-4 bg-[var(--border-subtle)] mx-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fitView({ padding: 0.18, duration: 240 })}
|
||||||
|
className="canvas-pill-btn"
|
||||||
|
title="Fit view (F)"
|
||||||
|
>
|
||||||
|
<Maximize2 size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTidy}
|
||||||
|
className="canvas-pill-btn"
|
||||||
|
title="Tidy layout"
|
||||||
|
>
|
||||||
|
<Wand2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService, readOnly }: CanvasProps) {
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const persistTimeout = useRef<number | null>(null);
|
const persistTimeout = useRef<number | null>(null);
|
||||||
const hydratedRef = useRef(false);
|
const hydratedRef = useRef(false);
|
||||||
@@ -203,7 +270,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>();
|
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport, zoomIn, zoomOut } = useReactFlow<CanvasNode, CanvasEdge>();
|
||||||
|
|
||||||
const environments = useMemo(() => {
|
const environments = useMemo(() => {
|
||||||
const envs = new Set(services.map((service) => service.environment ?? 'production'));
|
const envs = new Set(services.map((service) => service.environment ?? 'production'));
|
||||||
@@ -226,10 +293,22 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
);
|
);
|
||||||
const edges = useMemo(() => {
|
const edges = useMemo(() => {
|
||||||
const positionOf = (serviceId: string) => nodes.find((node) => node.id === serviceId)?.position;
|
const positionOf = (serviceId: string) => nodes.find((node) => node.id === serviceId)?.position;
|
||||||
return toFlowEdges(inferredLinks, positionOf).filter(
|
const statusOf = (serviceId: string) => services.find((service) => service.id === serviceId)?.status;
|
||||||
|
return toFlowEdges(inferredLinks, positionOf, statusOf).filter(
|
||||||
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
||||||
);
|
);
|
||||||
}, [inferredLinks, visibleIds, nodes]);
|
}, [inferredLinks, visibleIds, nodes, services]);
|
||||||
|
|
||||||
|
const renameGroup = useCallback(
|
||||||
|
(groupId: string, title: string) => {
|
||||||
|
setNodes((current) =>
|
||||||
|
current.map((node) =>
|
||||||
|
node.id === groupId && node.type === 'groupNode' ? { ...node, data: { ...node.data, title } } : node,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setNodes],
|
||||||
|
);
|
||||||
|
|
||||||
const invalidateServices = useCallback(() => {
|
const invalidateServices = useCallback(() => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||||
@@ -276,6 +355,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.showToast('Service deleted', 'success');
|
toast.showToast('Service deleted', 'success');
|
||||||
invalidateServices();
|
invalidateServices();
|
||||||
|
setSelectedServiceId(null);
|
||||||
},
|
},
|
||||||
onError: actionError,
|
onError: actionError,
|
||||||
});
|
});
|
||||||
@@ -286,10 +366,28 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
stopMutation.isPending ||
|
stopMutation.isPending ||
|
||||||
deleteMutation.isPending;
|
deleteMutation.isPending;
|
||||||
|
|
||||||
|
const tidyLayout = useCallback(() => {
|
||||||
|
const positions = computeLayeredLayout(
|
||||||
|
services,
|
||||||
|
inferredLinks.map((link) => link.edge),
|
||||||
|
);
|
||||||
|
setNodes((current) =>
|
||||||
|
current.map((node) => {
|
||||||
|
if (node.type !== 'serviceNode' || node.parentId) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
const next = positions.get(node.id);
|
||||||
|
return next ? { ...node, position: next } : node;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.setTimeout(() => void fitView({ padding: 0.18, duration: 300 }), 40);
|
||||||
|
}, [services, inferredLinks, setNodes, fitView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasStoredViewport = hasCanvasMetadata(projectId);
|
const hasStoredViewport = hasCanvasMetadata(projectId);
|
||||||
const metadata = loadCanvasMetadata(projectId, services);
|
const layout = hasStoredViewport ? undefined : layoutFromVariables(services, variablesByService);
|
||||||
const nextNodes = toFlowNodes(metadata, services, onOpenService);
|
const metadata = loadCanvasMetadata(projectId, services, layout);
|
||||||
|
const nextNodes = toFlowNodes(metadata, services, onOpenService, renameGroup);
|
||||||
|
|
||||||
viewportRestoredRef.current = false;
|
viewportRestoredRef.current = false;
|
||||||
setNodes(nextNodes);
|
setNodes(nextNodes);
|
||||||
@@ -302,7 +400,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom;
|
metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom;
|
||||||
|
|
||||||
if (!hasStoredViewport || isDefaultViewport) {
|
if (!hasStoredViewport || isDefaultViewport) {
|
||||||
void fitView({ padding: 0.15, duration: 120 });
|
void fitView({ padding: 0.18, duration: 120 });
|
||||||
} else {
|
} else {
|
||||||
void setViewport(metadata.viewport, { duration: 120 });
|
void setViewport(metadata.viewport, { duration: 120 });
|
||||||
}
|
}
|
||||||
@@ -311,7 +409,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
});
|
});
|
||||||
|
|
||||||
hydratedRef.current = true;
|
hydratedRef.current = true;
|
||||||
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services]);
|
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services, renameGroup, variablesByService]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydratedRef.current || !viewportRestoredRef.current) {
|
if (!hydratedRef.current || !viewportRestoredRef.current) {
|
||||||
@@ -352,6 +450,47 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
);
|
);
|
||||||
}, [selectedServiceId, setNodes]);
|
}, [selectedServiceId, setNodes]);
|
||||||
|
|
||||||
|
// Canvas keyboard shortcuts: F fit, 0 reset zoom, +/- zoom, Esc deselect.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
void fitView({ padding: 0.18, duration: 240 });
|
||||||
|
break;
|
||||||
|
case '0':
|
||||||
|
void setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 });
|
||||||
|
break;
|
||||||
|
case '=':
|
||||||
|
case '+':
|
||||||
|
void zoomIn({ duration: 160 });
|
||||||
|
break;
|
||||||
|
case '-':
|
||||||
|
case '_':
|
||||||
|
void zoomOut({ duration: 160 });
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setSelectedServiceId(null);
|
||||||
|
setContextMenu(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [fitView, setViewport, zoomIn, zoomOut]);
|
||||||
|
|
||||||
const getGroupUnderPoint = useCallback((x: number, y: number, ignoreGroupId?: string) => {
|
const getGroupUnderPoint = useCallback((x: number, y: number, ignoreGroupId?: string) => {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type !== 'groupNode' || node.id === ignoreGroupId) {
|
if (node.type !== 'groupNode' || node.id === ignoreGroupId) {
|
||||||
@@ -377,8 +516,8 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
}
|
}
|
||||||
|
|
||||||
const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position;
|
const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position;
|
||||||
const centerX = basePosition.x + SERVICE_NODE_WIDTH / 2;
|
const centerX = basePosition.x + NODE_WIDTH / 2;
|
||||||
const centerY = basePosition.y + SERVICE_NODE_HEIGHT / 2;
|
const centerY = basePosition.y + NODE_HEIGHT / 2;
|
||||||
const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId);
|
const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId);
|
||||||
|
|
||||||
if (targetGroup) {
|
if (targetGroup) {
|
||||||
@@ -386,8 +525,8 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
const targetWidth = typeof targetGroup.style?.width === 'number' ? targetGroup.style.width : GROUP_DEFAULT_WIDTH;
|
const targetWidth = typeof targetGroup.style?.width === 'number' ? targetGroup.style.width : GROUP_DEFAULT_WIDTH;
|
||||||
const targetHeight = typeof targetGroup.style?.height === 'number' ? targetGroup.style.height : GROUP_DEFAULT_HEIGHT;
|
const targetHeight = typeof targetGroup.style?.height === 'number' ? targetGroup.style.height : GROUP_DEFAULT_HEIGHT;
|
||||||
|
|
||||||
const relativeX = Math.max(12, Math.min(targetWidth - SERVICE_NODE_WIDTH - 12, basePosition.x - groupBase.x));
|
const relativeX = Math.max(12, Math.min(targetWidth - NODE_WIDTH - 12, basePosition.x - groupBase.x));
|
||||||
const relativeY = Math.max(30, Math.min(targetHeight - SERVICE_NODE_HEIGHT - 12, basePosition.y - groupBase.y));
|
const relativeY = Math.max(30, Math.min(targetHeight - NODE_HEIGHT - 12, basePosition.y - groupBase.y));
|
||||||
|
|
||||||
setNodes((current) =>
|
setNodes((current) =>
|
||||||
current.map((node) => {
|
current.map((node) => {
|
||||||
@@ -452,7 +591,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
x: center.x - GROUP_DEFAULT_WIDTH / 2,
|
x: center.x - GROUP_DEFAULT_WIDTH / 2,
|
||||||
y: center.y - GROUP_DEFAULT_HEIGHT / 2,
|
y: center.y - GROUP_DEFAULT_HEIGHT / 2,
|
||||||
},
|
},
|
||||||
data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}` },
|
data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}`, onRename: renameGroup },
|
||||||
draggable: true,
|
draggable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
style: {
|
style: {
|
||||||
@@ -461,7 +600,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
},
|
},
|
||||||
} satisfies GroupCanvasNode,
|
} satisfies GroupCanvasNode,
|
||||||
]);
|
]);
|
||||||
}, [screenToFlowPosition, setNodes]);
|
}, [screenToFlowPosition, setNodes, renameGroup]);
|
||||||
|
|
||||||
// Env filter hides nodes via React Flow's `hidden` flag — positions and
|
// Env filter hides nodes via React Flow's `hidden` flag — positions and
|
||||||
// persisted layout stay intact for services outside the current filter.
|
// persisted layout stay intact for services outside the current filter.
|
||||||
@@ -493,92 +632,37 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
const contextService = contextMenu ? services.find((service) => service.id === contextMenu.serviceId) ?? null : null;
|
const contextService = contextMenu ? services.find((service) => service.id === contextMenu.serviceId) ?? null : null;
|
||||||
|
|
||||||
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
|
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
|
||||||
const selectedServiceLinkCount = selectedService
|
const selectedConnections = useMemo(() => {
|
||||||
? inferredLinks.filter(
|
if (!selectedService) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const nameOf = (id: string) => services.find((service) => service.id === id)?.name ?? id;
|
||||||
|
return inferredLinks
|
||||||
|
.filter(
|
||||||
(link) =>
|
(link) =>
|
||||||
link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id,
|
link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id,
|
||||||
).length
|
)
|
||||||
: 0;
|
.map((link) => ({
|
||||||
|
id: link.edge.id,
|
||||||
|
outbound: link.edge.sourceServiceId === selectedService.id,
|
||||||
|
peer: link.edge.sourceServiceId === selectedService.id ? nameOf(link.edge.targetServiceId) : nameOf(link.edge.sourceServiceId),
|
||||||
|
peerId: link.edge.sourceServiceId === selectedService.id ? link.edge.targetServiceId : link.edge.sourceServiceId,
|
||||||
|
reasons: link.reasons,
|
||||||
|
}));
|
||||||
|
}, [inferredLinks, selectedService, services]);
|
||||||
|
|
||||||
|
const minimapNodeColor = useCallback(
|
||||||
|
(node: CanvasNode) => {
|
||||||
|
if (node.type === 'groupNode') {
|
||||||
|
return 'rgba(255,255,255,0.08)';
|
||||||
|
}
|
||||||
|
return serviceTypeColor((node.data as ServiceNodeData).service.type);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panel overflow-hidden">
|
<div ref={wrapperRef} className="relative h-full min-h-0 w-full bg-[var(--bg-void)]">
|
||||||
{/* Toolbar - Railway-inspired premium design */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAddService}
|
|
||||||
className="flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
|
||||||
>
|
|
||||||
<Plus size={15} />
|
|
||||||
Add Service
|
|
||||||
</button>
|
|
||||||
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addGroup}
|
|
||||||
className="flex items-center gap-2 h-9 px-3 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] hover:border-[var(--border-default)] transition-all"
|
|
||||||
>
|
|
||||||
<Layers size={14} />
|
|
||||||
Group
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{environments.length > 1 && (
|
|
||||||
<>
|
|
||||||
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
|
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-1">
|
|
||||||
{['all', ...environments].map((env) => (
|
|
||||||
<button
|
|
||||||
key={env}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEnvFilter(env)}
|
|
||||||
className={`px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-all ${
|
|
||||||
envFilter === env
|
|
||||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
|
||||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{env}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fitView({ padding: 0.2, duration: 240 })}
|
|
||||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
|
|
||||||
title="Fit View"
|
|
||||||
>
|
|
||||||
<Maximize2 size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
|
|
||||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
|
|
||||||
title="Reset View"
|
|
||||||
>
|
|
||||||
<RotateCcw size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
|
|
||||||
<Box size={12} />
|
|
||||||
<span className="font-medium">{services.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-px h-3 bg-[var(--border-subtle)]" />
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
|
|
||||||
<Link2 size={12} />
|
|
||||||
<span className="font-medium">{edges.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Canvas */}
|
|
||||||
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)] relative">
|
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={renderedNodes}
|
nodes={renderedNodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -605,18 +689,226 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
||||||
panOnDrag
|
panOnDrag
|
||||||
zoomOnScroll
|
zoomOnScroll
|
||||||
minZoom={0.25}
|
zoomOnDoubleClick={false}
|
||||||
maxZoom={2.3}
|
nodesConnectable={false}
|
||||||
|
nodeDragThreshold={3}
|
||||||
|
onlyRenderVisibleElements
|
||||||
|
minZoom={0.2}
|
||||||
|
maxZoom={2.2}
|
||||||
deleteKeyCode={null}
|
deleteKeyCode={null}
|
||||||
|
selectionKeyCode="Shift"
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
>
|
>
|
||||||
{/* SVG Definitions for premium edge styling */}
|
<Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="rgba(255,255,255,0.055)" />
|
||||||
<svg style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}>
|
<MiniMap
|
||||||
<defs />
|
position="bottom-right"
|
||||||
</svg>
|
pannable
|
||||||
<Background color="rgba(255,255,255,0.05)" gap={28} />
|
zoomable
|
||||||
|
nodeColor={minimapNodeColor}
|
||||||
|
bgColor="rgba(14, 15, 18, 0.92)"
|
||||||
|
maskColor="rgba(12, 13, 15, 0.72)"
|
||||||
|
className="canvas-minimap hidden md:block"
|
||||||
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|
||||||
|
{/* Floating: env filter + group (top-left) */}
|
||||||
|
<div className="absolute left-4 top-4 flex items-center gap-2">
|
||||||
|
{environments.length > 1 && (
|
||||||
|
<div className="canvas-pill flex items-center gap-0.5 p-1">
|
||||||
|
{['all', ...environments].map((env) => (
|
||||||
|
<button
|
||||||
|
key={env}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEnvFilter(env)}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-[11px] font-medium capitalize transition-all ${
|
||||||
|
envFilter === env
|
||||||
|
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||||
|
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{env}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addGroup}
|
||||||
|
className="canvas-pill canvas-pill-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
title="Add group"
|
||||||
|
>
|
||||||
|
<Layers size={13} />
|
||||||
|
Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating: zoom controls (bottom-left) */}
|
||||||
|
<div className="absolute bottom-4 left-4">
|
||||||
|
<ZoomControls onTidy={tidyLayout} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating: hint (bottom-center) — fades once a node is selected */}
|
||||||
|
{!selectedService && services.length > 0 && (
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 pointer-events-none hidden lg:block">
|
||||||
|
<p className="canvas-pill px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)] whitespace-nowrap">
|
||||||
|
Click to inspect · Double-click to open · Right-click for actions · F to fit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inspector (right) */}
|
||||||
|
{selectedService && (
|
||||||
|
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[300px] panel-glass flex flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
|
||||||
|
style={{ background: `${serviceTypeColor(selectedService.type)}1c`, color: serviceTypeColor(selectedService.type) }}
|
||||||
|
>
|
||||||
|
{serviceTypeIcon(selectedService.type, 16)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{selectedService.name}</h3>
|
||||||
|
<p className="text-[11px] text-[var(--text-tertiary)]">
|
||||||
|
{selectedService.type} · {selectedService.environment ?? 'production'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedServiceId(null)}
|
||||||
|
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
|
||||||
|
title="Close (Esc)"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||||
|
{selectedService.image && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-1">Image</p>
|
||||||
|
<p className="mono text-[11px] text-[var(--text-secondary)] break-all">{selectedService.image}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(selectedService.domain || selectedService.publicUrl || selectedService.port) && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-1">Address</p>
|
||||||
|
{selectedService.domain || selectedService.publicUrl ? (
|
||||||
|
<a
|
||||||
|
href={selectedService.domain ? `https://${selectedService.domain}` : selectedService.publicUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
|
||||||
|
>
|
||||||
|
{(selectedService.domain ?? selectedService.publicUrl ?? '').replace(/^https?:\/\//, '')}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{selectedService.port ? (
|
||||||
|
<p className="mono text-[11px] text-[var(--text-secondary)] mt-0.5">
|
||||||
|
{selectedService.name}:{selectedService.port}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||||
|
Connections ({selectedConnections.length})
|
||||||
|
</p>
|
||||||
|
{selectedConnections.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-[var(--text-muted)]">
|
||||||
|
No inferred connections. Reference another service via <span className="mono">{'{{variable}}'}</span> placeholders in env vars.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{selectedConnections.map((conn) => (
|
||||||
|
<button
|
||||||
|
key={conn.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedServiceId(conn.peerId)}
|
||||||
|
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
|
||||||
|
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
|
||||||
|
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
|
||||||
|
{conn.reasons.join(' · ')}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenService(selectedService.id)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
<ArrowUpRight size={14} />
|
||||||
|
Open service
|
||||||
|
</button>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionPending || readOnly}
|
||||||
|
onClick={() => deployMutation.mutate(selectedService.id)}
|
||||||
|
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{deployMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
|
||||||
|
Deploy
|
||||||
|
</button>
|
||||||
|
{selectedService.status !== 'running' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionPending || readOnly}
|
||||||
|
onClick={() => startMutation.mutate(selectedService.id)}
|
||||||
|
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Play size={11} />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionPending || readOnly}
|
||||||
|
onClick={() => restartMutation.mutate(selectedService.id)}
|
||||||
|
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{restartMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionPending || readOnly || selectedService.status !== 'running'}
|
||||||
|
onClick={() => stopMutation.mutate(selectedService.id)}
|
||||||
|
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Square size={11} />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionPending || readOnly}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete service "${selectedService.name}"? Its containers will be removed.`)) {
|
||||||
|
deleteMutation.mutate(selectedService.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
Delete service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{services.length === 0 && (
|
{services.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
@@ -626,7 +918,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
|
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
|
||||||
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs">
|
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs">
|
||||||
Add a service from a git repo or Docker image — it runs on a private network with the rest of this
|
Add a service from a git repo or Docker image - it runs on a private network with the rest of this
|
||||||
project.
|
project.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -645,7 +937,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
{/* Node context menu */}
|
{/* Node context menu */}
|
||||||
{contextMenu && contextService && (
|
{contextMenu && contextService && (
|
||||||
<div
|
<div
|
||||||
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] shadow-2xl py-1"
|
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] panel-glass py-1"
|
||||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
|
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
|
||||||
@@ -674,6 +966,8 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')}
|
Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionPending}
|
disabled={actionPending}
|
||||||
@@ -740,35 +1034,11 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3">
|
|
||||||
{selectedService ? (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
|
|
||||||
<Box size={14} className="text-[var(--accent-primary)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)]">{selectedService.name}</span>
|
|
||||||
<span className="ml-2 text-xs text-[var(--text-tertiary)]">({selectedService.type})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-[var(--surface-muted)] text-xs text-[var(--text-tertiary)]">
|
|
||||||
<Link2 size={10} />
|
|
||||||
<span>{selectedServiceLinkCount} connection{selectedServiceLinkCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)] text-center">
|
|
||||||
Click to select • Double-click to open • Right-click for actions • Connections auto-inferred from variables
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { ServiceEntity } from '@/lib/api-client';
|
||||||
|
import type { ServiceVariable } from '../auto-connections';
|
||||||
|
import { inferAutoConnections } from '../auto-connections';
|
||||||
|
|
||||||
|
export const NODE_WIDTH = 240;
|
||||||
|
export const NODE_HEIGHT = 104;
|
||||||
|
export const COL_GAP = 340;
|
||||||
|
export const ROW_GAP = 172;
|
||||||
|
|
||||||
|
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
|
||||||
|
|
||||||
|
// Layered left-to-right layout: a service's depth is the longest chain of
|
||||||
|
// dependencies beneath it, so consumers sit left and datastores right.
|
||||||
|
// Unconnected services get their own trailing column separated by a gap.
|
||||||
|
export function computeLayeredLayout(
|
||||||
|
services: ServiceEntity[],
|
||||||
|
edges: LinkEdge[],
|
||||||
|
): Map<string, { x: number; y: number }> {
|
||||||
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
|
if (services.length === 0) {
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set(services.map((service) => service.id));
|
||||||
|
const outgoing = new Map<string, string[]>();
|
||||||
|
const connected = new Set<string>();
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
if (!ids.has(edge.sourceServiceId) || !ids.has(edge.targetServiceId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
connected.add(edge.sourceServiceId);
|
||||||
|
connected.add(edge.targetServiceId);
|
||||||
|
const list = outgoing.get(edge.sourceServiceId) ?? [];
|
||||||
|
list.push(edge.targetServiceId);
|
||||||
|
outgoing.set(edge.sourceServiceId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const depthMemo = new Map<string, number>();
|
||||||
|
const depthOf = (id: string, trail: Set<string>): number => {
|
||||||
|
const memoized = depthMemo.get(id);
|
||||||
|
if (memoized !== undefined) {
|
||||||
|
return memoized;
|
||||||
|
}
|
||||||
|
if (trail.has(id)) {
|
||||||
|
return 0; // cycle guard
|
||||||
|
}
|
||||||
|
trail.add(id);
|
||||||
|
let best = 0;
|
||||||
|
for (const target of outgoing.get(id) ?? []) {
|
||||||
|
best = Math.max(best, depthOf(target, trail) + 1);
|
||||||
|
}
|
||||||
|
trail.delete(id);
|
||||||
|
depthMemo.set(id, best);
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectedServices = services
|
||||||
|
.filter((service) => connected.has(service.id))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const looseServices = services
|
||||||
|
.filter((service) => !connected.has(service.id))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const depthById = new Map(connectedServices.map((service) => [service.id, depthOf(service.id, new Set())]));
|
||||||
|
const maxDepth = Math.max(0, ...depthById.values());
|
||||||
|
|
||||||
|
// Column = maxDepth - depth so deeper consumers render further left.
|
||||||
|
const columns = new Map<number, ServiceEntity[]>();
|
||||||
|
for (const service of connectedServices) {
|
||||||
|
const col = maxDepth - (depthById.get(service.id) ?? 0);
|
||||||
|
const list = columns.get(col) ?? [];
|
||||||
|
list.push(service);
|
||||||
|
columns.set(col, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedColCount = columns.size;
|
||||||
|
const maxRows = Math.max(
|
||||||
|
1,
|
||||||
|
...Array.from(columns.values()).map((list) => list.length),
|
||||||
|
looseServices.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [col, list] of columns) {
|
||||||
|
const offsetY = ((maxRows - list.length) * ROW_GAP) / 2;
|
||||||
|
list.forEach((service, row) => {
|
||||||
|
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
looseServices.forEach((service, row) => {
|
||||||
|
const col = connectedColCount > 0 ? connectedColCount : 0;
|
||||||
|
const offsetY = ((maxRows - looseServices.length) * ROW_GAP) / 2;
|
||||||
|
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
||||||
|
});
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutFromVariables(
|
||||||
|
services: ServiceEntity[],
|
||||||
|
variablesByService: Record<string, ServiceVariable[]>,
|
||||||
|
): Map<string, { x: number; y: number }> {
|
||||||
|
const links = inferAutoConnections(services, variablesByService);
|
||||||
|
return computeLayeredLayout(
|
||||||
|
services,
|
||||||
|
links.map((link) => link.edge),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||||
import type { ServiceEntity } from '@/lib/api-client';
|
import type { ServiceEntity } from '@/lib/api-client';
|
||||||
import { serviceStatusClass } from '@/lib/api-client';
|
import { serviceStatusClass } from '@/lib/api-client';
|
||||||
import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react';
|
import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { serviceTypeColor, serviceTypeIcon } from './service-visuals';
|
||||||
|
|
||||||
export type ServiceNodeData = {
|
export type ServiceNodeData = {
|
||||||
service: ServiceEntity;
|
service: ServiceEntity;
|
||||||
@@ -12,37 +13,12 @@ export type ServiceNodeData = {
|
|||||||
|
|
||||||
export type GroupNodeData = {
|
export type GroupNodeData = {
|
||||||
title: string;
|
title: string;
|
||||||
|
onRename?: (groupId: string, title: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
||||||
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
|
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
|
||||||
|
|
||||||
function renderServiceTypeIcon(type: string, size = 18) {
|
|
||||||
switch (type) {
|
|
||||||
case 'web':
|
|
||||||
return <Globe size={size} />;
|
|
||||||
case 'database':
|
|
||||||
return <Database size={size} />;
|
|
||||||
case 'worker':
|
|
||||||
return <Terminal size={size} />;
|
|
||||||
default:
|
|
||||||
return <Box size={size} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceTypeColor(type: string): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'web':
|
|
||||||
return '#7ab8ff';
|
|
||||||
case 'database':
|
|
||||||
return '#b4e34a';
|
|
||||||
case 'worker':
|
|
||||||
return 'var(--accent-primary)';
|
|
||||||
default:
|
|
||||||
return '#9295a4';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: string): string {
|
function statusLabel(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
@@ -72,27 +48,29 @@ function statusColor(status: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
|
||||||
|
|
||||||
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||||
const { service, selected } = data;
|
const { service, selected } = data;
|
||||||
const typeColor = serviceTypeColor(service.type);
|
const typeColor = serviceTypeColor(service.type);
|
||||||
const replicas = service.replicas ?? 0;
|
const replicas = service.replicas ?? 0;
|
||||||
const stateColor = statusColor(service.status);
|
const stateColor = statusColor(service.status);
|
||||||
|
const isTransient = TRANSIENT_STATUSES.has(service.status);
|
||||||
|
|
||||||
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
||||||
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
|
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${
|
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
|
||||||
selected
|
selected
|
||||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
|
||||||
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg'
|
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
|
||||||
}`}
|
}`}
|
||||||
style={{ minWidth: 220, maxWidth: 280 }}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||||
style={{ background: `${typeColor}10` }}
|
style={{ background: `${typeColor}08` }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||||
@@ -105,22 +83,22 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
|||||||
style={{ background: stateColor }}
|
style={{ background: stateColor }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative p-4">
|
<div className="relative px-3.5 pt-3.5 pb-3">
|
||||||
<div className="flex items-start gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-2.5">
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
|
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||||
selected ? 'ring-2 ring-white/20' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
background: selected ? typeColor : `${typeColor}20`,
|
background: selected ? typeColor : `${typeColor}1c`,
|
||||||
color: selected ? 'white' : typeColor,
|
color: selected ? 'var(--bg-void)' : typeColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderServiceTypeIcon(service.type, 18)}
|
{isTransient ? <Loader2 size={16} className="animate-spin" /> : serviceTypeIcon(service.type, 16)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 pt-0.5">
|
<div className="min-w-0 flex-1">
|
||||||
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
|
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||||
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">
|
{service.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||||
{service.type}
|
{service.type}
|
||||||
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -133,7 +111,7 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50 group/addr">
|
<div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr">
|
||||||
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||||
{publicUrl ? (
|
{publicUrl ? (
|
||||||
<a
|
<a
|
||||||
@@ -141,12 +119,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline"
|
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
|
||||||
>
|
>
|
||||||
{publicUrl.replace(/^https?:\/\//, '')}
|
{publicUrl.replace(/^https?:\/\//, '')}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
||||||
{internalAddr}
|
{internalAddr}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -164,14 +142,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||||
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}>
|
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
|
||||||
{statusLabel(service.status)}
|
{statusLabel(service.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{service.image && (
|
{service.image && (
|
||||||
<span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}>
|
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
|
||||||
{service.image}
|
{service.image}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -181,29 +159,30 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupNode({ data }: NodeProps<GroupNodeType>) {
|
export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(data.title);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
const next = draft.trim() || data.title;
|
||||||
|
setEditing(false);
|
||||||
|
if (next !== data.title) {
|
||||||
|
data.onRename?.(id, next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
|
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
|
||||||
style={{
|
style={{
|
||||||
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
|
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
|
||||||
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30',
|
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(232,49,106,0.03)',
|
|
||||||
opacity: isHovered ? 1 : 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative p-4">
|
<div className="relative p-4">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div
|
<div
|
||||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||||
@@ -214,8 +193,34 @@ export function GroupNode({ data }: NodeProps<GroupNodeType>) {
|
|||||||
>
|
>
|
||||||
<Box size={14} />
|
<Box size={14} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
|
{editing ? (
|
||||||
</div>
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commit();
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setDraft(data.title);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="nodrag h-7 px-2 rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--surface-card)] text-sm font-semibold text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h3
|
||||||
|
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDraft(data.title);
|
||||||
|
setEditing(true);
|
||||||
|
}}
|
||||||
|
title="Double-click to rename"
|
||||||
|
>
|
||||||
|
{data.title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Box, Globe, Database, Terminal, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
export function serviceTypeIcon(type: string, size = 18) {
|
||||||
|
switch (type) {
|
||||||
|
case 'web':
|
||||||
|
return <Globe size={size} />;
|
||||||
|
case 'database':
|
||||||
|
return <Database size={size} />;
|
||||||
|
case 'worker':
|
||||||
|
return <Terminal size={size} />;
|
||||||
|
case 'cron':
|
||||||
|
return <Clock size={size} />;
|
||||||
|
default:
|
||||||
|
return <Box size={size} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serviceTypeColor(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'web':
|
||||||
|
return '#7ab8ff';
|
||||||
|
case 'database':
|
||||||
|
return '#b4e34a';
|
||||||
|
case 'worker':
|
||||||
|
return '#f2c94c';
|
||||||
|
case 'cron':
|
||||||
|
return '#f2994a';
|
||||||
|
default:
|
||||||
|
return '#9295a4';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,14 +44,17 @@ export const DEFAULT_VIEWPORT: CanvasViewport = {
|
|||||||
zoom: 1,
|
zoom: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata {
|
export function createDefaultCanvasMetadata(
|
||||||
|
services: ServiceEntity[],
|
||||||
|
layout?: Map<string, CanvasPoint>,
|
||||||
|
): ProjectCanvasMetadata {
|
||||||
const nodes = services.map((service, index) => {
|
const nodes = services.map((service, index) => {
|
||||||
const col = index % 3;
|
const col = index % 3;
|
||||||
const row = Math.floor(index / 3);
|
const row = Math.floor(index / 3);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
position: {
|
position: layout?.get(service.id) ?? {
|
||||||
x: 70 + col * 260,
|
x: 70 + col * 260,
|
||||||
y: 80 + row * 170,
|
y: 80 + row * 170,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -654,91 +654,27 @@ export function ProjectWorkspacePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canvasView = activeView === 'canvas';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
{/* Header */}
|
{/* Slim workspace bar — tabs + project meta, canvas fills everything below */}
|
||||||
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
|
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 px-4 backdrop-blur-sm">
|
||||||
<div className="w-full px-8 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/projects')}
|
onClick={() => navigate('/projects')}
|
||||||
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={14} />
|
||||||
<span>Projects</span>
|
<span className="hidden sm:inline">Projects</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="w-px h-5 bg-[var(--border-subtle)]" />
|
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
|
||||||
<div>
|
<h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
|
||||||
<h1 className="v-title">{project.name}<span className="v-cursor">_</span></h1>
|
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
|
||||||
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
|
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
|
||||||
</div>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
{/* View tabs */}
|
||||||
{/* Stats */}
|
<nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0">
|
||||||
<div className="hidden md:flex items-center gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
|
|
||||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
|
|
||||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-px h-8 bg-[var(--border-subtle)]" />
|
|
||||||
<LiveIndicator isLive={runningServices > 0} />
|
|
||||||
<div className="w-px h-8 bg-[var(--border-subtle)]" />
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
|
|
||||||
<Clock size={12} />
|
|
||||||
<span>{formatRelative(project.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search / Command Palette */}
|
|
||||||
<button
|
|
||||||
onClick={() => setCommandPaletteOpen(true)}
|
|
||||||
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
|
||||||
>
|
|
||||||
<Search size={14} />
|
|
||||||
<span>Search...</span>
|
|
||||||
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Add Service Button */}
|
|
||||||
{!isDemoMode && (
|
|
||||||
<button
|
|
||||||
onClick={openAddService}
|
|
||||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all"
|
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
<span className="hidden sm:inline">Add Service</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Demo Mode Banner */}
|
|
||||||
{isDemoMode && (
|
|
||||||
<div className="w-full px-8 py-4">
|
|
||||||
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
|
||||||
<Sparkles size={16} />
|
|
||||||
<span>Demo mode active — using sample data for preview</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="w-full px-8 py-6">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-6">
|
|
||||||
{/* Sidebar Navigation */}
|
|
||||||
<aside className="lg:sticky lg:top-6 lg:h-fit">
|
|
||||||
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
|
|
||||||
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
|
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
|
||||||
const active = activeView === item.key;
|
const active = activeView === item.key;
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
@@ -750,35 +686,81 @@ export function ProjectWorkspacePage() {
|
|||||||
next.set('view', item.key);
|
next.set('view', item.key);
|
||||||
return next;
|
return next;
|
||||||
})}
|
})}
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] transition-all whitespace-nowrap ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
|
||||||
active
|
active
|
||||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={13} />
|
||||||
<span className="text-sm font-medium">{item.label}</span>
|
<span className="hidden md:inline">{item.label}</span>
|
||||||
{active && (
|
|
||||||
<div className="hidden lg:block absolute left-0 w-0.5 h-5 bg-[var(--accent-primary)] rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||||
<section className="min-w-0">
|
<div className="hidden xl:block">
|
||||||
{activeView === 'canvas' && (
|
<LiveIndicator isLive={runningServices > 0} />
|
||||||
|
</div>
|
||||||
|
<span className="hidden xl:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||||
|
<Clock size={11} />
|
||||||
|
{formatRelative(project.updatedAt)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCommandPaletteOpen(true)}
|
||||||
|
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<Search size={12} />
|
||||||
|
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
{!isDemoMode && (
|
||||||
|
<button
|
||||||
|
onClick={openAddService}
|
||||||
|
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
<span className="hidden sm:inline">Add Service</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{canvasView ? (
|
||||||
|
<div className="relative flex-1 min-h-0">
|
||||||
<ProjectCanvas
|
<ProjectCanvas
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
services={services}
|
services={services}
|
||||||
variablesByService={variablesByService}
|
variablesByService={variablesByService}
|
||||||
onAddService={openAddService}
|
onAddService={openAddService}
|
||||||
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
|
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
|
||||||
|
readOnly={isDemoMode}
|
||||||
/>
|
/>
|
||||||
|
{isDemoMode && (
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 pointer-events-none">
|
||||||
|
<span className="canvas-pill flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-[var(--warning)]">
|
||||||
|
<Sparkles size={12} />
|
||||||
|
Demo data
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{isDemoMode && (
|
||||||
|
<div className="w-full px-8 pt-6">
|
||||||
|
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
||||||
|
<Sparkles size={16} />
|
||||||
|
<span>Demo mode active - using sample data for preview</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full px-8 py-6">
|
||||||
|
<section className="min-w-0">
|
||||||
{activeView === 'observability' && (
|
{activeView === 'observability' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Health Summary */}
|
{/* Health Summary */}
|
||||||
@@ -965,6 +947,7 @@ export function ProjectWorkspacePage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create Service Dialog */}
|
{/* Create Service Dialog */}
|
||||||
{!isDemoMode && (
|
{!isDemoMode && (
|
||||||
|
|||||||
@@ -171,9 +171,13 @@ export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMet
|
|||||||
canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
|
canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata {
|
export function loadCanvasMetadata(
|
||||||
|
projectId: string,
|
||||||
|
services: ServiceEntity[],
|
||||||
|
layout?: Map<string, { x: number; y: number }>,
|
||||||
|
): ProjectCanvasMetadata {
|
||||||
const key = canvasStorageKey(projectId);
|
const key = canvasStorageKey(projectId);
|
||||||
const fallback = createDefaultCanvasMetadata(services);
|
const fallback = createDefaultCanvasMetadata(services, layout);
|
||||||
const serviceIds = new Set(services.map((service) => service.id));
|
const serviceIds = new Set(services.map((service) => service.id));
|
||||||
|
|
||||||
const raw = canvasStorage()?.getItem(key);
|
const raw = canvasStorage()?.getItem(key);
|
||||||
@@ -195,7 +199,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
|
|||||||
const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
|
const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
|
||||||
const normalizedNodes: CanvasNodeLayout[] = [];
|
const normalizedNodes: CanvasNodeLayout[] = [];
|
||||||
|
|
||||||
for (const [index, service] of services.entries()) {
|
// Services added after the layout was saved park in a trailing column to
|
||||||
|
// the right of everything placed so far, instead of overlapping nodes the
|
||||||
|
// user already arranged.
|
||||||
|
const placedXs = parsed.nodes.map((node) => node.position.x);
|
||||||
|
let parkedRow = 0;
|
||||||
|
const parkedX = placedXs.length > 0 ? Math.max(...placedXs) + 320 : 70;
|
||||||
|
|
||||||
|
for (const service of services) {
|
||||||
const existing = nodeMap.get(service.id);
|
const existing = nodeMap.get(service.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
normalizedNodes.push({
|
normalizedNodes.push({
|
||||||
@@ -206,15 +217,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const col = index % 3;
|
const parked = layout?.get(service.id);
|
||||||
const row = Math.floor(index / 3);
|
|
||||||
normalizedNodes.push({
|
normalizedNodes.push({
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
position: {
|
position: parked ?? { x: parkedX, y: 80 + parkedRow * 160 },
|
||||||
x: 70 + col * 260,
|
|
||||||
y: 80 + row * 170,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (!parked) {
|
||||||
|
parkedRow += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenEdgeIds = new Set<string>();
|
const seenEdgeIds = new Set<string>();
|
||||||
|
|||||||
@@ -634,13 +634,13 @@ textarea {
|
|||||||
.edge-premium .react-flow__edge-path {
|
.edge-premium .react-flow__edge-path {
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
filter: drop-shadow(0 0 4px rgba(156, 126, 240, 0.3));
|
filter: drop-shadow(0 0 4px rgba(180, 227, 74, 0.25));
|
||||||
transition: filter 0.2s ease, stroke-width 0.2s ease;
|
transition: filter 0.2s ease, stroke-width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edge-premium:hover .react-flow__edge-path {
|
.edge-premium:hover .react-flow__edge-path {
|
||||||
filter: drop-shadow(0 0 8px rgba(156, 126, 240, 0.5));
|
filter: drop-shadow(0 0 8px rgba(180, 227, 74, 0.45));
|
||||||
stroke-width: 3px;
|
stroke-width: 2.6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -1685,3 +1685,86 @@ textarea {
|
|||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
.docs-article img { max-width: 100%; border-radius: var(--radius-md); }
|
.docs-article img { max-width: 100%; border-radius: var(--radius-md); }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CANVAS — fullscreen workspace graph
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Floating glass pills (zoom controls, env filter, hints) */
|
||||||
|
.canvas-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(19, 21, 24, 0.82);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-pill-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-pill-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* React Flow pane affordances */
|
||||||
|
.react-flow__pane {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.react-flow__pane.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.react-flow__node.selectable:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marquee selection tint */
|
||||||
|
.react-flow__selection {
|
||||||
|
background: var(--accent-primary-soft);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MiniMap — match surface card, dimmed nodes */
|
||||||
|
.react-flow__minimap {
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.react-flow__minimap-svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inspector panel entrance */
|
||||||
|
.canvas-inspector {
|
||||||
|
animation: canvas-panel-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes canvas-panel-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.canvas-inspector {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 145 KiB |
Reference in new issue
Block a user