Adds Monsters and Locations
This commit is contained in:
@@ -1,8 +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";
|
||||
import { NpcForm } from "./npc/NpcForm";
|
||||
|
||||
function assertUnreachable(_x: never): never {
|
||||
throw new Error("DocumentForm switch is not exhaustive");
|
||||
@@ -21,6 +23,10 @@ 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:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// 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,
|
||||
@@ -11,9 +13,11 @@ import {
|
||||
type Document,
|
||||
type Session,
|
||||
} from "@/lib/types";
|
||||
import { TreasureRow } from "./treasure/TreasureRow";
|
||||
import { SceneRow } from "./scene/SceneRow";
|
||||
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.
|
||||
@@ -26,6 +30,14 @@ 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} />;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -48,7 +48,7 @@ export const NpcForm = ({
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<h3>Create new npc</h3>
|
||||
<div className="flex gap-5 w-full">
|
||||
<div className="flex gap-5 w-full items-center">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -60,7 +60,7 @@ export const NpcForm = ({
|
||||
aria-label="Name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-5 w-full">
|
||||
<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"
|
||||
|
||||
@@ -43,16 +43,21 @@ export const TreasureForm = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex items-center gap-2 mt-4" onSubmit={handleSubmit}>
|
||||
<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="Add a new treasure..."
|
||||
value={newTreasure}
|
||||
onChange={(e) => setNewTreasure(e.target.value)}
|
||||
disabled={adding}
|
||||
aria-label="Add new treasure"
|
||||
/>
|
||||
<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"
|
||||
placeholder="Add a new treasure..."
|
||||
value={newTreasure}
|
||||
onChange={(e) => setNewTreasure(e.target.value)}
|
||||
disabled={adding}
|
||||
aria-label="Add new treasure"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -20,10 +20,12 @@ export type Campaign = RecordModel & {
|
||||
|
||||
export const RelationshipType = {
|
||||
DiscoveredIn: "discoveredIn",
|
||||
Locations: "locations",
|
||||
Monsters: "monsters",
|
||||
Npcs: "npcs",
|
||||
Scenes: "scenes",
|
||||
Secrets: "secrets",
|
||||
Treasures: "treasures",
|
||||
Npcs: "npcs",
|
||||
} as const;
|
||||
|
||||
export type RelationshipType =
|
||||
@@ -52,6 +54,35 @@ 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 &
|
||||
|
||||
@@ -51,7 +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