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:
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DOMAIN_COLORS,
|
||||
DOMAIN_LABELS,
|
||||
} from "~/components/analytics/computation-graph/domain-colors";
|
||||
import { useComputationGraphData } from "~/components/analytics/computation-graph/useComputationGraphData";
|
||||
import ComputationGraph2D from "~/components/analytics/ComputationGraph2D";
|
||||
import ComputationGraph3D from "~/components/analytics/ComputationGraph3D";
|
||||
|
||||
type Dimension = "2d" | "3d";
|
||||
|
||||
export default function ComputationGraphClient() {
|
||||
const state = useComputationGraphData();
|
||||
const [dimension, setDimension] = useState<Dimension>("2d");
|
||||
|
||||
const {
|
||||
viewMode, setViewMode,
|
||||
resourceId, setResourceId,
|
||||
month, setMonth,
|
||||
projectId, setProjectId,
|
||||
resources, projects,
|
||||
isLoading,
|
||||
activeDomains,
|
||||
graphData,
|
||||
highlightedNodes, setHighlightedNodes,
|
||||
domainFilter, toggleDomain,
|
||||
} = state;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
{/* ── Header Bar ── */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
{/* 2D / 3D Toggle */}
|
||||
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
|
||||
<button
|
||||
onClick={() => setDimension("2d")}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
dimension === "2d"
|
||||
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
|
||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
} rounded-l-lg`}
|
||||
>
|
||||
2D
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDimension("3d")}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
dimension === "3d"
|
||||
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
|
||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
} rounded-r-lg`}
|
||||
>
|
||||
3D
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
|
||||
<button
|
||||
onClick={() => setViewMode("resource")}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
viewMode === "resource"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
} rounded-l-lg`}
|
||||
>
|
||||
Resource View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("project")}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
viewMode === "project"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
} rounded-r-lg`}
|
||||
>
|
||||
Project View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selectors */}
|
||||
{viewMode === "resource" ? (
|
||||
<>
|
||||
<select
|
||||
value={resourceId}
|
||||
onChange={(e) => setResourceId(e.target.value)}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
>
|
||||
<option value="">Select Resource...</option>
|
||||
{resources.map((r: { id: string; displayName: string; eid: string }) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.displayName} ({r.eid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
>
|
||||
<option value="">Select Project...</option>
|
||||
{(Array.isArray(projects) ? projects : []).map((p: { id: string; name: string; shortCode?: string | null }) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.shortCode ? `${p.shortCode} — ` : ""}{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Meta info */}
|
||||
{graphData.nodes.length > 0 && (
|
||||
<span className="ml-auto text-xs text-zinc-500">
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} links
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Clear highlight */}
|
||||
{highlightedNodes && (
|
||||
<button
|
||||
onClick={() => setHighlightedNodes(null)}
|
||||
className="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Clear highlight
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Main Area ── */}
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
{/* Domain Filter Sidebar */}
|
||||
<div className="flex w-48 flex-col gap-1 border-r border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<span className="mb-1 text-xs font-semibold uppercase text-zinc-500">Domains</span>
|
||||
{activeDomains.map((domain) => (
|
||||
<button
|
||||
key={domain}
|
||||
onClick={() => toggleDomain(domain)}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors ${
|
||||
domainFilter.has(domain)
|
||||
? "text-zinc-400 line-through"
|
||||
: "text-zinc-700 dark:text-zinc-300"
|
||||
} hover:bg-zinc-200 dark:hover:bg-zinc-800`}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: domainFilter.has(domain) ? "#9ca3af" : DOMAIN_COLORS[domain],
|
||||
}}
|
||||
/>
|
||||
{DOMAIN_LABELS[domain]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Graph View */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center text-zinc-500">
|
||||
Loading computation graph...
|
||||
</div>
|
||||
) : dimension === "2d" ? (
|
||||
<ComputationGraph2D state={state} />
|
||||
) : (
|
||||
<ComputationGraph3D state={state} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user