Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ See **AGENTS.md** for the full writing workflow. Quick summary:
Stories follow this structure:
```
stories/{story-name}/
.story.json # Content type metadata (fiction | cartoon)
structure.md # Outline, characters, arc
genesis.md # Synopsis hook (~1000 chars)
plot-01.md # Chapter 1 (max 10K chars)
Expand Down Expand Up @@ -80,6 +81,7 @@ Both upload-cover and update-storyline sign messages with the OWS wallet (messag
| `/api/stories/:name/:file` | GET | Single file content and publish status |
| `/api/stories/:name/:file` | PUT | Update file content `{ content }` |
| `/api/stories/:name/:file/publish-status` | POST | Record publish result (txHash, storylineId, etc.) |
| `/api/stories/:name/metadata` | POST | Write story metadata `{ contentType }` |
| `/api/stories/:name/:file/mark-not-indexed` | POST | Mark file as not indexed `{ indexError? }` |

### Terminal
Expand Down
2 changes: 2 additions & 0 deletions app/lib/generate-claude-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Both upload-cover and update-storyline sign messages with the OWS wallet.
| \`/api/stories/:name/:file\` | GET | Single file content and publish status |
| \`/api/stories/:name/:file\` | PUT | Update file content \`{ content }\` |
| \`/api/stories/:name/:file/publish-status\` | POST | Record publish result (txHash, storylineId, etc.) |
| \`/api/stories/:name/metadata\` | POST | Write story metadata \`{ contentType }\` |
| \`/api/stories/:name/:file/mark-not-indexed\` | POST | Mark file as not indexed \`{ indexError? }\` |

## Terminal
Expand Down Expand Up @@ -110,6 +111,7 @@ Stories live in \`~/.plotlink-ows/stories/{story-name}/\`:

\`\`\`
stories/{story-name}/
.story.json # Content type metadata (fiction | cartoon)
structure.md # Outline, characters, arc
genesis.md # Synopsis hook (~1000 chars)
plot-01.md # Chapter 1 (max 10K chars)
Expand Down
59 changes: 59 additions & 0 deletions app/routes/stories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import { readStoryMeta, writeStoryMeta } from "./stories";

describe("story metadata (.story.json)", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "plotlink-test-"));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it("defaults to fiction when .story.json is missing", () => {
const meta = readStoryMeta(tmpDir);
expect(meta.contentType).toBe("fiction");
});

it("reads cartoon contentType from .story.json", () => {
fs.writeFileSync(path.join(tmpDir, ".story.json"), JSON.stringify({ contentType: "cartoon" }));
const meta = readStoryMeta(tmpDir);
expect(meta.contentType).toBe("cartoon");
});

it("reads fiction contentType from .story.json", () => {
fs.writeFileSync(path.join(tmpDir, ".story.json"), JSON.stringify({ contentType: "fiction" }));
const meta = readStoryMeta(tmpDir);
expect(meta.contentType).toBe("fiction");
});

it("defaults to fiction for malformed .story.json", () => {
fs.writeFileSync(path.join(tmpDir, ".story.json"), "not json");
const meta = readStoryMeta(tmpDir);
expect(meta.contentType).toBe("fiction");
});

it("defaults to fiction for unknown contentType value", () => {
fs.writeFileSync(path.join(tmpDir, ".story.json"), JSON.stringify({ contentType: "manga" }));
const meta = readStoryMeta(tmpDir);
expect(meta.contentType).toBe("fiction");
});

it("writeStoryMeta creates .story.json", () => {
writeStoryMeta(tmpDir, { contentType: "cartoon" });
const raw = JSON.parse(fs.readFileSync(path.join(tmpDir, ".story.json"), "utf-8"));
expect(raw.contentType).toBe("cartoon");
});

it("writeStoryMeta overwrites existing .story.json", () => {
writeStoryMeta(tmpDir, { contentType: "fiction" });
writeStoryMeta(tmpDir, { contentType: "cartoon" });
const meta = readStoryMeta(tmpDir);
expect(meta.contentType).toBe("cartoon");
});
});
50 changes: 48 additions & 2 deletions app/routes/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface StoryInfo {
hasGenesis: boolean;
plotCount: number;
publishedCount: number;
contentType: "fiction" | "cartoon";
}

function readPublishStatus(storyDir: string): Record<string, FileStatus> {
Expand All @@ -51,8 +52,31 @@ function writePublishStatus(storyDir: string, status: Record<string, FileStatus>
fs.writeFileSync(statusFile, JSON.stringify(status, null, 2) + "\n");
}

interface StoryMeta {
contentType: "fiction" | "cartoon";
}

function readStoryMeta(storyDir: string): StoryMeta {
const metaFile = path.join(storyDir, ".story.json");
try {
if (fs.existsSync(metaFile)) {
const raw = JSON.parse(fs.readFileSync(metaFile, "utf-8"));
if (raw.contentType === "fiction" || raw.contentType === "cartoon") {
return { contentType: raw.contentType };
}
}
} catch { /* ignore */ }
return { contentType: "fiction" };
}

function writeStoryMeta(storyDir: string, meta: StoryMeta) {
const metaFile = path.join(storyDir, ".story.json");
fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + "\n");
}

function scanStory(storyDir: string, name: string): StoryInfo {
const publishStatus = readPublishStatus(storyDir);
const storyMeta = readStoryMeta(storyDir);
const entries = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md"));

const files: FileStatus[] = entries.map((file) => {
Expand Down Expand Up @@ -84,7 +108,7 @@ function scanStory(storyDir: string, name: string): StoryInfo {
}
} catch { /* best effort */ }

return { name, title, files, hasStructure, hasGenesis, plotCount, publishedCount };
return { name, title, files, hasStructure, hasGenesis, plotCount, publishedCount, contentType: storyMeta.contentType };
}

/** GET /api/stories — list all stories */
Expand Down Expand Up @@ -177,6 +201,28 @@ stories.get("/:name", (c) => {
return c.json({ ...info, files: filesWithContent });
});

/** POST /api/stories/:name/metadata — write/update .story.json */
stories.post("/:name/metadata", async (c) => {
const name = safeName(c.req.param("name"));
if (!name) return c.json({ error: "Invalid story name" }, 400);
const storyDir = path.join(STORIES_DIR, name);

if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
return c.json({ error: "Story not found" }, 404);
}

const body = await c.req.json<{ contentType?: string }>();
if (body.contentType !== "fiction" && body.contentType !== "cartoon") {
return c.json({ error: "contentType must be 'fiction' or 'cartoon'" }, 400);
}

const existing = readStoryMeta(storyDir);
const meta: StoryMeta = { ...existing, contentType: body.contentType };
writeStoryMeta(storyDir, meta);

return c.json({ ok: true });
});

/** GET /api/stories/:name/:file — single file content */
stories.get("/:name/:file", (c) => {
const name = safeName(c.req.param("name"));
Expand Down Expand Up @@ -290,4 +336,4 @@ stories.post("/:name/:file/mark-not-indexed", async (c) => {
return c.json({ ok: true });
});

export { stories as storiesRoutes, readPublishStatus, STORIES_DIR };
export { stories as storiesRoutes, readPublishStatus, readStoryMeta, writeStoryMeta, STORIES_DIR };
47 changes: 47 additions & 0 deletions app/web/components/StoriesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
const [walletAddress, setWalletAddress] = useState<string | null>(null);
const [ratio, setRatio] = useState(loadRatio);
const [untitledSessions, setUntitledSessions] = useState<string[]>([]);
const [showNewStoryModal, setShowNewStoryModal] = useState(false);
const contentTypeMap = useRef<Map<string, "fiction" | "cartoon">>(new Map());
const knownStoriesRef = useRef<Set<string>>(new Set());
const renameRef = useRef<((oldName: string, newName: string) => Promise<boolean>) | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -73,7 +75,13 @@
}, []);

const handleNewStory = useCallback(() => {
setShowNewStoryModal(true);
}, []);

const handleCreateStory = useCallback((contentType: "fiction" | "cartoon") => {
setShowNewStoryModal(false);
const id = `_new_${Date.now()}`;
contentTypeMap.current.set(id, contentType);
setUntitledSessions((prev) => [...prev, id]);
setSelectedStory(id);
setSelectedFile(null);
Expand Down Expand Up @@ -103,6 +111,13 @@
}
if (renamed) {
setUntitledSessions((prev) => prev.slice(1));
const ct = contentTypeMap.current.get(oldName) || "fiction";
contentTypeMap.current.delete(oldName);
authFetch(`/api/stories/${name}/metadata`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: ct }),
}).catch(() => {});
}
setSelectedStory(name);
setSelectedFile(null);
Expand Down Expand Up @@ -277,11 +292,12 @@
setPublishProgress("");
}, 3000);
}
}, [authFetch]);

Check warning on line 295 in app/web/components/StoriesPage.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

React Hook useCallback has a missing dependency: 'walletAddress'. Either include it or remove the dependency array

const handleDestroySession = useCallback((name: string) => {
if (name.startsWith("_new_")) {
setUntitledSessions((prev) => prev.filter((id) => id !== name));
contentTypeMap.current.delete(name);
}
}, []);

Expand Down Expand Up @@ -369,6 +385,37 @@
</div>
)}
</div>

{showNewStoryModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
<div className="bg-surface border border-border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
<h3 className="text-sm font-serif font-medium text-foreground text-center">New Story</h3>
<p className="text-xs text-muted text-center">Choose a content type</p>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => handleCreateStory("fiction")}
className="border border-border rounded-lg p-4 hover:border-accent hover:bg-accent/5 transition-colors text-center space-y-1"
>
<p className="text-sm font-serif font-medium text-foreground">Fiction</p>
<p className="text-[11px] text-muted">Novels, short stories, poetry</p>
</button>
<button
onClick={() => handleCreateStory("cartoon")}
className="border border-border rounded-lg p-4 hover:border-accent hover:bg-accent/5 transition-colors text-center space-y-1"
>
<p className="text-sm font-serif font-medium text-foreground">Cartoon</p>
<p className="text-[11px] text-muted">Comics, manga, webtoons</p>
</button>
</div>
<button
onClick={() => setShowNewStoryModal(false)}
className="w-full px-3 py-1.5 text-xs text-muted hover:text-foreground hover:bg-surface rounded text-center"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
}
6 changes: 5 additions & 1 deletion app/web/components/StoryBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface StoryInfo {
hasGenesis: boolean;
plotCount: number;
publishedCount: number;
contentType?: "fiction" | "cartoon";
}

interface StoryBrowserProps {
Expand Down Expand Up @@ -230,7 +231,10 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
>
<span className="text-xs text-muted">{expanded.has(story.name) ? "\u25BC" : "\u25B6"}</span>
<span className="font-medium truncate" title={story.name}>{story.title || story.name}</span>
<span className="ml-auto text-xs text-muted">
{story.contentType === "cartoon" && (
<span className="bg-accent/10 text-accent rounded px-1.5 py-0.5 text-[10px] font-medium flex-shrink-0">Cartoon</span>
)}
<span className="ml-auto flex-shrink-0 text-xs text-muted">
{story.publishedCount}/{story.files.length}
</span>
</button>
Expand Down
3 changes: 3 additions & 0 deletions lib/genres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,8 @@ export const LANGUAGES = [
"Others",
] as const;

export const CONTENT_TYPES = ["fiction", "cartoon"] as const;

export type Genre = (typeof GENRES)[number];
export type Language = (typeof LANGUAGES)[number];
export type ContentType = (typeof CONTENT_TYPES)[number];
Loading