Compare commits
11 Commits
ab323798e9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bfd20ea8fb | |||
| 907df26395 | |||
| d1432d048f | |||
| e9d88fdce8 | |||
| f197a3fabe | |||
| f8aac31306 | |||
| d44fe72ff1 | |||
| 64aaad69d7 | |||
| c0638e34a8 | |||
| 8afe0a5345 | |||
| 625bc508aa |
100
package-lock.json
generated
100
package-lock.json
generated
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,33 +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} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const GenericNewDocumentForm = <T extends DocumentType>({
|
|||||||
// 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.
|
||||||
fields.map((field) => (
|
fields.map((field) => (
|
||||||
<GenericNewFormField
|
<GenericNewFormField
|
||||||
|
key={field.name}
|
||||||
field={field}
|
field={field}
|
||||||
value={field.getter(docData)}
|
value={field.getter(docData)}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ 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":
|
default:
|
||||||
case "location":
|
|
||||||
return (
|
return (
|
||||||
<GenericNewDocumentForm
|
<GenericNewDocumentForm
|
||||||
docType={docType}
|
docType={docType}
|
||||||
@@ -30,9 +29,5 @@ export const NewCampaignDocumentForm = ({
|
|||||||
onCreate={onCreate}
|
onCreate={onCreate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
|
||||||
throw new Error(
|
|
||||||
`Rendered NewCampaignDocumentForm with unsupported docType: ${docType}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>;
|
|
||||||
};
|
|
||||||
@@ -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"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>;
|
|
||||||
};
|
|
||||||
@@ -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"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
105
src/components/form/DocumentSearchForm.tsx
Normal file
105
src/components/form/DocumentSearchForm.tsx
Normal 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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 ?? [],
|
||||||
|
) ?? [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
src/context/document/DocumentTypeLoader.tsx
Normal file
41
src/context/document/DocumentTypeLoader.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -23,3 +23,15 @@ export const DocumentTypeLabePlural: Record<DocumentType, string> = {
|
|||||||
scene: "Scenes",
|
scene: "Scenes",
|
||||||
treasure: "Treasures",
|
treasure: "Treasures",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function identifierForDocType(docType: DocumentType): string {
|
||||||
|
switch (docType) {
|
||||||
|
case "scene":
|
||||||
|
case "secret":
|
||||||
|
case "thread":
|
||||||
|
case "treasure":
|
||||||
|
return "text";
|
||||||
|
default:
|
||||||
|
return "name";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { type DocumentData, type DocumentType } from "./types";
|
import { type DocumentData, type DocumentType } from "./types";
|
||||||
|
|
||||||
export type FieldType = "shortText" | "longText" | "toggle";
|
export type FieldType = "identifier" | "shortText" | "longText" | "toggle";
|
||||||
|
|
||||||
export type ValueForFieldType<F extends FieldType> = {
|
export type ValueForFieldType<F extends FieldType> = {
|
||||||
|
identifier: string;
|
||||||
shortText: string;
|
shortText: string;
|
||||||
longText: string;
|
longText: string;
|
||||||
toggle: boolean;
|
toggle: boolean;
|
||||||
@@ -10,6 +11,7 @@ export type ValueForFieldType<F extends FieldType> = {
|
|||||||
|
|
||||||
function defaultValue<F extends FieldType>(fieldType: F): ValueForFieldType<F> {
|
function defaultValue<F extends FieldType>(fieldType: F): ValueForFieldType<F> {
|
||||||
switch (fieldType) {
|
switch (fieldType) {
|
||||||
|
case "identifier":
|
||||||
case "shortText":
|
case "shortText":
|
||||||
case "longText":
|
case "longText":
|
||||||
return "" as ValueForFieldType<F>;
|
return "" as ValueForFieldType<F>;
|
||||||
@@ -51,46 +53,54 @@ const simpleFields = <D extends DocumentType>(
|
|||||||
export function getFieldsForType<D extends DocumentType>(
|
export function getFieldsForType<D extends DocumentType>(
|
||||||
docType: D,
|
docType: D,
|
||||||
): DocumentField<D, FieldType>[] {
|
): 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) {
|
switch (docType) {
|
||||||
case "front":
|
case "front":
|
||||||
return simpleFields<"front">({
|
return simpleFields<"front">({
|
||||||
Name: ["name", "shortText"],
|
Name: ["name", "shortText"],
|
||||||
Description: ["description", "longText"],
|
Description: ["description", "longText"],
|
||||||
Resolved: ["resolved", "toggle"],
|
Resolved: ["resolved", "toggle"],
|
||||||
});
|
}) as unknown as DocumentField<D, FieldType>[];
|
||||||
case "location":
|
case "location":
|
||||||
return [
|
return simpleFields<"location">({
|
||||||
simpleField("Name", "name", "shortText"),
|
Name: ["name", "shortText"],
|
||||||
simpleField("Description", "description", "longText"),
|
Description: ["description", "longText"],
|
||||||
];
|
}) as unknown as DocumentField<D, FieldType>[];
|
||||||
case "monster":
|
case "monster":
|
||||||
return [simpleField("Name", "name", "shortText")];
|
return simpleFields<"monster">({
|
||||||
|
Name: ["name", "shortText"],
|
||||||
|
}) as unknown as DocumentField<D, FieldType>[];
|
||||||
case "npc":
|
case "npc":
|
||||||
return [
|
return simpleFields<"npc">({
|
||||||
simpleField("Name", "name", "shortText"),
|
Name: ["name", "shortText"],
|
||||||
simpleField("Description", "description", "longText"),
|
Description: ["description", "longText"],
|
||||||
];
|
}) as unknown as DocumentField<D, FieldType>[];
|
||||||
case "scene":
|
case "scene":
|
||||||
return [simpleField("Text", "text", "longText")];
|
return simpleFields<"scene">({
|
||||||
|
Text: ["text", "longText"],
|
||||||
|
}) as unknown as DocumentField<D, FieldType>[];
|
||||||
case "secret":
|
case "secret":
|
||||||
return [
|
return simpleFields<"secret">({
|
||||||
simpleField("Discovered", "discovered", "toggle"),
|
Discovered: ["discovered", "toggle"],
|
||||||
simpleField("Text", "text", "shortText"),
|
Text: ["text", "shortText"],
|
||||||
];
|
}) as unknown as DocumentField<D, FieldType>[];
|
||||||
case "session":
|
case "session":
|
||||||
return [
|
return simpleFields<"session">({
|
||||||
simpleField("Name", "name", "shortText"),
|
Name: ["name", "shortText"],
|
||||||
simpleField("Strong Start", "strongStart", "longText"),
|
"Strong Start": ["strongStart", "longText"],
|
||||||
];
|
}) as unknown as DocumentField<D, FieldType>[];
|
||||||
case "thread":
|
case "thread":
|
||||||
return [
|
return simpleFields<"thread">({
|
||||||
simpleField("Resolved", "resolved", "toggle"),
|
Resolved: ["resolved", "toggle"],
|
||||||
simpleField("Text", "text", "shortText"),
|
Text: ["text", "shortText"],
|
||||||
];
|
}) as unknown as DocumentField<D, FieldType>[];
|
||||||
case "treasure":
|
case "treasure":
|
||||||
return [
|
return simpleFields<"treasure">({
|
||||||
simpleField("Discovered", "discovered", "toggle"),
|
Discovered: ["discovered", "toggle"],
|
||||||
simpleField("Text", "text", "shortText"),
|
Text: ["text", "shortText"],
|
||||||
];
|
}) as unknown as DocumentField<D, FieldType>[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user