Compare commits
65 Commits
3c989cf285
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bfd20ea8fb | |||
| 907df26395 | |||
| d1432d048f | |||
| e9d88fdce8 | |||
| f197a3fabe | |||
| f8aac31306 | |||
| d44fe72ff1 | |||
| 64aaad69d7 | |||
| c0638e34a8 | |||
| 8afe0a5345 | |||
| 625bc508aa | |||
| ab323798e9 | |||
| 6979bc4b8f | |||
| c9d27bce75 | |||
| 43afdc8684 | |||
| 1c26daa828 | |||
| 135debdf7f | |||
| 2fbc2c853f | |||
| 3310be9e9b | |||
| c7083a9b56 | |||
| 4a109d152c | |||
| 6d5d0e03a0 | |||
| 4c2ebdc292 | |||
| 8533f63a22 | |||
| 3390ecfb95 | |||
| b30999e907 | |||
| 2e9ea14507 | |||
| 762306023b | |||
| 8f96062058 | |||
| 258518d954 | |||
| 503c98c895 | |||
| db4ce36c27 | |||
| f27432ef05 | |||
| 32c5c40466 | |||
| f8130f0ba9 | |||
| 6ce462a77d | |||
| c00eb1d965 | |||
| 611eaca5b6 | |||
| 93536b0ac2 | |||
| 10554dbde1 | |||
| 0d1bbe7ea3 | |||
| 8bbb78fc58 | |||
| ad8fb07c69 | |||
| 293e1f9f62 | |||
| 9c607ba41a | |||
| ebe2e28cdb | |||
| 9f1e38ce47 | |||
| c28073d516 | |||
| 6845bd06bf | |||
| 38eee14253 | |||
| bef5e98480 | |||
| 9cfdfbaf23 | |||
| 9c0d9326e0 | |||
| f3bfb2736c | |||
| 4bed1c6e65 | |||
| 0ed2066b17 | |||
| 2c01a80604 | |||
| 81fd84790b | |||
| 6b6636d695 | |||
| d5dfa8c30a | |||
| 8b837d622c | |||
| 5eba132bda | |||
| 6336b150a7 | |||
| b3d4e90e7f | |||
| 8bee0973cd |
2
.envrc
2
.envrc
@@ -1,4 +1,4 @@
|
||||
use nix
|
||||
use flake
|
||||
|
||||
# Add Node modules only to the end of the path.
|
||||
# `layout node` will add it to the front, which is not as secure
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,4 +5,9 @@ dist-ssr
|
||||
*.local
|
||||
.direnv/
|
||||
.vite/
|
||||
|
||||
# Local Pocketbase data
|
||||
pb_data/
|
||||
|
||||
# Mprocs drops stuff here
|
||||
mprocs.log
|
||||
|
||||
153
README.md
153
README.md
@@ -1,6 +1,8 @@
|
||||
Welcome to your new TanStack app!
|
||||
# DM Companion App
|
||||
|
||||
# Getting Started
|
||||
## Development
|
||||
|
||||
### Getting Started
|
||||
|
||||
To run this application:
|
||||
|
||||
@@ -9,7 +11,7 @@ npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
# Building For Production
|
||||
### Building For Production
|
||||
|
||||
To build this application for production:
|
||||
|
||||
@@ -17,7 +19,7 @@ To build this application for production:
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Testing
|
||||
### Testing
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Styling
|
||||
### 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`.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
### Adding Links
|
||||
#### Adding Links
|
||||
|
||||
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).
|
||||
|
||||
### 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.
|
||||
|
||||
Here is an example layout that includes a header:
|
||||
|
||||
```tsx
|
||||
import { Outlet, createRootRoute } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
@@ -86,129 +86,20 @@ export const Route = createRootRoute({
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
),
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
### 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:
|
||||
|
||||
```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
|
||||
### 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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).
|
||||
|
||||
11
docker/app.dockerfile
Normal file
11
docker/app.dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:22-alpine3.20 AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
20
docker/pocketbase.dockerfile
Normal file
20
docker/pocketbase.dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# See https://pocketbase.io/docs/going-to-production/#using-docker
|
||||
FROM alpine:latest
|
||||
|
||||
ARG PB_VERSION=0.28.1
|
||||
|
||||
RUN apk add --no-cache \
|
||||
unzip \
|
||||
ca-certificates
|
||||
|
||||
# download and unzip PocketBase
|
||||
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
|
||||
RUN unzip /tmp/pb.zip -d /pb/
|
||||
|
||||
COPY ./pb_migrations /pb/pb_migrations
|
||||
COPY ./pb_hooks /pb/pb_hooks
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# start PocketBase
|
||||
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080"]
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1748302896,
|
||||
"narHash": "sha256-ixMT0a8mM091vSswlTORZj93WQAJsRNmEvqLL+qwTFM=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7848cd8c982f7740edf76ddb3b43d234cb80fc4d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"utils": "utils"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
134
flake.nix
Normal file
134
flake.nix
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-25.05";
|
||||
utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
utils,
|
||||
}:
|
||||
utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
# The path to the npm project
|
||||
src = ./.;
|
||||
|
||||
# Read the package-lock.json as a Nix attrset
|
||||
packageLock = builtins.fromJSON (builtins.readFile (src + "/package-lock.json"));
|
||||
|
||||
# Create an array of all (meaningful) dependencies
|
||||
deps = builtins.attrValues (removeAttrs packageLock.packages [ "" ]);
|
||||
# ++ builtins.attrValues (removeAttrs packageLock.dependencies [ "" ]);
|
||||
|
||||
# Turn each dependency into a fetchurl call
|
||||
tarballs = map (
|
||||
p:
|
||||
pkgs.fetchurl {
|
||||
url = p.resolved;
|
||||
hash = p.integrity;
|
||||
}
|
||||
) deps;
|
||||
|
||||
# Write a file with the list of tarballs
|
||||
tarballsFile = pkgs.writeTextFile {
|
||||
name = "tarballs";
|
||||
text = builtins.concatStringsSep "\n" tarballs;
|
||||
};
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
devShell =
|
||||
with pkgs;
|
||||
mkShell {
|
||||
buildInputs = [
|
||||
nodejs_22 # Even versions are more stable
|
||||
pocketbase
|
||||
];
|
||||
};
|
||||
|
||||
nodeModules = pkgs.stdenv.mkDerivation {
|
||||
name = "lazy-dm-node-modules";
|
||||
src = ./.;
|
||||
buildInputs = [ pkgs.nodejs ];
|
||||
buildPhase = ''
|
||||
export HOME=$PWD/.home
|
||||
export npm_config_cache=$PWD/.npm
|
||||
mkdir -p $out
|
||||
cd $out
|
||||
cp -r $src/package.json $src/package-lock.json .
|
||||
|
||||
while read package
|
||||
do
|
||||
echo "caching $package"
|
||||
npm cache add "$package"
|
||||
done <${tarballsFile}
|
||||
|
||||
npm ci
|
||||
'';
|
||||
};
|
||||
|
||||
# Derivation for node_modules (npm ci)
|
||||
# nodeModules = pkgs.stdenv.mkDerivation {
|
||||
# name = "lazy-dm-node-modules";
|
||||
# src = ./.;
|
||||
# buildInputs = [ pkgs.nodejs_22 ];
|
||||
# installPhase = ''
|
||||
# mkdir -p $out
|
||||
# cp package.json package-lock.json $out/
|
||||
# cd $out
|
||||
# npm ci --ignore-scripts
|
||||
# '';
|
||||
# # Only output node_modules
|
||||
# dontBuild = true;
|
||||
# dontConfigure = true;
|
||||
# };
|
||||
|
||||
# Derivation for vite build
|
||||
viteBuild = pkgs.stdenv.mkDerivation {
|
||||
name = "lazy-dm-vite-build";
|
||||
src = ./.;
|
||||
buildInputs = [ pkgs.nodejs_22 ];
|
||||
# Use node_modules from previous derivation
|
||||
NODE_PATH = "${self.outputs.nodeModules}/node_modules";
|
||||
installPhase = ''
|
||||
cp -r $src $out/app
|
||||
cd $out/app
|
||||
cp ${self.outputs.nodeModules}/package.json .
|
||||
cp -r ${self.outputs.nodeModules}/node_modules .
|
||||
npm run build
|
||||
mkdir -p $out/dist
|
||||
cp -r dist/* $out/dist/
|
||||
'';
|
||||
dontBuild = true;
|
||||
dontConfigure = true;
|
||||
};
|
||||
|
||||
dockerImage = pkgs.dockerTools.buildLayeredImage {
|
||||
name = "lazy-dm-app";
|
||||
tag = "latest";
|
||||
contents = [ pkgs.caddy ];
|
||||
config = {
|
||||
Cmd = [
|
||||
"/bin/caddy"
|
||||
"file-server"
|
||||
"--root"
|
||||
"/srv"
|
||||
"--listen"
|
||||
":8080"
|
||||
];
|
||||
ExposedPorts = {
|
||||
"8080/tcp" = { };
|
||||
};
|
||||
WorkingDir = "/srv";
|
||||
};
|
||||
extraCommands = ''
|
||||
mkdir -p $out/srv
|
||||
cp -r ${self.outputs.viteBuild}/dist/* $out/srv/
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -5,16 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-tsrouter-app"
|
||||
/>
|
||||
<meta name="description" content="Plan and run your D&D sessions" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Create TanStack App - .</title>
|
||||
<title>Dungeon Master's Companion</title>
|
||||
</head>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
375
package-lock.json
generated
375
package-lock.json
generated
@@ -6,19 +6,26 @@
|
||||
"": {
|
||||
"name": "lazy-dm",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-query": "^5.77.2",
|
||||
"@tanstack/react-query-devtools": "^5.79.0",
|
||||
"@tanstack/react-router": "^1.114.3",
|
||||
"@tanstack/react-router-devtools": "^1.114.3",
|
||||
"@tanstack/router-plugin": "^1.114.3",
|
||||
"dompurify": "^3.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^16.1.1",
|
||||
"pocketbase": "^0.26.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.6"
|
||||
"tailwindcss": "^4.0.6",
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -63,6 +70,17 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@atlaskit/pragmatic-drag-and-drop": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.4.tgz",
|
||||
"integrity": "sha512-lZHnO9BJdHPKnwB0uvVUCyDnIhL+WAHzXQ2EXX0qacogOsnvIUiCgY0BLKhBqTCWln3/f/Ox5jU54MKO6ayh9A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"bind-event-listener": "^3.0.0",
|
||||
"raf-schd": "^4.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -308,7 +326,6 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
|
||||
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -874,6 +891,79 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
|
||||
"integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz",
|
||||
"integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.0",
|
||||
"@floating-ui/utils": "^0.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.26.28",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.2",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
|
||||
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@headlessui/react": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.4.tgz",
|
||||
"integrity": "sha512-lz+OGcAH1dK93rgSMzXmm1qKOJkBUqZf1L4M8TWLNplftQD3IkoEDdUFNfAn4ylsN6WOTVtWaLmvmaHOUk1dTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.20.2",
|
||||
"@react-aria/interactions": "^3.25.0",
|
||||
"@tanstack/react-virtual": "^3.13.9",
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -934,6 +1024,103 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/focus": {
|
||||
"version": "3.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.3.tgz",
|
||||
"integrity": "sha512-rR5uZUMSY4xLHmpK/I8bP1V6vUNHFo33gTvrvNUsAKKqvMfa7R2nu5A6v97dr5g6tVH6xzpdkPsOJCWh90H2cw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/interactions": "^3.25.1",
|
||||
"@react-aria/utils": "^3.29.0",
|
||||
"@react-types/shared": "^3.29.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/interactions": {
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.1.tgz",
|
||||
"integrity": "sha512-ntLrlgqkmZupbbjekz3fE/n3eQH2vhncx8gUp0+N+GttKWevx7jos11JUBjnJwb1RSOPgRUFcrluOqBp0VgcfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.8",
|
||||
"@react-aria/utils": "^3.29.0",
|
||||
"@react-stately/flags": "^3.1.1",
|
||||
"@react-types/shared": "^3.29.1",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.8.tgz",
|
||||
"integrity": "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/utils": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.29.0.tgz",
|
||||
"integrity": "sha512-jSOrZimCuT1iKNVlhjIxDkAhgF7HSp3pqyT6qjg/ZoA0wfqCi/okmrMPiWSAKBnkgX93N8GYTLT3CIEO6WZe9Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.8",
|
||||
"@react-stately/flags": "^3.1.1",
|
||||
"@react-stately/utils": "^3.10.6",
|
||||
"@react-types/shared": "^3.29.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/flags": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.1.tgz",
|
||||
"integrity": "sha512-XPR5gi5LfrPdhxZzdIlJDz/B5cBf63l4q6/AzNqVWFKgd0QqY5LvWJftXkklaIUpKSJkIKQb8dphuZXDtkWNqg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/utils": {
|
||||
"version": "3.10.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.6.tgz",
|
||||
"integrity": "sha512-O76ip4InfTTzAJrg8OaZxKU4vvjMDOpfA/PGNOytiXwBbkct2ZeZwaimJ8Bt9W1bj5VsZ81/o/tW4BacbdDOMA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.29.1.tgz",
|
||||
"integrity": "sha512-KtM+cDf2CXoUX439rfEhbnEdAgFZX20UP2A35ypNIawR7/PFFPjQDWyA2EnClCcW/dLWJDEPX2U8+EJff8xqmQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz",
|
||||
@@ -1201,6 +1388,15 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz",
|
||||
@@ -1477,9 +1673,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.77.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.77.2.tgz",
|
||||
"integrity": "sha512-1lqJwPsR6GX6nZFw06erRt518O19tWU6Q+x0fJUygl4lxHCYF2nhzBPwLKk2NPjYOrpR0K567hxPc5K++xDe9Q==",
|
||||
"version": "5.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.79.0.tgz",
|
||||
"integrity": "sha512-s+epTqqLM0/TbJzMAK7OEhZIzh63P9sWz5HEFc5XHL4FvKQXQkcjI8F3nee+H/xVVn7mrP610nVXwOytTSYd0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.76.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.76.0.tgz",
|
||||
"integrity": "sha512-1p92nqOBPYVqVDU0Ua5nzHenC6EGZNrLnB2OZphYw8CNA1exuvI97FVgIKON7Uug3uQqvH/QY8suUKpQo8qHNQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -1487,12 +1694,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.77.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.77.2.tgz",
|
||||
"integrity": "sha512-BRHxWdy1mHmgAcYA/qy2IPLylT81oebLgkm9K85viN2Qol/Vq48t1dzDFeDIVQjTWDV96AmqsLNPlH5HjyKCxA==",
|
||||
"version": "5.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.79.0.tgz",
|
||||
"integrity": "sha512-DjC4JIYZnYzxaTzbg3osOU63VNLP67dOrWet2cZvXgmgwAXNxfS52AMq86M5++ILuzW+BqTUEVMTjhrZ7/XBuA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.77.2"
|
||||
"@tanstack/query-core": "5.79.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -1502,6 +1710,23 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.79.0.tgz",
|
||||
"integrity": "sha512-YVRWxjxsWycWChjKxvaIAPdNC5LX0zpiHoNyTB8teDZpQstM1b7mCuAp3x60cjX1MhLoO3vbaeY29EKst4D4ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.76.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.79.0",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-router": {
|
||||
"version": "1.120.10",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.120.10.tgz",
|
||||
@@ -1567,6 +1792,23 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.9.tgz",
|
||||
"integrity": "sha512-SPWC8kwG/dWBf7Py7cfheAPOxuvIv4fFQ54PdmYbg7CpXfsKxkucak43Q0qKsxVthhUJQ1A7CIMAIplq4BjVwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.9"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/router-core": {
|
||||
"version": "1.120.10",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.120.10.tgz",
|
||||
@@ -1640,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": {
|
||||
"version": "1.120.10",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.120.10.tgz",
|
||||
@@ -1696,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": {
|
||||
"version": "1.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.115.0.tgz",
|
||||
@@ -1725,6 +1985,16 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.9.tgz",
|
||||
"integrity": "sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-file-routes": {
|
||||
"version": "1.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.115.0.tgz",
|
||||
@@ -1840,6 +2110,13 @@
|
||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz",
|
||||
"integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz",
|
||||
@@ -1860,6 +2137,13 @@
|
||||
"@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": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz",
|
||||
@@ -2108,6 +2392,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bind-event-listener": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz",
|
||||
"integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
@@ -2397,6 +2687,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.5.157",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz",
|
||||
@@ -3008,6 +3307,12 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
|
||||
@@ -3043,6 +3348,18 @@
|
||||
"@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": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
@@ -3260,6 +3577,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/raf-schd": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
@@ -3482,6 +3805,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
|
||||
@@ -3679,6 +4008,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.19.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz",
|
||||
@@ -4106,10 +4441,24 @@
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"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": {
|
||||
"version": "3.25.28",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.28.tgz",
|
||||
"integrity": "sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.0.5.tgz",
|
||||
"integrity": "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
22
package.json
22
package.json
@@ -3,26 +3,36 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000",
|
||||
"start": "vite --port 3000",
|
||||
"build": "vite build && tsc",
|
||||
"dev": "mprocs \"npm run start\" \"pocketbase serve\"",
|
||||
"start": "VITE_POCKETBASE_URL=http://localhost:8090 vite --port 3000",
|
||||
"build": "tsc && vite build",
|
||||
"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:pocketbase": "docker build -t docker.havenisms.com/lazy-dm/pocketbase -f docker/pocketbase.dockerfile .",
|
||||
"docker:build": "npm run docker:build:app && npm run docker:build:pocketbase"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-query": "^5.77.2",
|
||||
"@tanstack/react-query-devtools": "^5.79.0",
|
||||
"@tanstack/react-router": "^1.114.3",
|
||||
"@tanstack/react-router-devtools": "^1.114.3",
|
||||
"@tanstack/router-plugin": "^1.114.3",
|
||||
"dompurify": "^3.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^16.1.1",
|
||||
"pocketbase": "^0.26.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.6"
|
||||
"tailwindcss": "^4.0.6",
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
42
pb_migrations/1748733166_updated_relationships.js
Normal file
42
pb_migrations/1748733166_updated_relationships.js
Normal 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(3, new Field({
|
||||
"hidden": false,
|
||||
"id": "select2363381545",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"discoveredIn",
|
||||
"secrets"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// 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": [
|
||||
"discoveredIn",
|
||||
"plannedSecrets"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
43
pb_migrations/1748738493_updated_relationships.js
Normal file
43
pb_migrations/1748738493_updated_relationships.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// 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": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// 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": [
|
||||
"discoveredIn",
|
||||
"secrets"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
45
pb_migrations/1748740224_updated_relationships.js
Normal file
45
pb_migrations/1748740224_updated_relationships.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// 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": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures",
|
||||
"scenes"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// 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": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
48
pb_migrations/1748755724_updated_relationships.js
Normal file
48
pb_migrations/1748755724_updated_relationships.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// 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": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures",
|
||||
"scenes",
|
||||
"npcs",
|
||||
"locations"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// 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": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures",
|
||||
"scenes"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
51
pb_migrations/1748757068_updated_relationships.js
Normal file
51
pb_migrations/1748757068_updated_relationships.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// 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": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures",
|
||||
"scenes",
|
||||
"npcs",
|
||||
"locations",
|
||||
"monsters"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_617371094")
|
||||
|
||||
// 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": [
|
||||
"discoveredIn",
|
||||
"secrets",
|
||||
"treasures",
|
||||
"scenes",
|
||||
"npcs",
|
||||
"locations"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
46
pb_migrations/1751082417_updated_documents.js
Normal file
46
pb_migrations/1751082417_updated_documents.js
Normal 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)
|
||||
})
|
||||
25
pb_migrations/1751082429_updated_documents.js
Normal file
25
pb_migrations/1751082429_updated_documents.js
Normal 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)
|
||||
})
|
||||
54
pb_migrations/1751082553_extract_document_types.js
Normal file
54
pb_migrations/1751082553_extract_document_types.js
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
42
pb_migrations/1751155422_updated_relationships.js
Normal file
42
pb_migrations/1751155422_updated_relationships.js
Normal 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)
|
||||
})
|
||||
34
pb_migrations/1751155435_updated_documents.js
Normal file
34
pb_migrations/1751155435_updated_documents.js
Normal 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)
|
||||
})
|
||||
40
pb_migrations/1751156191_updated_relationships.js
Normal file
40
pb_migrations/1751156191_updated_relationships.js
Normal 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)
|
||||
})
|
||||
54
pb_migrations/1754253375_updated_documents.js
Normal file
54
pb_migrations/1754253375_updated_documents.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_3332084752")
|
||||
|
||||
// update field
|
||||
collection.fields.addAt(3, new Field({
|
||||
"hidden": false,
|
||||
"id": "select2363381545",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"location",
|
||||
"monster",
|
||||
"npc",
|
||||
"scene",
|
||||
"secret",
|
||||
"session",
|
||||
"treasure",
|
||||
"thread",
|
||||
"front"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_3332084752")
|
||||
|
||||
// update field
|
||||
collection.fields.addAt(3, new Field({
|
||||
"hidden": false,
|
||||
"id": "select2363381545",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"location",
|
||||
"monster",
|
||||
"npc",
|
||||
"scene",
|
||||
"secret",
|
||||
"session",
|
||||
"treasure"
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "TanStack App",
|
||||
"name": "Create TanStack App Sample",
|
||||
"short_name": "DM Companion",
|
||||
"name": "Dungeon Master Companion",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
||||
21
shell.nix
21
shell.nix
@@ -1,21 +0,0 @@
|
||||
{
|
||||
pkgs ? import <nixpkgs> { },
|
||||
}:
|
||||
pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_22 # Even versions are more stable
|
||||
pocketbase
|
||||
# (vscode-with-extensions.override {
|
||||
# vscodeExtensions = with pkgs.vscode-extensions; [
|
||||
# asvetliakov.vscode-neovim
|
||||
# enkia.tokyo-night
|
||||
# github.copilot
|
||||
# github.copilot-chat
|
||||
# ];
|
||||
# })
|
||||
# Full VSCode seems to be required to get the Copilot extension to be able
|
||||
# to authenticate, likely due to some setting not present in
|
||||
# `vscode-with-extensions`.
|
||||
# vscode
|
||||
];
|
||||
}
|
||||
@@ -9,12 +9,14 @@ export function AutoSaveTextarea({
|
||||
onSave,
|
||||
delay = 500,
|
||||
className = "",
|
||||
multiline = true,
|
||||
...props
|
||||
}: {
|
||||
value: string;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
multiline?: boolean;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue || "");
|
||||
@@ -35,7 +37,9 @@ export function AutoSaveTextarea({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) => {
|
||||
setValue(e.target.value);
|
||||
setSaved(false);
|
||||
setFlash(false);
|
||||
@@ -55,12 +59,21 @@ export function AutoSaveTextarea({
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className={`w-full min-h-[4rem] 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}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
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 ${flash ? "ring-2 ring-emerald-400 border-emerald-400 bg-emerald-950" : ""} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
{saved && !saving && (
|
||||
<span className="absolute bottom-1 right-2 text-emerald-400 text-xs bg-slate-900 bg-opacity-80 px-2 py-0.5 rounded shadow">
|
||||
Saved
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { useAuth } from "@/context/auth/AuthContext";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { Campaign } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Button and form for creating a new campaign. Handles UI state and creation logic.
|
||||
*/
|
||||
export function CreateCampaignButton({ onCreated }: { onCreated?: (campaign: Campaign) => void }) {
|
||||
export function CreateCampaignButton({
|
||||
onCreated,
|
||||
}: {
|
||||
onCreated?: (campaign: Campaign) => void;
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -29,7 +33,7 @@ export function CreateCampaignButton({ onCreated }: { onCreated?: (campaign: Cam
|
||||
});
|
||||
setName("");
|
||||
setCreating(false);
|
||||
if (onCreated) onCreated({ id: record.id, name: record.name });
|
||||
if (onCreated) onCreated(record as Campaign);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create campaign.");
|
||||
} finally {
|
||||
@@ -55,7 +59,7 @@ export function CreateCampaignButton({ onCreated }: { onCreated?: (campaign: Cam
|
||||
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"
|
||||
placeholder="Campaign name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -68,7 +72,11 @@ export function CreateCampaignButton({ onCreated }: { onCreated?: (campaign: Cam
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-2 rounded text-slate-400 hover:text-red-400"
|
||||
onClick={() => { setCreating(false); setName(""); setError(null); }}
|
||||
onClick={() => {
|
||||
setCreating(false);
|
||||
setName("");
|
||||
setError(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
aria-label="Cancel"
|
||||
>
|
||||
|
||||
140
src/components/DocumentList.tsx
Normal file
140
src/components/DocumentList.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as Icons from "@/components/Icons.tsx";
|
||||
import type { AnyDocument, DocumentId } from "@/lib/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from "@headlessui/react";
|
||||
import { Fragment, useCallback, useState } from "react";
|
||||
|
||||
type Props<T extends AnyDocument> = {
|
||||
title?: React.ReactNode;
|
||||
error?: React.ReactNode;
|
||||
items: T[];
|
||||
renderRow: (item: T) => React.ReactNode;
|
||||
newItemForm: (onSubmit: () => void) => React.ReactNode;
|
||||
removeItem: (itemId: DocumentId) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* DocumentList is a generic list component for displaying document items with a dialog for adding new items.
|
||||
*
|
||||
* @param title - The title displayed above the list (left-aligned)
|
||||
* @param items - The array of document items to display
|
||||
* @param renderRow - Function to render each row's content
|
||||
* @param newItemForm - Function that renders a form for creating a new item; receives an onSubmit callback
|
||||
*/
|
||||
export function DocumentList<T extends AnyDocument>({
|
||||
title,
|
||||
error,
|
||||
items,
|
||||
renderRow,
|
||||
newItemForm,
|
||||
removeItem,
|
||||
}: Props<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const toggleEditMode = useCallback(
|
||||
() => setIsEditing((x) => !x),
|
||||
[setIsEditing],
|
||||
);
|
||||
|
||||
// Handles closing the dialog after form submission
|
||||
const handleFormSubmit = (): void => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
{title && <h2 className="text-xl font-bold text-slate-100">{title}</h2>}
|
||||
<div className="flex gap-2">
|
||||
{isEditing && (
|
||||
<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="Add new item"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Icons.Cross />
|
||||
</button>
|
||||
)}
|
||||
<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={toggleEditMode}
|
||||
>
|
||||
{isEditing ? <Icons.Done /> : <Icons.Edit />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="bg-red-900 rounded p-4 text-slate-100">{error}</div>
|
||||
)}
|
||||
<ul className="flex flex-col space-y-2">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="p-2 m-0 border-b-1 last:border-0 border-slate-700 flex flex-row justify-between items-center"
|
||||
>
|
||||
{renderRow(item)}
|
||||
|
||||
{isEditing && (
|
||||
<div>
|
||||
<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="Remove item"
|
||||
onClick={() => removeItem(item.id)}
|
||||
>
|
||||
<Icons.Remove />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={setOpen}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" />
|
||||
</TransitionChild>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
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">
|
||||
{newItemForm(handleFormSubmit)}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3 right-3 text-slate-400 hover:text-red-400 focus:outline-none"
|
||||
aria-label="Close dialog"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
35
src/components/EditToggle.tsx
Normal file
35
src/components/EditToggle.tsx
Normal 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}</>
|
||||
);
|
||||
3
src/components/FormattedDate.tsx
Normal file
3
src/components/FormattedDate.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const FormattedDate = ({ date }: { date: string }) => (
|
||||
<span>{new Date(date).toLocaleString()}</span>
|
||||
);
|
||||
22
src/components/FormattedText.tsx
Normal file
22
src/components/FormattedText.tsx
Normal 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>;
|
||||
}
|
||||
56
src/components/Icons.tsx
Normal file
56
src/components/Icons.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
export const Edit = () => (
|
||||
// Pencil icon
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 5.487a2.25 2.25 0 1 1 3.182 3.182L8.25 20.463 3 21.75l1.287-5.25 12.575-11.013z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Cross = () => (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Remove = () => (
|
||||
<svg
|
||||
className="w-5 h-5 rotate-45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Done = () => (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
135
src/components/RelationshipList.tsx
Normal file
135
src/components/RelationshipList.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { DocumentList } from "@/components/DocumentList";
|
||||
import { useDocumentCache, useDocument } from "@/context/document/hooks";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { displayName } from "@/lib/relationships";
|
||||
import type {
|
||||
AnyDocument,
|
||||
DocumentId,
|
||||
Relationship,
|
||||
RelationshipType,
|
||||
} from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
import { Loader } from "./Loader";
|
||||
import { DocumentRow } from "./documents/DocumentRow";
|
||||
import { NewRelatedDocumentForm } from "./documents/NewRelatedDocumentForm";
|
||||
|
||||
interface RelationshipListProps {
|
||||
root: AnyDocument;
|
||||
relationshipType: RelationshipType;
|
||||
}
|
||||
|
||||
/**
|
||||
* RelationshipList manages a list of documents related to a root document via a relationship type.
|
||||
* It handles fetching, creation, and relationship management, and renders a DocumentList.
|
||||
*/
|
||||
export function RelationshipList({
|
||||
root,
|
||||
relationshipType,
|
||||
}: RelationshipListProps) {
|
||||
const [_loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { docResult, dispatch } = useDocument(root.id);
|
||||
const { cache } = useDocumentCache();
|
||||
|
||||
if (docResult.type !== "ready") {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const relationshipResult = docResult.value.relationships[relationshipType];
|
||||
|
||||
const relationship =
|
||||
relationshipResult?.type === "ready" ? relationshipResult.value : null;
|
||||
|
||||
const itemIds =
|
||||
relationshipResult?.type === "ready"
|
||||
? relationshipResult.value.secondary
|
||||
: [];
|
||||
|
||||
const items = itemIds
|
||||
.map((id) => cache.documents[id])
|
||||
.filter((d) => d && d.type === "ready")
|
||||
.map((d) => d.value.doc);
|
||||
|
||||
const handleCreate = async (doc: AnyDocument) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Check for existing relationship
|
||||
if (relationship) {
|
||||
const updatedRelationship: Relationship = await pb
|
||||
.collection("relationships")
|
||||
.update(relationship.id, {
|
||||
"+secondary": doc.id,
|
||||
});
|
||||
dispatch({
|
||||
type: "setRelationship",
|
||||
docId: root.id,
|
||||
relationship: updatedRelationship,
|
||||
});
|
||||
} else {
|
||||
const updatedRelationship: Relationship = await pb
|
||||
.collection("relationships")
|
||||
.create({
|
||||
primary: root.id,
|
||||
secondary: [doc.id],
|
||||
type: relationshipType,
|
||||
});
|
||||
dispatch({
|
||||
type: "setRelationship",
|
||||
docId: root.id,
|
||||
relationship: updatedRelationship,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to add document to relationship.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (documentId: DocumentId) => {
|
||||
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 (
|
||||
<DocumentList
|
||||
title={displayName(relationshipType)}
|
||||
items={items}
|
||||
error={error}
|
||||
renderRow={(document) => <DocumentRow document={document} root={root} />}
|
||||
removeItem={handleRemove}
|
||||
newItemForm={(onSubmit) => (
|
||||
<NewRelatedDocumentForm
|
||||
campaignId={root.campaign}
|
||||
relationshipType={relationshipType}
|
||||
onCreate={async (doc: AnyDocument) => {
|
||||
await handleCreate(doc);
|
||||
onSubmit();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
69
src/components/campaign/CampaignDocuments.tsx
Normal file
69
src/components/campaign/CampaignDocuments.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
type AnyDocument,
|
||||
type CampaignId,
|
||||
type DocumentId,
|
||||
type DocumentType,
|
||||
} from "@/lib/types";
|
||||
import { useDocumentCache } from "@/context/document/hooks";
|
||||
import { DocumentList } from "../DocumentList";
|
||||
import { getAllDocumentsOfType } from "@/context/document/state";
|
||||
import { DocumentRow } from "../documents/DocumentRow";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { useEffect } from "react";
|
||||
import { NewCampaignDocumentForm } from "../documents/NewCampaignDocumentForm";
|
||||
|
||||
export type Props = {
|
||||
campaignId: CampaignId;
|
||||
docType: DocumentType;
|
||||
};
|
||||
|
||||
export const CampaignDocuments = ({ campaignId, docType }: Props) => {
|
||||
const { cache, dispatch } = useDocumentCache();
|
||||
|
||||
const items = getAllDocumentsOfType(docType, cache);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDocuments() {
|
||||
const documents: AnyDocument[] = await pb
|
||||
.collection("documents")
|
||||
.getFullList({
|
||||
filter: `campaign = "${campaignId}" && type = "${docType}"`,
|
||||
sort: "created",
|
||||
});
|
||||
|
||||
for (const doc of documents) {
|
||||
dispatch({
|
||||
type: "setDocument",
|
||||
doc,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fetchDocuments();
|
||||
}, [campaignId, docType]);
|
||||
|
||||
const handleRemove = (id: DocumentId) => {
|
||||
pb.collection("documents").delete(id);
|
||||
dispatch({
|
||||
type: "removeDocument",
|
||||
docId: id,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentList
|
||||
items={items}
|
||||
renderRow={(doc) => <DocumentRow document={doc} />}
|
||||
newItemForm={(onSubmit) => (
|
||||
<NewCampaignDocumentForm
|
||||
campaignId={campaignId}
|
||||
docType={docType}
|
||||
onCreate={async () => {
|
||||
onSubmit();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
removeItem={handleRemove}
|
||||
/>
|
||||
);
|
||||
};
|
||||
27
src/components/documents/BasicPreview.tsx
Normal file
27
src/components/documents/BasicPreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
26
src/components/documents/BasicRow.tsx
Normal file
26
src/components/documents/BasicRow.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { AnyDocument } from "@/lib/types";
|
||||
import { FormattedText } from "../FormattedText";
|
||||
import { DocumentLink } from "./DocumentLink";
|
||||
|
||||
export type Props = {
|
||||
doc: AnyDocument;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a simple row that links to the document
|
||||
*/
|
||||
export const BasicRow = ({ doc, title, description }: Props) => {
|
||||
return (
|
||||
<div>
|
||||
<DocumentLink
|
||||
childDocId={doc.id}
|
||||
className="!no-underline text-slate-100 hover:underline hover:text-violet-400"
|
||||
>
|
||||
{title && <h4 className="font-bold">{title}</h4>}
|
||||
{description && <FormattedText>{description}</FormattedText>}
|
||||
</DocumentLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
src/components/documents/DocumentLink.tsx
Normal file
53
src/components/documents/DocumentLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/documents/DocumentPreview.tsx
Normal file
86
src/components/documents/DocumentPreview.tsx
Normal 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} />;
|
||||
}
|
||||
};
|
||||
71
src/components/documents/DocumentRow.tsx
Normal file
71
src/components/documents/DocumentRow.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// DocumentRow.tsx
|
||||
// Generic row component for displaying any document type.
|
||||
import { SecretToggleRow } from "@/components/documents/secret/SecretToggleRow";
|
||||
import { type AnyDocument } from "@/lib/types";
|
||||
import { BasicRow } from "./BasicRow";
|
||||
import { TreasureToggleRow } from "./treasure/TreasureToggleRow";
|
||||
|
||||
/**
|
||||
* 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 DocumentRow = ({
|
||||
document,
|
||||
root,
|
||||
}: {
|
||||
document: AnyDocument;
|
||||
root?: AnyDocument;
|
||||
}) => {
|
||||
switch (document.type) {
|
||||
case "front":
|
||||
return (
|
||||
<BasicRow
|
||||
doc={document}
|
||||
title={document.data.name}
|
||||
description={document.data.description}
|
||||
/>
|
||||
);
|
||||
|
||||
case "location":
|
||||
return (
|
||||
<BasicRow
|
||||
doc={document}
|
||||
title={document.data.name}
|
||||
description={document.data.description}
|
||||
/>
|
||||
);
|
||||
|
||||
case "monster":
|
||||
return <BasicRow doc={document} title={document.data.name} />;
|
||||
|
||||
case "npc":
|
||||
return (
|
||||
<BasicRow
|
||||
doc={document}
|
||||
title={document.data.name}
|
||||
description={document.data.description}
|
||||
/>
|
||||
);
|
||||
|
||||
case "session":
|
||||
return (
|
||||
<BasicRow
|
||||
doc={document}
|
||||
title={document.data.name || document.created}
|
||||
description={document.data.strongStart}
|
||||
/>
|
||||
);
|
||||
|
||||
case "secret":
|
||||
return <SecretToggleRow secret={document} root={root} />;
|
||||
|
||||
case "scene":
|
||||
return <BasicRow doc={document} description={document.data.text} />;
|
||||
|
||||
case "thread":
|
||||
return <BasicRow doc={document} description={document.data.text} />;
|
||||
|
||||
case "treasure":
|
||||
return <TreasureToggleRow treasure={document} root={root} />;
|
||||
}
|
||||
};
|
||||
28
src/components/documents/DocumentTitle.tsx
Normal file
28
src/components/documents/DocumentTitle.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
112
src/components/documents/DocumentView.tsx
Normal file
112
src/components/documents/DocumentView.tsx
Normal 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} />;
|
||||
}
|
||||
82
src/components/documents/GenericEditForm.tsx
Normal file
82
src/components/documents/GenericEditForm.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { getDocumentType, type AnyDocument } from "@/lib/types";
|
||||
import { useDocumentCache } from "@/context/document/hooks";
|
||||
import {
|
||||
getFieldsForType,
|
||||
type DocumentField,
|
||||
type FieldType,
|
||||
} from "@/lib/fields";
|
||||
import { ToggleInput } from "../form/ToggleInput";
|
||||
|
||||
export type GenericFieldType = "multiline" | "singleline" | "checkbox";
|
||||
|
||||
export type Props<T extends AnyDocument> = {
|
||||
doc: T;
|
||||
};
|
||||
|
||||
export const GenericEditForm = <T extends AnyDocument>({ doc }: Props<T>) => {
|
||||
const docType = getDocumentType(doc) as T["type"];
|
||||
const fields = getFieldsForType(docType);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{
|
||||
// The type checker seems to lose the types when using Object.entries here.
|
||||
fields.map((documentField) => (
|
||||
<GenericEditFormField doc={doc} field={documentField} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GenericEditFormField = <T extends AnyDocument>({
|
||||
doc,
|
||||
field,
|
||||
}: {
|
||||
doc: T;
|
||||
field: DocumentField<T["type"], FieldType>;
|
||||
}) => {
|
||||
const { dispatch } = useDocumentCache();
|
||||
|
||||
// The type checker really doesn't like indexing into this type implicitly, so we'll store it in a temporary to give it the right hints.
|
||||
const data = doc.data as T["data"];
|
||||
|
||||
async function saveField(value: string | boolean) {
|
||||
const updated: T = await pb.collection("documents").update(doc.id, {
|
||||
data: field.setter(value, doc.data),
|
||||
});
|
||||
dispatch({ type: "setDocument", doc: updated });
|
||||
}
|
||||
|
||||
switch (field.fieldType) {
|
||||
case "longText":
|
||||
return (
|
||||
<AutoSaveTextarea
|
||||
multiline={true}
|
||||
value={field.getter(data) as string}
|
||||
onSave={saveField}
|
||||
id={field.name}
|
||||
/>
|
||||
);
|
||||
case "shortText":
|
||||
return (
|
||||
<AutoSaveTextarea
|
||||
multiline={false}
|
||||
value={field.getter(data) as string}
|
||||
onSave={saveField}
|
||||
id={field.name}
|
||||
/>
|
||||
);
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleInput
|
||||
label={field.name}
|
||||
value={!!field.getter(data)}
|
||||
onChange={saveField}
|
||||
placeholder={field.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
142
src/components/documents/GenericNewDocumentForm.tsx
Normal file
142
src/components/documents/GenericNewDocumentForm.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useDocumentCache } from "@/context/document/hooks";
|
||||
import { DocumentTypeLabel } from "@/lib/documents";
|
||||
import {
|
||||
getFieldsForType,
|
||||
type DocumentField,
|
||||
type FieldType,
|
||||
type ValueForFieldType,
|
||||
} from "@/lib/fields";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import {
|
||||
type CampaignId,
|
||||
type DocumentData,
|
||||
type DocumentsByType,
|
||||
type DocumentType,
|
||||
} from "@/lib/types";
|
||||
import { useCallback, useState } from "react";
|
||||
import { BaseForm } from "../form/BaseForm";
|
||||
import { MultiLineInput } from "../form/MultiLineInput";
|
||||
import { SingleLineInput } from "../form/SingleLineInput";
|
||||
import { ToggleInput } from "../form/ToggleInput";
|
||||
|
||||
export type GenericFieldType = "multiline" | "singleline" | "checkbox";
|
||||
|
||||
export type Props<T extends DocumentType> = {
|
||||
docType: T;
|
||||
campaignId: CampaignId;
|
||||
onCreate: (doc: DocumentsByType[T]) => Promise<void>;
|
||||
};
|
||||
|
||||
export const GenericNewDocumentForm = <T extends DocumentType>({
|
||||
docType,
|
||||
campaignId,
|
||||
onCreate,
|
||||
}: Props<T>) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { dispatch } = useDocumentCache();
|
||||
|
||||
const fields = getFieldsForType(docType);
|
||||
|
||||
const [docData, setDocData] = useState<DocumentData<T>>(
|
||||
fields.reduce((d, f) => f.setDefault(d), {} as DocumentData<T>),
|
||||
);
|
||||
|
||||
const updateData =
|
||||
<F extends FieldType>(field: DocumentField<T, F>) =>
|
||||
(value: ValueForFieldType<F>) =>
|
||||
setDocData(field.setter(value, docData));
|
||||
|
||||
const saveData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
console.log(`Creating ${docType}: `, docData);
|
||||
try {
|
||||
const newDocument: DocumentsByType[T] = await pb
|
||||
.collection("documents")
|
||||
.create({
|
||||
campaign: campaignId,
|
||||
type: docType,
|
||||
data: docData,
|
||||
});
|
||||
await onCreate(newDocument);
|
||||
dispatch({
|
||||
type: "setDocument",
|
||||
doc: newDocument,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError("An unknown error occurred while creating the session.");
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [campaignId, setIsLoading, setError, docData]);
|
||||
|
||||
// TODO: display name for docType
|
||||
return (
|
||||
<BaseForm
|
||||
title={`Create new ${DocumentTypeLabel[docType]}`}
|
||||
onSubmit={saveData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
content={
|
||||
// The type checker seems to lose the types when using Object.entries here.
|
||||
fields.map((field) => (
|
||||
<GenericNewFormField
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={field.getter(docData)}
|
||||
isLoading={isLoading}
|
||||
onUpdate={updateData(field)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const GenericNewFormField = <T extends DocumentType, F extends FieldType>({
|
||||
field,
|
||||
value,
|
||||
isLoading,
|
||||
onUpdate,
|
||||
}: {
|
||||
field: DocumentField<T, F>;
|
||||
value: ValueForFieldType<F>;
|
||||
isLoading: boolean;
|
||||
onUpdate: (value: ValueForFieldType<F>) => void;
|
||||
}) => {
|
||||
switch (field.fieldType) {
|
||||
case "longText":
|
||||
return (
|
||||
<MultiLineInput
|
||||
label={field.name}
|
||||
value={value as string}
|
||||
onChange={onUpdate as (v: string) => void}
|
||||
disabled={isLoading}
|
||||
placeholder={field.name}
|
||||
/>
|
||||
);
|
||||
case "shortText":
|
||||
return (
|
||||
<SingleLineInput
|
||||
label={field.name}
|
||||
value={value as string}
|
||||
onChange={onUpdate as (v: string) => void}
|
||||
disabled={isLoading}
|
||||
placeholder={field.name}
|
||||
/>
|
||||
);
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleInput
|
||||
label={field.name}
|
||||
value={value as boolean}
|
||||
onChange={onUpdate as (v: boolean) => void}
|
||||
disabled={isLoading}
|
||||
placeholder={field.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
33
src/components/documents/NewCampaignDocumentForm.tsx
Normal file
33
src/components/documents/NewCampaignDocumentForm.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
type AnyDocument,
|
||||
type CampaignId,
|
||||
type DocumentType,
|
||||
} from "@/lib/types";
|
||||
import { NewSessionForm } from "./session/NewSessionForm";
|
||||
import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
|
||||
|
||||
/**
|
||||
* Renders a form for any document type depending on the relationship.
|
||||
*/
|
||||
export const NewCampaignDocumentForm = ({
|
||||
campaignId,
|
||||
docType,
|
||||
onCreate,
|
||||
}: {
|
||||
campaignId: CampaignId;
|
||||
docType: DocumentType;
|
||||
onCreate: (doc: AnyDocument) => Promise<void>;
|
||||
}) => {
|
||||
switch (docType) {
|
||||
case "session":
|
||||
return <NewSessionForm campaignId={campaignId} onCreate={onCreate} />;
|
||||
default:
|
||||
return (
|
||||
<GenericNewDocumentForm
|
||||
docType={docType}
|
||||
campaignId={campaignId}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
62
src/components/documents/NewRelatedDocumentForm.tsx
Normal file
62
src/components/documents/NewRelatedDocumentForm.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
RelationshipType,
|
||||
type CampaignId,
|
||||
type AnyDocument,
|
||||
} from "@/lib/types";
|
||||
import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
|
||||
import { docTypeForRelationshipType } from "@/lib/relationships";
|
||||
import { useState } from "react";
|
||||
import { DocumentSearchForm } from "../form/DocumentSearchForm";
|
||||
import { identifierForDocType } from "@/lib/documents";
|
||||
|
||||
/**
|
||||
* Renders a form for any document type depending on the relationship.
|
||||
*/
|
||||
export const NewRelatedDocumentForm = ({
|
||||
campaignId,
|
||||
relationshipType,
|
||||
onCreate,
|
||||
}: {
|
||||
campaignId: CampaignId;
|
||||
relationshipType: RelationshipType;
|
||||
onCreate: (doc: AnyDocument) => Promise<void>;
|
||||
}) => {
|
||||
const [newOrExisting, setNewOrExisting] = useState<"new" | "existing">("new");
|
||||
|
||||
const docType = docTypeForRelationshipType(relationshipType);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex row gap-4">
|
||||
<button
|
||||
className={`${newOrExisting === "new" ? "font-bold" : "text-gray-400"}`}
|
||||
onClick={() => setNewOrExisting("new")}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
className={`${newOrExisting === "existing" ? "font-bold" : "text-gray-400"}`}
|
||||
onClick={() => setNewOrExisting("existing")}
|
||||
>
|
||||
Existing
|
||||
</button>
|
||||
</div>
|
||||
{newOrExisting === "new" && (
|
||||
<GenericNewDocumentForm
|
||||
docType={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>
|
||||
);
|
||||
};
|
||||
27
src/components/documents/RelatedDocumentList.tsx
Normal file
27
src/components/documents/RelatedDocumentList.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
82
src/components/documents/secret/SecretToggleRow.tsx
Normal file
82
src/components/documents/secret/SecretToggleRow.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
// SecretRow.tsx
|
||||
// Displays a single secret with discovered checkbox and text.
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { AnyDocument, Secret } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
import { DocumentLink } from "../DocumentLink";
|
||||
|
||||
/**
|
||||
* Renders a secret row with a discovered checkbox and secret text.
|
||||
* Handles updating the discovered state and discoveredIn relationship.
|
||||
*/
|
||||
export const SecretToggleRow = ({
|
||||
secret,
|
||||
root,
|
||||
}: {
|
||||
secret: Secret;
|
||||
root?: AnyDocument;
|
||||
}) => {
|
||||
const [checked, setChecked] = useState(
|
||||
!!(secret.data as any)?.secret?.discovered,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
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 (root || !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 (root) {
|
||||
if (newChecked) {
|
||||
await pb.collection("relationships").create({
|
||||
primary: secret.id,
|
||||
secondary: [root.id],
|
||||
type: "discoveredIn",
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-stretch gap-3 w-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
className="accent-emerald-500 w-5 h-5"
|
||||
aria-label="Discovered"
|
||||
disabled={loading}
|
||||
/>
|
||||
<DocumentLink
|
||||
childDocId={secret.id}
|
||||
className="!no-underline text-slate-100 hover:underline hover:text-violet-400"
|
||||
>
|
||||
{secret.data.text}
|
||||
</DocumentLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
57
src/components/documents/session/NewSessionForm.tsx
Normal file
57
src/components/documents/session/NewSessionForm.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useDocumentCache } from "@/context/document/hooks";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type {
|
||||
AnyDocument,
|
||||
CampaignId,
|
||||
Relationship,
|
||||
Session,
|
||||
} from "@/lib/types";
|
||||
import { useCallback } from "react";
|
||||
import { GenericNewDocumentForm } from "../GenericNewDocumentForm";
|
||||
|
||||
export type Props = {
|
||||
campaignId: CampaignId;
|
||||
onCreate: (doc: AnyDocument) => Promise<void>;
|
||||
};
|
||||
|
||||
export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
|
||||
const { dispatch } = useDocumentCache();
|
||||
|
||||
const createSessionRelations = useCallback(
|
||||
async (newSession: Session) => {
|
||||
// Check for a previous session
|
||||
const prevSession = await pb
|
||||
.collection("documents")
|
||||
.getFirstListItem(`campaign = "${campaignId}" && type = 'session'`, {
|
||||
sort: "-created",
|
||||
});
|
||||
|
||||
// If any relations, then copy things over
|
||||
if (prevSession) {
|
||||
const prevRelations = await pb
|
||||
.collection<Relationship>("relationships")
|
||||
.getFullList({
|
||||
filter: `primary = "${prevSession.id}"`,
|
||||
});
|
||||
|
||||
for (const relation of prevRelations) {
|
||||
await pb.collection("relationships").create({
|
||||
primary: newSession.id,
|
||||
type: relation.type,
|
||||
secondary: relation.secondary,
|
||||
});
|
||||
}
|
||||
}
|
||||
await onCreate(newSession);
|
||||
},
|
||||
[campaignId, dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<GenericNewDocumentForm
|
||||
docType="session"
|
||||
campaignId={campaignId}
|
||||
onCreate={createSessionRelations}
|
||||
/>
|
||||
);
|
||||
};
|
||||
81
src/components/documents/treasure/TreasureToggleRow.tsx
Normal file
81
src/components/documents/treasure/TreasureToggleRow.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// TreasureRow.tsx
|
||||
// Displays a single treasure with discovered checkbox and text.
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { AnyDocument, Treasure } from "@/lib/types";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Renders a treasure row with a discovered checkbox and treasure text.
|
||||
* Handles updating the discovered state and discoveredIn relationship.
|
||||
*/
|
||||
export const TreasureToggleRow = ({
|
||||
treasure,
|
||||
root,
|
||||
}: {
|
||||
treasure: Treasure;
|
||||
root?: AnyDocument;
|
||||
}) => {
|
||||
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 (root || !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 (root) {
|
||||
if (newChecked) {
|
||||
await pb.collection("relationships").create({
|
||||
primary: treasure.id,
|
||||
secondary: [root.id],
|
||||
type: "discoveredIn",
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<Link
|
||||
to="/document/$documentId/$"
|
||||
params={{ documentId: treasure.id }}
|
||||
className="text-lg !no-underline text-slate-100 hover:underline hover:text-violet-400"
|
||||
>
|
||||
{treasure.data.text}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
src/components/form/BaseForm.tsx
Normal file
38
src/components/form/BaseForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
105
src/components/form/DocumentSearchForm.tsx
Normal file
105
src/components/form/DocumentSearchForm.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { DocumentTypeLoader } from "@/context/document/DocumentTypeLoader";
|
||||
import { useDocumentCache } from "@/context/document/hooks";
|
||||
import type { AnyDocument, CampaignId, DocumentType } from "@/lib/types";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BaseForm } from "./BaseForm";
|
||||
import { DocumentTypeLabel } from "@/lib/documents";
|
||||
|
||||
export type Props = {
|
||||
campaignId: CampaignId;
|
||||
docType: DocumentType;
|
||||
searchField: string;
|
||||
onSubmit: (doc: AnyDocument) => void;
|
||||
};
|
||||
|
||||
export const DocumentSearchForm = (props: Props) => (
|
||||
<DocumentTypeLoader
|
||||
documentType={props.docType}
|
||||
campaignId={props.campaignId}
|
||||
>
|
||||
<DocumentSearchInput {...props} />
|
||||
</DocumentTypeLoader>
|
||||
);
|
||||
|
||||
/** Utility to help with typing */
|
||||
function getField(doc: AnyDocument, field: string): string | undefined {
|
||||
return (doc.data as Record<string, string>)[field];
|
||||
}
|
||||
|
||||
export const DocumentSearchInput = ({
|
||||
docType,
|
||||
searchField,
|
||||
onSubmit,
|
||||
}: Props) => {
|
||||
const { cache } = useDocumentCache();
|
||||
const [allOptions, setAllOptions] = useState<AnyDocument[]>([]);
|
||||
useEffect(() => {
|
||||
setAllOptions(
|
||||
Object.values(cache.documents).flatMap((docResult) => {
|
||||
if (docResult.type !== "ready") {
|
||||
return [];
|
||||
}
|
||||
if (docResult.value.doc.type !== docType) {
|
||||
return [];
|
||||
}
|
||||
return [docResult.value.doc];
|
||||
}),
|
||||
);
|
||||
}, [cache, setAllOptions]);
|
||||
|
||||
const [queryValue, setQueryValue] = useState("");
|
||||
const [selectedDoc, setSelectedDoc] = useState<AnyDocument | null>(null);
|
||||
|
||||
const options = allOptions.filter((doc) =>
|
||||
getField(doc, searchField)
|
||||
?.toLowerCase()
|
||||
?.includes(queryValue.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseForm
|
||||
title={`Find ${DocumentTypeLabel[docType]}`}
|
||||
buttonText="Add"
|
||||
error={null}
|
||||
onSubmit={() => selectedDoc && onSubmit(selectedDoc)}
|
||||
content={
|
||||
<Combobox<AnyDocument | null>
|
||||
name={searchField}
|
||||
value={selectedDoc}
|
||||
onChange={(doc) => {
|
||||
console.log("Selected", doc);
|
||||
setSelectedDoc(doc);
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
displayValue={(doc: AnyDocument) =>
|
||||
(doc && getField(doc, searchField)) ?? "(no value)"
|
||||
}
|
||||
onChange={(event) => setQueryValue(event.target.value)}
|
||||
className={`w-full p-2 rounded border bg-slate-800 text-slate-100 border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500 transition-colors`}
|
||||
/>
|
||||
<ComboboxOptions
|
||||
anchor="bottom start"
|
||||
className="border empty:invisible z-50 px-4 bg-black"
|
||||
>
|
||||
{options.map((doc) => (
|
||||
<ComboboxOption
|
||||
key={doc.id}
|
||||
value={doc}
|
||||
className="data-selected:font-bold data-focus:font-bold"
|
||||
>
|
||||
{getField(doc, searchField)}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</ComboboxOptions>
|
||||
</Combobox>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
28
src/components/form/MultiLineInput.tsx
Normal file
28
src/components/form/MultiLineInput.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
29
src/components/form/SingleLineInput.tsx
Normal file
29
src/components/form/SingleLineInput.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
29
src/components/form/ToggleInput.tsx
Normal file
29
src/components/form/ToggleInput.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
export type Props = {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
} & Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "onChange" | "className"
|
||||
>;
|
||||
|
||||
export const ToggleInput = ({
|
||||
value,
|
||||
onChange,
|
||||
className = "",
|
||||
label,
|
||||
...props
|
||||
}: Props) => (
|
||||
<div className="flex flex-row gap-4 p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className={`rounded border bg-slate-800 text-slate-100 border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500 transition-colors ${className}`}
|
||||
aria-label={label}
|
||||
{...props}
|
||||
/>
|
||||
{label && <label>{label}</label>}
|
||||
</div>
|
||||
);
|
||||
64
src/components/layout/TabbedLayout.tsx
Normal file
64
src/components/layout/TabbedLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,4 +3,5 @@
|
||||
*
|
||||
* This includes endpoints and other environment-specific settings.
|
||||
*/
|
||||
export const POCKETBASE_URL: string = "http://127.0.0.1:8090"; // Update as needed for deployment
|
||||
export const POCKETBASE_URL: string =
|
||||
import.meta.env.VITE_POCKETBASE_URL || "/"; // Update as needed for deployment
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createContext, useContext, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useCallback, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { AuthRecord } from "pocketbase";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
/**
|
||||
* Represents the shape of the authenticated user object from PocketBase.
|
||||
@@ -25,87 +25,49 @@ export interface AuthContextValue {
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Fetches the currently authenticated user from PocketBase.
|
||||
*/
|
||||
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.
|
||||
* Provider for authentication context.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: user, isLoading } = useQuery({
|
||||
queryKey: ["auth", "user"],
|
||||
queryFn: fetchUser,
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
email,
|
||||
password,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) => {
|
||||
await pb.collection("users").authWithPassword(email, password);
|
||||
return fetchUser();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["auth", "user"] });
|
||||
},
|
||||
});
|
||||
|
||||
const signupMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm,
|
||||
}: {
|
||||
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 });
|
||||
},
|
||||
[loginMutation],
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [user, setUser] = useState<AuthRecord | null>(
|
||||
pb.authStore.isValid ? pb.authStore.record : null,
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
function updateUser() {
|
||||
if (pb.authStore.isValid) {
|
||||
setUser(pb.authStore.record);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
console.log("login");
|
||||
setIsLoading(true);
|
||||
await pb.collection("users").authWithPassword(email, password);
|
||||
updateUser();
|
||||
navigate({ to: "/campaigns" });
|
||||
}, []);
|
||||
|
||||
const signup = useCallback(
|
||||
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" });
|
||||
},
|
||||
[signupMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutMutation.mutateAsync();
|
||||
}, [logoutMutation]);
|
||||
console.log("logout");
|
||||
pb.authStore.clear();
|
||||
setUser(null);
|
||||
navigate({ to: "/" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
|
||||
32
src/context/document/DocumentContext.tsx
Normal file
32
src/context/document/DocumentContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/context/document/DocumentLoader.tsx
Normal file
51
src/context/document/DocumentLoader.tsx
Normal 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;
|
||||
}
|
||||
41
src/context/document/DocumentTypeLoader.tsx
Normal file
41
src/context/document/DocumentTypeLoader.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import {
|
||||
type AnyDocument,
|
||||
type CampaignId,
|
||||
type DocumentType,
|
||||
} from "@/lib/types";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useDocumentCache } from "./hooks";
|
||||
|
||||
/**
|
||||
* Provider for the record cache context. Provides a singleton RecordCache instance to children.
|
||||
*/
|
||||
export function DocumentTypeLoader({
|
||||
campaignId,
|
||||
documentType,
|
||||
children,
|
||||
}: {
|
||||
campaignId: CampaignId;
|
||||
documentType: DocumentType;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { dispatch } = useDocumentCache();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDocuments() {
|
||||
const docs: AnyDocument[] = await pb.collection("documents").getFullList({
|
||||
filter: `campaign = "${campaignId}" && type = "${documentType}"`,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "setDocuments",
|
||||
docs: docs,
|
||||
});
|
||||
}
|
||||
|
||||
fetchDocuments();
|
||||
}, [campaignId, documentType]);
|
||||
|
||||
return children;
|
||||
}
|
||||
30
src/context/document/actions.ts
Normal file
30
src/context/document/actions.ts
Normal 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;
|
||||
};
|
||||
23
src/context/document/hooks.ts
Normal file
23
src/context/document/hooks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
173
src/context/document/reducer.ts
Normal file
173
src/context/document/reducer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
55
src/context/document/state.ts
Normal file
55
src/context/document/state.ts
Normal 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
62
src/lib/documentPath.ts
Normal 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
37
src/lib/documents.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { DocumentType } from "./types";
|
||||
|
||||
export const DocumentTypeLabel: Record<DocumentType, string> = {
|
||||
session: "Session",
|
||||
secret: "Secret",
|
||||
npc: "NPC",
|
||||
location: "Location",
|
||||
thread: "Thread",
|
||||
front: "Front",
|
||||
monster: "Monster",
|
||||
scene: "Scene",
|
||||
treasure: "Treasure",
|
||||
};
|
||||
|
||||
export const DocumentTypeLabePlural: Record<DocumentType, string> = {
|
||||
session: "Sessions",
|
||||
secret: "Secrets",
|
||||
npc: "NPCs",
|
||||
location: "Locations",
|
||||
thread: "Threads",
|
||||
front: "Fronts",
|
||||
monster: "Monsters",
|
||||
scene: "Scenes",
|
||||
treasure: "Treasures",
|
||||
};
|
||||
|
||||
export function identifierForDocType(docType: DocumentType): string {
|
||||
switch (docType) {
|
||||
case "scene":
|
||||
case "secret":
|
||||
case "thread":
|
||||
case "treasure":
|
||||
return "text";
|
||||
default:
|
||||
return "name";
|
||||
}
|
||||
}
|
||||
106
src/lib/fields.ts
Normal file
106
src/lib/fields.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { type DocumentData, type DocumentType } from "./types";
|
||||
|
||||
export type FieldType = "identifier" | "shortText" | "longText" | "toggle";
|
||||
|
||||
export type ValueForFieldType<F extends FieldType> = {
|
||||
identifier: string;
|
||||
shortText: string;
|
||||
longText: string;
|
||||
toggle: boolean;
|
||||
}[F];
|
||||
|
||||
function defaultValue<F extends FieldType>(fieldType: F): ValueForFieldType<F> {
|
||||
switch (fieldType) {
|
||||
case "identifier":
|
||||
case "shortText":
|
||||
case "longText":
|
||||
return "" as ValueForFieldType<F>;
|
||||
case "toggle":
|
||||
return false as ValueForFieldType<F>;
|
||||
}
|
||||
}
|
||||
|
||||
export type DocumentField<D extends DocumentType, F extends FieldType> = {
|
||||
name: string;
|
||||
fieldType: F;
|
||||
getter: (doc: DocumentData<D>) => ValueForFieldType<F>;
|
||||
setter: (
|
||||
value: ValueForFieldType<F>,
|
||||
doc: DocumentData<D>,
|
||||
) => DocumentData<D>;
|
||||
setDefault: (doc: DocumentData<D>) => DocumentData<D>;
|
||||
};
|
||||
|
||||
const simpleField = <D extends DocumentType, F extends FieldType>(
|
||||
name: string,
|
||||
key: keyof DocumentData<D>,
|
||||
fieldType: F,
|
||||
): DocumentField<D, F> => ({
|
||||
name,
|
||||
fieldType,
|
||||
getter: (doc) => doc[key] as unknown as ValueForFieldType<F>,
|
||||
setter: (value, doc) => ({ ...doc, [key]: value }),
|
||||
setDefault: (doc) => ({ ...doc, [key]: defaultValue(fieldType) }),
|
||||
});
|
||||
|
||||
const simpleFields = <D extends DocumentType>(
|
||||
fields: Record<string, [keyof DocumentData<D>, FieldType]>,
|
||||
): DocumentField<D, FieldType>[] =>
|
||||
Object.entries(fields).map(([name, [key, fieldType]]) =>
|
||||
simpleField(name, key, fieldType),
|
||||
);
|
||||
|
||||
export function getFieldsForType<D extends DocumentType>(
|
||||
docType: D,
|
||||
): DocumentField<D, FieldType>[] {
|
||||
// Explicit casts are required because the getter function puts the type D in the parameters position and thus the specialized getter is not valid in the case of the more general document type.
|
||||
// While the switch correctly sees that D is now "front", the _type_ could be a union and thus the getter needs to be able to accept any of them.
|
||||
// I know this will only ever be called in the context of one value, but this is clearly abusing the type system.
|
||||
// TODO: Fix the types
|
||||
switch (docType) {
|
||||
case "front":
|
||||
return simpleFields<"front">({
|
||||
Name: ["name", "shortText"],
|
||||
Description: ["description", "longText"],
|
||||
Resolved: ["resolved", "toggle"],
|
||||
}) as unknown as DocumentField<D, FieldType>[];
|
||||
case "location":
|
||||
return simpleFields<"location">({
|
||||
Name: ["name", "shortText"],
|
||||
Description: ["description", "longText"],
|
||||
}) as unknown as DocumentField<D, FieldType>[];
|
||||
case "monster":
|
||||
return simpleFields<"monster">({
|
||||
Name: ["name", "shortText"],
|
||||
}) as unknown as DocumentField<D, FieldType>[];
|
||||
case "npc":
|
||||
return simpleFields<"npc">({
|
||||
Name: ["name", "shortText"],
|
||||
Description: ["description", "longText"],
|
||||
}) as unknown as DocumentField<D, FieldType>[];
|
||||
case "scene":
|
||||
return simpleFields<"scene">({
|
||||
Text: ["text", "longText"],
|
||||
}) as unknown as DocumentField<D, FieldType>[];
|
||||
case "secret":
|
||||
return simpleFields<"secret">({
|
||||
Discovered: ["discovered", "toggle"],
|
||||
Text: ["text", "shortText"],
|
||||
}) as unknown as DocumentField<D, FieldType>[];
|
||||
case "session":
|
||||
return simpleFields<"session">({
|
||||
Name: ["name", "shortText"],
|
||||
"Strong Start": ["strongStart", "longText"],
|
||||
}) as unknown as DocumentField<D, FieldType>[];
|
||||
case "thread":
|
||||
return simpleFields<"thread">({
|
||||
Resolved: ["resolved", "toggle"],
|
||||
Text: ["text", "shortText"],
|
||||
}) as unknown as DocumentField<D, FieldType>[];
|
||||
case "treasure":
|
||||
return simpleFields<"treasure">({
|
||||
Discovered: ["discovered", "toggle"],
|
||||
Text: ["text", "shortText"],
|
||||
}) as unknown as DocumentField<D, FieldType>[];
|
||||
}
|
||||
}
|
||||
112
src/lib/recordCache.ts
Normal file
112
src/lib/recordCache.ts
Normal 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));
|
||||
}
|
||||
40
src/lib/relationships.ts
Normal file
40
src/lib/relationships.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
getDocumentType,
|
||||
RelationshipType,
|
||||
type AnyDocument,
|
||||
type DocumentType,
|
||||
} from "./types";
|
||||
|
||||
export function displayName(relationshipType: RelationshipType) {
|
||||
return relationshipType.charAt(0).toUpperCase() + relationshipType.slice(1);
|
||||
}
|
||||
|
||||
export function relationshipsForDocument(doc: AnyDocument): RelationshipType[] {
|
||||
switch (getDocumentType(doc)) {
|
||||
case "session":
|
||||
return [
|
||||
RelationshipType.Scenes,
|
||||
RelationshipType.Secrets,
|
||||
RelationshipType.Locations,
|
||||
RelationshipType.Npcs,
|
||||
RelationshipType.Monsters,
|
||||
RelationshipType.Treasures,
|
||||
];
|
||||
default:
|
||||
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];
|
||||
}
|
||||
197
src/lib/types.ts
197
src/lib/types.ts
@@ -1,10 +1,33 @@
|
||||
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 CampaignId = Id<"Campaign">;
|
||||
export type DocumentId = Id<"Document">;
|
||||
export type CollectionId = (typeof CollectionIds)[keyof typeof CollectionIds];
|
||||
|
||||
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 DbRecord<C extends CollectionId = CollectionId> = {
|
||||
[CollectionIds.Campaigns]: Campaign;
|
||||
[CollectionIds.Documents]: AnyDocument;
|
||||
[CollectionIds.Relationships]: Relationship;
|
||||
[CollectionIds.Users]: RecordModel;
|
||||
}[C];
|
||||
|
||||
/******************************************
|
||||
* Campaigns
|
||||
******************************************/
|
||||
|
||||
export type Campaign = RecordModel & {
|
||||
id: CampaignId;
|
||||
@@ -12,28 +35,139 @@ export type Campaign = RecordModel & {
|
||||
owner: UserId;
|
||||
};
|
||||
|
||||
export type Document = RecordModel & {
|
||||
/******************************************
|
||||
* Relationships
|
||||
******************************************/
|
||||
|
||||
export const RelationshipType = {
|
||||
DiscoveredIn: "discoveredIn",
|
||||
Locations: "locations",
|
||||
Monsters: "monsters",
|
||||
Npcs: "npcs",
|
||||
Scenes: "scenes",
|
||||
Secrets: "secrets",
|
||||
Treasures: "treasures",
|
||||
} as const;
|
||||
|
||||
export type RelationshipType =
|
||||
(typeof RelationshipType)[keyof typeof RelationshipType];
|
||||
|
||||
export type Relationship = RecordModel & {
|
||||
id: RelationshipId;
|
||||
collectionName: typeof CollectionIds.Relationships;
|
||||
primary: DocumentId;
|
||||
secondary: DocumentId[];
|
||||
type: RelationshipType;
|
||||
};
|
||||
|
||||
/******************************************
|
||||
* Documents
|
||||
******************************************/
|
||||
|
||||
export type DocumentType =
|
||||
| "front"
|
||||
| "location"
|
||||
| "monster"
|
||||
| "npc"
|
||||
| "scene"
|
||||
| "secret"
|
||||
| "session"
|
||||
| "thread"
|
||||
| "treasure";
|
||||
|
||||
export type Document<Type extends DocumentType, Data> = RecordModel & {
|
||||
id: DocumentId;
|
||||
campaign: Campaign;
|
||||
data: {};
|
||||
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 DocumentData<K extends string, V> = {
|
||||
data: Record<K, V>;
|
||||
export type AnyDocument =
|
||||
| Front
|
||||
| Location
|
||||
| Monster
|
||||
| Npc
|
||||
| Scene
|
||||
| Secret
|
||||
| Session
|
||||
| Thread
|
||||
| Treasure;
|
||||
|
||||
export type DocumentsByType = {
|
||||
front: Front;
|
||||
location: Location;
|
||||
monster: Monster;
|
||||
npc: Npc;
|
||||
scene: Scene;
|
||||
secret: Secret;
|
||||
session: Session;
|
||||
thread: Thread;
|
||||
treasure: Treasure;
|
||||
};
|
||||
|
||||
export type Session = Document &
|
||||
DocumentData<
|
||||
export type DocumentData<Type extends DocumentType> =
|
||||
DocumentsByType[Type]["data"];
|
||||
|
||||
export type GetDocumentType<D extends AnyDocument> = D["type"];
|
||||
|
||||
export function getDocumentType(doc: AnyDocument): DocumentType {
|
||||
return doc.type;
|
||||
}
|
||||
|
||||
/** Locations **/
|
||||
export type Location = Document<
|
||||
"location",
|
||||
{
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
>;
|
||||
|
||||
/** Monsters **/
|
||||
|
||||
export type Monster = Document<
|
||||
"monster",
|
||||
{
|
||||
name: string;
|
||||
}
|
||||
>;
|
||||
|
||||
/** NPCs **/
|
||||
|
||||
export type Npc = Document<
|
||||
"npc",
|
||||
{
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
>;
|
||||
|
||||
/** Session **/
|
||||
|
||||
export type Session = Document<
|
||||
"session",
|
||||
{
|
||||
name?: string;
|
||||
strongStart: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export type ISO8601Date = string & { __type: "iso8601date" };
|
||||
/** Scene **/
|
||||
|
||||
export type Secret = Document &
|
||||
DocumentData<
|
||||
export type Scene = Document<
|
||||
"scene",
|
||||
{
|
||||
text: string;
|
||||
}
|
||||
>;
|
||||
|
||||
/** Secret **/
|
||||
|
||||
export type Secret = Document<
|
||||
"secret",
|
||||
{
|
||||
text: string;
|
||||
@@ -41,8 +175,33 @@ export type Secret = Document &
|
||||
}
|
||||
>;
|
||||
|
||||
export type Relationship = RecordModel & {
|
||||
primary: DocumentId;
|
||||
secondary: DocumentId[];
|
||||
type: "plannedSecrets" | "discoveredIn";
|
||||
};
|
||||
/** Treasure **/
|
||||
|
||||
export type Treasure = Document<
|
||||
"treasure",
|
||||
{
|
||||
text: string;
|
||||
discovered: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
/** Thread **/
|
||||
|
||||
export type Thread = Document<
|
||||
"thread",
|
||||
{
|
||||
text: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
/** Front **/
|
||||
|
||||
export type Front = Document<
|
||||
"front",
|
||||
{
|
||||
name: string;
|
||||
description: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
31
src/main.tsx
31
src/main.tsx
@@ -1,42 +1,41 @@
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
import './styles.css'
|
||||
import reportWebVitals from './reportWebVitals.ts'
|
||||
import "./styles.css";
|
||||
import reportWebVitals from "./reportWebVitals.ts";
|
||||
|
||||
// Create a new router instance
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {},
|
||||
defaultPreload: 'intent',
|
||||
defaultPreload: "intent",
|
||||
scrollRestoration: true,
|
||||
defaultStructuralSharing: true,
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
defaultPendingMinMs: 0,
|
||||
});
|
||||
|
||||
// Register the router instance for type safety
|
||||
declare module '@tanstack/react-router' {
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the app
|
||||
const rootElement = document.getElementById('app')
|
||||
const rootElement = document.getElementById("app");
|
||||
if (rootElement && !rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement)
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals()
|
||||
reportWebVitals();
|
||||
|
||||
@@ -11,208 +11,234 @@
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as LoginImport } from './routes/login'
|
||||
import { Route as AboutImport } from './routes/about'
|
||||
import { Route as AuthenticatedImport } from './routes/_authenticated'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as AuthenticatedCampaignsIndexImport } from './routes/_authenticated/campaigns.index'
|
||||
import { Route as AuthenticatedDocumentDocumentIdImport } from './routes/_authenticated/document.$documentId'
|
||||
import { Route as AuthenticatedCampaignsCampaignIdImport } from './routes/_authenticated/campaigns.$campaignId'
|
||||
import { Route as AppImport } from './routes/_app'
|
||||
import { Route as AppIndexImport } from './routes/_app/index'
|
||||
import { Route as AppLoginImport } from './routes/_app/login'
|
||||
import { Route as AppAboutImport } from './routes/_app/about'
|
||||
import { Route as AppAuthenticatedImport } from './routes/_app/_authenticated'
|
||||
import { Route as AppAuthenticatedCampaignsIndexImport } from './routes/_app/_authenticated/campaigns.index'
|
||||
import { Route as AppAuthenticatedCampaignsCampaignIdImport } from './routes/_app/_authenticated/campaigns.$campaignId'
|
||||
import { Route as AppAuthenticatedDocumentDocumentIdSplatImport } from './routes/_app/_authenticated/document.$documentId.$'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const LoginRoute = LoginImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
const AppRoute = AppImport.update({
|
||||
id: '/_app',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const AboutRoute = AboutImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticatedRoute = AuthenticatedImport.update({
|
||||
id: '/_authenticated',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
const AppIndexRoute = AppIndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticatedCampaignsIndexRoute =
|
||||
AuthenticatedCampaignsIndexImport.update({
|
||||
const AppLoginRoute = AppLoginImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppAboutRoute = AppAboutImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppAuthenticatedRoute = AppAuthenticatedImport.update({
|
||||
id: '/_authenticated',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppAuthenticatedCampaignsIndexRoute =
|
||||
AppAuthenticatedCampaignsIndexImport.update({
|
||||
id: '/campaigns/',
|
||||
path: '/campaigns/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
getParentRoute: () => AppAuthenticatedRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticatedDocumentDocumentIdRoute =
|
||||
AuthenticatedDocumentDocumentIdImport.update({
|
||||
id: '/document/$documentId',
|
||||
path: '/document/$documentId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticatedCampaignsCampaignIdRoute =
|
||||
AuthenticatedCampaignsCampaignIdImport.update({
|
||||
const AppAuthenticatedCampaignsCampaignIdRoute =
|
||||
AppAuthenticatedCampaignsCampaignIdImport.update({
|
||||
id: '/campaigns/$campaignId',
|
||||
path: '/campaigns/$campaignId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
getParentRoute: () => AppAuthenticatedRoute,
|
||||
} as any)
|
||||
|
||||
const AppAuthenticatedDocumentDocumentIdSplatRoute =
|
||||
AppAuthenticatedDocumentDocumentIdSplatImport.update({
|
||||
id: '/document/$documentId/$',
|
||||
path: '/document/$documentId/$',
|
||||
getParentRoute: () => AppAuthenticatedRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/_authenticated': {
|
||||
id: '/_authenticated'
|
||||
'/_app': {
|
||||
id: '/_app'
|
||||
path: ''
|
||||
fullPath: ''
|
||||
preLoaderRoute: typeof AuthenticatedImport
|
||||
preLoaderRoute: typeof AppImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/about': {
|
||||
id: '/about'
|
||||
'/_app/_authenticated': {
|
||||
id: '/_app/_authenticated'
|
||||
path: ''
|
||||
fullPath: ''
|
||||
preLoaderRoute: typeof AppAuthenticatedImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/_app/about': {
|
||||
id: '/_app/about'
|
||||
path: '/about'
|
||||
fullPath: '/about'
|
||||
preLoaderRoute: typeof AboutImport
|
||||
parentRoute: typeof rootRoute
|
||||
preLoaderRoute: typeof AppAboutImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
'/_app/login': {
|
||||
id: '/_app/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginImport
|
||||
parentRoute: typeof rootRoute
|
||||
preLoaderRoute: typeof AppLoginImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/_authenticated/campaigns/$campaignId': {
|
||||
id: '/_authenticated/campaigns/$campaignId'
|
||||
'/_app/': {
|
||||
id: '/_app/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AppIndexImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/_app/_authenticated/campaigns/$campaignId': {
|
||||
id: '/_app/_authenticated/campaigns/$campaignId'
|
||||
path: '/campaigns/$campaignId'
|
||||
fullPath: '/campaigns/$campaignId'
|
||||
preLoaderRoute: typeof AuthenticatedCampaignsCampaignIdImport
|
||||
parentRoute: typeof AuthenticatedImport
|
||||
preLoaderRoute: typeof AppAuthenticatedCampaignsCampaignIdImport
|
||||
parentRoute: typeof AppAuthenticatedImport
|
||||
}
|
||||
'/_authenticated/document/$documentId': {
|
||||
id: '/_authenticated/document/$documentId'
|
||||
path: '/document/$documentId'
|
||||
fullPath: '/document/$documentId'
|
||||
preLoaderRoute: typeof AuthenticatedDocumentDocumentIdImport
|
||||
parentRoute: typeof AuthenticatedImport
|
||||
}
|
||||
'/_authenticated/campaigns/': {
|
||||
id: '/_authenticated/campaigns/'
|
||||
'/_app/_authenticated/campaigns/': {
|
||||
id: '/_app/_authenticated/campaigns/'
|
||||
path: '/campaigns'
|
||||
fullPath: '/campaigns'
|
||||
preLoaderRoute: typeof AuthenticatedCampaignsIndexImport
|
||||
parentRoute: typeof AuthenticatedImport
|
||||
preLoaderRoute: typeof AppAuthenticatedCampaignsIndexImport
|
||||
parentRoute: typeof AppAuthenticatedImport
|
||||
}
|
||||
'/_app/_authenticated/document/$documentId/$': {
|
||||
id: '/_app/_authenticated/document/$documentId/$'
|
||||
path: '/document/$documentId/$'
|
||||
fullPath: '/document/$documentId/$'
|
||||
preLoaderRoute: typeof AppAuthenticatedDocumentDocumentIdSplatImport
|
||||
parentRoute: typeof AppAuthenticatedImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
interface AuthenticatedRouteChildren {
|
||||
AuthenticatedCampaignsCampaignIdRoute: typeof AuthenticatedCampaignsCampaignIdRoute
|
||||
AuthenticatedDocumentDocumentIdRoute: typeof AuthenticatedDocumentDocumentIdRoute
|
||||
AuthenticatedCampaignsIndexRoute: typeof AuthenticatedCampaignsIndexRoute
|
||||
interface AppAuthenticatedRouteChildren {
|
||||
AppAuthenticatedCampaignsCampaignIdRoute: typeof AppAuthenticatedCampaignsCampaignIdRoute
|
||||
AppAuthenticatedCampaignsIndexRoute: typeof AppAuthenticatedCampaignsIndexRoute
|
||||
AppAuthenticatedDocumentDocumentIdSplatRoute: typeof AppAuthenticatedDocumentDocumentIdSplatRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedCampaignsCampaignIdRoute: AuthenticatedCampaignsCampaignIdRoute,
|
||||
AuthenticatedDocumentDocumentIdRoute: AuthenticatedDocumentDocumentIdRoute,
|
||||
AuthenticatedCampaignsIndexRoute: AuthenticatedCampaignsIndexRoute,
|
||||
const AppAuthenticatedRouteChildren: AppAuthenticatedRouteChildren = {
|
||||
AppAuthenticatedCampaignsCampaignIdRoute:
|
||||
AppAuthenticatedCampaignsCampaignIdRoute,
|
||||
AppAuthenticatedCampaignsIndexRoute: AppAuthenticatedCampaignsIndexRoute,
|
||||
AppAuthenticatedDocumentDocumentIdSplatRoute:
|
||||
AppAuthenticatedDocumentDocumentIdSplatRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
AuthenticatedRouteChildren,
|
||||
)
|
||||
const AppAuthenticatedRouteWithChildren =
|
||||
AppAuthenticatedRoute._addFileChildren(AppAuthenticatedRouteChildren)
|
||||
|
||||
interface AppRouteChildren {
|
||||
AppAuthenticatedRoute: typeof AppAuthenticatedRouteWithChildren
|
||||
AppAboutRoute: typeof AppAboutRoute
|
||||
AppLoginRoute: typeof AppLoginRoute
|
||||
AppIndexRoute: typeof AppIndexRoute
|
||||
}
|
||||
|
||||
const AppRouteChildren: AppRouteChildren = {
|
||||
AppAuthenticatedRoute: AppAuthenticatedRouteWithChildren,
|
||||
AppAboutRoute: AppAboutRoute,
|
||||
AppLoginRoute: AppLoginRoute,
|
||||
AppIndexRoute: AppIndexRoute,
|
||||
}
|
||||
|
||||
const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'': typeof AuthenticatedRouteWithChildren
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/campaigns/$campaignId': typeof AuthenticatedCampaignsCampaignIdRoute
|
||||
'/document/$documentId': typeof AuthenticatedDocumentDocumentIdRoute
|
||||
'/campaigns': typeof AuthenticatedCampaignsIndexRoute
|
||||
'': typeof AppAuthenticatedRouteWithChildren
|
||||
'/about': typeof AppAboutRoute
|
||||
'/login': typeof AppLoginRoute
|
||||
'/': typeof AppIndexRoute
|
||||
'/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute
|
||||
'/campaigns': typeof AppAuthenticatedCampaignsIndexRoute
|
||||
'/document/$documentId/$': typeof AppAuthenticatedDocumentDocumentIdSplatRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'': typeof AuthenticatedRouteWithChildren
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/campaigns/$campaignId': typeof AuthenticatedCampaignsCampaignIdRoute
|
||||
'/document/$documentId': typeof AuthenticatedDocumentDocumentIdRoute
|
||||
'/campaigns': typeof AuthenticatedCampaignsIndexRoute
|
||||
'': typeof AppAuthenticatedRouteWithChildren
|
||||
'/about': typeof AppAboutRoute
|
||||
'/login': typeof AppLoginRoute
|
||||
'/': typeof AppIndexRoute
|
||||
'/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute
|
||||
'/campaigns': typeof AppAuthenticatedCampaignsIndexRoute
|
||||
'/document/$documentId/$': typeof AppAuthenticatedDocumentDocumentIdSplatRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/_authenticated/campaigns/$campaignId': typeof AuthenticatedCampaignsCampaignIdRoute
|
||||
'/_authenticated/document/$documentId': typeof AuthenticatedDocumentDocumentIdRoute
|
||||
'/_authenticated/campaigns/': typeof AuthenticatedCampaignsIndexRoute
|
||||
'/_app': typeof AppRouteWithChildren
|
||||
'/_app/_authenticated': typeof AppAuthenticatedRouteWithChildren
|
||||
'/_app/about': typeof AppAboutRoute
|
||||
'/_app/login': typeof AppLoginRoute
|
||||
'/_app/': typeof AppIndexRoute
|
||||
'/_app/_authenticated/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute
|
||||
'/_app/_authenticated/campaigns/': typeof AppAuthenticatedCampaignsIndexRoute
|
||||
'/_app/_authenticated/document/$documentId/$': typeof AppAuthenticatedDocumentDocumentIdSplatRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| ''
|
||||
| '/about'
|
||||
| '/login'
|
||||
| '/'
|
||||
| '/campaigns/$campaignId'
|
||||
| '/document/$documentId'
|
||||
| '/campaigns'
|
||||
| '/document/$documentId/$'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| ''
|
||||
| '/about'
|
||||
| '/login'
|
||||
| '/'
|
||||
| '/campaigns/$campaignId'
|
||||
| '/document/$documentId'
|
||||
| '/campaigns'
|
||||
| '/document/$documentId/$'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/_authenticated'
|
||||
| '/about'
|
||||
| '/login'
|
||||
| '/_authenticated/campaigns/$campaignId'
|
||||
| '/_authenticated/document/$documentId'
|
||||
| '/_authenticated/campaigns/'
|
||||
| '/_app'
|
||||
| '/_app/_authenticated'
|
||||
| '/_app/about'
|
||||
| '/_app/login'
|
||||
| '/_app/'
|
||||
| '/_app/_authenticated/campaigns/$campaignId'
|
||||
| '/_app/_authenticated/campaigns/'
|
||||
| '/_app/_authenticated/document/$documentId/$'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
||||
AboutRoute: typeof AboutRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
AppRoute: typeof AppRouteWithChildren
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
||||
AboutRoute: AboutRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
AppRoute: AppRouteWithChildren,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
@@ -225,40 +251,50 @@ export const routeTree = rootRoute
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/_authenticated",
|
||||
"/about",
|
||||
"/login"
|
||||
"/_app"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/_authenticated": {
|
||||
"filePath": "_authenticated.tsx",
|
||||
"/_app": {
|
||||
"filePath": "_app.tsx",
|
||||
"children": [
|
||||
"/_authenticated/campaigns/$campaignId",
|
||||
"/_authenticated/document/$documentId",
|
||||
"/_authenticated/campaigns/"
|
||||
"/_app/_authenticated",
|
||||
"/_app/about",
|
||||
"/_app/login",
|
||||
"/_app/"
|
||||
]
|
||||
},
|
||||
"/about": {
|
||||
"filePath": "about.tsx"
|
||||
"/_app/_authenticated": {
|
||||
"filePath": "_app/_authenticated.tsx",
|
||||
"parent": "/_app",
|
||||
"children": [
|
||||
"/_app/_authenticated/campaigns/$campaignId",
|
||||
"/_app/_authenticated/campaigns/",
|
||||
"/_app/_authenticated/document/$documentId/$"
|
||||
]
|
||||
},
|
||||
"/login": {
|
||||
"filePath": "login.tsx"
|
||||
"/_app/about": {
|
||||
"filePath": "_app/about.tsx",
|
||||
"parent": "/_app"
|
||||
},
|
||||
"/_authenticated/campaigns/$campaignId": {
|
||||
"filePath": "_authenticated/campaigns.$campaignId.tsx",
|
||||
"parent": "/_authenticated"
|
||||
"/_app/login": {
|
||||
"filePath": "_app/login.tsx",
|
||||
"parent": "/_app"
|
||||
},
|
||||
"/_authenticated/document/$documentId": {
|
||||
"filePath": "_authenticated/document.$documentId.tsx",
|
||||
"parent": "/_authenticated"
|
||||
"/_app/": {
|
||||
"filePath": "_app/index.tsx",
|
||||
"parent": "/_app"
|
||||
},
|
||||
"/_authenticated/campaigns/": {
|
||||
"filePath": "_authenticated/campaigns.index.tsx",
|
||||
"parent": "/_authenticated"
|
||||
"/_app/_authenticated/campaigns/$campaignId": {
|
||||
"filePath": "_app/_authenticated/campaigns.$campaignId.tsx",
|
||||
"parent": "/_app/_authenticated"
|
||||
},
|
||||
"/_app/_authenticated/campaigns/": {
|
||||
"filePath": "_app/_authenticated/campaigns.index.tsx",
|
||||
"parent": "/_app/_authenticated"
|
||||
},
|
||||
"/_app/_authenticated/document/$documentId/$": {
|
||||
"filePath": "_app/_authenticated/document.$documentId.$.tsx",
|
||||
"parent": "/_app/_authenticated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,17 @@
|
||||
import { Link, Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/context/auth/AuthContext";
|
||||
import { DocumentProvider } from "@/context/document/DocumentContext";
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { AuthProvider, useAuth } from "@/context/auth/AuthContext";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/**
|
||||
* Root header with navigation and user authentication controls.
|
||||
*/
|
||||
function RootHeader() {
|
||||
const { user, logout, isLoading } = useAuth();
|
||||
return (
|
||||
<header className="flex items-center justify-between px-8 py-4 border-b border-slate-700 bg-slate-900">
|
||||
<h1 className="text-2xl font-bold text-slate-100 m-0">
|
||||
DM's Table Companion
|
||||
</h1>
|
||||
<nav aria-label="Main navigation" className="flex gap-6">
|
||||
<Link
|
||||
to="/campaigns"
|
||||
className="no-underline text-slate-200 hover:text-violet-400 transition-colors font-medium border-b-2 border-transparent pb-1"
|
||||
activeProps={{
|
||||
className:
|
||||
"no-underline text-violet-400 border-violet-400 border-b-2 pb-1",
|
||||
}}
|
||||
>
|
||||
Campaigns
|
||||
</Link>
|
||||
<Link
|
||||
to="/sessions"
|
||||
className="no-underline text-slate-200 hover:text-violet-400 transition-colors font-medium border-b-2 border-transparent pb-1"
|
||||
activeProps={{
|
||||
className:
|
||||
"no-underline text-violet-400 border-violet-400 border-b-2 pb-1",
|
||||
}}
|
||||
>
|
||||
Sessions
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="no-underline text-slate-200 hover:text-violet-400 transition-colors font-medium border-b-2 border-transparent pb-1"
|
||||
activeProps={{
|
||||
className:
|
||||
"no-underline text-violet-400 border-violet-400 border-b-2 pb-1",
|
||||
}}
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<>
|
||||
<span className="text-slate-200 text-sm" aria-label="User email">
|
||||
{user.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
disabled={isLoading}
|
||||
className="bg-red-600 hover:bg-red-700 text-white text-sm font-semibold px-3 py-1 rounded transition-colors disabled:opacity-60"
|
||||
aria-label="Log out"
|
||||
type="button"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="bg-violet-600 hover:bg-violet-700 text-white text-sm font-semibold px-3 py-1 rounded transition-colors"
|
||||
aria-label="Log in"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<>
|
||||
<AuthProvider>
|
||||
<RootHeader />
|
||||
<DocumentProvider>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</DocumentProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
81
src/routes/_app.tsx
Normal file
81
src/routes/_app.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { useAuth } from "@/context/auth/AuthContext";
|
||||
import { Link, Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export const Route = createFileRoute("/_app")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
/**
|
||||
* Root header with navigation and user authentication controls.
|
||||
*/
|
||||
function AppHeader() {
|
||||
const { user, logout, isLoading } = useAuth();
|
||||
return (
|
||||
<header className="flex flex-wrap items-center justify-between px-8 py-4 border-b border-slate-700 bg-slate-900">
|
||||
<h1 className="text-2xl font-bold text-slate-100 m-0">
|
||||
DM's Table Companion
|
||||
</h1>
|
||||
<nav aria-label="Main navigation" className="flex gap-6">
|
||||
<Link
|
||||
to="/campaigns"
|
||||
className="no-underline text-slate-200 hover:text-violet-400 transition-colors font-medium border-b-2 border-transparent pb-1"
|
||||
activeProps={{
|
||||
className:
|
||||
"no-underline text-violet-400 border-violet-400 border-b-2 pb-1",
|
||||
}}
|
||||
>
|
||||
Campaigns
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="no-underline text-slate-200 hover:text-violet-400 transition-colors font-medium border-b-2 border-transparent pb-1"
|
||||
activeProps={{
|
||||
className:
|
||||
"no-underline text-violet-400 border-violet-400 border-b-2 pb-1",
|
||||
}}
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<>
|
||||
<span className="text-slate-200 text-sm" aria-label="User email">
|
||||
{user.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
disabled={isLoading}
|
||||
className="bg-red-600 hover:bg-red-700 text-white text-sm font-semibold px-3 py-1 rounded transition-colors disabled:opacity-60"
|
||||
aria-label="Log out"
|
||||
type="button"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="bg-violet-600 hover:bg-violet-700 text-white text-sm font-semibold px-3 py-1 rounded transition-colors"
|
||||
aria-label="Log in"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<>
|
||||
<AppHeader />
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isAuthenticated } from "@/lib/pocketbase";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated")({
|
||||
export const Route = createFileRoute("/_app/_authenticated")({
|
||||
beforeLoad: () => {
|
||||
if (!isAuthenticated()) {
|
||||
throw redirect({
|
||||
111
src/routes/_app/_authenticated/campaigns.$campaignId.tsx
Normal file
111
src/routes/_app/_authenticated/campaigns.$campaignId.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { CampaignDocuments } from "@/components/campaign/CampaignDocuments";
|
||||
import { DocumentPreview } from "@/components/documents/DocumentPreview";
|
||||
import { Tab, TabbedLayout } from "@/components/layout/TabbedLayout";
|
||||
import { Loader } from "@/components/Loader";
|
||||
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(
|
||||
"/_app/_authenticated/campaigns/$campaignId",
|
||||
)({
|
||||
component: RouteComponent,
|
||||
pendingComponent: Loader,
|
||||
validateSearch: (s) => campaignSearchSchema.parse(s),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const params = Route.useParams();
|
||||
const { tab, docId } = Route.useSearch();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setLoading(true);
|
||||
const campaign = await pb
|
||||
.collection("campaigns")
|
||||
.getOne(params.campaignId);
|
||||
setCampaign(campaign as Campaign);
|
||||
setLoading(false);
|
||||
}
|
||||
fetchData();
|
||||
}, [setCampaign, setLoading]);
|
||||
|
||||
if (loading || campaign === null) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TabbedLayout
|
||||
title={
|
||||
<h2 className="text-2xl font-bold text-slate-100">{campaign.name}</h2>
|
||||
}
|
||||
navigation={
|
||||
<Link
|
||||
to="/campaigns"
|
||||
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
|
||||
>
|
||||
← Back to campaigns
|
||||
</Link>
|
||||
}
|
||||
tabs={Object.entries(CampaignTabs).map(([key, { label }]) => (
|
||||
<Tab
|
||||
key={key}
|
||||
label={label}
|
||||
active={tab === key}
|
||||
to={Route.to}
|
||||
params={{
|
||||
campaignId: campaign.id,
|
||||
}}
|
||||
search={{
|
||||
tab: key,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
content={
|
||||
<CampaignDocuments
|
||||
campaignId={campaign.id}
|
||||
docType={CampaignTabs[tab].docType}
|
||||
/>
|
||||
}
|
||||
flyout={docId && <Flyout key={docId} docId={docId} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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} />;
|
||||
}
|
||||
@@ -6,10 +6,17 @@ import { Loader } from "@/components/Loader";
|
||||
import { CreateCampaignButton } from "@/components/CreateCampaignButton";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/campaigns/")({
|
||||
export const Route = createFileRoute("/_app/_authenticated/campaigns/")({
|
||||
loader: async () => {
|
||||
const records = await pb.collection("campaigns").getFullList();
|
||||
return { campaigns: records.map((rec: any) => ({ id: rec.id, name: rec.name })) as Campaign[] };
|
||||
const records = await pb.collection("campaigns").getFullList({
|
||||
sort: "-created",
|
||||
});
|
||||
return {
|
||||
campaigns: records.map((rec: any) => ({
|
||||
id: rec.id,
|
||||
name: rec.name,
|
||||
})) as Campaign[],
|
||||
};
|
||||
},
|
||||
component: RouteComponent,
|
||||
pendingComponent: Loader,
|
||||
@@ -23,11 +30,12 @@ function RouteComponent() {
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
if (!campaigns || campaigns.length === 0) return <div>No campaigns found.</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-8">
|
||||
<h2 className="text-xl font-bold mb-4 text-slate-100">Your Campaigns</h2>
|
||||
{!campaigns || campaigns.length === 0 ? (
|
||||
<div>No campaigns found.</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{campaigns.map((c) => (
|
||||
<li key={c.id}>
|
||||
@@ -35,12 +43,14 @@ function RouteComponent() {
|
||||
to="/campaigns/$campaignId"
|
||||
params={{ campaignId: c.id }}
|
||||
className="block px-4 py-2 rounded bg-slate-800 hover:bg-violet-700 text-slate-100 transition-colors"
|
||||
search={{ tab: "sessions" }}
|
||||
>
|
||||
{c.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<CreateCampaignButton onCreated={handleCreated} />
|
||||
</div>
|
||||
28
src/routes/_app/_authenticated/document.$documentId.$.tsx
Normal file
28
src/routes/_app/_authenticated/document.$documentId.$.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/about')({
|
||||
export const Route = createFileRoute('/_app/about')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
export const Route = createFileRoute("/_app/")({
|
||||
component: App,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useState } from "react";
|
||||
* Login and signup page for authentication.
|
||||
* Allows users to log in or create a new account.
|
||||
*/
|
||||
export const Route = createFileRoute("/login")({
|
||||
export const Route = createFileRoute("/_app/login")({
|
||||
beforeLoad: () => {
|
||||
if (isAuthenticated()) {
|
||||
throw redirect({
|
||||
@@ -1,58 +0,0 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { Campaign } from "@/lib/types";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/campaigns/$campaignId")({
|
||||
loader: async ({ params }) => {
|
||||
const record = await pb.collection("campaigns").getOne(params.campaignId);
|
||||
// Fetch all documents for this campaign
|
||||
const docs = await pb.collection("documents").getFullList({
|
||||
filter: `campaign = "${params.campaignId}"`,
|
||||
});
|
||||
// Filter to only those with data.session
|
||||
const sessions = docs.filter((doc: any) => doc.data && doc.data.session);
|
||||
return {
|
||||
campaign: { id: record.id, name: record.name } as Campaign,
|
||||
sessions,
|
||||
};
|
||||
},
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { campaign, sessions } = Route.useLoaderData();
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-8">
|
||||
<div className="mb-2">
|
||||
<Link
|
||||
to="/campaigns"
|
||||
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
|
||||
>
|
||||
← Back to campaigns
|
||||
</Link>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-4 text-slate-100">{campaign.name}</h2>
|
||||
<h3 className="text-lg font-semibold mb-2 text-slate-200">Sessions</h3>
|
||||
{sessions && sessions.length > 0 ? (
|
||||
<div>
|
||||
<ul className="space-y-2">
|
||||
{sessions.map((s: any) => (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
to="/document/$documentId"
|
||||
params={{ documentId: s.id }}
|
||||
className="block px-4 py-2 rounded bg-slate-800 hover:bg-violet-700 text-slate-100 transition-colors"
|
||||
>
|
||||
{s.data.session.strongStart || <span className="italic text-slate-400">(No strong start)</span>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-400">No sessions found for this campaign.</div>
|
||||
)}
|
||||
{/* More campaign details can go here */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||
import { useState } from "react";
|
||||
import type { Secret } from "@/lib/types";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/document/$documentId")({
|
||||
loader: async ({ params }) => {
|
||||
const doc = await pb.collection("documents").getOne(params.documentId);
|
||||
// Fetch the unique relationship where this document is the primary and type is "plannedSecrets"
|
||||
const relationships = await pb.collection("relationships").getList(1, 1, {
|
||||
filter: `primary = "${params.documentId}" && type = "plannedSecrets"`,
|
||||
});
|
||||
// Get all related secret document IDs from the secondary field
|
||||
const secretIds =
|
||||
relationships.items.length > 0 ? relationships.items[0].secondary : [];
|
||||
// Fetch all related secret documents
|
||||
let secrets: any[] = [];
|
||||
if (Array.isArray(secretIds) && secretIds.length > 0) {
|
||||
secrets = await pb.collection("documents").getFullList({
|
||||
filter: secretIds.map((id) => `id = "${id}"`).join(" || "),
|
||||
});
|
||||
}
|
||||
return { document: doc, secrets };
|
||||
},
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { document: session, secrets } = Route.useLoaderData();
|
||||
const strongStart = session?.data?.session?.strongStart || "";
|
||||
const [newSecret, setNewSecret] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [secretList, setSecretList] = useState(secrets);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSaveStrongStart(newValue: string) {
|
||||
await pb.collection("documents").update(session.id, {
|
||||
data: {
|
||||
...session.data,
|
||||
session: {
|
||||
...session.data.session,
|
||||
strongStart: newValue,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAddSecret() {
|
||||
if (!newSecret.trim()) return;
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 1. Create the secret document
|
||||
const secretDoc = await pb.collection("documents").create({
|
||||
campaign: session.campaign, // assuming campaign is an id or object
|
||||
data: {
|
||||
secret: {
|
||||
text: newSecret,
|
||||
discoveredOn: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
// 2. Check for existing relationship
|
||||
const existing = await pb.collection("relationships").getFullList({
|
||||
filter: `primary = "${session.id}" && type = "plannedSecrets"`,
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
// Update existing relationship to add new secret to secondary array
|
||||
await pb.collection("relationships").update(existing[0].id, {
|
||||
"+secondary": secretDoc.id,
|
||||
});
|
||||
} else {
|
||||
// Create new relationship
|
||||
await pb.collection("relationships").create({
|
||||
primary: session.id,
|
||||
secondary: [secretDoc.id],
|
||||
type: "plannedSecrets",
|
||||
});
|
||||
}
|
||||
setSecretList([...secretList, secretDoc]);
|
||||
setNewSecret("");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to add secret.");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleDiscovered(secret: Secret, checked: boolean) {
|
||||
// 1. Update the discovered field in the secret document
|
||||
await pb.collection("documents").update(secret.id, {
|
||||
data: {
|
||||
...secret.data,
|
||||
secret: {
|
||||
text: secret.data.secret.text,
|
||||
discovered: checked,
|
||||
},
|
||||
},
|
||||
});
|
||||
// 2. 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);
|
||||
}
|
||||
// 3. If marking as discovered, add a new discoveredIn relationship
|
||||
if (checked) {
|
||||
await pb.collection("relationships").create({
|
||||
primary: secret.id,
|
||||
secondary: [session.id],
|
||||
type: "discoveredIn",
|
||||
});
|
||||
}
|
||||
// 4. Update local state
|
||||
setSecretList(
|
||||
secretList.map((s: any) =>
|
||||
s.id === secret.id
|
||||
? {
|
||||
...s,
|
||||
data: {
|
||||
...s.data,
|
||||
secret: { ...s.data.secret, discovered: checked },
|
||||
},
|
||||
}
|
||||
: s,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-8">
|
||||
<h2 className="text-2xl font-bold mb-4 text-slate-100">
|
||||
Session Strong Start
|
||||
</h2>
|
||||
<AutoSaveTextarea
|
||||
value={strongStart}
|
||||
onSave={handleSaveStrongStart}
|
||||
placeholder="Enter a strong start for this session..."
|
||||
aria-label="Strong Start"
|
||||
/>
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-slate-200">
|
||||
Planned Secrets
|
||||
</h3>
|
||||
{secretList && secretList.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{secretList.map((secret: any) => (
|
||||
<li
|
||||
key={secret.id}
|
||||
className="bg-slate-800 rounded p-4 text-slate-100 flex items-center gap-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!secret.data?.secret?.discovered}
|
||||
onChange={(e) =>
|
||||
handleToggleDiscovered(secret, e.target.checked)
|
||||
}
|
||||
className="accent-emerald-500 w-5 h-5"
|
||||
aria-label="Discovered"
|
||||
/>
|
||||
<span>
|
||||
{secret.data?.secret?.text || (
|
||||
<span className="italic text-slate-400">
|
||||
(No secret text)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-slate-400">
|
||||
No planned secrets for this session.
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
className="flex items-center gap-2 mt-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddSecret();
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
background-color: #0f172a; /* slate-900 */
|
||||
color: #f1f5f9; /* slate-100 */
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -15,24 +17,21 @@ body {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
code, pre {
|
||||
font-family: 'Fira Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
|
||||
/* The container for all content */
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: "Fira Mono", "Menlo", "Monaco", "Consolas", monospace;
|
||||
background: #1e293b; /* slate-800 */
|
||||
color: #a5b4fc; /* violet-300 */
|
||||
border-radius: 0.25rem;
|
||||
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 */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
|
||||
@@ -7,14 +7,21 @@ import { resolve } from "node:path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite({ autoCodeSplitting: true }), viteReact(), tailwindcss()],
|
||||
plugins: [
|
||||
TanStackRouterVite({ autoCodeSplitting: true }),
|
||||
viteReact(),
|
||||
tailwindcss(),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user