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
|
@@ -15,6 +15,7 @@ interface StoryInfo {
|
|
|
15
15
|
hasGenesis: boolean;
|
|
16
16
|
plotCount: number;
|
|
17
17
|
publishedCount: number;
|
|
18
|
+
contentType?: "fiction" | "cartoon";
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
interface StoryBrowserProps {
|
|
@@ -103,32 +104,36 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
103
104
|
}
|
|
104
105
|
}, [selectedStory]);
|
|
105
106
|
|
|
107
|
+
const toggleExpand = (name: string) => {
|
|
108
|
+
setExpanded((prev) => {
|
|
109
|
+
const next = new Set(prev);
|
|
110
|
+
if (next.has(name)) next.delete(name);
|
|
111
|
+
else next.add(name);
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
106
116
|
const getLatestFile = (files: FileStatus[]): string | null => {
|
|
107
|
-
// Latest plot by highest number
|
|
108
117
|
const plots = files
|
|
109
118
|
.map((f) => ({ file: f.file, num: f.file.match(/^plot-(\d+)\.md$/)?.[1] }))
|
|
110
119
|
.filter((p) => p.num != null)
|
|
111
120
|
.sort((a, b) => parseInt(b.num!) - parseInt(a.num!));
|
|
112
121
|
if (plots.length > 0) return plots[0].file;
|
|
113
|
-
// Fallback: genesis, then structure
|
|
114
122
|
if (files.some((f) => f.file === "genesis.md")) return "genesis.md";
|
|
115
123
|
if (files.some((f) => f.file === "structure.md")) return "structure.md";
|
|
116
124
|
return files[0]?.file ?? null;
|
|
117
125
|
};
|
|
118
126
|
|
|
119
|
-
const toggleExpand = (name: string) => {
|
|
120
|
-
setExpanded((prev) => {
|
|
121
|
-
const next = new Set(prev);
|
|
122
|
-
if (next.has(name)) next.delete(name);
|
|
123
|
-
else next.add(name);
|
|
124
|
-
return next;
|
|
125
|
-
});
|
|
126
|
-
};
|
|
127
|
-
|
|
128
127
|
const handleStoryClick = (story: StoryInfo) => {
|
|
129
128
|
toggleExpand(story.name);
|
|
130
|
-
//
|
|
131
|
-
|
|
129
|
+
// Cartoon: a root-row click opens the story-level progress overview (#418) on
|
|
130
|
+
// EVERY click (an empty file selection reveals it), incl. when already
|
|
131
|
+
// expanded with a file open. Fiction PRESERVES the existing auto-open-latest-
|
|
132
|
+
// file behavior — fiction can still reach the overview via the "Progress"
|
|
133
|
+
// button in the file header. File rows below open a specific file.
|
|
134
|
+
if (story.contentType === "cartoon") {
|
|
135
|
+
onSelectFile(story.name, "");
|
|
136
|
+
} else {
|
|
132
137
|
const latest = getLatestFile(story.files);
|
|
133
138
|
if (latest) onSelectFile(story.name, latest);
|
|
134
139
|
}
|
|
@@ -230,7 +235,10 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
230
235
|
>
|
|
231
236
|
<span className="text-xs text-muted">{expanded.has(story.name) ? "\u25BC" : "\u25B6"}</span>
|
|
232
237
|
<span className="font-medium truncate" title={story.name}>{story.title || story.name}</span>
|
|
233
|
-
|
|
238
|
+
{story.contentType === "cartoon" && (
|
|
239
|
+
<span className="bg-accent/10 text-accent rounded px-1.5 py-0.5 text-[10px] font-medium flex-shrink-0">Cartoon</span>
|
|
240
|
+
)}
|
|
241
|
+
<span className="ml-auto flex-shrink-0 text-xs text-muted">
|
|
234
242
|
{story.publishedCount}/{story.files.length}
|
|
235
243
|
</span>
|
|
236
244
|
</button>
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { GENRES, LANGUAGES, canonicalizeGenre } from "../../../lib/genres";
|
|
3
|
+
import { importImageToCompliantBlob } from "../lib/import-image";
|
|
4
|
+
|
|
5
|
+
interface StoryInfoPageProps {
|
|
6
|
+
storyName: string;
|
|
7
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
8
|
+
/** Notify the parent of saved publish metadata so its maps stay in sync. */
|
|
9
|
+
onSaved?: (patch: { genre?: string; language?: string; isNsfw?: boolean }) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type CoverState = "missing" | "present" | "invalid" | "unknown";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dedicated "Define Story Info" page for cartoon stories (#439, spec §4).
|
|
16
|
+
*
|
|
17
|
+
* Centralizes the public story-token metadata — title, short description, genre,
|
|
18
|
+
* language, read-only content type, adult flag, and cover — into one clear page,
|
|
19
|
+
* so Genesis can stay about the reader-facing Episode 1 content instead of
|
|
20
|
+
* feeling like a publish form. All fields persist to `.story.json` through the
|
|
21
|
+
* existing `/publish-metadata` route (no new on-chain contract surface); the
|
|
22
|
+
* cover reuses the browser-convert → `/import-cover` flow the publish panel uses.
|
|
23
|
+
*
|
|
24
|
+
* Cartoon-only: the caller mounts this from the cartoon workflow nav, so fiction
|
|
25
|
+
* metadata/publish behavior is untouched.
|
|
26
|
+
*/
|
|
27
|
+
export function StoryInfoPage({ storyName, authFetch, onSaved }: StoryInfoPageProps) {
|
|
28
|
+
const [loading, setLoading] = useState(true);
|
|
29
|
+
const [loadError, setLoadError] = useState(false);
|
|
30
|
+
|
|
31
|
+
const [title, setTitle] = useState("");
|
|
32
|
+
const [description, setDescription] = useState("");
|
|
33
|
+
const [genre, setGenre] = useState("");
|
|
34
|
+
const [language, setLanguage] = useState("");
|
|
35
|
+
const [isNsfw, setIsNsfw] = useState(false);
|
|
36
|
+
const [contentType, setContentType] = useState<"fiction" | "cartoon">("cartoon");
|
|
37
|
+
const [cover, setCover] = useState<CoverState>("unknown");
|
|
38
|
+
|
|
39
|
+
const [saving, setSaving] = useState(false);
|
|
40
|
+
const [saved, setSaved] = useState(false);
|
|
41
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
42
|
+
const [importing, setImporting] = useState(false);
|
|
43
|
+
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
|
44
|
+
const [promptCopied, setPromptCopied] = useState(false);
|
|
45
|
+
const coverInputRef = useRef<HTMLInputElement>(null);
|
|
46
|
+
|
|
47
|
+
// Load current values: story detail for the text fields (incl. description),
|
|
48
|
+
// and the progress payload for the derived cover state. Reset display state on
|
|
49
|
+
// every exit path so a failed reload can never leave stale fields showing.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
setLoading(true);
|
|
53
|
+
setLoadError(false);
|
|
54
|
+
setSaved(false);
|
|
55
|
+
setSaveError(null);
|
|
56
|
+
(async () => {
|
|
57
|
+
try {
|
|
58
|
+
const [detailRes, progressRes] = await Promise.all([
|
|
59
|
+
authFetch(`/api/stories/${storyName}`),
|
|
60
|
+
authFetch(`/api/stories/${storyName}/progress`),
|
|
61
|
+
]);
|
|
62
|
+
if (!detailRes.ok) { if (!cancelled) { setLoadError(true); setLoading(false); } return; }
|
|
63
|
+
const detail = await detailRes.json();
|
|
64
|
+
const progress = progressRes.ok ? await progressRes.json().catch(() => null) : null;
|
|
65
|
+
if (cancelled) return;
|
|
66
|
+
setTitle(detail.title ?? "");
|
|
67
|
+
setDescription(detail.description ?? "");
|
|
68
|
+
setGenre(canonicalizeGenre(detail.genre) ?? "");
|
|
69
|
+
setLanguage((detail.language && LANGUAGES.find((l) => l.toLowerCase() === detail.language.toLowerCase())) || "");
|
|
70
|
+
setIsNsfw(!!detail.isNsfw);
|
|
71
|
+
setContentType(detail.contentType === "fiction" ? "fiction" : "cartoon");
|
|
72
|
+
setCover(progress?.cover ?? "unknown");
|
|
73
|
+
setLoading(false);
|
|
74
|
+
} catch {
|
|
75
|
+
if (!cancelled) { setLoadError(true); setLoading(false); }
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
return () => { cancelled = true; };
|
|
79
|
+
}, [storyName, authFetch]);
|
|
80
|
+
|
|
81
|
+
const handleSave = useCallback(async () => {
|
|
82
|
+
setSaving(true);
|
|
83
|
+
setSaved(false);
|
|
84
|
+
setSaveError(null);
|
|
85
|
+
const patch = { title: title.trim(), description: description.trim(), genre, language, isNsfw };
|
|
86
|
+
try {
|
|
87
|
+
const res = await authFetch(`/api/stories/${storyName}/publish-metadata`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify(patch),
|
|
91
|
+
});
|
|
92
|
+
if (res.ok) {
|
|
93
|
+
setSaved(true);
|
|
94
|
+
onSaved?.({ genre, language, isNsfw });
|
|
95
|
+
} else {
|
|
96
|
+
const data = await res.json().catch(() => ({}));
|
|
97
|
+
setSaveError(data.error || "Could not save story info.");
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
setSaveError("Could not save story info.");
|
|
101
|
+
}
|
|
102
|
+
setSaving(false);
|
|
103
|
+
}, [storyName, authFetch, title, description, genre, language, isNsfw, onSaved]);
|
|
104
|
+
|
|
105
|
+
const handleCoverImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
106
|
+
const file = e.target.files?.[0];
|
|
107
|
+
if (coverInputRef.current) coverInputRef.current.value = "";
|
|
108
|
+
if (!file) return;
|
|
109
|
+
setImporting(true);
|
|
110
|
+
setSaveError(null);
|
|
111
|
+
try {
|
|
112
|
+
let blob: Blob;
|
|
113
|
+
try {
|
|
114
|
+
blob = await importImageToCompliantBlob(file);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
setSaveError(err instanceof Error ? err.message : "Could not import image");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const ext = blob.type === "image/jpeg" ? "jpg" : "webp";
|
|
120
|
+
const imported = new File([blob], `cover.${ext}`, { type: blob.type });
|
|
121
|
+
const formData = new FormData();
|
|
122
|
+
formData.append("file", imported);
|
|
123
|
+
const res = await authFetch(`/api/stories/${storyName}/import-cover`, { method: "POST", body: formData });
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
const data = await res.json().catch(() => ({}));
|
|
126
|
+
setSaveError(data.error || "Cover import failed.");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
setCover("present");
|
|
130
|
+
setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(imported); });
|
|
131
|
+
} catch {
|
|
132
|
+
setSaveError("Cover import failed.");
|
|
133
|
+
} finally {
|
|
134
|
+
setImporting(false);
|
|
135
|
+
}
|
|
136
|
+
}, [storyName, authFetch]);
|
|
137
|
+
|
|
138
|
+
const copyCoverPrompt = useCallback(() => {
|
|
139
|
+
const prompt = `Generate a cover image for this story (${title || storyName}) and save it as assets/cover.webp — portrait 600x900, WebP, under 1MB. Don't publish.`;
|
|
140
|
+
navigator.clipboard?.writeText(prompt).then(() => { setPromptCopied(true); }).catch(() => {});
|
|
141
|
+
}, [title, storyName]);
|
|
142
|
+
|
|
143
|
+
if (loading) {
|
|
144
|
+
return <div className="h-full flex items-center justify-center text-muted text-sm" data-testid="story-info-loading">Loading story info…</div>;
|
|
145
|
+
}
|
|
146
|
+
if (loadError) {
|
|
147
|
+
return <div className="h-full flex items-center justify-center text-muted text-sm">Could not load story info.</div>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const coverLabel = cover === "present" ? "Cover set" : cover === "invalid" ? "Invalid cover — re-import a WebP/JPEG under 1MB" : "Missing cover";
|
|
151
|
+
const coverTone = cover === "present" ? "text-green-700" : cover === "invalid" ? "text-amber-700" : "text-muted";
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className="h-full overflow-y-auto px-4 py-4" data-testid="story-info-page">
|
|
155
|
+
<h2 className="text-base font-serif text-foreground">Story Info</h2>
|
|
156
|
+
<p className="mt-0.5 text-[11px] text-muted">These details appear on PlotLink when the story is published.</p>
|
|
157
|
+
|
|
158
|
+
<div className="mt-4 flex flex-col gap-4 max-w-xl">
|
|
159
|
+
<label className="flex flex-col gap-1">
|
|
160
|
+
<span className="text-[11px] font-medium text-muted">Public title</span>
|
|
161
|
+
<input
|
|
162
|
+
type="text" value={title} onChange={(e) => { setTitle(e.target.value); setSaved(false); }}
|
|
163
|
+
data-testid="story-info-title"
|
|
164
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
165
|
+
/>
|
|
166
|
+
</label>
|
|
167
|
+
|
|
168
|
+
<label className="flex flex-col gap-1">
|
|
169
|
+
<span className="text-[11px] font-medium text-muted">Short description</span>
|
|
170
|
+
<textarea
|
|
171
|
+
value={description} onChange={(e) => { setDescription(e.target.value); setSaved(false); }}
|
|
172
|
+
rows={3} data-testid="story-info-description"
|
|
173
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none resize-y"
|
|
174
|
+
/>
|
|
175
|
+
</label>
|
|
176
|
+
|
|
177
|
+
<div className="flex flex-wrap gap-4">
|
|
178
|
+
<label className="flex flex-col gap-1 min-w-[140px] flex-1">
|
|
179
|
+
<span className="text-[11px] font-medium text-muted">Genre</span>
|
|
180
|
+
<select
|
|
181
|
+
value={genre} onChange={(e) => { setGenre(e.target.value); setSaved(false); }}
|
|
182
|
+
data-testid="story-info-genre"
|
|
183
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
184
|
+
>
|
|
185
|
+
<option value="">Needs metadata</option>
|
|
186
|
+
{GENRES.map((g) => <option key={g} value={g}>{g}</option>)}
|
|
187
|
+
</select>
|
|
188
|
+
</label>
|
|
189
|
+
|
|
190
|
+
<label className="flex flex-col gap-1 min-w-[140px] flex-1">
|
|
191
|
+
<span className="text-[11px] font-medium text-muted">Language</span>
|
|
192
|
+
<select
|
|
193
|
+
value={language} onChange={(e) => { setLanguage(e.target.value); setSaved(false); }}
|
|
194
|
+
data-testid="story-info-language"
|
|
195
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
196
|
+
>
|
|
197
|
+
<option value="">Needs metadata</option>
|
|
198
|
+
{LANGUAGES.map((l) => <option key={l} value={l}>{l}</option>)}
|
|
199
|
+
</select>
|
|
200
|
+
</label>
|
|
201
|
+
|
|
202
|
+
<label className="flex flex-col gap-1 min-w-[140px] flex-1">
|
|
203
|
+
<span className="text-[11px] font-medium text-muted">Content type</span>
|
|
204
|
+
<span
|
|
205
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-surface text-muted"
|
|
206
|
+
data-testid="story-info-content-type"
|
|
207
|
+
title="Content type is locked after creation."
|
|
208
|
+
>
|
|
209
|
+
{contentType === "cartoon" ? "Cartoon · locked" : "Fiction · locked"}
|
|
210
|
+
</span>
|
|
211
|
+
</label>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div className="flex flex-col gap-1.5">
|
|
215
|
+
<span className="text-[11px] font-medium text-muted">Cover image</span>
|
|
216
|
+
<div className="flex items-start gap-3">
|
|
217
|
+
{coverPreview && (
|
|
218
|
+
<img src={coverPreview} alt="Cover preview" className="w-16 h-24 object-cover rounded border border-border" />
|
|
219
|
+
)}
|
|
220
|
+
<div className="flex flex-col gap-1.5">
|
|
221
|
+
<span className={`text-[11px] font-medium ${coverTone}`} data-testid="story-info-cover-status">{coverLabel}</span>
|
|
222
|
+
<span className="text-[10px] text-muted">WebP or JPEG, max 1MB, 600×900 recommended.</span>
|
|
223
|
+
<div className="flex items-center gap-2">
|
|
224
|
+
<button
|
|
225
|
+
type="button" onClick={() => coverInputRef.current?.click()} disabled={importing}
|
|
226
|
+
data-testid="story-info-import-cover"
|
|
227
|
+
className="rounded border border-border px-2.5 py-1 text-[11px] text-foreground hover:border-accent hover:text-accent transition-colors disabled:opacity-50"
|
|
228
|
+
>
|
|
229
|
+
{importing ? "Importing…" : "Import cover"}
|
|
230
|
+
</button>
|
|
231
|
+
<button
|
|
232
|
+
type="button" onClick={copyCoverPrompt}
|
|
233
|
+
data-testid="story-info-cover-prompt"
|
|
234
|
+
className="rounded border border-border px-2.5 py-1 text-[11px] text-muted hover:border-accent hover:text-accent transition-colors"
|
|
235
|
+
>
|
|
236
|
+
{promptCopied ? "Copied!" : "Ask agent for cover prompt"}
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
<input ref={coverInputRef} type="file" accept="image/*" onChange={handleCoverImport} className="hidden" />
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<label className="flex items-center gap-2">
|
|
245
|
+
<input
|
|
246
|
+
type="checkbox" checked={isNsfw} onChange={(e) => { setIsNsfw(e.target.checked); setSaved(false); }}
|
|
247
|
+
data-testid="story-info-nsfw"
|
|
248
|
+
/>
|
|
249
|
+
<span className="text-xs text-foreground">This story contains adult content (18+)</span>
|
|
250
|
+
</label>
|
|
251
|
+
|
|
252
|
+
<div className="flex items-center gap-3">
|
|
253
|
+
<button
|
|
254
|
+
type="button" onClick={handleSave} disabled={saving}
|
|
255
|
+
data-testid="story-info-save"
|
|
256
|
+
className="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
|
|
257
|
+
>
|
|
258
|
+
{saving ? "Saving…" : "Save Story Info"}
|
|
259
|
+
</button>
|
|
260
|
+
{saved && <span className="text-[11px] text-green-700" data-testid="story-info-saved">Saved</span>}
|
|
261
|
+
{saveError && <span className="text-[11px] text-error" data-testid="story-info-error">{saveError}</span>}
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|