plotlink-ows 1.0.33 → 1.2.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/app/lib/active-wallet.ts +260 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +813 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +8 -1
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +242 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/dashboard.ts +6 -4
- package/app/routes/publish.ts +259 -45
- package/app/routes/settings.ts +92 -37
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/routes/wallet.ts +58 -30
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonNextAction.tsx +145 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1337 -0
- package/app/web/components/Dashboard.tsx +15 -6
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1182 -0
- package/app/web/components/PreviewPanel.tsx +952 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +745 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +446 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +156 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-DxATSk7X.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/scripts/e2e-verify.ts +0 -1100
package/app/server.ts
CHANGED
|
@@ -22,9 +22,11 @@ import { dashboardRoutes } from "./routes/dashboard";
|
|
|
22
22
|
import { terminalRoutes, attachTerminalWs } from "./routes/terminal";
|
|
23
23
|
import { storiesRoutes } from "./routes/stories";
|
|
24
24
|
import { settingsRoutes } from "./routes/settings";
|
|
25
|
-
import {
|
|
25
|
+
import { agentRoutes } from "./routes/agent";
|
|
26
|
+
import { codexImagesRoutes } from "./routes/codex-images";
|
|
27
|
+
import { db, initDb } from "./db";
|
|
26
28
|
import { generateClaudeMd } from "./lib/generate-claude-md";
|
|
27
|
-
import {
|
|
29
|
+
import { loadSchemaStatements } from "./lib/apply-schema";
|
|
28
30
|
import fs from "fs";
|
|
29
31
|
|
|
30
32
|
const __dirname = __dirnamePre;
|
|
@@ -48,6 +50,10 @@ app.use("/api/stories/*", requireAuth);
|
|
|
48
50
|
app.route("/api/stories", storiesRoutes);
|
|
49
51
|
app.use("/api/settings/*", requireAuth);
|
|
50
52
|
app.route("/api/settings", settingsRoutes);
|
|
53
|
+
app.use("/api/agent/*", requireAuth);
|
|
54
|
+
app.route("/api/agent", agentRoutes);
|
|
55
|
+
app.use("/api/codex/*", requireAuth);
|
|
56
|
+
app.route("/api/codex", codexImagesRoutes);
|
|
51
57
|
|
|
52
58
|
// App version (read once at startup)
|
|
53
59
|
const appVersion = (() => {
|
|
@@ -128,15 +134,36 @@ async function start() {
|
|
|
128
134
|
// Generate/update ~/.plotlink-ows/CLAUDE.md for agent discovery
|
|
129
135
|
generateClaudeMd();
|
|
130
136
|
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
137
|
+
// Bring the local SQLite schema up to date WITHOUT the native Prisma
|
|
138
|
+
// schema-engine. `prisma db push` spawns a platform-specific schema-engine
|
|
139
|
+
// binary that fails to start in some packed prod-only installs (#484, EPIC
|
|
140
|
+
// #465: an empty "Schema engine error:" on macOS arm64). Instead we apply the
|
|
141
|
+
// committed DDL (app/prisma/schema.sql, generated from schema.prisma) through
|
|
142
|
+
// the Prisma client's library query engine — the same engine the app already
|
|
143
|
+
// uses for every query, so if the app runs at all, schema setup runs too.
|
|
144
|
+
// SQLite creates the DB file but NOT its parent dir, so ensure
|
|
145
|
+
// ~/.plotlink-ows/data exists first (a fresh prod-only install has none).
|
|
146
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
147
|
+
const schemaSqlPath = path.join(__dirname, "prisma", "schema.sql");
|
|
148
|
+
try {
|
|
149
|
+
await initDb(); // connect the client (library query engine; no schema-engine)
|
|
150
|
+
// Statements are CREATE TABLE/INDEX IF NOT EXISTS, so this is idempotent and
|
|
151
|
+
// safe to run on every startup against an existing database.
|
|
152
|
+
for (const statement of loadSchemaStatements(schemaSqlPath)) {
|
|
153
|
+
await db.$executeRawUnsafe(statement);
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
// Surface a useful diagnostic instead of a raw stack (#479/#484).
|
|
157
|
+
const home = os.homedir();
|
|
158
|
+
const redact = (s: string) => s.split(home).join("~");
|
|
159
|
+
console.error("\n ✗ Database setup failed (applying schema.sql).");
|
|
160
|
+
console.error(` schema: ${redact(schemaSqlPath)}`);
|
|
161
|
+
console.error(` database: ${redact(DATABASE_URL)}`);
|
|
162
|
+
console.error(` reason: ${err instanceof Error ? err.message : String(err)}`);
|
|
163
|
+
console.error(" This usually means a corrupted install (missing the generated Prisma");
|
|
164
|
+
console.error(" client/query engine or schema.sql). Reinstall with: npx plotlink-ows@latest\n");
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
140
167
|
|
|
141
168
|
const port = Number(process.env.APP_PORT) || 7777;
|
|
142
169
|
const server = serve({ fetch: app.fetch, port }, (info) => {
|
|
@@ -159,8 +186,16 @@ async function start() {
|
|
|
159
186
|
if (!session || session.expiresAt < new Date()) { socket.destroy(); return; }
|
|
160
187
|
const story = url.searchParams.get("story") || undefined;
|
|
161
188
|
const resume = url.searchParams.get("resume") === "true";
|
|
189
|
+
const bypass = url.searchParams.get("bypass") === "true";
|
|
190
|
+
const provider = url.searchParams.get("provider");
|
|
162
191
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
163
|
-
attachTerminalWs(
|
|
192
|
+
attachTerminalWs(
|
|
193
|
+
ws as unknown as WebSocket,
|
|
194
|
+
story,
|
|
195
|
+
resume,
|
|
196
|
+
bypass,
|
|
197
|
+
provider === "claude" || provider === "codex" ? provider : undefined,
|
|
198
|
+
);
|
|
164
199
|
});
|
|
165
200
|
}).catch(() => socket.destroy());
|
|
166
201
|
}
|
package/app/vite.config.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { defineConfig } from "vite";
|
|
2
2
|
import react from "@vitejs/plugin-react";
|
|
3
3
|
import tailwindcss from "@tailwindcss/vite";
|
|
4
|
+
import path from "path";
|
|
4
5
|
|
|
5
6
|
export default defineConfig({
|
|
6
7
|
root: "app/web",
|
|
7
8
|
plugins: [react(), tailwindcss()],
|
|
9
|
+
resolve: {
|
|
10
|
+
alias: {
|
|
11
|
+
"@app-lib": path.resolve(__dirname, "lib"),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
8
14
|
server: {
|
|
9
15
|
port: 5173,
|
|
10
16
|
proxy: {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { StoryProgress } from "@app-lib/story-progress";
|
|
3
|
+
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
4
|
+
import { WorkflowCoachView } from "./WorkflowCoach";
|
|
5
|
+
|
|
6
|
+
export function storyInfoNextStep(progress: StoryProgress): string {
|
|
7
|
+
if (progress.cover !== "present") {
|
|
8
|
+
return progress.cover === "invalid"
|
|
9
|
+
? "Replace the cover image - it must be a valid WebP or JPEG."
|
|
10
|
+
: "Add a cover image before publishing.";
|
|
11
|
+
}
|
|
12
|
+
const missing: string[] = [];
|
|
13
|
+
if (!progress.metadata.language) missing.push("language");
|
|
14
|
+
if (!progress.metadata.genre) missing.push("genre");
|
|
15
|
+
if (!progress.metadata.title) missing.push("title");
|
|
16
|
+
return `Add the story ${missing.join(" and ") || "details"} before publishing.`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function cartoonWorkflowActiveKey(progress: StoryProgress): string | null {
|
|
20
|
+
const coach = progress.coach ?? null;
|
|
21
|
+
const m = progress.metadata;
|
|
22
|
+
const hasStructure = progress.setup.hasStructure;
|
|
23
|
+
const hasGenesis = progress.setup.hasGenesis;
|
|
24
|
+
const coverDone = progress.cover === "present";
|
|
25
|
+
const metadataIncomplete = !m.title || !m.language || !m.genre;
|
|
26
|
+
const activeEp = progress.episodes.find((e) => !e.published) ?? null;
|
|
27
|
+
const productionPending = !!activeEp && activeEp.state !== "ready";
|
|
28
|
+
|
|
29
|
+
if (!hasStructure) return "whitepaper";
|
|
30
|
+
if (!hasGenesis) return "genesis.md";
|
|
31
|
+
if (metadataIncomplete) return "story-info";
|
|
32
|
+
if (productionPending && coach?.episodeFile) return coach.episodeFile;
|
|
33
|
+
if (!coverDone) return "story-info";
|
|
34
|
+
return coach?.episodeFile ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function StoryInfoNextActionCard({
|
|
38
|
+
progress,
|
|
39
|
+
onOpenStoryInfo,
|
|
40
|
+
}: {
|
|
41
|
+
progress: StoryProgress;
|
|
42
|
+
onOpenStoryInfo?: () => void;
|
|
43
|
+
}) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="m-3 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 shadow-sm" data-testid="story-info-cta">
|
|
46
|
+
<div className="flex items-center gap-3">
|
|
47
|
+
<div className="min-w-0 flex-1">
|
|
48
|
+
<span className="inline-flex rounded-full bg-background px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent">
|
|
49
|
+
Story info
|
|
50
|
+
</span>
|
|
51
|
+
<p className="mt-1 text-sm text-foreground" data-testid="story-info-next-action">
|
|
52
|
+
<span className="font-semibold">Next: </span>
|
|
53
|
+
<span>{storyInfoNextStep(progress)}</span>
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={onOpenStoryInfo}
|
|
59
|
+
disabled={!onOpenStoryInfo}
|
|
60
|
+
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"
|
|
61
|
+
data-testid="story-info-next-action-btn"
|
|
62
|
+
>
|
|
63
|
+
Next Action
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function CartoonNextActionView({
|
|
71
|
+
progress,
|
|
72
|
+
onCoachAction,
|
|
73
|
+
onOpenStoryInfo,
|
|
74
|
+
}: {
|
|
75
|
+
progress: StoryProgress;
|
|
76
|
+
onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
77
|
+
onOpenStoryInfo?: () => void;
|
|
78
|
+
}) {
|
|
79
|
+
const activeKey = cartoonWorkflowActiveKey(progress);
|
|
80
|
+
if (activeKey === "story-info") {
|
|
81
|
+
return <StoryInfoNextActionCard progress={progress} onOpenStoryInfo={onOpenStoryInfo} />;
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
<WorkflowCoachView
|
|
85
|
+
coach={progress.coach ?? null}
|
|
86
|
+
showEmptyState
|
|
87
|
+
onAction={onCoachAction}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function CartoonNextAction({
|
|
93
|
+
storyName,
|
|
94
|
+
authFetch,
|
|
95
|
+
refreshKey = 0,
|
|
96
|
+
onCoachAction,
|
|
97
|
+
onOpenStoryInfo,
|
|
98
|
+
}: {
|
|
99
|
+
storyName: string;
|
|
100
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
101
|
+
refreshKey?: number;
|
|
102
|
+
onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
103
|
+
onOpenStoryInfo?: () => void;
|
|
104
|
+
}) {
|
|
105
|
+
const [progress, setProgress] = useState<StoryProgress | null | undefined>(undefined);
|
|
106
|
+
|
|
107
|
+
const targetKey = JSON.stringify([storyName, refreshKey]);
|
|
108
|
+
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
|
109
|
+
if (loadedKey !== targetKey) {
|
|
110
|
+
setProgress(undefined);
|
|
111
|
+
setLoadedKey(targetKey);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
let cancelled = false;
|
|
116
|
+
authFetch(`/api/stories/${storyName}/progress`)
|
|
117
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
118
|
+
.then((data: StoryProgress | null) => {
|
|
119
|
+
if (!cancelled) setProgress(isValidProgress(data) ? data : null);
|
|
120
|
+
})
|
|
121
|
+
.catch(() => {
|
|
122
|
+
if (!cancelled) setProgress(null);
|
|
123
|
+
});
|
|
124
|
+
return () => { cancelled = true; };
|
|
125
|
+
}, [storyName, authFetch, refreshKey]);
|
|
126
|
+
|
|
127
|
+
if (progress === undefined) return null;
|
|
128
|
+
if (!progress) {
|
|
129
|
+
return <WorkflowCoachView coach={null} showEmptyState onAction={onCoachAction} />;
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<CartoonNextActionView
|
|
133
|
+
progress={progress}
|
|
134
|
+
onCoachAction={onCoachAction}
|
|
135
|
+
onOpenStoryInfo={onOpenStoryInfo}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isValidProgress(data: StoryProgress | null): data is StoryProgress {
|
|
141
|
+
return !!data
|
|
142
|
+
&& !!data.metadata
|
|
143
|
+
&& !!data.setup
|
|
144
|
+
&& Array.isArray(data.episodes);
|
|
145
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { AssetImage } from "./asset-image";
|
|
3
|
+
import { cutNextAction } from "@app-lib/cuts";
|
|
4
|
+
|
|
5
|
+
type AuthFetch = (url: string, opts?: RequestInit) => Promise<Response>;
|
|
6
|
+
|
|
7
|
+
interface CutDialogue {
|
|
8
|
+
speaker: string;
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Cut {
|
|
13
|
+
id: number;
|
|
14
|
+
shotType: string;
|
|
15
|
+
description: string;
|
|
16
|
+
characters: string[];
|
|
17
|
+
dialogue: CutDialogue[];
|
|
18
|
+
narration: string;
|
|
19
|
+
sfx: string;
|
|
20
|
+
cleanImagePath: string | null;
|
|
21
|
+
finalImagePath: string | null;
|
|
22
|
+
exportedAt: string | null;
|
|
23
|
+
uploadedCid: string | null;
|
|
24
|
+
uploadedUrl: string | null;
|
|
25
|
+
kind?: "image" | "text";
|
|
26
|
+
background?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CutsFile {
|
|
30
|
+
version: number;
|
|
31
|
+
plotFile: string;
|
|
32
|
+
cuts: Cut[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CartoonPreviewProps {
|
|
36
|
+
storyName: string;
|
|
37
|
+
fileName: string;
|
|
38
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
39
|
+
// #371: deep-link from a cut's next-action CTA into the Edit tab for that exact
|
|
40
|
+
// cut. `opensEditor` is whether the lettering editor can open directly (clean
|
|
41
|
+
// image / text panel / final) vs. just focusing the row to add clean art.
|
|
42
|
+
onEditCut?: (cutId: number, opensEditor: boolean) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function TextOverlay({ cut }: { cut: Cut }) {
|
|
46
|
+
const hasText = cut.dialogue.length > 0 || cut.narration || cut.sfx;
|
|
47
|
+
if (!hasText) return null;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-1.5" data-testid={`cut-${cut.id}-overlay`}>
|
|
51
|
+
{cut.dialogue.map((d, i) => (
|
|
52
|
+
<div key={i} className="flex gap-2 text-xs">
|
|
53
|
+
<span className="font-medium text-foreground flex-shrink-0">{d.speaker}:</span>
|
|
54
|
+
<span className="text-foreground">{d.text}</span>
|
|
55
|
+
</div>
|
|
56
|
+
))}
|
|
57
|
+
{cut.narration && (
|
|
58
|
+
<div className="border-l-2 border-border pl-3">
|
|
59
|
+
<p className="text-xs text-muted italic">{cut.narration}</p>
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
{cut.sfx && (
|
|
63
|
+
<p className="text-xs font-mono text-muted">SFX: {cut.sfx}</p>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function CutCard({ cut, storyName, authFetch, onEditCut }: { cut: Cut; storyName: string; authFetch: AuthFetch; onEditCut?: (cutId: number, opensEditor: boolean) => void }) {
|
|
70
|
+
const hasFinal = !!cut.finalImagePath;
|
|
71
|
+
const hasClean = !!cut.cleanImagePath;
|
|
72
|
+
const hasImage = hasFinal || hasClean;
|
|
73
|
+
// A cut with no clean/final image is a planned image cut whose art is still
|
|
74
|
+
// pending — even if narration/dialogue text already exists in cuts.json. It is
|
|
75
|
+
// NOT a finished narration-only card.
|
|
76
|
+
const hasPlannedText = cut.dialogue.length > 0 || !!cut.narration || !!cut.sfx;
|
|
77
|
+
const isTextPanel = cut.kind === "text";
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
{/* Cut header */}
|
|
82
|
+
<div className="flex items-center gap-2">
|
|
83
|
+
<span className="text-[10px] font-mono text-muted bg-surface border border-border rounded px-1.5 py-0.5">
|
|
84
|
+
#{cut.id}
|
|
85
|
+
</span>
|
|
86
|
+
<span className="text-[10px] font-mono text-muted">{cut.shotType}</span>
|
|
87
|
+
{cut.characters.length > 0 && (
|
|
88
|
+
<span className="text-[10px] text-muted truncate">
|
|
89
|
+
{cut.characters.join(", ")}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Final image — lettered, no overlay needed */}
|
|
95
|
+
{hasFinal && (
|
|
96
|
+
<AssetImage
|
|
97
|
+
storyName={storyName}
|
|
98
|
+
assetPath={cut.finalImagePath!}
|
|
99
|
+
authFetch={authFetch}
|
|
100
|
+
alt={cut.description || `Cut ${cut.id}`}
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{/* Clean image with text overlay */}
|
|
105
|
+
{!hasFinal && hasClean && (
|
|
106
|
+
<div className="border border-border rounded overflow-hidden">
|
|
107
|
+
<AssetImage
|
|
108
|
+
storyName={storyName}
|
|
109
|
+
assetPath={cut.cleanImagePath!}
|
|
110
|
+
authFetch={authFetch}
|
|
111
|
+
alt={cut.description || `Cut ${cut.id}`}
|
|
112
|
+
/>
|
|
113
|
+
<div className="px-3 py-2 bg-surface/80 border-t border-border">
|
|
114
|
+
<TextOverlay cut={cut} />
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{/* Intentional text/interstitial panel (#351) — not pending art. Shows on
|
|
120
|
+
its styled background; the text is the panel content, not a caption. */}
|
|
121
|
+
{!hasImage && isTextPanel && (
|
|
122
|
+
<div
|
|
123
|
+
className="w-full border border-border rounded p-4 space-y-2"
|
|
124
|
+
style={{ background: cut.background || undefined }}
|
|
125
|
+
data-testid={`cut-${cut.id}-textpanel`}
|
|
126
|
+
>
|
|
127
|
+
<span className="text-[10px] font-mono text-muted">Text panel</span>
|
|
128
|
+
{hasPlannedText ? (
|
|
129
|
+
<TextOverlay cut={cut} />
|
|
130
|
+
) : (
|
|
131
|
+
<p className="text-xs text-muted italic">Empty text panel — open the editor to add text.</p>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{/* Planned image cut — art not generated/uploaded yet */}
|
|
137
|
+
{!hasImage && !isTextPanel && (
|
|
138
|
+
<div
|
|
139
|
+
className="w-full bg-surface border border-dashed border-border rounded p-4 space-y-2"
|
|
140
|
+
data-testid={`cut-${cut.id}-pending`}
|
|
141
|
+
>
|
|
142
|
+
<div className="aspect-video flex flex-col items-center justify-center gap-1 text-center">
|
|
143
|
+
<span className="text-xs text-muted font-medium">Image pending</span>
|
|
144
|
+
<span className="text-[10px] text-muted">Planned image cut — generate & upload the art</span>
|
|
145
|
+
</div>
|
|
146
|
+
{hasPlannedText && (
|
|
147
|
+
<div className="border-t border-dashed border-border pt-2 space-y-1">
|
|
148
|
+
<span className="text-[10px] font-mono text-muted">Planned text (will be lettered onto the image)</span>
|
|
149
|
+
<TextOverlay cut={cut} />
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{/* Description */}
|
|
156
|
+
{cut.description && (
|
|
157
|
+
<p className="text-xs text-muted italic">{cut.description}</p>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{/* Text shown below final images (already lettered, so just metadata) */}
|
|
161
|
+
{hasFinal && (
|
|
162
|
+
<TextOverlay cut={cut} />
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
{/* #371: direct next-action CTA — jumps to the Edit tab for THIS cut so the
|
|
166
|
+
writer never has to hunt for it in the cut list. Works for image cuts
|
|
167
|
+
and text/interstitial panels alike. */}
|
|
168
|
+
{onEditCut && (() => {
|
|
169
|
+
const action = cutNextAction(cut);
|
|
170
|
+
return (
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
data-testid={`cut-${cut.id}-cta`}
|
|
174
|
+
data-cut-action={action.key}
|
|
175
|
+
onClick={() => onEditCut(cut.id, action.opensEditor)}
|
|
176
|
+
className="w-full px-3 py-1.5 text-xs font-medium rounded bg-accent text-white hover:bg-accent-dim"
|
|
177
|
+
>
|
|
178
|
+
{action.label}
|
|
179
|
+
</button>
|
|
180
|
+
);
|
|
181
|
+
})()}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function CartoonPreview({ storyName, fileName, authFetch, onEditCut }: CartoonPreviewProps) {
|
|
187
|
+
const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
|
|
188
|
+
const [loading, setLoading] = useState(true);
|
|
189
|
+
const [error, setError] = useState<string | null>(null);
|
|
190
|
+
|
|
191
|
+
const plotFile = fileName.replace(/\.md$/, "");
|
|
192
|
+
|
|
193
|
+
const loadCuts = useCallback(async () => {
|
|
194
|
+
setLoading(true);
|
|
195
|
+
setError(null);
|
|
196
|
+
try {
|
|
197
|
+
const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`);
|
|
198
|
+
if (res.status === 404) {
|
|
199
|
+
setCutsFile(null);
|
|
200
|
+
setLoading(false);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const data = await res.json();
|
|
205
|
+
setError(data.error || "Failed to load cuts");
|
|
206
|
+
setLoading(false);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const data = await res.json();
|
|
210
|
+
setCutsFile(data);
|
|
211
|
+
} catch {
|
|
212
|
+
setError("Failed to load cuts");
|
|
213
|
+
} finally {
|
|
214
|
+
setLoading(false);
|
|
215
|
+
}
|
|
216
|
+
}, [authFetch, storyName, plotFile]);
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
loadCuts();
|
|
220
|
+
const interval = setInterval(loadCuts, 5000);
|
|
221
|
+
return () => clearInterval(interval);
|
|
222
|
+
}, [loadCuts]);
|
|
223
|
+
|
|
224
|
+
if (loading && !cutsFile) {
|
|
225
|
+
return (
|
|
226
|
+
<div className="h-full flex items-center justify-center text-muted text-sm">
|
|
227
|
+
Loading cuts...
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (error) {
|
|
233
|
+
return (
|
|
234
|
+
<div className="h-full flex flex-col items-center justify-center gap-2 px-4 text-center" data-testid="cuts-error">
|
|
235
|
+
<p className="text-sm text-error font-medium">Invalid cuts file</p>
|
|
236
|
+
<p className="text-xs text-error">{error}</p>
|
|
237
|
+
<p className="text-xs text-muted max-w-sm">
|
|
238
|
+
{plotFile}.cuts.json must follow the OWS v1 schema. Ask Claude to regenerate it using the v1 cuts schema shown in the cartoon writing instructions.
|
|
239
|
+
</p>
|
|
240
|
+
<button onClick={loadCuts} className="text-xs text-accent hover:text-accent-dim">
|
|
241
|
+
Retry
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!cutsFile || cutsFile.cuts.length === 0) {
|
|
248
|
+
return (
|
|
249
|
+
<div className="h-full flex flex-col items-center justify-center gap-2 px-4 text-center">
|
|
250
|
+
<p className="text-sm text-muted">No cuts yet</p>
|
|
251
|
+
<p className="text-xs text-muted">
|
|
252
|
+
Ask Claude to create a cut plan for this episode.
|
|
253
|
+
</p>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div className="h-full overflow-y-auto">
|
|
260
|
+
<div className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
|
261
|
+
{cutsFile.cuts.map((cut) => (
|
|
262
|
+
<CutCard key={cut.id} cut={cut} storyName={storyName} authFetch={authFetch} onEditCut={onEditCut} />
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|