Compare commits

..

8 Commits

15 changed files with 288 additions and 125 deletions

100
package-lock.json generated
View File

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

View File

@@ -30,7 +30,6 @@
"zod": "^4.0.5"
},
"devDependencies": {
"@astrojs/ts-plugin": "^1.10.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/lodash": "^4.17.17",

View File

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

View File

@@ -30,6 +30,9 @@ export function DocumentView({
if (v.type === "ready") {
return v.value.secondary.length.toString();
}
if (v.type === "empty") {
return "0";
}
return "...";
});

View File

@@ -84,6 +84,7 @@ export const GenericNewDocumentForm = <T extends DocumentType>({
// 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}

View File

@@ -5,6 +5,9 @@ import {
} from "@/lib/types";
import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
import { docTypeForRelationshipType } from "@/lib/relationships";
import { useState } from "react";
import { DocumentSearchForm } from "../form/DocumentSearchForm";
import { identifierForDocType } from "@/lib/documents";
/**
* Renders a form for any document type depending on the relationship.
@@ -18,11 +21,42 @@ export const NewRelatedDocumentForm = ({
relationshipType: RelationshipType;
onCreate: (doc: AnyDocument) => Promise<void>;
}) => {
const [newOrExisting, setNewOrExisting] = useState<"new" | "existing">("new");
const docType = docTypeForRelationshipType(relationshipType);
return (
<div>
<div className="flex row gap-4">
<button
className={`${newOrExisting === "new" ? "font-bold" : "text-gray-400"}`}
onClick={() => setNewOrExisting("new")}
>
New
</button>
<button
className={`${newOrExisting === "existing" ? "font-bold" : "text-gray-400"}`}
onClick={() => setNewOrExisting("existing")}
>
Existing
</button>
</div>
{newOrExisting === "new" && (
<GenericNewDocumentForm
docType={docTypeForRelationshipType(relationshipType)}
docType={docType}
campaignId={campaignId}
onCreate={onCreate}
/>
)}
{newOrExisting === "existing" && (
// TODO: Make this into a form with a "Add" button so it's not instant
<DocumentSearchForm
campaignId={campaignId}
onSubmit={onCreate}
docType={docType}
searchField={identifierForDocType(docType)}
/>
)}
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,3 +23,15 @@ export const DocumentTypeLabePlural: Record<DocumentType, string> = {
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";
}
}

View File

@@ -1,8 +1,9 @@
import { type DocumentData, type DocumentType } from "./types";
export type FieldType = "shortText" | "longText" | "toggle";
export type FieldType = "identifier" | "shortText" | "longText" | "toggle";
export type ValueForFieldType<F extends FieldType> = {
identifier: string;
shortText: string;
longText: string;
toggle: boolean;
@@ -10,6 +11,7 @@ export type ValueForFieldType<F extends FieldType> = {
function defaultValue<F extends FieldType>(fieldType: F): ValueForFieldType<F> {
switch (fieldType) {
case "identifier":
case "shortText":
case "longText":
return "" as ValueForFieldType<F>;

View File

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