Compare commits

...

37 Commits

Author SHA1 Message Date
bfd20ea8fb Fixes type errors in the DocumentSearchForm 2025-10-11 18:29:05 -07:00
907df26395 Support adding existing documents to a session 2025-10-11 18:06:22 -07:00
d1432d048f Formatting 2025-10-11 15:18:15 -07:00
e9d88fdce8 Fixes bug in loader when a relationship is empty 2025-10-11 15:12:22 -07:00
f197a3fabe Makes the reducer a bit more debuggable 2025-10-11 15:00:48 -07:00
f8aac31306 Adds sourcemaps 2025-10-11 13:59:55 -07:00
d44fe72ff1 Fixes manifest 2025-10-11 13:54:02 -07:00
64aaad69d7 Support not-present relationships 2025-10-11 13:47:58 -07:00
c0638e34a8 Uses generic forms everywhere, gets rid of most doc-specific stuff 2025-10-09 16:48:50 -07:00
8afe0a5345 Swap tsc and vite build so I can pass params to vite build 2025-09-24 18:00:04 -07:00
625bc508aa Cleanup 2025-09-24 17:58:35 -07:00
ab323798e9 Threads done with generic forms. 2025-09-24 15:52:02 -07:00
6979bc4b8f Removes some extra logging 2025-09-24 15:24:24 -07:00
c9d27bce75 Creates the generic new-document form 2025-09-24 15:24:07 -07:00
43afdc8684 Prototype of making the new threads via generic interfaces 2025-09-17 16:39:50 -07:00
1c26daa828 Adding UI for threads 2025-08-09 15:49:36 -07:00
135debdf7f Adds new campaign form. Adds fronts and thread types 2025-08-03 14:27:06 -07:00
2fbc2c853f Makes campaigns load all types of docs and then link to the docs 2025-08-03 12:50:52 -07:00
3310be9e9b Updates tabbed layouts for phones 2025-07-26 13:33:14 -07:00
c7083a9b56 Makes some document previews 2025-07-26 13:26:31 -07:00
4a109d152c Adds tabs to the campaign page, but they don't do much yet 2025-07-23 16:25:29 -07:00
6d5d0e03a0 Restructures the campaign page 2025-07-23 15:46:22 -07:00
4c2ebdc292 Adds markdown formatting. Layout and style improvements. 2025-07-23 15:37:44 -07:00
8533f63a22 Completes the three-panel layout 2025-07-21 20:50:18 -07:00
3390ecfb95 Finally gets the routing working in a somewhat reasonable way 2025-07-21 13:34:06 -07:00
b30999e907 Linting 2025-07-15 11:02:47 -07:00
2e9ea14507 Adds a title to the document page. 2025-07-15 11:00:37 -07:00
762306023b Removes Tanstack Query 2025-07-15 10:53:28 -07:00
8f96062058 Fixes bug with updating relationships when an item is added 2025-07-15 10:09:53 -07:00
258518d954 Uses the router to handle tab state 2025-07-14 17:13:05 -07:00
503c98c895 I think I have a working document cache solution that's actually pretty good. 2025-07-03 16:24:58 -07:00
db4ce36c27 Forms now update documents directly. 2025-07-02 17:36:45 -07:00
f27432ef05 Converts using full document state management 2025-07-02 17:18:08 -07:00
32c5c40466 Sets up global cache and uses it to fetch sessions 2025-07-02 14:46:13 -07:00
f8130f0ba9 Cleans up the new-doc forms. 2025-06-28 18:01:15 -07:00
6ce462a77d Fixes the copy on new sessions, some additional styling work 2025-06-28 17:48:56 -07:00
c00eb1d965 Changes all documents to have an explicit type 2025-06-27 22:02:46 -07:00
87 changed files with 2751 additions and 1904 deletions

153
README.md
View File

@@ -1,6 +1,8 @@
Welcome to your new TanStack app! # DM Companion App
# Getting Started ## Development
### Getting Started
To run this application: To run this application:
@@ -9,7 +11,7 @@ npm install
npm run start npm run start
``` ```
# Building For Production ### Building For Production
To build this application for production: To build this application for production:
@@ -17,7 +19,7 @@ To build this application for production:
npm run build npm run build
``` ```
## Testing ### Testing
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with: This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
@@ -25,17 +27,15 @@ This project uses [Vitest](https://vitest.dev/) for testing. You can run the tes
npm run test npm run test
``` ```
## Styling ### Styling
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling. This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
### Routing
## Routing
This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`. This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
### Adding A Route #### Adding A Route
To add a new route to your application just add another a new file in the `./src/routes` directory. To add a new route to your application just add another a new file in the `./src/routes` directory.
@@ -43,7 +43,7 @@ TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them. Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links #### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`. To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
@@ -61,15 +61,15 @@ This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent). More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout #### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `<Outlet />` component. In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `<Outlet />` component.
Here is an example layout that includes a header: Here is an example layout that includes a header:
```tsx ```tsx
import { Outlet, createRootRoute } from '@tanstack/react-router' import { Outlet, createRootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
@@ -86,129 +86,20 @@ export const Route = createRootRoute({
<TanStackRouterDevtools /> <TanStackRouterDevtools />
</> </>
), ),
}) });
``` ```
The `<TanStackRouterDevtools />` component is not required so you can remove it if you don't want it in your layout. The `<TanStackRouterDevtools />` component is not required so you can remove it if you don't want it in your layout.
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts). More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
### Data Fetching
## Data Fetching #### Pocketbase
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered. TODO
For example: ### State Management
```tsx
const peopleRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/people",
loader: async () => {
const response = await fetch("https://swapi.dev/api/people");
return response.json() as Promise<{
results: {
name: string;
}[];
}>;
},
component: () => {
const data = peopleRoute.useLoaderData();
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
);
},
});
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
### React-Query
React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze.
First add your dependencies:
```bash
npm install @tanstack/react-query @tanstack/react-query-devtools
```
Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`.
```tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// ...
const queryClient = new QueryClient();
// ...
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
```
You can also add TanStack Query Devtools to the root route (optional).
```tsx
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const rootRoute = createRootRoute({
component: () => (
<>
<Outlet />
<ReactQueryDevtools buttonPosition="top-right" />
<TanStackRouterDevtools />
</>
),
});
```
Now you can use `useQuery` to fetch your data.
```tsx
import { useQuery } from "@tanstack/react-query";
import "./App.css";
function App() {
const { data } = useQuery({
queryKey: ["people"],
queryFn: () =>
fetch("https://swapi.dev/api/people")
.then((res) => res.json())
.then((data) => data.results as { name: string }[]),
initialData: [],
});
return (
<div>
<ul>
{data.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
</div>
);
}
export default App;
```
You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).
## State Management
Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project. Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project.
@@ -280,11 +171,3 @@ We use the `Derived` class to create a new store that is derived from another st
Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook. Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook.
You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest). You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest).
# Demo files
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).

View File

@@ -11,7 +11,7 @@
<title>Dungeon Master's Companion</title> <title>Dungeon Master's Companion</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app" class="flex flex-col h-full w-full"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

74
package-lock.json generated
View File

@@ -9,16 +9,18 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4", "@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
"@headlessui/react": "^2.2.4", "@headlessui/react": "^2.2.4",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@tanstack/react-query": "^5.79.0",
"@tanstack/react-query-devtools": "^5.79.0", "@tanstack/react-query-devtools": "^5.79.0",
"@tanstack/react-router": "^1.114.3", "@tanstack/react-router": "^1.114.3",
"@tanstack/react-router-devtools": "^1.114.3", "@tanstack/react-router-devtools": "^1.114.3",
"@tanstack/router-plugin": "^1.114.3", "@tanstack/router-plugin": "^1.114.3",
"dompurify": "^3.2.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "^16.1.1",
"pocketbase": "^0.26.0", "pocketbase": "^0.26.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwindcss": "^4.0.6" "tailwindcss": "^4.0.6",
"zod": "^4.0.5"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
@@ -1675,6 +1677,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.79.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.79.0.tgz",
"integrity": "sha512-s+epTqqLM0/TbJzMAK7OEhZIzh63P9sWz5HEFc5XHL4FvKQXQkcjI8F3nee+H/xVVn7mrP610nVXwOytTSYd0w==", "integrity": "sha512-s+epTqqLM0/TbJzMAK7OEhZIzh63P9sWz5HEFc5XHL4FvKQXQkcjI8F3nee+H/xVVn7mrP610nVXwOytTSYd0w==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
@@ -1695,6 +1698,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.79.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.79.0.tgz",
"integrity": "sha512-DjC4JIYZnYzxaTzbg3osOU63VNLP67dOrWet2cZvXgmgwAXNxfS52AMq86M5++ILuzW+BqTUEVMTjhrZ7/XBuA==", "integrity": "sha512-DjC4JIYZnYzxaTzbg3osOU63VNLP67dOrWet2cZvXgmgwAXNxfS52AMq86M5++ILuzW+BqTUEVMTjhrZ7/XBuA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.79.0" "@tanstack/query-core": "5.79.0"
}, },
@@ -1878,6 +1882,15 @@
} }
} }
}, },
"node_modules/@tanstack/router-generator/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@tanstack/router-plugin": { "node_modules/@tanstack/router-plugin": {
"version": "1.120.10", "version": "1.120.10",
"resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.120.10.tgz", "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.120.10.tgz",
@@ -1934,6 +1947,15 @@
} }
} }
}, },
"node_modules/@tanstack/router-plugin/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@tanstack/router-utils": { "node_modules/@tanstack/router-utils": {
"version": "1.115.0", "version": "1.115.0",
"resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.115.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.115.0.tgz",
@@ -2115,6 +2137,13 @@
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz",
@@ -2658,6 +2687,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.157", "version": "1.5.157",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz",
@@ -3310,6 +3348,18 @@
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
} }
}, },
"node_modules/marked": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.1.1.tgz",
"integrity": "sha512-ij/2lXfCRT71L6u0M29tJPhP0bM5shLL3u5BePhFwPELj2blMJ6GDtD7PfJhRLhJ/c2UwrK17ySVcDzy2YHjHQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minipass": { "node_modules/minipass": {
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@@ -4391,10 +4441,24 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.28", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.28.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.5.tgz",
"integrity": "sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q==", "integrity": "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View File

@@ -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 .",
@@ -16,16 +16,18 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4", "@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
"@headlessui/react": "^2.2.4", "@headlessui/react": "^2.2.4",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@tanstack/react-query": "^5.79.0",
"@tanstack/react-query-devtools": "^5.79.0", "@tanstack/react-query-devtools": "^5.79.0",
"@tanstack/react-router": "^1.114.3", "@tanstack/react-router": "^1.114.3",
"@tanstack/react-router-devtools": "^1.114.3", "@tanstack/react-router-devtools": "^1.114.3",
"@tanstack/router-plugin": "^1.114.3", "@tanstack/router-plugin": "^1.114.3",
"dompurify": "^3.2.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "^16.1.1",
"pocketbase": "^0.26.0", "pocketbase": "^0.26.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwindcss": "^4.0.6" "tailwindcss": "^4.0.6",
"zod": "^4.0.5"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",

View File

@@ -0,0 +1,46 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": [
"CREATE INDEX `idx_gxNj5R3hxv` ON `documents` (`type`)"
]
}, collection)
// add 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)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": []
}, collection)
// remove field
collection.fields.removeById("select2363381545")
return app.save(collection)
})

View File

@@ -0,0 +1,25 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": [
"CREATE INDEX `idx_gxNj5R3hxv` ON `documents` (`type`)",
"CREATE INDEX `idx_KtpMErDe1C` ON `documents` (`campaign`)"
]
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": [
"CREATE INDEX `idx_gxNj5R3hxv` ON `documents` (`type`)"
]
}, collection)
return app.save(collection)
})

View File

@@ -0,0 +1,54 @@
const DocType = [
"location",
"monster",
"npc",
"scene",
"secret",
"session",
"treasure",
];
function parseJsonB(data) {
if (typeof data === "string") {
return JSON.parse(data);
} else if (data instanceof Array) {
return JSON.parse(String.fromCharCode.apply(String, data));
}
throw new Error("Unsupported data type for JSON parsing");
}
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
let documents = app.findAllRecords("documents");
console.log("Records to parse: ", documents.length);
documents: for (const doc of documents) {
if (!doc) continue;
let data = parseJsonB(doc.get("data"));
if (data[""]) {
data = data[""];
}
for (const t of DocType) {
if (data[t]) {
doc.set("type", t);
doc.set("data", data[t]);
app.save(doc);
continue documents;
}
}
}
},
(app) => {
// add down queries...
let documents = app.findAllRecords("documents");
for (const doc of documents) {
if (!doc) continue;
doc.set("data", { [doc.get("type")]: doc.get("data") });
app.save(doc);
}
},
);

View File

@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_617371094")
// update field
collection.fields.addAt(0, new Field({
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": true,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_617371094")
// update field
collection.fields.addAt(0, new Field({
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
}))
return app.save(collection)
})

View File

@@ -0,0 +1,34 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update field
collection.fields.addAt(2, new Field({
"hidden": false,
"id": "json2918445923",
"maxSize": 0,
"name": "data",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update field
collection.fields.addAt(2, new Field({
"hidden": false,
"id": "json2918445923",
"maxSize": 0,
"name": "data",
"presentable": true,
"required": false,
"system": false,
"type": "json"
}))
return app.save(collection)
})

View File

@@ -0,0 +1,40 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_617371094")
// update field
collection.fields.addAt(1, new Field({
"cascadeDelete": true,
"collectionId": "pbc_3332084752",
"hidden": false,
"id": "relation390457990",
"maxSelect": 1,
"minSelect": 0,
"name": "primary",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_617371094")
// update field
collection.fields.addAt(1, new Field({
"cascadeDelete": false,
"collectionId": "pbc_3332084752",
"hidden": false,
"id": "relation390457990",
"maxSelect": 1,
"minSelect": 0,
"name": "primary",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
})

View 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)
})

View File

@@ -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",

View File

@@ -63,7 +63,7 @@ export function AutoSaveTextarea({
<textarea <textarea
value={value} value={value}
onChange={handleChange} onChange={handleChange}
className={`w-full min-h-[6em] field-sizing-content 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 ${flash ? "ring-2 ring-emerald-400 border-emerald-400 bg-emerald-950" : ""} ${className}`} className={`w-full min-h-[10em] field-sizing-content 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 ${flash ? "ring-2 ring-emerald-400 border-emerald-400 bg-emerald-950" : ""} ${className}`}
{...props} {...props}
/> />
) : ( ) : (

View File

@@ -1,16 +1,15 @@
import type { Document, DocumentId } from "@/lib/types"; import * as Icons from "@/components/Icons.tsx";
import type { AnyDocument, DocumentId } from "@/lib/types";
import { import {
Dialog, Dialog,
DialogPanel, DialogPanel,
DialogTitle,
Transition, Transition,
TransitionChild, TransitionChild,
} from "@headlessui/react"; } from "@headlessui/react";
import { Fragment, useCallback, useState } from "react"; import { Fragment, useCallback, useState } from "react";
import * as Icons from "@/components/Icons.tsx";
type Props<T extends Document> = { 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;
@@ -26,7 +25,7 @@ type Props<T extends Document> = {
* @param renderRow - Function to render each row's content * @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 * @param newItemForm - Function that renders a form for creating a new item; receives an onSubmit callback
*/ */
export function DocumentList<T extends Document>({ export function DocumentList<T extends AnyDocument>({
title, title,
error, error,
items, items,
@@ -48,9 +47,9 @@ export function DocumentList<T extends Document>({
}; };
return ( return (
<section className="w-full max-w-2xl mx-auto"> <section className="w-full">
<div className="flex items-center justify-between my-4"> <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
@@ -75,13 +74,13 @@ export function DocumentList<T extends Document>({
{error && ( {error && (
<div className="bg-red-900 rounded p-4 text-slate-100">{error}</div> <div className="bg-red-900 rounded p-4 text-slate-100">{error}</div>
)} )}
<ul className="space-y-2"> <ul className="flex flex-col space-y-2">
{items.map((item) => ( {items.map((item) => (
<li <li
key={item.id} key={item.id}
className="bg-slate-800 rounded p-4 text-slate-100 flex flex-row justify-between items-center" className="p-2 m-0 border-b-1 last:border-0 border-slate-700 flex flex-row justify-between items-center"
> >
<div>{renderRow(item)}</div> {renderRow(item)}
{isEditing && ( {isEditing && (
<div> <div>
@@ -122,9 +121,6 @@ export function DocumentList<T extends Document>({
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel className="bg-slate-900 rounded-lg shadow-xl max-w-md w-full p-6 border border-slate-700 relative"> <DialogPanel className="bg-slate-900 rounded-lg shadow-xl max-w-md w-full p-6 border border-slate-700 relative">
<DialogTitle className="text-lg font-semibold text-slate-100 mb-4">
Add New
</DialogTitle>
{newItemForm(handleFormSubmit)} {newItemForm(handleFormSubmit)}
<button <button
type="button" type="button"

View File

@@ -0,0 +1,35 @@
import * as Icons from "./Icons";
import { useState, Children } from "react";
export function EditToggle({ children }: React.PropsWithChildren) {
const [isEditing, setIsEditing] = useState(false);
const editChildren = (
Children.toArray(children) as React.ReactElement[]
).filter((c) => c.type === Editing);
const nonEditChildren = (
Children.toArray(children) as React.ReactElement[]
).filter((c) => c.type !== Editing);
return (
<div className="relative">
<div className="absolute right-0 top-0 z-50">
<button
type="button"
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
aria-label={isEditing ? "Exit edit mode" : "Enter edit mode"}
onClick={() => setIsEditing(!isEditing)}
>
<Icons.Edit />
</button>
</div>
{isEditing ? editChildren : nonEditChildren}
</div>
);
}
export const Editing = ({ children }: React.PropsWithChildren) => (
<>{children}</>
);
export const NotEditing = ({ children }: React.PropsWithChildren) => (
<>{children}</>
);

View File

@@ -0,0 +1,22 @@
import DOMPurify from "dompurify";
import * as Marked from "marked";
export type Props = {
value: string;
};
function formatText(text: React.ReactNode): { __html: string } {
if (typeof text === "string") {
return {
__html: DOMPurify.sanitize(
Marked.parse(text, { async: false }) as string,
),
};
}
throw new Error("Attempted to safe-render a non-string.");
}
export function FormattedText({ children }: React.PropsWithChildren) {
return <div dangerouslySetInnerHTML={formatText(children)}></div>;
}

View File

@@ -1,14 +1,14 @@
import { DocumentList } from "@/components/DocumentList"; import { DocumentList } from "@/components/DocumentList";
import { useDocumentCache, useDocument } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { displayName } from "@/lib/relationships"; import { displayName } from "@/lib/relationships";
import type { import type {
AnyDocument, AnyDocument,
Document, DocumentId,
Relationship, Relationship,
RelationshipType, RelationshipType,
} from "@/lib/types"; } from "@/lib/types";
import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react";
import { useEffect, useState } from "react";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { DocumentRow } from "./documents/DocumentRow"; import { DocumentRow } from "./documents/DocumentRow";
import { NewRelatedDocumentForm } from "./documents/NewRelatedDocumentForm"; import { NewRelatedDocumentForm } from "./documents/NewRelatedDocumentForm";
@@ -26,64 +26,60 @@ export function RelationshipList({
root, root,
relationshipType, relationshipType,
}: RelationshipListProps) { }: RelationshipListProps) {
const [items, setItems] = useState<Document[]>([]); const [_loading, setLoading] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient(); const { docResult, dispatch } = useDocument(root.id);
const { cache } = useDocumentCache();
useEffect(() => { if (docResult.type !== "ready") {
async function fetchItems() { return <Loader />;
const { items } = await queryClient.fetchQuery({ }
queryKey: ["relationship", relationshipType, root.id],
staleTime: 5 * 60 * 1000, // 5 mintues
queryFn: async () => {
setLoading(true);
const relationship: Relationship = await pb
.collection("relationships")
.getFirstListItem(
`primary = "${root.id}" && type = "${relationshipType}"`,
{
expand: "secondary",
},
);
setLoading(false); const relationshipResult = docResult.value.relationships[relationshipType];
return { items: relationship.expand?.secondary ?? [] }; const relationship =
}, relationshipResult?.type === "ready" ? relationshipResult.value : null;
});
setItems(items);
}
fetchItems(); const itemIds =
}); relationshipResult?.type === "ready"
? relationshipResult.value.secondary
: [];
// Handles creation of a new document and adds it to the relationship const items = itemIds
const handleCreate = async (doc: Document) => { .map((id) => cache.documents[id])
.filter((d) => d && d.type === "ready")
.map((d) => d.value.doc);
const handleCreate = async (doc: AnyDocument) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// Check for existing relationship // Check for existing relationship
const existing = await pb.collection("relationships").getFullList({ if (relationship) {
filter: `primary = "${root.id}" && type = "${relationshipType}"`, const updatedRelationship: Relationship = await pb
}); .collection("relationships")
if (existing.length > 0) { .update(relationship.id, {
console.debug("Adding to existing relationship"); "+secondary": doc.id,
await pb.collection("relationships").update(existing[0].id, { });
"+secondary": doc.id, dispatch({
type: "setRelationship",
docId: root.id,
relationship: updatedRelationship,
}); });
} else { } else {
console.debug("Creating new relationship"); const updatedRelationship: Relationship = await pb
await pb.collection("relationships").create({ .collection("relationships")
primary: root.id, .create({
secondary: [doc.id], primary: root.id,
type: relationshipType, secondary: [doc.id],
type: relationshipType,
});
dispatch({
type: "setRelationship",
docId: root.id,
relationship: updatedRelationship,
}); });
} }
queryClient.invalidateQueries({
queryKey: ["relationship", relationshipType, root.id],
});
setItems((prev) => [...prev, doc]);
} catch (e: any) { } catch (e: any) {
setError(e?.message || "Failed to add document to relationship."); setError(e?.message || "Failed to add document to relationship.");
} finally { } finally {
@@ -91,22 +87,44 @@ export function RelationshipList({
} }
}; };
if (loading) { const handleRemove = async (documentId: DocumentId) => {
<Loader />; setLoading(true);
} setError(null);
try {
if (relationship) {
const updatedRelationship: Relationship = await pb
.collection("relationships")
.update(relationship.id, {
"secondary-": documentId,
});
dispatch({
type: "setRelationship",
docId: root.id,
relationship: updatedRelationship,
});
}
} catch (e: any) {
setError(
e?.message || `Failed to remove document from ${relationshipType}.`,
);
} finally {
setLoading(false);
}
};
return ( return (
<DocumentList <DocumentList
title={displayName(relationshipType)} title={displayName(relationshipType)}
items={items} items={items}
error={error} error={error}
renderRow={(document) => <DocumentRow document={document} />} renderRow={(document) => <DocumentRow document={document} root={root} />}
removeItem={() => {}} removeItem={handleRemove}
newItemForm={(onSubmit) => ( newItemForm={(onSubmit) => (
<NewRelatedDocumentForm <NewRelatedDocumentForm
campaignId={root.campaign} campaignId={root.campaign}
relationshipType={relationshipType} relationshipType={relationshipType}
onCreate={async (doc: Document) => { onCreate={async (doc: AnyDocument) => {
await handleCreate(doc); await handleCreate(doc);
onSubmit(); onSubmit();
}} }}

View 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}
/>
);
};

View File

@@ -0,0 +1,27 @@
import { Link } from "@tanstack/react-router";
import { FormattedText } from "../FormattedText";
import type { DocumentId } from "@/lib/types";
export type Props = {
id: DocumentId;
title?: string;
description?: string;
};
export const BasicPreview = ({ id, title, description }: Props) => {
return (
<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>}
{description && <FormattedText>{description}</FormattedText>}
</div>
);
};

View File

@@ -1,9 +1,10 @@
import type { AnyDocument } from "@/lib/types"; import type { AnyDocument } from "@/lib/types";
import { Link } from "@tanstack/react-router"; import { FormattedText } from "../FormattedText";
import { DocumentLink } from "./DocumentLink";
export type Props = { export type Props = {
doc: AnyDocument; doc: AnyDocument;
title: string; title?: string;
description?: string; description?: string;
}; };
@@ -12,15 +13,14 @@ export type Props = {
*/ */
export const BasicRow = ({ doc, title, description }: Props) => { export const BasicRow = ({ doc, title, description }: Props) => {
return ( return (
<li> <div>
<Link <DocumentLink
to="/document/$documentId" childDocId={doc.id}
params={{ documentId: doc.id }} className="!no-underline text-slate-100 hover:underline hover:text-violet-400"
className="text-lg"
> >
<h4>{title}</h4> {title && <h4 className="font-bold">{title}</h4>}
</Link> {description && <FormattedText>{description}</FormattedText>}
{description && <p>{description}</p>} </DocumentLink>
</li> </div>
); );
}; };

View File

@@ -1,56 +0,0 @@
import {
isLocation,
isMonster,
isNpc,
isScene,
isSecret,
isSession,
isTreasure,
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";
function assertUnreachable(_x: never): never {
throw new Error("DocumentForm switch is not exhaustive");
}
/**
* Renders a form for any document type depending on the relationship.
*/
export const DocumentEditForm = ({ document }: { document: AnyDocument }) => {
if (isLocation(document)) {
return <LocationEditForm location={document} />;
}
if (isMonster(document)) {
return <MonsterEditForm monster={document} />;
}
if (isNpc(document)) {
return <NpcEditForm npc={document} />;
}
if (isScene(document)) {
return <SceneEditForm scene={document} />;
}
if (isSecret(document)) {
return <SecretEditForm secret={document} />;
}
if (isSession(document)) {
return <SessionEditForm session={document} />;
}
if (isTreasure(document)) {
return <TreasureEditForm treasure={document} />;
}
return assertUnreachable(document);
};

View File

@@ -0,0 +1,53 @@
import { makeDocumentPath } from "@/lib/documentPath";
import type { DocumentId } from "@/lib/types";
import { Link } from "@tanstack/react-router";
export type Props = React.PropsWithChildren<{
childDocId: DocumentId;
className?: string;
}>;
export function DocumentLink({ childDocId, className, children }: Props) {
// 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 (
<Link to={to} className={className}>
{children}
</Link>
);
}

View File

@@ -0,0 +1,86 @@
// Shows a preview of a document with it's relationships.
import { makeDocumentPath } from "@/lib/documentPath";
import { relationshipsForDocument } from "@/lib/relationships";
import { type AnyDocument } from "@/lib/types";
import { Link } from "@tanstack/react-router";
import { Editing, EditToggle, NotEditing } from "../EditToggle";
import { BasicPreview } from "./BasicPreview";
import { GenericEditForm } from "./GenericEditForm";
export const DocumentPreview = ({ doc }: { doc: AnyDocument }) => {
const relationships = relationshipsForDocument(doc);
return (
<div>
<EditToggle>
<Editing>
<GenericEditForm doc={doc} />
</Editing>
<NotEditing>
<ShowDocument doc={doc} />
</NotEditing>
</EditToggle>
<ul>
{relationships.map((relType) => (
<li>
<Link to={makeDocumentPath(doc.id, relType)}>{relType}</Link>
</li>
))}
</ul>
</div>
);
};
const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
switch (doc.type) {
case "front":
return (
<BasicPreview
id={doc.id}
title={doc.data.name}
description={doc.data.description}
/>
);
case "location":
return (
<BasicPreview
id={doc.id}
title={doc.data.name}
description={doc.data.description}
/>
);
case "monster":
return <BasicPreview id={doc.id} title={doc.data.name} />;
case "npc":
return (
<BasicPreview
id={doc.id}
title={doc.data.name}
description={doc.data.description}
/>
);
case "session":
return (
<BasicPreview
id={doc.id}
title={doc.data.name ?? doc.created}
description={doc.data.strongStart}
/>
);
case "secret":
return <BasicPreview id={doc.id} title={doc.data.text} />;
case "scene":
return <BasicPreview id={doc.id} description={doc.data.text} />;
case "thread":
return <BasicPreview id={doc.id} title={doc.data.text} />;
case "treasure":
return <BasicPreview id={doc.id} title={doc.data.text} />;
}
};

View File

@@ -1,64 +0,0 @@
// DocumentRow.tsx
// Generic row component for displaying any document type.
import {
isLocation,
isMonster,
isNpc,
isScene,
isSecret,
isSession,
isTreasure,
type Document,
} from "@/lib/types";
import { LocationPrintRow } from "./location/LocationPrintRow";
import { MonsterPrintRow } from "./monsters/MonsterPrintRow";
import { TreasurePrintRow } from "./treasure/TreasurePrintRow";
import { SecretPrintRow } from "./secret/SecretPrintRow";
import { NpcPrintRow } from "./npc/NpcPrintRow";
import { ScenePrintRow } from "./scene/ScenePrintRow";
import { SessionPrintRow } from "./session/SessionPrintRow";
/**
* 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: Document }) => {
if (isLocation(document)) {
return <LocationPrintRow location={document} />;
}
if (isMonster(document)) {
return <MonsterPrintRow monster={document} />;
}
if (isNpc(document)) {
return <NpcPrintRow npc={document} />;
}
if (isSession(document)) {
return <SessionPrintRow session={document} />;
}
if (isSecret(document)) {
return <SecretPrintRow secret={document} />;
}
if (isScene(document)) {
return <ScenePrintRow scene={document} />;
}
if (isTreasure(document)) {
return <TreasurePrintRow treasure={document} />;
}
// Fallback: show ID and creation time
return (
<div>
<div className="font-semibold text-lg text-slate-300">
Unrecognized Document
</div>
<div className="text-slate-400 text-sm">ID: {document.id}</div>
<div className="text-slate-400 text-sm">Created: {document.created}</div>
</div>
);
};

View File

@@ -1,17 +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 { import { type AnyDocument } from "@/lib/types";
isLocation,
isMonster,
isNpc,
isScene,
isSecret,
isSession,
isTreasure,
type Document,
type Session,
} from "@/lib/types";
import { BasicRow } from "./BasicRow"; import { BasicRow } from "./BasicRow";
import { TreasureToggleRow } from "./treasure/TreasureToggleRow"; import { TreasureToggleRow } from "./treasure/TreasureToggleRow";
@@ -21,65 +11,61 @@ import { TreasureToggleRow } from "./treasure/TreasureToggleRow";
*/ */
export const DocumentRow = ({ export const DocumentRow = ({
document, document,
session, root,
}: { }: {
document: Document; document: AnyDocument;
session?: Session; root?: AnyDocument;
}) => { }) => {
if (isLocation(document)) { switch (document.type) {
return ( case "front":
<BasicRow return (
doc={document} <BasicRow
title={document.data.location.name} doc={document}
description={document.data.location.description} title={document.data.name}
/> description={document.data.description}
); />
} );
if (isMonster(document)) { case "location":
return <BasicRow doc={document} title={document.data.monster.name} />; return (
} <BasicRow
doc={document}
title={document.data.name}
description={document.data.description}
/>
);
if (isNpc(document)) { case "monster":
return ( return <BasicRow doc={document} title={document.data.name} />;
<BasicRow
doc={document}
title={document.data.npc.name}
description={document.data.npc.description}
/>
);
}
if (isSession(document)) { case "npc":
return ( return (
<BasicRow <BasicRow
doc={document} doc={document}
title={document.created} title={document.data.name}
description={document.data.session.strongStart} description={document.data.description}
/> />
); );
}
if (isSecret(document)) { case "session":
return <SecretToggleRow secret={document} session={session} />; return (
} <BasicRow
doc={document}
title={document.data.name || document.created}
description={document.data.strongStart}
/>
);
if (isScene(document)) { case "secret":
return <BasicRow doc={document} title={document.data.scene.text} />; return <SecretToggleRow secret={document} root={root} />;
}
if (isTreasure(document)) { case "scene":
return <TreasureToggleRow treasure={document} session={session} />; return <BasicRow doc={document} description={document.data.text} />;
}
// Fallback: show ID and creation time case "thread":
return ( return <BasicRow doc={document} description={document.data.text} />;
<div>
<div className="font-semibold text-lg text-slate-300"> case "treasure":
Unrecognized Document return <TreasureToggleRow treasure={document} root={root} />;
</div> }
<div className="text-slate-400 text-sm">ID: {document.id}</div>
<div className="text-slate-400 text-sm">Created: {document.created}</div>
</div>
);
}; };

View File

@@ -0,0 +1,28 @@
import { type AnyDocument } from "@/lib/types";
import { FormattedDate } from "../FormattedDate";
/**
* Renders the document title to go at the top a document page.
*/
export const DocumentTitle = ({ doc }: { doc: AnyDocument }) => {
return (
<h1 className="text-2xl font-bold">
<TitleText doc={doc} />
</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:
// TODO: Put in proper names for other document types
return doc.type;
}
};

View File

@@ -0,0 +1,112 @@
import { useDocument } from "@/context/document/hooks";
import { displayName, relationshipsForDocument } from "@/lib/relationships";
import { RelationshipType, type DocumentId } from "@/lib/types";
import { Link } from "@tanstack/react-router";
import _ from "lodash";
import { Tab, TabbedLayout } from "../layout/TabbedLayout";
import { Loader } from "../Loader";
import { DocumentPreview } from "./DocumentPreview";
import { DocumentTitle } from "./DocumentTitle";
import { GenericEditForm } from "./GenericEditForm";
import { RelatedDocumentList } from "./RelatedDocumentList";
export function DocumentView({
documentId,
relationshipType,
childDocId,
}: {
documentId: DocumentId;
relationshipType: RelationshipType | null;
childDocId: DocumentId | null;
}) {
const { docResult } = useDocument(documentId);
if (docResult?.type !== "ready") {
return <Loader />;
}
const doc = docResult.value.doc;
const relationshipCounts = _.mapValues(docResult.value.relationships, (v) => {
if (v.type === "ready") {
return v.value.secondary.length.toString();
}
if (v.type === "empty") {
return "0";
}
return "...";
});
const relationshipList = relationshipsForDocument(doc);
return (
<TabbedLayout
navigation={
<>
<Link
to="/campaigns/$campaignId"
params={{ campaignId: doc.campaign }}
search={{ tab: "sessions" }}
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
>
Back to campaign
</Link>
{/* Print link isn't currently working */}
{/* <Link */}
{/* to="/document/$documentId/print" */}
{/* params={{ documentId: doc.id }} */}
{/* className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors" */}
{/* > */}
{/* Print */}
{/* </Link> */}
</>
}
title={<DocumentTitle doc={doc} />}
tabs={[
<Tab
to="/document/$documentId"
key="attributes"
params={{
documentId,
}}
label="Attributes"
active={relationshipType === null}
/>,
...relationshipList.map((relationshipEntry) => (
<Tab
to="/document/$documentId/$relationshipType"
key={relationshipEntry}
params={{
documentId,
relationshipType: relationshipEntry,
}}
label={`${displayName(relationshipEntry)} (${relationshipCounts[relationshipEntry] ?? 0})`}
active={relationshipEntry === relationshipType}
/>
)),
]}
content={
relationshipType === null ? (
<GenericEditForm doc={doc} />
) : (
<RelatedDocumentList
documentId={doc.id}
relationshipType={relationshipType}
/>
)
}
flyout={childDocId && <Flyout key={childDocId} docId={childDocId} />}
/>
);
}
function Flyout({ docId }: { docId: DocumentId }) {
const { docResult } = useDocument(docId);
if (docResult?.type !== "ready") {
return <Loader />;
}
const doc = docResult.value.doc;
return <DocumentPreview doc={doc} />;
}

View 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}
/>
);
}
};

View 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}
/>
);
}
};

View 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}
/>
);
}
};

View File

@@ -1,14 +1,13 @@
import { RelationshipType, type CampaignId, type Document } from "@/lib/types"; import {
import { NewLocationForm } from "./location/NewLocationForm"; RelationshipType,
import { NewMonsterForm } from "./monsters/NewMonsterForm"; type CampaignId,
import { NewNpcForm } from "./npc/NewNpcForm"; type AnyDocument,
import { NewSceneForm } from "./scene/NewSceneForm"; } from "@/lib/types";
import { NewSecretForm } from "./secret/NewSecretForm"; import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
import { NewTreasureForm } from "./treasure/NewTreasureForm"; import { docTypeForRelationshipType } from "@/lib/relationships";
import { useState } from "react";
function assertUnreachable(_x: never): never { import { DocumentSearchForm } from "../form/DocumentSearchForm";
throw new Error("DocumentForm switch is not exhaustive"); import { identifierForDocType } from "@/lib/documents";
}
/** /**
* Renders a form for any document type depending on the relationship. * Renders a form for any document type depending on the relationship.
@@ -20,24 +19,44 @@ export const NewRelatedDocumentForm = ({
}: { }: {
campaignId: CampaignId; campaignId: CampaignId;
relationshipType: RelationshipType; relationshipType: RelationshipType;
onCreate: (document: Document) => 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} />;
case RelationshipType.Monsters:
return <NewMonsterForm campaign={campaignId} onCreate={onCreate} />;
case RelationshipType.Npcs:
return <NewNpcForm campaign={campaignId} onCreate={onCreate} />;
case RelationshipType.Secrets:
return <NewSecretForm campaign={campaignId} onCreate={onCreate} />;
case RelationshipType.Treasures:
return <NewTreasureForm campaign={campaignId} onCreate={onCreate} />;
case RelationshipType.Scenes:
return <NewSceneForm campaign={campaignId} onCreate={onCreate} />;
case RelationshipType.DiscoveredIn:
return "Form not supported here";
}
return assertUnreachable(relationshipType); const docType = docTypeForRelationshipType(relationshipType);
return (
<div>
<div className="flex row gap-4">
<button
className={`${newOrExisting === "new" ? "font-bold" : "text-gray-400"}`}
onClick={() => setNewOrExisting("new")}
>
New
</button>
<button
className={`${newOrExisting === "existing" ? "font-bold" : "text-gray-400"}`}
onClick={() => setNewOrExisting("existing")}
>
Existing
</button>
</div>
{newOrExisting === "new" && (
<GenericNewDocumentForm
docType={docType}
campaignId={campaignId}
onCreate={onCreate}
/>
)}
{newOrExisting === "existing" && (
// TODO: Make this into a form with a "Add" button so it's not instant
<DocumentSearchForm
campaignId={campaignId}
onSubmit={onCreate}
docType={docType}
searchField={identifierForDocType(docType)}
/>
)}
</div>
);
}; };

View File

@@ -0,0 +1,27 @@
import { useDocument } from "@/context/document/hooks";
import type { DocumentId, RelationshipType } from "@/lib/types";
import { Loader } from "../Loader";
import { RelationshipList } from "../RelationshipList";
export type Props = {
documentId: DocumentId;
relationshipType: RelationshipType;
};
export function RelatedDocumentList({ documentId, relationshipType }: Props) {
const { docResult } = useDocument(documentId);
if (docResult?.type !== "ready") {
return <Loader />;
}
const doc = docResult.value.doc;
return (
<RelationshipList
key={relationshipType}
root={doc}
relationshipType={relationshipType}
/>
);
}

View File

@@ -1,46 +0,0 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase";
import type { Location } from "@/lib/types";
/**
* Renders an editable location form
*/
export const LocationEditForm = ({ location }: { location: Location }) => {
async function saveLocationName(name: string) {
await pb.collection("documents").update(location.id, {
data: {
...location.data,
location: {
...location.data.location,
name,
},
},
});
}
async function saveLocationDescription(description: string) {
await pb.collection("documents").update(location.id, {
data: {
...location.data,
location: {
...location.data.location,
description,
},
},
});
}
return (
<div className="">
<AutoSaveTextarea
multiline={false}
value={location.data.location.name}
onSave={saveLocationName}
/>
<AutoSaveTextarea
value={location.data.location.description}
onSave={saveLocationDescription}
/>
</div>
);
};

View File

@@ -1,13 +0,0 @@
import type { Location } from "@/lib/types";
/**
* Renders an print-friendly location row
*/
export const LocationPrintRow = ({ location }: { location: Location }) => {
return (
<li>
<h4>{location.data.location.name}</h4>
<p>{location.data.location.description}</p>
</li>
);
};

View File

@@ -1,84 +0,0 @@
import { useState } from "react";
import type { CampaignId, Location } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
/**
* 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 [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,
data: {
location: {
name,
description,
},
},
});
setName("");
setDescription("");
await onCreate(locationDoc);
} catch (e: any) {
setError(e?.message || "Failed to add location.");
} finally {
setAdding(false);
}
}
return (
<form
className="flex items-left flex-col gap-2 mt-4"
onSubmit={handleSubmit}
>
<h3>Create new location</h3>
<div className="flex gap-5 w-full items-center">
<label>Name</label>
<input
type="text"
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={adding}
aria-label="Name"
/>
</div>
<div className="flex gap-5 w-full items-center">
<label>Description</label>
<textarea
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={adding}
aria-label="Description"
/>
</div>
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
<button
type="submit"
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
disabled={adding || !name.trim()}
>
{adding ? "Adding..." : "Create"}
</button>
</form>
);
};

View File

@@ -1,30 +0,0 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase";
import type { Monster } from "@/lib/types";
/**
* Renders an editable monster row
*/
export const MonsterEditForm = ({ monster }: { monster: Monster }) => {
async function saveMonsterName(name: string) {
await pb.collection("documents").update(monster.id, {
data: {
...monster.data,
monster: {
...monster.data.monster,
name,
},
},
});
}
return (
<div className="">
<AutoSaveTextarea
multiline={false}
value={monster.data.monster.name}
onSave={saveMonsterName}
/>
</div>
);
};

View File

@@ -1,8 +0,0 @@
import type { Monster } from "@/lib/types";
/**
* Renders an editable monster row
*/
export const MonsterPrintRow = ({ monster }: { monster: Monster }) => {
return <li>{monster.data.monster.name}</li>;
};

View File

@@ -1,73 +0,0 @@
import { useState } from "react";
import type { CampaignId, Monster } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
/**
* 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 [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 monsterDoc: Monster = await pb.collection("documents").create({
campaign,
data: {
monster: {
name,
description,
},
},
});
setName("");
setDescription("");
await onCreate(monsterDoc);
} catch (e: any) {
setError(e?.message || "Failed to add monster.");
} finally {
setAdding(false);
}
}
return (
<form
className="flex items-left flex-col gap-2 mt-4"
onSubmit={handleSubmit}
>
<h3>Create new monster</h3>
<div className="flex gap-5 w-full align-center">
<label>Name</label>
<input
type="text"
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={adding}
aria-label="Name"
/>
</div>
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
<button
type="submit"
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
disabled={adding || !name.trim()}
>
{adding ? "Adding..." : "Create"}
</button>
</form>
);
};

View File

@@ -1,84 +0,0 @@
import { useState } from "react";
import type { CampaignId, Npc } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
/**
* 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 [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,
data: {
npc: {
name,
description,
},
},
});
setName("");
setDescription("");
await onCreate(npcDoc);
} catch (e: any) {
setError(e?.message || "Failed to add npc.");
} finally {
setAdding(false);
}
}
return (
<form
className="flex items-left flex-col gap-2 mt-4"
onSubmit={handleSubmit}
>
<h3>Create new npc</h3>
<div className="flex gap-5 w-full items-center">
<label>Name</label>
<input
type="text"
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={adding}
aria-label="Name"
/>
</div>
<div className="flex gap-5 w-full items-center">
<label>Description</label>
<textarea
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={adding}
aria-label="Description"
/>
</div>
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
<button
type="submit"
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
disabled={adding || !name.trim()}
>
{adding ? "Adding..." : "Create"}
</button>
</form>
);
};

View File

@@ -1,46 +0,0 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase";
import type { Npc } from "@/lib/types";
/**
* Renders an editable npc form
*/
export const NpcEditForm = ({ npc }: { npc: Npc }) => {
async function saveNpcName(name: string) {
await pb.collection("documents").update(npc.id, {
data: {
...npc.data,
npc: {
...npc.data.npc,
name,
},
},
});
}
async function saveNpcDescription(description: string) {
await pb.collection("documents").update(npc.id, {
data: {
...npc.data,
npc: {
...npc.data.npc,
description,
},
},
});
}
return (
<div className="">
<AutoSaveTextarea
multiline={false}
value={npc.data.npc.name}
onSave={saveNpcName}
/>
<AutoSaveTextarea
value={npc.data.npc.description}
onSave={saveNpcDescription}
/>
</div>
);
};

View File

@@ -1,13 +0,0 @@
import type { Npc } from "@/lib/types";
/**
* Renders an editable npc row
*/
export const NpcPrintRow = ({ npc }: { npc: Npc }) => {
return (
<li className="">
<h4>{npc.data.npc.name}</h4>
<p>{npc.data.npc.description}</p>
</li>
);
};

View File

@@ -1,69 +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";
/**
* 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 [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,
data: {
scene: {
text,
},
},
});
setText("");
await onCreate(sceneDoc);
} catch (e: any) {
setError(e?.message || "Failed to add scene.");
} finally {
setAdding(false);
}
}
return (
<form
className="flex flex-col items-left gap-2 mt-4"
onSubmit={handleSubmit}
>
<h3>Create new scene</h3>
<input
type="text"
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
placeholder="Add a new scene..."
value={text}
onChange={(e) => setText(e.target.value)}
disabled={adding}
aria-label="Add new scene"
/>
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
<button
type="submit"
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
disabled={adding || !text.trim()}
>
{adding ? "Adding..." : "Create"}
</button>
</form>
);
};

View File

@@ -1,31 +0,0 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase";
import type { Scene } from "@/lib/types";
import { useQueryClient } from "@tanstack/react-query";
/**
* Renders an editable scene form
*/
export const SceneEditForm = ({ scene }: { scene: Scene }) => {
const queryClient = useQueryClient();
async function saveScene(text: string) {
await pb.collection("documents").update(scene.id, {
data: {
...scene.data,
scene: {
text,
},
},
});
queryClient.invalidateQueries({
queryKey: ["relationship"],
});
}
return (
<div className="">
<AutoSaveTextarea value={scene.data.scene.text} onSave={saveScene} />
</div>
);
};

View File

@@ -1,8 +0,0 @@
import type { Scene } from "@/lib/types";
/**
* Renders an editable scene row
*/
export const ScenePrintRow = ({ scene }: { scene: Scene }) => {
return <li className="">{scene.data.scene.text}</li>;
};

View File

@@ -1,66 +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";
/**
* 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 [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,
data: {
secret: {
text: newSecret,
discovered: false,
},
},
});
setNewSecret("");
await onCreate(secretDoc);
} catch (e: any) {
setError(e?.message || "Failed to add secret.");
} finally {
setAdding(false);
}
}
return (
<form className="flex items-center gap-2 mt-4" onSubmit={handleSubmit}>
<input
type="text"
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
placeholder="Add a new secret..."
value={newSecret}
onChange={(e) => setNewSecret(e.target.value)}
disabled={adding}
aria-label="Add new secret"
/>
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
<button
type="submit"
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
disabled={adding || !newSecret.trim()}
>
{adding ? "Adding..." : "Add Secret"}
</button>
</form>
);
};

View File

@@ -1,90 +0,0 @@
// Displays a single secret with discovered checkbox and text.
import type { Secret, Session } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
import { useState } from "react";
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
/**
* Renders an editable secret form.
* Handles updating the discovered state and discoveredIn relationship.
*/
export const SecretEditForm = ({
secret,
session,
}: {
secret: Secret;
session?: Session;
}) => {
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 {
await pb.collection("documents").update(secret.id, {
data: {
...secret.data,
secret: {
...(secret.data as any).secret,
discovered: newChecked,
},
},
});
if (session || !newChecked) {
// If the session exists or the element is being unchecked, remove any
// existing discoveredIn relationship
const rels = await pb.collection("relationships").getList(1, 1, {
filter: `primary = "${secret.id}" && type = "discoveredIn"`,
});
if (rels.items.length > 0) {
await pb.collection("relationships").delete(rels.items[0].id);
}
}
if (session) {
if (newChecked) {
await pb.collection("relationships").create({
primary: secret.id,
secondary: [session.id],
type: "discoveredIn",
});
}
}
} finally {
setLoading(false);
}
}
async function saveText(text: string) {
await pb.collection("documents").update(secret.id, {
data: {
...secret.data,
secret: {
...secret.data.secret,
text,
},
},
});
}
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.secret.text}
onSave={saveText}
/>
</div>
);
};

View File

@@ -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>
);
};

View File

@@ -1,8 +1,9 @@
// SecretRow.tsx // SecretRow.tsx
// Displays a single secret with discovered checkbox and text. // Displays a single secret with discovered checkbox and text.
import type { Secret, Session } from "@/lib/types";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import type { AnyDocument, Secret } from "@/lib/types";
import { useState } from "react"; import { useState } from "react";
import { DocumentLink } from "../DocumentLink";
/** /**
* Renders a secret row with a discovered checkbox and secret text. * Renders a secret row with a discovered checkbox and secret text.
@@ -10,10 +11,10 @@ import { useState } from "react";
*/ */
export const SecretToggleRow = ({ export const SecretToggleRow = ({
secret, secret,
session, root,
}: { }: {
secret: Secret; secret: Secret;
session?: Session; root?: AnyDocument;
}) => { }) => {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
!!(secret.data as any)?.secret?.discovered, !!(secret.data as any)?.secret?.discovered,
@@ -21,6 +22,8 @@ export const SecretToggleRow = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) { async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
e.stopPropagation();
e.preventDefault();
const newChecked = e.target.checked; const newChecked = e.target.checked;
setLoading(true); setLoading(true);
setChecked(newChecked); setChecked(newChecked);
@@ -34,7 +37,7 @@ export const SecretToggleRow = ({
}, },
}, },
}); });
if (session || !newChecked) { if (root || !newChecked) {
// If the session exists or the element is being unchecked, remove any // If the session exists or the element is being unchecked, remove any
// existing discoveredIn relationship // existing discoveredIn relationship
const rels = await pb.collection("relationships").getList(1, 1, { const rels = await pb.collection("relationships").getList(1, 1, {
@@ -44,11 +47,11 @@ export const SecretToggleRow = ({
await pb.collection("relationships").delete(rels.items[0].id); await pb.collection("relationships").delete(rels.items[0].id);
} }
} }
if (session) { if (root) {
if (newChecked) { if (newChecked) {
await pb.collection("relationships").create({ await pb.collection("relationships").create({
primary: secret.id, primary: secret.id,
secondary: [session.id], secondary: [root.id],
type: "discoveredIn", type: "discoveredIn",
}); });
} }
@@ -59,7 +62,7 @@ export const SecretToggleRow = ({
} }
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center justify-stretch gap-3 w-full">
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
@@ -68,11 +71,12 @@ export const SecretToggleRow = ({
aria-label="Discovered" aria-label="Discovered"
disabled={loading} disabled={loading}
/> />
<span> <DocumentLink
{(secret.data as any)?.secret?.text || ( childDocId={secret.id}
<span className="italic text-slate-400">(No secret text)</span> className="!no-underline text-slate-100 hover:underline hover:text-violet-400"
)} >
</span> {secret.data.text}
</DocumentLink>
</div> </div>
); );
}; };

View 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}
/>
);
};

View File

@@ -1,29 +0,0 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase";
import type { Session } from "@/lib/types";
export const SessionEditForm = ({ session }: { session: Session }) => {
async function saveStrongStart(strongStart: string) {
await pb.collection("documents").update(session.id, {
data: {
...session.data,
session: {
...session.data.session,
strongStart,
},
},
});
}
return (
<form>
<h3 className="text-lg font-bold mb-4 text-slate-100">Strong Start</h3>
<AutoSaveTextarea
value={session.data.session.strongStart}
onSave={saveStrongStart}
placeholder="Enter a strong start for this session..."
aria-label="Strong Start"
/>
</form>
);
};

View File

@@ -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.session.strongStart}</div>
</div>
);
};

View File

@@ -1,18 +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="/document/$documentId"
params={{ documentId: session.id }}
className="block font-semibold text-lg text-slate-300"
>
<FormattedDate date={session.created} />
</Link>
<div className="">{session.data.session.strongStart}</div>
</div>
);
};

View File

@@ -1,71 +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";
/**
* 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 [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,
data: {
treasure: {
text: newTreasure,
discovered: false,
},
},
});
setNewTreasure("");
await onCreate(treasureDoc);
} catch (e: any) {
setError(e?.message || "Failed to add treasure.");
} finally {
setAdding(false);
}
}
return (
<form
className="flex flex-col items-center gap-2 mt-4 w-full"
onSubmit={handleSubmit}
>
<div>
<input
type="text"
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
placeholder="Add a new treasure..."
value={newTreasure}
onChange={(e) => setNewTreasure(e.target.value)}
disabled={adding}
aria-label="Add new treasure"
/>
</div>
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
<button
type="submit"
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
disabled={adding || !newTreasure.trim()}
>
{adding ? "Adding..." : "Add Treasure"}
</button>
</form>
);
};

View File

@@ -1,90 +0,0 @@
// Displays a single treasure with discovered checkbox and text.
import type { Treasure, Session } from "@/lib/types";
import { pb } from "@/lib/pocketbase";
import { useState } from "react";
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
/**
* Renders an editable treasure form.
* Handles updating the discovered state and discoveredIn relationship.
*/
export const TreasureEditForm = ({
treasure,
session,
}: {
treasure: Treasure;
session?: Session;
}) => {
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 {
await pb.collection("documents").update(treasure.id, {
data: {
...treasure.data,
treasure: {
...(treasure.data as any).treasure,
discovered: newChecked,
},
},
});
if (session || !newChecked) {
// If the session exists or the element is being unchecked, remove any
// existing discoveredIn relationship
const rels = await pb.collection("relationships").getList(1, 1, {
filter: `primary = "${treasure.id}" && type = "discoveredIn"`,
});
if (rels.items.length > 0) {
await pb.collection("relationships").delete(rels.items[0].id);
}
}
if (session) {
if (newChecked) {
await pb.collection("relationships").create({
primary: treasure.id,
secondary: [session.id],
type: "discoveredIn",
});
}
}
} finally {
setLoading(false);
}
}
async function saveText(text: string) {
await pb.collection("documents").update(treasure.id, {
data: {
...treasure.data,
treasure: {
...treasure.data.treasure,
text,
},
},
});
}
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.treasure.text}
onSave={saveText}
/>
</div>
);
};

View File

@@ -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 (
<li 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>
</li>
);
};

View File

@@ -1,7 +1,8 @@
// TreasureRow.tsx // TreasureRow.tsx
// Displays a single treasure with discovered checkbox and text. // Displays a single treasure with discovered checkbox and text.
import type { Treasure, Session } from "@/lib/types";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import type { AnyDocument, Treasure } from "@/lib/types";
import { Link } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
/** /**
@@ -10,10 +11,10 @@ import { useState } from "react";
*/ */
export const TreasureToggleRow = ({ export const TreasureToggleRow = ({
treasure, treasure,
session, root,
}: { }: {
treasure: Treasure; treasure: Treasure;
session?: Session; root?: AnyDocument;
}) => { }) => {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
!!(treasure.data as any)?.treasure?.discovered, !!(treasure.data as any)?.treasure?.discovered,
@@ -34,7 +35,7 @@ export const TreasureToggleRow = ({
}, },
}, },
}); });
if (session || !newChecked) { if (root || !newChecked) {
// If the session exists or the element is being unchecked, remove any // If the session exists or the element is being unchecked, remove any
// existing discoveredIn relationship // existing discoveredIn relationship
const rels = await pb.collection("relationships").getList(1, 1, { const rels = await pb.collection("relationships").getList(1, 1, {
@@ -44,11 +45,11 @@ export const TreasureToggleRow = ({
await pb.collection("relationships").delete(rels.items[0].id); await pb.collection("relationships").delete(rels.items[0].id);
} }
} }
if (session) { if (root) {
if (newChecked) { if (newChecked) {
await pb.collection("relationships").create({ await pb.collection("relationships").create({
primary: treasure.id, primary: treasure.id,
secondary: [session.id], secondary: [root.id],
type: "discoveredIn", type: "discoveredIn",
}); });
} }
@@ -68,11 +69,13 @@ export const TreasureToggleRow = ({
aria-label="Discovered" aria-label="Discovered"
disabled={loading} disabled={loading}
/> />
<span> <Link
{(treasure.data as any)?.treasure?.text || ( to="/document/$documentId/$"
<span className="italic text-slate-400">(No treasure text)</span> params={{ documentId: treasure.id }}
)} className="text-lg !no-underline text-slate-100 hover:underline hover:text-violet-400"
</span> >
{treasure.data.text}
</Link>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,38 @@
export type Props = {
title?: string;
content: React.ReactNode;
buttonText?: string;
isLoading?: boolean;
error?: string | null;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
};
export const BaseForm = ({
title,
content,
buttonText,
isLoading,
error,
onSubmit,
}: Props) => {
return (
<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>
<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>}
<button
type="submit"
className="mt-2 px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
disabled={isLoading}
>
{buttonText ? buttonText : "Submit"}
</button>
</form>
);
};

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

@@ -0,0 +1,28 @@
export type Props = {
value: string;
onChange: (value: string) => void;
label?: string;
className?: string;
} & Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"value" | "onChange" | "className"
>;
export const MultiLineInput = ({
value,
onChange,
className = "",
label,
...props
}: Props) => (
<>
{label && <label>{label}</label>}
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
className={`w-full min-h-[10em] field-sizing-content 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 ${className}`}
aria-label={label}
{...props}
/>
</>
);

View File

@@ -0,0 +1,29 @@
export type Props = {
value: string;
onChange: (value: string) => void;
label?: string;
className?: string;
} & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "className"
>;
export const SingleLineInput = ({
value,
onChange,
className = "",
label,
...props
}: Props) => (
<>
{label && <label>{label}</label>}
<input
type="text"
value={value}
onChange={(e) => onChange(e.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 ${className}`}
aria-label={label}
{...props}
/>
</>
);

View 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>
);

View File

@@ -0,0 +1,64 @@
import { Link } from "@tanstack/react-router";
export type Props = {
title: React.ReactNode;
navigation: React.ReactNode;
tabs: React.ReactNode[];
content: React.ReactNode;
flyout?: React.ReactNode;
};
export function TabbedLayout({
navigation,
title,
tabs,
content,
flyout,
}: Props) {
return (
<div className="grow p-2 flex flex-col gap-2">
<div className="flex flex-row gap-2">{navigation}</div>
<div>{title}</div>
<div className="flex flex-col md:flex-row justify-start grow">
<div className="shrink-0 grow-0 md:w-40 p-0 flex flex-row flex-wrap md:flex-col md:flex-nowrap">
{tabs}
</div>
<div
className={`grow md:w-md p-2 bg-slate-800 border-t border-b border-r border-slate-700 ${flyout && "hidden"} md:block`}
>
{content}
</div>
{flyout && (
<div className="grow md:w-md p-2 bg-slate-800 border border-slate-700">
{flyout}
</div>
)}
</div>
</div>
);
}
export type TabProps = {
label: string;
to: string;
params?: Record<string, any>;
search?: Record<string, any>;
active?: boolean;
};
const activeTabClass =
"text-slate-100 font-bold bg-slate-800 border-t border-b border-l";
const inactiveTabClass = "text-slate-300 bg-slate-900 border";
export function Tab({ label, to, params, active, search }: TabProps) {
return (
<Link
key={label}
to={to}
params={params}
search={search}
className={`block p-2 border-slate-700 whitespace-nowrap ${active ? activeTabClass : inactiveTabClass}`}
>
{label}
</Link>
);
}

View File

@@ -1,5 +1,4 @@
import { createContext, useContext, useCallback } from "react"; import { createContext, useContext, useCallback, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import type { AuthRecord } from "pocketbase"; import type { AuthRecord } from "pocketbase";
@@ -26,91 +25,49 @@ export interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | undefined>(undefined); const AuthContext = createContext<AuthContextValue | undefined>(undefined);
/** /**
* Fetches the currently authenticated user from PocketBase. * Provider for authentication context.
*/
async function fetchUser(): Promise<AuthRecord | null> {
if (pb.authStore.isValid) {
return pb.authStore.record;
}
return null;
}
/**
* Provider for authentication context, using TanStack Query for state management.
*/ */
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient(); const [isLoading, setIsLoading] = useState(false);
const { data: user, isLoading } = useQuery({ const [user, setUser] = useState<AuthRecord | null>(
queryKey: ["auth", "user"], pb.authStore.isValid ? pb.authStore.record : null,
queryFn: fetchUser, );
});
const navigate = useNavigate(); const navigate = useNavigate();
const loginMutation = useMutation({ function updateUser() {
mutationFn: async ({ if (pb.authStore.isValid) {
email, setUser(pb.authStore.record);
password, }
}: { setIsLoading(false);
email: string; }
password: string;
}) => {
await pb.collection("users").authWithPassword(email, password);
return fetchUser();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "user"] });
},
});
const signupMutation = useMutation({ const login = useCallback(async (email: string, password: string) => {
mutationFn: async ({ console.log("login");
email, setIsLoading(true);
password, await pb.collection("users").authWithPassword(email, password);
passwordConfirm, updateUser();
}: { navigate({ to: "/campaigns" });
email: string; }, []);
password: string;
passwordConfirm: string;
}) => {
await pb.collection("users").create({ email, password, passwordConfirm });
await pb.collection("users").authWithPassword(email, password);
return fetchUser();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "user"] });
},
});
const logoutMutation = useMutation({
mutationFn: async () => {
pb.authStore.clear();
return null;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "user"] });
},
});
const login = useCallback(
async (email: string, password: string) => {
await loginMutation.mutateAsync({ email, password });
navigate({ to: "/campaigns" });
},
[loginMutation],
);
const signup = useCallback( const signup = useCallback(
async (email: string, password: string, passwordConfirm: string) => { async (email: string, password: string, passwordConfirm: string) => {
await signupMutation.mutateAsync({ email, password, passwordConfirm }); console.log("signup");
setIsLoading(true);
await pb.collection("users").create({ email, password, passwordConfirm });
await pb.collection("users").authWithPassword(email, password);
updateUser();
navigate({ to: "/campaigns" }); navigate({ to: "/campaigns" });
}, },
[signupMutation], [],
); );
const logout = useCallback(async () => { const logout = useCallback(async () => {
await logoutMutation.mutateAsync(); console.log("logout");
pb.authStore.clear();
setUser(null);
navigate({ to: "/" }); navigate({ to: "/" });
}, [logoutMutation]); }, []);
return ( return (
<AuthContext.Provider <AuthContext.Provider

View File

@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { createContext, useReducer } from "react";
import type { DocumentAction } from "./actions";
import { reducer } from "./reducer";
import { initialState, type DocumentState } from "./state";
export type DocumentContextValue = {
cache: DocumentState;
dispatch: (action: DocumentAction) => void;
};
export const DocumentContext = createContext<DocumentContextValue | undefined>(
undefined,
);
/**
* Provider for the record cache context. Provides a singleton RecordCache instance to children.
*/
export function DocumentProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState());
return (
<DocumentContext.Provider
value={{
cache: state,
dispatch,
}}
>
{children}
</DocumentContext.Provider>
);
}

View File

@@ -0,0 +1,51 @@
import { pb } from "@/lib/pocketbase";
import { type AnyDocument, type DocumentId } from "@/lib/types";
import type { RecordModel } from "pocketbase";
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 DocumentLoader({
documentId,
children,
}: {
documentId: DocumentId;
children: ReactNode;
}) {
const { dispatch } = useDocumentCache();
useEffect(() => {
async function fetchDocumentAndRelations() {
dispatch({
type: "loadingDocument",
docId: documentId,
});
const doc: AnyDocument = await pb
.collection("documents")
.getOne(documentId, {
expand:
"relationships_via_primary,relationships_via_primary.secondary",
});
dispatch({
type: "setDocumentTree",
doc,
relationships: doc.expand?.relationships_via_primary || [],
relatedDocuments:
doc.expand?.relationships_via_primary?.flatMap(
(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 ?? [],
) ?? [],
});
}
fetchDocumentAndRelations();
}, [documentId]);
return children;
}

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

@@ -0,0 +1,30 @@
import type { AnyDocument, DocumentId, Relationship } from "@/lib/types";
export type DocumentAction =
| {
type: "loadingDocument";
docId: DocumentId;
}
| {
type: "setDocument";
doc: AnyDocument;
}
| {
type: "setDocuments";
docs: AnyDocument[];
}
| {
type: "setRelationship";
docId: DocumentId;
relationship: Relationship;
}
| {
type: "setDocumentTree";
doc: AnyDocument;
relationships: Relationship[];
relatedDocuments: AnyDocument[];
}
| {
type: "removeDocument";
docId: DocumentId;
};

View File

@@ -0,0 +1,23 @@
import type { DocumentId } from "@/lib/types";
import { useContext } from "react";
import { DocumentContext } from "./DocumentContext";
export function useDocument(id: DocumentId) {
const ctx = useContext(DocumentContext);
if (!ctx)
throw new Error("useDocument must be used within a DocumentProvider");
return {
docResult: ctx.cache.documents[id],
dispatch: ctx.dispatch,
};
}
export function useDocumentCache() {
const ctx = useContext(DocumentContext);
if (!ctx)
throw new Error("useDocument must be used within a DocumentProvider");
return {
cache: ctx.cache,
dispatch: ctx.dispatch,
};
}

View File

@@ -0,0 +1,173 @@
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(
docId: DocumentId,
state: DocumentState,
): DocumentState {
return {
...state,
documents: {
...state.documents,
[docId]: loading(),
},
};
}
function setDocument(state: DocumentState, doc: AnyDocument): DocumentState {
const previous = state.documents[doc.id];
const relationships =
previous?.type === "ready"
? previous.value.relationships
: Object.fromEntries(
relationshipsForDocument(doc).map((relationshipType) => [
relationshipType,
unloaded(),
]),
);
return {
...state,
documents: {
...state.documents,
[doc.id]: ready({
doc: doc,
relationships,
}),
},
};
}
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,
relationship: Relationship,
): DocumentState {
const previousResult = state.documents[docId];
if (previousResult?.type !== "ready") {
return state;
}
const previousEntry = previousResult.value;
return {
...state,
documents: {
...state.documents,
[docId]: ready({
...previousEntry,
relationships: {
...previousEntry.relationships,
[relationship.type]: ready(relationship),
},
}),
},
};
}
function removeDocument(
docId: DocumentId,
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,
): DocumentState {
console.debug("Processing action", action);
switch (action.type) {
case "loadingDocument":
return setLoadingDocument(action.docId, initialState);
case "setDocument":
return setDocument(initialState, action.doc);
case "setDocuments":
return action.docs.reduce(setDocument, initialState);
case "setRelationship":
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,
emptyRemainingRelationships,
);
case "removeDocument":
return removeDocument(action.docId, initialState);
}
}

View File

@@ -0,0 +1,55 @@
import type {
AnyDocument,
DocumentId,
DocumentType,
Relationship,
RelationshipType,
} from "@/lib/types";
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>(
result: Result<A>,
f: (a: A) => B,
): Result<B> => {
if (result.type === "ready") {
return ready(f(result.value));
}
return result;
};
export type DocumentState = {
documents: Record<
DocumentId,
Result<{
doc: AnyDocument;
relationships: Record<RelationshipType, Result<Relationship>>;
}>
>;
};
export const initialState = (): DocumentState =>
({
documents: {},
}) 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 }]
: [],
);

62
src/lib/documentPath.ts Normal file
View File

@@ -0,0 +1,62 @@
import { useParams } from "@tanstack/react-router";
import * as z from "zod";
import type { RelationshipType, DocumentId } from "./types";
const documentParams = z
.templateLiteral([
z.string(),
z.optional(z.literal("/")),
z.optional(z.string()),
])
.pipe(
z.transform((path: string) => {
if (path === "") {
return {
relationshipType: null,
childDocId: null,
};
}
const [relationshipType, childDocId] = path.split("/");
return {
relationshipType: (relationshipType ?? null) as RelationshipType | null,
childDocId: (childDocId ?? null) as DocumentId | null,
};
}),
);
export function useDocumentPath():
| {
documentId: DocumentId;
relationshipType: RelationshipType | null;
childDocId: DocumentId | null;
}
| undefined {
const params = useParams({
from: "/_app/_authenticated/document/$documentId/$",
shouldThrow: false,
});
if (params) {
const { relationshipType, childDocId } = documentParams.parse(
params._splat,
);
return {
documentId: params.documentId as DocumentId,
relationshipType,
childDocId,
};
}
return undefined;
}
export function makeDocumentPath(
documentId: DocumentId,
relationshipType?: RelationshipType | null,
childDocId?: DocumentId | null,
) {
return (
"/document/" +
[documentId, relationshipType, childDocId].filter((x) => x).join("/")
);
}

37
src/lib/documents.ts Normal file
View 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
View 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>[];
}
}

112
src/lib/recordCache.ts Normal file
View File

@@ -0,0 +1,112 @@
import {
type CollectionId,
type AnyDocument,
CollectionIds,
type Relationship,
type DocumentId,
type RelationshipId,
type DbRecord,
} from "./types";
export type CacheKey<C extends CollectionId = CollectionId> = `${C}:${string}`;
export type CacheValue<C extends CollectionId = CollectionId> = {
record: Promise<DbRecord<C>>;
subscriptions: ((record: DbRecord<C> | null) => void)[];
};
type DocumentKey = CacheKey<typeof CollectionIds.Documents>;
type RelationshipKey = CacheKey<typeof CollectionIds.Relationships>;
export class RecordCache {
private cache: Record<`${CollectionId}:${string}`, CacheValue> = {};
static makeKey<C extends CollectionId>(
collectionId: C,
id: string,
): CacheKey<C> {
return `${collectionId}:${id}`;
}
static docKey = (id: DocumentId): DocumentKey =>
RecordCache.makeKey(CollectionIds.Documents, id);
static relationKey = (id: RelationshipId): RelationshipKey =>
RecordCache.makeKey(CollectionIds.Relationships, id);
get = <C extends CollectionId>(
key: CacheKey<C>,
): Promise<DbRecord<C>> | undefined =>
this.cache[key]?.record as Promise<DbRecord<C>> | undefined;
pending = <C extends CollectionId>(
key: CacheKey<C>,
record: Promise<DbRecord<C>>,
) => {
if (this.cache[key] === undefined) {
this.cache[key] = {
record: record,
subscriptions: [],
};
}
const entry = this.cache[key];
entry.record = record;
record.then((record) => {
for (const subscription of entry.subscriptions) {
subscription(record);
}
for (const doc of record.expand?.secondary ?? []) {
this.set(doc);
}
for (const rel of record.expand?.relationships_via_primary ?? []) {
this.set(rel);
}
});
};
set = <C extends CollectionId>(record: DbRecord<C>) => {
const key = RecordCache.makeKey(
record.collectionName as CollectionId,
record.id,
);
if (this.cache[key] === undefined) {
this.cache[key] = {
record: Promise.resolve(record),
subscriptions: [],
};
}
const entry = this.cache[key];
entry.record = Promise.resolve(record);
for (const subscription of entry.subscriptions) {
subscription(record);
}
for (const doc of record.expand?.secondary ?? []) {
this.set(doc);
}
for (const rel of record.expand?.relationships_via_primary ?? []) {
this.set(rel);
}
};
remove = (key: CacheKey) => {
const entry = this.cache[key];
delete this.cache[key];
for (const subscription of entry.subscriptions) {
subscription(null);
}
};
getDocument = (id: DocumentId): AnyDocument | undefined =>
this.get(RecordCache.docKey(id)) as AnyDocument | undefined;
getRelationship = (id: RelationshipId): Relationship | undefined =>
this.get(RecordCache.relationKey(id)) as Relationship | undefined;
removeDocument = (id: DocumentId) => this.remove(RecordCache.docKey(id));
removeRelationship = (id: RelationshipId) =>
this.remove(RecordCache.relationKey(id));
}

View File

@@ -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];
}

View File

@@ -1,13 +1,34 @@
import type { RecordModel } from "pocketbase"; import type { RecordModel } from "pocketbase";
export type Id<T extends string> = string & { __type: T }; export const CollectionIds = {
Users: "users",
Campaigns: "campaigns",
Documents: "documents",
Relationships: "relationships",
} as const;
export type UserId = Id<"User">; export type CollectionId = (typeof CollectionIds)[keyof typeof CollectionIds];
export type CampaignId = Id<"Campaign">;
export type DocumentId = Id<"Document">; export type Id<T extends CollectionId> = string & { __type: T };
export type UserId = Id<typeof CollectionIds.Users>;
export type CampaignId = Id<typeof CollectionIds.Campaigns>;
export type DocumentId = Id<typeof CollectionIds.Documents>;
export type RelationshipId = Id<typeof CollectionIds.Relationships>;
export type ISO8601Date = string & { __type: "iso8601date" }; export type ISO8601Date = string & { __type: "iso8601date" };
export type DbRecord<C extends CollectionId = CollectionId> = {
[CollectionIds.Campaigns]: Campaign;
[CollectionIds.Documents]: AnyDocument;
[CollectionIds.Relationships]: Relationship;
[CollectionIds.Users]: RecordModel;
}[C];
/******************************************
* Campaigns
******************************************/
export type Campaign = RecordModel & { export type Campaign = RecordModel & {
id: CampaignId; id: CampaignId;
name: string; name: string;
@@ -32,6 +53,8 @@ export type RelationshipType =
(typeof RelationshipType)[keyof typeof RelationshipType]; (typeof RelationshipType)[keyof typeof RelationshipType];
export type Relationship = RecordModel & { export type Relationship = RecordModel & {
id: RelationshipId;
collectionName: typeof CollectionIds.Relationships;
primary: DocumentId; primary: DocumentId;
secondary: DocumentId[]; secondary: DocumentId[];
type: RelationshipType; type: RelationshipType;
@@ -41,156 +64,144 @@ export type Relationship = RecordModel & {
* Documents * Documents
******************************************/ ******************************************/
export type DocumentData<K extends string, V> = {
data: Record<K, V>;
};
export type Document = RecordModel & {
id: DocumentId;
campaign: CampaignId;
data: {
[K in DocumentType]?: unknown;
};
// These two are not in Pocketbase's types, but they seem to always be present
created: ISO8601Date;
updated: ISO8601Date;
};
export type DocumentType = export type DocumentType =
| "front"
| "location" | "location"
| "monster" | "monster"
| "npc" | "npc"
| "scene" | "scene"
| "secret" | "secret"
| "session" | "session"
| "thread"
| "treasure"; | "treasure";
export type Document<Type extends DocumentType, Data> = RecordModel & {
id: DocumentId;
collectionName: typeof CollectionIds.Documents;
campaign: CampaignId;
type: Type;
data: Data;
// These two are not in Pocketbase's types, but they seem to always be present
created: ISO8601Date;
updated: ISO8601Date;
};
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 {
if (isLocation(doc)) { return doc.type;
return "location";
} else if (isMonster(doc)) {
return "monster";
} else if (isNpc(doc)) {
return "npc";
} else if (isScene(doc)) {
return "scene";
} else if (isSecret(doc)) {
return "secret";
} else if (isSession(doc)) {
return "session";
} else if (isTreasure(doc)) {
return "treasure";
}
throw new Error(`Document type not found: ${JSON.stringify(doc)}`);
} }
/** Locations **/ /** Locations **/
export type Location = Document<
export type Location = Document & "location",
DocumentData< {
"location", name: string;
{ description: string;
name: string; }
description: string; >;
}
>;
export function isLocation(doc: Document): doc is Location {
return Object.hasOwn(doc.data, "location");
}
/** Monsters **/ /** Monsters **/
export type Monster = Document & export type Monster = Document<
DocumentData< "monster",
"monster", {
{ name: string;
name: string; }
} >;
>;
export function isMonster(doc: Document): doc is Monster {
return Object.hasOwn(doc.data, "monster");
}
/** NPCs **/ /** NPCs **/
export type Npc = Document & export type Npc = Document<
DocumentData< "npc",
"npc", {
{ name: string;
name: string; description: string;
description: string; }
} >;
>;
export function isNpc(doc: Document): doc is Npc {
return Object.hasOwn(doc.data, "npc");
}
/** Session **/ /** Session **/
export type Session = Document & export type Session = Document<
DocumentData< "session",
"session", {
{ name?: string;
strongStart: string; strongStart: string;
} }
>; >;
export function isSession(doc: Document): doc is Session {
return Object.hasOwn(doc.data, "session");
}
/** Scene **/ /** Scene **/
export type Scene = Document & export type Scene = Document<
DocumentData< "scene",
"scene", {
{ text: string;
text: string; }
} >;
>;
export function isScene(doc: Document): doc is Scene {
return Object.hasOwn(doc.data, "scene");
}
/** Secret **/ /** Secret **/
export type Secret = Document & export type Secret = Document<
DocumentData< "secret",
"secret", {
{ text: string;
text: string; discovered: boolean;
discovered: boolean; }
} >;
>;
export function isSecret(doc: Document): doc is Secret {
return Object.hasOwn(doc.data, "secret");
}
/** Treasure **/ /** Treasure **/
export type Treasure = Document & export type Treasure = Document<
DocumentData< "treasure",
"treasure", {
{ text: string;
text: string; discovered: boolean;
discovered: boolean; }
} >;
>;
export function isTreasure(doc: Document): doc is Treasure { /** Thread **/
return Object.hasOwn(doc.data, "treasure");
} export type Thread = Document<
"thread",
{
text: string;
resolved: boolean;
}
>;
/** Front **/
export type Front = Document<
"front",
{
name: string;
description: string;
resolved: boolean;
}
>;

View File

@@ -1,7 +1,6 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router"; import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Import the generated route tree // Import the generated route tree
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
@@ -9,16 +8,13 @@ import { routeTree } from "./routeTree.gen";
import "./styles.css"; import "./styles.css";
import reportWebVitals from "./reportWebVitals.ts"; import reportWebVitals from "./reportWebVitals.ts";
const queryClient = new QueryClient();
// Create a new router instance // Create a new router instance
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
context: { queryClient },
defaultPreload: "intent", defaultPreload: "intent",
scrollRestoration: true, scrollRestoration: true,
defaultStructuralSharing: true, defaultStructuralSharing: true,
defaultPreloadStaleTime: 0, defaultPendingMinMs: 0,
}); });
// Register the router instance for type safety // Register the router instance for type safety
@@ -34,9 +30,7 @@ if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement); const root = ReactDOM.createRoot(rootElement);
root.render( root.render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <RouterProvider router={router} />
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>, </StrictMode>,
); );
} }

View File

@@ -17,9 +17,8 @@ import { Route as AppLoginImport } from './routes/_app/login'
import { Route as AppAboutImport } from './routes/_app/about' import { Route as AppAboutImport } from './routes/_app/about'
import { Route as AppAuthenticatedImport } from './routes/_app/_authenticated' import { Route as AppAuthenticatedImport } from './routes/_app/_authenticated'
import { Route as AppAuthenticatedCampaignsIndexImport } from './routes/_app/_authenticated/campaigns.index' import { Route as AppAuthenticatedCampaignsIndexImport } from './routes/_app/_authenticated/campaigns.index'
import { Route as AppAuthenticatedDocumentDocumentIdImport } from './routes/_app/_authenticated/document.$documentId'
import { Route as AppAuthenticatedCampaignsCampaignIdImport } from './routes/_app/_authenticated/campaigns.$campaignId' import { Route as AppAuthenticatedCampaignsCampaignIdImport } from './routes/_app/_authenticated/campaigns.$campaignId'
import { Route as AppauthenticatedDocumentDocumentIdPrintImport } from './routes/_app_._authenticated.document_.$documentId.print' import { Route as AppAuthenticatedDocumentDocumentIdSplatImport } from './routes/_app/_authenticated/document.$documentId.$'
// Create/Update Routes // Create/Update Routes
@@ -58,13 +57,6 @@ const AppAuthenticatedCampaignsIndexRoute =
getParentRoute: () => AppAuthenticatedRoute, getParentRoute: () => AppAuthenticatedRoute,
} as any) } as any)
const AppAuthenticatedDocumentDocumentIdRoute =
AppAuthenticatedDocumentDocumentIdImport.update({
id: '/document/$documentId',
path: '/document/$documentId',
getParentRoute: () => AppAuthenticatedRoute,
} as any)
const AppAuthenticatedCampaignsCampaignIdRoute = const AppAuthenticatedCampaignsCampaignIdRoute =
AppAuthenticatedCampaignsCampaignIdImport.update({ AppAuthenticatedCampaignsCampaignIdImport.update({
id: '/campaigns/$campaignId', id: '/campaigns/$campaignId',
@@ -72,11 +64,11 @@ const AppAuthenticatedCampaignsCampaignIdRoute =
getParentRoute: () => AppAuthenticatedRoute, getParentRoute: () => AppAuthenticatedRoute,
} as any) } as any)
const AppauthenticatedDocumentDocumentIdPrintRoute = const AppAuthenticatedDocumentDocumentIdSplatRoute =
AppauthenticatedDocumentDocumentIdPrintImport.update({ AppAuthenticatedDocumentDocumentIdSplatImport.update({
id: '/_app_/_authenticated/document_/$documentId/print', id: '/document/$documentId/$',
path: '/document/$documentId/print', path: '/document/$documentId/$',
getParentRoute: () => rootRoute, getParentRoute: () => AppAuthenticatedRoute,
} as any) } as any)
// Populate the FileRoutesByPath interface // Populate the FileRoutesByPath interface
@@ -125,13 +117,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppAuthenticatedCampaignsCampaignIdImport preLoaderRoute: typeof AppAuthenticatedCampaignsCampaignIdImport
parentRoute: typeof AppAuthenticatedImport parentRoute: typeof AppAuthenticatedImport
} }
'/_app/_authenticated/document/$documentId': {
id: '/_app/_authenticated/document/$documentId'
path: '/document/$documentId'
fullPath: '/document/$documentId'
preLoaderRoute: typeof AppAuthenticatedDocumentDocumentIdImport
parentRoute: typeof AppAuthenticatedImport
}
'/_app/_authenticated/campaigns/': { '/_app/_authenticated/campaigns/': {
id: '/_app/_authenticated/campaigns/' id: '/_app/_authenticated/campaigns/'
path: '/campaigns' path: '/campaigns'
@@ -139,12 +124,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppAuthenticatedCampaignsIndexImport preLoaderRoute: typeof AppAuthenticatedCampaignsIndexImport
parentRoute: typeof AppAuthenticatedImport parentRoute: typeof AppAuthenticatedImport
} }
'/_app_/_authenticated/document_/$documentId/print': { '/_app/_authenticated/document/$documentId/$': {
id: '/_app_/_authenticated/document_/$documentId/print' id: '/_app/_authenticated/document/$documentId/$'
path: '/document/$documentId/print' path: '/document/$documentId/$'
fullPath: '/document/$documentId/print' fullPath: '/document/$documentId/$'
preLoaderRoute: typeof AppauthenticatedDocumentDocumentIdPrintImport preLoaderRoute: typeof AppAuthenticatedDocumentDocumentIdSplatImport
parentRoute: typeof rootRoute parentRoute: typeof AppAuthenticatedImport
} }
} }
} }
@@ -153,16 +138,16 @@ declare module '@tanstack/react-router' {
interface AppAuthenticatedRouteChildren { interface AppAuthenticatedRouteChildren {
AppAuthenticatedCampaignsCampaignIdRoute: typeof AppAuthenticatedCampaignsCampaignIdRoute AppAuthenticatedCampaignsCampaignIdRoute: typeof AppAuthenticatedCampaignsCampaignIdRoute
AppAuthenticatedDocumentDocumentIdRoute: typeof AppAuthenticatedDocumentDocumentIdRoute
AppAuthenticatedCampaignsIndexRoute: typeof AppAuthenticatedCampaignsIndexRoute AppAuthenticatedCampaignsIndexRoute: typeof AppAuthenticatedCampaignsIndexRoute
AppAuthenticatedDocumentDocumentIdSplatRoute: typeof AppAuthenticatedDocumentDocumentIdSplatRoute
} }
const AppAuthenticatedRouteChildren: AppAuthenticatedRouteChildren = { const AppAuthenticatedRouteChildren: AppAuthenticatedRouteChildren = {
AppAuthenticatedCampaignsCampaignIdRoute: AppAuthenticatedCampaignsCampaignIdRoute:
AppAuthenticatedCampaignsCampaignIdRoute, AppAuthenticatedCampaignsCampaignIdRoute,
AppAuthenticatedDocumentDocumentIdRoute:
AppAuthenticatedDocumentDocumentIdRoute,
AppAuthenticatedCampaignsIndexRoute: AppAuthenticatedCampaignsIndexRoute, AppAuthenticatedCampaignsIndexRoute: AppAuthenticatedCampaignsIndexRoute,
AppAuthenticatedDocumentDocumentIdSplatRoute:
AppAuthenticatedDocumentDocumentIdSplatRoute,
} }
const AppAuthenticatedRouteWithChildren = const AppAuthenticatedRouteWithChildren =
@@ -190,9 +175,8 @@ export interface FileRoutesByFullPath {
'/login': typeof AppLoginRoute '/login': typeof AppLoginRoute
'/': typeof AppIndexRoute '/': typeof AppIndexRoute
'/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute '/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute
'/document/$documentId': typeof AppAuthenticatedDocumentDocumentIdRoute
'/campaigns': typeof AppAuthenticatedCampaignsIndexRoute '/campaigns': typeof AppAuthenticatedCampaignsIndexRoute
'/document/$documentId/print': typeof AppauthenticatedDocumentDocumentIdPrintRoute '/document/$documentId/$': typeof AppAuthenticatedDocumentDocumentIdSplatRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@@ -201,9 +185,8 @@ export interface FileRoutesByTo {
'/login': typeof AppLoginRoute '/login': typeof AppLoginRoute
'/': typeof AppIndexRoute '/': typeof AppIndexRoute
'/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute '/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute
'/document/$documentId': typeof AppAuthenticatedDocumentDocumentIdRoute
'/campaigns': typeof AppAuthenticatedCampaignsIndexRoute '/campaigns': typeof AppAuthenticatedCampaignsIndexRoute
'/document/$documentId/print': typeof AppauthenticatedDocumentDocumentIdPrintRoute '/document/$documentId/$': typeof AppAuthenticatedDocumentDocumentIdSplatRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -214,9 +197,8 @@ export interface FileRoutesById {
'/_app/login': typeof AppLoginRoute '/_app/login': typeof AppLoginRoute
'/_app/': typeof AppIndexRoute '/_app/': typeof AppIndexRoute
'/_app/_authenticated/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute '/_app/_authenticated/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute
'/_app/_authenticated/document/$documentId': typeof AppAuthenticatedDocumentDocumentIdRoute
'/_app/_authenticated/campaigns/': typeof AppAuthenticatedCampaignsIndexRoute '/_app/_authenticated/campaigns/': typeof AppAuthenticatedCampaignsIndexRoute
'/_app_/_authenticated/document_/$documentId/print': typeof AppauthenticatedDocumentDocumentIdPrintRoute '/_app/_authenticated/document/$documentId/$': typeof AppAuthenticatedDocumentDocumentIdSplatRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@@ -227,9 +209,8 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/' | '/'
| '/campaigns/$campaignId' | '/campaigns/$campaignId'
| '/document/$documentId'
| '/campaigns' | '/campaigns'
| '/document/$documentId/print' | '/document/$documentId/$'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '' | ''
@@ -237,9 +218,8 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/' | '/'
| '/campaigns/$campaignId' | '/campaigns/$campaignId'
| '/document/$documentId'
| '/campaigns' | '/campaigns'
| '/document/$documentId/print' | '/document/$documentId/$'
id: id:
| '__root__' | '__root__'
| '/_app' | '/_app'
@@ -248,21 +228,17 @@ export interface FileRouteTypes {
| '/_app/login' | '/_app/login'
| '/_app/' | '/_app/'
| '/_app/_authenticated/campaigns/$campaignId' | '/_app/_authenticated/campaigns/$campaignId'
| '/_app/_authenticated/document/$documentId'
| '/_app/_authenticated/campaigns/' | '/_app/_authenticated/campaigns/'
| '/_app_/_authenticated/document_/$documentId/print' | '/_app/_authenticated/document/$documentId/$'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
AppRoute: typeof AppRouteWithChildren AppRoute: typeof AppRouteWithChildren
AppauthenticatedDocumentDocumentIdPrintRoute: typeof AppauthenticatedDocumentDocumentIdPrintRoute
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
AppRoute: AppRouteWithChildren, AppRoute: AppRouteWithChildren,
AppauthenticatedDocumentDocumentIdPrintRoute:
AppauthenticatedDocumentDocumentIdPrintRoute,
} }
export const routeTree = rootRoute export const routeTree = rootRoute
@@ -275,8 +251,7 @@ export const routeTree = rootRoute
"__root__": { "__root__": {
"filePath": "__root.tsx", "filePath": "__root.tsx",
"children": [ "children": [
"/_app", "/_app"
"/_app_/_authenticated/document_/$documentId/print"
] ]
}, },
"/_app": { "/_app": {
@@ -293,8 +268,8 @@ export const routeTree = rootRoute
"parent": "/_app", "parent": "/_app",
"children": [ "children": [
"/_app/_authenticated/campaigns/$campaignId", "/_app/_authenticated/campaigns/$campaignId",
"/_app/_authenticated/document/$documentId", "/_app/_authenticated/campaigns/",
"/_app/_authenticated/campaigns/" "/_app/_authenticated/document/$documentId/$"
] ]
}, },
"/_app/about": { "/_app/about": {
@@ -313,16 +288,13 @@ export const routeTree = rootRoute
"filePath": "_app/_authenticated/campaigns.$campaignId.tsx", "filePath": "_app/_authenticated/campaigns.$campaignId.tsx",
"parent": "/_app/_authenticated" "parent": "/_app/_authenticated"
}, },
"/_app/_authenticated/document/$documentId": {
"filePath": "_app/_authenticated/document.$documentId.tsx",
"parent": "/_app/_authenticated"
},
"/_app/_authenticated/campaigns/": { "/_app/_authenticated/campaigns/": {
"filePath": "_app/_authenticated/campaigns.index.tsx", "filePath": "_app/_authenticated/campaigns.index.tsx",
"parent": "/_app/_authenticated" "parent": "/_app/_authenticated"
}, },
"/_app_/_authenticated/document_/$documentId/print": { "/_app/_authenticated/document/$documentId/$": {
"filePath": "_app_._authenticated.document_.$documentId.print.tsx" "filePath": "_app/_authenticated/document.$documentId.$.tsx",
"parent": "/_app/_authenticated"
} }
} }
} }

View File

@@ -1,5 +1,5 @@
import { AuthProvider } from "@/context/auth/AuthContext"; import { AuthProvider } from "@/context/auth/AuthContext";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { DocumentProvider } from "@/context/document/DocumentContext";
import { Outlet, createRootRoute } from "@tanstack/react-router"; import { Outlet, createRootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
@@ -7,10 +7,11 @@ export const Route = createRootRoute({
component: () => ( component: () => (
<> <>
<AuthProvider> <AuthProvider>
<Outlet /> <DocumentProvider>
<Outlet />
</DocumentProvider>
</AuthProvider> </AuthProvider>
<TanStackRouterDevtools /> <TanStackRouterDevtools />
<ReactQueryDevtools buttonPosition="bottom-right" />
</> </>
), ),
}); });

View File

@@ -1,5 +1,7 @@
import { Loader } from "@/components/Loader";
import { useAuth } from "@/context/auth/AuthContext"; import { useAuth } from "@/context/auth/AuthContext";
import { Link, Outlet, createFileRoute } from "@tanstack/react-router"; import { Link, Outlet, createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
export const Route = createFileRoute("/_app")({ export const Route = createFileRoute("/_app")({
component: RouteComponent, component: RouteComponent,
@@ -71,7 +73,9 @@ function RouteComponent() {
return ( return (
<> <>
<AppHeader /> <AppHeader />
<Outlet /> <Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</> </>
); );
} }

View File

@@ -1,136 +1,111 @@
import { useCallback } from "react"; import { CampaignDocuments } from "@/components/campaign/CampaignDocuments";
import { createFileRoute, Link } from "@tanstack/react-router"; import { DocumentPreview } from "@/components/documents/DocumentPreview";
import { pb } from "@/lib/pocketbase"; import { Tab, TabbedLayout } from "@/components/layout/TabbedLayout";
import { SessionRow } from "@/components/documents/session/SessionRow";
import { Button } from "@headlessui/react";
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import type { Relationship } from "@/lib/types"; import { DocumentLoader } from "@/context/document/DocumentLoader";
import { useDocument } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase";
import type { Campaign, DocumentId } from "@/lib/types";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { z } from "zod";
const CampaignTabs = {
sessions: { label: "Sessions", docType: "session" },
secrets: { label: "Secrets", docType: "secret" },
npcs: { label: "NPCs", docType: "npc" },
locations: { label: "Locations", docType: "location" },
threads: { label: "Threads", docType: "thread" },
fronts: { label: "Fronts", docType: "front" },
} as const;
const campaignSearchSchema = z.object({
tab: z
.enum(Object.keys(CampaignTabs) as (keyof typeof CampaignTabs)[])
.default("sessions"),
docId: z.optional(z.string().transform((s) => s as DocumentId)),
});
export const Route = createFileRoute( export const Route = createFileRoute(
"/_app/_authenticated/campaigns/$campaignId", "/_app/_authenticated/campaigns/$campaignId",
)({ )({
component: RouteComponent, component: RouteComponent,
pendingComponent: Loader, pendingComponent: Loader,
validateSearch: (s) => campaignSearchSchema.parse(s),
}); });
function RouteComponent() { function RouteComponent() {
const queryClient = useQueryClient();
const params = Route.useParams(); const params = Route.useParams();
const { tab, docId } = Route.useSearch();
const { const [loading, setLoading] = useState(true);
data: { campaign, sessions }, const [campaign, setCampaign] = useState<Campaign | null>(null);
} = useSuspenseQuery({
queryKey: ["campaign"], useEffect(() => {
queryFn: async () => { async function fetchData() {
setLoading(true);
const campaign = await pb const campaign = await pb
.collection("campaigns") .collection("campaigns")
.getOne(params.campaignId); .getOne(params.campaignId);
// Fetch all documents for this campaign setCampaign(campaign as Campaign);
const docs = await pb.collection("documents").getFullList({ setLoading(false);
filter: `campaign = "${params.campaignId}"`,
sort: "-created",
});
// Filter to only those with data.session
const sessions = docs.filter((doc: any) => doc.data && doc.data.session);
return {
campaign,
sessions,
};
},
});
const createNewSession = useCallback(async () => {
// Check for a previous session
const prevSession = await pb
.collection("documents")
.getFirstListItem(
`campaign = "${campaign.id}" && json_extract(data, '$.session') IS NOT NULL`,
{
sort: "-created",
},
);
console.log("Previous session: ", {
id: prevSession.id,
created: prevSession.created,
});
const newSession = await pb.collection("documents").create({
campaign: campaign.id,
data: {
session: {
strongStart: "",
},
},
});
queryClient.invalidateQueries({ queryKey: ["campaign"] });
// If any, then copy things over
if (prevSession) {
const prevRelations = await pb
.collection<Relationship>("relationships")
.getFullList({
filter: `primary = "${prevSession.id}"`,
});
console.log(`Found ${prevRelations.length} previous relations`);
for (const relation of prevRelations) {
console.log(
`Adding ${relation.secondary.length} items to ${relation.type}`,
);
await pb.collection("relationships").create({
primary: newSession.id,
type: relation.type,
seciondary: relation.secondary,
});
}
} }
fetchData();
}, [setCampaign, setLoading]);
queryClient.invalidateQueries({ queryKey: ["campaign"] }); if (loading || campaign === null) {
}, [campaign]); return <Loader />;
}
return ( return (
<div className="max-w-xl mx-auto py-8"> <TabbedLayout
<div className="mb-2"> title={
<h2 className="text-2xl font-bold text-slate-100">{campaign.name}</h2>
}
navigation={
<Link <Link
to="/campaigns" to="/campaigns"
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 campaigns Back to campaigns
</Link> </Link>
</div> }
<h2 className="text-2xl font-bold mb-4 text-slate-100"> tabs={Object.entries(CampaignTabs).map(([key, { label }]) => (
{campaign.name} <Tab
</h2> key={key}
<div className="flex justify-between"> label={label}
<h3 className="text-lg font-semibold mb-2 text-slate-200">Sessions</h3> active={tab === key}
<div> to={Route.to}
<Button params={{
onClick={() => createNewSession()} campaignId: campaign.id,
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" }}
> search={{
New Session tab: key,
</Button> }}
</div> />
</div> ))}
{sessions && sessions.length > 0 ? ( content={
<div> <CampaignDocuments
<ul className="space-y-2"> campaignId={campaign.id}
{sessions.map((s: any) => ( docType={CampaignTabs[tab].docType}
<li key={s.id}> />
<SessionRow session={s} /> }
</li> flyout={docId && <Flyout key={docId} docId={docId} />}
))} />
</ul>
</div>
) : (
<div className="text-slate-400">
No sessions found for this campaign.
</div>
)}
</div>
); );
} }
function Flyout({ docId }: { docId: DocumentId }) {
const { docResult } = useDocument(docId);
if (docResult?.type !== "ready") {
return (
<DocumentLoader documentId={docId}>
<Loader />
</DocumentLoader>
);
}
const doc = docResult.value.doc;
return <DocumentPreview doc={doc} />;
}

View File

@@ -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>

View File

@@ -0,0 +1,28 @@
import { DocumentView } from "@/components/documents/DocumentView";
import { DocumentLoader } from "@/context/document/DocumentLoader";
import { useDocumentPath } from "@/lib/documentPath";
import type { DocumentId } from "@/lib/types";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute(
"/_app/_authenticated/document/$documentId/$",
)({
component: RouteComponent,
});
function RouteComponent() {
const path = useDocumentPath();
const documentId = path?.documentId;
const relationshipType = path?.relationshipType ?? null;
const childDocId = path?.childDocId ?? null;
return (
<DocumentLoader documentId={documentId as DocumentId}>
<DocumentView
documentId={documentId as DocumentId}
relationshipType={relationshipType}
childDocId={childDocId}
/>
</DocumentLoader>
);
}

View File

@@ -1,60 +0,0 @@
import { RelationshipList } from "@/components/RelationshipList";
import { DocumentEditForm } from "@/components/documents/DocumentEditForm";
import { pb } from "@/lib/pocketbase";
import { displayName, relationshipsForDocument } from "@/lib/relationships";
import { RelationshipType, type AnyDocument } from "@/lib/types";
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react";
import { createFileRoute, Link } from "@tanstack/react-router";
export const Route = createFileRoute(
"/_app/_authenticated/document/$documentId",
)({
loader: async ({ params }) => {
const doc = await pb.collection("documents").getOne(params.documentId);
return {
document: doc,
};
},
component: RouteComponent,
});
function RouteComponent() {
const { document } = Route.useLoaderData() as {
document: AnyDocument;
};
const relationshipList = relationshipsForDocument(document);
return (
<div key={document.id} className="max-w-xl mx-auto py-2 px-4">
<Link
to="/document/$documentId/print"
params={{ documentId: document.id }}
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors mb-4"
>
Print
</Link>
<DocumentEditForm document={document} />
<TabGroup>
<TabList className="flex flex-row flex-wrap gap-1 mt-2">
{relationshipList.map((relationshipType) => (
<Tab className="px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500 data-selected:bg-violet-900 data-selected:border-violet-700">
{displayName(relationshipType)}
</Tab>
))}
</TabList>
<TabPanels>
{relationshipList.map((relationshipType) => (
<TabPanel>
<RelationshipList
key={relationshipType}
root={document}
relationshipType={relationshipType}
/>
</TabPanel>
))}
</TabPanels>
</TabGroup>
</div>
);
}

View File

@@ -1,72 +0,0 @@
import { DocumentPrintRow } from "@/components/documents/DocumentPrintRow";
import { SessionPrintRow } from "@/components/documents/session/SessionPrintRow";
import { Loader } from "@/components/Loader";
import { pb } from "@/lib/pocketbase";
import { RelationshipType, type Relationship, type Session } from "@/lib/types";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import _ from "lodash";
export const Route = createFileRoute(
"/_app_/_authenticated/document_/$documentId/print",
)({
component: RouteComponent,
pendingComponent: Loader,
});
function RouteComponent() {
const params = Route.useParams();
const {
data: { session, relationships },
} = useSuspenseQuery({
queryKey: ["session", "relationships"],
queryFn: async () => {
const session = await pb
.collection("documents")
.getOne(params.documentId);
const relationships: Relationship[] = await pb
.collection("relationships")
.getFullList({
filter: `primary = "${params.documentId}"`,
expand: "secondary",
});
console.log("Fetched data: ", relationships);
return {
session: session as Session,
relationships: _.mapValues(
_.groupBy(relationships, (r) => r.type),
(rs: Relationship[]) => rs.flatMap((r) => r.expand?.secondary),
),
};
},
});
console.log("Parsed data: ", relationships);
return (
<div className="fill-w py-8 columns-2 gap-8 text-sm">
<SessionPrintRow session={session}></SessionPrintRow>
{[
RelationshipType.Scenes,
RelationshipType.Secrets,
RelationshipType.Locations,
RelationshipType.Npcs,
RelationshipType.Monsters,
RelationshipType.Treasures,
].map((relationshipType) => (
<div className="break-inside-avoid">
<h3 className="text-lg font-bold text-slate-600">
{relationshipType.charAt(0).toUpperCase() +
relationshipType.slice(1)}
</h3>
<ul className="list-disc pl-5">
{(relationships[relationshipType] ?? []).map((item) => (
<DocumentPrintRow document={item} />
))}
</ul>
</div>
))}
</div>
);
}

View File

@@ -1,11 +1,13 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tailwindcss/utilities";
html, body { html,
body {
height: 100%; height: 100%;
min-height: 100%; min-height: 100%;
background-color: #0f172a; /* slate-900 */ background-color: #0f172a; /* slate-900 */
color: #f1f5f9; /* slate-100 */ color: #f1f5f9; /* slate-100 */
font-family: 'Inter', system-ui, sans-serif; font-family: "Inter", system-ui, sans-serif;
line-height: 1.5; line-height: 1.5;
} }
@@ -15,24 +17,21 @@ body {
min-width: 320px; min-width: 320px;
} }
code, pre { /* The container for all content */
font-family: 'Fira Mono', 'Menlo', 'Monaco', 'Consolas', monospace; #app {
height: 100%;
width: 100%;
}
code,
pre {
font-family: "Fira Mono", "Menlo", "Monaco", "Consolas", monospace;
background: #1e293b; /* slate-800 */ background: #1e293b; /* slate-800 */
color: #a5b4fc; /* violet-300 */ color: #a5b4fc; /* violet-300 */
border-radius: 0.25rem; border-radius: 0.25rem;
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
} }
a {
color: #a5b4fc; /* violet-300 */
text-decoration: underline;
transition: color 0.2s;
}
a:hover, a:focus {
color: #7c3aed; /* violet-600 */
}
/* Remove default outline, but keep focus-visible for accessibility */ /* Remove default outline, but keep focus-visible for accessibility */
:focus:not(:focus-visible) { :focus:not(:focus-visible) {
outline: none; outline: none;

View File

@@ -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,
},
}); });