Compare commits

...

15 Commits

49 changed files with 722 additions and 1203 deletions

100
package-lock.json generated
View File

@@ -23,7 +23,6 @@
"zod": "^4.0.5" "zod": "^4.0.5"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/ts-plugin": "^1.10.4",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/lodash": "^4.17.17", "@types/lodash": "^4.17.17",
@@ -71,52 +70,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/@astrojs/compiler": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.2.tgz",
"integrity": "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw==",
"dev": true,
"license": "MIT"
},
"node_modules/@astrojs/ts-plugin": {
"version": "1.10.4",
"resolved": "https://registry.npmjs.org/@astrojs/ts-plugin/-/ts-plugin-1.10.4.tgz",
"integrity": "sha512-rapryQINgv5VLZF884R/wmgX3mM9eH1PC/I3kkPV9rP6lEWrRN1YClF3bGcDHFrf8EtTLc0Wqxne1Uetpevozg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@astrojs/compiler": "^2.10.3",
"@astrojs/yaml2ts": "^0.2.2",
"@jridgewell/sourcemap-codec": "^1.4.15",
"@volar/language-core": "~2.4.7",
"@volar/typescript": "~2.4.7",
"semver": "^7.3.8",
"vscode-languageserver-textdocument": "^1.0.11"
}
},
"node_modules/@astrojs/ts-plugin/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@astrojs/yaml2ts": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.2.tgz",
"integrity": "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yaml": "^2.5.0"
}
},
"node_modules/@atlaskit/pragmatic-drag-and-drop": { "node_modules/@atlaskit/pragmatic-drag-and-drop": {
"version": "1.7.4", "version": "1.7.4",
"resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.4.tgz", "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.4.tgz",
@@ -2325,35 +2278,6 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@volar/language-core": {
"version": "2.4.16",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.16.tgz",
"integrity": "sha512-mcoAFkYVQV4iiLYjTlbolbsm9hhDLtz4D4wTG+rwzSCUbEnxEec+KBlneLMlfdVNjkVEh8lUUSsCGNEQR+hFdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.16"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.16",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.16.tgz",
"integrity": "sha512-4rBiAhOw4MfFTpkvweDnjbDkixpmWNgBws95rpu2oFdMprkTtqFEb8pUOxQ/ruru8/zXSYLwRNXNozznjW9Vtw==",
"dev": true,
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.16",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.16.tgz",
"integrity": "sha512-CrRuG20euPerYc4H0kvDWSSLTBo6qgSI1/0BjXL9ogjm5j6l0gIffvNzEvfmVjr8TAuoMPD0NxuEkteIapfZQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.16",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.1", "version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@@ -3531,13 +3455,6 @@
"url": "https://github.com/inikulin/parse5?sponsor=1" "url": "https://github.com/inikulin/parse5?sponsor=1"
} }
}, },
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true,
"license": "MIT"
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -4389,20 +4306,6 @@
} }
} }
}, },
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -4542,8 +4445,9 @@
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },

View File

@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "mprocs \"npm run start\" \"pocketbase serve\"", "dev": "mprocs \"npm run start\" \"pocketbase serve\"",
"start": "VITE_POCKETBASE_URL=http://localhost:8090 vite --port 3000", "start": "VITE_POCKETBASE_URL=http://localhost:8090 vite --port 3000",
"build": "vite build && tsc", "build": "tsc && vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "vitest run", "test": "vitest run",
"docker:build:app": "docker build -t docker.havenisms.com/lazy-dm/app -f docker/app.dockerfile --build-arg VITE_POCKETBASE_URL=/api .", "docker:build:app": "docker build -t docker.havenisms.com/lazy-dm/app -f docker/app.dockerfile --build-arg VITE_POCKETBASE_URL=/api .",
@@ -30,7 +30,6 @@
"zod": "^4.0.5" "zod": "^4.0.5"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/ts-plugin": "^1.10.4",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/lodash": "^4.17.17", "@types/lodash": "^4.17.17",

View File

@@ -1,6 +1,6 @@
{ {
"short_name": "TanStack App", "short_name": "DM Companion",
"name": "Create TanStack App Sample", "name": "Dungeon Master Companion",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",

View File

@@ -1,41 +0,0 @@
import { type AnyDocument } from "@/lib/types";
import { LocationEditForm } from "./location/LocationEditForm";
import { MonsterEditForm } from "./monsters/MonsterEditForm";
import { NpcEditForm } from "./npc/NpcEditForm";
import { SceneEditForm } from "./scene/SceneEditForm";
import { SecretEditForm } from "./secret/SecretEditForm";
import { SessionEditForm } from "./session/SessionEditForm";
import { TreasureEditForm } from "./treasure/TreasureEditForm";
import { GenericEditForm } from "./GenericEditForm";
/**
* Renders a form for any document type depending on the relationship.
*/
export const DocumentEditForm = ({ document }: { document: AnyDocument }) => {
switch (document.type) {
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} />;
case "thread":
return (
<GenericEditForm
doc={document}
fields={{
text: "multiline",
resolved: "checkbox",
}}
/>
);
}
};

View File

@@ -1,6 +1,6 @@
import { makeDocumentPath, useDocumentPath } from "@/lib/documentPath"; import { makeDocumentPath } from "@/lib/documentPath";
import type { DocumentId } from "@/lib/types"; import type { DocumentId } from "@/lib/types";
import { Link, useParams, useSearch } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
export type Props = React.PropsWithChildren<{ export type Props = React.PropsWithChildren<{
childDocId: DocumentId; childDocId: DocumentId;
@@ -8,37 +8,45 @@ export type Props = React.PropsWithChildren<{
}>; }>;
export function DocumentLink({ childDocId, className, children }: Props) { export function DocumentLink({ childDocId, className, children }: Props) {
const docPath = useDocumentPath(); // const docPath = useDocumentPath();
//
// const params = useParams({
// strict: false,
// });
//
// const campaignSearch = useSearch({
// from: "/_app/_authenticated/campaigns/$campaignId",
// shouldThrow: false,
// });
//
// const to = params.campaignId
// ? `/campaigns/${params.campaignId}`
// : docPath
// ? makeDocumentPath(
// docPath.documentId,
// docPath?.relationshipType,
// childDocId,
// )
// : undefined;
//
// const search = campaignSearch
// ? { tab: campaignSearch.tab, docId: childDocId }
// : undefined;
//
// if (to === undefined) {
// throw new Error("Not in a document or campaign context");
// }
//
// return (
// <Link to={to} search={search} className={className}>
// {children}
// </Link>
// );
const params = useParams({ const to = makeDocumentPath(childDocId);
strict: false,
});
const campaignSearch = useSearch({
from: "/_app/_authenticated/campaigns/$campaignId",
shouldThrow: false,
});
const to = params.campaignId
? `/campaigns/${params.campaignId}`
: docPath
? makeDocumentPath(
docPath.documentId,
docPath?.relationshipType,
childDocId,
)
: undefined;
const search = campaignSearch
? { tab: campaignSearch.tab, docId: childDocId }
: undefined;
if (to === undefined) {
throw new Error("Not in a document or campaign context");
}
return ( return (
<Link to={to} search={search} className={className}> <Link to={to} className={className}>
{children} {children}
</Link> </Link>
); );

View File

@@ -5,7 +5,7 @@ import { type AnyDocument } from "@/lib/types";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Editing, EditToggle, NotEditing } from "../EditToggle"; import { Editing, EditToggle, NotEditing } from "../EditToggle";
import { BasicPreview } from "./BasicPreview"; import { BasicPreview } from "./BasicPreview";
import { DocumentEditForm } from "./DocumentEditForm"; import { GenericEditForm } from "./GenericEditForm";
export const DocumentPreview = ({ doc }: { doc: AnyDocument }) => { export const DocumentPreview = ({ doc }: { doc: AnyDocument }) => {
const relationships = relationshipsForDocument(doc); const relationships = relationshipsForDocument(doc);
@@ -13,7 +13,7 @@ export const DocumentPreview = ({ doc }: { doc: AnyDocument }) => {
<div> <div>
<EditToggle> <EditToggle>
<Editing> <Editing>
<DocumentEditForm document={doc} /> <GenericEditForm doc={doc} />
</Editing> </Editing>
<NotEditing> <NotEditing>
<ShowDocument doc={doc} /> <ShowDocument doc={doc} />

View File

@@ -1,33 +0,0 @@
// DocumentRow.tsx
// Generic row component for displaying any document type.
import { type AnyDocument } from "@/lib/types";
import { LocationPrintRow } from "./location/LocationPrintRow";
import { MonsterPrintRow } from "./monsters/MonsterPrintRow";
import { NpcPrintRow } from "./npc/NpcPrintRow";
import { ScenePrintRow } from "./scene/ScenePrintRow";
import { SecretPrintRow } from "./secret/SecretPrintRow";
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.
* If rendering a SecretRow, uses the provided session prop if available.
*/
export const DocumentPrintRow = ({ document }: { document: AnyDocument }) => {
switch (document.type) {
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} />;
}
};

View File

@@ -1,15 +1,14 @@
import { useDocument } from "@/context/document/hooks"; import { useDocument } from "@/context/document/hooks";
import { displayName, relationshipsForDocument } from "@/lib/relationships"; import { displayName, relationshipsForDocument } from "@/lib/relationships";
import { RelationshipType, type DocumentId } from "@/lib/types"; import { RelationshipType, type DocumentId } from "@/lib/types";
import { Route as CampaignRoute } from "@/routes/_app/_authenticated/campaigns.$campaignId";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import _ from "lodash"; import _ from "lodash";
import { Loader } from "../Loader";
import { DocumentTitle } from "./DocumentTitle";
import { Tab, TabbedLayout } from "../layout/TabbedLayout"; import { Tab, TabbedLayout } from "../layout/TabbedLayout";
import { DocumentEditForm } from "./DocumentEditForm"; import { Loader } from "../Loader";
import { RelatedDocumentList } from "./RelatedDocumentList";
import { DocumentPreview } from "./DocumentPreview"; import { DocumentPreview } from "./DocumentPreview";
import { DocumentTitle } from "./DocumentTitle";
import { GenericEditForm } from "./GenericEditForm";
import { RelatedDocumentList } from "./RelatedDocumentList";
export function DocumentView({ export function DocumentView({
documentId, documentId,
@@ -31,6 +30,9 @@ export function DocumentView({
if (v.type === "ready") { if (v.type === "ready") {
return v.value.secondary.length.toString(); return v.value.secondary.length.toString();
} }
if (v.type === "empty") {
return "0";
}
return "..."; return "...";
}); });
@@ -84,7 +86,7 @@ export function DocumentView({
]} ]}
content={ content={
relationshipType === null ? ( relationshipType === null ? (
<DocumentEditForm document={doc} /> <GenericEditForm doc={doc} />
) : ( ) : (
<RelatedDocumentList <RelatedDocumentList
documentId={doc.id} documentId={doc.id}

View File

@@ -1,33 +1,31 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import type { AnyDocument, Location } from "@/lib/types"; import { getDocumentType, type AnyDocument } from "@/lib/types";
import { useDocumentCache } from "@/context/document/hooks"; import { useDocumentCache } from "@/context/document/hooks";
import {
getFieldsForType,
type DocumentField,
type FieldType,
} from "@/lib/fields";
import { ToggleInput } from "../form/ToggleInput";
export type GenericFieldType = "multiline" | "singleline" | "checkbox"; export type GenericFieldType = "multiline" | "singleline" | "checkbox";
export type Props<T extends AnyDocument> = { export type Props<T extends AnyDocument> = {
doc: T; doc: T;
fields: { [K in keyof T["data"]]: GenericFieldType };
}; };
export const GenericEditForm = <T extends AnyDocument>({ export const GenericEditForm = <T extends AnyDocument>({ doc }: Props<T>) => {
doc, const docType = getDocumentType(doc) as T["type"];
fields, const fields = getFieldsForType(docType);
}: Props<T>) => {
return ( return (
<div className=""> <div className="">
{ {
// The type checker seems to lose the types when using Object.entries here. // The type checker seems to lose the types when using Object.entries here.
Object.entries(fields).map( fields.map((documentField) => (
([fieldName, fieldType]: [string, unknown]) => ( <GenericEditFormField doc={doc} field={documentField} />
<GenericEditFormField ))
key={fieldName}
doc={doc}
fieldName={fieldName as keyof T["data"]}
fieldType={fieldType as GenericFieldType}
/>
),
)
} }
</div> </div>
); );
@@ -35,52 +33,49 @@ export const GenericEditForm = <T extends AnyDocument>({
const GenericEditFormField = <T extends AnyDocument>({ const GenericEditFormField = <T extends AnyDocument>({
doc, doc,
fieldName, field,
fieldType,
}: { }: {
doc: T; doc: T;
fieldName: keyof T["data"]; field: DocumentField<T["type"], FieldType>;
fieldType: GenericFieldType;
}) => { }) => {
const { dispatch } = useDocumentCache(); const { dispatch } = useDocumentCache();
// The type checker really doesn't like indexing into this type implicitly, so we'll store it in a temporary to give it the right hints. // The type checker really doesn't like indexing into this type implicitly, so we'll store it in a temporary to give it the right hints.
const data = doc.data as T["data"]; const data = doc.data as T["data"];
async function saveField(value: string) { async function saveField(value: string | boolean) {
const updated: T = await pb.collection("documents").update(doc.id, { const updated: T = await pb.collection("documents").update(doc.id, {
data: { data: field.setter(value, doc.data),
...doc.data,
[fieldName]: value,
},
}); });
dispatch({ type: "setDocument", doc: updated }); dispatch({ type: "setDocument", doc: updated });
} }
switch (fieldType) { switch (field.fieldType) {
case "multiline": case "longText":
return ( return (
<AutoSaveTextarea <AutoSaveTextarea
multiline={true} multiline={true}
value={data[fieldName] as string} value={field.getter(data) as string}
onSave={saveField} onSave={saveField}
id={field.name}
/> />
); );
case "singleline": case "shortText":
return ( return (
<AutoSaveTextarea <AutoSaveTextarea
multiline={false} multiline={false}
value={data[fieldName] as string} value={field.getter(data) as string}
onSave={saveField} onSave={saveField}
id={field.name}
/> />
); );
case "checkbox": case "toggle":
return ( return (
<input <ToggleInput
type="checkbox" label={field.name}
checked={data[fieldName] as boolean} value={!!field.getter(data)}
onChange={(e) => saveField(e.target.value)} onChange={saveField}
className="accent-emerald-500 w-5 h-5" placeholder={field.name}
/> />
); );
} }

View File

@@ -0,0 +1,142 @@
import { useDocumentCache } from "@/context/document/hooks";
import { DocumentTypeLabel } from "@/lib/documents";
import {
getFieldsForType,
type DocumentField,
type FieldType,
type ValueForFieldType,
} from "@/lib/fields";
import { pb } from "@/lib/pocketbase";
import {
type CampaignId,
type DocumentData,
type DocumentsByType,
type DocumentType,
} from "@/lib/types";
import { useCallback, useState } from "react";
import { BaseForm } from "../form/BaseForm";
import { MultiLineInput } from "../form/MultiLineInput";
import { SingleLineInput } from "../form/SingleLineInput";
import { ToggleInput } from "../form/ToggleInput";
export type GenericFieldType = "multiline" | "singleline" | "checkbox";
export type Props<T extends DocumentType> = {
docType: T;
campaignId: CampaignId;
onCreate: (doc: DocumentsByType[T]) => Promise<void>;
};
export const GenericNewDocumentForm = <T extends DocumentType>({
docType,
campaignId,
onCreate,
}: Props<T>) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { dispatch } = useDocumentCache();
const fields = getFieldsForType(docType);
const [docData, setDocData] = useState<DocumentData<T>>(
fields.reduce((d, f) => f.setDefault(d), {} as DocumentData<T>),
);
const updateData =
<F extends FieldType>(field: DocumentField<T, F>) =>
(value: ValueForFieldType<F>) =>
setDocData(field.setter(value, docData));
const saveData = useCallback(async () => {
setIsLoading(true);
console.log(`Creating ${docType}: `, docData);
try {
const newDocument: DocumentsByType[T] = await pb
.collection("documents")
.create({
campaign: campaignId,
type: docType,
data: docData,
});
await onCreate(newDocument);
dispatch({
type: "setDocument",
doc: newDocument,
});
} catch (e: unknown) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("An unknown error occurred while creating the session.");
}
}
setIsLoading(false);
}, [campaignId, setIsLoading, setError, docData]);
// TODO: display name for docType
return (
<BaseForm
title={`Create new ${DocumentTypeLabel[docType]}`}
onSubmit={saveData}
isLoading={isLoading}
error={error}
content={
// The type checker seems to lose the types when using Object.entries here.
fields.map((field) => (
<GenericNewFormField
key={field.name}
field={field}
value={field.getter(docData)}
isLoading={isLoading}
onUpdate={updateData(field)}
/>
))
}
/>
);
};
const GenericNewFormField = <T extends DocumentType, F extends FieldType>({
field,
value,
isLoading,
onUpdate,
}: {
field: DocumentField<T, F>;
value: ValueForFieldType<F>;
isLoading: boolean;
onUpdate: (value: ValueForFieldType<F>) => void;
}) => {
switch (field.fieldType) {
case "longText":
return (
<MultiLineInput
label={field.name}
value={value as string}
onChange={onUpdate as (v: string) => void}
disabled={isLoading}
placeholder={field.name}
/>
);
case "shortText":
return (
<SingleLineInput
label={field.name}
value={value as string}
onChange={onUpdate as (v: string) => void}
disabled={isLoading}
placeholder={field.name}
/>
);
case "toggle":
return (
<ToggleInput
label={field.name}
value={value as boolean}
onChange={onUpdate as (v: boolean) => void}
disabled={isLoading}
placeholder={field.name}
/>
);
}
};

View File

@@ -4,6 +4,7 @@ import {
type DocumentType, type DocumentType,
} from "@/lib/types"; } from "@/lib/types";
import { NewSessionForm } from "./session/NewSessionForm"; import { NewSessionForm } from "./session/NewSessionForm";
import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
/** /**
* Renders a form for any document type depending on the relationship. * Renders a form for any document type depending on the relationship.
@@ -20,11 +21,13 @@ export const NewCampaignDocumentForm = ({
switch (docType) { switch (docType) {
case "session": case "session":
return <NewSessionForm campaignId={campaignId} onCreate={onCreate} />; return <NewSessionForm campaignId={campaignId} onCreate={onCreate} />;
case "thread":
return <NewThreadForm campaignId={campaignId} onCreate={onCreate} />;
default: default:
throw new Error( return (
`Rendered NewCampaignDocumentForm with unsupported docType: ${docType}`, <GenericNewDocumentForm
docType={docType}
campaignId={campaignId}
onCreate={onCreate}
/>
); );
} }
}; };

View File

@@ -3,12 +3,11 @@ import {
type CampaignId, type CampaignId,
type AnyDocument, type AnyDocument,
} from "@/lib/types"; } from "@/lib/types";
import { NewLocationForm } from "./location/NewLocationForm"; import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
import { NewMonsterForm } from "./monsters/NewMonsterForm"; import { docTypeForRelationshipType } from "@/lib/relationships";
import { NewNpcForm } from "./npc/NewNpcForm"; import { useState } from "react";
import { NewSceneForm } from "./scene/NewSceneForm"; import { DocumentSearchForm } from "../form/DocumentSearchForm";
import { NewSecretForm } from "./secret/NewSecretForm"; import { identifierForDocType } from "@/lib/documents";
import { NewTreasureForm } from "./treasure/NewTreasureForm";
/** /**
* Renders a form for any document type depending on the relationship. * Renders a form for any document type depending on the relationship.
@@ -22,20 +21,42 @@ export const NewRelatedDocumentForm = ({
relationshipType: RelationshipType; relationshipType: RelationshipType;
onCreate: (doc: AnyDocument) => Promise<void>; onCreate: (doc: AnyDocument) => Promise<void>;
}) => { }) => {
switch (relationshipType) { const [newOrExisting, setNewOrExisting] = useState<"new" | "existing">("new");
case RelationshipType.Locations:
return <NewLocationForm campaign={campaignId} onCreate={onCreate} />; const docType = docTypeForRelationshipType(relationshipType);
case RelationshipType.Monsters:
return <NewMonsterForm campaign={campaignId} onCreate={onCreate} />; return (
case RelationshipType.Npcs: <div>
return <NewNpcForm campaign={campaignId} onCreate={onCreate} />; <div className="flex row gap-4">
case RelationshipType.Secrets: <button
return <NewSecretForm campaign={campaignId} onCreate={onCreate} />; className={`${newOrExisting === "new" ? "font-bold" : "text-gray-400"}`}
case RelationshipType.Treasures: onClick={() => setNewOrExisting("new")}
return <NewTreasureForm campaign={campaignId} onCreate={onCreate} />; >
case RelationshipType.Scenes: New
return <NewSceneForm campaign={campaignId} onCreate={onCreate} />; </button>
case RelationshipType.DiscoveredIn: <button
return "Form not supported here"; className={`${newOrExisting === "existing" ? "font-bold" : "text-gray-400"}`}
} onClick={() => setNewOrExisting("existing")}
>
Existing
</button>
</div>
{newOrExisting === "new" && (
<GenericNewDocumentForm
docType={docType}
campaignId={campaignId}
onCreate={onCreate}
/>
)}
{newOrExisting === "existing" && (
// TODO: Make this into a form with a "Add" button so it's not instant
<DocumentSearchForm
campaignId={campaignId}
onSubmit={onCreate}
docType={docType}
searchField={identifierForDocType(docType)}
/>
)}
</div>
);
}; };

View File

@@ -1,48 +0,0 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase";
import type { Location } from "@/lib/types";
import { useDocumentCache } from "@/context/document/hooks";
/**
* Renders an editable location form
*/
export const LocationEditForm = ({ location }: { location: Location }) => {
const { dispatch } = useDocumentCache();
async function saveLocationName(name: string) {
const updated: Location = await pb
.collection("documents")
.update(location.id, {
data: {
...location.data,
name,
},
});
dispatch({ type: "setDocument", doc: updated });
}
async function saveLocationDescription(description: string) {
const updated: Location = await pb
.collection("documents")
.update(location.id, {
data: {
...location.data,
description,
},
});
dispatch({ type: "setDocument", doc: updated });
}
return (
<div className="">
<AutoSaveTextarea
multiline={false}
value={location.data.name}
onSave={saveLocationName}
/>
<AutoSaveTextarea
value={location.data.description}
onSave={saveLocationDescription}
/>
</div>
);
};

View File

@@ -1,13 +0,0 @@
import type { Location } from "@/lib/types";
/**
* Renders an print-friendly location row
*/
export const LocationPrintRow = ({ location }: { location: Location }) => {
return (
<div>
<h4>{location.data.name}</h4>
<p>{location.data.description}</p>
</div>
);
};

View File

@@ -1,76 +0,0 @@
import { useState } from "react";
import type { CampaignId, Location } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
import { BaseForm } from "@/components/form/BaseForm";
import { MultiLineInput } from "@/components/form/MultiLineInput";
import { SingleLineInput } from "@/components/form/SingleLineInput";
import { useDocumentCache } from "@/context/document/hooks";
/**
* Renders a form to add a new location. Calls onCreate with the new location document.
*/
export const NewLocationForm = ({
campaign,
onCreate,
}: {
campaign: CampaignId;
onCreate: (location: Location) => Promise<void>;
}) => {
const { dispatch } = useDocumentCache();
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,
type: "location",
data: {
name,
description,
},
});
setName("");
setDescription("");
dispatch({ type: "setDocument", doc: locationDoc });
await onCreate(locationDoc);
} catch (e: any) {
setError(e?.message || "Failed to add location.");
} finally {
setAdding(false);
}
}
return (
<BaseForm
title="Create new Location"
onSubmit={handleSubmit}
isLoading={adding || !name.trim()}
error={error}
content={
<>
<SingleLineInput
label="Name"
value={name}
onChange={setName}
disabled={adding}
placeholder="Enter location name"
/>
<MultiLineInput
label="Description"
value={description}
placeholder="Enter location description"
onChange={setDescription}
disabled={adding}
/>
</>
}
/>
);
};

View File

@@ -1,32 +0,0 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase";
import type { Monster } from "@/lib/types";
import { useDocumentCache } from "@/context/document/hooks";
/**
* Renders an editable monster row
*/
export const MonsterEditForm = ({ monster }: { monster: Monster }) => {
const { dispatch } = useDocumentCache();
async function saveMonsterName(name: string) {
const updated: Monster = await pb
.collection("documents")
.update(monster.id, {
data: {
...monster.data,
name,
},
});
dispatch({ type: "setDocument", doc: updated });
}
return (
<div className="">
<AutoSaveTextarea
multiline={false}
value={monster.data.name}
onSave={saveMonsterName}
/>
</div>
);
};

View File

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

View File

@@ -1,61 +0,0 @@
import { useState } from "react";
import type { CampaignId, Monster } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
import { BaseForm } from "@/components/form/BaseForm";
import { SingleLineInput } from "@/components/form/SingleLineInput";
import { useDocumentCache } from "@/context/document/hooks";
/**
* Renders a form to add a new monster. Calls onCreate with the new monster document.
*/
export const NewMonsterForm = ({
campaign,
onCreate,
}: {
campaign: CampaignId;
onCreate: (monster: Monster) => Promise<void>;
}) => {
const { dispatch } = useDocumentCache();
const [name, setName] = 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,
type: "monster",
data: {
name,
},
});
setName("");
dispatch({ type: "setDocument", doc: monsterDoc });
await onCreate(monsterDoc);
} catch (e: any) {
setError(e?.message || "Failed to add monster.");
} finally {
setAdding(false);
}
}
return (
<BaseForm
title="Create new monster"
isLoading={adding || !name.trim()}
onSubmit={handleSubmit}
error={error}
content={
<SingleLineInput
value={name}
onChange={setName}
placeholder="Monster description"
/>
}
/>
);
};

View File

@@ -1,76 +0,0 @@
import { useState } from "react";
import type { CampaignId, Npc } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
import { BaseForm } from "@/components/form/BaseForm";
import { SingleLineInput } from "@/components/form/SingleLineInput";
import { MultiLineInput } from "@/components/form/MultiLineInput";
import { useDocumentCache } from "@/context/document/hooks";
/**
* Renders a form to add a new npc. Calls onCreate with the new npc document.
*/
export const NewNpcForm = ({
campaign,
onCreate,
}: {
campaign: CampaignId;
onCreate: (npc: Npc) => Promise<void>;
}) => {
const { dispatch } = useDocumentCache();
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,
type: "npc",
data: {
name,
description,
},
});
setName("");
setDescription("");
dispatch({ type: "setDocument", doc: npcDoc });
await onCreate(npcDoc);
} catch (e: any) {
setError(e?.message || "Failed to add npc.");
} finally {
setAdding(false);
}
}
return (
<BaseForm
title="Create new NPC"
onSubmit={handleSubmit}
isLoading={adding}
error={error}
content={
<>
<SingleLineInput
label="Name"
value={name}
onChange={setName}
disabled={adding}
placeholder="Enter NPC name"
/>
<MultiLineInput
label="Description"
value={description}
placeholder="Enter NPC description"
onChange={setDescription}
disabled={adding}
/>
</>
}
/>
);
};

View File

@@ -1,44 +0,0 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { useDocumentCache } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase";
import type { Npc } from "@/lib/types";
/**
* Renders an editable npc form
*/
export const NpcEditForm = ({ npc }: { npc: Npc }) => {
const { dispatch } = useDocumentCache();
async function saveNpcName(name: string) {
const updated: Npc = await pb.collection("documents").update(npc.id, {
data: {
...npc.data,
name,
},
});
dispatch({ type: "setDocument", doc: updated });
}
async function saveNpcDescription(description: string) {
const updated: Npc = await pb.collection("documents").update(npc.id, {
data: {
...npc.data,
description,
},
});
dispatch({ type: "setDocument", doc: updated });
}
return (
<div className="">
<AutoSaveTextarea
multiline={false}
value={npc.data.name}
onSave={saveNpcName}
/>
<AutoSaveTextarea
value={npc.data.description}
onSave={saveNpcDescription}
/>
</div>
);
};

View File

@@ -1,13 +0,0 @@
import type { Npc } from "@/lib/types";
/**
* Renders an editable npc row
*/
export const NpcPrintRow = ({ npc }: { npc: Npc }) => {
return (
<div className="">
<h4>{npc.data.name}</h4>
<p>{npc.data.description}</p>
</div>
);
};

View File

@@ -1,67 +0,0 @@
// SceneForm.tsx
// Form for adding a new scene to a session.
import { useState } from "react";
import type { CampaignId, Scene } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
import { BaseForm } from "@/components/form/BaseForm";
import { MultiLineInput } from "@/components/form/MultiLineInput";
import { useDocumentCache } from "@/context/document/hooks";
/**
* Renders a form to add a new scene. Calls onCreate with the new scene document.
*/
export const NewSceneForm = ({
campaign,
onCreate,
}: {
campaign: CampaignId;
onCreate: (scene: Scene) => Promise<void>;
}) => {
const { dispatch } = useDocumentCache();
const [text, setText] = useState("");
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!text.trim()) return;
setAdding(true);
setError(null);
try {
const sceneDoc: Scene = await pb.collection("documents").create({
campaign,
type: "scene",
data: {
text,
},
});
setText("");
dispatch({ type: "setDocument", doc: sceneDoc });
await onCreate(sceneDoc);
} catch (e: any) {
setError(e?.message || "Failed to add scene.");
} finally {
setAdding(false);
}
}
return (
<BaseForm
title="Create new scene"
onSubmit={handleSubmit}
error={error}
buttonText={adding ? "Adding..." : "Create"}
content={
<>
<MultiLineInput
value={text}
onChange={(v) => setText(v)}
disabled={adding}
placeholder="Scene description..."
aria-label="Add new scene"
/>
</>
}
/>
);
};

View File

@@ -1,27 +0,0 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase";
import type { Scene } from "@/lib/types";
import { useDocumentCache } from "@/context/document/hooks";
/**
* Renders an editable scene form
*/
export const SceneEditForm = ({ scene }: { scene: Scene }) => {
const { dispatch } = useDocumentCache();
async function saveScene(text: string) {
const updated: Scene = await pb.collection("documents").update(scene.id, {
data: {
...scene.data,
text,
},
});
dispatch({ type: "setDocument", doc: updated });
}
return (
<div className="">
<AutoSaveTextarea value={scene.data.text} onSave={saveScene} />
</div>
);
};

View File

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

View File

@@ -1,64 +0,0 @@
// SecretForm.tsx
// Form for adding a new secret to a session.
import { useState } from "react";
import type { CampaignId, Secret } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
import { BaseForm } from "@/components/form/BaseForm";
import { SingleLineInput } from "@/components/form/SingleLineInput";
import { useDocumentCache } from "@/context/document/hooks";
/**
* Renders a form to add a new secret. Calls onCreate with the new secret document.
*/
export const NewSecretForm = ({
campaign,
onCreate,
}: {
campaign: CampaignId;
onCreate: (secret: Secret) => Promise<void>;
}) => {
const { dispatch } = useDocumentCache();
const [newSecret, setNewSecret] = useState("");
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!newSecret.trim()) return;
setAdding(true);
setError(null);
try {
const secretDoc: Secret = await pb.collection("documents").create({
campaign,
type: "secret",
data: {
text: newSecret,
discovered: false,
},
});
setNewSecret("");
dispatch({ type: "setDocument", doc: secretDoc as Secret });
await onCreate(secretDoc);
} catch (e: any) {
setError(e?.message || "Failed to add secret.");
} finally {
setAdding(false);
}
}
return (
<BaseForm
title="Create new secret"
isLoading={adding || !newSecret.trim()}
onSubmit={handleSubmit}
error={error}
content={
<SingleLineInput
value={newSecret}
onChange={setNewSecret}
placeholder="Secret description"
/>
}
/>
);
};

View File

@@ -1,65 +0,0 @@
// Displays a single secret with discovered checkbox and text.
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { useDocumentCache } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase";
import type { Secret } from "@/lib/types";
import { useState } from "react";
/**
* Renders an editable secret form.
* Handles updating the discovered state and discoveredIn relationship.
*/
export const SecretEditForm = ({ secret }: { secret: Secret }) => {
const { dispatch } = useDocumentCache();
const [checked, setChecked] = useState(
!!(secret.data as any)?.secret?.discovered,
);
const [loading, setLoading] = useState(false);
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const newChecked = e.target.checked;
setLoading(true);
setChecked(newChecked);
try {
const updated: Secret = await pb
.collection("documents")
.update(secret.id, {
data: {
...secret.data,
discovered: newChecked,
},
});
dispatch({ type: "setDocument", doc: updated });
} finally {
setLoading(false);
}
}
async function saveText(text: string) {
const updated: Secret = await pb.collection("documents").update(secret.id, {
data: {
...secret.data,
text,
},
});
dispatch({ type: "setDocument", doc: updated });
}
return (
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={checked}
onChange={handleChange}
className="accent-emerald-500 w-5 h-5"
aria-label="Discovered"
disabled={loading}
/>
<AutoSaveTextarea
multiline={false}
value={secret.data.text}
onSave={saveText}
/>
</div>
);
};

View File

@@ -1,24 +0,0 @@
// SecretRow.tsx
// Displays a single secret with discovered checkbox and text.
import type { Secret } from "@/lib/types";
/**
* Renders a secret row with a discovered checkbox and secret text.
* Handles updating the discovered state and discoveredIn relationship.
*/
export const SecretPrintRow = ({ secret }: { secret: Secret }) => {
return (
<li className="flex items-center gap-3">
<input
type="checkbox"
className="flex-none accent-emerald-500 w-5 h-5"
aria-label="Discovered"
/>
<span>
{(secret.data as any)?.secret?.text || (
<span className="italic text-slate-400">(No secret text)</span>
)}
</span>
</li>
);
};

View File

@@ -1,5 +1,3 @@
import { BaseForm } from "@/components/form/BaseForm";
import { SingleLineInput } from "@/components/form/SingleLineInput";
import { useDocumentCache } from "@/context/document/hooks"; import { useDocumentCache } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import type { import type {
@@ -8,7 +6,8 @@ import type {
Relationship, Relationship,
Session, Session,
} from "@/lib/types"; } from "@/lib/types";
import { useCallback, useState } from "react"; import { useCallback } from "react";
import { GenericNewDocumentForm } from "../GenericNewDocumentForm";
export type Props = { export type Props = {
campaignId: CampaignId; campaignId: CampaignId;
@@ -16,17 +15,10 @@ export type Props = {
}; };
export const NewSessionForm = ({ campaignId, onCreate }: Props) => { export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState<string>("");
const { dispatch } = useDocumentCache(); const { dispatch } = useDocumentCache();
console.log("Rendering with name: ", name); const createSessionRelations = useCallback(
async (newSession: Session) => {
const createNewSession = useCallback(async () => {
setIsLoading(true);
console.log("Creating session: ", name);
try {
// Check for a previous session // Check for a previous session
const prevSession = await pb const prevSession = await pb
.collection("documents") .collection("documents")
@@ -34,15 +26,6 @@ export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
sort: "-created", sort: "-created",
}); });
const newSession: Session = await pb.collection("documents").create({
campaign: campaignId,
type: "session",
data: {
name,
strongStart: "",
},
});
// If any relations, then copy things over // If any relations, then copy things over
if (prevSession) { if (prevSession) {
const prevRelations = await pb const prevRelations = await pb
@@ -59,38 +42,16 @@ export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
}); });
} }
} }
dispatch({ await onCreate(newSession);
type: "setDocument", },
doc: newSession, [campaignId, dispatch],
}); );
await onCreate?.(newSession);
} catch (e: unknown) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("An unknown error occurred while creating the session.");
}
}
setIsLoading(false);
}, [campaignId, name, dispatch, setIsLoading, setError]);
return ( return (
<BaseForm <GenericNewDocumentForm
title="Create new session" docType="session"
onSubmit={createNewSession} campaignId={campaignId}
isLoading={isLoading} onCreate={createSessionRelations}
error={error}
content={
<>
<SingleLineInput
label="Name"
value={name}
onChange={setName}
disabled={isLoading}
placeholder="Enter session name"
/>
</>
}
/> />
); );
}; };

View File

@@ -1,33 +0,0 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { useDocumentCache } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase";
import type { Session } from "@/lib/types";
export const SessionEditForm = ({ session }: { session: Session }) => {
const { dispatch } = useDocumentCache();
async function saveStrongStart(strongStart: string) {
const doc: Session = await pb.collection("documents").update(session.id, {
data: {
...session.data,
strongStart,
},
});
dispatch({
type: "setDocument",
doc,
});
}
return (
<form>
<h3 className="text-lg font-bold mb-4 text-slate-100">Strong Start</h3>
<AutoSaveTextarea
value={session.data.strongStart}
onSave={saveStrongStart}
placeholder="Enter a strong start for this session..."
aria-label="Strong Start"
/>
</form>
);
};

View File

@@ -1,10 +0,0 @@
import type { Session } from "@/lib/types";
export const SessionPrintRow = ({ session }: { session: Session }) => {
return (
<div>
<h3 className="text-lg font-bold text-slate-600">StrongStart</h3>
<div className="">{session.data.strongStart}</div>
</div>
);
};

View File

@@ -1,19 +0,0 @@
import { FormattedDate } from "@/components/FormattedDate";
import type { Session } from "@/lib/types";
import { Link } from "@tanstack/react-router";
export const SessionRow = ({ session }: { session: Session }) => {
return (
<div>
<Link
to="/campaigns/$campaignId"
params={{ campaignId: session.campaign }}
search={{ tab: "sessions", docId: session.id }}
className="block font-semibold text-lg text-slate-300"
>
{session.name ? session.name : <FormattedDate date={session.created} />}
</Link>
<div className="">{session.data.strongStart}</div>
</div>
);
};

View File

@@ -1,67 +0,0 @@
// TreasureForm.tsx
// Form for adding a new treasure to a session.
import { useState } from "react";
import type { CampaignId, Treasure } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
import { BaseForm } from "@/components/form/BaseForm";
import { SingleLineInput } from "@/components/form/SingleLineInput";
import { useDocumentCache } from "@/context/document/hooks";
/**
* Renders a form to add a new treasure. Calls onCreate with the new treasure document.
*/
export const NewTreasureForm = ({
campaign,
onCreate,
}: {
campaign: CampaignId;
onCreate: (treasure: Treasure) => Promise<void>;
}) => {
const { dispatch } = useDocumentCache();
const [newTreasure, setNewTreasure] = useState("");
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!newTreasure.trim()) return;
setAdding(true);
setError(null);
try {
const treasureDoc: Treasure = await pb.collection("documents").create({
campaign,
type: "treasure",
data: {
text: newTreasure,
discovered: false,
},
});
setNewTreasure("");
dispatch({
type: "setDocument",
doc: treasureDoc,
});
await onCreate(treasureDoc);
} catch (e: any) {
setError(e?.message || "Failed to add treasure.");
} finally {
setAdding(false);
}
}
return (
<BaseForm
title="Create new treasure"
isLoading={adding || !newTreasure.trim()}
onSubmit={handleSubmit}
error={error}
content={
<SingleLineInput
value={newTreasure}
onChange={setNewTreasure}
placeholder="Treasure description"
/>
}
/>
);
};

View File

@@ -1,70 +0,0 @@
// Displays a single treasure with discovered checkbox and text.
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { useDocumentCache } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase";
import type { Treasure } from "@/lib/types";
import { useState } from "react";
/**
* Renders an editable treasure form.
* Handles updating the discovered state and discoveredIn relationship.
*/
export const TreasureEditForm = ({ treasure }: { treasure: Treasure }) => {
const { dispatch } = useDocumentCache();
const [checked, setChecked] = useState(
!!(treasure.data as any)?.treasure?.discovered,
);
const [loading, setLoading] = useState(false);
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const newChecked = e.target.checked;
setLoading(true);
setChecked(newChecked);
try {
const updated: Treasure = await pb
.collection("documents")
.update(treasure.id, {
data: {
...treasure.data,
treasure: {
...(treasure.data as any).treasure,
discovered: newChecked,
},
},
});
dispatch({ type: "setDocument", doc: updated });
} finally {
setLoading(false);
}
}
async function saveText(text: string) {
const updated: Treasure = await pb
.collection("documents")
.update(treasure.id, {
data: {
...treasure.data,
text,
},
});
dispatch({ type: "setDocument", doc: updated });
}
return (
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={checked}
onChange={handleChange}
className="accent-emerald-500 w-5 h-5"
aria-label="Discovered"
disabled={loading}
/>
<AutoSaveTextarea
multiline={false}
value={treasure.data.text}
onSave={saveText}
/>
</div>
);
};

View File

@@ -1,24 +0,0 @@
// TreasureRow.tsx
// Displays a single treasure with discovered checkbox and text.
import type { Treasure } from "@/lib/types";
/**
* Renders a treasure row with a discovered checkbox and treasure text.
* Handles updating the discovered state and discoveredIn relationship.
*/
export const TreasurePrintRow = ({ treasure }: { treasure: Treasure }) => {
return (
<div className="flex items-center gap-3">
<input
type="checkbox"
className="flex-none accent-emerald-500 w-5 h-5"
aria-label="Discovered"
/>
<span>
{(treasure.data as any)?.treasure?.text || (
<span className="italic text-slate-400">(No treasure text)</span>
)}
</span>
</div>
);
};

View File

@@ -70,7 +70,7 @@ export const TreasureToggleRow = ({
disabled={loading} disabled={loading}
/> />
<Link <Link
to="/document/$documentId" to="/document/$documentId/$"
params={{ documentId: treasure.id }} params={{ documentId: treasure.id }}
className="text-lg !no-underline text-slate-100 hover:underline hover:text-violet-400" className="text-lg !no-underline text-slate-100 hover:underline hover:text-violet-400"
> >

View File

@@ -0,0 +1,105 @@
import { DocumentTypeLoader } from "@/context/document/DocumentTypeLoader";
import { useDocumentCache } from "@/context/document/hooks";
import type { AnyDocument, CampaignId, DocumentType } from "@/lib/types";
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/react";
import { useEffect, useState } from "react";
import { BaseForm } from "./BaseForm";
import { DocumentTypeLabel } from "@/lib/documents";
export type Props = {
campaignId: CampaignId;
docType: DocumentType;
searchField: string;
onSubmit: (doc: AnyDocument) => void;
};
export const DocumentSearchForm = (props: Props) => (
<DocumentTypeLoader
documentType={props.docType}
campaignId={props.campaignId}
>
<DocumentSearchInput {...props} />
</DocumentTypeLoader>
);
/** Utility to help with typing */
function getField(doc: AnyDocument, field: string): string | undefined {
return (doc.data as Record<string, string>)[field];
}
export const DocumentSearchInput = ({
docType,
searchField,
onSubmit,
}: Props) => {
const { cache } = useDocumentCache();
const [allOptions, setAllOptions] = useState<AnyDocument[]>([]);
useEffect(() => {
setAllOptions(
Object.values(cache.documents).flatMap((docResult) => {
if (docResult.type !== "ready") {
return [];
}
if (docResult.value.doc.type !== docType) {
return [];
}
return [docResult.value.doc];
}),
);
}, [cache, setAllOptions]);
const [queryValue, setQueryValue] = useState("");
const [selectedDoc, setSelectedDoc] = useState<AnyDocument | null>(null);
const options = allOptions.filter((doc) =>
getField(doc, searchField)
?.toLowerCase()
?.includes(queryValue.toLowerCase()),
);
return (
<BaseForm
title={`Find ${DocumentTypeLabel[docType]}`}
buttonText="Add"
error={null}
onSubmit={() => selectedDoc && onSubmit(selectedDoc)}
content={
<Combobox<AnyDocument | null>
name={searchField}
value={selectedDoc}
onChange={(doc) => {
console.log("Selected", doc);
setSelectedDoc(doc);
}}
>
<ComboboxInput
displayValue={(doc: AnyDocument) =>
(doc && getField(doc, searchField)) ?? "(no value)"
}
onChange={(event) => setQueryValue(event.target.value)}
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`}
/>
<ComboboxOptions
anchor="bottom start"
className="border empty:invisible z-50 px-4 bg-black"
>
{options.map((doc) => (
<ComboboxOption
key={doc.id}
value={doc}
className="data-selected:font-bold data-focus:font-bold"
>
{getField(doc, searchField)}
</ComboboxOption>
))}
</ComboboxOptions>
</Combobox>
}
/>
);
};

View File

@@ -0,0 +1,29 @@
export type Props = {
value: boolean;
onChange: (value: boolean) => void;
label?: string;
className?: string;
} & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "className"
>;
export const ToggleInput = ({
value,
onChange,
className = "",
label,
...props
}: Props) => (
<div className="flex flex-row gap-4 p-2">
<input
type="checkbox"
checked={value}
onChange={(e) => onChange(e.target.checked)}
className={`rounded border bg-slate-800 text-slate-100 border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500 transition-colors ${className}`}
aria-label={label}
{...props}
/>
{label && <label>{label}</label>}
</div>
);

View File

@@ -36,8 +36,11 @@ export function DocumentLoader({
relationships: doc.expand?.relationships_via_primary || [], relationships: doc.expand?.relationships_via_primary || [],
relatedDocuments: relatedDocuments:
doc.expand?.relationships_via_primary?.flatMap( doc.expand?.relationships_via_primary?.flatMap(
(r: RecordModel) => r.expand?.secondary, (r: RecordModel): AnyDocument[] =>
) || [], // Note: If there are no entries in the expanded secondaries there
// just won't be an entry instead of an empty list.
r.expand?.secondary ?? [],
) ?? [],
}); });
} }

View File

@@ -0,0 +1,41 @@
import { pb } from "@/lib/pocketbase";
import {
type AnyDocument,
type CampaignId,
type DocumentType,
} from "@/lib/types";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { useDocumentCache } from "./hooks";
/**
* Provider for the record cache context. Provides a singleton RecordCache instance to children.
*/
export function DocumentTypeLoader({
campaignId,
documentType,
children,
}: {
campaignId: CampaignId;
documentType: DocumentType;
children: ReactNode;
}) {
const { dispatch } = useDocumentCache();
useEffect(() => {
async function fetchDocuments() {
const docs: AnyDocument[] = await pb.collection("documents").getFullList({
filter: `campaign = "${campaignId}" && type = "${documentType}"`,
});
dispatch({
type: "setDocuments",
docs: docs,
});
}
fetchDocuments();
}, [campaignId, documentType]);
return children;
}

View File

@@ -9,6 +9,10 @@ export type DocumentAction =
type: "setDocument"; type: "setDocument";
doc: AnyDocument; doc: AnyDocument;
} }
| {
type: "setDocuments";
docs: AnyDocument[];
}
| { | {
type: "setRelationship"; type: "setRelationship";
docId: DocumentId; docId: DocumentId;

View File

@@ -1,14 +1,15 @@
import _ from "lodash"; import { relationshipsForDocument } from "@/lib/relationships";
import type { AnyDocument, DocumentId, Relationship } from "@/lib/types"; import type { AnyDocument, DocumentId, Relationship } from "@/lib/types";
import _ from "lodash";
import type { DocumentAction } from "./actions"; import type { DocumentAction } from "./actions";
import { import {
ready, empty,
loading, loading,
mapResult,
ready,
unloaded, unloaded,
type DocumentState, type DocumentState,
mapResult,
} from "./state"; } from "./state";
import { relationshipsForDocument } from "@/lib/relationships";
function setLoadingDocument( function setLoadingDocument(
docId: DocumentId, docId: DocumentId,
@@ -47,6 +48,36 @@ function setDocument(state: DocumentState, doc: AnyDocument): DocumentState {
}; };
} }
function setAllRelationshipsEmpty(
docId: DocumentId,
state: DocumentState,
): DocumentState {
const prevDocResult = state.documents[docId];
if (prevDocResult?.type !== "ready") {
return state;
}
const prevDoc = prevDocResult.value.doc;
const relationships = prevDocResult.value.relationships;
return {
...state,
documents: {
...state.documents,
[docId]: ready({
...prevDocResult.value,
relationships: Object.fromEntries(
relationshipsForDocument(prevDoc).map((relType) =>
relationships[relType]?.type === "ready"
? [relType, relationships[relType]]
: [relType, empty()],
),
),
}),
},
};
}
function setRelationship( function setRelationship(
docId: DocumentId, docId: DocumentId,
state: DocumentState, state: DocumentState,
@@ -103,25 +134,40 @@ function removeDocument(
} }
export function reducer( export function reducer(
state: DocumentState, initialState: DocumentState,
action: DocumentAction, action: DocumentAction,
): DocumentState { ): DocumentState {
console.debug("Processing action", action);
switch (action.type) { switch (action.type) {
case "loadingDocument": case "loadingDocument":
return setLoadingDocument(action.docId, state); return setLoadingDocument(action.docId, initialState);
case "setDocument": case "setDocument":
return setDocument(state, action.doc); return setDocument(initialState, action.doc);
case "setDocuments":
return action.docs.reduce(setDocument, initialState);
case "setRelationship": case "setRelationship":
return setRelationship(action.docId, state, action.relationship); return setRelationship(action.docId, initialState, action.relationship);
case "setDocumentTree": case "setDocumentTree":
const updatedDocumentState = setAllRelationshipsEmpty(
action.doc.id,
setDocument(initialState, action.doc),
);
const updatedRelationshipsState = action.relationships.reduce(
setRelationship.bind(null, action.doc.id),
updatedDocumentState,
);
const emptyRemainingRelationships = setAllRelationshipsEmpty(
action.doc.id,
updatedRelationshipsState,
);
return action.relatedDocuments.reduce( return action.relatedDocuments.reduce(
setDocument, setDocument,
action.relationships.reduce( emptyRemainingRelationships,
setRelationship.bind(null, action.doc.id),
setDocument(state, action.doc),
),
); );
case "removeDocument": case "removeDocument":
return removeDocument(action.docId, state); return removeDocument(action.docId, initialState);
} }
} }

View File

@@ -10,11 +10,13 @@ export type Result<V> =
| { type: "unloaded" } | { type: "unloaded" }
| { type: "error"; err: unknown } | { type: "error"; err: unknown }
| { type: "loading" } | { type: "loading" }
| { type: "empty" }
| { type: "ready"; value: V }; | { type: "ready"; value: V };
export const unloaded = (): Result<any> => ({ type: "unloaded" }); export const unloaded = (): Result<any> => ({ type: "unloaded" });
export const error = (err: unknown): Result<any> => ({ type: "error", err }); export const error = (err: unknown): Result<any> => ({ type: "error", err });
export const loading = (): Result<any> => ({ type: "loading" }); export const loading = (): Result<any> => ({ type: "loading" });
export const empty = (): Result<any> => ({ type: "empty" });
export const ready = <V>(value: V): Result<V> => ({ type: "ready", value }); export const ready = <V>(value: V): Result<V> => ({ type: "ready", value });
export const mapResult = <A, B>( export const mapResult = <A, B>(

37
src/lib/documents.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { DocumentType } from "./types";
export const DocumentTypeLabel: Record<DocumentType, string> = {
session: "Session",
secret: "Secret",
npc: "NPC",
location: "Location",
thread: "Thread",
front: "Front",
monster: "Monster",
scene: "Scene",
treasure: "Treasure",
};
export const DocumentTypeLabePlural: Record<DocumentType, string> = {
session: "Sessions",
secret: "Secrets",
npc: "NPCs",
location: "Locations",
thread: "Threads",
front: "Fronts",
monster: "Monsters",
scene: "Scenes",
treasure: "Treasures",
};
export function identifierForDocType(docType: DocumentType): string {
switch (docType) {
case "scene":
case "secret":
case "thread":
case "treasure":
return "text";
default:
return "name";
}
}

106
src/lib/fields.ts Normal file
View File

@@ -0,0 +1,106 @@
import { type DocumentData, type DocumentType } from "./types";
export type FieldType = "identifier" | "shortText" | "longText" | "toggle";
export type ValueForFieldType<F extends FieldType> = {
identifier: string;
shortText: string;
longText: string;
toggle: boolean;
}[F];
function defaultValue<F extends FieldType>(fieldType: F): ValueForFieldType<F> {
switch (fieldType) {
case "identifier":
case "shortText":
case "longText":
return "" as ValueForFieldType<F>;
case "toggle":
return false as ValueForFieldType<F>;
}
}
export type DocumentField<D extends DocumentType, F extends FieldType> = {
name: string;
fieldType: F;
getter: (doc: DocumentData<D>) => ValueForFieldType<F>;
setter: (
value: ValueForFieldType<F>,
doc: DocumentData<D>,
) => DocumentData<D>;
setDefault: (doc: DocumentData<D>) => DocumentData<D>;
};
const simpleField = <D extends DocumentType, F extends FieldType>(
name: string,
key: keyof DocumentData<D>,
fieldType: F,
): DocumentField<D, F> => ({
name,
fieldType,
getter: (doc) => doc[key] as unknown as ValueForFieldType<F>,
setter: (value, doc) => ({ ...doc, [key]: value }),
setDefault: (doc) => ({ ...doc, [key]: defaultValue(fieldType) }),
});
const simpleFields = <D extends DocumentType>(
fields: Record<string, [keyof DocumentData<D>, FieldType]>,
): DocumentField<D, FieldType>[] =>
Object.entries(fields).map(([name, [key, fieldType]]) =>
simpleField(name, key, fieldType),
);
export function getFieldsForType<D extends DocumentType>(
docType: D,
): DocumentField<D, FieldType>[] {
// Explicit casts are required because the getter function puts the type D in the parameters position and thus the specialized getter is not valid in the case of the more general document type.
// While the switch correctly sees that D is now "front", the _type_ could be a union and thus the getter needs to be able to accept any of them.
// I know this will only ever be called in the context of one value, but this is clearly abusing the type system.
// TODO: Fix the types
switch (docType) {
case "front":
return simpleFields<"front">({
Name: ["name", "shortText"],
Description: ["description", "longText"],
Resolved: ["resolved", "toggle"],
}) as unknown as DocumentField<D, FieldType>[];
case "location":
return simpleFields<"location">({
Name: ["name", "shortText"],
Description: ["description", "longText"],
}) as unknown as DocumentField<D, FieldType>[];
case "monster":
return simpleFields<"monster">({
Name: ["name", "shortText"],
}) as unknown as DocumentField<D, FieldType>[];
case "npc":
return simpleFields<"npc">({
Name: ["name", "shortText"],
Description: ["description", "longText"],
}) as unknown as DocumentField<D, FieldType>[];
case "scene":
return simpleFields<"scene">({
Text: ["text", "longText"],
}) as unknown as DocumentField<D, FieldType>[];
case "secret":
return simpleFields<"secret">({
Discovered: ["discovered", "toggle"],
Text: ["text", "shortText"],
}) as unknown as DocumentField<D, FieldType>[];
case "session":
return simpleFields<"session">({
Name: ["name", "shortText"],
"Strong Start": ["strongStart", "longText"],
}) as unknown as DocumentField<D, FieldType>[];
case "thread":
return simpleFields<"thread">({
Resolved: ["resolved", "toggle"],
Text: ["text", "shortText"],
}) as unknown as DocumentField<D, FieldType>[];
case "treasure":
return simpleFields<"treasure">({
Discovered: ["discovered", "toggle"],
Text: ["text", "shortText"],
}) as unknown as DocumentField<D, FieldType>[];
}
}

View File

@@ -1,8 +1,8 @@
import { import {
getDocumentType, getDocumentType,
type DocumentType,
RelationshipType, RelationshipType,
type AnyDocument, type AnyDocument,
type DocumentType,
} from "./types"; } from "./types";
export function displayName(relationshipType: RelationshipType) { export function displayName(relationshipType: RelationshipType) {
@@ -24,3 +24,17 @@ export function relationshipsForDocument(doc: AnyDocument): RelationshipType[] {
return []; return [];
} }
} }
const DocTypeForRelationshipType: { [k in RelationshipType]: DocumentType } = {
[RelationshipType.DiscoveredIn]: "session",
[RelationshipType.Locations]: "location",
[RelationshipType.Monsters]: "monster",
[RelationshipType.Npcs]: "npc",
[RelationshipType.Scenes]: "scene",
[RelationshipType.Secrets]: "secret",
[RelationshipType.Treasures]: "treasure",
} as const;
export function docTypeForRelationshipType(rt: RelationshipType): DocumentType {
return DocTypeForRelationshipType[rt];
}

View File

@@ -75,11 +75,6 @@ export type DocumentType =
| "thread" | "thread"
| "treasure"; | "treasure";
export type DocumentData<Type extends DocumentType, Data> = {
type: Type;
data: Data;
};
export type Document<Type extends DocumentType, Data> = RecordModel & { export type Document<Type extends DocumentType, Data> = RecordModel & {
id: DocumentId; id: DocumentId;
collectionName: typeof CollectionIds.Documents; collectionName: typeof CollectionIds.Documents;
@@ -102,6 +97,23 @@ export type AnyDocument =
| Thread | Thread
| Treasure; | Treasure;
export type DocumentsByType = {
front: Front;
location: Location;
monster: Monster;
npc: Npc;
scene: Scene;
secret: Secret;
session: Session;
thread: Thread;
treasure: Treasure;
};
export type DocumentData<Type extends DocumentType> =
DocumentsByType[Type]["data"];
export type GetDocumentType<D extends AnyDocument> = D["type"];
export function getDocumentType(doc: AnyDocument): DocumentType { export function getDocumentType(doc: AnyDocument): DocumentType {
return doc.type; return doc.type;
} }

View File

@@ -5,9 +5,9 @@ import { Loader } from "@/components/Loader";
import { DocumentLoader } from "@/context/document/DocumentLoader"; import { DocumentLoader } from "@/context/document/DocumentLoader";
import { useDocument } from "@/context/document/hooks"; import { useDocument } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import type { Campaign, DocumentId, Relationship, Session } from "@/lib/types"; import type { Campaign, DocumentId } from "@/lib/types";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useCallback, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { z } from "zod"; import { z } from "zod";
const CampaignTabs = { const CampaignTabs = {

View File

@@ -43,6 +43,7 @@ function RouteComponent() {
to="/campaigns/$campaignId" to="/campaigns/$campaignId"
params={{ campaignId: c.id }} params={{ campaignId: c.id }}
className="block px-4 py-2 rounded bg-slate-800 hover:bg-violet-700 text-slate-100 transition-colors" className="block px-4 py-2 rounded bg-slate-800 hover:bg-violet-700 text-slate-100 transition-colors"
search={{ tab: "sessions" }}
> >
{c.name} {c.name}
</Link> </Link>

View File

@@ -7,14 +7,21 @@ import { resolve } from "node:path";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [TanStackRouterVite({ autoCodeSplitting: true }), viteReact(), tailwindcss()], plugins: [
TanStackRouterVite({ autoCodeSplitting: true }),
viteReact(),
tailwindcss(),
],
test: { test: {
globals: true, globals: true,
environment: "jsdom", environment: "jsdom",
}, },
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, './src'), "@": resolve(__dirname, "./src"),
}, },
} },
build: {
sourcemap: true,
},
}); });