feat(nexus): add StudioWorkshopDetail page (phase 10)
Two-column workshop detail shell — params on the left (holds the existing generator panel), preview placeholder and action bar (Save/Export/Send to Assistant) on the right. Maps each workshop slug to one of the legacy ContentStudio generator panels, with ConvertPanel folded in for the convert workshop and a placeholder fallback for unknown slugs. Reads ?prompt= from the URL and surfaces it as a chip. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cd75772a6a
commit
d432326e7a
2 changed files with 561 additions and 0 deletions
201
ui/src/pages/StudioWorkshopDetail.test.tsx
Normal file
201
ui/src/pages/StudioWorkshopDetail.test.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { MemoryRouter } from "@/lib/router";
|
||||||
|
|
||||||
|
// Mock all generator panels — Phase 10 does not rewrite any generator
|
||||||
|
// internals, and the real panels hit network. We only care that the
|
||||||
|
// StudioWorkshopDetail shell maps slugs to the right generator.
|
||||||
|
vi.mock("../components/DiagramGeneratePanel", () => ({
|
||||||
|
DiagramGeneratePanel: ({ companyId }: { companyId: string }) => (
|
||||||
|
<div data-testid="mock-diagram-panel">diagram:{companyId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/IconGeneratePanel", () => ({
|
||||||
|
IconGeneratePanel: ({ companyId }: { companyId: string }) => (
|
||||||
|
<div data-testid="mock-icon-panel">icon:{companyId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/WallpaperGeneratePanel", () => ({
|
||||||
|
WallpaperGeneratePanel: ({ companyId }: { companyId: string }) => (
|
||||||
|
<div data-testid="mock-wallpaper-panel">wallpaper:{companyId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/SocialPostPanel", () => ({
|
||||||
|
SocialPostPanel: ({ companyId }: { companyId: string }) => (
|
||||||
|
<div data-testid="mock-social-panel">social:{companyId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/DocumentGeneratePanel", () => ({
|
||||||
|
DocumentGeneratePanel: ({ companyId }: { companyId: string }) => (
|
||||||
|
<div data-testid="mock-document-panel">document:{companyId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/BrandKitPanel", () => ({
|
||||||
|
BrandKitPanel: ({ companyId }: { companyId: string }) => (
|
||||||
|
<div data-testid="mock-brand-panel">brand:{companyId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/ConvertPanel", () => ({
|
||||||
|
ConvertPanel: ({ companyId }: { companyId: string }) => (
|
||||||
|
<div data-testid="mock-convert-panel">convert:{companyId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/ThemeSeedInput", () => ({
|
||||||
|
ThemeSeedInput: () => <div data-testid="mock-theme-seed">seed</div>,
|
||||||
|
}));
|
||||||
|
vi.mock("../components/ThemePaletteGrid", () => ({
|
||||||
|
ThemePaletteGrid: () => <div />,
|
||||||
|
}));
|
||||||
|
vi.mock("../components/ThemePreviewPanel", () => ({
|
||||||
|
ThemePreviewPanel: () => <div />,
|
||||||
|
}));
|
||||||
|
vi.mock("../components/ThemeExportTabs", () => ({
|
||||||
|
ThemeExportTabs: () => <div />,
|
||||||
|
}));
|
||||||
|
vi.mock("../components/ThemeApplyConfirmDialog", () => ({
|
||||||
|
ThemeApplyConfirmDialog: () => <div />,
|
||||||
|
}));
|
||||||
|
vi.mock("../hooks/useContentJob", () => ({
|
||||||
|
useContentJob: () => ({
|
||||||
|
status: "idle",
|
||||||
|
resultAssetId: null,
|
||||||
|
submit: () => {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stub CompanyContext so the router's Link/useNavigate wrappers work
|
||||||
|
// without a CompanyProvider — IconRail's test uses the same stub.
|
||||||
|
vi.mock("@/context/CompanyContext", () => ({
|
||||||
|
useCompany: () => ({
|
||||||
|
companies: [],
|
||||||
|
selectedCompanyId: "acme",
|
||||||
|
selectedCompany: { id: "acme", issuePrefix: "NEX" },
|
||||||
|
selectionSource: "bootstrap" as const,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
setSelectedCompanyId: () => {},
|
||||||
|
reloadCompanies: async () => {},
|
||||||
|
createCompany: async () => {
|
||||||
|
throw new Error("not implemented in test stub");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Load after the mocks are wired.
|
||||||
|
import { StudioWorkshopDetail } from "./StudioWorkshopDetail";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("StudioWorkshopDetail", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root!.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
}
|
||||||
|
if (container.parentNode) container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(slug: string, initialPath = `/NEX/content-studio/${slug}`) {
|
||||||
|
root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root!.render(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<StudioWorkshopDetail slug={slug} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders the diagrams workshop with the diagram generator in the params column", () => {
|
||||||
|
render("diagrams");
|
||||||
|
expect(container.querySelector("[data-testid='mock-diagram-panel']")).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("STUDIO");
|
||||||
|
expect(container.textContent).toContain("DIAGRAMS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the convert workshop with the ConvertPanel folded in", () => {
|
||||||
|
render("convert");
|
||||||
|
expect(container.querySelector("[data-testid='mock-convert-panel']")).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("CONVERT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders each non-theme generator for its corresponding slug", () => {
|
||||||
|
const cases = [
|
||||||
|
["diagrams", "mock-diagram-panel"],
|
||||||
|
["icons", "mock-icon-panel"],
|
||||||
|
["wallpapers", "mock-wallpaper-panel"],
|
||||||
|
["documents", "mock-document-panel"],
|
||||||
|
["brand-kits", "mock-brand-panel"],
|
||||||
|
["social", "mock-social-panel"],
|
||||||
|
["convert", "mock-convert-panel"],
|
||||||
|
] as const;
|
||||||
|
for (const [slug, testid] of cases) {
|
||||||
|
render(slug);
|
||||||
|
expect(
|
||||||
|
container.querySelector(`[data-testid='${testid}']`),
|
||||||
|
`slug ${slug} should render ${testid}`,
|
||||||
|
).not.toBeNull();
|
||||||
|
act(() => {
|
||||||
|
root!.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the theme workshop body (theme seed input visible)", () => {
|
||||||
|
render("themes");
|
||||||
|
expect(container.querySelector("[data-testid='mock-theme-seed']")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a 404 fallback for an unknown slug", () => {
|
||||||
|
render("not-a-real-workshop", "/NEX/content-studio/not-a-real-workshop");
|
||||||
|
expect(container.querySelector("[data-testid='workshop-not-found']")).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("Unknown workshop");
|
||||||
|
expect(container.textContent).toContain("not-a-real-workshop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the action bar with Save, Export, and Send to Assistant", () => {
|
||||||
|
render("diagrams");
|
||||||
|
const bar = container.querySelector("[data-testid='workshop-action-bar']") as HTMLElement;
|
||||||
|
expect(bar).not.toBeNull();
|
||||||
|
const labels = Array.from(bar.querySelectorAll("button")).map((b) => b.textContent?.trim());
|
||||||
|
expect(labels).toContain("Save");
|
||||||
|
expect(labels).toContain("Export");
|
||||||
|
expect(labels).toContain("Send to Assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the two-column grid layout on large screens", () => {
|
||||||
|
render("diagrams");
|
||||||
|
const grid = container.querySelector("[data-testid='workshop-detail-grid']") as HTMLElement;
|
||||||
|
expect(grid).not.toBeNull();
|
||||||
|
expect(grid.className).toContain("lg:grid-cols-");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a ?prompt=... query param as a visible chip", () => {
|
||||||
|
render("wallpapers", "/NEX/content-studio/wallpapers?prompt=forest%20at%20dusk");
|
||||||
|
const chip = container.querySelector("[data-testid='workshop-prefilled-prompt']");
|
||||||
|
expect(chip).not.toBeNull();
|
||||||
|
expect(chip?.textContent).toContain("forest at dusk");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a back button with a 'Back to Studio' aria-label", () => {
|
||||||
|
render("diagrams");
|
||||||
|
const back = container.querySelector("button[aria-label='Back to Studio']");
|
||||||
|
expect(back).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
360
ui/src/pages/StudioWorkshopDetail.tsx
Normal file
360
ui/src/pages/StudioWorkshopDetail.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
import { useMemo, type ComponentType } from "react";
|
||||||
|
import { ArrowLeft, Save, Download, MessageCircle } from "lucide-react";
|
||||||
|
import { useNavigate, useLocation } from "@/lib/router";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { findWorkshop, type WorkshopDefinition } from "../components/studio/workshops";
|
||||||
|
import { DiagramGeneratePanel } from "../components/DiagramGeneratePanel";
|
||||||
|
import { IconGeneratePanel } from "../components/IconGeneratePanel";
|
||||||
|
import { WallpaperGeneratePanel } from "../components/WallpaperGeneratePanel";
|
||||||
|
import { SocialPostPanel } from "../components/SocialPostPanel";
|
||||||
|
import { DocumentGeneratePanel } from "../components/DocumentGeneratePanel";
|
||||||
|
import { BrandKitPanel } from "../components/BrandKitPanel";
|
||||||
|
import { ConvertPanel } from "../components/ConvertPanel";
|
||||||
|
import { ThemeSeedInput } from "../components/ThemeSeedInput";
|
||||||
|
import { ThemePaletteGrid, type PaletteRole } from "../components/ThemePaletteGrid";
|
||||||
|
import { ThemePreviewPanel } from "../components/ThemePreviewPanel";
|
||||||
|
import { ThemeExportTabs } from "../components/ThemeExportTabs";
|
||||||
|
import { ThemeApplyConfirmDialog } from "../components/ThemeApplyConfirmDialog";
|
||||||
|
import { useContentJob } from "../hooks/useContentJob";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface StudioWorkshopDetailProps {
|
||||||
|
/**
|
||||||
|
* Workshop slug. Normally parsed from `/content-studio/:workshopSlug`.
|
||||||
|
* Phase 10 feeds this in from ContentStudio (which path-matches location,
|
||||||
|
* because App.tsx does not yet define the sub-route — see plan for
|
||||||
|
* routing-request to the controller).
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 10 — Studio workshop detail view.
|
||||||
|
*
|
||||||
|
* Spec (§6.4): two-column layout on desktop. Left column = parameters
|
||||||
|
* panel (holds the existing generator component). Right column = preview
|
||||||
|
* area with an action bar at the bottom (Save / Export / Send to Assistant).
|
||||||
|
*
|
||||||
|
* For Phase 10 the existing generator panels already render their own
|
||||||
|
* side-by-side inputs + preview, so we hand the whole generator to the
|
||||||
|
* left column and show the action bar underneath. Phase 14 (recipe-aware
|
||||||
|
* flow) will split generators into explicit params and preview halves.
|
||||||
|
*/
|
||||||
|
export function StudioWorkshopDetail({ slug }: StudioWorkshopDetailProps) {
|
||||||
|
const workshop = findWorkshop(slug);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Read ?prompt=... from the URL for StudioPromptBar's intent routing.
|
||||||
|
// The existing generator panels don't currently accept a pre-filled
|
||||||
|
// prompt prop, so for Phase 10 we just surface it to the user via a
|
||||||
|
// read-only chip at the top of the params panel and expect them to
|
||||||
|
// paste it in. Phase 14 wires prefill into every generator.
|
||||||
|
const prefilledPrompt = useMemo(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
return params.get("prompt") ?? "";
|
||||||
|
}, [location.search]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
navigate("content-studio");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendToAssistant = () => {
|
||||||
|
const base = "assistant";
|
||||||
|
if (prefilledPrompt) {
|
||||||
|
navigate(`${base}?prompt=${encodeURIComponent(prefilledPrompt)}`);
|
||||||
|
} else {
|
||||||
|
navigate(base);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!workshop) {
|
||||||
|
return <WorkshopNotFound slug={slug} onBack={handleBack} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-[1400px] px-6 py-8">
|
||||||
|
<WorkshopHeader workshop={workshop} onBack={handleBack} />
|
||||||
|
|
||||||
|
{prefilledPrompt ? (
|
||||||
|
<div
|
||||||
|
data-testid="workshop-prefilled-prompt"
|
||||||
|
className="mt-4 rounded-md border border-border bg-card px-4 py-3 text-[14px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span className="font-semibold uppercase tracking-[0.1em] text-primary">From prompt: </span>
|
||||||
|
{prefilledPrompt}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="workshop-detail-grid"
|
||||||
|
className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
aria-label="Workshop parameters"
|
||||||
|
className="flex flex-col gap-4 rounded-lg border border-border bg-card p-6"
|
||||||
|
>
|
||||||
|
<h2 className="text-[12px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
Parameters
|
||||||
|
</h2>
|
||||||
|
<WorkshopBody workshop={workshop} companyId={selectedCompanyId ?? ""} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-label="Workshop preview"
|
||||||
|
className="flex flex-col gap-4 rounded-lg border border-border bg-card p-6"
|
||||||
|
>
|
||||||
|
<h2 className="text-[12px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
Preview
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
data-testid="workshop-preview-area"
|
||||||
|
className="flex-1 rounded-md border border-dashed border-border p-6 text-[14px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
Preview renders inline inside the generator above. Phase 14 will
|
||||||
|
separate params from preview into this right column.
|
||||||
|
</div>
|
||||||
|
<WorkshopActionBar onSendToAssistant={handleSendToAssistant} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkshopHeader({
|
||||||
|
workshop,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
workshop: WorkshopDefinition;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = workshop.icon;
|
||||||
|
return (
|
||||||
|
<header className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
aria-label="Back to Studio"
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground",
|
||||||
|
"transition-colors duration-100 hover:text-primary hover:bg-card",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 text-[14px] font-semibold uppercase tracking-[0.1em]">
|
||||||
|
<span className="text-muted-foreground">STUDIO</span>
|
||||||
|
<span aria-hidden="true" className="text-muted-foreground">
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
<span className="text-primary">{workshop.title}</span>
|
||||||
|
</div>
|
||||||
|
<Icon aria-hidden="true" className="ml-auto h-6 w-6 text-primary" strokeWidth={1.5} />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkshopActionBar({ onSendToAssistant }: { onSendToAssistant: () => void }) {
|
||||||
|
// Save / Export are placeholders in Phase 10 — they're part of the spec
|
||||||
|
// but wiring them to real backends is out of scope. Clicking them is a
|
||||||
|
// no-op for now; Phase 14 will connect them to the content-job store.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="workshop-action-bar"
|
||||||
|
className="mt-auto flex items-center justify-end gap-2 border-t border-border pt-4"
|
||||||
|
>
|
||||||
|
<ActionButton label="Save" icon={Save} />
|
||||||
|
<ActionButton label="Export" icon={Download} />
|
||||||
|
<ActionButton label="Send to Assistant" icon={MessageCircle} onClick={onSendToAssistant} primary />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
onClick,
|
||||||
|
primary,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType<{ className?: string; strokeWidth?: number | string }>;
|
||||||
|
onClick?: () => void;
|
||||||
|
primary?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-[13px] font-semibold uppercase tracking-[0.05em]",
|
||||||
|
"transition-colors duration-100",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
primary
|
||||||
|
? "border border-border text-primary hover:bg-card"
|
||||||
|
: "text-muted-foreground hover:text-primary hover:bg-card",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkshopNotFound({ slug, onBack }: { slug: string; onBack: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="workshop-not-found"
|
||||||
|
className="mx-auto flex w-full max-w-[800px] flex-col items-start gap-4 px-6 py-16"
|
||||||
|
>
|
||||||
|
<div className="text-[14px] font-semibold uppercase tracking-[0.1em] text-primary">
|
||||||
|
STUDIO / NOT FOUND
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[32px] font-bold uppercase leading-tight text-foreground">
|
||||||
|
Unknown workshop
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-muted-foreground">
|
||||||
|
There is no workshop with slug <code className="text-foreground">{slug}</code>.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-md border border-border px-3 py-2",
|
||||||
|
"text-[13px] font-semibold uppercase tracking-[0.05em] text-primary hover:bg-card",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
<span>Back to Studio</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the existing generator component that corresponds to the given
|
||||||
|
* workshop. Phase 10 does not rewrite any generator internals.
|
||||||
|
*/
|
||||||
|
function WorkshopBody({
|
||||||
|
workshop,
|
||||||
|
companyId,
|
||||||
|
}: {
|
||||||
|
workshop: WorkshopDefinition;
|
||||||
|
companyId: string;
|
||||||
|
}) {
|
||||||
|
if (!companyId) {
|
||||||
|
return (
|
||||||
|
<p className="text-[14px] text-muted-foreground">
|
||||||
|
Select a company to get started.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (workshop.componentKey) {
|
||||||
|
case "diagram":
|
||||||
|
return <DiagramGeneratePanel companyId={companyId} />;
|
||||||
|
case "icon":
|
||||||
|
return <IconGeneratePanel companyId={companyId} />;
|
||||||
|
case "theme":
|
||||||
|
return <ThemeWorkshopBody companyId={companyId} />;
|
||||||
|
case "wallpaper":
|
||||||
|
return <WallpaperGeneratePanel companyId={companyId} />;
|
||||||
|
case "document":
|
||||||
|
return <DocumentGeneratePanel companyId={companyId} />;
|
||||||
|
case "brand":
|
||||||
|
return <BrandKitPanel companyId={companyId} />;
|
||||||
|
case "social":
|
||||||
|
return <SocialPostPanel companyId={companyId} />;
|
||||||
|
case "convert":
|
||||||
|
return <ConvertPanel companyId={companyId} />;
|
||||||
|
default:
|
||||||
|
return <WorkshopPlaceholder slug={workshop.slug} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkshopPlaceholder({ slug }: { slug: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`workshop-placeholder-${slug}`}
|
||||||
|
className="rounded-md border border-dashed border-border p-6 text-[14px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div className="mb-1 text-[12px] font-semibold uppercase tracking-[0.1em] text-primary">
|
||||||
|
Coming soon
|
||||||
|
</div>
|
||||||
|
No generator is wired up for <code className="text-foreground">{slug}</code> yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThemeWorkshopBody — lifted verbatim from the legacy ContentStudio tab
|
||||||
|
* body. Phase 10 does not rewrite theme generation; it just relocates
|
||||||
|
* the existing UI under the Studio workshop detail shell.
|
||||||
|
*/
|
||||||
|
function ThemeWorkshopBody({ companyId }: { companyId: string }) {
|
||||||
|
const themeJob = useContentJob(companyId);
|
||||||
|
const [showApplyDialog, setShowApplyDialog] = useState(false);
|
||||||
|
const [seedColor, setSeedColor] = useState("var(--primary)");
|
||||||
|
const [themeBundle, setThemeBundle] = useState<{
|
||||||
|
palette: PaletteRole[];
|
||||||
|
exports: { css: string; tailwind: string; vscode: string; json: string };
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (themeJob.status === "done" && themeJob.resultAssetId && companyId) {
|
||||||
|
fetch(`/api/companies/${companyId}/assets/${themeJob.resultAssetId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setThemeBundle(data as typeof themeBundle))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, [themeJob.status, themeJob.resultAssetId, companyId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ThemeSeedInput value={seedColor} onChange={setSeedColor} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded-md bg-primary px-4 py-2",
|
||||||
|
"text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
)}
|
||||||
|
disabled={themeJob.status === "running" || themeJob.status === "queued"}
|
||||||
|
onClick={() => {
|
||||||
|
setThemeBundle(null);
|
||||||
|
themeJob.submit("theme-palette", { seedColor });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{themeJob.status === "running" || themeJob.status === "queued"
|
||||||
|
? "Generating..."
|
||||||
|
: "Generate Palette"}
|
||||||
|
</button>
|
||||||
|
{themeBundle ? (
|
||||||
|
<>
|
||||||
|
<ThemePaletteGrid palette={themeBundle.palette} />
|
||||||
|
<ThemePreviewPanel palette={themeBundle.palette} variant="light" />
|
||||||
|
<ThemeExportTabs exports={themeBundle.exports} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded-md bg-primary px-4 py-2",
|
||||||
|
"text-sm font-medium text-primary-foreground hover:bg-primary/90",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
)}
|
||||||
|
onClick={() => setShowApplyDialog(true)}
|
||||||
|
>
|
||||||
|
Apply to Nexus
|
||||||
|
</button>
|
||||||
|
<ThemeApplyConfirmDialog
|
||||||
|
open={showApplyDialog}
|
||||||
|
onConfirm={() => setShowApplyDialog(false)}
|
||||||
|
onCancel={() => setShowApplyDialog(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue