feat: project cover art with AI generation, branding rename, RBAC fix, computation graph

- Add DALL-E cover art generation for projects (Azure OpenAI + standard OpenAI)
- CoverArtSection component with generate/upload/remove/focus-point controls
- Client-side image compression (10MB input → WebP/JPEG, max 1920px)
- DALL-E settings in admin panel (deployment, endpoint, API key)
- MCP assistant tools for cover art (generate_project_cover, remove_project_cover)
- Rename "Planarchy" → "plANARCHY" across all UI-facing text (13 files)
- Fix hardcoded canEdit={true} on project detail page — now checks user role
- Computation graph visualization (2D/3D) for calculation rules
- OG image and OpenGraph metadata

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 11:31:56 +01:00
parent 21af720f90
commit 093e13b88f
86 changed files with 5623 additions and 744 deletions
@@ -0,0 +1,126 @@
"use client";
import { useCallback, useState } from "react";
import dynamic from "next/dynamic";
import { DOMAIN_LABELS } from "~/components/analytics/computation-graph/domain-colors";
import { createNodeSprite, createDimmedNodeSprite, getLinkColor } from "~/components/analytics/computation-graph/node-renderer";
import type { PositionedNode } from "~/components/analytics/computation-graph/graph-data";
import type { ComputationGraphState } from "~/components/analytics/computation-graph/useComputationGraphData";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ForceGraph3D = dynamic(() => import("react-force-graph-3d"), { ssr: false }) as any;
interface Props {
state: ComputationGraphState;
}
export default function ComputationGraph3DView({ state }: Props) {
const { graphData, highlightedNodes, handleNodeClick, setHoveredNode } = state;
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onNodeClick = useCallback((node: any) => {
handleNodeClick((node as PositionedNode).id);
}, [handleNodeClick]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onNodeHover = useCallback((node: any) => {
setHoveredNode(node as PositionedNode | null);
}, [setHoveredNode]);
const nodeThreeObject = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(node: any) => {
const n = node as PositionedNode;
if (highlightedNodes && !highlightedNodes.has(n.id)) {
return createDimmedNodeSprite(n);
}
return createNodeSprite(n);
},
[highlightedNodes],
);
const linkColorFn = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(link: any) => {
if (!highlightedNodes) return getLinkColor(link, 0.4);
const sourceId = typeof link.source === "object" ? link.source.id : link.source;
const targetId = typeof link.target === "object" ? link.target.id : link.target;
if (highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)) {
return getLinkColor(link, 0.9);
}
return getLinkColor(link, 0.08);
},
[highlightedNodes],
);
const linkWidthFn = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(link: any) => {
if (!highlightedNodes) return link.weight ?? 1;
const sourceId = typeof link.source === "object" ? link.source.id : link.source;
const targetId = typeof link.target === "object" ? link.target.id : link.target;
if (highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)) {
return (link.weight ?? 1) * 2.5;
}
return 0.3;
},
[highlightedNodes],
);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY });
}, []);
if (graphData.nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center text-zinc-500">
{state.viewMode === "resource" ? "Select a resource and month" : "Select a project"}
</div>
);
}
return (
<div className="relative h-full w-full" onMouseMove={handleMouseMove}>
<ForceGraph3D
graphData={graphData}
nodeThreeObject={nodeThreeObject}
nodeThreeObjectExtend={false}
onNodeClick={onNodeClick}
onNodeHover={onNodeHover}
linkColor={linkColorFn}
linkWidth={linkWidthFn}
linkDirectionalArrowLength={4}
linkDirectionalArrowRelPos={0.8}
linkCurvature={0.1}
backgroundColor="#0f172a"
showNavInfo={false}
warmupTicks={50}
cooldownTicks={0}
/>
{/* Hover Tooltip */}
{state.hoveredNode && (
<div
className="pointer-events-none fixed z-50 max-w-sm rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-3 text-sm text-zinc-200 shadow-xl"
style={{ top: mousePos.y + 16, left: mousePos.x + 16 }}
>
<div className="flex items-center gap-2">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: state.hoveredNode.color }}
/>
<span className="font-semibold">{state.hoveredNode.label}</span>
<span className="ml-auto text-xs text-zinc-500">{DOMAIN_LABELS[state.hoveredNode.domain]}</span>
</div>
<div className="mt-1 text-lg font-bold">
{state.hoveredNode.value} <span className="text-sm font-normal text-zinc-400">{state.hoveredNode.unit}</span>
</div>
<div className="mt-1 text-xs text-zinc-400">{state.hoveredNode.description}</div>
{state.hoveredNode.formula && (
<div className="mt-1 font-mono text-xs text-blue-400">{state.hoveredNode.formula}</div>
)}
</div>
)}
</div>
);
}