diff --git a/index.html b/index.html
index c021e9d..2a3c9d4 100644
--- a/index.html
+++ b/index.html
@@ -5,13 +5,10 @@
-
+
-
Create TanStack App - .
+ Dungeon Master's Companion
diff --git a/package-lock.json b/package-lock.json
index 2ae6485..3fd8d46 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,7 @@
"": {
"name": "lazy-dm",
"dependencies": {
+ "@headlessui/react": "^2.2.4",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-query": "^5.77.2",
"@tanstack/react-router": "^1.114.3",
@@ -874,6 +875,79 @@
"node": ">=18"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
+ "integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.9"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz",
+ "integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.0",
+ "@floating-ui/utils": "^0.2.9"
+ }
+ },
+ "node_modules/@floating-ui/react": {
+ "version": "0.26.28",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
+ "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.1.2",
+ "@floating-ui/utils": "^0.2.8",
+ "tabbable": "^6.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
+ "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
+ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
+ "license": "MIT"
+ },
+ "node_modules/@headlessui/react": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.4.tgz",
+ "integrity": "sha512-lz+OGcAH1dK93rgSMzXmm1qKOJkBUqZf1L4M8TWLNplftQD3IkoEDdUFNfAn4ylsN6WOTVtWaLmvmaHOUk1dTA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react": "^0.26.16",
+ "@react-aria/focus": "^3.20.2",
+ "@react-aria/interactions": "^3.25.0",
+ "@tanstack/react-virtual": "^3.13.9",
+ "use-sync-external-store": "^1.5.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -934,6 +1008,103 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@react-aria/focus": {
+ "version": "3.20.3",
+ "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.3.tgz",
+ "integrity": "sha512-rR5uZUMSY4xLHmpK/I8bP1V6vUNHFo33gTvrvNUsAKKqvMfa7R2nu5A6v97dr5g6tVH6xzpdkPsOJCWh90H2cw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@react-aria/interactions": "^3.25.1",
+ "@react-aria/utils": "^3.29.0",
+ "@react-types/shared": "^3.29.1",
+ "@swc/helpers": "^0.5.0",
+ "clsx": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/interactions": {
+ "version": "3.25.1",
+ "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.1.tgz",
+ "integrity": "sha512-ntLrlgqkmZupbbjekz3fE/n3eQH2vhncx8gUp0+N+GttKWevx7jos11JUBjnJwb1RSOPgRUFcrluOqBp0VgcfQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@react-aria/ssr": "^3.9.8",
+ "@react-aria/utils": "^3.29.0",
+ "@react-stately/flags": "^3.1.1",
+ "@react-types/shared": "^3.29.1",
+ "@swc/helpers": "^0.5.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/ssr": {
+ "version": "3.9.8",
+ "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.8.tgz",
+ "integrity": "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/utils": {
+ "version": "3.29.0",
+ "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.29.0.tgz",
+ "integrity": "sha512-jSOrZimCuT1iKNVlhjIxDkAhgF7HSp3pqyT6qjg/ZoA0wfqCi/okmrMPiWSAKBnkgX93N8GYTLT3CIEO6WZe9Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@react-aria/ssr": "^3.9.8",
+ "@react-stately/flags": "^3.1.1",
+ "@react-stately/utils": "^3.10.6",
+ "@react-types/shared": "^3.29.1",
+ "@swc/helpers": "^0.5.0",
+ "clsx": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-stately/flags": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.1.tgz",
+ "integrity": "sha512-XPR5gi5LfrPdhxZzdIlJDz/B5cBf63l4q6/AzNqVWFKgd0QqY5LvWJftXkklaIUpKSJkIKQb8dphuZXDtkWNqg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ }
+ },
+ "node_modules/@react-stately/utils": {
+ "version": "3.10.6",
+ "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.6.tgz",
+ "integrity": "sha512-O76ip4InfTTzAJrg8OaZxKU4vvjMDOpfA/PGNOytiXwBbkct2ZeZwaimJ8Bt9W1bj5VsZ81/o/tW4BacbdDOMA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-types/shared": {
+ "version": "3.29.1",
+ "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.29.1.tgz",
+ "integrity": "sha512-KtM+cDf2CXoUX439rfEhbnEdAgFZX20UP2A35ypNIawR7/PFFPjQDWyA2EnClCcW/dLWJDEPX2U8+EJff8xqmQ==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz",
@@ -1201,6 +1372,15 @@
"win32"
]
},
+ "node_modules/@swc/helpers": {
+ "version": "0.5.17",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
+ "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/@tailwindcss/node": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz",
@@ -1567,6 +1747,23 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.9",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.9.tgz",
+ "integrity": "sha512-SPWC8kwG/dWBf7Py7cfheAPOxuvIv4fFQ54PdmYbg7CpXfsKxkucak43Q0qKsxVthhUJQ1A7CIMAIplq4BjVwA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.9"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@tanstack/router-core": {
"version": "1.120.10",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.120.10.tgz",
@@ -1725,6 +1922,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.9",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.9.tgz",
+ "integrity": "sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@tanstack/virtual-file-routes": {
"version": "1.115.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.115.0.tgz",
@@ -3482,6 +3689,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
@@ -3679,6 +3892,12 @@
"node": ">=18"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/tsx": {
"version": "4.19.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz",
diff --git a/package.json b/package.json
index 788ec4f..a033320 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "vite --port 3000",
+ "dev": "mprocs \"npm run start\" \"pocketbase serve\"",
"start": "vite --port 3000",
"build": "vite build && tsc",
"serve": "vite preview",
@@ -13,6 +13,7 @@
"docker:build": "npm run docker:build:app && npm run docker:build:pocketbase"
},
"dependencies": {
+ "@headlessui/react": "^2.2.4",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-query": "^5.77.2",
"@tanstack/react-router": "^1.114.3",
diff --git a/src/components/DocumentList.tsx b/src/components/DocumentList.tsx
new file mode 100644
index 0000000..036ac6d
--- /dev/null
+++ b/src/components/DocumentList.tsx
@@ -0,0 +1,115 @@
+import type { Document } from "@/lib/types";
+import {
+ Dialog,
+ DialogPanel,
+ DialogTitle,
+ Transition,
+ TransitionChild,
+} from "@headlessui/react";
+import { Fragment, useState } from "react";
+
+type Props = {
+ title: React.ReactNode;
+ items: T[];
+ renderRow: (item: T) => React.ReactNode;
+ newItemForm: (onSubmit: () => void) => React.ReactNode;
+};
+
+/**
+ * DocumentList is a generic list component for displaying document items with a dialog for adding new items.
+ *
+ * @param title - The title displayed above the list (left-aligned)
+ * @param items - The array of document items to display
+ * @param renderRow - Function to render each row's content
+ * @param newItemForm - Function that renders a form for creating a new item; receives an onSubmit callback
+ */
+export function DocumentList({
+ title,
+ items,
+ renderRow,
+ newItemForm,
+}: Props) {
+ const [open, setOpen] = useState(false);
+
+ // Handles closing the dialog after form submission
+ const handleFormSubmit = (): void => {
+ setOpen(false);
+ };
+
+ return (
+
+
+
{title}
+
+
+
+ {items.map((item) => (
+ -
+ {renderRow(item)}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 4e100de..df93362 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -41,8 +41,13 @@ export type Secret = Document &
}
>;
+export const RelationshipType = {
+ Secrets: "secrets",
+ DiscoveredIn: "discoveredIn",
+} as const;
+
export type Relationship = RecordModel & {
primary: DocumentId;
secondary: DocumentId[];
- type: "plannedSecrets" | "discoveredIn";
+ type: (typeof RelationshipType)[keyof typeof RelationshipType];
};
diff --git a/src/routes/_authenticated/document.$documentId.tsx b/src/routes/_authenticated/document.$documentId.tsx
index 4c49a2a..ffe3a95 100644
--- a/src/routes/_authenticated/document.$documentId.tsx
+++ b/src/routes/_authenticated/document.$documentId.tsx
@@ -2,14 +2,15 @@ import { createFileRoute } from "@tanstack/react-router";
import { pb } from "@/lib/pocketbase";
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { useState } from "react";
-import type { Secret } from "@/lib/types";
+import { RelationshipType, type Secret } from "@/lib/types";
+import { DocumentList } from "@/components/DocumentList";
export const Route = createFileRoute("/_authenticated/document/$documentId")({
loader: async ({ params }) => {
const doc = await pb.collection("documents").getOne(params.documentId);
// Fetch the unique relationship where this document is the primary and type is "plannedSecrets"
const relationships = await pb.collection("relationships").getList(1, 1, {
- filter: `primary = "${params.documentId}" && type = "plannedSecrets"`,
+ filter: `primary = "${params.documentId}" && type = "${RelationshipType.Secrets}"`,
});
// Get all related secret document IDs from the secondary field
const secretIds =
@@ -31,7 +32,7 @@ function RouteComponent() {
const strongStart = session?.data?.session?.strongStart || "";
const [newSecret, setNewSecret] = useState("");
const [adding, setAdding] = useState(false);
- const [secretList, setSecretList] = useState(secrets);
+ const [secretList, setSecretList] = useState(secrets);
const [error, setError] = useState(null);
async function handleSaveStrongStart(newValue: string) {
@@ -63,7 +64,7 @@ function RouteComponent() {
});
// 2. Check for existing relationship
const existing = await pb.collection("relationships").getFullList({
- filter: `primary = "${session.id}" && type = "plannedSecrets"`,
+ filter: `primary = "${session.id}" && type = "${RelationshipType.Secrets}"`,
});
if (existing.length > 0) {
// Update existing relationship to add new secret to secondary array
@@ -75,7 +76,7 @@ function RouteComponent() {
await pb.collection("relationships").create({
primary: session.id,
secondary: [secretDoc.id],
- type: "plannedSecrets",
+ type: RelationshipType.Secrets,
});
}
setSecretList([...secretList, secretDoc]);
@@ -132,12 +133,40 @@ function RouteComponent() {
placeholder="Enter a strong start for this session..."
aria-label="Strong Start"
/>
-
- Planned Secrets
-
- {secretList && secretList.length > 0 ? (
-
- {secretList.map((secret: any) => (
+ {secretList && (
+ (
+
+ )}
+ renderRow={(secret) => (
-
- ))}
-
- ) : (
-
- No planned secrets for this session.
-
- )}
-
- {error && {error}
}
+ )}
);
}