feat: full feature set - filters, context menu, patchbay, position persistence

Backend:
- GET/PUT /api/positions - server-side node position persistence (~/.config/pwweb/)
- GET/PUT /api/patchbay - save/load connection snapshots
- POST /api/patchbay/apply - apply saved connections

Frontend (custom SVG canvas):
- Right-click wire to disconnect (context menu)
- Port type filter toolbar (audio/midi/video/other toggles)
- Save/Load patchbay buttons
- Node positions persisted to localStorage + server
- Node border color by mode (green=output, red=input)
- Type indicator in node header [audio] [midi]
- Selected wire highlight (white, thicker)
- Select wire + Delete to disconnect
- Drag output port to input port to connect
- Pan (drag bg) and zoom (scroll wheel)
- Filtered ports shown as dim dots when hidden
This commit is contained in:
joren
2026-03-29 22:45:10 +02:00
parent f8c57fbdd3
commit 77e2fdca14
3 changed files with 388 additions and 286 deletions

View File

@@ -8,119 +8,51 @@
} from '../lib/stores';
import type { Node, Port, Link } from '../lib/types';
// Viewport state
// Viewport
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
let svgEl: SVGElement | null = null;
// Interaction state
let dragging = $state<{ type: string; startX: number; startY: number; origX: number; origY: number; nodeId?: string } | null>(null);
let connecting = $state<{ outputPortId: number; outputX: number; outputY: number; mouseX: number; mouseY: number; portType: string } | null>(null);
let hoveredPort = $state<number | null>(null);
let selectedEdge = $state<string | null>(null);
let svgEl: SVGElement | null = null;
let contextMenu = $state<{ x: number; y: number; outputPortId: number; inputPortId: number } | null>(null);
// Node positions
const POS_KEY = 'pwweb_pos';
function loadPos(): Record<string, { x: number; y: number }> {
try { return JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { return {}; }
}
function savePos(pos: Record<string, { x: number; y: number }>) {
try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch {}
}
let nodePositions = $state(loadPos());
// Filters
let showAudio = $state(true);
let showMidi = $state(true);
let showVideo = $state(true);
let showOther = $state(true);
// Layout defaults
function getDefaultPositions(n: Node[]): Record<string, { x: number; y: number }> {
const out = n.filter(nd => nd.mode === 'output');
const inp = n.filter(nd => nd.mode === 'input');
const other = n.filter(nd => nd.mode !== 'output' && nd.mode !== 'input');
const result: Record<string, { x: number; y: number }> = {};
let i = 0;
for (const nd of out) {
if (!result[nd.id]) result[nd.id] = { x: 0, y: (i % 8) * 100 };
i++;
}
i = 0;
for (const nd of other) {
if (!result[nd.id]) result[nd.id] = { x: 320, y: (i % 8) * 100 };
i++;
}
i = 0;
for (const nd of inp) {
if (!result[nd.id]) result[nd.id] = { x: 640, y: (i % 8) * 100 };
i++;
}
return result;
}
// Patchbay
let patchbayName = $state('');
// Compute layout
let graphNodes = $derived.by(() => {
const n = $nodes;
const p = $ports;
const portMap = new Map<number, Port>();
for (const port of p) portMap.set(port.id, port);
// Positions
let nodePositions = $state<Record<string, { x: number; y: number }>>({});
const POS_KEY = 'pwweb_positions';
const defaults = getDefaultPositions(n);
const pos = { ...defaults, ...nodePositions };
return n.map(nd => {
const ndPorts = nd.port_ids.map(pid => portMap.get(pid)).filter(Boolean) as Port[];
const inPorts = ndPorts.filter(pp => pp.mode === 'input');
const outPorts = ndPorts.filter(pp => pp.mode === 'output');
const nodePos = pos[String(nd.id)] || { x: 0, y: 0 };
// Compute node dimensions
const maxPorts = Math.max(inPorts.length, outPorts.length, 1);
const headerH = 24;
const portH = 18;
const height = headerH + maxPorts * portH + 6;
const width = 200;
// Compute port positions
const portPositions = new Map<number, { x: number; y: number }>();
let yi = headerH;
for (const port of inPorts) {
portPositions.set(port.id, { x: nodePos.x, y: nodePos.y + yi + portH / 2 });
yi += portH;
function loadPositions() {
// Try localStorage first
try { nodePositions = JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { nodePositions = {}; }
// Also load from server
fetch('/api/positions').then(r => r.json()).then(data => {
if (data && typeof data === 'object' && !data.error) {
nodePositions = { ...data, ...nodePositions };
}
let yo = headerH;
for (const port of outPorts) {
portPositions.set(port.id, { x: nodePos.x + width, y: nodePos.y + yo + portH / 2 });
yo += portH;
}
return {
...nd,
x: nodePos.x,
y: nodePos.y,
width,
height,
inPorts,
outPorts,
portPositions,
};
});
});
let graphLinks = $derived.by(() => {
const l = $links;
const p = $ports;
const portMap = new Map<number, Port>();
for (const port of p) portMap.set(port.id, port);
return l.map(link => {
const outPort = portMap.get(link.output_port_id);
const inPort = portMap.get(link.input_port_id);
return { ...link, outPort, inPort };
}).filter(l => l.outPort && l.inPort) as Array<Link & { outPort: Port; inPort: Port }>;
});
// Port positions lookup across all nodes
function getPortPos(portId: number): { x: number; y: number } | null {
for (const node of graphNodes) {
const pos = node.portPositions.get(portId);
if (pos) return pos;
}
return null;
}).catch(() => {});
}
function savePositions() {
try { localStorage.setItem(POS_KEY, JSON.stringify(nodePositions)); } catch {}
fetch('/api/positions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nodePositions),
}).catch(() => {});
}
// Helpers
function portColor(pt: string): string {
switch (pt) {
case 'audio': return '#4a9';
@@ -135,75 +67,137 @@
return idx >= 0 ? name.substring(idx + 1) : name;
}
// Bezier curve between two points
function isPortVisible(pt: string): boolean {
switch (pt) {
case 'audio': return showAudio;
case 'midi': return showMidi;
case 'video': return showVideo;
default: return showOther;
}
}
function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.abs(x2 - x1) * 0.5;
const dx = Math.max(Math.abs(x2 - x1) * 0.5, 30);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
// Mouse events
function svgPoint(e: MouseEvent): { x: number; y: number } {
if (!svgEl) return { x: e.clientX, y: e.clientY };
const pt = (svgEl as any).createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
pt.x = e.clientX; pt.y = e.clientY;
const ctm = (svgEl as SVGGraphicsElement).getScreenCTM();
if (!ctm) return { x: e.clientX, y: e.clientY };
const svgP = pt.matrixTransform(ctm.inverse());
return { x: svgP.x, y: svgP.y };
}
// Computed layout
let graphNodes = $derived.by(() => {
const n = $nodes;
const p = $ports;
const portMap = new Map<number, Port>();
for (const port of p) portMap.set(port.id, port);
const out = n.filter(nd => nd.mode === 'output');
const inp = n.filter(nd => nd.mode === 'input');
const other = n.filter(nd => nd.mode !== 'output' && nd.mode !== 'input');
const defaults: Record<string, { x: number; y: number }> = {};
let i = 0;
for (const nd of out) { defaults[String(nd.id)] = { x: 0, y: (i % 10) * 90 }; i++; }
i = 0;
for (const nd of other) { defaults[String(nd.id)] = { x: 280, y: (i % 10) * 90 }; i++; }
i = 0;
for (const nd of inp) { defaults[String(nd.id)] = { x: 560, y: (i % 10) * 90 }; i++; }
const pos = { ...defaults, ...nodePositions };
return n.map(nd => {
const ndPorts = nd.port_ids.map(pid => portMap.get(pid)).filter(Boolean) as Port[];
const inPorts = ndPorts.filter(pp => pp.mode === 'input' && isPortVisible(pp.port_type));
const outPorts = ndPorts.filter(pp => pp.mode === 'output' && isPortVisible(pp.port_type));
const allInPorts = ndPorts.filter(pp => pp.mode === 'input');
const allOutPorts = ndPorts.filter(pp => pp.mode === 'output');
const nodePos = pos[String(nd.id)] || { x: 0, y: 0 };
const headerH = 22;
const portH = 16;
const maxPorts = Math.max(allInPorts.length, allOutPorts.length, 1);
const height = headerH + maxPorts * portH + 4;
const width = 220;
const portPositions = new Map<number, { x: number; y: number }>();
let yi = headerH;
for (const port of allInPorts) {
portPositions.set(port.id, { x: nodePos.x, y: nodePos.y + yi + portH / 2 });
yi += portH;
}
let yo = headerH;
for (const port of allOutPorts) {
portPositions.set(port.id, { x: nodePos.x + width, y: nodePos.y + yo + portH / 2 });
yo += portH;
}
return { ...nd, x: nodePos.x, y: nodePos.y, width, height, inPorts: allInPorts, outPorts: allOutPorts, portPositions };
});
});
let graphLinks = $derived.by(() => {
const l = $links;
const p = $ports;
const portMap = new Map<number, Port>();
for (const port of p) portMap.set(port.id, port);
return l.map(link => {
const outPort = portMap.get(link.output_port_id);
const inPort = portMap.get(link.input_port_id);
return { ...link, outPort, inPort };
}).filter(l => l.outPort && l.inPort && isPortVisible(l.outPort!.port_type)) as Array<Link & { outPort: Port; inPort: Port }>;
});
function getPortPos(portId: number): { x: number; y: number } | null {
for (const node of graphNodes) {
const pos = node.portPositions.get(portId);
if (pos) return pos;
}
return null;
}
// Mouse handlers
function onMouseDown(e: MouseEvent) {
contextMenu = null;
if (e.button === 2) return; // right-click handled by oncontextmenu
const pt = svgPoint(e);
const target = e.target as HTMLElement;
// Check if clicking on a port circle
if (target.classList.contains('port-circle')) {
const portId = Number(target.dataset.portId);
const port = $portById.get(portId);
if (port && port.mode === 'output') {
const pos = getPortPos(portId);
if (pos) {
connecting = {
outputPortId: portId,
outputX: pos.x,
outputY: pos.y,
mouseX: pt.x,
mouseY: pt.y,
portType: port.port_type,
};
connecting = { outputPortId: portId, outputX: pos.x, outputY: pos.y, mouseX: pt.x, mouseY: pt.y, portType: port.port_type };
}
}
return;
}
// Check if clicking on a node header (to drag)
if (target.classList.contains('node-header') || target.closest('.node-group')) {
const nodeGroup = target.closest('.node-group') as HTMLElement;
if (nodeGroup) {
const nodeId = nodeGroup.dataset.nodeId!;
const nd = graphNodes.find(n => String(n.id) === nodeId);
if (nd) {
dragging = {
type: 'node',
startX: pt.x,
startY: pt.y,
origX: nd.x,
origY: nd.y,
nodeId,
};
dragging = { type: 'node', startX: pt.x, startY: pt.y, origX: nd.x, origY: nd.y, nodeId };
}
return;
}
}
// Check if clicking on an edge
if (target.classList.contains('edge-path')) {
selectedEdge = target.dataset.edgeId || null;
return;
}
// Click on background: pan
selectedEdge = null;
dragging = { type: 'pan', startX: e.clientX, startY: e.clientY, origX: viewBox.x, origY: viewBox.y };
}
@@ -213,26 +207,19 @@
const pt = svgPoint(e);
if (connecting) {
connecting.mouseX = pt.x;
connecting.mouseY = pt.y;
connecting = { ...connecting, mouseX: pt.x, mouseY: pt.y };
return;
}
if (!dragging) return;
if (dragging.type === 'node' && dragging.nodeId) {
const dx = pt.x - dragging.startX;
const dy = pt.y - dragging.startY;
nodePositions[dragging.nodeId] = {
x: dragging.origX + dx,
y: dragging.origY + dy,
};
nodePositions = nodePositions; // trigger reactivity
nodePositions[dragging.nodeId] = { x: dragging.origX + dx, y: dragging.origY + dy };
} else if (dragging.type === 'pan') {
const dx = (e.clientX - dragging.startX) * (viewBox.w / (svgEl?.clientWidth || 1));
const dy = (e.clientY - dragging.startY) * (viewBox.h / (svgEl?.clientHeight || 1));
viewBox.x = dragging.origX - dx;
viewBox.y = dragging.origY - dy;
viewBox = { ...viewBox, x: dragging.origX - dx, y: dragging.origY - dy };
}
}
@@ -248,11 +235,9 @@
}
connecting = null;
}
if (dragging?.type === 'node' && dragging.nodeId) {
savePos(nodePositions);
savePositions();
}
dragging = null;
}
@@ -260,47 +245,83 @@
e.preventDefault();
const pt = svgPoint(e);
const factor = e.deltaY > 0 ? 1.1 : 0.9;
const nw = viewBox.w * factor;
const nh = viewBox.h * factor;
viewBox.x = pt.x - (pt.x - viewBox.x) * factor;
viewBox.y = pt.y - (pt.y - viewBox.y) * factor;
viewBox.w = nw;
viewBox.h = nh;
viewBox = {
x: pt.x - (pt.x - viewBox.x) * factor,
y: pt.y - (pt.y - viewBox.y) * factor,
w: viewBox.w * factor,
h: viewBox.h * factor,
};
}
function onEdgeDblClick(e: MouseEvent) {
function onContextMenu(e: MouseEvent) {
e.preventDefault();
const target = e.target as HTMLElement;
if (target.classList.contains('edge-path')) {
const edgeId = target.dataset.edgeId;
const link = $links.find(l => String(l.id) === edgeId);
if (link) {
disconnectPorts(link.output_port_id, link.input_port_id);
selectedEdge = edgeId || null;
contextMenu = { x: e.clientX, y: e.clientY, outputPortId: link.output_port_id, inputPortId: link.input_port_id };
}
}
}
function doDisconnect() {
if (!contextMenu) return;
disconnectPorts(contextMenu.outputPortId, contextMenu.inputPortId);
contextMenu = null;
}
function onKey(e: KeyboardEvent) {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdge) {
const link = $links.find(l => String(l.id) === selectedEdge);
if (link) {
disconnectPorts(link.output_port_id, link.input_port_id);
selectedEdge = null;
}
if (link) { disconnectPorts(link.output_port_id, link.input_port_id); selectedEdge = null; }
}
}
onMount(() => { initGraph(); });
// Patchbay
async function savePatchbay() {
const name = patchbayName.trim() || new Date().toISOString().replace(/[:.]/g, '-');
const data = { name, connections: $links.map(l => ({ output_port_id: l.output_port_id, input_port_id: l.input_port_id })) };
await fetch('/api/patchbay', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
patchbayName = name;
}
async function loadPatchbay() {
try {
const res = await fetch('/api/patchbay');
const data = await res.json();
if (data.connections && data.connections.length > 0) {
for (const conn of data.connections) {
await fetch('/api/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(conn) });
}
}
} catch {}
}
onMount(() => { initGraph(); loadPositions(); });
onDestroy(() => { destroyGraph(); });
</script>
<svelte:window onkeydown={onKey} />
<div class="wrap">
<div class="bar">
<div class="toolbar">
<span class="dot" class:on={$connected}></span>
<span>{$connected ? 'Connected' : 'Disconnected'}</span>
<span class="st">{$nodes.length} nodes | {$ports.length} ports | {$links.length} links</span>
<span class="help">Drag port->port to connect. Dbl-click wire to disconnect. Scroll to zoom. Drag bg to pan.</span>
<span class="sep"></span>
<label class="filter" class:active={showAudio}><input type="checkbox" bind:checked={showAudio} /> <span class="fc audio"></span> Audio</label>
<label class="filter" class:active={showMidi}><input type="checkbox" bind:checked={showMidi} /> <span class="fc midi"></span> MIDI</label>
<label class="filter" class:active={showVideo}><input type="checkbox" bind:checked={showVideo} /> <span class="fc video"></span> Video</label>
<label class="filter" class:active={showOther}><input type="checkbox" bind:checked={showOther} /> <span class="fc other"></span> Other</label>
<span class="sep"></span>
<button onclick={savePatchbay} title="Save current connections">Save Patchbay</button>
<button onclick={loadPatchbay} title="Load saved connections">Load Patchbay</button>
<input class="pbname" bind:value={patchbayName} placeholder="patchbay name" />
<span class="stats">{$nodes.length} nodes | {$ports.length} ports | {$links.length} links</span>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -311,9 +332,15 @@
onmousemove={onMouseMove}
onmouseup={onMouseUp}
onwheel={onWheel}
ondblclick={onEdgeDblClick}
oncontextmenu={onContextMenu}
class="canvas"
>
<defs>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="1" dy="1" stdDeviation="2" flood-opacity="0.4" />
</filter>
</defs>
<!-- Edges -->
{#each graphLinks as link (link.id)}
{@const outPos = getPortPos(link.output_port_id)}
@@ -323,15 +350,16 @@
<path
d={bezierPath(outPos.x, outPos.y, inPos.x, inPos.y)}
stroke={selectedEdge === String(link.id) ? '#fff' : color}
stroke-width={selectedEdge === String(link.id) ? 3 : 2}
stroke-width={selectedEdge === String(link.id) ? 3.5 : 2}
fill="none"
class="edge-path"
data-edge-id={String(link.id)}
stroke-linecap="round"
/>
{/if}
{/each}
<!-- Connecting line (while dragging) -->
<!-- Connecting line -->
{#if connecting}
<path
d={bezierPath(connecting.outputX, connecting.outputY, connecting.mouseX, connecting.mouseY)}
@@ -339,158 +367,138 @@
stroke-width="2"
stroke-dasharray="6 3"
fill="none"
opacity="0.8"
opacity="0.7"
/>
{/if}
<!-- Nodes -->
{#each graphNodes as nd (nd.id)}
{@const headerColor = nd.mode === 'output' ? '#2a3a2a' : nd.mode === 'input' ? '#3a2a2a' : '#2a2a3a'}
{@const borderColor = nd.mode === 'output' ? '#4a9' : nd.mode === 'input' ? '#a44' : '#555'}
{@const isSource = nd.mode === 'output'}
{@const isSink = nd.mode === 'input'}
{@const bg = isSource ? '#1a2a1a' : isSink ? '#2a1a1a' : '#1e1e2e'}
{@const border = isSource ? '#4a9' : isSink ? '#a44' : '#555'}
{@const headerBg = isSource ? '#263826' : isSink ? '#382626' : '#262638'}
<g class="node-group" data-node-id={String(nd.id)}>
<!-- Background -->
<rect
x={nd.x} y={nd.y}
width={nd.width} height={nd.height}
rx="5" ry="5"
fill="#1e1e2e"
stroke={borderColor}
stroke-width="1"
/>
<!-- Header -->
<rect
x={nd.x} y={nd.y}
width={nd.width} height="24"
rx="5" ry="5"
fill={headerColor}
class="node-header"
/>
<rect
x={nd.x} y={nd.y + 18}
width={nd.width} height="6"
fill={headerColor}
/>
<text
x={nd.x + 6} y={nd.y + 16}
font-size="11" font-family="monospace"
fill="#ddd" font-weight="bold"
>
{nd.name.length > 28 ? nd.name.substring(0, 25) + '...' : nd.name}
<g class="node-group" data-node-id={String(nd.id)} filter="url(#shadow)">
<rect x={nd.x} y={nd.y} width={nd.width} height={nd.height} rx="4" fill={bg} stroke={border} stroke-width="1" />
<rect x={nd.x} y={nd.y} width={nd.width} height="22" rx="4" fill={headerBg} />
<rect x={nd.x} y={nd.y + 16} width={nd.width} height="6" fill={headerBg} />
<text x={nd.x + 6} y={nd.y + 15} font-size="10" font-family="monospace" fill="#ddd" font-weight="bold">
{nd.nick || nd.name}
</text>
<text x={nd.x + nd.width - 6} y={nd.y + 15} font-size="9" font-family="monospace" fill="#777" text-anchor="end">
[{nd.node_type}]
</text>
<!-- Input ports (left side) -->
{#each nd.inPorts as port, i (port.id)}
{@const py = nd.y + 24 + i * 18 + 9}
<circle
cx={nd.x} cy={py}
r="5"
fill={portColor(port.port_type)}
stroke="#fff"
stroke-width={hoveredPort === port.id ? 2 : 0.5}
class="port-circle"
data-port-id={String(port.id)}
onmouseenter={() => { hoveredPort = port.id; }}
onmouseleave={() => { hoveredPort = null; }}
/>
<text
x={nd.x + 10} y={py + 4}
font-size="10" font-family="monospace"
fill="#aaa"
>{shortName(port.name)}</text>
{@const py = nd.y + 22 + i * 16 + 8}
{#if isPortVisible(port.port_type)}
<circle
cx={nd.x} cy={py} r="4"
fill={portColor(port.port_type)}
stroke={hoveredPort === port.id ? '#fff' : '#333'}
stroke-width={hoveredPort === port.id ? 2 : 0.5}
class="port-circle"
data-port-id={String(port.id)}
onmouseenter={() => { hoveredPort = port.id; }}
onmouseleave={() => { hoveredPort = null; }}
/>
<text x={nd.x + 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999">
{shortName(port.name)}
</text>
{:else}
<circle cx={nd.x} cy={py} r="3" fill="#333" />
{/if}
{/each}
<!-- Output ports (right side) -->
{#each nd.outPorts as port, i (port.id)}
{@const py = nd.y + 24 + i * 18 + 9}
<text
x={nd.x + nd.width - 10} y={py + 4}
font-size="10" font-family="monospace"
fill="#aaa"
text-anchor="end"
>{shortName(port.name)}</text>
<circle
cx={nd.x + nd.width} cy={py}
r="5"
fill={portColor(port.port_type)}
stroke="#fff"
stroke-width={hoveredPort === port.id ? 2 : 0.5}
class="port-circle"
data-port-id={String(port.id)}
onmouseenter={() => { hoveredPort = port.id; }}
onmouseleave={() => { hoveredPort = null; }}
/>
{@const py = nd.y + 22 + i * 16 + 8}
{#if isPortVisible(port.port_type)}
<text x={nd.x + nd.width - 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999" text-anchor="end">
{shortName(port.name)}
</text>
<circle
cx={nd.x + nd.width} cy={py} r="4"
fill={portColor(port.port_type)}
stroke={hoveredPort === port.id ? '#fff' : '#333'}
stroke-width={hoveredPort === port.id ? 2 : 0.5}
class="port-circle"
data-port-id={String(port.id)}
onmouseenter={() => { hoveredPort = port.id; }}
onmouseleave={() => { hoveredPort = null; }}
/>
{:else}
<circle cx={nd.x + nd.width} cy={py} r="3" fill="#333" />
{/if}
{/each}
</g>
{/each}
</svg>
{#if contextMenu}
<div class="ctx" style="left:{contextMenu.x}px;top:{contextMenu.y}px" role="menu">
<button onclick={doDisconnect}>Disconnect</button>
</div>
{/if}
</div>
<style>
.wrap {
width: 100%;
height: 100vh;
background: #1a1a2e;
position: relative;
overflow: hidden;
}
.wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; }
.canvas { width: 100%; height: 100%; display: block; cursor: default; }
.canvas {
width: 100%;
height: 100%;
display: block;
cursor: default;
}
.bar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
background: rgba(20, 20, 30, 0.95);
.toolbar {
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
display: flex; align-items: center; gap: 8px;
padding: 4px 12px;
background: rgba(16, 16, 24, 0.97);
border-bottom: 1px solid #333;
font-size: 12px;
color: #aaa;
font-family: monospace;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #a44;
display: inline-block;
font-size: 11px; color: #aaa; font-family: monospace;
}
.dot { width: 7px; height: 7px; border-radius: 50%; background: #a44; flex-shrink: 0; }
.dot.on { background: #4a9; }
.sep { width: 1px; height: 16px; background: #444; margin: 0 4px; }
.st { margin-left: auto; color: #666; }
.help { color: #555; font-size: 11px; margin-left: 16px; }
.port-circle {
cursor: crosshair;
.filter {
display: flex; align-items: center; gap: 3px; cursor: pointer;
padding: 2px 5px; border-radius: 3px; font-size: 10px;
}
.port-circle:hover {
filter: brightness(1.5);
.filter:hover { background: rgba(255,255,255,0.05); }
.filter input { display: none; }
.filter .fc { width: 8px; height: 8px; border-radius: 2px; }
.filter .fc.audio { background: #4a9; }
.filter .fc.midi { background: #a44; }
.filter .fc.video { background: #49a; }
.filter .fc.other { background: #999; }
.filter:not(.active) { opacity: 0.4; }
.filter:not(.active) .fc { background: #555 !important; }
.toolbar button {
padding: 2px 8px; background: #2a2a3e; border: 1px solid #444;
color: #aaa; font-size: 10px; cursor: pointer; border-radius: 3px; font-family: monospace;
}
.toolbar button:hover { background: #3a3a4e; }
.pbname {
width: 100px; padding: 2px 6px; background: #1a1a2e; border: 1px solid #444;
color: #aaa; font-size: 10px; border-radius: 3px; font-family: monospace;
}
.edge-path {
cursor: pointer;
pointer-events: stroke;
}
.edge-path:hover {
stroke-width: 4;
}
.stats { margin-left: auto; color: #555; }
.node-header {
cursor: grab;
.ctx {
position: fixed; z-index: 100;
background: #2a2a3e; border: 1px solid #555;
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
}
.node-group:hover rect {
filter: brightness(1.1);
.ctx button {
display: block; width: 100%; padding: 6px 20px;
background: none; border: none; color: #ccc;
font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
}
.ctx button:hover { background: #444; }
.port-circle { cursor: crosshair; }
.port-circle:hover { filter: brightness(1.5); }
.edge-path { cursor: pointer; pointer-events: stroke; }
.edge-path:hover { stroke-width: 4; }
</style>

View File

@@ -4,9 +4,40 @@
#include <cstdio>
#include <algorithm>
#include <condition_variable>
#include <fstream>
#include <sys/stat.h>
#include <pwd.h>
using namespace pwgraph;
// ============================================================================
// File I/O helpers
// ============================================================================
std::string WebServer::dataDir() const {
const char *home = getenv("HOME");
if (!home) {
auto *pw = getpwuid(getuid());
if (pw) home = pw->pw_dir;
}
std::string dir = (home ? home : "/tmp");
dir += "/.config/pwweb";
mkdir(dir.c_str(), 0755);
return dir;
}
std::string WebServer::readFile(const std::string &path) const {
std::ifstream f(path);
if (!f.is_open()) return "{}";
return std::string((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
}
void WebServer::writeFile(const std::string &path, const std::string &data) const {
std::ofstream f(path);
if (f.is_open()) f << data;
}
// ============================================================================
// JSON serialization helpers
// ============================================================================
@@ -309,6 +340,69 @@ void WebServer::setupRoutes() {
};
m_http.Options("/api/connect", cors_handler);
m_http.Options("/api/disconnect", cors_handler);
m_http.Options("/api/positions", cors_handler);
m_http.Options("/api/patchbay", cors_handler);
// Positions persistence: GET /api/positions
m_http.Get("/api/positions", [this](const httplib::Request &, httplib::Response &res) {
std::string path = dataDir() + "/positions.json";
res.set_content(readFile(path), "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Positions persistence: PUT /api/positions
m_http.Put("/api/positions", [this](const httplib::Request &req, httplib::Response &res) {
std::string path = dataDir() + "/positions.json";
writeFile(path, req.body);
res.set_content("{\"ok\":true}", "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Patchbay: GET /api/patchbay
m_http.Get("/api/patchbay", [this](const httplib::Request &, httplib::Response &res) {
std::string path = dataDir() + "/patchbay.json";
res.set_content(readFile(path), "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Patchbay: PUT /api/patchbay (save current connections as a named snapshot)
m_http.Put("/api/patchbay", [this](const httplib::Request &req, httplib::Response &res) {
std::string path = dataDir() + "/patchbay.json";
writeFile(path, req.body);
res.set_content("{\"ok\":true}", "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Patchbay: POST /api/patchbay/apply (apply saved connections to current graph)
m_http.Post("/api/patchbay/apply", [this](const httplib::Request &req, httplib::Response &res) {
// req.body is a JSON array of {output_port_id, input_port_id} pairs
// We just parse it and connect each pair
// Simple parsing: find all pairs
std::string body = req.body;
size_t pos = 0;
int connected = 0;
while (pos < body.size()) {
uint32_t out_id = 0, in_id = 0;
int chars = 0;
if (sscanf(body.c_str() + pos,
"{\"output_port_id\":%u,\"input_port_id\":%u}%n",
&out_id, &in_id, &chars) == 2 ||
sscanf(body.c_str() + pos,
"{\"output_port_id\":%u, \"input_port_id\":%u}%n",
&out_id, &in_id, &chars) == 2)
{
if (m_engine.connectPorts(out_id, in_id))
connected++;
}
pos = body.find('{', pos + 1);
if (pos == std::string::npos) break;
}
if (connected > 0) broadcastGraph();
char buf[64];
snprintf(buf, sizeof(buf), "{\"ok\":true,\"connected\":%d}", connected);
res.set_content(buf, "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
}
// end of web_server.cpp

View File

@@ -6,7 +6,7 @@
#include <mutex>
#include <set>
#include <atomic>
#include <functional>
#include <string>
namespace pwgraph {
@@ -17,13 +17,14 @@ public:
bool start();
void stop();
// Broadcast graph update to all connected SSE clients
void broadcastGraph();
private:
void setupRoutes();
std::string buildGraphJson() const;
std::string dataDir() const;
std::string readFile(const std::string &path) const;
void writeFile(const std::string &path, const std::string &data) const;
GraphEngine &m_engine;
int m_port;
@@ -31,7 +32,6 @@ private:
std::thread m_thread;
std::atomic<bool> m_running;
// SSE clients
mutable std::mutex m_sse_mutex;
std::set<httplib::DataSink *> m_sse_clients;
};