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.
- package/app/lib/generate-claude-md.ts +147 -0
- package/app/lib/publish.ts +35 -0
- package/app/routes/publish.ts +36 -1
- package/app/server.ts +12 -1
- package/app/web/components/Layout.tsx +8 -1
- package/app/web/components/PreviewPanel.tsx +174 -3
- package/app/web/dist/assets/{index-CXg4YULp.css → index-B-2Ft7Yv.css} +1 -1
- package/app/web/dist/assets/{index-9T4gFznD.js → index-BFw-v-OZ.js} +43 -39
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
|
@@ -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: \`\`
|
|
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
|
+
}
|
package/app/lib/publish.ts
CHANGED
|
@@ -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).
|
package/app/routes/publish.ts
CHANGED
|
@@ -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
|
+

|
|
549
|
+
</code>
|
|
550
|
+
<button
|
|
551
|
+
onClick={() => {
|
|
552
|
+
navigator.clipboard.writeText(``);
|
|
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={() =>
|
|
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">
|