plotlink-ows 1.0.32 → 1.2.94
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/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 +811 -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 +10 -3
- 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 +243 -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/publish.ts +209 -28
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -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 +1299 -0
- 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 +1141 -0
- package/app/web/components/PreviewPanel.tsx +1017 -144
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -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-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -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-BFw-v-OZ.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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import ReactMarkdown from "react-markdown";
|
|
2
|
+
import remarkBreaks from "remark-breaks";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
5
|
+
import { summarizeCartoonMarkdown, PROSE_PREVIEW_LIMIT } from "../lib/cartoon-publish-summary";
|
|
6
|
+
import { cartoonPublishVerdict, type CartoonReadinessStage } from "@app-lib/cartoon-readiness";
|
|
7
|
+
|
|
8
|
+
/** Custom sanitizer matching plotlink.xyz — allows img with src, alt, title. */
|
|
9
|
+
const sanitizeSchema = {
|
|
10
|
+
...defaultSchema,
|
|
11
|
+
attributes: {
|
|
12
|
+
...defaultSchema.attributes,
|
|
13
|
+
img: ["src", "alt", "title"],
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const VERDICT_TONE: Record<"ok" | "info" | "warning" | "blocker", string> = {
|
|
18
|
+
ok: "border-green-300 bg-green-50 text-green-800",
|
|
19
|
+
info: "border-accent/30 bg-accent/5 text-foreground",
|
|
20
|
+
warning: "border-amber-300 bg-amber-50 text-amber-800",
|
|
21
|
+
blocker: "border-error/30 bg-error/5 text-error",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface CartoonPublishPreviewProps {
|
|
25
|
+
/** The exact plot-NN.md markdown that will be sent to PlotLink. */
|
|
26
|
+
content: string;
|
|
27
|
+
/** Current readiness stage (from classifyCartoonReadiness), if known. */
|
|
28
|
+
stage: CartoonReadinessStage | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Publish Preview: renders EXACTLY the markdown PlotLink will publish (image
|
|
33
|
+
* blocks plus any prose actually in the markdown), with a compact pre-publish
|
|
34
|
+
* summary — image count, char count, readiness, and any non-image prose that
|
|
35
|
+
* will be published. This is deliberately NOT the cuts.json planning view (see
|
|
36
|
+
* CartoonPreview / Cut Inspector); planning prose must not masquerade as publish
|
|
37
|
+
* content (#289).
|
|
38
|
+
*/
|
|
39
|
+
export function CartoonPublishPreview({ content, stage }: CartoonPublishPreviewProps) {
|
|
40
|
+
const summary = summarizeCartoonMarkdown(content);
|
|
41
|
+
const truncated = summary.nonImageProse.length > PROSE_PREVIEW_LIMIT;
|
|
42
|
+
// Two-axis verdict (#421): "Publish possible?" (hard) vs "Recommended?" (soft),
|
|
43
|
+
// so a placeholder is never shown as simply "Ready to publish".
|
|
44
|
+
const verdict = cartoonPublishVerdict({
|
|
45
|
+
stage,
|
|
46
|
+
imageCount: summary.imageCount,
|
|
47
|
+
hasNonImageProse: summary.nonImageProse.length > 0,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="h-full overflow-y-auto" data-testid="cartoon-publish-preview">
|
|
52
|
+
{/* Compact pre-publish content summary */}
|
|
53
|
+
<div
|
|
54
|
+
className="px-4 py-2 border-b border-border text-[10px] text-muted flex flex-wrap items-center gap-x-3 gap-y-1"
|
|
55
|
+
data-testid="cartoon-publish-summary"
|
|
56
|
+
>
|
|
57
|
+
<span>{summary.imageCount} image{summary.imageCount === 1 ? "" : "s"}</span>
|
|
58
|
+
<span>{summary.charCount.toLocaleString()} / 10,000 chars</span>
|
|
59
|
+
<span
|
|
60
|
+
className={`rounded-full px-2 py-0.5 font-medium ${verdict.possible ? "bg-green-100 text-green-800" : "bg-background text-muted"}`}
|
|
61
|
+
data-testid="publish-possible"
|
|
62
|
+
>
|
|
63
|
+
{verdict.possible ? "Publish possible" : "Publish not possible yet"}
|
|
64
|
+
</span>
|
|
65
|
+
<span
|
|
66
|
+
className={`rounded-full px-2 py-0.5 font-medium ${verdict.recommended ? "bg-green-100 text-green-800" : verdict.tone === "warning" ? "bg-amber-100 text-amber-800" : "bg-background text-muted"}`}
|
|
67
|
+
data-testid="publish-recommended"
|
|
68
|
+
>
|
|
69
|
+
{verdict.recommended ? "Recommended" : "Not recommended yet"}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Plain-language verdict headline + the single next action (#421), so the
|
|
74
|
+
writer sees what to do instead of decoding validator strings. */}
|
|
75
|
+
<div
|
|
76
|
+
className={`px-4 py-2 border-b text-[11px] ${VERDICT_TONE[verdict.tone]}`}
|
|
77
|
+
data-testid="cartoon-publish-verdict"
|
|
78
|
+
>
|
|
79
|
+
<p className="font-medium">{verdict.headline}</p>
|
|
80
|
+
{verdict.detail && <p className="mt-0.5 opacity-90">{verdict.detail}</p>}
|
|
81
|
+
{verdict.action && <p className="mt-0.5 opacity-90">→ {verdict.action}</p>}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Any non-image text in the markdown WILL be published verbatim. Surface
|
|
85
|
+
it explicitly so leftover planning/placeholder prose can't slip past. */}
|
|
86
|
+
{summary.nonImageProse && (
|
|
87
|
+
<div
|
|
88
|
+
className="px-4 py-2 border-b border-amber-300 bg-amber-50 text-[11px] text-amber-800"
|
|
89
|
+
data-testid="cartoon-nonimage-prose"
|
|
90
|
+
>
|
|
91
|
+
<p className="font-medium">⚠ Non-image text in the published markdown:</p>
|
|
92
|
+
<p className="font-mono mt-1 whitespace-pre-wrap break-words">
|
|
93
|
+
{summary.nonImageProsePreview}{truncated ? "…" : ""}
|
|
94
|
+
</p>
|
|
95
|
+
<p className="mt-1">
|
|
96
|
+
This text publishes verbatim around the comic images. Remove it (or re-run
|
|
97
|
+
“Prepare episode for publish”) if it is planning or placeholder prose.
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{/* Exactly what PlotLink renders from the published markdown */}
|
|
103
|
+
<div className="max-w-lg mx-auto px-4 py-6">
|
|
104
|
+
{content.trim() ? (
|
|
105
|
+
<div className="prose max-w-none">
|
|
106
|
+
<ReactMarkdown
|
|
107
|
+
remarkPlugins={[remarkBreaks, remarkGfm]}
|
|
108
|
+
rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
|
|
109
|
+
>
|
|
110
|
+
{content}
|
|
111
|
+
</ReactMarkdown>
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
<p className="text-muted italic text-sm" data-testid="cartoon-publish-empty">
|
|
115
|
+
No publish markdown yet — build it from the cut plan (Edit → Upload & Prepare for Publish).
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { CARTOON_CLEAN_IMAGE_HELP, type CartoonChecklist } from "@app-lib/cartoon-readiness";
|
|
2
|
+
|
|
3
|
+
interface CartoonStepGuideProps {
|
|
4
|
+
checklist: CartoonChecklist | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const STATUS_MARK: Record<"done" | "current" | "todo", string> = {
|
|
8
|
+
done: "✓",
|
|
9
|
+
current: "▸",
|
|
10
|
+
todo: "○",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Granular step checklist for the cartoon plot workspace (#335). Renders the six
|
|
15
|
+
* production steps a creator actually performs — plan cuts → create clean images
|
|
16
|
+
* → add bubbles → export → upload → publish — each with real per-cut status and
|
|
17
|
+
* a plain-language "next step" line, so a first-time writer can tell what to do
|
|
18
|
+
* next without knowing what "markdown generation" means. The checklist is
|
|
19
|
+
* computed upstream (it needs cuts.json + asset/upload/publish state); this just
|
|
20
|
+
* renders it. Renders nothing when there is no checklist (e.g. a fiction plot),
|
|
21
|
+
* so it never appears outside the cartoon flow.
|
|
22
|
+
*/
|
|
23
|
+
export function CartoonStepGuide({ checklist }: CartoonStepGuideProps) {
|
|
24
|
+
if (!checklist || checklist.steps.length === 0) return null;
|
|
25
|
+
const { steps, nextStep } = checklist;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className="w-full max-w-[32rem] flex flex-col gap-3 rounded-xl border border-border bg-surface/70 p-3"
|
|
30
|
+
data-testid="cartoon-step-guide"
|
|
31
|
+
data-layout="diagram"
|
|
32
|
+
>
|
|
33
|
+
<div className="flex items-center justify-between gap-2">
|
|
34
|
+
<span className="text-xs font-medium text-foreground">Episode steps</span>
|
|
35
|
+
<span className="text-[10px] uppercase tracking-[0.18em] text-muted">Flow</span>
|
|
36
|
+
</div>
|
|
37
|
+
<ol className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
38
|
+
{steps.map((s, i) => (
|
|
39
|
+
<li
|
|
40
|
+
key={s.key}
|
|
41
|
+
data-testid={`cartoon-step-${s.key}`}
|
|
42
|
+
data-status={s.status}
|
|
43
|
+
className={`rounded-lg border px-2.5 py-2 text-xs ${
|
|
44
|
+
s.status === "current"
|
|
45
|
+
? "border-accent/40 bg-accent/10 text-accent"
|
|
46
|
+
: s.status === "done"
|
|
47
|
+
? "border-border bg-background/70 text-foreground"
|
|
48
|
+
: "border-border/80 bg-background/50 text-muted"
|
|
49
|
+
}`}
|
|
50
|
+
>
|
|
51
|
+
<div className="flex items-start gap-2">
|
|
52
|
+
<span
|
|
53
|
+
aria-hidden
|
|
54
|
+
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-medium ${
|
|
55
|
+
s.status === "current"
|
|
56
|
+
? "bg-accent text-white"
|
|
57
|
+
: s.status === "done"
|
|
58
|
+
? "bg-foreground text-background"
|
|
59
|
+
: "bg-surface text-muted"
|
|
60
|
+
}`}
|
|
61
|
+
>
|
|
62
|
+
{STATUS_MARK[s.status]}
|
|
63
|
+
</span>
|
|
64
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
65
|
+
<span className="leading-tight">
|
|
66
|
+
{i + 1}. {s.label}
|
|
67
|
+
</span>
|
|
68
|
+
{s.detail && (
|
|
69
|
+
<span className="font-normal text-[10px] text-muted" data-testid={`cartoon-step-${s.key}-detail`}>
|
|
70
|
+
{s.detail}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ol>
|
|
78
|
+
<div className="rounded-lg border border-border/80 bg-background/60 px-3 py-2">
|
|
79
|
+
{nextStep && (
|
|
80
|
+
<span className="block text-xs text-foreground mt-0.5" data-testid="cartoon-next-step">
|
|
81
|
+
Next: {nextStep}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
<span className="mt-1 block text-[11px] text-muted" data-testid="cartoon-clean-image-help">
|
|
85
|
+
{CARTOON_CLEAN_IMAGE_HELP}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent right-panel workflow navigation for cartoon stories (#439, spec §2).
|
|
3
|
+
*
|
|
4
|
+
* A normal webtoon creator should not need the file tree: this compact tab bar
|
|
5
|
+
* sits above the right-panel content whenever a CARTOON story is selected and
|
|
6
|
+
* routes between the workflow pages — Progress, Story Info, Whitepaper, Genesis /
|
|
7
|
+
* Episode 1, Episodes, Publish. The left file tree stays for power users; opening
|
|
8
|
+
* a file directly just reflects the closest workflow tab here.
|
|
9
|
+
*
|
|
10
|
+
* Fiction renders no nav (the caller only mounts this for cartoon stories), so
|
|
11
|
+
* the fiction UX is unchanged.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type CartoonWorkflowTab =
|
|
15
|
+
| "progress"
|
|
16
|
+
| "story-info"
|
|
17
|
+
| "whitepaper"
|
|
18
|
+
| "genesis"
|
|
19
|
+
| "episodes"
|
|
20
|
+
| "publish";
|
|
21
|
+
|
|
22
|
+
const TABS: { key: CartoonWorkflowTab; label: string }[] = [
|
|
23
|
+
{ key: "progress", label: "Progress" },
|
|
24
|
+
{ key: "story-info", label: "Story Info" },
|
|
25
|
+
{ key: "whitepaper", label: "Whitepaper" },
|
|
26
|
+
{ key: "genesis", label: "Genesis / Ep 1" },
|
|
27
|
+
{ key: "episodes", label: "Episodes" },
|
|
28
|
+
{ key: "publish", label: "Publish" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
interface CartoonWorkflowNavProps {
|
|
32
|
+
storyTitle: string;
|
|
33
|
+
active: CartoonWorkflowTab;
|
|
34
|
+
onSelect: (tab: CartoonWorkflowTab) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function CartoonWorkflowNav({ storyTitle, active, onSelect }: CartoonWorkflowNavProps) {
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex-shrink-0 border-b border-border bg-surface/40" data-testid="cartoon-workflow-nav">
|
|
40
|
+
<div className="flex items-center gap-2 px-3 pt-2">
|
|
41
|
+
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-accent">Cartoon</span>
|
|
42
|
+
<span className="text-xs font-serif text-foreground truncate">{storyTitle}</span>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="flex items-center gap-1 px-2 py-1.5 overflow-x-auto" role="tablist">
|
|
45
|
+
{TABS.map((tab) => {
|
|
46
|
+
const isActive = tab.key === active;
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
key={tab.key}
|
|
50
|
+
role="tab"
|
|
51
|
+
aria-selected={isActive}
|
|
52
|
+
data-testid={`nav-tab-${tab.key}`}
|
|
53
|
+
data-active={isActive}
|
|
54
|
+
onClick={() => onSelect(tab.key)}
|
|
55
|
+
className={`flex-shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
|
56
|
+
isActive
|
|
57
|
+
? "bg-accent text-white"
|
|
58
|
+
: "text-muted hover:text-foreground hover:bg-surface"
|
|
59
|
+
}`}
|
|
60
|
+
>
|
|
61
|
+
{tab.label}
|
|
62
|
+
</button>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { listCodexCacheImages, fetchCodexCacheFile, type CodexCacheImage } from "../lib/codex-import";
|
|
3
|
+
|
|
4
|
+
type AuthFetch = (url: string, opts?: RequestInit) => Promise<Response>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Codex generated-image cache picker (#403, visual selection + filtering #409).
|
|
8
|
+
*
|
|
9
|
+
* Lists the recent images in Codex's generated-image cache (newest first) and
|
|
10
|
+
* lets the writer import one straight into the current cut — so a Codex-generated
|
|
11
|
+
* PNG no longer requires hunting through a hidden `~/.codex/generated_images`
|
|
12
|
+
* folder in an OS file dialog. Picking an image fetches its bytes as a File and
|
|
13
|
+
* hands it to `onImport`, which runs the SAME in-browser PNG→WebP conversion +
|
|
14
|
+
* upload-clean path as a manually-selected file, so the asset constraints and
|
|
15
|
+
* upload validation are unchanged.
|
|
16
|
+
*
|
|
17
|
+
* #409: the cache can hold a long run of near-identical `ig_<hash>.png` names, so
|
|
18
|
+
* the picker is built for *visual* selection — a large thumbnail leads each row,
|
|
19
|
+
* the noisy hash filename is demoted to a hover title, and the readable metadata
|
|
20
|
+
* (how recently it was generated + its size) is what the writer reads. A filter
|
|
21
|
+
* box narrows a long list by filename. The list stays read-only until the writer
|
|
22
|
+
* explicitly clicks Import.
|
|
23
|
+
*
|
|
24
|
+
* Read-only and best-effort: a missing/empty cache (e.g. Codex not installed)
|
|
25
|
+
* simply shows an empty state with no error, since this is an optional shortcut
|
|
26
|
+
* over the still-present manual "Upload clean image" button.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Load an auth-protected URL as a blob object URL for an <img> thumbnail. */
|
|
30
|
+
function useAuthedObjectUrl(url: string, authFetch: AuthFetch): string | null {
|
|
31
|
+
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let revoked: string | null = null;
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
(async () => {
|
|
36
|
+
try {
|
|
37
|
+
const res = await authFetch(url);
|
|
38
|
+
if (!res.ok) return;
|
|
39
|
+
const blob = await res.blob();
|
|
40
|
+
if (cancelled) return;
|
|
41
|
+
revoked = URL.createObjectURL(blob);
|
|
42
|
+
setObjectUrl(revoked);
|
|
43
|
+
} catch {
|
|
44
|
+
/* best-effort thumbnail; the row still imports without it */
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
if (revoked) URL.revokeObjectURL(revoked);
|
|
50
|
+
};
|
|
51
|
+
}, [url, authFetch]);
|
|
52
|
+
return objectUrl;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatSize(bytes: number): string {
|
|
56
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
57
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
58
|
+
return `${bytes} B`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Human "how long ago" label for a cache image's mtime (#409). Pure and
|
|
63
|
+
* now-injectable so it's deterministic in tests. The cache lists newest-first, so
|
|
64
|
+
* this is the writer's main cue for "which one did I just generate".
|
|
65
|
+
*/
|
|
66
|
+
export function formatRelativeTime(mtimeMs: number, nowMs: number): string {
|
|
67
|
+
const diff = nowMs - mtimeMs;
|
|
68
|
+
if (!Number.isFinite(diff) || diff < 45_000) return "just now";
|
|
69
|
+
const mins = Math.round(diff / 60_000);
|
|
70
|
+
if (mins < 60) return `${mins}m ago`;
|
|
71
|
+
const hours = Math.round(diff / 3_600_000);
|
|
72
|
+
if (hours < 24) return `${hours}h ago`;
|
|
73
|
+
const days = Math.round(diff / 86_400_000);
|
|
74
|
+
if (days < 7) return `${days}d ago`;
|
|
75
|
+
const weeks = Math.round(diff / (7 * 86_400_000));
|
|
76
|
+
return `${weeks}w ago`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function CodexThumb({ image, authFetch }: { image: CodexCacheImage; authFetch: AuthFetch }) {
|
|
80
|
+
const url = useAuthedObjectUrl(`/api/codex/images/${encodeURIComponent(image.token)}`, authFetch);
|
|
81
|
+
if (!url) {
|
|
82
|
+
return <div className="w-16 h-16 flex-shrink-0 rounded border border-border bg-surface" />;
|
|
83
|
+
}
|
|
84
|
+
return (
|
|
85
|
+
<img
|
|
86
|
+
src={url}
|
|
87
|
+
alt={image.name}
|
|
88
|
+
className="w-16 h-16 flex-shrink-0 rounded border border-border object-cover bg-white"
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function CodexImportPicker({
|
|
94
|
+
authFetch,
|
|
95
|
+
cutId,
|
|
96
|
+
onImport,
|
|
97
|
+
onClose,
|
|
98
|
+
}: {
|
|
99
|
+
authFetch: AuthFetch;
|
|
100
|
+
cutId: number;
|
|
101
|
+
/** Receives the fetched cache file; runs the shared PNG→WebP import + upload. */
|
|
102
|
+
onImport: (file: File) => Promise<void>;
|
|
103
|
+
onClose: () => void;
|
|
104
|
+
}) {
|
|
105
|
+
const [images, setImages] = useState<CodexCacheImage[] | null>(null);
|
|
106
|
+
const [error, setError] = useState<string | null>(null);
|
|
107
|
+
const [importingToken, setImportingToken] = useState<string | null>(null);
|
|
108
|
+
const [query, setQuery] = useState("");
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
let cancelled = false;
|
|
112
|
+
(async () => {
|
|
113
|
+
const list = await listCodexCacheImages(authFetch);
|
|
114
|
+
if (!cancelled) setImages(list);
|
|
115
|
+
})();
|
|
116
|
+
return () => {
|
|
117
|
+
cancelled = true;
|
|
118
|
+
};
|
|
119
|
+
}, [authFetch]);
|
|
120
|
+
|
|
121
|
+
const trimmedQuery = query.trim().toLowerCase();
|
|
122
|
+
const filtered = useMemo(() => {
|
|
123
|
+
if (!images) return [];
|
|
124
|
+
if (!trimmedQuery) return images;
|
|
125
|
+
return images.filter((img) => img.name.toLowerCase().includes(trimmedQuery));
|
|
126
|
+
}, [images, trimmedQuery]);
|
|
127
|
+
|
|
128
|
+
// One timestamp per render so all rows share the same "x ago" reference point.
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
|
|
131
|
+
const handlePick = async (image: CodexCacheImage) => {
|
|
132
|
+
setError(null);
|
|
133
|
+
setImportingToken(image.token);
|
|
134
|
+
try {
|
|
135
|
+
const file = await fetchCodexCacheFile(authFetch, image);
|
|
136
|
+
await onImport(file);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
setError(err instanceof Error ? err.message : "Could not import the generated image");
|
|
139
|
+
} finally {
|
|
140
|
+
setImportingToken(null);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const hasImages = images !== null && images.length > 0;
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
className="rounded border border-border bg-surface/60 p-2 space-y-2"
|
|
149
|
+
data-testid={`codex-picker-${cutId}`}
|
|
150
|
+
>
|
|
151
|
+
<div className="flex items-center justify-between">
|
|
152
|
+
<p className="text-[11px] font-medium text-foreground">Import a Codex-generated image</p>
|
|
153
|
+
<button
|
|
154
|
+
onClick={onClose}
|
|
155
|
+
data-testid={`codex-picker-close-${cutId}`}
|
|
156
|
+
className="text-[11px] text-muted hover:text-foreground"
|
|
157
|
+
>
|
|
158
|
+
Close
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
{hasImages && (
|
|
163
|
+
<div className="flex items-center gap-2">
|
|
164
|
+
<input
|
|
165
|
+
type="search"
|
|
166
|
+
value={query}
|
|
167
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
168
|
+
placeholder="Filter by file name…"
|
|
169
|
+
data-testid={`codex-picker-search-${cutId}`}
|
|
170
|
+
className="min-w-0 flex-1 px-2 py-1 text-[11px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
171
|
+
/>
|
|
172
|
+
<span className="text-[10px] text-muted whitespace-nowrap" data-testid={`codex-picker-count-${cutId}`}>
|
|
173
|
+
{trimmedQuery ? `${filtered.length} of ${images!.length}` : `${images!.length} image${images!.length === 1 ? "" : "s"}`}
|
|
174
|
+
</span>
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
{images === null && (
|
|
179
|
+
<p className="text-[11px] text-muted" data-testid={`codex-picker-loading-${cutId}`}>
|
|
180
|
+
Looking for generated images…
|
|
181
|
+
</p>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{images !== null && images.length === 0 && (
|
|
185
|
+
<p className="text-[11px] text-muted" data-testid={`codex-picker-empty-${cutId}`}>
|
|
186
|
+
No generated images found in the Codex cache yet. Generate art in Codex, then reopen this
|
|
187
|
+
list — or use “Upload clean image” to pick a file.
|
|
188
|
+
</p>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{hasImages && filtered.length === 0 && (
|
|
192
|
+
<p className="text-[11px] text-muted" data-testid={`codex-picker-no-match-${cutId}`}>
|
|
193
|
+
No generated images match “{query.trim()}”.
|
|
194
|
+
</p>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{hasImages && filtered.length > 0 && (
|
|
198
|
+
<ul className="space-y-1 max-h-72 overflow-y-auto">
|
|
199
|
+
{filtered.map((img) => (
|
|
200
|
+
<li
|
|
201
|
+
key={img.token}
|
|
202
|
+
data-testid={`codex-image-${img.token}`}
|
|
203
|
+
className="flex items-center gap-2 rounded border border-border bg-background/40 p-1.5"
|
|
204
|
+
>
|
|
205
|
+
<CodexThumb image={img} authFetch={authFetch} />
|
|
206
|
+
<div className="min-w-0 flex-1">
|
|
207
|
+
<p className="text-[11px] text-foreground">
|
|
208
|
+
{formatRelativeTime(img.mtimeMs, now)} · {formatSize(img.size)}
|
|
209
|
+
</p>
|
|
210
|
+
<p className="truncate text-[10px] font-mono text-muted" title={img.name}>
|
|
211
|
+
{img.name}
|
|
212
|
+
</p>
|
|
213
|
+
</div>
|
|
214
|
+
<button
|
|
215
|
+
onClick={() => handlePick(img)}
|
|
216
|
+
disabled={importingToken !== null}
|
|
217
|
+
data-testid={`codex-import-${img.token}`}
|
|
218
|
+
className="px-2 py-1 text-[11px] border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
|
|
219
|
+
>
|
|
220
|
+
{importingToken === img.token ? "Importing…" : "Import to this cut"}
|
|
221
|
+
</button>
|
|
222
|
+
</li>
|
|
223
|
+
))}
|
|
224
|
+
</ul>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{error && <p className="text-[11px] text-error">{error}</p>}
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|