plotlink-ows 1.0.24 → 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 +44 -0
- package/app/lib/publish.ts +14 -5
- package/app/routes/publish.ts +5 -0
- package/app/web/components/Layout.tsx +11 -0
- package/app/web/components/PreviewPanel.tsx +81 -12
- package/app/web/components/StoriesPage.tsx +2 -13
- package/app/web/dist/assets/{index-CU2rL-Z3.js → index-DWqOuJeA.js} +42 -42
- package/app/web/dist/index.html +1 -1
- package/package.json +1 -1
- package/packages/cli/src/commands/create.ts +3 -0
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
|
```
|
package/app/lib/publish.ts
CHANGED
|
@@ -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));
|
package/app/routes/publish.ts
CHANGED
|
@@ -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
|
|
|
@@ -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
|
|
373
|
-
<
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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) {
|