plotlink-ows 1.0.28 → 1.0.32

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.
@@ -0,0 +1,147 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { CONFIG_DIR } from "./paths";
4
+
5
+ const CLAUDE_MD_PATH = path.join(CONFIG_DIR, "CLAUDE.md");
6
+
7
+ /** Read the installed package version from package.json */
8
+ function getVersion(): string {
9
+ const pkgPath = path.join(__dirname, "..", "..", "package.json");
10
+ try {
11
+ return JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version;
12
+ } catch {
13
+ return "unknown";
14
+ }
15
+ }
16
+
17
+ function generateContent(version: string, port: number): string {
18
+ return `# PlotLink OWS — Local Writer API (v${version})
19
+
20
+ > Auto-generated by PlotLink OWS on startup. Do not edit manually.
21
+
22
+ Local writer app running at \`http://localhost:${port}\`.
23
+ All endpoints except auth use \`Authorization: Bearer {token}\` headers.
24
+
25
+ ## Authentication
26
+
27
+ The OWS passphrase is stored in \`~/.plotlink-ows/.env\` as \`OWS_PASSPHRASE\`.
28
+ For login, the passphrase is hashed with HMAC-SHA256 and compared against the stored hash.
29
+
30
+ | Endpoint | Method | Auth | Purpose |
31
+ |----------|--------|------|---------|
32
+ | \`/api/auth/status\` | GET | No | Check if passphrase is configured |
33
+ | \`/api/auth/setup\` | POST | No | First-run passphrase setup (≥4 chars) → returns \`{ token }\` |
34
+ | \`/api/auth/login\` | POST | No | Login with passphrase → returns \`{ token }\` (24h TTL) |
35
+ | \`/api/auth/verify\` | GET | Bearer | Check token validity |
36
+ | \`/api/auth/reset-passphrase\` | POST | Bearer | Update passphrase |
37
+
38
+ ## Publishing
39
+
40
+ | Endpoint | Method | Purpose |
41
+ |----------|--------|---------|
42
+ | \`/api/publish/preflight\` | GET | Check wallet balance, Filebase config |
43
+ | \`/api/publish/file\` | POST | Publish story on-chain (SSE stream of progress events) |
44
+ | \`/api/publish/retry-index\` | POST | Retry indexing for a published file |
45
+ | \`/api/publish/upload-cover\` | POST | Upload cover image — FormData \`file\` field, **WebP or JPEG only**, max 500KB → returns \`{ cid }\` |
46
+ | \`/api/publish/upload-plot-image\` | POST | Upload plot illustration — FormData \`file\` field, **WebP or JPEG only**, max 500KB → returns \`{ cid, url }\` |
47
+ | \`/api/publish/update-storyline\` | POST | Update storyline metadata (coverCid, genre, language, isNsfw) |
48
+
49
+ **Publish flow:** Upload to IPFS → estimate gas → sign with OWS wallet → broadcast → confirm → index on plotlink.xyz (8s delay + 10 retries × 30s). Genesis files call \`createStoryline\`, plot files (\`plot-*.md\`) call \`chainPlot\`. Content limit: 10K chars.
50
+
51
+ **Cover update workflow:**
52
+ 1. \`POST /api/publish/upload-cover\` with image file → get \`cid\`
53
+ 2. \`POST /api/publish/update-storyline\` with \`{ storylineId, coverCid: cid }\` → updates on plotlink.xyz
54
+
55
+ **Metadata update workflow:**
56
+ 1. \`POST /api/publish/update-storyline\` with \`{ storylineId, genre?, language?, isNsfw? }\`
57
+
58
+ Both upload-cover and update-storyline sign messages with the OWS wallet.
59
+
60
+ **Illustration workflow (for plot files):**
61
+ 1. Upload image via \`POST /api/publish/upload-plot-image\` → get \`{ cid, url }\`
62
+ 2. Insert markdown in the plot content: \`![Scene description](url)\`
63
+ 3. Verify the image renders correctly in Preview before publishing
64
+ 4. Publish the plot — content is stored on IPFS with an on-chain keccak256 hash
65
+
66
+ **WARNING: Content is immutable after publish.** Once published, plot content (including image references) cannot be edited, removed, or changed. Always verify illustrations in Preview before publishing.
67
+
68
+ ## Stories
69
+
70
+ | Endpoint | Method | Purpose |
71
+ |----------|--------|---------|
72
+ | \`/api/stories\` | GET | List all stories |
73
+ | \`/api/stories/archived\` | GET | List archived stories |
74
+ | \`/api/stories/archive\` | POST | Archive a story \`{ name }\` |
75
+ | \`/api/stories/restore\` | POST | Restore archived story \`{ name }\` |
76
+ | \`/api/stories/:name\` | GET | Story detail with file contents |
77
+ | \`/api/stories/:name/:file\` | GET | Single file content and publish status |
78
+ | \`/api/stories/:name/:file\` | PUT | Update file content \`{ content }\` |
79
+ | \`/api/stories/:name/:file/publish-status\` | POST | Record publish result (txHash, storylineId, etc.) |
80
+ | \`/api/stories/:name/:file/mark-not-indexed\` | POST | Mark file as not indexed \`{ indexError? }\` |
81
+
82
+ ## Terminal
83
+
84
+ | Endpoint | Method | Purpose |
85
+ |----------|--------|---------|
86
+ | \`/api/terminal/spawn\` | POST | Spawn Claude CLI session for a story \`{ storyName?, resume? }\` |
87
+ | \`/api/terminal/session/:storyName\` | GET | Get stored session ID for a story |
88
+ | \`/api/terminal/status\` | GET | List all active terminal sessions |
89
+ | \`/api/terminal/rename\` | POST | Rename session \`{ oldName, newName }\` |
90
+ | \`/api/terminal/stop\` | POST | Kill default PTY (legacy) |
91
+ | \`/api/terminal/:storyName\` | DELETE | Kill a story's PTY |
92
+ | \`/api/terminal/:storyName/discard\` | DELETE | Kill PTY and clean metadata |
93
+ | \`/ws/terminal\` | WebSocket | Live PTY relay \`?token={token}&story={name}&resume={bool}\` |
94
+
95
+ ## Other Endpoints
96
+
97
+ | Endpoint | Method | Purpose |
98
+ |----------|--------|---------|
99
+ | \`/api/wallet\` | GET | Wallet info and balances (ETH, USDC, PLOT) |
100
+ | \`/api/wallet/create\` | POST | Create OWS wallet if not exists |
101
+ | \`/api/dashboard\` | GET | Writer dashboard stats (stories, costs, royalties) |
102
+ | \`/api/settings/register-agent\` | POST | Register wallet on ERC-8004 |
103
+ | \`/api/settings/generate-binding\` | POST | Generate wallet binding proof |
104
+ | \`/api/settings/link-status\` | GET | Check ERC-8004 registration status |
105
+ | \`/api/health\` | GET | Health check |
106
+
107
+ ## Story File Structure
108
+
109
+ Stories live in \`~/.plotlink-ows/stories/{story-name}/\`:
110
+
111
+ \`\`\`
112
+ stories/{story-name}/
113
+ structure.md # Outline, characters, arc
114
+ genesis.md # Synopsis hook (~1000 chars)
115
+ plot-01.md # Chapter 1 (max 10K chars)
116
+ ...
117
+ \`\`\`
118
+
119
+ ## Supported Genres (21)
120
+
121
+ Romance, Fantasy, Science Fiction, Mystery, Thriller, Horror, Adventure, Historical Fiction, Contemporary Lit, Humor, Poetry, Non-Fiction, Fanfiction, Short Story, Paranormal, Werewolf, LGBTQ+, New Adult, Teen Fiction, Diverse Lit, Others
122
+
123
+ ## Supported Languages (11)
124
+
125
+ English, Chinese, Korean, Japanese, Spanish, French, Hindi, Arabic, Portuguese, Russian, Others
126
+ `;
127
+ }
128
+
129
+ /**
130
+ * Write/update ~/.plotlink-ows/CLAUDE.md on startup.
131
+ * Skips the write if the file already contains the current version stamp.
132
+ */
133
+ export function generateClaudeMd(): void {
134
+ const version = getVersion();
135
+ const port = Number(process.env.APP_PORT) || 7777;
136
+
137
+ // Check if the file already matches the current version
138
+ if (fs.existsSync(CLAUDE_MD_PATH)) {
139
+ try {
140
+ const firstLine = fs.readFileSync(CLAUDE_MD_PATH, "utf-8").split("\n")[0];
141
+ if (firstLine.includes(`(v${version})`)) return;
142
+ } catch { /* regenerate on read error */ }
143
+ }
144
+
145
+ fs.writeFileSync(CLAUDE_MD_PATH, generateContent(version, port), "utf-8");
146
+ console.log(` Updated ~/.plotlink-ows/CLAUDE.md (v${version})`);
147
+ }
@@ -461,6 +461,41 @@ export async function uploadCoverImage(
461
461
  return data.cid;
462
462
  }
463
463
 
464
+ /**
465
+ * Upload a plot illustration image to PlotLink via signed API call.
466
+ * Returns the IPFS CID and URL of the uploaded image.
467
+ */
468
+ export async function uploadPlotImage(
469
+ walletName: string,
470
+ walletAddress: `0x${string}`,
471
+ imageFile: File,
472
+ ): Promise<{ cid: string; url: string }> {
473
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
474
+ const account = createOwsAccount(walletName, walletAddress);
475
+
476
+ const timestamp = Date.now();
477
+ const message = `PlotLink: Upload plot image\nTimestamp: ${timestamp}`;
478
+ const signature = await account.signMessage({ message });
479
+
480
+ const formData = new FormData();
481
+ formData.append("file", imageFile);
482
+ formData.append("message", message);
483
+ formData.append("signature", signature);
484
+
485
+ const res = await fetch(`${PLOTLINK_URL}/api/upload-plot-image`, {
486
+ method: "POST",
487
+ body: formData,
488
+ });
489
+
490
+ if (!res.ok) {
491
+ const err = await res.json().catch(() => ({})) as Record<string, string>;
492
+ throw new Error(err.error || `Plot image upload failed: HTTP ${res.status}`);
493
+ }
494
+
495
+ const data = await res.json() as { cid: string; url: string };
496
+ return data;
497
+ }
498
+
464
499
  /**
465
500
  * Update storyline metadata (cover, genre, language, NSFW) on PlotLink via signed API call.
466
501
  * Uses createOwsAccount for signing (not raw owsSignMsg).
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
- import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, updateStoryline } from "../lib/publish";
3
+ import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, uploadPlotImage, updateStoryline } from "../lib/publish";
4
4
  import { keccak256, toBytes } from "viem";
5
5
  import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
6
6
 
@@ -231,6 +231,41 @@ publish.post("/upload-cover", async (c) => {
231
231
  }
232
232
  });
233
233
 
234
+ /** POST /api/publish/upload-plot-image — upload plot illustration with wallet signature */
235
+ publish.post("/upload-plot-image", async (c) => {
236
+ try {
237
+ const wallets = listAgentWallets();
238
+ const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
239
+ if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
240
+
241
+ const address = getBaseAddress(wallet);
242
+ if (!address) return c.json({ error: "No EVM address on wallet" }, 400);
243
+
244
+ const formData = await c.req.formData();
245
+ const file = formData.get("file");
246
+ if (!file || !(file instanceof File)) {
247
+ return c.json({ error: "No image file provided" }, 400);
248
+ }
249
+
250
+ // Validate file size (500KB max)
251
+ if (file.size > 500 * 1024) {
252
+ return c.json({ error: "Image exceeds 500KB limit" }, 400);
253
+ }
254
+
255
+ // Validate file type — only WebP and JPEG accepted by the plotlink server
256
+ const allowedTypes = ["image/webp", "image/jpeg"];
257
+ if (!allowedTypes.includes(file.type)) {
258
+ return c.json({ error: "Only WebP and JPEG images are accepted" }, 400);
259
+ }
260
+
261
+ const result = await uploadPlotImage(wallet.name, address as `0x${string}`, file);
262
+ return c.json(result);
263
+ } catch (err: unknown) {
264
+ const message = err instanceof Error ? err.message : "Plot image upload failed";
265
+ return c.json({ error: message }, 500);
266
+ }
267
+ });
268
+
234
269
  /** POST /api/publish/update-storyline — update storyline metadata with wallet signature */
235
270
  publish.post("/update-storyline", async (c) => {
236
271
  try {
package/app/server.ts CHANGED
@@ -23,6 +23,7 @@ import { terminalRoutes, attachTerminalWs } from "./routes/terminal";
23
23
  import { storiesRoutes } from "./routes/stories";
24
24
  import { settingsRoutes } from "./routes/settings";
25
25
  import { initDb } from "./db";
26
+ import { generateClaudeMd } from "./lib/generate-claude-md";
26
27
  import { execSync } from "child_process";
27
28
  import fs from "fs";
28
29
 
@@ -48,8 +49,15 @@ app.route("/api/stories", storiesRoutes);
48
49
  app.use("/api/settings/*", requireAuth);
49
50
  app.route("/api/settings", settingsRoutes);
50
51
 
52
+ // App version (read once at startup)
53
+ const appVersion = (() => {
54
+ try {
55
+ return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8")).version;
56
+ } catch { return "unknown"; }
57
+ })();
58
+
51
59
  // Health check
52
- app.get("/api/health", (c) => c.json({ status: "ok" }));
60
+ app.get("/api/health", (c) => c.json({ status: "ok", version: appVersion }));
53
61
 
54
62
  // In production, serve the built frontend
55
63
  const distPath = path.join(__dirname, "web", "dist");
@@ -117,6 +125,9 @@ async function start() {
117
125
  // Auto-migrate from old package-relative paths
118
126
  migrateOldData();
119
127
 
128
+ // Generate/update ~/.plotlink-ows/CLAUDE.md for agent discovery
129
+ generateClaudeMd();
130
+
120
131
  // Run Prisma db push to ensure schema is up to date
121
132
  const schemaPath = path.join(__dirname, "prisma", "schema.prisma");
122
133
  execSync(`npx prisma db push --schema ${schemaPath} --skip-generate`, {
@@ -69,6 +69,7 @@ function WalletSetupPage({ token, onComplete }: { token: string; onComplete: ()
69
69
  export function Layout({ token, onLogout }: { token: string; onLogout: () => void }) {
70
70
  const [page, setPage] = useState<Page>("home");
71
71
  const [storyCount, setStoryCount] = useState(0);
72
+ const [appVersion, setAppVersion] = useState<string | null>(null);
72
73
 
73
74
  const authFetch = useCallback(async (url: string, opts?: RequestInit) => {
74
75
  return fetch(url, {
@@ -80,6 +81,12 @@ export function Layout({ token, onLogout }: { token: string; onLogout: () => voi
80
81
  });
81
82
  }, [token]);
82
83
 
84
+ useEffect(() => {
85
+ fetch("/api/health").then((r) => r.json()).then((d) => {
86
+ if (d.version) setAppVersion(d.version);
87
+ }).catch(() => {});
88
+ }, []);
89
+
83
90
  useEffect(() => {
84
91
  async function checkSetup() {
85
92
  try {
@@ -111,7 +118,7 @@ export function Layout({ token, onLogout }: { token: string; onLogout: () => voi
111
118
  <button onClick={() => { if (page !== "wallet-setup") setPage("home"); }} className="flex items-center gap-2 hover:opacity-80">
112
119
  <span className="text-accent text-sm font-bold tracking-tight">PlotLink OWS</span>
113
120
  </button>
114
- <span className="text-muted text-[10px] uppercase tracking-wider">writer</span>
121
+ <span className="text-muted text-[10px] uppercase tracking-wider">writer{appVersion ? ` v${appVersion}` : ""}</span>
115
122
  </div>
116
123
  {page !== "wallet-setup" && (
117
124
  <nav className="flex items-center gap-4">
@@ -2,9 +2,48 @@ import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import ReactMarkdown from "react-markdown";
3
3
  import remarkBreaks from "remark-breaks";
4
4
  import remarkGfm from "remark-gfm";
5
- import rehypeSanitize from "rehype-sanitize";
5
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
6
6
  import { GENRES, LANGUAGES } from "../../../lib/genres";
7
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 IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/";
18
+
19
+ /** Find all markdown image references in content */
20
+ function findImageRefs(text: string): Array<{ full: string; alt: string; url: string }> {
21
+ const results: Array<{ full: string; alt: string; url: string }> = [];
22
+ const re = /!\[([^\]]*)\]\(([^)]+)\)/g;
23
+ let m;
24
+ while ((m = re.exec(text)) !== null) {
25
+ results.push({ full: m[0], alt: m[1], url: m[2] });
26
+ }
27
+ return results;
28
+ }
29
+
30
+ /** Validate image references for publishing */
31
+ function validateImageRefs(text: string): { count: number; warnings: string[] } {
32
+ const refs = findImageRefs(text);
33
+ const warnings: string[] = [];
34
+ for (const ref of refs) {
35
+ if (!ref.url.startsWith(IPFS_GATEWAY)) {
36
+ warnings.push(`Non-IPFS image URL: ${ref.url.length > 60 ? ref.url.slice(0, 60) + "..." : ref.url}`);
37
+ }
38
+ }
39
+ // Check for malformed image markdown (missing closing bracket/paren)
40
+ const malformed = text.match(/!\[[^\]]*\]\([^)]*$|!\[[^\]]*$(?!\])/gm);
41
+ if (malformed) {
42
+ warnings.push("Malformed image markdown detected — check brackets and parentheses");
43
+ }
44
+ return { count: refs.length, warnings };
45
+ }
46
+
8
47
  interface PreviewPanelProps {
9
48
  storyName: string | null;
10
49
  fileName: string | null;
@@ -56,6 +95,14 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
56
95
  const [editSuccess, setEditSuccess] = useState(false);
57
96
  const coverInputRef = useRef<HTMLInputElement>(null);
58
97
 
98
+ // Inline illustration state
99
+ const [showIllustrations, setShowIllustrations] = useState(false);
100
+ const [illustrationUploading, setIllustrationUploading] = useState(false);
101
+ const [illustrationError, setIllustrationError] = useState<string | null>(null);
102
+ const [uploadedImages, setUploadedImages] = useState<Array<{ cid: string; url: string }>>([]);
103
+ const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
104
+ const illustrationInputRef = useRef<HTMLInputElement>(null);
105
+
59
106
  const prevFileRef = useRef<string | null>(null);
60
107
 
61
108
  const loadFile = useCallback(async () => {
@@ -153,6 +200,45 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
153
200
  setEditError(null);
154
201
  }, []);
155
202
 
203
+ // Handle illustration image upload from File object
204
+ const uploadIllustration = useCallback(async (file: File) => {
205
+ if (file.size > 500 * 1024) {
206
+ setIllustrationError("Image exceeds 500KB limit");
207
+ return;
208
+ }
209
+ const allowedTypes = ["image/webp", "image/jpeg"];
210
+ if (!allowedTypes.includes(file.type)) {
211
+ setIllustrationError("Only WebP and JPEG images are accepted");
212
+ return;
213
+ }
214
+ setIllustrationUploading(true);
215
+ setIllustrationError(null);
216
+ try {
217
+ const formData = new FormData();
218
+ formData.append("file", file);
219
+ const res = await authFetch("/api/publish/upload-plot-image", {
220
+ method: "POST",
221
+ body: formData,
222
+ });
223
+ if (!res.ok) {
224
+ const err = await res.json();
225
+ throw new Error(err.error || "Upload failed");
226
+ }
227
+ const data = await res.json();
228
+ setUploadedImages((prev) => [...prev, { cid: data.cid, url: data.url }]);
229
+ } catch (err) {
230
+ setIllustrationError(err instanceof Error ? err.message : "Upload failed");
231
+ } finally {
232
+ setIllustrationUploading(false);
233
+ if (illustrationInputRef.current) illustrationInputRef.current.value = "";
234
+ }
235
+ }, [authFetch]);
236
+
237
+ const handleIllustrationInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
238
+ const file = e.target.files?.[0];
239
+ if (file) uploadIllustration(file);
240
+ }, [uploadIllustration]);
241
+
156
242
  // Save storyline edits (cover upload + metadata update)
157
243
  const handleEditSave = useCallback(async () => {
158
244
  if (!fileData?.storylineId) return;
@@ -215,6 +301,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
215
301
  setEditError(null);
216
302
  setEditSuccess(false);
217
303
  setEditMetaLoaded(false);
304
+ setShowIllustrations(false);
305
+ setUploadedImages([]);
306
+ setIllustrationError(null);
218
307
  }, [storyName, fileName]);
219
308
 
220
309
  // Fetch current storyline metadata when edit panel opens
@@ -310,6 +399,10 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
310
399
  // Don't show over-limit warning for already-published files
311
400
  const overLimit = !isPublished && charLimit !== null && charCount > charLimit;
312
401
 
402
+ // Pre-publish image validation for pending content
403
+ const publishContent = fileData?.content ?? "";
404
+ const imageValidation = !isPublished ? validateImageRefs(publishContent) : { count: 0, warnings: [] };
405
+
313
406
  return (
314
407
  <div className="h-full flex flex-col">
315
408
  {/* Header with file path + tabs */}
@@ -372,7 +465,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
372
465
  <div className="prose max-w-none">
373
466
  <ReactMarkdown
374
467
  remarkPlugins={[remarkBreaks, remarkGfm]}
375
- rehypePlugins={[rehypeSanitize]}
468
+ rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
376
469
  >
377
470
  {fileData.content}
378
471
  </ReactMarkdown>
@@ -407,6 +500,70 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
407
500
  {saving ? "Saving..." : "Save"}
408
501
  </button>
409
502
  </div>
503
+ {/* Inline illustration upload for plot files */}
504
+ {isPlot && (
505
+ <div className="px-3 py-1.5 border-t border-border">
506
+ <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
507
+ <input
508
+ type="checkbox"
509
+ checked={showIllustrations}
510
+ onChange={(e) => setShowIllustrations(e.target.checked)}
511
+ className="rounded border-border"
512
+ />
513
+ Add illustrations in the plot
514
+ </label>
515
+ {showIllustrations && (
516
+ <div className="mt-2 flex flex-col gap-2">
517
+ <div
518
+ className="border-2 border-dashed border-border rounded p-3 flex flex-col items-center gap-1.5 cursor-pointer hover:border-accent transition-colors"
519
+ onClick={() => illustrationInputRef.current?.click()}
520
+ onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
521
+ onDrop={(e) => {
522
+ e.preventDefault();
523
+ e.stopPropagation();
524
+ const file = e.dataTransfer.files?.[0];
525
+ if (file) uploadIllustration(file);
526
+ }}
527
+ >
528
+ <input
529
+ ref={illustrationInputRef}
530
+ type="file"
531
+ accept="image/webp,image/jpeg"
532
+ onChange={handleIllustrationInput}
533
+ className="hidden"
534
+ />
535
+ <span className="text-xs text-muted">
536
+ {illustrationUploading ? "Uploading..." : "Drop image here or click to browse"}
537
+ </span>
538
+ <span className="text-xs text-muted">WebP/JPEG, max 500KB</span>
539
+ </div>
540
+ {illustrationError && (
541
+ <span className="text-error text-xs">{illustrationError}</span>
542
+ )}
543
+ {uploadedImages.map((img, i) => (
544
+ <div key={img.cid} className="border border-border rounded p-2 flex flex-col gap-1 bg-surface">
545
+ <span className="text-xs text-green-700">Image uploaded! Copy the markdown below and paste it where you want the illustration to appear in your plot:</span>
546
+ <div className="flex items-center gap-1.5">
547
+ <code className="flex-1 text-xs bg-background px-2 py-1 rounded font-mono break-all">
548
+ ![Scene description]({img.url})
549
+ </code>
550
+ <button
551
+ onClick={() => {
552
+ navigator.clipboard.writeText(`![Scene description](${img.url})`);
553
+ setCopiedIndex(i);
554
+ setTimeout(() => setCopiedIndex(null), 2000);
555
+ }}
556
+ className="px-2 py-1 text-xs border border-border rounded hover:bg-surface shrink-0"
557
+ >
558
+ {copiedIndex === i ? "Copied!" : "Copy"}
559
+ </button>
560
+ </div>
561
+ </div>
562
+ ))}
563
+ </div>
564
+ )}
565
+ </div>
566
+ )}
410
567
  </div>
411
568
  )}
412
569
 
@@ -636,7 +793,14 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
636
793
  </>
637
794
  )}
638
795
  <button
639
- onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw)}
796
+ onClick={() => {
797
+ if (!storyName || !fileName) return;
798
+ if (imageValidation.count > 0) {
799
+ const msg = `This plot contains ${imageValidation.count} illustration(s). Content is immutable after publishing — image references cannot be changed or removed.\n\nPlease verify illustrations appear correctly in Preview before continuing.\n\nPublish now?`;
800
+ if (!window.confirm(msg)) return;
801
+ }
802
+ onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw);
803
+ }}
640
804
  disabled={!!publishingFile || overLimit}
641
805
  className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
642
806
  >
@@ -646,6 +810,13 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
646
810
  <span className="text-error text-xs">Reduce content to publish</span>
647
811
  )}
648
812
  </div>
813
+ {imageValidation.warnings.length > 0 && (
814
+ <div className="flex flex-col gap-0.5">
815
+ {imageValidation.warnings.map((w, i) => (
816
+ <span key={i} className="text-amber-600 text-xs">{w}</span>
817
+ ))}
818
+ </div>
819
+ )}
649
820
  {(isGenesis) && (
650
821
  <div className="flex items-center gap-2">
651
822
  <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">