Compare commits
3 Commits
0ed2066b17
...
9c0d9326e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c0d9326e0 | |||
| f3bfb2736c | |||
| 4bed1c6e65 |
2
.envrc
2
.envrc
@@ -1,4 +1,4 @@
|
||||
use nix
|
||||
use flake
|
||||
|
||||
# Add Node modules only to the end of the path.
|
||||
# `layout node` will add it to the front, which is not as secure
|
||||
|
||||
44
package-lock.json
generated
44
package-lock.json
generated
@@ -8,7 +8,8 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-query": "^5.77.2",
|
||||
"@tanstack/react-query": "^5.79.0",
|
||||
"@tanstack/react-query-devtools": "^5.79.0",
|
||||
"@tanstack/react-router": "^1.114.3",
|
||||
"@tanstack/react-router-devtools": "^1.114.3",
|
||||
"@tanstack/router-plugin": "^1.114.3",
|
||||
@@ -1659,9 +1660,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.77.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.77.2.tgz",
|
||||
"integrity": "sha512-1lqJwPsR6GX6nZFw06erRt518O19tWU6Q+x0fJUygl4lxHCYF2nhzBPwLKk2NPjYOrpR0K567hxPc5K++xDe9Q==",
|
||||
"version": "5.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.79.0.tgz",
|
||||
"integrity": "sha512-s+epTqqLM0/TbJzMAK7OEhZIzh63P9sWz5HEFc5XHL4FvKQXQkcjI8F3nee+H/xVVn7mrP610nVXwOytTSYd0w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.76.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.76.0.tgz",
|
||||
"integrity": "sha512-1p92nqOBPYVqVDU0Ua5nzHenC6EGZNrLnB2OZphYw8CNA1exuvI97FVgIKON7Uug3uQqvH/QY8suUKpQo8qHNQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -1669,12 +1680,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.77.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.77.2.tgz",
|
||||
"integrity": "sha512-BRHxWdy1mHmgAcYA/qy2IPLylT81oebLgkm9K85viN2Qol/Vq48t1dzDFeDIVQjTWDV96AmqsLNPlH5HjyKCxA==",
|
||||
"version": "5.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.79.0.tgz",
|
||||
"integrity": "sha512-DjC4JIYZnYzxaTzbg3osOU63VNLP67dOrWet2cZvXgmgwAXNxfS52AMq86M5++ILuzW+BqTUEVMTjhrZ7/XBuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.77.2"
|
||||
"@tanstack/query-core": "5.79.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -1684,6 +1695,23 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.79.0.tgz",
|
||||
"integrity": "sha512-YVRWxjxsWycWChjKxvaIAPdNC5LX0zpiHoNyTB8teDZpQstM1b7mCuAp3x60cjX1MhLoO3vbaeY29EKst4D4ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.76.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.79.0",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-router": {
|
||||
"version": "1.120.10",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.120.10.tgz",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-query": "^5.77.2",
|
||||
"@tanstack/react-query": "^5.79.0",
|
||||
"@tanstack/react-query-devtools": "^5.79.0",
|
||||
"@tanstack/react-router": "^1.114.3",
|
||||
"@tanstack/react-router-devtools": "^1.114.3",
|
||||
"@tanstack/router-plugin": "^1.114.3",
|
||||
|
||||
48
pb_migrations/1748755724_updated_relationships.js
Normal file
48
pb_migrations/1748755724_updated_relationships.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// update field
|
||||
collection.fields.addAt(3, new Field({
|
||||
"hidden": false,
|
||||
"id": "select2363381545",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures",
|
||||
"scenes",
|
||||
"npcs",
|
||||
"locations"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// update field
|
||||
collection.fields.addAt(3, new Field({
|
||||
"hidden": false,
|
||||
"id": "select2363381545",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures",
|
||||
"scenes"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
51
pb_migrations/1748757068_updated_relationships.js
Normal file
51
pb_migrations/1748757068_updated_relationships.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// update field
|
||||
collection.fields.addAt(3, new Field({
|
||||
"hidden": false,
|
||||
"id": "select2363381545",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures",
|
||||
"scenes",
|
||||
"npcs",
|
||||
"locations",
|
||||
"monsters"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// update field
|
||||
collection.fields.addAt(3, new Field({
|
||||
"hidden": false,
|
||||
"id": "select2363381545",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures",
|
||||
"scenes",
|
||||
"npcs",
|
||||
"locations"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -9,12 +9,14 @@ export function AutoSaveTextarea({
|
||||
onSave,
|
||||
delay = 500,
|
||||
className = "",
|
||||
multiline = true,
|
||||
...props
|
||||
}: {
|
||||
value: string;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
multiline?: boolean;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue || "");
|
||||
@@ -35,7 +37,9 @@ export function AutoSaveTextarea({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) => {
|
||||
setValue(e.target.value);
|
||||
setSaved(false);
|
||||
setFlash(false);
|
||||
@@ -55,12 +59,21 @@ export function AutoSaveTextarea({
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className={`w-full min-h-[4rem] p-2 rounded border bg-slate-800 text-slate-100 border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500 transition-colors ${flash ? "ring-2 ring-emerald-400 border-emerald-400 bg-emerald-950" : ""} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className={`w-full p-2 rounded border bg-slate-800 text-slate-100 border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500 transition-colors ${flash ? "ring-2 ring-emerald-400 border-emerald-400 bg-emerald-950" : ""} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
{saved && !saving && (
|
||||
<span className="absolute bottom-1 right-2 text-emerald-400 text-xs bg-slate-900 bg-opacity-80 px-2 py-0.5 rounded shadow">
|
||||
Saved
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { RelationshipType, type CampaignId, type Document } from "@/lib/types";
|
||||
import { LocationForm } from "./location/LocationForm";
|
||||
import { MonsterForm } from "./monsters/MonsterForm";
|
||||
import { NpcForm } from "./npc/NpcForm";
|
||||
import { SceneForm } from "./scene/SceneForm";
|
||||
import { SecretForm } from "./secret/SecretForm";
|
||||
import { TreasureForm } from "./treasure/TreasureForm";
|
||||
import { SceneForm } from "./scene/SceneForm";
|
||||
|
||||
function assertUnreachable(_x: never): never {
|
||||
throw new Error("DocumentForm switch is not exhaustive");
|
||||
@@ -20,6 +23,12 @@ export const DocumentForm = ({
|
||||
onCreate: (document: Document) => Promise<void>;
|
||||
}) => {
|
||||
switch (relationshipType) {
|
||||
case RelationshipType.Locations:
|
||||
return <LocationForm campaign={campaignId} onCreate={onCreate} />;
|
||||
case RelationshipType.Monsters:
|
||||
return <MonsterForm campaign={campaignId} onCreate={onCreate} />;
|
||||
case RelationshipType.Npcs:
|
||||
return <NpcForm campaign={campaignId} onCreate={onCreate} />;
|
||||
case RelationshipType.Secrets:
|
||||
return <SecretForm campaign={campaignId} onCreate={onCreate} />;
|
||||
case RelationshipType.DiscoveredIn:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// DocumentRow.tsx
|
||||
// Generic row component for displaying any document type.
|
||||
import { SessionRow } from "@/components/documents/session/SessionRow";
|
||||
import { SecretRow } from "@/components/documents/secret/SecretRow";
|
||||
import { SessionRow } from "@/components/documents/session/SessionRow";
|
||||
import {
|
||||
isLocation,
|
||||
isMonster,
|
||||
isNpc,
|
||||
isScene,
|
||||
isSecret,
|
||||
isSession,
|
||||
@@ -10,8 +13,11 @@ import {
|
||||
type Document,
|
||||
type Session,
|
||||
} from "@/lib/types";
|
||||
import { TreasureRow } from "./treasure/TreasureRow";
|
||||
import { LocationRow } from "./location/LocationRow";
|
||||
import { MonsterRow } from "./monsters/MonsterRow";
|
||||
import { NpcRow } from "./npc/NpcRow";
|
||||
import { SceneRow } from "./scene/SceneRow";
|
||||
import { TreasureRow } from "./treasure/TreasureRow";
|
||||
|
||||
/**
|
||||
* Renders a row for any document type. Prioritizes Session, then Secret, then falls back to ID and creation time.
|
||||
@@ -24,10 +30,22 @@ export const DocumentRow = ({
|
||||
document: Document;
|
||||
session?: Session;
|
||||
}) => {
|
||||
if (isLocation(document)) {
|
||||
return <LocationRow location={document} />;
|
||||
}
|
||||
|
||||
if (isMonster(document)) {
|
||||
return <MonsterRow monster={document} />;
|
||||
}
|
||||
|
||||
if (isNpc(document)) {
|
||||
return <NpcRow npc={document} />;
|
||||
}
|
||||
|
||||
if (isSession(document)) {
|
||||
// Use SessionRow for session documents
|
||||
return <SessionRow session={document} />;
|
||||
}
|
||||
|
||||
if (isSecret(document)) {
|
||||
return <SecretRow secret={document} session={session} />;
|
||||
}
|
||||
|
||||
84
src/components/documents/location/LocationForm.tsx
Normal file
84
src/components/documents/location/LocationForm.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import type { CampaignId, Location } from "@/lib/types";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
|
||||
/**
|
||||
* Renders a form to add a new location. Calls onCreate with the new location document.
|
||||
*/
|
||||
export const LocationForm = ({
|
||||
campaign,
|
||||
onCreate,
|
||||
}: {
|
||||
campaign: CampaignId;
|
||||
onCreate: (location: Location) => Promise<void>;
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
const locationDoc: Location = await pb.collection("documents").create({
|
||||
campaign,
|
||||
data: {
|
||||
location: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
},
|
||||
});
|
||||
setName("");
|
||||
setDescription("");
|
||||
await onCreate(locationDoc);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to add location.");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex items-left flex-col gap-2 mt-4"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<h3>Create new location</h3>
|
||||
<div className="flex gap-5 w-full items-center">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={adding}
|
||||
aria-label="Name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-5 w-full items-center">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={adding}
|
||||
aria-label="Description"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
|
||||
disabled={adding || !name.trim()}
|
||||
>
|
||||
{adding ? "Adding..." : "Create"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
46
src/components/documents/location/LocationRow.tsx
Normal file
46
src/components/documents/location/LocationRow.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { Location } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Renders an editable location row
|
||||
*/
|
||||
export const LocationRow = ({ location }: { location: Location }) => {
|
||||
async function saveLocationName(name: string) {
|
||||
await pb.collection("documents").update(location.id, {
|
||||
data: {
|
||||
...location.data,
|
||||
location: {
|
||||
...location.data.location,
|
||||
name,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function saveLocationDescription(description: string) {
|
||||
await pb.collection("documents").update(location.id, {
|
||||
data: {
|
||||
...location.data,
|
||||
location: {
|
||||
...location.data.location,
|
||||
description,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<AutoSaveTextarea
|
||||
multiline={false}
|
||||
value={location.data.location.name}
|
||||
onSave={saveLocationName}
|
||||
/>
|
||||
<AutoSaveTextarea
|
||||
value={location.data.location.description}
|
||||
onSave={saveLocationDescription}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
73
src/components/documents/monsters/MonsterForm.tsx
Normal file
73
src/components/documents/monsters/MonsterForm.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
import type { CampaignId, Monster } from "@/lib/types";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
|
||||
/**
|
||||
* Renders a form to add a new monster. Calls onCreate with the new monster document.
|
||||
*/
|
||||
export const MonsterForm = ({
|
||||
campaign,
|
||||
onCreate,
|
||||
}: {
|
||||
campaign: CampaignId;
|
||||
onCreate: (monster: Monster) => Promise<void>;
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
const monsterDoc: Monster = await pb.collection("documents").create({
|
||||
campaign,
|
||||
data: {
|
||||
monster: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
},
|
||||
});
|
||||
setName("");
|
||||
setDescription("");
|
||||
await onCreate(monsterDoc);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to add monster.");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex items-left flex-col gap-2 mt-4"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<h3>Create new monster</h3>
|
||||
<div className="flex gap-5 w-full align-center">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={adding}
|
||||
aria-label="Name"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
|
||||
disabled={adding || !name.trim()}
|
||||
>
|
||||
{adding ? "Adding..." : "Create"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
30
src/components/documents/monsters/MonsterRow.tsx
Normal file
30
src/components/documents/monsters/MonsterRow.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { Monster } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Renders an editable monster row
|
||||
*/
|
||||
export const MonsterRow = ({ monster }: { monster: Monster }) => {
|
||||
async function saveMonsterName(name: string) {
|
||||
await pb.collection("documents").update(monster.id, {
|
||||
data: {
|
||||
...monster.data,
|
||||
monster: {
|
||||
...monster.data.monster,
|
||||
name,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<AutoSaveTextarea
|
||||
multiline={false}
|
||||
value={monster.data.monster.name}
|
||||
onSave={saveMonsterName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
src/components/documents/npc/NpcForm.tsx
Normal file
84
src/components/documents/npc/NpcForm.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import type { CampaignId, Npc } from "@/lib/types";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
|
||||
/**
|
||||
* Renders a form to add a new npc. Calls onCreate with the new npc document.
|
||||
*/
|
||||
export const NpcForm = ({
|
||||
campaign,
|
||||
onCreate,
|
||||
}: {
|
||||
campaign: CampaignId;
|
||||
onCreate: (npc: Npc) => Promise<void>;
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
const npcDoc: Npc = await pb.collection("documents").create({
|
||||
campaign,
|
||||
data: {
|
||||
npc: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
},
|
||||
});
|
||||
setName("");
|
||||
setDescription("");
|
||||
await onCreate(npcDoc);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to add npc.");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex items-left flex-col gap-2 mt-4"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<h3>Create new npc</h3>
|
||||
<div className="flex gap-5 w-full items-center">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={adding}
|
||||
aria-label="Name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-5 w-full items-center">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={adding}
|
||||
aria-label="Description"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
|
||||
disabled={adding || !name.trim()}
|
||||
>
|
||||
{adding ? "Adding..." : "Create"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
46
src/components/documents/npc/NpcRow.tsx
Normal file
46
src/components/documents/npc/NpcRow.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { Npc } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Renders an editable npc row
|
||||
*/
|
||||
export const NpcRow = ({ npc }: { npc: Npc }) => {
|
||||
async function saveNpcName(name: string) {
|
||||
await pb.collection("documents").update(npc.id, {
|
||||
data: {
|
||||
...npc.data,
|
||||
npc: {
|
||||
...npc.data.npc,
|
||||
name,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function saveNpcDescription(description: string) {
|
||||
await pb.collection("documents").update(npc.id, {
|
||||
data: {
|
||||
...npc.data,
|
||||
npc: {
|
||||
...npc.data.npc,
|
||||
description,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<AutoSaveTextarea
|
||||
multiline={false}
|
||||
value={npc.data.npc.name}
|
||||
onSave={saveNpcName}
|
||||
/>
|
||||
<AutoSaveTextarea
|
||||
value={npc.data.npc.description}
|
||||
onSave={saveNpcDescription}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -43,7 +43,11 @@ export const TreasureForm = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex items-center gap-2 mt-4" onSubmit={handleSubmit}>
|
||||
<form
|
||||
className="flex flex-col items-center gap-2 mt-4 w-full"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
@@ -53,6 +57,7 @@ export const TreasureForm = ({
|
||||
disabled={adding}
|
||||
aria-label="Add new treasure"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -20,6 +20,9 @@ export type Campaign = RecordModel & {
|
||||
|
||||
export const RelationshipType = {
|
||||
DiscoveredIn: "discoveredIn",
|
||||
Locations: "locations",
|
||||
Monsters: "monsters",
|
||||
Npcs: "npcs",
|
||||
Scenes: "scenes",
|
||||
Secrets: "secrets",
|
||||
Treasures: "treasures",
|
||||
@@ -51,6 +54,50 @@ export type Document = RecordModel & {
|
||||
updated: ISO8601Date;
|
||||
};
|
||||
|
||||
/** Locations **/
|
||||
|
||||
export type Location = Document &
|
||||
DocumentData<
|
||||
"location",
|
||||
{
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export function isLocation(doc: Document): doc is Location {
|
||||
return Object.hasOwn(doc.data, "location");
|
||||
}
|
||||
|
||||
/** Monsters **/
|
||||
|
||||
export type Monster = Document &
|
||||
DocumentData<
|
||||
"monster",
|
||||
{
|
||||
name: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export function isMonster(doc: Document): doc is Monster {
|
||||
return Object.hasOwn(doc.data, "monster");
|
||||
}
|
||||
|
||||
/** NPCs **/
|
||||
|
||||
export type Npc = Document &
|
||||
DocumentData<
|
||||
"npc",
|
||||
{
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export function isNpc(doc: Document): doc is Npc {
|
||||
return Object.hasOwn(doc.data, "npc");
|
||||
}
|
||||
|
||||
/** Session **/
|
||||
|
||||
export type Session = Document &
|
||||
|
||||
35
src/main.tsx
35
src/main.tsx
@@ -1,42 +1,47 @@
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
import './styles.css'
|
||||
import reportWebVitals from './reportWebVitals.ts'
|
||||
import "./styles.css";
|
||||
import reportWebVitals from "./reportWebVitals.ts";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// Create a new router instance
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {},
|
||||
defaultPreload: 'intent',
|
||||
context: { queryClient },
|
||||
defaultPreload: "intent",
|
||||
scrollRestoration: true,
|
||||
defaultStructuralSharing: true,
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
});
|
||||
|
||||
// Register the router instance for type safety
|
||||
declare module '@tanstack/react-router' {
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the app
|
||||
const rootElement = document.getElementById('app')
|
||||
const rootElement = document.getElementById("app");
|
||||
if (rootElement && !rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement)
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals()
|
||||
reportWebVitals();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Link, Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { AuthProvider, useAuth } from "@/context/auth/AuthContext";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
/**
|
||||
* Root header with navigation and user authentication controls.
|
||||
@@ -69,12 +67,13 @@ function RootHeader() {
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<>
|
||||
<AuthProvider>
|
||||
<RootHeader />
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
<TanStackRouterDevtools />
|
||||
<ReactQueryDevtools buttonPosition="bottom-right" />
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { Campaign } from "@/lib/types";
|
||||
import { SessionRow } from "@/components/documents/session/SessionRow";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Loader } from "@/components/Loader";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/campaigns/$campaignId")({
|
||||
loader: async ({ params }) => {
|
||||
const record = await pb.collection("campaigns").getOne(params.campaignId);
|
||||
component: RouteComponent,
|
||||
pendingComponent: Loader,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const queryClient = useQueryClient();
|
||||
const params = Route.useParams();
|
||||
|
||||
const {
|
||||
data: { campaign, sessions },
|
||||
} = useSuspenseQuery({
|
||||
queryKey: ["campaign"],
|
||||
queryFn: async () => {
|
||||
const campaign = await pb
|
||||
.collection("campaigns")
|
||||
.getOne(params.campaignId);
|
||||
// Fetch all documents for this campaign
|
||||
const docs = await pb.collection("documents").getFullList({
|
||||
filter: `campaign = "${params.campaignId}"`,
|
||||
@@ -13,15 +30,24 @@ export const Route = createFileRoute("/_authenticated/campaigns/$campaignId")({
|
||||
// Filter to only those with data.session
|
||||
const sessions = docs.filter((doc: any) => doc.data && doc.data.session);
|
||||
return {
|
||||
campaign: { id: record.id, name: record.name } as Campaign,
|
||||
campaign,
|
||||
sessions,
|
||||
};
|
||||
},
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { campaign, sessions } = Route.useLoaderData();
|
||||
const createNewSession = useCallback(async () => {
|
||||
await pb.collection("documents").create({
|
||||
campaign: campaign.id,
|
||||
data: {
|
||||
session: {
|
||||
strongStart: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["campaign"] });
|
||||
}, [campaign]);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-8">
|
||||
<div className="mb-2">
|
||||
@@ -35,7 +61,17 @@ function RouteComponent() {
|
||||
<h2 className="text-2xl font-bold mb-4 text-slate-100">
|
||||
{campaign.name}
|
||||
</h2>
|
||||
<div className="flex justify-between">
|
||||
<h3 className="text-lg font-semibold mb-2 text-slate-200">Sessions</h3>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => createNewSession()}
|
||||
className="inline-flex items-center justify-center rounded bg-violet-600 hover:bg-violet-700 text-white px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||
>
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{sessions && sessions.length > 0 ? (
|
||||
<div>
|
||||
<ul className="space-y-2">
|
||||
@@ -51,7 +87,6 @@ function RouteComponent() {
|
||||
No sessions found for this campaign.
|
||||
</div>
|
||||
)}
|
||||
{/* More campaign details can go here */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ import { useRouter } from "@tanstack/react-router";
|
||||
export const Route = createFileRoute("/_authenticated/campaigns/")({
|
||||
loader: async () => {
|
||||
const records = await pb.collection("campaigns").getFullList();
|
||||
return { campaigns: records.map((rec: any) => ({ id: rec.id, name: rec.name })) as Campaign[] };
|
||||
return {
|
||||
campaigns: records.map((rec: any) => ({
|
||||
id: rec.id,
|
||||
name: rec.name,
|
||||
})) as Campaign[],
|
||||
};
|
||||
},
|
||||
component: RouteComponent,
|
||||
pendingComponent: Loader,
|
||||
@@ -23,11 +28,12 @@ function RouteComponent() {
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
if (!campaigns || campaigns.length === 0) return <div>No campaigns found.</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-8">
|
||||
<h2 className="text-xl font-bold mb-4 text-slate-100">Your Campaigns</h2>
|
||||
{!campaigns || campaigns.length === 0 ? (
|
||||
<div>No campaigns found.</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{campaigns.map((c) => (
|
||||
<li key={c.id}>
|
||||
@@ -41,6 +47,7 @@ function RouteComponent() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<CreateCampaignButton onCreated={handleCreated} />
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,9 @@ function RouteComponent() {
|
||||
{[
|
||||
RelationshipType.Scenes,
|
||||
RelationshipType.Secrets,
|
||||
RelationshipType.Locations,
|
||||
RelationshipType.Npcs,
|
||||
RelationshipType.Monsters,
|
||||
RelationshipType.Treasures,
|
||||
].map((relationshipType) => (
|
||||
<RelationshipList
|
||||
|
||||
Reference in New Issue
Block a user