Compare commits
18 Commits
3310be9e9b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bfd20ea8fb | |||
| 907df26395 | |||
| d1432d048f | |||
| e9d88fdce8 | |||
| f197a3fabe | |||
| f8aac31306 | |||
| d44fe72ff1 | |||
| 64aaad69d7 | |||
| c0638e34a8 | |||
| 8afe0a5345 | |||
| 625bc508aa | |||
| ab323798e9 | |||
| 6979bc4b8f | |||
| c9d27bce75 | |||
| 43afdc8684 | |||
| 1c26daa828 | |||
| 135debdf7f | |||
| 2fbc2c853f |
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",
|
||||||
|
|||||||
54
pb_migrations/1754253375_updated_documents.js
Normal file
54
pb_migrations/1754253375_updated_documents.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3332084752")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(3, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select2363381545",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"location",
|
||||||
|
"monster",
|
||||||
|
"npc",
|
||||||
|
"scene",
|
||||||
|
"secret",
|
||||||
|
"session",
|
||||||
|
"treasure",
|
||||||
|
"thread",
|
||||||
|
"front"
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3332084752")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(3, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select2363381545",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"location",
|
||||||
|
"monster",
|
||||||
|
"npc",
|
||||||
|
"scene",
|
||||||
|
"secret",
|
||||||
|
"session",
|
||||||
|
"treasure"
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { Fragment, useCallback, useState } from "react";
|
import { Fragment, useCallback, useState } from "react";
|
||||||
|
|
||||||
type Props<T extends AnyDocument> = {
|
type Props<T extends AnyDocument> = {
|
||||||
title: React.ReactNode;
|
title?: React.ReactNode;
|
||||||
error?: React.ReactNode;
|
error?: React.ReactNode;
|
||||||
items: T[];
|
items: T[];
|
||||||
renderRow: (item: T) => React.ReactNode;
|
renderRow: (item: T) => React.ReactNode;
|
||||||
@@ -49,7 +49,7 @@ export function DocumentList<T extends AnyDocument>({
|
|||||||
return (
|
return (
|
||||||
<section className="w-full">
|
<section className="w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-bold text-slate-100">{title}</h2>
|
{title && <h2 className="text-xl font-bold text-slate-100">{title}</h2>}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
69
src/components/campaign/CampaignDocuments.tsx
Normal file
69
src/components/campaign/CampaignDocuments.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
type AnyDocument,
|
||||||
|
type CampaignId,
|
||||||
|
type DocumentId,
|
||||||
|
type DocumentType,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { useDocumentCache } from "@/context/document/hooks";
|
||||||
|
import { DocumentList } from "../DocumentList";
|
||||||
|
import { getAllDocumentsOfType } from "@/context/document/state";
|
||||||
|
import { DocumentRow } from "../documents/DocumentRow";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { NewCampaignDocumentForm } from "../documents/NewCampaignDocumentForm";
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
campaignId: CampaignId;
|
||||||
|
docType: DocumentType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CampaignDocuments = ({ campaignId, docType }: Props) => {
|
||||||
|
const { cache, dispatch } = useDocumentCache();
|
||||||
|
|
||||||
|
const items = getAllDocumentsOfType(docType, cache);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchDocuments() {
|
||||||
|
const documents: AnyDocument[] = await pb
|
||||||
|
.collection("documents")
|
||||||
|
.getFullList({
|
||||||
|
filter: `campaign = "${campaignId}" && type = "${docType}"`,
|
||||||
|
sort: "created",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const doc of documents) {
|
||||||
|
dispatch({
|
||||||
|
type: "setDocument",
|
||||||
|
doc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDocuments();
|
||||||
|
}, [campaignId, docType]);
|
||||||
|
|
||||||
|
const handleRemove = (id: DocumentId) => {
|
||||||
|
pb.collection("documents").delete(id);
|
||||||
|
dispatch({
|
||||||
|
type: "removeDocument",
|
||||||
|
docId: id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentList
|
||||||
|
items={items}
|
||||||
|
renderRow={(doc) => <DocumentRow document={doc} />}
|
||||||
|
newItemForm={(onSubmit) => (
|
||||||
|
<NewCampaignDocumentForm
|
||||||
|
campaignId={campaignId}
|
||||||
|
docType={docType}
|
||||||
|
onCreate={async () => {
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
removeItem={handleRemove}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import { FormattedText } from "../FormattedText";
|
import { FormattedText } from "../FormattedText";
|
||||||
|
import type { DocumentId } from "@/lib/types";
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
|
id: DocumentId;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BasicPreview = ({ title, description }: Props) => {
|
export const BasicPreview = ({ id, title, description }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<Link
|
||||||
|
to="/document/$documentId/$"
|
||||||
|
params={{
|
||||||
|
documentId: id,
|
||||||
|
}}
|
||||||
|
className="!no-underline text-violet-400 hover:underline hover:text-violet-500"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
{title && <h4 className="font-bold">{title}</h4>}
|
{title && <h4 className="font-bold">{title}</h4>}
|
||||||
{description && <FormattedText>{description}</FormattedText>}
|
{description && <FormattedText>{description}</FormattedText>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +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";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { makeDocumentPath, useDocumentPath } from "@/lib/documentPath";
|
import { makeDocumentPath } from "@/lib/documentPath";
|
||||||
import type { DocumentId, RelationshipType } from "@/lib/types";
|
import type { DocumentId } from "@/lib/types";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
export type Props = React.PropsWithChildren<{
|
export type Props = React.PropsWithChildren<{
|
||||||
@@ -8,13 +8,45 @@ export type Props = React.PropsWithChildren<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function DocumentLink({ childDocId, className, children }: Props) {
|
export function DocumentLink({ childDocId, className, children }: Props) {
|
||||||
const { documentId, relationshipType } = 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 to = makeDocumentPath(childDocId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link to={to} className={className}>
|
||||||
to={makeDocumentPath(documentId, relationshipType, childDocId)}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />
|
||||||
@@ -32,20 +32,31 @@ export const DocumentPreview = ({ doc }: { doc: AnyDocument }) => {
|
|||||||
|
|
||||||
const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
|
const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
|
||||||
switch (doc.type) {
|
switch (doc.type) {
|
||||||
|
case "front":
|
||||||
|
return (
|
||||||
|
<BasicPreview
|
||||||
|
id={doc.id}
|
||||||
|
title={doc.data.name}
|
||||||
|
description={doc.data.description}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case "location":
|
case "location":
|
||||||
return (
|
return (
|
||||||
<BasicPreview
|
<BasicPreview
|
||||||
|
id={doc.id}
|
||||||
title={doc.data.name}
|
title={doc.data.name}
|
||||||
description={doc.data.description}
|
description={doc.data.description}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "monster":
|
case "monster":
|
||||||
return <BasicPreview title={doc.data.name} />;
|
return <BasicPreview id={doc.id} title={doc.data.name} />;
|
||||||
|
|
||||||
case "npc":
|
case "npc":
|
||||||
return (
|
return (
|
||||||
<BasicPreview
|
<BasicPreview
|
||||||
|
id={doc.id}
|
||||||
title={doc.data.name}
|
title={doc.data.name}
|
||||||
description={doc.data.description}
|
description={doc.data.description}
|
||||||
/>
|
/>
|
||||||
@@ -53,16 +64,23 @@ const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
|
|||||||
|
|
||||||
case "session":
|
case "session":
|
||||||
return (
|
return (
|
||||||
<BasicPreview title={doc.created} description={doc.data.strongStart} />
|
<BasicPreview
|
||||||
|
id={doc.id}
|
||||||
|
title={doc.data.name ?? doc.created}
|
||||||
|
description={doc.data.strongStart}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "secret":
|
case "secret":
|
||||||
return <BasicPreview title={doc.data.text} />;
|
return <BasicPreview id={doc.id} title={doc.data.text} />;
|
||||||
|
|
||||||
case "scene":
|
case "scene":
|
||||||
return <BasicPreview description={doc.data.text} />;
|
return <BasicPreview id={doc.id} description={doc.data.text} />;
|
||||||
|
|
||||||
|
case "thread":
|
||||||
|
return <BasicPreview id={doc.id} title={doc.data.text} />;
|
||||||
|
|
||||||
case "treasure":
|
case "treasure":
|
||||||
return <BasicPreview title={doc.data.text} />;
|
return <BasicPreview id={doc.id} title={doc.data.text} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
// DocumentRow.tsx
|
// DocumentRow.tsx
|
||||||
// Generic row component for displaying any document type.
|
// Generic row component for displaying any document type.
|
||||||
import { SecretToggleRow } from "@/components/documents/secret/SecretToggleRow";
|
import { SecretToggleRow } from "@/components/documents/secret/SecretToggleRow";
|
||||||
import { type AnyDocument, type Session } from "@/lib/types";
|
import { type AnyDocument } from "@/lib/types";
|
||||||
import { BasicRow } from "./BasicRow";
|
import { BasicRow } from "./BasicRow";
|
||||||
import { TreasureToggleRow } from "./treasure/TreasureToggleRow";
|
import { TreasureToggleRow } from "./treasure/TreasureToggleRow";
|
||||||
|
|
||||||
@@ -17,6 +17,15 @@ export const DocumentRow = ({
|
|||||||
root?: AnyDocument;
|
root?: AnyDocument;
|
||||||
}) => {
|
}) => {
|
||||||
switch (document.type) {
|
switch (document.type) {
|
||||||
|
case "front":
|
||||||
|
return (
|
||||||
|
<BasicRow
|
||||||
|
doc={document}
|
||||||
|
title={document.data.name}
|
||||||
|
description={document.data.description}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case "location":
|
case "location":
|
||||||
return (
|
return (
|
||||||
<BasicRow
|
<BasicRow
|
||||||
@@ -42,7 +51,7 @@ export const DocumentRow = ({
|
|||||||
return (
|
return (
|
||||||
<BasicRow
|
<BasicRow
|
||||||
doc={document}
|
doc={document}
|
||||||
title={document.created}
|
title={document.data.name || document.created}
|
||||||
description={document.data.strongStart}
|
description={document.data.strongStart}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -53,6 +62,9 @@ export const DocumentRow = ({
|
|||||||
case "scene":
|
case "scene":
|
||||||
return <BasicRow doc={document} description={document.data.text} />;
|
return <BasicRow doc={document} description={document.data.text} />;
|
||||||
|
|
||||||
|
case "thread":
|
||||||
|
return <BasicRow doc={document} description={document.data.text} />;
|
||||||
|
|
||||||
case "treasure":
|
case "treasure":
|
||||||
return <TreasureToggleRow treasure={document} root={root} />;
|
return <TreasureToggleRow treasure={document} root={root} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import { type AnyDocument, type Session } from "@/lib/types";
|
import { type AnyDocument } from "@/lib/types";
|
||||||
import { FormattedDate } from "../FormattedDate";
|
import { FormattedDate } from "../FormattedDate";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the document title to go at the top a document page.
|
* Renders the document title to go at the top a document page.
|
||||||
*/
|
*/
|
||||||
export const DocumentTitle = ({
|
export const DocumentTitle = ({ doc }: { doc: AnyDocument }) => {
|
||||||
document,
|
|
||||||
}: {
|
|
||||||
document: AnyDocument;
|
|
||||||
session?: Session;
|
|
||||||
}) => {
|
|
||||||
switch (document.type) {
|
|
||||||
case "session":
|
|
||||||
return (
|
return (
|
||||||
<h1>
|
<h1 className="text-2xl font-bold">
|
||||||
<FormattedDate date={document.created} />
|
<TitleText doc={doc} />
|
||||||
</h1>
|
</h1>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TitleText = ({ doc }: { doc: AnyDocument }) => {
|
||||||
|
switch (doc.type) {
|
||||||
|
case "session":
|
||||||
|
if (doc.data.name) {
|
||||||
|
return doc.data.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FormattedDate date={doc.created} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <h1>document.type</h1>;
|
// TODO: Put in proper names for other document types
|
||||||
|
return doc.type;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 "...";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,22 +43,24 @@ export function DocumentView({
|
|||||||
navigation={
|
navigation={
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to={CampaignRoute.to}
|
to="/campaigns/$campaignId"
|
||||||
params={{ campaignId: doc.campaign }}
|
params={{ campaignId: doc.campaign }}
|
||||||
|
search={{ tab: "sessions" }}
|
||||||
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
|
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
|
||||||
>
|
>
|
||||||
← Back to campaign
|
← Back to campaign
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{/* Print link isn't currently working */}
|
||||||
to="/document/$documentId/print"
|
{/* <Link */}
|
||||||
params={{ documentId: doc.id }}
|
{/* to="/document/$documentId/print" */}
|
||||||
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
|
{/* params={{ documentId: doc.id }} */}
|
||||||
>
|
{/* className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors" */}
|
||||||
Print
|
{/* > */}
|
||||||
</Link>
|
{/* Print */}
|
||||||
|
{/* </Link> */}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
title={<DocumentTitle document={doc} />}
|
title={<DocumentTitle doc={doc} />}
|
||||||
tabs={[
|
tabs={[
|
||||||
<Tab
|
<Tab
|
||||||
to="/document/$documentId"
|
to="/document/$documentId"
|
||||||
@@ -82,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}
|
||||||
|
|||||||
82
src/components/documents/GenericEditForm.tsx
Normal file
82
src/components/documents/GenericEditForm.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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.
|
||||||
|
fields.map((documentField) => (
|
||||||
|
<GenericEditFormField doc={doc} field={documentField} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GenericEditFormField = <T extends AnyDocument>({
|
||||||
|
doc,
|
||||||
|
field,
|
||||||
|
}: {
|
||||||
|
doc: T;
|
||||||
|
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 | boolean) {
|
||||||
|
const updated: T = await pb.collection("documents").update(doc.id, {
|
||||||
|
data: field.setter(value, doc.data),
|
||||||
|
});
|
||||||
|
dispatch({ type: "setDocument", doc: updated });
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (field.fieldType) {
|
||||||
|
case "longText":
|
||||||
|
return (
|
||||||
|
<AutoSaveTextarea
|
||||||
|
multiline={true}
|
||||||
|
value={field.getter(data) as string}
|
||||||
|
onSave={saveField}
|
||||||
|
id={field.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "shortText":
|
||||||
|
return (
|
||||||
|
<AutoSaveTextarea
|
||||||
|
multiline={false}
|
||||||
|
value={field.getter(data) as string}
|
||||||
|
onSave={saveField}
|
||||||
|
id={field.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "toggle":
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
33
src/components/documents/NewCampaignDocumentForm.tsx
Normal file
33
src/components/documents/NewCampaignDocumentForm.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
type AnyDocument,
|
||||||
|
type CampaignId,
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export const NewCampaignDocumentForm = ({
|
||||||
|
campaignId,
|
||||||
|
docType,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
campaignId: CampaignId;
|
||||||
|
docType: DocumentType;
|
||||||
|
onCreate: (doc: AnyDocument) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
switch (docType) {
|
||||||
|
case "session":
|
||||||
|
return <NewSessionForm campaignId={campaignId} onCreate={onCreate} />;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<GenericNewDocumentForm
|
||||||
|
docType={docType}
|
||||||
|
campaignId={campaignId}
|
||||||
|
onCreate={onCreate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
57
src/components/documents/session/NewSessionForm.tsx
Normal file
57
src/components/documents/session/NewSessionForm.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useDocumentCache } from "@/context/document/hooks";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
import type {
|
||||||
|
AnyDocument,
|
||||||
|
CampaignId,
|
||||||
|
Relationship,
|
||||||
|
Session,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { GenericNewDocumentForm } from "../GenericNewDocumentForm";
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
campaignId: CampaignId;
|
||||||
|
onCreate: (doc: AnyDocument) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
|
||||||
|
const { dispatch } = useDocumentCache();
|
||||||
|
|
||||||
|
const createSessionRelations = useCallback(
|
||||||
|
async (newSession: Session) => {
|
||||||
|
// Check for a previous session
|
||||||
|
const prevSession = await pb
|
||||||
|
.collection("documents")
|
||||||
|
.getFirstListItem(`campaign = "${campaignId}" && type = 'session'`, {
|
||||||
|
sort: "-created",
|
||||||
|
});
|
||||||
|
|
||||||
|
// If any relations, then copy things over
|
||||||
|
if (prevSession) {
|
||||||
|
const prevRelations = await pb
|
||||||
|
.collection<Relationship>("relationships")
|
||||||
|
.getFullList({
|
||||||
|
filter: `primary = "${prevSession.id}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const relation of prevRelations) {
|
||||||
|
await pb.collection("relationships").create({
|
||||||
|
primary: newSession.id,
|
||||||
|
type: relation.type,
|
||||||
|
secondary: relation.secondary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await onCreate(newSession);
|
||||||
|
},
|
||||||
|
[campaignId, dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"
|
|
||||||
>
|
|
||||||
<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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ export const BaseForm = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col items-left gap-2" onSubmit={onSubmit}>
|
<form
|
||||||
|
className="flex flex-col items-left gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h3 className="text-lg font-semibold text-slate-100">{title}</h3>
|
<h3 className="text-lg font-semibold text-slate-100">{title}</h3>
|
||||||
<div className="flex flex-col gap-2 w-full items-stretch">{content}</div>
|
<div className="flex flex-col gap-2 w-full items-stretch">{content}</div>
|
||||||
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
@@ -23,7 +23,7 @@ export function TabbedLayout({
|
|||||||
{tabs}
|
{tabs}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`grow p-2 bg-slate-800 border-t border-b border-r border-slate-700 ${flyout && "hidden"} md:block`}
|
className={`grow md:w-md p-2 bg-slate-800 border-t border-b border-r border-slate-700 ${flyout && "hidden"} md:block`}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
|||||||
*/
|
*/
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [user, setUser] = useState<AuthRecord | null>(pb.authStore.record);
|
const [user, setUser] = useState<AuthRecord | null>(
|
||||||
|
pb.authStore.isValid ? pb.authStore.record : null,
|
||||||
|
);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -19,4 +23,8 @@ export type DocumentAction =
|
|||||||
doc: AnyDocument;
|
doc: AnyDocument;
|
||||||
relationships: Relationship[];
|
relationships: Relationship[];
|
||||||
relatedDocuments: AnyDocument[];
|
relatedDocuments: AnyDocument[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "removeDocument";
|
||||||
|
docId: DocumentId;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { AnyDocument, DocumentId, Relationship } from "@/lib/types";
|
|
||||||
import type { DocumentAction } from "./actions";
|
|
||||||
import { ready, loading, unloaded, type DocumentState } from "./state";
|
|
||||||
import { relationshipsForDocument } from "@/lib/relationships";
|
import { relationshipsForDocument } from "@/lib/relationships";
|
||||||
|
import type { AnyDocument, DocumentId, Relationship } from "@/lib/types";
|
||||||
|
import _ from "lodash";
|
||||||
|
import type { DocumentAction } from "./actions";
|
||||||
|
import {
|
||||||
|
empty,
|
||||||
|
loading,
|
||||||
|
mapResult,
|
||||||
|
ready,
|
||||||
|
unloaded,
|
||||||
|
type DocumentState,
|
||||||
|
} from "./state";
|
||||||
|
|
||||||
function setLoadingDocument(
|
function setLoadingDocument(
|
||||||
docId: DocumentId,
|
docId: DocumentId,
|
||||||
@@ -40,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,
|
||||||
@@ -65,24 +103,71 @@ function setRelationship(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reducer(
|
function removeDocument(
|
||||||
|
docId: DocumentId,
|
||||||
state: DocumentState,
|
state: DocumentState,
|
||||||
|
): DocumentState {
|
||||||
|
const remainingDocs: DocumentState["documents"] = _.omit(state.documents, [
|
||||||
|
docId,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
documents: _.mapValues(remainingDocs, (result) => {
|
||||||
|
if (result.type !== "ready") {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return ready({
|
||||||
|
doc: result.value.doc,
|
||||||
|
relationships: _.mapValues(
|
||||||
|
result.value.relationships,
|
||||||
|
(relationshipResult) =>
|
||||||
|
mapResult(relationshipResult, (relationship) => ({
|
||||||
|
...relationship,
|
||||||
|
secondary: relationship.secondary.filter(
|
||||||
|
(relatedId) => relatedId !== docId,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reducer(
|
||||||
|
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":
|
||||||
|
return removeDocument(action.docId, initialState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AnyDocument,
|
AnyDocument,
|
||||||
DocumentId,
|
DocumentId,
|
||||||
|
DocumentType,
|
||||||
Relationship,
|
Relationship,
|
||||||
RelationshipType,
|
RelationshipType,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
@@ -9,13 +10,25 @@ 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>(
|
||||||
|
result: Result<A>,
|
||||||
|
f: (a: A) => B,
|
||||||
|
): Result<B> => {
|
||||||
|
if (result.type === "ready") {
|
||||||
|
return ready(f(result.value));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
export type DocumentState = {
|
export type DocumentState = {
|
||||||
documents: Record<
|
documents: Record<
|
||||||
DocumentId,
|
DocumentId,
|
||||||
@@ -30,3 +43,13 @@ export const initialState = (): DocumentState =>
|
|||||||
({
|
({
|
||||||
documents: {},
|
documents: {},
|
||||||
}) as DocumentState;
|
}) as DocumentState;
|
||||||
|
|
||||||
|
export const getAllDocumentsOfType = <T extends DocumentType>(
|
||||||
|
docType: T,
|
||||||
|
state: DocumentState,
|
||||||
|
): (AnyDocument & { type: T })[] =>
|
||||||
|
Object.values(state.documents).flatMap((docRecord) =>
|
||||||
|
docRecord.type === "ready" && docRecord.value.doc.type === docType
|
||||||
|
? [docRecord.value.doc as AnyDocument & { type: T }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|||||||
@@ -24,17 +24,22 @@ const documentParams = z
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function useDocumentPath(): {
|
export function useDocumentPath():
|
||||||
|
| {
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
relationshipType: RelationshipType | null;
|
relationshipType: RelationshipType | null;
|
||||||
childDocId: DocumentId | null;
|
childDocId: DocumentId | null;
|
||||||
} {
|
}
|
||||||
|
| undefined {
|
||||||
const params = useParams({
|
const params = useParams({
|
||||||
from: "/_app/_authenticated/document/$documentId/$",
|
from: "/_app/_authenticated/document/$documentId/$",
|
||||||
|
shouldThrow: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { relationshipType, childDocId } = documentParams.parse(params._splat);
|
if (params) {
|
||||||
|
const { relationshipType, childDocId } = documentParams.parse(
|
||||||
|
params._splat,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
documentId: params.documentId as DocumentId,
|
documentId: params.documentId as DocumentId,
|
||||||
relationshipType,
|
relationshipType,
|
||||||
@@ -42,6 +47,9 @@ export function useDocumentPath(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function makeDocumentPath(
|
export function makeDocumentPath(
|
||||||
documentId: DocumentId,
|
documentId: DocumentId,
|
||||||
relationshipType?: RelationshipType | null,
|
relationshipType?: RelationshipType | null,
|
||||||
|
|||||||
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,4 +1,9 @@
|
|||||||
import { getDocumentType, RelationshipType, type AnyDocument } from "./types";
|
import {
|
||||||
|
getDocumentType,
|
||||||
|
RelationshipType,
|
||||||
|
type AnyDocument,
|
||||||
|
type DocumentType,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export function displayName(relationshipType: RelationshipType) {
|
export function displayName(relationshipType: RelationshipType) {
|
||||||
return relationshipType.charAt(0).toUpperCase() + relationshipType.slice(1);
|
return relationshipType.charAt(0).toUpperCase() + relationshipType.slice(1);
|
||||||
@@ -19,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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,19 +65,16 @@ export type Relationship = RecordModel & {
|
|||||||
******************************************/
|
******************************************/
|
||||||
|
|
||||||
export type DocumentType =
|
export type DocumentType =
|
||||||
|
| "front"
|
||||||
| "location"
|
| "location"
|
||||||
| "monster"
|
| "monster"
|
||||||
| "npc"
|
| "npc"
|
||||||
| "scene"
|
| "scene"
|
||||||
| "secret"
|
| "secret"
|
||||||
| "session"
|
| "session"
|
||||||
|
| "thread"
|
||||||
| "treasure";
|
| "treasure";
|
||||||
|
|
||||||
export type DocumentData<Type extends DocumentType, Data> = {
|
|
||||||
type: Type;
|
|
||||||
data: Data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Document<Type extends DocumentType, Data> = RecordModel & {
|
export type Document<Type extends DocumentType, Data> = RecordModel & {
|
||||||
id: DocumentId;
|
id: DocumentId;
|
||||||
collectionName: typeof CollectionIds.Documents;
|
collectionName: typeof CollectionIds.Documents;
|
||||||
@@ -90,14 +87,33 @@ export type Document<Type extends DocumentType, Data> = RecordModel & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AnyDocument =
|
export type AnyDocument =
|
||||||
|
| Front
|
||||||
| Location
|
| Location
|
||||||
| Monster
|
| Monster
|
||||||
| Npc
|
| Npc
|
||||||
| Scene
|
| Scene
|
||||||
| Secret
|
| Secret
|
||||||
| Session
|
| Session
|
||||||
|
| Thread
|
||||||
| Treasure;
|
| Treasure;
|
||||||
|
|
||||||
|
export type DocumentsByType = {
|
||||||
|
front: Front;
|
||||||
|
location: Location;
|
||||||
|
monster: Monster;
|
||||||
|
npc: Npc;
|
||||||
|
scene: Scene;
|
||||||
|
secret: Secret;
|
||||||
|
session: Session;
|
||||||
|
thread: Thread;
|
||||||
|
treasure: Treasure;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentData<Type extends DocumentType> =
|
||||||
|
DocumentsByType[Type]["data"];
|
||||||
|
|
||||||
|
export type GetDocumentType<D extends AnyDocument> = D["type"];
|
||||||
|
|
||||||
export function getDocumentType(doc: AnyDocument): DocumentType {
|
export function getDocumentType(doc: AnyDocument): DocumentType {
|
||||||
return doc.type;
|
return doc.type;
|
||||||
}
|
}
|
||||||
@@ -135,6 +151,7 @@ export type Npc = Document<
|
|||||||
export type Session = Document<
|
export type Session = Document<
|
||||||
"session",
|
"session",
|
||||||
{
|
{
|
||||||
|
name?: string;
|
||||||
strongStart: string;
|
strongStart: string;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
@@ -167,3 +184,24 @@ export type Treasure = Document<
|
|||||||
discovered: boolean;
|
discovered: boolean;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/** Thread **/
|
||||||
|
|
||||||
|
export type Thread = Document<
|
||||||
|
"thread",
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
resolved: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** Front **/
|
||||||
|
|
||||||
|
export type Front = Document<
|
||||||
|
"front",
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
resolved: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
|
import { CampaignDocuments } from "@/components/campaign/CampaignDocuments";
|
||||||
import { DocumentPreview } from "@/components/documents/DocumentPreview";
|
import { DocumentPreview } from "@/components/documents/DocumentPreview";
|
||||||
import { DocumentRow } from "@/components/documents/DocumentRow";
|
|
||||||
import { SessionRow } from "@/components/documents/session/SessionRow";
|
|
||||||
import { Tab, TabbedLayout } from "@/components/layout/TabbedLayout";
|
import { Tab, TabbedLayout } from "@/components/layout/TabbedLayout";
|
||||||
import { Loader } from "@/components/Loader";
|
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 { Button } from "@headlessui/react";
|
|
||||||
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 = {
|
||||||
sessions: "Sessions",
|
sessions: { label: "Sessions", docType: "session" },
|
||||||
npcs: "NPCs",
|
secrets: { label: "Secrets", docType: "secret" },
|
||||||
locations: "Locations",
|
npcs: { label: "NPCs", docType: "npc" },
|
||||||
factions: "Factions",
|
locations: { label: "Locations", docType: "location" },
|
||||||
threads: "Threads",
|
threads: { label: "Threads", docType: "thread" },
|
||||||
|
fronts: { label: "Fronts", docType: "front" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const campaignSearchSchema = z.object({
|
const campaignSearchSchema = z.object({
|
||||||
@@ -41,7 +40,6 @@ function RouteComponent() {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
@@ -49,54 +47,11 @@ function RouteComponent() {
|
|||||||
const campaign = await pb
|
const campaign = await pb
|
||||||
.collection("campaigns")
|
.collection("campaigns")
|
||||||
.getOne(params.campaignId);
|
.getOne(params.campaignId);
|
||||||
// Fetch all documents for this campaign
|
|
||||||
const sessions = await pb.collection("documents").getFullList({
|
|
||||||
filter: `campaign = "${params.campaignId}" && type = 'session'`,
|
|
||||||
sort: "-created",
|
|
||||||
});
|
|
||||||
setSessions(sessions as Session[]);
|
|
||||||
setCampaign(campaign as Campaign);
|
setCampaign(campaign as Campaign);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [setCampaign, setSessions, setLoading]);
|
}, [setCampaign, setLoading]);
|
||||||
|
|
||||||
const createNewSession = useCallback(async () => {
|
|
||||||
if (campaign === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Check for a previous session
|
|
||||||
const prevSession = await pb
|
|
||||||
.collection("documents")
|
|
||||||
.getFirstListItem(`campaign = "${campaign.id}" && type = 'session'`, {
|
|
||||||
sort: "-created",
|
|
||||||
});
|
|
||||||
|
|
||||||
const newSession = await pb.collection("documents").create({
|
|
||||||
campaign: campaign.id,
|
|
||||||
type: "session",
|
|
||||||
data: {
|
|
||||||
strongStart: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// If any relations, then copy things over
|
|
||||||
if (prevSession) {
|
|
||||||
const prevRelations = await pb
|
|
||||||
.collection<Relationship>("relationships")
|
|
||||||
.getFullList({
|
|
||||||
filter: `primary = "${prevSession.id}"`,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const relation of prevRelations) {
|
|
||||||
await pb.collection("relationships").create({
|
|
||||||
primary: newSession.id,
|
|
||||||
type: relation.type,
|
|
||||||
secondary: relation.secondary,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [campaign]);
|
|
||||||
|
|
||||||
if (loading || campaign === null) {
|
if (loading || campaign === null) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
@@ -115,8 +70,9 @@ function RouteComponent() {
|
|||||||
← Back to campaigns
|
← Back to campaigns
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
tabs={Object.entries(CampaignTabs).map(([key, label]) => (
|
tabs={Object.entries(CampaignTabs).map(([key, { label }]) => (
|
||||||
<Tab
|
<Tab
|
||||||
|
key={key}
|
||||||
label={label}
|
label={label}
|
||||||
active={tab === key}
|
active={tab === key}
|
||||||
to={Route.to}
|
to={Route.to}
|
||||||
@@ -129,36 +85,10 @@ function RouteComponent() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
content={
|
content={
|
||||||
<div>
|
<CampaignDocuments
|
||||||
<div className="flex justify-between">
|
campaignId={campaign.id}
|
||||||
<h3 className="text-lg font-semibold mb-2 text-slate-200">
|
docType={CampaignTabs[tab].docType}
|
||||||
Sessions
|
/>
|
||||||
</h3>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
onClick={() => createNewSession()}
|
|
||||||
className="inline-flex items-center justify-center rounded bg-violet-600 hover:bg-violet-700 text-white px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-violet-400"
|
|
||||||
>
|
|
||||||
New Session
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{sessions && sessions.length > 0 ? (
|
|
||||||
<div>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{sessions.map((s: any) => (
|
|
||||||
<li key={s.id}>
|
|
||||||
<SessionRow session={s} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-slate-400">
|
|
||||||
No sessions found for this campaign.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
flyout={docId && <Flyout key={docId} docId={docId} />}
|
flyout={docId && <Flyout key={docId} docId={docId} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export const Route = createFileRoute(
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { documentId, relationshipType, childDocId } = useDocumentPath();
|
const path = useDocumentPath();
|
||||||
|
const documentId = path?.documentId;
|
||||||
|
const relationshipType = path?.relationshipType ?? null;
|
||||||
|
const childDocId = path?.childDocId ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentLoader documentId={documentId as DocumentId}>
|
<DocumentLoader documentId={documentId as DocumentId}>
|
||||||
|
|||||||
@@ -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