Compare commits

..

2 Commits

42 changed files with 573 additions and 450 deletions

View File

@@ -0,0 +1,46 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": [
"CREATE INDEX `idx_gxNj5R3hxv` ON `documents` (`type`)"
]
}, collection)
// add field
collection.fields.addAt(3, new Field({
"hidden": false,
"id": "select2363381545",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"location",
"monster",
"npc",
"scene",
"secret",
"session",
"treasure"
]
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": []
}, collection)
// remove field
collection.fields.removeById("select2363381545")
return app.save(collection)
})

View File

@@ -0,0 +1,25 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": [
"CREATE INDEX `idx_gxNj5R3hxv` ON `documents` (`type`)",
"CREATE INDEX `idx_KtpMErDe1C` ON `documents` (`campaign`)"
]
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": [
"CREATE INDEX `idx_gxNj5R3hxv` ON `documents` (`type`)"
]
}, collection)
return app.save(collection)
})

View File

@@ -0,0 +1,54 @@
const DocType = [
"location",
"monster",
"npc",
"scene",
"secret",
"session",
"treasure",
];
function parseJsonB(data) {
if (typeof data === "string") {
return JSON.parse(data);
} else if (data instanceof Array) {
return JSON.parse(String.fromCharCode.apply(String, data));
}
throw new Error("Unsupported data type for JSON parsing");
}
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
let documents = app.findAllRecords("documents");
console.log("Records to parse: ", documents.length);
documents: for (const doc of documents) {
if (!doc) continue;
let data = parseJsonB(doc.get("data"));
if (data[""]) {
data = data[""];
}
for (const t of DocType) {
if (data[t]) {
doc.set("type", t);
doc.set("data", data[t]);
app.save(doc);
continue documents;
}
}
}
},
(app) => {
// add down queries...
let documents = app.findAllRecords("documents");
for (const doc of documents) {
if (!doc) continue;
doc.set("data", { [doc.get("type")]: doc.get("data") });
app.save(doc);
}
},
);

View File

@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_617371094")
// update field
collection.fields.addAt(0, new Field({
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": true,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_617371094")
// update field
collection.fields.addAt(0, new Field({
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
}))
return app.save(collection)
})

View File

@@ -0,0 +1,34 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update field
collection.fields.addAt(2, new Field({
"hidden": false,
"id": "json2918445923",
"maxSize": 0,
"name": "data",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update field
collection.fields.addAt(2, new Field({
"hidden": false,
"id": "json2918445923",
"maxSize": 0,
"name": "data",
"presentable": true,
"required": false,
"system": false,
"type": "json"
}))
return app.save(collection)
})

View File

@@ -0,0 +1,40 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_617371094")
// update field
collection.fields.addAt(1, new Field({
"cascadeDelete": true,
"collectionId": "pbc_3332084752",
"hidden": false,
"id": "relation390457990",
"maxSelect": 1,
"minSelect": 0,
"name": "primary",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_617371094")
// update field
collection.fields.addAt(1, new Field({
"cascadeDelete": false,
"collectionId": "pbc_3332084752",
"hidden": false,
"id": "relation390457990",
"maxSelect": 1,
"minSelect": 0,
"name": "primary",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
})

View File

@@ -63,7 +63,7 @@ export function AutoSaveTextarea({
<textarea <textarea
value={value} value={value}
onChange={handleChange} onChange={handleChange}
className={`w-full min-h-[6em] field-sizing-content 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}`} className={`w-full min-h-[10em] field-sizing-content 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} {...props}
/> />
) : ( ) : (

View File

@@ -1,4 +1,4 @@
import type { Document, DocumentId } from "@/lib/types"; import type { AnyDocument, DocumentId } from "@/lib/types";
import { import {
Dialog, Dialog,
DialogPanel, DialogPanel,
@@ -9,7 +9,7 @@ import {
import { Fragment, useCallback, useState } from "react"; import { Fragment, useCallback, useState } from "react";
import * as Icons from "@/components/Icons.tsx"; import * as Icons from "@/components/Icons.tsx";
type Props<T extends Document> = { type Props<T extends AnyDocument> = {
title: React.ReactNode; title: React.ReactNode;
error?: React.ReactNode; error?: React.ReactNode;
items: T[]; items: T[];
@@ -26,7 +26,7 @@ type Props<T extends Document> = {
* @param renderRow - Function to render each row's content * @param renderRow - Function to render each row's content
* @param newItemForm - Function that renders a form for creating a new item; receives an onSubmit callback * @param newItemForm - Function that renders a form for creating a new item; receives an onSubmit callback
*/ */
export function DocumentList<T extends Document>({ export function DocumentList<T extends AnyDocument>({
title, title,
error, error,
items, items,
@@ -122,9 +122,6 @@ export function DocumentList<T extends Document>({
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel className="bg-slate-900 rounded-lg shadow-xl max-w-md w-full p-6 border border-slate-700 relative"> <DialogPanel className="bg-slate-900 rounded-lg shadow-xl max-w-md w-full p-6 border border-slate-700 relative">
<DialogTitle className="text-lg font-semibold text-slate-100 mb-4">
Add New
</DialogTitle>
{newItemForm(handleFormSubmit)} {newItemForm(handleFormSubmit)}
<button <button
type="button" type="button"

View File

@@ -3,7 +3,7 @@ import { pb } from "@/lib/pocketbase";
import { displayName } from "@/lib/relationships"; import { displayName } from "@/lib/relationships";
import type { import type {
AnyDocument, AnyDocument,
Document, DocumentId,
Relationship, Relationship,
RelationshipType, RelationshipType,
} from "@/lib/types"; } from "@/lib/types";
@@ -26,14 +26,15 @@ export function RelationshipList({
root, root,
relationshipType, relationshipType,
}: RelationshipListProps) { }: RelationshipListProps) {
const [items, setItems] = useState<Document[]>([]); const [items, setItems] = useState<AnyDocument[]>([]);
const [relationshipId, setRelationshipId] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
async function fetchItems() { async function fetchItems() {
const { items } = await queryClient.fetchQuery({ const { relationship } = await queryClient.fetchQuery({
queryKey: ["relationship", relationshipType, root.id], queryKey: ["relationship", relationshipType, root.id],
staleTime: 5 * 60 * 1000, // 5 mintues staleTime: 5 * 60 * 1000, // 5 mintues
queryFn: async () => { queryFn: async () => {
@@ -49,36 +50,35 @@ export function RelationshipList({
setLoading(false); setLoading(false);
return { items: relationship.expand?.secondary ?? [] }; return { relationship };
}, },
}); });
setItems(items); setRelationshipId(relationship.id);
setItems(relationship.expand?.secondary ?? []);
} }
fetchItems(); fetchItems();
}); }, [root, relationshipType]);
// Handles creation of a new document and adds it to the relationship // Handles creation of a new document and adds it to the relationship
const handleCreate = async (doc: Document) => { const handleCreate = async (doc: AnyDocument) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// Check for existing relationship // Check for existing relationship
const existing = await pb.collection("relationships").getFullList({ if (relationshipId) {
filter: `primary = "${root.id}" && type = "${relationshipType}"`, console.debug("Adding to existing relationship", relationshipId);
}); await pb.collection("relationships").update(relationshipId, {
if (existing.length > 0) {
console.debug("Adding to existing relationship");
await pb.collection("relationships").update(existing[0].id, {
"+secondary": doc.id, "+secondary": doc.id,
}); });
} else { } else {
console.debug("Creating new relationship"); console.debug("Creating new relationship");
await pb.collection("relationships").create({ const relationship = await pb.collection("relationships").create({
primary: root.id, primary: root.id,
secondary: [doc.id], secondary: [doc.id],
type: relationshipType, type: relationshipType,
}); });
setRelationshipId(relationship.id);
} }
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["relationship", relationshipType, root.id], queryKey: ["relationship", relationshipType, root.id],
@@ -91,6 +91,32 @@ export function RelationshipList({
} }
}; };
const handleRemove = async (documentId: DocumentId) => {
setLoading(true);
setError(null);
try {
if (relationshipId) {
console.debug("Removing from existing relationship", relationshipId);
await pb.collection("relationships").update(relationshipId, {
secondary: items
.map((item) => item.id)
.filter((id) => id !== documentId),
});
}
queryClient.invalidateQueries({
queryKey: ["relationship", relationshipType, root.id],
});
setItems((prev) => prev.filter((item) => item.id != documentId));
} catch (e: any) {
setError(
e?.message || `Failed to remove document from ${relationshipType}.`,
);
} finally {
setLoading(false);
}
};
if (loading) { if (loading) {
<Loader />; <Loader />;
} }
@@ -101,12 +127,12 @@ export function RelationshipList({
items={items} items={items}
error={error} error={error}
renderRow={(document) => <DocumentRow document={document} />} renderRow={(document) => <DocumentRow document={document} />}
removeItem={() => {}} removeItem={handleRemove}
newItemForm={(onSubmit) => ( newItemForm={(onSubmit) => (
<NewRelatedDocumentForm <NewRelatedDocumentForm
campaignId={root.campaign} campaignId={root.campaign}
relationshipType={relationshipType} relationshipType={relationshipType}
onCreate={async (doc: Document) => { onCreate={async (doc: AnyDocument) => {
await handleCreate(doc); await handleCreate(doc);
onSubmit(); onSubmit();
}} }}

View File

@@ -12,15 +12,15 @@ export type Props = {
*/ */
export const BasicRow = ({ doc, title, description }: Props) => { export const BasicRow = ({ doc, title, description }: Props) => {
return ( return (
<li> <div>
<Link <Link
to="/document/$documentId" to="/document/$documentId"
params={{ documentId: doc.id }} params={{ documentId: doc.id }}
className="text-lg" className="text-lg !no-underline text-slate-100 hover:underline hover:text-violet-400"
> >
<h4>{title}</h4> <h4>{title}</h4>
</Link> </Link>
{description && <p>{description}</p>} {description && <p>{description}</p>}
</li> </div>
); );
}; };

View File

@@ -1,13 +1,4 @@
import { import { type AnyDocument } from "@/lib/types";
isLocation,
isMonster,
isNpc,
isScene,
isSecret,
isSession,
isTreasure,
type AnyDocument,
} from "@/lib/types";
import { LocationEditForm } from "./location/LocationEditForm"; import { LocationEditForm } from "./location/LocationEditForm";
import { MonsterEditForm } from "./monsters/MonsterEditForm"; import { MonsterEditForm } from "./monsters/MonsterEditForm";
import { NpcEditForm } from "./npc/NpcEditForm"; import { NpcEditForm } from "./npc/NpcEditForm";
@@ -16,41 +7,24 @@ import { SecretEditForm } from "./secret/SecretEditForm";
import { SessionEditForm } from "./session/SessionEditForm"; import { SessionEditForm } from "./session/SessionEditForm";
import { TreasureEditForm } from "./treasure/TreasureEditForm"; import { TreasureEditForm } from "./treasure/TreasureEditForm";
function assertUnreachable(_x: never): never {
throw new Error("DocumentForm switch is not exhaustive");
}
/** /**
* Renders a form for any document type depending on the relationship. * Renders a form for any document type depending on the relationship.
*/ */
export const DocumentEditForm = ({ document }: { document: AnyDocument }) => { export const DocumentEditForm = ({ document }: { document: AnyDocument }) => {
if (isLocation(document)) { switch (document.type) {
return <LocationEditForm location={document} />; case "location":
return <LocationEditForm location={document} />;
case "monster":
return <MonsterEditForm monster={document} />;
case "npc":
return <NpcEditForm npc={document} />;
case "scene":
return <SceneEditForm scene={document} />;
case "secret":
return <SecretEditForm secret={document} />;
case "session":
return <SessionEditForm session={document} />;
case "treasure":
return <TreasureEditForm treasure={document} />;
} }
if (isMonster(document)) {
return <MonsterEditForm monster={document} />;
}
if (isNpc(document)) {
return <NpcEditForm npc={document} />;
}
if (isScene(document)) {
return <SceneEditForm scene={document} />;
}
if (isSecret(document)) {
return <SecretEditForm secret={document} />;
}
if (isSession(document)) {
return <SessionEditForm session={document} />;
}
if (isTreasure(document)) {
return <TreasureEditForm treasure={document} />;
}
return assertUnreachable(document);
}; };

View File

@@ -1,64 +1,33 @@
// DocumentRow.tsx // DocumentRow.tsx
// Generic row component for displaying any document type. // Generic row component for displaying any document type.
import { import { type AnyDocument } from "@/lib/types";
isLocation,
isMonster,
isNpc,
isScene,
isSecret,
isSession,
isTreasure,
type Document,
} from "@/lib/types";
import { LocationPrintRow } from "./location/LocationPrintRow"; import { LocationPrintRow } from "./location/LocationPrintRow";
import { MonsterPrintRow } from "./monsters/MonsterPrintRow"; import { MonsterPrintRow } from "./monsters/MonsterPrintRow";
import { TreasurePrintRow } from "./treasure/TreasurePrintRow";
import { SecretPrintRow } from "./secret/SecretPrintRow";
import { NpcPrintRow } from "./npc/NpcPrintRow"; import { NpcPrintRow } from "./npc/NpcPrintRow";
import { ScenePrintRow } from "./scene/ScenePrintRow"; import { ScenePrintRow } from "./scene/ScenePrintRow";
import { SecretPrintRow } from "./secret/SecretPrintRow";
import { SessionPrintRow } from "./session/SessionPrintRow"; import { SessionPrintRow } from "./session/SessionPrintRow";
import { TreasurePrintRow } from "./treasure/TreasurePrintRow";
/** /**
* Renders a row for any document type. Prioritizes Session, then Secret, then falls back to ID and creation time. * Renders a row for any document type. Prioritizes Session, then Secret, then falls back to ID and creation time.
* If rendering a SecretRow, uses the provided session prop if available. * If rendering a SecretRow, uses the provided session prop if available.
*/ */
export const DocumentPrintRow = ({ document }: { document: Document }) => { export const DocumentPrintRow = ({ document }: { document: AnyDocument }) => {
if (isLocation(document)) { switch (document.type) {
return <LocationPrintRow location={document} />; case "location":
return <LocationPrintRow location={document} />;
case "monster":
return <MonsterPrintRow monster={document} />;
case "npc":
return <NpcPrintRow npc={document} />;
case "scene":
return <ScenePrintRow scene={document} />;
case "secret":
return <SecretPrintRow secret={document} />;
case "session":
return <SessionPrintRow session={document} />;
case "treasure":
return <TreasurePrintRow treasure={document} />;
} }
if (isMonster(document)) {
return <MonsterPrintRow monster={document} />;
}
if (isNpc(document)) {
return <NpcPrintRow npc={document} />;
}
if (isSession(document)) {
return <SessionPrintRow session={document} />;
}
if (isSecret(document)) {
return <SecretPrintRow secret={document} />;
}
if (isScene(document)) {
return <ScenePrintRow scene={document} />;
}
if (isTreasure(document)) {
return <TreasurePrintRow treasure={document} />;
}
// Fallback: show ID and creation time
return (
<div>
<div className="font-semibold text-lg text-slate-300">
Unrecognized Document
</div>
<div className="text-slate-400 text-sm">ID: {document.id}</div>
<div className="text-slate-400 text-sm">Created: {document.created}</div>
</div>
);
}; };

View File

@@ -1,17 +1,7 @@
// DocumentRow.tsx // DocumentRow.tsx
// Generic row component for displaying any document type. // Generic row component for displaying any document type.
import { SecretToggleRow } from "@/components/documents/secret/SecretToggleRow"; import { SecretToggleRow } from "@/components/documents/secret/SecretToggleRow";
import { import { type AnyDocument, type Session } from "@/lib/types";
isLocation,
isMonster,
isNpc,
isScene,
isSecret,
isSession,
isTreasure,
type Document,
type Session,
} from "@/lib/types";
import { BasicRow } from "./BasicRow"; import { BasicRow } from "./BasicRow";
import { TreasureToggleRow } from "./treasure/TreasureToggleRow"; import { TreasureToggleRow } from "./treasure/TreasureToggleRow";
@@ -23,63 +13,47 @@ export const DocumentRow = ({
document, document,
session, session,
}: { }: {
document: Document; document: AnyDocument;
session?: Session; session?: Session;
}) => { }) => {
if (isLocation(document)) { switch (document.type) {
return ( case "location":
<BasicRow return (
doc={document} <BasicRow
title={document.data.location.name} doc={document}
description={document.data.location.description} title={document.data.name}
/> description={document.data.description}
); />
} );
if (isMonster(document)) { case "monster":
return <BasicRow doc={document} title={document.data.monster.name} />; return <BasicRow doc={document} title={document.data.name} />;
}
if (isNpc(document)) { case "npc":
return ( return (
<BasicRow <BasicRow
doc={document} doc={document}
title={document.data.npc.name} title={document.data.name}
description={document.data.npc.description} description={document.data.description}
/> />
); );
}
if (isSession(document)) { case "session":
return ( return (
<BasicRow <BasicRow
doc={document} doc={document}
title={document.created} title={document.created}
description={document.data.session.strongStart} description={document.data.strongStart}
/> />
); );
}
if (isSecret(document)) { case "secret":
return <SecretToggleRow secret={document} session={session} />; return <SecretToggleRow secret={document} session={session} />;
}
if (isScene(document)) { case "scene":
return <BasicRow doc={document} title={document.data.scene.text} />; return <BasicRow doc={document} title={document.data.text} />;
}
if (isTreasure(document)) { case "treasure":
return <TreasureToggleRow treasure={document} session={session} />; return <TreasureToggleRow treasure={document} session={session} />;
} }
// Fallback: show ID and creation time
return (
<div>
<div className="font-semibold text-lg text-slate-300">
Unrecognized Document
</div>
<div className="text-slate-400 text-sm">ID: {document.id}</div>
<div className="text-slate-400 text-sm">Created: {document.created}</div>
</div>
);
}; };

View File

@@ -1,4 +1,8 @@
import { RelationshipType, type CampaignId, type Document } from "@/lib/types"; import {
RelationshipType,
type CampaignId,
type AnyDocument,
} from "@/lib/types";
import { NewLocationForm } from "./location/NewLocationForm"; import { NewLocationForm } from "./location/NewLocationForm";
import { NewMonsterForm } from "./monsters/NewMonsterForm"; import { NewMonsterForm } from "./monsters/NewMonsterForm";
import { NewNpcForm } from "./npc/NewNpcForm"; import { NewNpcForm } from "./npc/NewNpcForm";
@@ -6,10 +10,6 @@ import { NewSceneForm } from "./scene/NewSceneForm";
import { NewSecretForm } from "./secret/NewSecretForm"; import { NewSecretForm } from "./secret/NewSecretForm";
import { NewTreasureForm } from "./treasure/NewTreasureForm"; import { NewTreasureForm } from "./treasure/NewTreasureForm";
function assertUnreachable(_x: never): never {
throw new Error("DocumentForm switch is not exhaustive");
}
/** /**
* Renders a form for any document type depending on the relationship. * Renders a form for any document type depending on the relationship.
*/ */
@@ -20,7 +20,7 @@ export const NewRelatedDocumentForm = ({
}: { }: {
campaignId: CampaignId; campaignId: CampaignId;
relationshipType: RelationshipType; relationshipType: RelationshipType;
onCreate: (document: Document) => Promise<void>; onCreate: (doc: AnyDocument) => Promise<void>;
}) => { }) => {
switch (relationshipType) { switch (relationshipType) {
case RelationshipType.Locations: case RelationshipType.Locations:
@@ -38,6 +38,4 @@ export const NewRelatedDocumentForm = ({
case RelationshipType.DiscoveredIn: case RelationshipType.DiscoveredIn:
return "Form not supported here"; return "Form not supported here";
} }
return assertUnreachable(relationshipType);
}; };

View File

@@ -10,10 +10,7 @@ export const LocationEditForm = ({ location }: { location: Location }) => {
await pb.collection("documents").update(location.id, { await pb.collection("documents").update(location.id, {
data: { data: {
...location.data, ...location.data,
location: { name,
...location.data.location,
name,
},
}, },
}); });
} }
@@ -22,10 +19,7 @@ export const LocationEditForm = ({ location }: { location: Location }) => {
await pb.collection("documents").update(location.id, { await pb.collection("documents").update(location.id, {
data: { data: {
...location.data, ...location.data,
location: { description,
...location.data.location,
description,
},
}, },
}); });
} }
@@ -34,11 +28,11 @@ export const LocationEditForm = ({ location }: { location: Location }) => {
<div className=""> <div className="">
<AutoSaveTextarea <AutoSaveTextarea
multiline={false} multiline={false}
value={location.data.location.name} value={location.data.name}
onSave={saveLocationName} onSave={saveLocationName}
/> />
<AutoSaveTextarea <AutoSaveTextarea
value={location.data.location.description} value={location.data.description}
onSave={saveLocationDescription} onSave={saveLocationDescription}
/> />
</div> </div>

View File

@@ -5,9 +5,9 @@ import type { Location } from "@/lib/types";
*/ */
export const LocationPrintRow = ({ location }: { location: Location }) => { export const LocationPrintRow = ({ location }: { location: Location }) => {
return ( return (
<li> <div>
<h4>{location.data.location.name}</h4> <h4>{location.data.name}</h4>
<p>{location.data.location.description}</p> <p>{location.data.description}</p>
</li> </div>
); );
}; };

View File

@@ -25,11 +25,10 @@ export const NewLocationForm = ({
try { try {
const locationDoc: Location = await pb.collection("documents").create({ const locationDoc: Location = await pb.collection("documents").create({
campaign, campaign,
type: "location",
data: { data: {
location: { name,
name, description,
description,
},
}, },
}); });
setName(""); setName("");

View File

@@ -10,10 +10,7 @@ export const MonsterEditForm = ({ monster }: { monster: Monster }) => {
await pb.collection("documents").update(monster.id, { await pb.collection("documents").update(monster.id, {
data: { data: {
...monster.data, ...monster.data,
monster: { name,
...monster.data.monster,
name,
},
}, },
}); });
} }
@@ -22,7 +19,7 @@ export const MonsterEditForm = ({ monster }: { monster: Monster }) => {
<div className=""> <div className="">
<AutoSaveTextarea <AutoSaveTextarea
multiline={false} multiline={false}
value={monster.data.monster.name} value={monster.data.name}
onSave={saveMonsterName} onSave={saveMonsterName}
/> />
</div> </div>

View File

@@ -4,5 +4,5 @@ import type { Monster } from "@/lib/types";
* Renders an editable monster row * Renders an editable monster row
*/ */
export const MonsterPrintRow = ({ monster }: { monster: Monster }) => { export const MonsterPrintRow = ({ monster }: { monster: Monster }) => {
return <li>{monster.data.monster.name}</li>; return <div>{monster.data.name}</div>;
}; };

View File

@@ -25,11 +25,10 @@ export const NewMonsterForm = ({
try { try {
const monsterDoc: Monster = await pb.collection("documents").create({ const monsterDoc: Monster = await pb.collection("documents").create({
campaign, campaign,
type: "monster",
data: { data: {
monster: { name,
name, description,
description,
},
}, },
}); });
setName(""); setName("");

View File

@@ -25,11 +25,10 @@ export const NewNpcForm = ({
try { try {
const npcDoc: Npc = await pb.collection("documents").create({ const npcDoc: Npc = await pb.collection("documents").create({
campaign, campaign,
type: "npc",
data: { data: {
npc: { name,
name, description,
description,
},
}, },
}); });
setName(""); setName("");

View File

@@ -10,10 +10,7 @@ export const NpcEditForm = ({ npc }: { npc: Npc }) => {
await pb.collection("documents").update(npc.id, { await pb.collection("documents").update(npc.id, {
data: { data: {
...npc.data, ...npc.data,
npc: { name,
...npc.data.npc,
name,
},
}, },
}); });
} }
@@ -22,10 +19,7 @@ export const NpcEditForm = ({ npc }: { npc: Npc }) => {
await pb.collection("documents").update(npc.id, { await pb.collection("documents").update(npc.id, {
data: { data: {
...npc.data, ...npc.data,
npc: { description,
...npc.data.npc,
description,
},
}, },
}); });
} }
@@ -34,11 +28,11 @@ export const NpcEditForm = ({ npc }: { npc: Npc }) => {
<div className=""> <div className="">
<AutoSaveTextarea <AutoSaveTextarea
multiline={false} multiline={false}
value={npc.data.npc.name} value={npc.data.name}
onSave={saveNpcName} onSave={saveNpcName}
/> />
<AutoSaveTextarea <AutoSaveTextarea
value={npc.data.npc.description} value={npc.data.description}
onSave={saveNpcDescription} onSave={saveNpcDescription}
/> />
</div> </div>

View File

@@ -5,9 +5,9 @@ import type { Npc } from "@/lib/types";
*/ */
export const NpcPrintRow = ({ npc }: { npc: Npc }) => { export const NpcPrintRow = ({ npc }: { npc: Npc }) => {
return ( return (
<li className=""> <div className="">
<h4>{npc.data.npc.name}</h4> <h4>{npc.data.name}</h4>
<p>{npc.data.npc.description}</p> <p>{npc.data.description}</p>
</li> </div>
); );
}; };

View File

@@ -3,6 +3,8 @@
import { useState } from "react"; import { useState } from "react";
import type { CampaignId, Scene } from "@/lib/types"; import type { CampaignId, Scene } from "@/lib/types";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { BaseForm } from "@/components/form/BaseForm";
import { MultiLineInput } from "@/components/form/MultiLineInput";
/** /**
* Renders a form to add a new scene. Calls onCreate with the new scene document. * Renders a form to add a new scene. Calls onCreate with the new scene document.
@@ -26,10 +28,9 @@ export const NewSceneForm = ({
try { try {
const sceneDoc: Scene = await pb.collection("documents").create({ const sceneDoc: Scene = await pb.collection("documents").create({
campaign, campaign,
type: "scene",
data: { data: {
scene: { text,
text,
},
}, },
}); });
setText(""); setText("");
@@ -42,28 +43,22 @@ export const NewSceneForm = ({
} }
return ( return (
<form <BaseForm
className="flex flex-col items-left gap-2 mt-4" title="Create new scene"
onSubmit={handleSubmit} onSubmit={handleSubmit}
> error={error}
<h3>Create new scene</h3> buttonText={adding ? "Adding..." : "Create"}
<input content={
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" <MultiLineInput
placeholder="Add a new scene..." value={text}
value={text} onChange={(v) => setText(v)}
onChange={(e) => setText(e.target.value)} disabled={adding}
disabled={adding} placeholder="Scene description..."
aria-label="Add new scene" aria-label="Add new scene"
/> />
{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 || !text.trim()}
>
{adding ? "Adding..." : "Create"}
</button>
</form>
); );
}; };

View File

@@ -13,9 +13,7 @@ export const SceneEditForm = ({ scene }: { scene: Scene }) => {
await pb.collection("documents").update(scene.id, { await pb.collection("documents").update(scene.id, {
data: { data: {
...scene.data, ...scene.data,
scene: { text,
text,
},
}, },
}); });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -25,7 +23,7 @@ export const SceneEditForm = ({ scene }: { scene: Scene }) => {
return ( return (
<div className=""> <div className="">
<AutoSaveTextarea value={scene.data.scene.text} onSave={saveScene} /> <AutoSaveTextarea value={scene.data.text} onSave={saveScene} />
</div> </div>
); );
}; };

View File

@@ -4,5 +4,5 @@ import type { Scene } from "@/lib/types";
* Renders an editable scene row * Renders an editable scene row
*/ */
export const ScenePrintRow = ({ scene }: { scene: Scene }) => { export const ScenePrintRow = ({ scene }: { scene: Scene }) => {
return <li className="">{scene.data.scene.text}</li>; return <div className="">{scene.data.text}</div>;
}; };

View File

@@ -26,11 +26,10 @@ export const NewSecretForm = ({
try { try {
const secretDoc: Secret = await pb.collection("documents").create({ const secretDoc: Secret = await pb.collection("documents").create({
campaign, campaign,
type: "secret",
data: { data: {
secret: { text: newSecret,
text: newSecret, discovered: false,
discovered: false,
},
}, },
}); });
setNewSecret(""); setNewSecret("");

View File

@@ -28,10 +28,7 @@ export const SecretEditForm = ({
await pb.collection("documents").update(secret.id, { await pb.collection("documents").update(secret.id, {
data: { data: {
...secret.data, ...secret.data,
secret: { discovered: newChecked,
...(secret.data as any).secret,
discovered: newChecked,
},
}, },
}); });
if (session || !newChecked) { if (session || !newChecked) {
@@ -62,10 +59,7 @@ export const SecretEditForm = ({
await pb.collection("documents").update(secret.id, { await pb.collection("documents").update(secret.id, {
data: { data: {
...secret.data, ...secret.data,
secret: { text,
...secret.data.secret,
text,
},
}, },
}); });
} }
@@ -82,7 +76,7 @@ export const SecretEditForm = ({
/> />
<AutoSaveTextarea <AutoSaveTextarea
multiline={false} multiline={false}
value={secret.data.secret.text} value={secret.data.text}
onSave={saveText} onSave={saveText}
/> />
</div> </div>

View File

@@ -68,11 +68,7 @@ export const SecretToggleRow = ({
aria-label="Discovered" aria-label="Discovered"
disabled={loading} disabled={loading}
/> />
<span> <span>{secret.data.text}</span>
{(secret.data as any)?.secret?.text || (
<span className="italic text-slate-400">(No secret text)</span>
)}
</span>
</div> </div>
); );
}; };

View File

@@ -7,10 +7,7 @@ export const SessionEditForm = ({ session }: { session: Session }) => {
await pb.collection("documents").update(session.id, { await pb.collection("documents").update(session.id, {
data: { data: {
...session.data, ...session.data,
session: { strongStart,
...session.data.session,
strongStart,
},
}, },
}); });
} }
@@ -19,7 +16,7 @@ export const SessionEditForm = ({ session }: { session: Session }) => {
<form> <form>
<h3 className="text-lg font-bold mb-4 text-slate-100">Strong Start</h3> <h3 className="text-lg font-bold mb-4 text-slate-100">Strong Start</h3>
<AutoSaveTextarea <AutoSaveTextarea
value={session.data.session.strongStart} value={session.data.strongStart}
onSave={saveStrongStart} onSave={saveStrongStart}
placeholder="Enter a strong start for this session..." placeholder="Enter a strong start for this session..."
aria-label="Strong Start" aria-label="Strong Start"

View File

@@ -4,7 +4,7 @@ export const SessionPrintRow = ({ session }: { session: Session }) => {
return ( return (
<div> <div>
<h3 className="text-lg font-bold text-slate-600">StrongStart</h3> <h3 className="text-lg font-bold text-slate-600">StrongStart</h3>
<div className="">{session.data.session.strongStart}</div> <div className="">{session.data.strongStart}</div>
</div> </div>
); );
}; };

View File

@@ -12,7 +12,7 @@ export const SessionRow = ({ session }: { session: Session }) => {
> >
<FormattedDate date={session.created} /> <FormattedDate date={session.created} />
</Link> </Link>
<div className="">{session.data.session.strongStart}</div> <div className="">{session.data.strongStart}</div>
</div> </div>
); );
}; };

View File

@@ -26,11 +26,10 @@ export const NewTreasureForm = ({
try { try {
const treasureDoc: Treasure = await pb.collection("documents").create({ const treasureDoc: Treasure = await pb.collection("documents").create({
campaign, campaign,
type: "treasure",
data: { data: {
treasure: { text: newTreasure,
text: newTreasure, discovered: false,
discovered: false,
},
}, },
}); });
setNewTreasure(""); setNewTreasure("");

View File

@@ -62,10 +62,7 @@ export const TreasureEditForm = ({
await pb.collection("documents").update(treasure.id, { await pb.collection("documents").update(treasure.id, {
data: { data: {
...treasure.data, ...treasure.data,
treasure: { text,
...treasure.data.treasure,
text,
},
}, },
}); });
} }
@@ -82,7 +79,7 @@ export const TreasureEditForm = ({
/> />
<AutoSaveTextarea <AutoSaveTextarea
multiline={false} multiline={false}
value={treasure.data.treasure.text} value={treasure.data.text}
onSave={saveText} onSave={saveText}
/> />
</div> </div>

View File

@@ -8,7 +8,7 @@ import type { Treasure } from "@/lib/types";
*/ */
export const TreasurePrintRow = ({ treasure }: { treasure: Treasure }) => { export const TreasurePrintRow = ({ treasure }: { treasure: Treasure }) => {
return ( return (
<li className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="checkbox" type="checkbox"
className="flex-none accent-emerald-500 w-5 h-5" className="flex-none accent-emerald-500 w-5 h-5"
@@ -19,6 +19,6 @@ export const TreasurePrintRow = ({ treasure }: { treasure: Treasure }) => {
<span className="italic text-slate-400">(No treasure text)</span> <span className="italic text-slate-400">(No treasure text)</span>
)} )}
</span> </span>
</li> </div>
); );
}; };

View File

@@ -68,11 +68,7 @@ export const TreasureToggleRow = ({
aria-label="Discovered" aria-label="Discovered"
disabled={loading} disabled={loading}
/> />
<span> <span>{treasure.data.text}</span>
{(treasure.data as any)?.treasure?.text || (
<span className="italic text-slate-400">(No treasure text)</span>
)}
</span>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,32 @@
export type Props = {
title?: string;
content: React.ReactNode;
buttonText?: string;
isLoading?: boolean;
error?: string | null;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
};
export const BaseForm = ({
title,
content,
buttonText,
isLoading,
error,
onSubmit,
}: Props) => {
return (
<form className="flex flex-col items-left gap-2" onSubmit={onSubmit}>
<h3 className="text-lg font-semibold text-slate-100 mb-4">{title}</h3>
<div>{content}</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={isLoading}
>
{buttonText ? buttonText : "Submit"}
</button>
</form>
);
};

View File

@@ -0,0 +1,22 @@
export type Props = {
value: string;
onChange: (value: string) => void;
className?: string;
} & Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"value" | "onChange" | "className"
>;
export const MultiLineInput = ({
value,
onChange,
className = "",
...props
}: Props) => (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
className={`w-full min-h-[10em] field-sizing-content 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 ${className}`}
{...props}
/>
);

View File

@@ -41,21 +41,6 @@ export type Relationship = RecordModel & {
* Documents * Documents
******************************************/ ******************************************/
export type DocumentData<K extends string, V> = {
data: Record<K, V>;
};
export type Document = RecordModel & {
id: DocumentId;
campaign: CampaignId;
data: {
[K in DocumentType]?: unknown;
};
// These two are not in Pocketbase's types, but they seem to always be present
created: ISO8601Date;
updated: ISO8601Date;
};
export type DocumentType = export type DocumentType =
| "location" | "location"
| "monster" | "monster"
@@ -65,6 +50,21 @@ export type DocumentType =
| "session" | "session"
| "treasure"; | "treasure";
export type DocumentData<Type extends DocumentType, Data> = {
type: Type;
data: Data;
};
export type Document<Type extends DocumentType, Data> = RecordModel & {
id: DocumentId;
campaign: CampaignId;
type: Type;
data: Data;
// These two are not in Pocketbase's types, but they seem to always be present
created: ISO8601Date;
updated: ISO8601Date;
};
export type AnyDocument = export type AnyDocument =
| Location | Location
| Monster | Monster
@@ -75,122 +75,71 @@ export type AnyDocument =
| Treasure; | Treasure;
export function getDocumentType(doc: AnyDocument): DocumentType { export function getDocumentType(doc: AnyDocument): DocumentType {
if (isLocation(doc)) { return doc.type;
return "location";
} else if (isMonster(doc)) {
return "monster";
} else if (isNpc(doc)) {
return "npc";
} else if (isScene(doc)) {
return "scene";
} else if (isSecret(doc)) {
return "secret";
} else if (isSession(doc)) {
return "session";
} else if (isTreasure(doc)) {
return "treasure";
}
throw new Error(`Document type not found: ${JSON.stringify(doc)}`);
} }
/** Locations **/ /** Locations **/
export type Location = Document<
export type Location = Document & "location",
DocumentData< {
"location", name: string;
{ description: string;
name: string; }
description: string; >;
}
>;
export function isLocation(doc: Document): doc is Location {
return Object.hasOwn(doc.data, "location");
}
/** Monsters **/ /** Monsters **/
export type Monster = Document & export type Monster = Document<
DocumentData< "monster",
"monster", {
{ name: string;
name: string; }
} >;
>;
export function isMonster(doc: Document): doc is Monster {
return Object.hasOwn(doc.data, "monster");
}
/** NPCs **/ /** NPCs **/
export type Npc = Document & export type Npc = Document<
DocumentData< "npc",
"npc", {
{ name: string;
name: string; description: string;
description: string; }
} >;
>;
export function isNpc(doc: Document): doc is Npc {
return Object.hasOwn(doc.data, "npc");
}
/** Session **/ /** Session **/
export type Session = Document & export type Session = Document<
DocumentData< "session",
"session", {
{ strongStart: string;
strongStart: string; }
} >;
>;
export function isSession(doc: Document): doc is Session {
return Object.hasOwn(doc.data, "session");
}
/** Scene **/ /** Scene **/
export type Scene = Document & export type Scene = Document<
DocumentData< "scene",
"scene", {
{ text: string;
text: string; }
} >;
>;
export function isScene(doc: Document): doc is Scene {
return Object.hasOwn(doc.data, "scene");
}
/** Secret **/ /** Secret **/
export type Secret = Document & export type Secret = Document<
DocumentData< "secret",
"secret", {
{ text: string;
text: string; discovered: boolean;
discovered: boolean; }
} >;
>;
export function isSecret(doc: Document): doc is Secret {
return Object.hasOwn(doc.data, "secret");
}
/** Treasure **/ /** Treasure **/
export type Treasure = Document & export type Treasure = Document<
DocumentData< "treasure",
"treasure", {
{ text: string;
text: string; discovered: boolean;
discovered: boolean; }
} >;
>;
export function isTreasure(doc: Document): doc is Treasure {
return Object.hasOwn(doc.data, "treasure");
}

View File

@@ -27,12 +27,10 @@ function RouteComponent() {
.collection("campaigns") .collection("campaigns")
.getOne(params.campaignId); .getOne(params.campaignId);
// Fetch all documents for this campaign // Fetch all documents for this campaign
const docs = await pb.collection("documents").getFullList({ const sessions = await pb.collection("documents").getFullList({
filter: `campaign = "${params.campaignId}"`, filter: `campaign = "${params.campaignId}" && type = 'session'`,
sort: "-created", sort: "-created",
}); });
// Filter to only those with data.session
const sessions = docs.filter((doc: any) => doc.data && doc.data.session);
return { return {
campaign, campaign,
sessions, sessions,
@@ -44,12 +42,9 @@ function RouteComponent() {
// Check for a previous session // Check for a previous session
const prevSession = await pb const prevSession = await pb
.collection("documents") .collection("documents")
.getFirstListItem( .getFirstListItem(`campaign = "${campaign.id}" && type = 'session'`, {
`campaign = "${campaign.id}" && json_extract(data, '$.session') IS NOT NULL`, sort: "-created",
{ });
sort: "-created",
},
);
console.log("Previous session: ", { console.log("Previous session: ", {
id: prevSession.id, id: prevSession.id,
@@ -58,16 +53,13 @@ function RouteComponent() {
const newSession = await pb.collection("documents").create({ const newSession = await pb.collection("documents").create({
campaign: campaign.id, campaign: campaign.id,
type: "session",
data: { data: {
session: { strongStart: "",
strongStart: "",
},
}, },
}); });
queryClient.invalidateQueries({ queryKey: ["campaign"] }); // If any relations, then copy things over
// If any, then copy things over
if (prevSession) { if (prevSession) {
const prevRelations = await pb const prevRelations = await pb
.collection<Relationship>("relationships") .collection<Relationship>("relationships")
@@ -84,7 +76,7 @@ function RouteComponent() {
await pb.collection("relationships").create({ await pb.collection("relationships").create({
primary: newSession.id, primary: newSession.id,
type: relation.type, type: relation.type,
seciondary: relation.secondary, secondary: relation.secondary,
}); });
} }
} }

View File

@@ -38,14 +38,17 @@ function RouteComponent() {
<TabGroup> <TabGroup>
<TabList className="flex flex-row flex-wrap gap-1 mt-2"> <TabList className="flex flex-row flex-wrap gap-1 mt-2">
{relationshipList.map((relationshipType) => ( {relationshipList.map((relationshipType) => (
<Tab className="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 data-selected:bg-violet-900 data-selected:border-violet-700"> <Tab
key={relationshipType}
className="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 data-selected:bg-violet-900 data-selected:border-violet-700"
>
{displayName(relationshipType)} {displayName(relationshipType)}
</Tab> </Tab>
))} ))}
</TabList> </TabList>
<TabPanels> <TabPanels>
{relationshipList.map((relationshipType) => ( {relationshipList.map((relationshipType) => (
<TabPanel> <TabPanel key={relationshipType}>
<RelationshipList <RelationshipList
key={relationshipType} key={relationshipType}
root={document} root={document}

View File

@@ -1,11 +1,13 @@
@import "tailwindcss"; @import "tailwindcss";
@tailwind utilities;
html, body { html,
body {
height: 100%; height: 100%;
min-height: 100%; min-height: 100%;
background-color: #0f172a; /* slate-900 */ background-color: #0f172a; /* slate-900 */
color: #f1f5f9; /* slate-100 */ color: #f1f5f9; /* slate-100 */
font-family: 'Inter', system-ui, sans-serif; font-family: "Inter", system-ui, sans-serif;
line-height: 1.5; line-height: 1.5;
} }
@@ -15,24 +17,15 @@ body {
min-width: 320px; min-width: 320px;
} }
code, pre { code,
font-family: 'Fira Mono', 'Menlo', 'Monaco', 'Consolas', monospace; pre {
font-family: "Fira Mono", "Menlo", "Monaco", "Consolas", monospace;
background: #1e293b; /* slate-800 */ background: #1e293b; /* slate-800 */
color: #a5b4fc; /* violet-300 */ color: #a5b4fc; /* violet-300 */
border-radius: 0.25rem; border-radius: 0.25rem;
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
} }
a {
color: #a5b4fc; /* violet-300 */
text-decoration: underline;
transition: color 0.2s;
}
a:hover, a:focus {
color: #7c3aed; /* violet-600 */
}
/* Remove default outline, but keep focus-visible for accessibility */ /* Remove default outline, but keep focus-visible for accessibility */
:focus:not(:focus-visible) { :focus:not(:focus-visible) {
outline: none; outline: none;