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,155 @@
"use client";
import { useState, useMemo, useEffect, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import {
RESOURCE_VIEW_DOMAINS,
PROJECT_VIEW_DOMAINS,
type Domain,
type GraphNode,
} from "./domain-colors";
import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data";
export type ViewMode = "resource" | "project";
export interface ComputationGraphState {
viewMode: ViewMode;
setViewMode: (m: ViewMode) => void;
resourceId: string;
setResourceId: (id: string) => void;
month: string;
setMonth: (m: string) => void;
projectId: string;
setProjectId: (id: string) => void;
resources: Array<{ id: string; displayName: string; eid: string }>;
projects: Array<{ id: string; name: string; shortCode?: string | null }>;
isLoading: boolean;
activeDomains: Domain[];
graphData: ForceGraphData;
highlightedNodes: Set<string> | null;
setHighlightedNodes: (s: Set<string> | null) => void;
hoveredNode: PositionedNode | null;
setHoveredNode: (n: PositionedNode | null) => void;
domainFilter: Set<Domain>;
toggleDomain: (domain: Domain) => void;
handleNodeClick: (nodeId: string) => void;
}
export function useComputationGraphData(): ComputationGraphState {
const [viewMode, setViewMode] = useState<ViewMode>("resource");
const [resourceId, setResourceId] = useState<string>("");
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [projectId, setProjectId] = useState<string>("");
const [highlightedNodes, setHighlightedNodes] = useState<Set<string> | null>(null);
const [hoveredNode, setHoveredNode] = useState<PositionedNode | null>(null);
const [domainFilter, setDomainFilter] = useState<Set<Domain>>(new Set());
// Load selectors
const { data: resourceData } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const resources = resourceData?.resources ?? [];
const { data: projectData } = trpc.project.list.useQuery(
{},
{ staleTime: 60_000 },
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const projects: Array<{ id: string; name: string; shortCode?: string | null }> = (projectData as any)?.projects ?? (projectData as any) ?? [];
// Auto-select first resource/project
useEffect(() => {
if (!resourceId && resources.length > 0) {
setResourceId(resources[0]!.id);
}
}, [resources, resourceId]);
useEffect(() => {
if (!projectId && Array.isArray(projects) && projects.length > 0) {
setProjectId(projects[0]!.id);
}
}, [projects, projectId]);
// Fetch graph data
const { data: resourceGraphData, isLoading: resourceLoading } = trpc.computationGraph.getResourceData.useQuery(
{ resourceId, month },
{ enabled: viewMode === "resource" && !!resourceId, staleTime: 30_000 },
);
const { data: projectGraphData, isLoading: projectLoading } = trpc.computationGraph.getProjectData.useQuery(
{ projectId },
{ enabled: viewMode === "project" && !!projectId, staleTime: 30_000 },
);
const rawData = viewMode === "resource" ? resourceGraphData : projectGraphData;
const isLoading = viewMode === "resource" ? resourceLoading : projectLoading;
const activeDomains = viewMode === "resource" ? RESOURCE_VIEW_DOMAINS : PROJECT_VIEW_DOMAINS;
// Build graph data with positions
const graphData = useMemo(() => {
if (!rawData) return { nodes: [], links: [] };
let filteredNodes = rawData.nodes as GraphNode[];
if (domainFilter.size > 0) {
filteredNodes = filteredNodes.filter((nd: GraphNode) => !domainFilter.has(nd.domain as Domain));
}
const filteredNodeIds = new Set(filteredNodes.map((nd: GraphNode) => nd.id));
const filteredLinks = rawData.links.filter(
(lk: { source: string; target: string }) =>
filteredNodeIds.has(lk.source) && filteredNodeIds.has(lk.target),
);
return buildForceGraphData(filteredNodes, filteredLinks);
}, [rawData, domainFilter]);
// Domain filter toggle
const toggleDomain = useCallback((domain: Domain) => {
setDomainFilter((prev) => {
const next = new Set(prev);
if (next.has(domain)) {
next.delete(domain);
} else {
next.add(domain);
}
return next;
});
}, []);
// Node click → highlight path
const handleNodeClick = useCallback(
(nodeId: string) => {
if (highlightedNodes?.has(nodeId)) {
setHighlightedNodes(null);
} else {
const connected = getConnectedNodeIds(nodeId, graphData.links);
setHighlightedNodes(connected);
}
},
[graphData.links, highlightedNodes],
);
return {
viewMode,
setViewMode,
resourceId,
setResourceId,
month,
setMonth,
projectId,
setProjectId,
resources,
projects,
isLoading,
activeDomains,
graphData,
highlightedNodes,
setHighlightedNodes,
hoveredNode,
setHoveredNode,
domainFilter,
toggleDomain,
handleNodeClick,
};
}