Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions app/web/components/CartoonNextAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import type { StoryProgress } from "@app-lib/story-progress";
import type { CoachUiAction } from "@app-lib/cartoon-coach";
import { WorkflowCoachView } from "./WorkflowCoach";

export function storyInfoNextStep(progress: StoryProgress): string {
if (progress.cover !== "present") {
return progress.cover === "invalid"
? "Replace the cover image - it must be a valid WebP or JPEG."
: "Add a cover image before publishing.";
}
const missing: string[] = [];
if (!progress.metadata.language) missing.push("language");
if (!progress.metadata.genre) missing.push("genre");
if (!progress.metadata.title) missing.push("title");
return `Add the story ${missing.join(" and ") || "details"} before publishing.`;
}

export function cartoonWorkflowActiveKey(progress: StoryProgress): string | null {
const coach = progress.coach ?? null;
const m = progress.metadata;
const hasStructure = progress.setup.hasStructure;
const hasGenesis = progress.setup.hasGenesis;
const coverDone = progress.cover === "present";
const metadataIncomplete = !m.title || !m.language || !m.genre;
const activeEp = progress.episodes.find((e) => !e.published) ?? null;
const productionPending = !!activeEp && activeEp.state !== "ready";

if (!hasStructure) return "whitepaper";
if (!hasGenesis) return "genesis.md";
if (metadataIncomplete) return "story-info";
if (productionPending && coach?.episodeFile) return coach.episodeFile;
if (!coverDone) return "story-info";
return coach?.episodeFile ?? null;
}

export function StoryInfoNextActionCard({
progress,
onOpenStoryInfo,
}: {
progress: StoryProgress;
onOpenStoryInfo?: () => void;
}) {
return (
<div className="m-3 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 shadow-sm" data-testid="story-info-cta">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<span className="inline-flex rounded-full bg-background px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent">
Story info
</span>
<p className="mt-1 text-sm text-foreground" data-testid="story-info-next-action">
<span className="font-semibold">Next: </span>
<span>{storyInfoNextStep(progress)}</span>
</p>
</div>
<button
type="button"
onClick={onOpenStoryInfo}
disabled={!onOpenStoryInfo}
className="flex-shrink-0 rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50"
data-testid="story-info-next-action-btn"
>
Next Action
</button>
</div>
</div>
);
}

export function CartoonNextActionView({
progress,
onCoachAction,
onOpenStoryInfo,
}: {
progress: StoryProgress;
onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
onOpenStoryInfo?: () => void;
}) {
const activeKey = cartoonWorkflowActiveKey(progress);
if (activeKey === "story-info") {
return <StoryInfoNextActionCard progress={progress} onOpenStoryInfo={onOpenStoryInfo} />;
}
return (
<WorkflowCoachView
coach={progress.coach ?? null}
showEmptyState
onAction={onCoachAction}
/>
);
}

export function CartoonNextAction({
storyName,
authFetch,
refreshKey = 0,
onCoachAction,
onOpenStoryInfo,
}: {
storyName: string;
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
refreshKey?: number;
onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
onOpenStoryInfo?: () => void;
}) {
const [progress, setProgress] = useState<StoryProgress | null | undefined>(undefined);

const targetKey = JSON.stringify([storyName, refreshKey]);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
if (loadedKey !== targetKey) {
setProgress(undefined);
setLoadedKey(targetKey);
}

useEffect(() => {
let cancelled = false;
authFetch(`/api/stories/${storyName}/progress`)
.then((res) => (res.ok ? res.json() : null))
.then((data: StoryProgress | null) => {
if (!cancelled) setProgress(isValidProgress(data) ? data : null);
})
.catch(() => {
if (!cancelled) setProgress(null);
});
return () => { cancelled = true; };
}, [storyName, authFetch, refreshKey]);

if (progress === undefined) return null;
if (!progress) {
return <WorkflowCoachView coach={null} showEmptyState onAction={onCoachAction} />;
}
return (
<CartoonNextActionView
progress={progress}
onCoachAction={onCoachAction}
onOpenStoryInfo={onOpenStoryInfo}
/>
);
}

function isValidProgress(data: StoryProgress | null): data is StoryProgress {
return !!data
&& !!data.metadata
&& !!data.setup
&& Array.isArray(data.episodes);
}
3 changes: 2 additions & 1 deletion app/web/components/PreviewPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ describe("PreviewPanel — Genesis workflow-coach cut actions (#429)", () => {

// The coach loads for Genesis with an in-app (lettering) action.
const doBtn = await screen.findByTestId("workflow-coach-do");
expect(doBtn).toHaveTextContent(/lettering/i);
expect(screen.getByTestId("workflow-coach-action")).toHaveTextContent(/Review cuts and start lettering/i);
expect(doBtn).toHaveTextContent("Next Action");
// Before acting, the cut workspace isn't mounted.
expect(screen.queryByTestId("cut-list-panel")).not.toBeInTheDocument();

Expand Down
1 change: 1 addition & 0 deletions app/web/components/PreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,7 @@
authFetch={authFetch}
refreshKey={cutsRefreshKey}
onAction={handleCoachAction}
showEmptyState
/>
)}

Expand Down Expand Up @@ -1249,7 +1250,7 @@
<div className="flex items-start gap-3">
{coverPreview && (
<div className="relative">
<img

Check warning on line 1253 in app/web/components/PreviewPanel.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={coverPreview}
alt="Cover preview"
className="w-16 h-24 object-cover rounded border border-border"
Expand Down Expand Up @@ -1471,7 +1472,7 @@
<div className="flex items-start gap-3">
{coverPreview && (
<div className="relative">
<img

Check warning on line 1475 in app/web/components/PreviewPanel.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={coverPreview}
alt="Cover preview"
className="w-16 h-24 object-cover rounded border border-border"
Expand Down
48 changes: 46 additions & 2 deletions app/web/components/StoriesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { render, screen, cleanup, waitFor, fireEvent, within } from "@testing-library/react";
import { StoriesPage } from "./StoriesPage";

// Capture props passed to the mocked child panels so tests can drive the
Expand Down Expand Up @@ -428,13 +428,37 @@ function makeTwoCartoonAuthFetch() {
{ name: "cartoon-a", title: "Story A", hasStructure: true, hasGenesis: true, contentType: "cartoon", agentProvider: "codex" },
{ name: "cartoon-b", title: "Story B", hasStructure: true, hasGenesis: true, contentType: "cartoon", agentProvider: "codex" },
];
const progress = {
name: "cartoon-a",
contentType: "cartoon",
metadata: { title: "Story A", language: "English", genre: "Science Fiction", isNsfw: false, contentType: "cartoon" },
setup: { hasStructure: true, hasGenesis: true },
cover: "missing",
episodes: [
{
file: "genesis.md",
label: "Episode 1 / Genesis",
kind: "genesis",
title: "Opening",
state: "ready",
summary: "Ready to publish",
published: false,
checklist: [],
cuts: { total: 0, needClean: 0, withClean: 0, withText: 0, exported: 0, uploaded: 0 },
},
],
summary: { episodes: 1, published: 0, readyToPublish: 1, placeholders: 0, blocked: 0 },
nextAction: "Add a cover image before publishing.",
nextPrompt: null,
coach: null,
};
const fn = vi.fn().mockImplementation((url: string, opts?: RequestInit) => {
if (url === "/api/wallet") return Promise.resolve({ ok: true, json: () => Promise.resolve({ address: "0xabc" }) });
if (url === "/api/agent/readiness") {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ claude: { installed: true }, codex: { installed: true, version: "codex-cli 0.135.0", imageGeneration: "enabled", auth: "ok" }, checkedAt: 1748000000000 }) });
}
if (url === "/api/stories" && !opts) return Promise.resolve({ ok: true, json: () => Promise.resolve({ stories }) });
if (url.endsWith("/progress")) return Promise.resolve({ ok: true, json: () => Promise.resolve({ contentType: "cartoon", episodes: [], cover: "missing" }) });
if (url.endsWith("/progress")) return Promise.resolve({ ok: true, json: () => Promise.resolve(progress) });
// story detail (cartoon ⇒ no auto-open) and everything else.
return Promise.resolve({ ok: true, json: () => Promise.resolve({ contentType: "cartoon", files: [] }) });
});
Expand Down Expand Up @@ -487,4 +511,24 @@ describe("StoriesPage cartoon workflow nav routing (#439)", () => {
expect(screen.getByTestId("nav-tab-genesis")).toHaveAttribute("data-active", "false");
expect(screen.queryByTestId("mock-preview")).not.toBeInTheDocument();
}, 10000);

it("shows the Story Info next-action CTA on story-level right-pane pages when cover is missing (#487 RE1)", async () => {
const { fn } = makeTwoCartoonAuthFetch();
render(<StoriesPage token="t" authFetch={fn} />);
await waitFor(() => expect(childProps.onSelectStory).not.toBeNull());
childProps.onSelectStory!("cartoon-a");

fireEvent.click(await screen.findByTestId("nav-tab-publish"));
const publishCta = await screen.findByTestId("workflow-context-next-action");
expect(within(publishCta).getByTestId("story-info-next-action")).toHaveTextContent(/Next: Add a cover image before publishing/i);
expect(within(publishCta).getByRole("button", { name: "Next Action" })).toBeInTheDocument();
expect(within(publishCta).queryByText("No next action available")).not.toBeInTheDocument();

fireEvent.click(within(publishCta).getByRole("button", { name: "Next Action" }));
await waitFor(() => expect(screen.getByTestId("nav-tab-story-info")).toHaveAttribute("data-active", "true"));

const storyInfoCta = screen.getByTestId("workflow-context-next-action");
expect(within(storyInfoCta).getByTestId("story-info-next-action")).toHaveTextContent(/Next: Add a cover image before publishing/i);
expect(within(storyInfoCta).queryByText("No next action available")).not.toBeInTheDocument();
}, 10000);
});
35 changes: 35 additions & 0 deletions app/web/components/StoriesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { CartoonWorkflowNav, type CartoonWorkflowTab } from "./CartoonWorkflowNa
import { StoryInfoPage } from "./StoryInfoPage";
import { EpisodesPage } from "./EpisodesPage";
import { CartoonPublishPage } from "./CartoonPublishPage";
import { CartoonNextAction } from "./CartoonNextAction";
import { LANGUAGES, GENRES } from "../../../lib/genres";
import { getContentTypeForPublish, resolveSelectedContentType, needsLegacyProviderRepair, attachCoverToStoryline, derivePublishTitle, shouldBlockDuplicatePlotPublish, isRawFilenameTitle, hasExplicitEpisodeTitle, isPreflightBlocked, formatPreflightBlock } from "../lib/publish-helpers";
import { verifyPublicCartoonTitle, publicTitleWarning } from "../lib/verify-public-title";
import { isCodexAuthUnclear, CODEX_AUTH_UNCLEAR_MESSAGE, type AgentReadiness } from "@app-lib/agent-readiness";
import { cartoonGenesisReadiness } from "@app-lib/cartoon-readiness";
import type { CoachUiAction } from "@app-lib/cartoon-coach";

interface StoriesPageProps {
token: string;
Expand Down Expand Up @@ -763,6 +765,27 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
}
}, [selectedStory, handleSelectFile]);

const handleWorkflowNextAction = useCallback((action: CoachUiAction, episodeFile: string | null) => {
const story = selectedStory;
if (!story) return;
switch (action) {
case "view-progress":
setCartoonView(null);
setSelectedFile(null);
break;
case "publish":
setCartoonView("publish");
break;
case "open-cuts":
case "open-lettering":
case "upload":
case "refresh-assets":
case "generate-markdown":
if (episodeFile) handleSelectFile(story, episodeFile);
break;
}
}, [selectedStory, handleSelectFile]);

// Keep the publish-control seeds in sync after a Story Info save (#439).
const handleStoryInfoSaved = useCallback((patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
if (!selectedStory) return;
Expand Down Expand Up @@ -814,6 +837,17 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
onSelect={handleCartoonNav}
/>
)}
{isCartoonStory && selectedStory && cartoonView !== null && (
<div className="flex-shrink-0 border-b border-border" data-testid="workflow-context-next-action">
<CartoonNextAction
storyName={selectedStory}
authFetch={authFetch}
refreshKey={cartoonPublishRefresh}
onCoachAction={handleWorkflowNextAction}
onOpenStoryInfo={() => setCartoonView("story-info")}
/>
</div>
)}
{isCartoonStory && cartoonView === "story-info" && selectedStory ? (
<StoryInfoPage storyName={selectedStory} authFetch={authFetch} onSaved={handleStoryInfoSaved} />
) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
Expand All @@ -836,6 +870,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
storyName={selectedStory}
authFetch={authFetch}
onOpenFile={handleSelectFile}
onOpenStoryInfo={() => setCartoonView("story-info")}
/>
) : (
<PreviewPanel
Expand Down
Loading
Loading