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)} +
  • + ))} +
+ + + +
+ +
+ + + + Add New + + {newItemForm(handleFormSubmit)} + + + +
+
+
+
+ ); +} 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 && ( + ( +
    { + e.preventDefault(); + handleAddSecret(); + onSubmit(); + }} + > + setNewSecret(e.target.value)} + disabled={adding} + /> + {error && ( +
    {error}
    + )} + +
    + )} + renderRow={(secret) => (
  • - ))} -
- ) : ( -
- No planned secrets for this session. -
- )} -
{ - e.preventDefault(); - handleAddSecret(); - }} - > - setNewSecret(e.target.value)} - disabled={adding} + )} /> - -
- {error &&
{error}
} + )} ); }