plotlink-ows 1.0.23 → 1.0.25

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 CHANGED
@@ -7,6 +7,7 @@
7
7
  <p>
8
8
  <a href="https://plotlink.xyz"><strong>Live App</strong></a> ·
9
9
  <a href="#-quick-start"><strong>Quick Start</strong></a> ·
10
+ <a href="#-wallet-setup"><strong>Wallet Setup</strong></a> ·
10
11
  <a href="#how-it-works"><strong>How it Works</strong></a> ·
11
12
  <a href="https://docs.openwallet.sh/"><strong>OWS Docs</strong></a>
12
13
  </p>
@@ -116,6 +117,49 @@ npx plotlink-ows status # Show config + wallet info
116
117
 
117
118
  ---
118
119
 
120
+ ## 🔑 Wallet Setup
121
+
122
+ PlotLink OWS uses an encrypted local wallet via Open Wallet Standard. No raw private keys are exposed to scripts or environment variables.
123
+
124
+ ### Initial Setup
125
+
126
+ ```bash
127
+ npx plotlink-ows init
128
+ ```
129
+
130
+ The setup wizard will:
131
+ 1. Ask you to create a passphrase (encrypts your wallet at rest)
132
+ 2. Generate a new OWS wallet in `~/.ows/`
133
+ 3. Display your Base (L2) wallet address
134
+
135
+ ### Funding Your Wallet
136
+
137
+ Send a small amount of ETH on **Base** to your wallet address. Publishing costs less than $0.05 per story.
138
+
139
+ You can bridge ETH from Ethereum mainnet to Base using the [official Base Bridge](https://bridge.base.org) or any supported bridge.
140
+
141
+ ### Environment Variables
142
+
143
+ | Variable | Required | Description |
144
+ |----------|----------|-------------|
145
+ | `OWS_PASSPHRASE` | Yes | Your wallet encryption passphrase |
146
+ | `NEXT_PUBLIC_RPC_URL` | No | Custom Base RPC URL (defaults to `https://mainnet.base.org`) |
147
+ | `NEXT_PUBLIC_APP_URL` | No | PlotLink API URL (defaults to `https://plotlink.xyz`) |
148
+
149
+ Copy `.env.example` to `.env` and fill in your values:
150
+
151
+ ```bash
152
+ cp .env.example .env
153
+ ```
154
+
155
+ > **Security best practices:**
156
+ > - Never share your passphrase or wallet files with anyone
157
+ > - Use a dedicated wallet for agent operations — do not reuse your main wallet
158
+ > - Never commit `.env` or wallet files to version control
159
+ > - Store backups of `~/.ows/` in a secure, offline location
160
+
161
+ ---
162
+
119
163
  ## 🏗️ Architecture
120
164
 
121
165
  ```
@@ -142,9 +142,9 @@ async function indexWithDelayAndRetry(
142
142
  * Upload story content to IPFS via PlotLink's API (plotlink.xyz/api/upload).
143
143
  * PlotLink handles Filebase credentials server-side.
144
144
  */
145
- export async function uploadToIPFS(content: string, title: string, genre?: string): Promise<string> {
145
+ export async function uploadToIPFS(content: string, title: string, genre?: string, language?: string): Promise<string> {
146
146
  const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
147
- const metadata = JSON.stringify({ title, genre, content });
147
+ const metadata = JSON.stringify({ title, genre, language, content });
148
148
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40);
149
149
  const key = `plotlink/storylines/${Date.now()}-${slug}.json`;
150
150
 
@@ -293,10 +293,16 @@ export async function publishStoryline(
293
293
  content: string,
294
294
  genre: string | undefined,
295
295
  onProgress: (progress: PublishProgress) => void,
296
+ language?: string,
297
+ isNsfw?: boolean,
296
298
  ): Promise<PublishResult> {
299
+ // Normalize optional fields to backwards-compatible defaults
300
+ const normalizedLanguage = language || "English";
301
+ const normalizedIsNsfw = isNsfw ?? false;
302
+
297
303
  // Step 1: Upload to IPFS
298
304
  onProgress({ step: "uploading", message: "Uploading story to IPFS..." });
299
- const contentCid = await uploadToIPFS(content, title, genre);
305
+ const contentCid = await uploadToIPFS(content, title, genre, normalizedLanguage);
300
306
 
301
307
  // Step 2: Compute content hash + get creation fee
302
308
  const contentHash = keccak256(toBytes(content));
@@ -334,7 +340,7 @@ export async function publishStoryline(
334
340
  // Streams "Indexing…" progress so the user does not escalate to Retry Publish.
335
341
  const indexError = await indexWithDelayAndRetry(
336
342
  "storyline",
337
- { txHash, content, genre },
343
+ { txHash, content, genre, language: normalizedLanguage, isNsfw: normalizedIsNsfw },
338
344
  onProgress,
339
345
  txHash,
340
346
  contentCid,
@@ -361,10 +367,13 @@ export async function publishPlot(
361
367
  content: string,
362
368
  genre: string | undefined,
363
369
  onProgress: (progress: PublishProgress) => void,
370
+ language?: string,
364
371
  ): Promise<PublishResult> {
372
+ const normalizedLanguage = language || "English";
373
+
365
374
  // Step 1: Upload to IPFS
366
375
  onProgress({ step: "uploading", message: "Uploading plot to IPFS..." });
367
- const contentCid = await uploadToIPFS(content, title, genre);
376
+ const contentCid = await uploadToIPFS(content, title, genre, normalizedLanguage);
368
377
 
369
378
  // Step 2: Compute content hash
370
379
  const contentHash = keccak256(toBytes(content));
@@ -71,6 +71,8 @@ publish.post("/file", async (c) => {
71
71
  title: string;
72
72
  content: string;
73
73
  genre?: string;
74
+ language?: string;
75
+ isNsfw?: boolean;
74
76
  storylineId?: number;
75
77
  }>();
76
78
 
@@ -81,7 +83,7 @@ publish.post("/file", async (c) => {
81
83
  // Enforce character limits
82
84
  const isGenesis = body.fileName === "genesis.md";
83
85
  const isPlot = /^plot-\d+\.md$/.test(body.fileName);
84
- const charLimit = isGenesis ? 1000 : isPlot ? 10000 : null;
86
+ const charLimit = (isGenesis || isPlot) ? 10000 : null;
85
87
  if (charLimit && body.content.length > charLimit) {
86
88
  return c.json({
87
89
  error: `Content exceeds ${charLimit.toLocaleString()} character limit (${body.content.length.toLocaleString()} chars). Reduce content before publishing.`,
@@ -118,6 +120,7 @@ publish.post("/file", async (c) => {
118
120
  async (progress) => {
119
121
  await stream.writeSSE({ data: JSON.stringify(progress) });
120
122
  },
123
+ body.language,
121
124
  );
122
125
  } else {
123
126
  // Create new storyline (genesis or first file)
@@ -129,6 +132,8 @@ publish.post("/file", async (c) => {
129
132
  async (progress) => {
130
133
  await stream.writeSSE({ data: JSON.stringify(progress) });
131
134
  },
135
+ body.language,
136
+ body.isNsfw,
132
137
  );
133
138
  }
134
139
 
@@ -174,6 +174,17 @@ export function Layout({ token, onLogout }: { token: string; onLogout: () => voi
174
174
  </ol>
175
175
  </div>
176
176
 
177
+ <div className="text-center">
178
+ <a
179
+ href="https://github.com/realproject7/plotlink-ows#-wallet-setup"
180
+ target="_blank"
181
+ rel="noopener noreferrer"
182
+ className="text-xs text-muted hover:text-accent underline transition-colors"
183
+ >
184
+ Wallet Setup Guide
185
+ </a>
186
+ </div>
187
+
177
188
  <WalletCard token={token} />
178
189
  </div>
179
190
  )}
@@ -3,12 +3,13 @@ import ReactMarkdown from "react-markdown";
3
3
  import remarkBreaks from "remark-breaks";
4
4
  import remarkGfm from "remark-gfm";
5
5
  import rehypeSanitize from "rehype-sanitize";
6
+ import { GENRES, LANGUAGES } from "../../../lib/genres";
6
7
 
7
8
  interface PreviewPanelProps {
8
9
  storyName: string | null;
9
10
  fileName: string | null;
10
11
  authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
11
- onPublish?: (storyName: string, fileName: string) => void;
12
+ onPublish?: (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => void;
12
13
  publishingFile?: string | null;
13
14
  }
14
15
 
@@ -34,6 +35,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
34
35
  const [dirty, setDirty] = useState(false);
35
36
  const [retrying, setRetrying] = useState(false);
36
37
  const [indexTimeLeft, setIndexTimeLeft] = useState<number | null>(null);
38
+ const [selectedGenre, setSelectedGenre] = useState(GENRES[0]);
39
+ const [selectedLanguage, setSelectedLanguage] = useState(LANGUAGES[0]);
40
+ const [isNsfw, setIsNsfw] = useState(false);
37
41
  const textareaRef = useRef<HTMLTextAreaElement>(null);
38
42
  const dirtyRef = useRef(false);
39
43
 
@@ -75,6 +79,31 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
75
79
  return () => clearInterval(interval);
76
80
  }, [storyName, fileName, loadFile, activeTab, dirty]);
77
81
 
82
+ // Auto-detect genre from structure.md when story changes
83
+ useEffect(() => {
84
+ if (!storyName) return;
85
+ let cancelled = false;
86
+ authFetch(`/api/stories/${storyName}/structure.md`)
87
+ .then((res) => res.ok ? res.json() : null)
88
+ .then((data) => {
89
+ if (cancelled || !data?.content) return;
90
+ const match = data.content.match(/\*{0,2}genre\*{0,2}[:\s]+(.+)/i);
91
+ if (match) {
92
+ const detected = match[1].replace(/\*+/g, "").trim();
93
+ const found = GENRES.find((g) => g.toLowerCase() === detected.toLowerCase());
94
+ if (found) setSelectedGenre(found);
95
+ }
96
+ const langMatch = data.content.match(/\*{0,2}language\*{0,2}[:\s]+(.+)/i);
97
+ if (langMatch) {
98
+ const detected = langMatch[1].replace(/\*+/g, "").trim();
99
+ const found = LANGUAGES.find((l) => l.toLowerCase() === detected.toLowerCase());
100
+ if (found) setSelectedLanguage(found);
101
+ }
102
+ })
103
+ .catch(() => {});
104
+ return () => { cancelled = true; };
105
+ }, [storyName, authFetch]);
106
+
78
107
  const handleSave = useCallback(async () => {
79
108
  if (!storyName || !fileName) return;
80
109
  setSaving(true);
@@ -303,7 +332,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
303
332
  )}
304
333
  {isPlot && (
305
334
  <button
306
- onClick={() => storyName && fileName && onPublish?.(storyName, fileName)}
335
+ onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw)}
307
336
  disabled={!!publishingFile}
308
337
  className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
309
338
  >
@@ -369,16 +398,56 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
369
398
  )}
370
399
  </div>
371
400
  ) : (
372
- <div className="flex items-center gap-2">
373
- <button
374
- onClick={() => storyName && fileName && onPublish?.(storyName, fileName)}
375
- disabled={!!publishingFile || overLimit}
376
- className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
377
- >
378
- {publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
379
- </button>
380
- {overLimit && (
381
- <span className="text-error text-xs">Reduce content to publish</span>
401
+ <div className="flex flex-col gap-2">
402
+ <div className="flex items-center gap-2">
403
+ {(isGenesis) && (
404
+ <>
405
+ <select
406
+ value={selectedGenre}
407
+ onChange={(e) => setSelectedGenre(e.target.value)}
408
+ className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
409
+ >
410
+ {GENRES.map((g) => (
411
+ <option key={g} value={g}>{g}</option>
412
+ ))}
413
+ </select>
414
+ <select
415
+ value={selectedLanguage}
416
+ onChange={(e) => setSelectedLanguage(e.target.value)}
417
+ className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
418
+ >
419
+ {LANGUAGES.map((l) => (
420
+ <option key={l} value={l}>{l}</option>
421
+ ))}
422
+ </select>
423
+ </>
424
+ )}
425
+ <button
426
+ onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw)}
427
+ disabled={!!publishingFile || overLimit}
428
+ className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
429
+ >
430
+ {publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
431
+ </button>
432
+ {overLimit && (
433
+ <span className="text-error text-xs">Reduce content to publish</span>
434
+ )}
435
+ </div>
436
+ {(isGenesis) && (
437
+ <div className="flex items-center gap-2">
438
+ <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
439
+ <input
440
+ type="checkbox"
441
+ checked={isNsfw}
442
+ onChange={(e) => setIsNsfw(e.target.checked)}
443
+ className="rounded border-border"
444
+ />
445
+ This story contains adult content (18+)
446
+ </label>
447
+ {isNsfw && (
448
+ <span className="text-xs text-amber-600">Adult content will be hidden from the default browse view.</span>
449
+ )}
450
+ </div>
382
451
  )}
383
452
  </div>
384
453
  )}
@@ -177,7 +177,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
177
177
  window.addEventListener("mouseup", onMouseUp);
178
178
  }, []);
179
179
 
180
- const handlePublish = useCallback(async (storyName: string, fileName: string) => {
180
+ const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => {
181
181
  setPublishingFile(fileName);
182
182
  setPublishProgress("Reading file...");
183
183
 
@@ -191,17 +191,6 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
191
191
  const titleMatch = fileData.content.match(/^#\s+(.+)$/m);
192
192
  const title = titleMatch ? titleMatch[1].slice(0, 60) : fileName.replace(".md", "");
193
193
 
194
- // Determine genre from structure.md if available
195
- let genre = "Fiction";
196
- try {
197
- const structRes = await authFetch(`/api/stories/${storyName}/structure.md`);
198
- if (structRes.ok) {
199
- const structData = await structRes.json();
200
- const genreMatch = structData.content.match(/genre[:\s]+(.+)/i);
201
- if (genreMatch) genre = genreMatch[1].trim().slice(0, 30);
202
- }
203
- } catch { /* ignore */ }
204
-
205
194
  // For plot files, find the storylineId from the genesis publish status
206
195
  let storylineId: number | undefined;
207
196
  if (fileName.match(/^plot-\d+\.md$/)) {
@@ -226,7 +215,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
226
215
  const publishRes = await authFetch("/api/publish/file", {
227
216
  method: "POST",
228
217
  headers: { "Content-Type": "application/json" },
229
- body: JSON.stringify({ storyName, fileName, title, content: fileData.content, genre, storylineId }),
218
+ body: JSON.stringify({ storyName, fileName, title, content: fileData.content, genre, language, isNsfw, storylineId }),
230
219
  });
231
220
 
232
221
  if (!publishRes.ok) {