generated from nholtware/service-template-react
feat: story import/export as JSON
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0cbc9bac3c
commit
f081013b9d
4 changed files with 164 additions and 1 deletions
48
src/lib/exchange.test.ts
Normal file
48
src/lib/exchange.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { exportStory, parseImport } from "./exchange";
|
||||
import { emptyDoc } from "./stories";
|
||||
|
||||
describe("exchange", () => {
|
||||
it("round-trips a story", () => {
|
||||
const input = { title: "T", description: "d", doc: emptyDoc() };
|
||||
expect(parseImport(exportStory(input))).toEqual({
|
||||
...input,
|
||||
doc: {
|
||||
...input.doc,
|
||||
nodes: { start: { ...input.doc.nodes.start!, ending: undefined } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a bare {title, doc} and defaults optional fields", () => {
|
||||
const s = parseImport(
|
||||
JSON.stringify({
|
||||
title: " X ",
|
||||
doc: { start: "a", nodes: { a: { title: "A" } } },
|
||||
}),
|
||||
);
|
||||
expect(s.title).toBe("X");
|
||||
expect(s.description).toBe("");
|
||||
expect(s.doc.nodes.a).toMatchObject({ text: "", choices: [] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["not json", /valid JSON/],
|
||||
["[]", /Missing title/],
|
||||
[JSON.stringify({ format: "other/9", title: "t" }), /Unsupported format/],
|
||||
[JSON.stringify({ title: "t" }), /Missing doc/],
|
||||
[
|
||||
JSON.stringify({
|
||||
title: "t",
|
||||
doc: { start: "a", nodes: { "Bad Key": {} } },
|
||||
}),
|
||||
/Invalid node key/,
|
||||
],
|
||||
[
|
||||
JSON.stringify({ title: "t", doc: { start: "a", nodes: { a: {} } } }),
|
||||
/no title/,
|
||||
],
|
||||
])("rejects %s", (text, err) => {
|
||||
expect(() => parseImport(text)).toThrow(err);
|
||||
});
|
||||
});
|
||||
77
src/lib/exchange.ts
Normal file
77
src/lib/exchange.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { KEY_RE, type StoryDoc, type StoryInput } from "./stories";
|
||||
|
||||
const FORMAT = "adventure-story/1";
|
||||
|
||||
export function exportStory(s: StoryInput): string {
|
||||
return JSON.stringify(
|
||||
{ format: FORMAT, title: s.title, description: s.description, doc: s.doc },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse an exported (or bare {title, doc}) story. Throws a readable Error. */
|
||||
export function parseImport(text: string): StoryInput {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("Not valid JSON");
|
||||
}
|
||||
const o = raw as Record<string, unknown> | null;
|
||||
if (!o || typeof o !== "object") throw new Error("Expected a JSON object");
|
||||
if (o.format !== undefined && o.format !== FORMAT) {
|
||||
throw new Error(`Unsupported format "${String(o.format)}"`);
|
||||
}
|
||||
if (typeof o.title !== "string" || !o.title.trim()) {
|
||||
throw new Error("Missing title");
|
||||
}
|
||||
const doc = o.doc as StoryDoc | undefined;
|
||||
if (
|
||||
!doc ||
|
||||
typeof doc.start !== "string" ||
|
||||
!doc.nodes ||
|
||||
typeof doc.nodes !== "object"
|
||||
) {
|
||||
throw new Error("Missing doc.start / doc.nodes");
|
||||
}
|
||||
const nodes: StoryDoc["nodes"] = {};
|
||||
for (const [k, v] of Object.entries(doc.nodes)) {
|
||||
if (!KEY_RE.test(k)) throw new Error(`Invalid node key "${k}"`);
|
||||
const node = v as Partial<StoryDoc["nodes"][string]>;
|
||||
if (typeof node?.title !== "string")
|
||||
throw new Error(`Node "${k}" has no title`);
|
||||
nodes[k] = {
|
||||
title: node.title,
|
||||
text: typeof node.text === "string" ? node.text : "",
|
||||
ending:
|
||||
node.ending === "win" ||
|
||||
node.ending === "lose" ||
|
||||
node.ending === "neutral"
|
||||
? node.ending
|
||||
: undefined,
|
||||
choices: Array.isArray(node.choices)
|
||||
? node.choices.map((c) => ({
|
||||
label: String(c?.label ?? ""),
|
||||
to: String(c?.to ?? ""),
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: o.title.trim(),
|
||||
description: typeof o.description === "string" ? o.description : "",
|
||||
doc: { start: doc.start, nodes },
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadJson(filename: string, text: string) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([text], { type: "application/json" }),
|
||||
);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { downloadJson, exportStory } from "../lib/exchange";
|
||||
import {
|
||||
analyze,
|
||||
ApiError,
|
||||
|
|
@ -130,6 +131,16 @@ export function Editor({ story }: { story: Story }) {
|
|||
<button onClick={() => void togglePublish()}>
|
||||
{published ? "Unpublish" : "Publish"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
downloadJson(
|
||||
`${story.id}.json`,
|
||||
exportStory({ title, description, doc }),
|
||||
)
|
||||
}
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
{published && <Link to={`/play/${story.id}`}>Play →</Link>}
|
||||
<span role="status">{message ?? (dirty ? "Unsaved changes" : "")}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
|
||||
import { parseImport } from "../lib/exchange";
|
||||
import {
|
||||
createStory,
|
||||
deleteStory,
|
||||
|
|
@ -13,6 +15,7 @@ export function BuildList() {
|
|||
const stories = useLoaderData() as StorySummary[];
|
||||
const navigate = useNavigate();
|
||||
const { revalidate } = useRevalidator();
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
async function create() {
|
||||
const { story } = await createStory({
|
||||
|
|
@ -23,6 +26,17 @@ export function BuildList() {
|
|||
navigate(`/build/${story.id}`);
|
||||
}
|
||||
|
||||
async function importFile(file: File | undefined) {
|
||||
if (!file) return;
|
||||
try {
|
||||
const input = parseImport(await file.text());
|
||||
const { story } = await createStory(input);
|
||||
navigate(`/build/${story.id}`);
|
||||
} catch (e) {
|
||||
setImportError(e instanceof Error ? e.message : "Import failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s: StorySummary) {
|
||||
if (!confirm(`Delete "${s.title}"? This cannot be undone.`)) return;
|
||||
await deleteStory(s.id);
|
||||
|
|
@ -33,8 +47,21 @@ export function BuildList() {
|
|||
<section>
|
||||
<h1>Your stories</h1>
|
||||
<p>
|
||||
<button onClick={create}>New story</button>
|
||||
<button onClick={create}>New story</button>{" "}
|
||||
<label className="button">
|
||||
Import JSON
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
void importFile(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
{importError && <p role="alert">Import failed: {importError}</p>}
|
||||
<ul className="cards">
|
||||
{stories.map((s) => (
|
||||
<li key={s.id}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue