plotlink-ows 1.0.33 → 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.
Files changed (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +8 -1
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +203 -22
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +951 -78
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. 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 &amp; 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 &ldquo;Upload clean image&rdquo; 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 &ldquo;{query.trim()}&rdquo;.
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
+ }