Compare commits
15 Commits
1c26daa828
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bfd20ea8fb | |||
| 907df26395 | |||
| d1432d048f | |||
| e9d88fdce8 | |||
| f197a3fabe | |||
| f8aac31306 | |||
| d44fe72ff1 | |||
| 64aaad69d7 | |||
| c0638e34a8 | |||
| 8afe0a5345 | |||
| 625bc508aa | |||
| ab323798e9 | |||
| 6979bc4b8f | |||
| c9d27bce75 | |||
| 43afdc8684 |
100
package-lock.json
generated
100
package-lock.json
generated
@@ -23,7 +23,6 @@
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/ts-plugin": "^1.10.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/lodash": "^4.17.17",
|
||||
@@ -71,52 +70,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.7.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
@@ -3531,13 +3455,6 @@
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
@@ -4542,8 +4445,9 @@
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "mprocs \"npm run start\" \"pocketbase serve\"",
|
||||
"start": "VITE_POCKETBASE_URL=http://localhost:8090 vite --port 3000",
|
||||
"build": "vite build && tsc",
|
||||
"build": "tsc && vite build",
|
||||
"serve": "vite preview",
|
||||
"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 .",
|
||||
@@ -30,7 +30,6 @@
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/ts-plugin": "^1.10.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/lodash": "^4.17.17",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "TanStack App",
|
||||
"name": "Create TanStack App Sample",
|
||||
"short_name": "DM Companion",
|
||||
"name": "Dungeon Master Companion",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { makeDocumentPath, useDocumentPath } from "@/lib/documentPath";
|
||||
import { makeDocumentPath } from "@/lib/documentPath";
|
||||
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<{
|
||||
childDocId: DocumentId;
|
||||
@@ -8,37 +8,45 @@ export type Props = React.PropsWithChildren<{
|
||||
}>;
|
||||
|
||||
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({
|
||||
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");
|
||||
}
|
||||
const to = makeDocumentPath(childDocId);
|
||||
|
||||
return (
|
||||
<Link to={to} search={search} className={className}>
|
||||
<Link to={to} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type AnyDocument } from "@/lib/types";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Editing, EditToggle, NotEditing } from "../EditToggle";
|
||||
import { BasicPreview } from "./BasicPreview";
|
||||
import { DocumentEditForm } from "./DocumentEditForm";
|
||||
import { GenericEditForm } from "./GenericEditForm";
|
||||
|
||||
export const DocumentPreview = ({ doc }: { doc: AnyDocument }) => {
|
||||
const relationships = relationshipsForDocument(doc);
|
||||
@@ -13,7 +13,7 @@ export const DocumentPreview = ({ doc }: { doc: AnyDocument }) => {
|
||||
<div>
|
||||
<EditToggle>
|
||||
<Editing>
|
||||
<DocumentEditForm document={doc} />
|
||||
<GenericEditForm doc={doc} />
|
||||
</Editing>
|
||||
<NotEditing>
|
||||
<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 { displayName, relationshipsForDocument } from "@/lib/relationships";
|
||||
import { RelationshipType, type DocumentId } from "@/lib/types";
|
||||
import { Route as CampaignRoute } from "@/routes/_app/_authenticated/campaigns.$campaignId";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import _ from "lodash";
|
||||
import { Loader } from "../Loader";
|
||||
import { DocumentTitle } from "./DocumentTitle";
|
||||
import { Tab, TabbedLayout } from "../layout/TabbedLayout";
|
||||
import { DocumentEditForm } from "./DocumentEditForm";
|
||||
import { RelatedDocumentList } from "./RelatedDocumentList";
|
||||
import { Loader } from "../Loader";
|
||||
import { DocumentPreview } from "./DocumentPreview";
|
||||
import { DocumentTitle } from "./DocumentTitle";
|
||||
import { GenericEditForm } from "./GenericEditForm";
|
||||
import { RelatedDocumentList } from "./RelatedDocumentList";
|
||||
|
||||
export function DocumentView({
|
||||
documentId,
|
||||
@@ -31,6 +30,9 @@ export function DocumentView({
|
||||
if (v.type === "ready") {
|
||||
return v.value.secondary.length.toString();
|
||||
}
|
||||
if (v.type === "empty") {
|
||||
return "0";
|
||||
}
|
||||
return "...";
|
||||
});
|
||||
|
||||
@@ -84,7 +86,7 @@ export function DocumentView({
|
||||
]}
|
||||
content={
|
||||
relationshipType === null ? (
|
||||
<DocumentEditForm document={doc} />
|
||||
<GenericEditForm doc={doc} />
|
||||
) : (
|
||||
<RelatedDocumentList
|
||||
documentId={doc.id}
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||
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 {
|
||||
getFieldsForType,
|
||||
type DocumentField,
|
||||
type FieldType,
|
||||
} from "@/lib/fields";
|
||||
import { ToggleInput } from "../form/ToggleInput";
|
||||
|
||||
export type GenericFieldType = "multiline" | "singleline" | "checkbox";
|
||||
|
||||
export type Props<T extends AnyDocument> = {
|
||||
doc: T;
|
||||
fields: { [K in keyof T["data"]]: GenericFieldType };
|
||||
};
|
||||
|
||||
export const GenericEditForm = <T extends AnyDocument>({
|
||||
doc,
|
||||
fields,
|
||||
}: Props<T>) => {
|
||||
export const GenericEditForm = <T extends AnyDocument>({ doc }: Props<T>) => {
|
||||
const docType = getDocumentType(doc) as T["type"];
|
||||
const fields = getFieldsForType(docType);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{
|
||||
// The type checker seems to lose the types when using Object.entries here.
|
||||
Object.entries(fields).map(
|
||||
([fieldName, fieldType]: [string, unknown]) => (
|
||||
<GenericEditFormField
|
||||
key={fieldName}
|
||||
doc={doc}
|
||||
fieldName={fieldName as keyof T["data"]}
|
||||
fieldType={fieldType as GenericFieldType}
|
||||
/>
|
||||
),
|
||||
)
|
||||
fields.map((documentField) => (
|
||||
<GenericEditFormField doc={doc} field={documentField} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
@@ -35,52 +33,49 @@ export const GenericEditForm = <T extends AnyDocument>({
|
||||
|
||||
const GenericEditFormField = <T extends AnyDocument>({
|
||||
doc,
|
||||
fieldName,
|
||||
fieldType,
|
||||
field,
|
||||
}: {
|
||||
doc: T;
|
||||
fieldName: keyof T["data"];
|
||||
fieldType: GenericFieldType;
|
||||
field: DocumentField<T["type"], FieldType>;
|
||||
}) => {
|
||||
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.
|
||||
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, {
|
||||
data: {
|
||||
...doc.data,
|
||||
[fieldName]: value,
|
||||
},
|
||||
data: field.setter(value, doc.data),
|
||||
});
|
||||
dispatch({ type: "setDocument", doc: updated });
|
||||
}
|
||||
|
||||
switch (fieldType) {
|
||||
case "multiline":
|
||||
switch (field.fieldType) {
|
||||
case "longText":
|
||||
return (
|
||||
<AutoSaveTextarea
|
||||
multiline={true}
|
||||
value={data[fieldName] as string}
|
||||
value={field.getter(data) as string}
|
||||
onSave={saveField}
|
||||
id={field.name}
|
||||
/>
|
||||
);
|
||||
case "singleline":
|
||||
case "shortText":
|
||||
return (
|
||||
<AutoSaveTextarea
|
||||
multiline={false}
|
||||
value={data[fieldName] as string}
|
||||
value={field.getter(data) as string}
|
||||
onSave={saveField}
|
||||
id={field.name}
|
||||
/>
|
||||
);
|
||||
case "checkbox":
|
||||
case "toggle":
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data[fieldName] as boolean}
|
||||
onChange={(e) => saveField(e.target.value)}
|
||||
className="accent-emerald-500 w-5 h-5"
|
||||
<ToggleInput
|
||||
label={field.name}
|
||||
value={!!field.getter(data)}
|
||||
onChange={saveField}
|
||||
placeholder={field.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
142
src/components/documents/GenericNewDocumentForm.tsx
Normal file
142
src/components/documents/GenericNewDocumentForm.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type DocumentType,
|
||||
} from "@/lib/types";
|
||||
import { NewSessionForm } from "./session/NewSessionForm";
|
||||
import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
|
||||
|
||||
/**
|
||||
* Renders a form for any document type depending on the relationship.
|
||||
@@ -20,11 +21,13 @@ export const NewCampaignDocumentForm = ({
|
||||
switch (docType) {
|
||||
case "session":
|
||||
return <NewSessionForm campaignId={campaignId} onCreate={onCreate} />;
|
||||
case "thread":
|
||||
return <NewThreadForm campaignId={campaignId} onCreate={onCreate} />;
|
||||
default:
|
||||
throw new Error(
|
||||
`Rendered NewCampaignDocumentForm with unsupported docType: ${docType}`,
|
||||
return (
|
||||
<GenericNewDocumentForm
|
||||
docType={docType}
|
||||
campaignId={campaignId}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,12 +3,11 @@ import {
|
||||
type CampaignId,
|
||||
type AnyDocument,
|
||||
} from "@/lib/types";
|
||||
import { NewLocationForm } from "./location/NewLocationForm";
|
||||
import { NewMonsterForm } from "./monsters/NewMonsterForm";
|
||||
import { NewNpcForm } from "./npc/NewNpcForm";
|
||||
import { NewSceneForm } from "./scene/NewSceneForm";
|
||||
import { NewSecretForm } from "./secret/NewSecretForm";
|
||||
import { NewTreasureForm } from "./treasure/NewTreasureForm";
|
||||
import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
|
||||
import { docTypeForRelationshipType } from "@/lib/relationships";
|
||||
import { useState } from "react";
|
||||
import { DocumentSearchForm } from "../form/DocumentSearchForm";
|
||||
import { identifierForDocType } from "@/lib/documents";
|
||||
|
||||
/**
|
||||
* Renders a form for any document type depending on the relationship.
|
||||
@@ -22,20 +21,42 @@ export const NewRelatedDocumentForm = ({
|
||||
relationshipType: RelationshipType;
|
||||
onCreate: (doc: AnyDocument) => Promise<void>;
|
||||
}) => {
|
||||
switch (relationshipType) {
|
||||
case RelationshipType.Locations:
|
||||
return <NewLocationForm campaign={campaignId} onCreate={onCreate} />;
|
||||
case RelationshipType.Monsters:
|
||||
return <NewMonsterForm campaign={campaignId} onCreate={onCreate} />;
|
||||
case RelationshipType.Npcs:
|
||||
return <NewNpcForm campaign={campaignId} onCreate={onCreate} />;
|
||||
case RelationshipType.Secrets:
|
||||
return <NewSecretForm campaign={campaignId} onCreate={onCreate} />;
|
||||
case RelationshipType.Treasures:
|
||||
return <NewTreasureForm campaign={campaignId} onCreate={onCreate} />;
|
||||
case RelationshipType.Scenes:
|
||||
return <NewSceneForm campaign={campaignId} onCreate={onCreate} />;
|
||||
case RelationshipType.DiscoveredIn:
|
||||
return "Form not supported here";
|
||||
}
|
||||
const [newOrExisting, setNewOrExisting] = useState<"new" | "existing">("new");
|
||||
|
||||
const docType = docTypeForRelationshipType(relationshipType);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex row gap-4">
|
||||
<button
|
||||
className={`${newOrExisting === "new" ? "font-bold" : "text-gray-400"}`}
|
||||
onClick={() => setNewOrExisting("new")}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
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,5 +1,3 @@
|
||||
import { BaseForm } from "@/components/form/BaseForm";
|
||||
import { SingleLineInput } from "@/components/form/SingleLineInput";
|
||||
import { useDocumentCache } from "@/context/document/hooks";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type {
|
||||
@@ -8,7 +6,8 @@ import type {
|
||||
Relationship,
|
||||
Session,
|
||||
} from "@/lib/types";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { GenericNewDocumentForm } from "../GenericNewDocumentForm";
|
||||
|
||||
export type Props = {
|
||||
campaignId: CampaignId;
|
||||
@@ -16,17 +15,10 @@ export type 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();
|
||||
|
||||
console.log("Rendering with name: ", name);
|
||||
|
||||
const createNewSession = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
console.log("Creating session: ", name);
|
||||
try {
|
||||
const createSessionRelations = useCallback(
|
||||
async (newSession: Session) => {
|
||||
// Check for a previous session
|
||||
const prevSession = await pb
|
||||
.collection("documents")
|
||||
@@ -34,15 +26,6 @@ export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
|
||||
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 (prevSession) {
|
||||
const prevRelations = await pb
|
||||
@@ -59,38 +42,16 @@ export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
dispatch({
|
||||
type: "setDocument",
|
||||
doc: newSession,
|
||||
});
|
||||
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]);
|
||||
await onCreate(newSession);
|
||||
},
|
||||
[campaignId, dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseForm
|
||||
title="Create new session"
|
||||
onSubmit={createNewSession}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
content={
|
||||
<>
|
||||
<SingleLineInput
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
disabled={isLoading}
|
||||
placeholder="Enter session name"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
<GenericNewDocumentForm
|
||||
docType="session"
|
||||
campaignId={campaignId}
|
||||
onCreate={createSessionRelations}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<Link
|
||||
to="/document/$documentId"
|
||||
to="/document/$documentId/$"
|
||||
params={{ documentId: treasure.id }}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
29
src/components/form/ToggleInput.tsx
Normal file
29
src/components/form/ToggleInput.tsx
Normal 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>
|
||||
);
|
||||
@@ -36,8 +36,11 @@ export function DocumentLoader({
|
||||
relationships: doc.expand?.relationships_via_primary || [],
|
||||
relatedDocuments:
|
||||
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";
|
||||
doc: AnyDocument;
|
||||
}
|
||||
| {
|
||||
type: "setDocuments";
|
||||
docs: AnyDocument[];
|
||||
}
|
||||
| {
|
||||
type: "setRelationship";
|
||||
docId: DocumentId;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import _ from "lodash";
|
||||
import { relationshipsForDocument } from "@/lib/relationships";
|
||||
import type { AnyDocument, DocumentId, Relationship } from "@/lib/types";
|
||||
import _ from "lodash";
|
||||
import type { DocumentAction } from "./actions";
|
||||
import {
|
||||
ready,
|
||||
empty,
|
||||
loading,
|
||||
mapResult,
|
||||
ready,
|
||||
unloaded,
|
||||
type DocumentState,
|
||||
mapResult,
|
||||
} from "./state";
|
||||
import { relationshipsForDocument } from "@/lib/relationships";
|
||||
|
||||
function setLoadingDocument(
|
||||
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(
|
||||
docId: DocumentId,
|
||||
state: DocumentState,
|
||||
@@ -103,25 +134,40 @@ function removeDocument(
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: DocumentState,
|
||||
initialState: DocumentState,
|
||||
action: DocumentAction,
|
||||
): DocumentState {
|
||||
console.debug("Processing action", action);
|
||||
switch (action.type) {
|
||||
case "loadingDocument":
|
||||
return setLoadingDocument(action.docId, state);
|
||||
return setLoadingDocument(action.docId, initialState);
|
||||
case "setDocument":
|
||||
return setDocument(state, action.doc);
|
||||
return setDocument(initialState, action.doc);
|
||||
case "setDocuments":
|
||||
return action.docs.reduce(setDocument, initialState);
|
||||
case "setRelationship":
|
||||
return setRelationship(action.docId, state, action.relationship);
|
||||
return setRelationship(action.docId, initialState, action.relationship);
|
||||
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(
|
||||
setDocument,
|
||||
action.relationships.reduce(
|
||||
setRelationship.bind(null, action.doc.id),
|
||||
setDocument(state, action.doc),
|
||||
),
|
||||
emptyRemainingRelationships,
|
||||
);
|
||||
case "removeDocument":
|
||||
return removeDocument(action.docId, state);
|
||||
return removeDocument(action.docId, initialState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ export type Result<V> =
|
||||
| { type: "unloaded" }
|
||||
| { type: "error"; err: unknown }
|
||||
| { type: "loading" }
|
||||
| { type: "empty" }
|
||||
| { type: "ready"; value: V };
|
||||
|
||||
export const unloaded = (): Result<any> => ({ type: "unloaded" });
|
||||
export const error = (err: unknown): Result<any> => ({ type: "error", err });
|
||||
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 mapResult = <A, B>(
|
||||
|
||||
37
src/lib/documents.ts
Normal file
37
src/lib/documents.ts
Normal 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
106
src/lib/fields.ts
Normal 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>[];
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
getDocumentType,
|
||||
type DocumentType,
|
||||
RelationshipType,
|
||||
type AnyDocument,
|
||||
type DocumentType,
|
||||
} from "./types";
|
||||
|
||||
export function displayName(relationshipType: RelationshipType) {
|
||||
@@ -24,3 +24,17 @@ export function relationshipsForDocument(doc: AnyDocument): RelationshipType[] {
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -75,11 +75,6 @@ export type DocumentType =
|
||||
| "thread"
|
||||
| "treasure";
|
||||
|
||||
export type DocumentData<Type extends DocumentType, Data> = {
|
||||
type: Type;
|
||||
data: Data;
|
||||
};
|
||||
|
||||
export type Document<Type extends DocumentType, Data> = RecordModel & {
|
||||
id: DocumentId;
|
||||
collectionName: typeof CollectionIds.Documents;
|
||||
@@ -102,6 +97,23 @@ export type AnyDocument =
|
||||
| Thread
|
||||
| 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 {
|
||||
return doc.type;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Loader } from "@/components/Loader";
|
||||
import { DocumentLoader } from "@/context/document/DocumentLoader";
|
||||
import { useDocument } from "@/context/document/hooks";
|
||||
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 { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
const CampaignTabs = {
|
||||
|
||||
@@ -43,6 +43,7 @@ function RouteComponent() {
|
||||
to="/campaigns/$campaignId"
|
||||
params={{ campaignId: c.id }}
|
||||
className="block px-4 py-2 rounded bg-slate-800 hover:bg-violet-700 text-slate-100 transition-colors"
|
||||
search={{ tab: "sessions" }}
|
||||
>
|
||||
{c.name}
|
||||
</Link>
|
||||
|
||||
@@ -7,14 +7,21 @@ import { resolve } from "node:path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite({ autoCodeSplitting: true }), viteReact(), tailwindcss()],
|
||||
plugins: [
|
||||
TanStackRouterVite({ autoCodeSplitting: true }),
|
||||
viteReact(),
|
||||
tailwindcss(),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user