← back to Trademarks Copyright

src/components/Canvas.tsx

124 lines

"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import ReactFlow, {
  Background, Controls, MiniMap, addEdge, useEdgesState, useNodesState,
  type Connection, type Edge, type Node, type NodeProps,
} from "reactflow";
import "reactflow/dist/style.css";

type ItemLite = {
  id: number;
  name: string;
  kind: string;
  status: string;
  composite_score: number | null;
};

function ScoreHue(n: number | null): string {
  if (n === null) return "#cbd5e1";
  const hue = Math.round((n / 100) * 120);
  return `hsl(${hue} 55% 45%)`;
}

function ItemNode({ data }: NodeProps<ItemLite>) {
  return (
    <div className="rounded-lg border-2 bg-white p-2 shadow-sm" style={{ borderColor: ScoreHue(data.composite_score), minWidth: 180 }}>
      <div className="text-xs uppercase tracking-wide text-ink/50">{data.kind} · {data.status}</div>
      <div className="font-semibold">{data.name}</div>
      {data.composite_score !== null && (
        <div className="mt-1 inline-block rounded px-1.5 py-0.5 text-xs font-semibold text-white"
             style={{ backgroundColor: ScoreHue(data.composite_score) }}>
          {Number(data.composite_score).toFixed(1)}
        </div>
      )}
    </div>
  );
}

const nodeTypes = { item: ItemNode };

export default function Canvas() {
  const [nodes, setNodes, onNodesChange] = useNodesState([]);
  const [edges, setEdges, onEdgesChange] = useEdgesState([]);
  const [items, setItems] = useState<ItemLite[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const raw = sessionStorage.getItem("canvasIds");
    const ids: number[] = raw ? JSON.parse(raw) : [];
    fetch("/api/items")
      .then((r) => r.json())
      .then((j) => {
        const all: ItemLite[] = (j.items || []).map((i: { id: number; name: string; kind: string; status: string; composite_score: number | null }) => ({
          id: i.id, name: i.name, kind: i.kind, status: i.status,
          composite_score: i.composite_score,
        }));
        const subset = ids.length ? all.filter((i) => ids.includes(i.id)) : all.slice(0, 8);
        setItems(subset);

        const cols = Math.ceil(Math.sqrt(subset.length));
        const nn: Node[] = subset.map((it, i) => ({
          id: String(it.id),
          type: "item",
          data: it,
          position: { x: (i % cols) * 240, y: Math.floor(i / cols) * 140 },
        }));
        setNodes(nn);
      })
      .finally(() => setLoading(false));
  }, [setNodes]);

  const onConnect = useCallback(
    (c: Connection) => setEdges((eds) => addEdge({ ...c, animated: true, style: { stroke: "#b38f4e" } }, eds)),
    [setEdges]
  );

  const addMore = useMemo(
    () => async () => {
      const r = await fetch("/api/items");
      const j = await r.json();
      const existing = new Set(nodes.map((n) => n.id));
      const all: ItemLite[] = j.items || [];
      const extra = all.filter((i) => !existing.has(String(i.id))).slice(0, 6);
      const startY = nodes.length ? Math.max(...nodes.map((n) => n.position.y)) + 180 : 0;
      const cols = 4;
      const toAdd: Node[] = extra.map((it, i) => ({
        id: String(it.id),
        type: "item",
        data: it,
        position: { x: (i % cols) * 240, y: startY + Math.floor(i / cols) * 140 },
      }));
      setNodes((nds) => [...nds, ...toAdd]);
      setItems((its) => [...its, ...extra]);
    },
    [nodes, setNodes]
  );

  return (
    <div className="h-[calc(100vh-220px)] w-full">
      <div className="mb-3 flex items-center gap-3">
        <div className="text-sm text-ink/60">
          {loading ? "loading…" : `${items.length} nodes`} · drag to rearrange, connect to cluster
        </div>
        <button className="btn-ghost ml-auto" onClick={addMore}>+ Add more items</button>
      </div>
      <div className="card h-full">
        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          nodeTypes={nodeTypes}
          fitView
        >
          <Background gap={24} />
          <Controls />
          <MiniMap pannable zoomable />
        </ReactFlow>
      </div>
    </div>
  );
}