generated from nholtware/service-template-react
feat: story library, player and builder UI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
52778622a4
commit
364a911794
14 changed files with 1149 additions and 41 deletions
|
|
@ -26,26 +26,44 @@ afterEach(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("app", () => {
|
describe("app", () => {
|
||||||
it("home shows the app name from runtime config and the build sha", async () => {
|
it("library lists published stories and links to play", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
stories: [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
title: "Cave",
|
||||||
|
description: "Dark",
|
||||||
|
nodeCount: 3,
|
||||||
|
published: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
renderAt("/");
|
renderAt("/");
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "test-app" }),
|
await screen.findByRole("heading", { name: "Cave" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("build-sha")).toHaveTextContent("dev");
|
expect(
|
||||||
|
screen.getAllByRole("link", { name: "Play" }).at(-1)!,
|
||||||
|
).toHaveAttribute("href", "/play/s1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("home hides the banner while hello-banner is off", async () => {
|
it("library shows a builder prompt when empty", async () => {
|
||||||
renderAt("/");
|
vi.stubGlobal(
|
||||||
await screen.findByRole("heading", { name: "test-app" });
|
"fetch",
|
||||||
expect(screen.queryByTestId("banner")).not.toBeInTheDocument();
|
vi.fn(async () => new Response(JSON.stringify({ stories: [] }))),
|
||||||
});
|
|
||||||
|
|
||||||
it("home shows the banner while hello-banner is on", async () => {
|
|
||||||
flagsOn.add("hello-banner");
|
|
||||||
renderAt("/");
|
|
||||||
expect(await screen.findByTestId("banner")).toHaveTextContent(
|
|
||||||
"hello-banner flag is on",
|
|
||||||
);
|
);
|
||||||
|
renderAt("/");
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/No published stories yet/),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("status fetches / and /readyz under API_BASE_URL and renders the JSON", async () => {
|
it("status fetches / and /readyz under API_BASE_URL and renders the JSON", async () => {
|
||||||
|
|
|
||||||
35
src/lib/play.ts
Normal file
35
src/lib/play.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { StoryDoc } from "./stories";
|
||||||
|
|
||||||
|
export interface PlayState {
|
||||||
|
path: string[]; // node keys visited, last is current
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initial = (doc: StoryDoc): PlayState => ({ path: [doc.start] });
|
||||||
|
|
||||||
|
export function choose(doc: StoryDoc, s: PlayState, index: number): PlayState {
|
||||||
|
const cur = s.path[s.path.length - 1]!;
|
||||||
|
const choice = doc.nodes[cur]?.choices[index];
|
||||||
|
if (!choice || !doc.nodes[choice.to] || doc.nodes[cur]?.ending) return s;
|
||||||
|
return { path: [...s.path, choice.to] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const back = (s: PlayState): PlayState =>
|
||||||
|
s.path.length > 1 ? { path: s.path.slice(0, -1) } : s;
|
||||||
|
|
||||||
|
/** Drop saved paths that no longer fit the (possibly edited) story. */
|
||||||
|
export function restore(doc: StoryDoc, raw: string | null): PlayState {
|
||||||
|
try {
|
||||||
|
const path = (JSON.parse(raw ?? "null") as { path?: unknown } | null)?.path;
|
||||||
|
if (
|
||||||
|
Array.isArray(path) &&
|
||||||
|
path.length > 0 &&
|
||||||
|
path[0] === doc.start &&
|
||||||
|
path.every((k) => typeof k === "string" && doc.nodes[k])
|
||||||
|
) {
|
||||||
|
return { path: path as string[] };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to a fresh game
|
||||||
|
}
|
||||||
|
return initial(doc);
|
||||||
|
}
|
||||||
87
src/lib/stories.test.ts
Normal file
87
src/lib/stories.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { back, choose, initial, restore } from "./play";
|
||||||
|
import {
|
||||||
|
analyze,
|
||||||
|
emptyDoc,
|
||||||
|
nextKey,
|
||||||
|
removeNode,
|
||||||
|
renameNode,
|
||||||
|
type StoryDoc,
|
||||||
|
} from "./stories";
|
||||||
|
|
||||||
|
const doc: StoryDoc = {
|
||||||
|
start: "a",
|
||||||
|
nodes: {
|
||||||
|
a: {
|
||||||
|
title: "A",
|
||||||
|
text: "",
|
||||||
|
choices: [
|
||||||
|
{ label: "to b", to: "b" },
|
||||||
|
{ label: "to c", to: "c" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
b: { title: "B", text: "", ending: "win", choices: [] },
|
||||||
|
c: { title: "C", text: "", choices: [{ label: "back", to: "a" }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("play", () => {
|
||||||
|
it("walks, undoes, and refuses to move past an ending", () => {
|
||||||
|
let s = initial(doc);
|
||||||
|
s = choose(doc, s, 1);
|
||||||
|
expect(s.path).toEqual(["a", "c"]);
|
||||||
|
s = back(s);
|
||||||
|
expect(s.path).toEqual(["a"]);
|
||||||
|
s = choose(doc, s, 0);
|
||||||
|
expect(choose(doc, s, 0)).toBe(s);
|
||||||
|
expect(back(initial(doc)).path).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores a valid saved path and discards a stale one", () => {
|
||||||
|
expect(restore(doc, JSON.stringify({ path: ["a", "c"] })).path).toEqual([
|
||||||
|
"a",
|
||||||
|
"c",
|
||||||
|
]);
|
||||||
|
expect(restore(doc, JSON.stringify({ path: ["a", "gone"] })).path).toEqual([
|
||||||
|
"a",
|
||||||
|
]);
|
||||||
|
expect(restore(doc, JSON.stringify({ path: ["c"] })).path).toEqual(["a"]);
|
||||||
|
expect(restore(doc, "not json").path).toEqual(["a"]);
|
||||||
|
expect(restore(doc, null).path).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editor helpers", () => {
|
||||||
|
it("a fresh doc has the start dead end flagged", () => {
|
||||||
|
const msgs = analyze(emptyDoc()).map((i) => i.message);
|
||||||
|
expect(msgs).toContain("dead end: no choices and not marked as an ending");
|
||||||
|
expect(msgs).toContain("no ending is reachable from the start node");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a valid doc has no errors", () => {
|
||||||
|
expect(analyze(doc).filter((i) => i.level === "error")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renameNode rewrites start and choice targets; ignores collisions", () => {
|
||||||
|
const r = renameNode(doc, "a", "intro");
|
||||||
|
expect(r.start).toBe("intro");
|
||||||
|
expect(r.nodes.c!.choices[0]!.to).toBe("intro");
|
||||||
|
expect(r.nodes.a).toBeUndefined();
|
||||||
|
expect(renameNode(doc, "a", "b")).toBe(doc);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeNode drops inbound choices and repoints start", () => {
|
||||||
|
const r = removeNode(doc, "b");
|
||||||
|
expect(r.nodes.a!.choices.map((c) => c.to)).toEqual(["c"]);
|
||||||
|
expect(removeNode(doc, "a").start).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nextKey avoids collisions", () => {
|
||||||
|
expect(
|
||||||
|
nextKey({
|
||||||
|
start: "a",
|
||||||
|
nodes: { "node-2": doc.nodes.a!, a: doc.nodes.a! },
|
||||||
|
}),
|
||||||
|
).toBe("node-3");
|
||||||
|
});
|
||||||
|
});
|
||||||
215
src/lib/stories.ts
Normal file
215
src/lib/stories.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import { getConfig } from "./config";
|
||||||
|
|
||||||
|
export type Ending = "win" | "lose" | "neutral";
|
||||||
|
export interface Choice {
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
export interface StoryNode {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
ending?: Ending;
|
||||||
|
choices: Choice[];
|
||||||
|
}
|
||||||
|
export interface StoryDoc {
|
||||||
|
start: string;
|
||||||
|
nodes: Record<string, StoryNode>;
|
||||||
|
}
|
||||||
|
export interface Story {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
doc: StoryDoc;
|
||||||
|
published: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
export interface StorySummary extends Omit<Story, "doc"> {
|
||||||
|
nodeCount: number;
|
||||||
|
}
|
||||||
|
export interface Issue {
|
||||||
|
level: "error" | "warning";
|
||||||
|
node?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export interface StoryInput {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
doc: StoryDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
readonly issues: Issue[] = [],
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const base = getConfig().API_BASE_URL.replace(/\/$/, "");
|
||||||
|
const res = await fetch(`${base}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const text = await res.text();
|
||||||
|
let data: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
// non-JSON error body
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
(data.error as string | undefined) ?? `request failed (${res.status})`,
|
||||||
|
res.status,
|
||||||
|
(data.issues as Issue[] | undefined) ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listStories = (mine: boolean) =>
|
||||||
|
call<{ stories: StorySummary[] }>(
|
||||||
|
"GET",
|
||||||
|
`/stories${mine ? "?mine=1" : ""}`,
|
||||||
|
).then((r) => r.stories);
|
||||||
|
export const getStory = (id: string) =>
|
||||||
|
call<{ story: Story; issues: Issue[] }>("GET", `/stories/${id}`);
|
||||||
|
export const createStory = (input: StoryInput) =>
|
||||||
|
call<{ story: Story; issues: Issue[] }>("POST", "/stories", input);
|
||||||
|
export const saveStory = (id: string, input: StoryInput) =>
|
||||||
|
call<{ story: Story; issues: Issue[] }>("PUT", `/stories/${id}`, input);
|
||||||
|
export const publishStory = (id: string) =>
|
||||||
|
call<{ story: Story; issues: Issue[] }>("POST", `/stories/${id}/publish`);
|
||||||
|
export const unpublishStory = (id: string) =>
|
||||||
|
call<{ story: Story; issues: Issue[] }>("POST", `/stories/${id}/unpublish`);
|
||||||
|
export const deleteStory = (id: string) =>
|
||||||
|
call<void>("DELETE", `/stories/${id}`);
|
||||||
|
|
||||||
|
export const KEY_RE = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
||||||
|
|
||||||
|
/** Mirror of the backend's analyze(), for live feedback while editing. */
|
||||||
|
export function analyze(doc: StoryDoc): Issue[] {
|
||||||
|
const issues: Issue[] = [];
|
||||||
|
const nodes = doc.nodes;
|
||||||
|
if (!nodes[doc.start]) {
|
||||||
|
issues.push({
|
||||||
|
level: "error",
|
||||||
|
message: `start node "${doc.start}" does not exist`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [k, n] of Object.entries(nodes)) {
|
||||||
|
if (!KEY_RE.test(k)) {
|
||||||
|
issues.push({
|
||||||
|
level: "error",
|
||||||
|
node: k,
|
||||||
|
message: "invalid node key (use a-z, 0-9, - _)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const c of n.choices) {
|
||||||
|
if (!nodes[c.to]) {
|
||||||
|
issues.push({
|
||||||
|
level: "error",
|
||||||
|
node: k,
|
||||||
|
message: `choice "${c.label}" points to missing node "${c.to}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!n.ending && n.choices.length === 0) {
|
||||||
|
issues.push({
|
||||||
|
level: "error",
|
||||||
|
node: k,
|
||||||
|
message: "dead end: no choices and not marked as an ending",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (n.ending && n.choices.length > 0) {
|
||||||
|
issues.push({
|
||||||
|
level: "warning",
|
||||||
|
node: k,
|
||||||
|
message: "ending node has choices that will never be shown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nodes[doc.start]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const stack = [doc.start];
|
||||||
|
while (stack.length) {
|
||||||
|
const k = stack.pop()!;
|
||||||
|
const n = nodes[k];
|
||||||
|
if (seen.has(k) || !n) continue;
|
||||||
|
seen.add(k);
|
||||||
|
if (!n.ending) for (const c of n.choices) stack.push(c.to);
|
||||||
|
}
|
||||||
|
for (const k of Object.keys(nodes)) {
|
||||||
|
if (!seen.has(k)) {
|
||||||
|
issues.push({
|
||||||
|
level: "warning",
|
||||||
|
node: k,
|
||||||
|
message: "unreachable from the start node",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (![...seen].some((k) => nodes[k]?.ending)) {
|
||||||
|
issues.push({
|
||||||
|
level: "error",
|
||||||
|
message: "no ending is reachable from the start node",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyDoc(): StoryDoc {
|
||||||
|
return {
|
||||||
|
start: "start",
|
||||||
|
nodes: {
|
||||||
|
start: {
|
||||||
|
title: "The beginning",
|
||||||
|
text: "Your story starts here.",
|
||||||
|
choices: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename a node key and rewrite every reference to it. */
|
||||||
|
export function renameNode(doc: StoryDoc, from: string, to: string): StoryDoc {
|
||||||
|
if (from === to || doc.nodes[to]) return doc;
|
||||||
|
const nodes: Record<string, StoryNode> = {};
|
||||||
|
for (const [k, n] of Object.entries(doc.nodes)) {
|
||||||
|
nodes[k === from ? to : k] = {
|
||||||
|
...n,
|
||||||
|
choices: n.choices.map((c) => (c.to === from ? { ...c, to } : c)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { start: doc.start === from ? to : doc.start, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeNode(doc: StoryDoc, key: string): StoryDoc {
|
||||||
|
const nodes: Record<string, StoryNode> = {};
|
||||||
|
for (const [k, n] of Object.entries(doc.nodes)) {
|
||||||
|
if (k !== key)
|
||||||
|
nodes[k] = { ...n, choices: n.choices.filter((c) => c.to !== key) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start: doc.start === key ? (Object.keys(nodes)[0] ?? "") : doc.start,
|
||||||
|
nodes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextKey(doc: StoryDoc): string {
|
||||||
|
for (let i = Object.keys(doc.nodes).length + 1; ; i++) {
|
||||||
|
if (!doc.nodes[`node-${i}`]) return `node-${i}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||||
import { FlagsProvider } from "./lib/flags";
|
import { FlagsProvider } from "./lib/flags";
|
||||||
import { routes } from "./routes";
|
import { routes } from "./routes";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
const router = createBrowserRouter(routes);
|
const router = createBrowserRouter(routes);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import type { RouteObject } from "react-router";
|
import type { RouteObject } from "react-router";
|
||||||
|
import { Build, buildLoader } from "./routes/Build";
|
||||||
|
import { BuildList, buildListLoader } from "./routes/BuildList";
|
||||||
import { ErrorPage } from "./routes/ErrorPage";
|
import { ErrorPage } from "./routes/ErrorPage";
|
||||||
import { Home } from "./routes/Home";
|
|
||||||
import { Layout } from "./routes/Layout";
|
import { Layout } from "./routes/Layout";
|
||||||
|
import { Library, libraryLoader } from "./routes/Library";
|
||||||
|
import { Play, playLoader } from "./routes/Play";
|
||||||
import { Status, statusLoader } from "./routes/Status";
|
import { Status, statusLoader } from "./routes/Status";
|
||||||
|
|
||||||
// Data-mode route table. Add pages here; loaders live next to their component.
|
// Data-mode route table. Add pages here; loaders live next to their component.
|
||||||
|
|
@ -11,7 +14,10 @@ export const routes: RouteObject[] = [
|
||||||
element: <Layout />,
|
element: <Layout />,
|
||||||
errorElement: <ErrorPage />,
|
errorElement: <ErrorPage />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Home /> },
|
{ index: true, element: <Library />, loader: libraryLoader },
|
||||||
|
{ path: "play/:id", element: <Play />, loader: playLoader },
|
||||||
|
{ path: "build", element: <BuildList />, loader: buildListLoader },
|
||||||
|
{ path: "build/:id", element: <Build />, loader: buildLoader },
|
||||||
{ path: "status", element: <Status />, loader: statusLoader },
|
{ path: "status", element: <Status />, loader: statusLoader },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
313
src/routes/Build.tsx
Normal file
313
src/routes/Build.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||||
|
import {
|
||||||
|
analyze,
|
||||||
|
ApiError,
|
||||||
|
getStory,
|
||||||
|
KEY_RE,
|
||||||
|
nextKey,
|
||||||
|
publishStory,
|
||||||
|
removeNode,
|
||||||
|
renameNode,
|
||||||
|
saveStory,
|
||||||
|
unpublishStory,
|
||||||
|
type Ending,
|
||||||
|
type Issue,
|
||||||
|
type Story,
|
||||||
|
type StoryDoc,
|
||||||
|
type StoryNode,
|
||||||
|
} from "../lib/stories";
|
||||||
|
|
||||||
|
export async function buildLoader({ params }: LoaderFunctionArgs) {
|
||||||
|
return (await getStory(params.id!)).story;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Build() {
|
||||||
|
return <Editor story={useLoaderData() as Story} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Editor({ story }: { story: Story }) {
|
||||||
|
const [title, setTitle] = useState(story.title);
|
||||||
|
const [description, setDescription] = useState(story.description);
|
||||||
|
const [doc, setDoc] = useState<StoryDoc>(story.doc);
|
||||||
|
const [published, setPublished] = useState(story.published);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [serverIssues, setServerIssues] = useState<Issue[] | null>(null);
|
||||||
|
|
||||||
|
const issues = useMemo(() => analyze(doc), [doc]);
|
||||||
|
const keys = Object.keys(doc.nodes);
|
||||||
|
|
||||||
|
const edit = (fn: (d: StoryDoc) => StoryDoc) => {
|
||||||
|
setDoc(fn);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
const patchNode = (k: string, patch: Partial<StoryNode>) =>
|
||||||
|
edit((d) => ({
|
||||||
|
...d,
|
||||||
|
nodes: { ...d.nodes, [k]: { ...d.nodes[k]!, ...patch } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await saveStory(story.id, { title, description, doc });
|
||||||
|
setDirty(false);
|
||||||
|
setMessage("Saved.");
|
||||||
|
setServerIssues(null);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
setMessage(
|
||||||
|
e instanceof Error ? `Save failed: ${e.message}` : "Save failed",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePublish() {
|
||||||
|
if (dirty && !(await save())) return;
|
||||||
|
try {
|
||||||
|
if (published) {
|
||||||
|
await unpublishStory(story.id);
|
||||||
|
setPublished(false);
|
||||||
|
setMessage("Unpublished.");
|
||||||
|
} else {
|
||||||
|
await publishStory(story.id);
|
||||||
|
setPublished(true);
|
||||||
|
setMessage("Published!");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) setServerIssues(e.issues);
|
||||||
|
setMessage(e instanceof Error ? e.message : "Publish failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="editor">
|
||||||
|
<p>
|
||||||
|
<Link to="/build">← Your stories</Link>
|
||||||
|
</p>
|
||||||
|
<h1>Edit story</h1>
|
||||||
|
<label>
|
||||||
|
Title
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Description
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDescription(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Start scene
|
||||||
|
<select
|
||||||
|
value={doc.start}
|
||||||
|
onChange={(e) => edit((d) => ({ ...d, start: e.target.value }))}
|
||||||
|
>
|
||||||
|
{keys.map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{doc.nodes[k]!.title} ({k})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<button onClick={() => void save()} disabled={!dirty}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onClick={() => void togglePublish()}>
|
||||||
|
{published ? "Unpublish" : "Publish"}
|
||||||
|
</button>
|
||||||
|
{published && <Link to={`/play/${story.id}`}>Play →</Link>}
|
||||||
|
<span role="status">{message ?? (dirty ? "Unsaved changes" : "")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div aria-label="Problems" className="issues">
|
||||||
|
{(serverIssues ?? issues).length === 0 ? (
|
||||||
|
<p className="ok">No problems found.</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{(serverIssues ?? issues).map((i, n) => (
|
||||||
|
<li key={n} className={i.level}>
|
||||||
|
<strong>{i.level}</strong>
|
||||||
|
{i.node ? ` [${i.node}]` : ""}: {i.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{keys.map((k) => (
|
||||||
|
<NodeEditor
|
||||||
|
key={k}
|
||||||
|
nodeKey={k}
|
||||||
|
node={doc.nodes[k]!}
|
||||||
|
allKeys={keys}
|
||||||
|
doc={doc}
|
||||||
|
onPatch={(p) => patchNode(k, p)}
|
||||||
|
onRename={(to) => edit((d) => renameNode(d, k, to))}
|
||||||
|
onRemove={() => edit((d) => removeNode(d, k))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
edit((d) => {
|
||||||
|
const k = nextKey(d);
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
nodes: {
|
||||||
|
...d.nodes,
|
||||||
|
[k]: { title: "New scene", text: "", choices: [] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Add scene
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeEditor(props: {
|
||||||
|
nodeKey: string;
|
||||||
|
node: StoryNode;
|
||||||
|
allKeys: string[];
|
||||||
|
doc: StoryDoc;
|
||||||
|
onPatch: (p: Partial<StoryNode>) => void;
|
||||||
|
onRename: (to: string) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { nodeKey, node, allKeys, onPatch } = props;
|
||||||
|
const [keyDraft, setKeyDraft] = useState(nodeKey);
|
||||||
|
const keyTaken = keyDraft !== nodeKey && allKeys.includes(keyDraft);
|
||||||
|
const keyOk = KEY_RE.test(keyDraft) && !keyTaken;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="node">
|
||||||
|
<legend>
|
||||||
|
{node.title} <code>{nodeKey}</code>
|
||||||
|
</legend>
|
||||||
|
<label>
|
||||||
|
Key
|
||||||
|
<input
|
||||||
|
value={keyDraft}
|
||||||
|
aria-invalid={!keyOk}
|
||||||
|
onChange={(e) => setKeyDraft(e.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
keyOk ? props.onRename(keyDraft) : setKeyDraft(nodeKey)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Scene title
|
||||||
|
<input
|
||||||
|
value={node.title}
|
||||||
|
onChange={(e) => onPatch({ title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Text
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={node.text}
|
||||||
|
onChange={(e) => onPatch({ text: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ending
|
||||||
|
<select
|
||||||
|
value={node.ending ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPatch({
|
||||||
|
ending: (e.target.value || undefined) as Ending | undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Not an ending</option>
|
||||||
|
<option value="win">Win</option>
|
||||||
|
<option value="lose">Lose</option>
|
||||||
|
<option value="neutral">Neutral</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<strong>Choices</strong>
|
||||||
|
{node.choices.map((c, i) => (
|
||||||
|
<div key={i} className="choice-row">
|
||||||
|
<input
|
||||||
|
aria-label={`Choice ${i + 1} label`}
|
||||||
|
value={c.label}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPatch({
|
||||||
|
choices: node.choices.map((x, j) =>
|
||||||
|
j === i ? { ...x, label: e.target.value } : x,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
aria-label={`Choice ${i + 1} target`}
|
||||||
|
value={c.to}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPatch({
|
||||||
|
choices: node.choices.map((x, j) =>
|
||||||
|
j === i ? { ...x, to: e.target.value } : x,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{allKeys.map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{props.doc.nodes[k]!.title} ({k})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="link"
|
||||||
|
aria-label={`Remove choice ${i + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
onPatch({ choices: node.choices.filter((_, j) => j !== i) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="link"
|
||||||
|
disabled={node.choices.length >= 10}
|
||||||
|
onClick={() =>
|
||||||
|
onPatch({
|
||||||
|
choices: [
|
||||||
|
...node.choices,
|
||||||
|
{ label: "Do something", to: allKeys[0]! },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Add choice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="link danger"
|
||||||
|
onClick={props.onRemove}
|
||||||
|
disabled={allKeys.length < 2}
|
||||||
|
>
|
||||||
|
Delete scene
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/routes/BuildList.tsx
Normal file
59
src/routes/BuildList.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
|
||||||
|
import {
|
||||||
|
createStory,
|
||||||
|
deleteStory,
|
||||||
|
emptyDoc,
|
||||||
|
listStories,
|
||||||
|
type StorySummary,
|
||||||
|
} from "../lib/stories";
|
||||||
|
|
||||||
|
export const buildListLoader = () => listStories(true);
|
||||||
|
|
||||||
|
export function BuildList() {
|
||||||
|
const stories = useLoaderData() as StorySummary[];
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
const { story } = await createStory({
|
||||||
|
title: "Untitled adventure",
|
||||||
|
description: "",
|
||||||
|
doc: emptyDoc(),
|
||||||
|
});
|
||||||
|
navigate(`/build/${story.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(s: StorySummary) {
|
||||||
|
if (!confirm(`Delete "${s.title}"? This cannot be undone.`)) return;
|
||||||
|
await deleteStory(s.id);
|
||||||
|
await revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>Your stories</h1>
|
||||||
|
<p>
|
||||||
|
<button onClick={create}>New story</button>
|
||||||
|
</p>
|
||||||
|
<ul className="cards">
|
||||||
|
{stories.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<h2>
|
||||||
|
{s.title}{" "}
|
||||||
|
<span className="badge">
|
||||||
|
{s.published ? "published" : "draft"}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<small>{s.nodeCount} scenes</small>{" "}
|
||||||
|
<Link className="button" to={`/build/${s.id}`}>
|
||||||
|
Edit
|
||||||
|
</Link>{" "}
|
||||||
|
<button className="link" onClick={() => remove(s)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { buildSha, getConfig } from "../lib/config";
|
|
||||||
import { useFlag } from "../lib/flags";
|
|
||||||
|
|
||||||
export function Home() {
|
|
||||||
// Demo flag (Plan 07): toggle "hello-banner" in Unleash, then reload.
|
|
||||||
const banner = useFlag("hello-banner");
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
{banner && (
|
|
||||||
<p
|
|
||||||
role="status"
|
|
||||||
data-testid="banner"
|
|
||||||
style={{ background: "#fff3c4", padding: "0.5rem 1rem" }}
|
|
||||||
>
|
|
||||||
The hello-banner flag is on.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<h1>{getConfig().APP_NAME}</h1>
|
|
||||||
<p>
|
|
||||||
Build: <code data-testid="build-sha">{buildSha}</code>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,10 @@ export function Layout() {
|
||||||
<header>
|
<header>
|
||||||
<strong>{getConfig().APP_NAME}</strong>
|
<strong>{getConfig().APP_NAME}</strong>
|
||||||
<nav>
|
<nav>
|
||||||
<NavLink to="/">Home</NavLink> |{" "}
|
<NavLink to="/" end>
|
||||||
|
Play
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/build">Build</NavLink>
|
||||||
<NavLink to="/status">Status</NavLink>
|
<NavLink to="/status">Status</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
31
src/routes/Library.tsx
Normal file
31
src/routes/Library.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Link, useLoaderData } from "react-router";
|
||||||
|
import { listStories, type StorySummary } from "../lib/stories";
|
||||||
|
|
||||||
|
export const libraryLoader = () => listStories(false);
|
||||||
|
|
||||||
|
export function Library() {
|
||||||
|
const stories = useLoaderData() as StorySummary[];
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>Choose your adventure</h1>
|
||||||
|
{stories.length === 0 ? (
|
||||||
|
<p>
|
||||||
|
No published stories yet. <Link to="/build">Build one!</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="cards">
|
||||||
|
{stories.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<h2>{s.title}</h2>
|
||||||
|
<p>{s.description}</p>
|
||||||
|
<small>{s.nodeCount} scenes</small>{" "}
|
||||||
|
<Link className="button" to={`/play/${s.id}`}>
|
||||||
|
Play
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/routes/Play.tsx
Normal file
79
src/routes/Play.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||||
|
import { back, choose, initial, restore, type PlayState } from "../lib/play";
|
||||||
|
import { getStory, type Story } from "../lib/stories";
|
||||||
|
|
||||||
|
export async function playLoader({ params }: LoaderFunctionArgs) {
|
||||||
|
return (await getStory(params.id!)).story;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Play() {
|
||||||
|
const story = useLoaderData() as Story;
|
||||||
|
return <Player story={story} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Player({ story }: { story: Story }) {
|
||||||
|
const storageKey = `adventure:${story.id}`;
|
||||||
|
const [state, setState] = useState<PlayState>(() =>
|
||||||
|
restore(story.doc, localStorage.getItem(storageKey)),
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||||
|
}, [state, storageKey]);
|
||||||
|
|
||||||
|
const key = state.path[state.path.length - 1]!;
|
||||||
|
const node = story.doc.nodes[key]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="player">
|
||||||
|
<p>
|
||||||
|
<Link to="/">← Library</Link>
|
||||||
|
</p>
|
||||||
|
<h1>{story.title}</h1>
|
||||||
|
<h2>{node.title}</h2>
|
||||||
|
{node.text.split(/\n{2,}/).map((p, i) => (
|
||||||
|
<p key={i}>{p}</p>
|
||||||
|
))}
|
||||||
|
{node.ending ? (
|
||||||
|
<div className={`ending ${node.ending}`} role="status">
|
||||||
|
<strong>
|
||||||
|
{node.ending === "win"
|
||||||
|
? "You win!"
|
||||||
|
: node.ending === "lose"
|
||||||
|
? "The end… for you."
|
||||||
|
: "The end."}
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
You made {state.path.length - 1} choice
|
||||||
|
{state.path.length === 2 ? "" : "s"}.
|
||||||
|
</p>
|
||||||
|
<button onClick={() => setState(initial(story.doc))}>
|
||||||
|
Play again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol className="choices">
|
||||||
|
{node.choices.map((c, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<button onClick={() => setState((s) => choose(story.doc, s, i))}>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<button
|
||||||
|
className="link"
|
||||||
|
disabled={state.path.length < 2}
|
||||||
|
onClick={() => setState(back)}
|
||||||
|
>
|
||||||
|
↩ Undo last choice
|
||||||
|
</button>{" "}
|
||||||
|
<button className="link" onClick={() => setState(initial(story.doc))}>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/routes/ui.test.tsx
Normal file
119
src/routes/ui.test.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Editor } from "./Build";
|
||||||
|
import { Player } from "./Play";
|
||||||
|
import type { Story } from "../lib/stories";
|
||||||
|
|
||||||
|
vi.mock("@unleash/proxy-client-react", () => ({
|
||||||
|
FlagProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
useFlag: () => false,
|
||||||
|
useVariant: () => ({ name: "disabled", enabled: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const story: Story = {
|
||||||
|
id: "s1",
|
||||||
|
title: "Cave",
|
||||||
|
description: "",
|
||||||
|
published: false,
|
||||||
|
updatedAt: "",
|
||||||
|
doc: {
|
||||||
|
start: "a",
|
||||||
|
nodes: {
|
||||||
|
a: {
|
||||||
|
title: "Entrance",
|
||||||
|
text: "It is dark.",
|
||||||
|
choices: [{ label: "Go in", to: "b" }],
|
||||||
|
},
|
||||||
|
b: { title: "Treasure", text: "Gold!", ending: "win", choices: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
window.__APP_CONFIG__ = { API_BASE_URL: "/api", APP_NAME: "t" };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Player", () => {
|
||||||
|
it("plays to an ending, shows the result, and restarts", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Player story={story} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("It is dark.")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Go in" }));
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("You win!");
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("1 choice.");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Play again" }));
|
||||||
|
expect(screen.getByText("It is dark.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes from saved progress", async () => {
|
||||||
|
localStorage.setItem("adventure:s1", JSON.stringify({ path: ["a", "b"] }));
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Player story={story} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Gold!")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Editor", () => {
|
||||||
|
it("shows live problems and saves via PUT", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
async () => new Response(JSON.stringify({ story, issues: [] })),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Editor story={story} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("No problems found.")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Remove choice 1" }));
|
||||||
|
expect(screen.getByText(/dead end/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(await screen.findByText("Saved.")).toBeInTheDocument();
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(url).toBe("/api/stories/s1");
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces server publish errors", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "story has errors; fix them before publishing",
|
||||||
|
issues: [{ level: "error", message: "boom" }],
|
||||||
|
}),
|
||||||
|
{ status: 422 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Editor story={story} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Publish" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/fix them before publishing/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/boom/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
166
src/styles.css
Normal file
166
src/styles.css
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1d1d1f;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #faf8f3;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #2b2118;
|
||||||
|
color: #f5e9d3;
|
||||||
|
}
|
||||||
|
header nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
header a {
|
||||||
|
color: #f5e9d3;
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
header a.active {
|
||||||
|
opacity: 1;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
.button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border: 1px solid #6b4f2d;
|
||||||
|
background: #6b4f2d;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
button.link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6b4f2d;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
button.danger {
|
||||||
|
color: #a31d1d;
|
||||||
|
}
|
||||||
|
.cards {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.cards li {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e3dccd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.cards h2 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: #e3dccd;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.player {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.choices {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.choices button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
}
|
||||||
|
.ending {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid;
|
||||||
|
}
|
||||||
|
.ending.win {
|
||||||
|
border-color: #2e7d32;
|
||||||
|
background: #e8f5e9;
|
||||||
|
}
|
||||||
|
.ending.lose {
|
||||||
|
border-color: #a31d1d;
|
||||||
|
background: #fdecea;
|
||||||
|
}
|
||||||
|
.ending.neutral {
|
||||||
|
border-color: #6b4f2d;
|
||||||
|
background: #f3ece0;
|
||||||
|
}
|
||||||
|
.editor label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.editor input,
|
||||||
|
.editor textarea,
|
||||||
|
.editor select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.35rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.editor input[aria-invalid="true"] {
|
||||||
|
outline: 2px solid #a31d1d;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.issues ul {
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
.issues .error strong {
|
||||||
|
color: #a31d1d;
|
||||||
|
}
|
||||||
|
.issues .warning strong {
|
||||||
|
color: #b26a00;
|
||||||
|
}
|
||||||
|
.issues .ok {
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
.node {
|
||||||
|
border: 1px solid #d7cdb8;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.choice-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
.choice-row input,
|
||||||
|
.choice-row select {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue