audiorouter-dashboard 0.1.5

HTTP/SSE dashboard API and static-file host for audiorouter
import {
  ReactFlowProvider,
  type OnConnect,
  useReactFlow,
  useNodesInitialized,
} from "@xyflow/react";
import {
  Background,
  BackgroundVariant,
  ControlButton,
  Controls,
  ReactFlow,
  type Edge,
  type Node,
  type OnEdgesChange,
  type OnNodesChange,
} from "@xyflow/react";
import { useEffect } from "react";
import "@xyflow/react/dist/style.css";
import { DeviceNode } from "./DeviceNode";
import { RouteUpdateProvider, type RouteUpdateFn } from "./route-update-context";
import { RouteEdge } from "./RouteEdge";

const nodeTypes = { device: DeviceNode };
const edgeTypes = { route: RouteEdge };
const proOptions = { hideAttribution: true };

interface Props {
  nodes: Node[];
  edges: Edge[];
  onNodesChange: OnNodesChange;
  onEdgesChange: OnEdgesChange;
  onConnect: OnConnect;
  onNodeClick: (nodeId: string) => void;
  onEdgeClick: (edgeId: string) => void;
  onPaneClick: () => void;
  onLayout: () => void;
  layoutVersion: number;
  isInteractive: boolean;
  onToggleInteractive: () => void;
  onUpdateRoute: RouteUpdateFn;
}

function FlowCanvasInner({
  nodes,
  edges,
  onNodesChange,
  onEdgesChange,
  onConnect,
  onNodeClick,
  onEdgeClick,
  onPaneClick,
  onLayout,
  layoutVersion,
  isInteractive,
  onToggleInteractive,
  onUpdateRoute,
}: Props) {
  const { fitView } = useReactFlow();
  const nodesInitialized = useNodesInitialized();

  // Fire fitView after React has committed the new node positions.
  // useEffect runs post-commit; the extra rAF waits for React Flow's
  // own internal recalc before we ask for the bounding box.
  useEffect(() => {
    if (layoutVersion === 0) return;
    const id = requestAnimationFrame(() => {
      void fitView({ duration: 0, padding: 0.25 });
    });
    return () => cancelAnimationFrame(id);
  }, [layoutVersion, fitView]);

  // After initial node measurement, the invisible interaction paths for edges
  // are recalculated. Without this, edges can't be clicked until a node moves.
  useEffect(() => {
    if (!nodesInitialized) return;
    const id = requestAnimationFrame(() => {
      void fitView({ duration: 0, padding: 0.25 });
    });
    return () => cancelAnimationFrame(id);
  }, [nodesInitialized, fitView]);

  const handleLayout = () => {
    onLayout();
  };

  return (
    <RouteUpdateProvider value={onUpdateRoute}>
      <div data-locked={!isInteractive || undefined} className="h-full w-full">
        <ReactFlow
          nodes={nodes}
          edges={edges}
          nodeTypes={nodeTypes}
          edgeTypes={edgeTypes}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          onNodeClick={(_, node) => onNodeClick(node.id)}
          onEdgeClick={(_, edge) => onEdgeClick(edge.id)}
          onPaneClick={onPaneClick}
          selectNodesOnDrag={false}
          nodesDraggable={isInteractive}
          nodesConnectable={isInteractive}
          elementsSelectable={true}
          proOptions={proOptions}
          fitView
          fitViewOptions={{ padding: 0.25 }}
          defaultEdgeOptions={{ type: "route" }}
          colorMode="dark"
        >
          <Background
            variant={BackgroundVariant.Dots}
            gap={16}
            size={1}
            color="var(--color-border)"
          />
          <Controls
            showInteractive={false}
            fitViewOptions={{ padding: 0.25, duration: 0 }}
            className="!border-[var(--color-border)] !bg-[var(--color-card)]"
          >
            <ControlButton
              onClick={onToggleInteractive}
              title={isInteractive ? "キャンバスをロック" : "キャンバスのロックを解除"}
            >
              {isInteractive ? <UnlockedIcon /> : <LockedIcon />}
            </ControlButton>
            <ControlButton
              onClick={handleLayout}
              title="ノードを自動整列"
              disabled={!isInteractive}
            >
              <LayoutIcon />
            </ControlButton>
          </Controls>
        </ReactFlow>
      </div>
    </RouteUpdateProvider>
  );
}

export function FlowCanvas(props: Props) {
  return (
    <ReactFlowProvider>
      <FlowCanvasInner {...props} />
    </ReactFlowProvider>
  );
}

function LockedIcon() {
  return (
    <svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
      <path
        d="M5.5 7V5a2.5 2.5 0 015 0v2"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
      />
      <rect x="2.5" y="7" width="11" height="8" rx="1.5" />
    </svg>
  );
}

function UnlockedIcon() {
  return (
    <svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
      <path
        d="M5.5 7V5a2.5 2.5 0 015 0V2.5"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
      />
      <rect x="2.5" y="7" width="11" height="8" rx="1.5" />
    </svg>
  );
}

function LayoutIcon() {
  return (
    <svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
      <rect x="1" y="5" width="5" height="6" rx="1.2" />
      <rect x="10" y="1" width="5" height="5" rx="1.2" />
      <rect x="10" y="10" width="5" height="5" rx="1.2" />
      <line x1="6" y1="8" x2="10" y2="3.5" stroke="currentColor" strokeWidth="1.3" />
      <line x1="6" y1="8" x2="10" y2="12.5" stroke="currentColor" strokeWidth="1.3" />
    </svg>
  );
}