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