refactor(nexus): rewire content studio to workshop grid

This commit is contained in:
Nexus Dev 2026-04-11 12:21:29 +00:00
parent f20fd0ec8d
commit cd6c172d48
3 changed files with 69 additions and 149 deletions

View file

@ -7,8 +7,8 @@ import {
Award,
Share2,
Repeat,
type LucideIcon,
} from "lucide-react";
import type { ComponentType } from "react";
/**
* Phase 10 single source-of-truth for Studio workshops.
@ -35,7 +35,7 @@ export interface WorkshopDefinition {
/** Inter 400 silver — single line. */
subtitle: string;
/** Lucide icon component, rendered at 32x32 in volt, top-right of the card. */
icon: ComponentType<{ className?: string }>;
icon: LucideIcon;
/**
* Key used by StudioWorkshopDetail to look up the concrete generator
* component. Phase 10 does not rewrite any generator internals each

View file

@ -1,155 +1,76 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { useCompany } from "../context/CompanyContext";
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 { PresentationPanel } from "../components/PresentationPanel";
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 { useState, useEffect } from "react";
import { useLocation, useNavigate } from "@/lib/router";
import { WorkshopGrid } from "../components/studio/WorkshopGrid";
import { StudioPromptBar } from "../components/studio/StudioPromptBar";
import { WORKSHOPS, type WorkshopSlug } from "../components/studio/workshops";
import { StudioWorkshopDetail } from "./StudioWorkshopDetail";
/**
* Phase 10 Studio home.
*
* Replaces the legacy 7-tab ContentStudio with a grid of 8 workshop cards
* plus a freeform prompt bar at the bottom. Clicking a card navigates to
* `/<prefix>/content-studio/<slug>`. The prompt bar routes classifiable
* prompts to the matching workshop (with the prompt as a query param) and
* falls through to the Assistant for anything unclassified.
*
* App.tsx currently only exposes a single `content-studio` route with no
* sub-route param (Phase 10 is forbidden from editing App.tsx). To render
* StudioWorkshopDetail without a dedicated sub-route, this component
* path-matches `location.pathname` and conditionally renders the detail
* view. The controller should replace this with a proper
* `content-studio/:workshopSlug` route after Wave 2.
*/
export function ContentStudio() {
const { selectedCompanyId } = useCompany();
const companyId = selectedCompanyId ?? "";
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);
const location = useLocation();
const navigate = useNavigate();
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]);
const workshopSlug = matchWorkshopSlug(location.pathname);
if (workshopSlug) {
return <StudioWorkshopDetail slug={workshopSlug} />;
}
const handleSelectWorkshop = (slug: WorkshopSlug) => {
navigate(`content-studio/${slug}`);
};
const handleClassified = (slug: WorkshopSlug, prefilledPrompt: string) => {
navigate(`content-studio/${slug}?prompt=${encodeURIComponent(prefilledPrompt)}`);
};
const handleFallbackToAssistant = (prompt: string) => {
navigate(`assistant?prompt=${encodeURIComponent(prompt)}`);
};
return (
<div className="flex flex-col gap-6 p-6">
<h1 className="text-xl font-semibold">Content Studio</h1>
<div className="mx-auto w-full max-w-[1200px] px-6 py-8">
<header className="mb-8">
<h1 className="text-[14px] font-semibold uppercase tracking-[0.1em] text-primary">
STUDIO
</h1>
<p className="mt-2 max-w-[560px] text-[14px] text-muted-foreground">
Eight workshops. Pick one or describe what you need.
</p>
</header>
<Tabs defaultValue="diagrams" className="w-full">
<TabsList>
<TabsTrigger value="diagrams">Diagrams</TabsTrigger>
<TabsTrigger value="icons">Icons</TabsTrigger>
<TabsTrigger value="themes">Themes</TabsTrigger>
<TabsTrigger value="wallpapers">Wallpapers</TabsTrigger>
<TabsTrigger value="social">Social</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="brand">Brand</TabsTrigger>
<TabsTrigger value="presentations">Presentations</TabsTrigger>
</TabsList>
<WorkshopGrid workshops={WORKSHOPS} onSelect={handleSelectWorkshop} />
<TabsContent value="diagrams" className="mt-4">
{companyId ? (
<DiagramGeneratePanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
<TabsContent value="icons" className="mt-4">
{companyId ? (
<IconGeneratePanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
<TabsContent value="themes" className="mt-4">
{companyId ? (
<div className="flex flex-col gap-6">
<ThemeSeedInput
value={seedColor}
onChange={setSeedColor}
/>
<button
className="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"
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
className="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"
onClick={() => setShowApplyDialog(true)}
>
Apply to Nexus
</button>
<ThemeApplyConfirmDialog
open={showApplyDialog}
onConfirm={() => {
setShowApplyDialog(false);
}}
onCancel={() => setShowApplyDialog(false)}
/>
</>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
<TabsContent value="wallpapers" className="mt-4">
{companyId ? (
<WallpaperGeneratePanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
<TabsContent value="social" className="mt-4">
{companyId ? (
<SocialPostPanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
<TabsContent value="documents" className="mt-4">
{companyId ? (
<DocumentGeneratePanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
<TabsContent value="brand" className="mt-4">
{companyId ? (
<BrandKitPanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
<TabsContent value="presentations" className="mt-4">
{companyId ? (
<PresentationPanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
</Tabs>
<div className="mt-12">
<StudioPromptBar
onClassified={handleClassified}
onFallbackToAssistant={handleFallbackToAssistant}
/>
</div>
</div>
);
}
/**
* Parse `/:companyPrefix/content-studio/<slug>` out of a pathname. Returns
* null for the Studio home. This is the Phase 10 workaround for the
* missing `content-studio/:workshopSlug` route in App.tsx.
*/
export function matchWorkshopSlug(pathname: string): string | null {
const match = pathname.match(/\/content-studio\/([^/?#]+)/);
return match ? match[1]! : null;
}

View file

@ -1,4 +1,4 @@
import { useMemo, type ComponentType } from "react";
import { useEffect, useMemo, useState, type ComponentType } from "react";
import { ArrowLeft, Save, Download, MessageCircle } from "lucide-react";
import { useNavigate, useLocation } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
@ -17,7 +17,6 @@ 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 {
/**