Compare commits
10 Commits
3c989cf285
...
0ed2066b17
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ed2066b17 | |||
| 2c01a80604 | |||
| 81fd84790b | |||
| 6b6636d695 | |||
| d5dfa8c30a | |||
| 8b837d622c | |||
| 5eba132bda | |||
| 6336b150a7 | |||
| b3d4e90e7f | |||
| 8bee0973cd |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,4 +5,9 @@ dist-ssr
|
|||||||
*.local
|
*.local
|
||||||
.direnv/
|
.direnv/
|
||||||
.vite/
|
.vite/
|
||||||
|
|
||||||
|
# Local Pocketbase data
|
||||||
pb_data/
|
pb_data/
|
||||||
|
|
||||||
|
# Mprocs drops stuff here
|
||||||
|
mprocs.log
|
||||||
|
|||||||
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;"]
|
||||||
23
docker/pocketbase.dockerfile
Normal file
23
docker/pocketbase.dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 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/
|
||||||
|
|
||||||
|
# uncomment to copy the local pb_migrations dir into the image
|
||||||
|
COPY ./pb_migrations /pb/pb_migrations
|
||||||
|
|
||||||
|
# uncomment to copy the local pb_hooks dir into the image
|
||||||
|
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,13 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta name="description" content="Plan and run your D&D sessions" />
|
||||||
name="description"
|
|
||||||
content="Web site created using create-tsrouter-app"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>Create TanStack App - .</title>
|
<title>Dungeon Master's Companion</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
234
package-lock.json
generated
234
package-lock.json
generated
@@ -6,11 +6,13 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "lazy-dm",
|
"name": "lazy-dm",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.4",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@tanstack/react-query": "^5.77.2",
|
"@tanstack/react-query": "^5.77.2",
|
||||||
"@tanstack/react-router": "^1.114.3",
|
"@tanstack/react-router": "^1.114.3",
|
||||||
"@tanstack/react-router-devtools": "^1.114.3",
|
"@tanstack/react-router-devtools": "^1.114.3",
|
||||||
"@tanstack/router-plugin": "^1.114.3",
|
"@tanstack/router-plugin": "^1.114.3",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"pocketbase": "^0.26.0",
|
"pocketbase": "^0.26.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@types/lodash": "^4.17.17",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -874,6 +877,79 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@isaacs/fs-minipass": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||||
@@ -934,6 +1010,103 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.9",
|
"version": "1.0.0-beta.9",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz",
|
||||||
@@ -1201,6 +1374,15 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.7",
|
"version": "4.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz",
|
||||||
@@ -1567,6 +1749,23 @@
|
|||||||
"react-dom": "^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/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": {
|
"node_modules/@tanstack/router-core": {
|
||||||
"version": "1.120.10",
|
"version": "1.120.10",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.120.10.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.120.10.tgz",
|
||||||
@@ -1725,6 +1924,16 @@
|
|||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@tanstack/virtual-file-routes": {
|
||||||
"version": "1.115.0",
|
"version": "1.115.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.115.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.115.0.tgz",
|
||||||
@@ -1840,6 +2049,13 @@
|
|||||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.1.5",
|
"version": "19.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz",
|
||||||
@@ -3008,6 +3224,12 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/loupe": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
|
||||||
@@ -3482,6 +3704,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.7",
|
"version": "4.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
|
||||||
@@ -3679,6 +3907,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.19.4",
|
"version": "4.19.4",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -3,18 +3,23 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 3000",
|
"dev": "mprocs \"npm run start\" \"pocketbase serve\"",
|
||||||
"start": "vite --port 3000",
|
"start": "vite --port 3000",
|
||||||
"build": "vite build && tsc",
|
"build": "vite build && tsc",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"docker:build:app": "docker build -t docker.havenisms.com/lazy-dm/app -f docker/app.dockerfile .",
|
||||||
|
"docker:build:pocketbase": "docker build -t docker.havenisms.com/lazy-dm/pocketbase -f docker/pocketsbase.dockerfile .",
|
||||||
|
"docker:build": "npm run docker:build:app && npm run docker:build:pocketbase"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.4",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@tanstack/react-query": "^5.77.2",
|
"@tanstack/react-query": "^5.77.2",
|
||||||
"@tanstack/react-router": "^1.114.3",
|
"@tanstack/react-router": "^1.114.3",
|
||||||
"@tanstack/react-router-devtools": "^1.114.3",
|
"@tanstack/react-router-devtools": "^1.114.3",
|
||||||
"@tanstack/router-plugin": "^1.114.3",
|
"@tanstack/router-plugin": "^1.114.3",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"pocketbase": "^0.26.0",
|
"pocketbase": "^0.26.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -23,6 +28,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@types/lodash": "^4.17.17",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@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)
|
||||||
|
})
|
||||||
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
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { pb } from "@/lib/pocketbase";
|
|
||||||
import { useAuth } from "@/context/auth/AuthContext";
|
import { useAuth } from "@/context/auth/AuthContext";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
import type { Campaign } from "@/lib/types";
|
import type { Campaign } from "@/lib/types";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Button and form for creating a new campaign. Handles UI state and creation logic.
|
* 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 [creating, setCreating] = useState(false);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -29,7 +33,7 @@ export function CreateCampaignButton({ onCreated }: { onCreated?: (campaign: Cam
|
|||||||
});
|
});
|
||||||
setName("");
|
setName("");
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
if (onCreated) onCreated({ id: record.id, name: record.name });
|
if (onCreated) onCreated(record as Campaign);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || "Failed to create campaign.");
|
setError(e?.message || "Failed to create campaign.");
|
||||||
} finally {
|
} 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"
|
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"
|
placeholder="Campaign name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -68,7 +72,11 @@ export function CreateCampaignButton({ onCreated }: { onCreated?: (campaign: Cam
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="px-2 py-2 rounded text-slate-400 hover:text-red-400"
|
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}
|
disabled={loading}
|
||||||
aria-label="Cancel"
|
aria-label="Cancel"
|
||||||
>
|
>
|
||||||
|
|||||||
120
src/components/DocumentList.tsx
Normal file
120
src/components/DocumentList.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Document } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogPanel,
|
||||||
|
DialogTitle,
|
||||||
|
Transition,
|
||||||
|
TransitionChild,
|
||||||
|
} from "@headlessui/react";
|
||||||
|
import { Fragment, useState } from "react";
|
||||||
|
|
||||||
|
type Props<T extends Document> = {
|
||||||
|
title: React.ReactNode;
|
||||||
|
error?: React.ReactNode;
|
||||||
|
items: T[];
|
||||||
|
renderRow: (item: T) => React.ReactNode;
|
||||||
|
newItemForm: (onSubmit: () => void) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DocumentList is a generic list component for displaying document items with a dialog for adding new items.
|
||||||
|
*
|
||||||
|
* @param title - The title displayed above the list (left-aligned)
|
||||||
|
* @param items - The array of document items to display
|
||||||
|
* @param renderRow - Function to render each row's content
|
||||||
|
* @param newItemForm - Function that renders a form for creating a new item; receives an onSubmit callback
|
||||||
|
*/
|
||||||
|
export function DocumentList<T extends Document>({
|
||||||
|
title,
|
||||||
|
error,
|
||||||
|
items,
|
||||||
|
renderRow,
|
||||||
|
newItemForm,
|
||||||
|
}: Props<T>) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Handles closing the dialog after form submission
|
||||||
|
const handleFormSubmit = (): void => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full max-w-2xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-slate-100">{title}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-9 h-9 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||||
|
aria-label="Add new item"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-900 rounded p-4 text-slate-100">{error}</div>
|
||||||
|
)}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="bg-slate-800 rounded p-4 text-slate-100">
|
||||||
|
{renderRow(item)}
|
||||||
|
</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">
|
||||||
|
<DialogTitle className="text-lg font-semibold text-slate-100 mb-4">
|
||||||
|
Add New
|
||||||
|
</DialogTitle>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/RelationshipList.tsx
Normal file
82
src/components/RelationshipList.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { DocumentList } from "@/components/DocumentList";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
import type { Document, RelationshipType } from "@/lib/types";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
import { DocumentForm } from "./documents/DocumentForm";
|
||||||
|
import { DocumentRow } from "./documents/DocumentRow";
|
||||||
|
|
||||||
|
interface RelationshipListProps {
|
||||||
|
root: Document;
|
||||||
|
items: Document[];
|
||||||
|
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,
|
||||||
|
items: initialItems,
|
||||||
|
relationshipType,
|
||||||
|
}: RelationshipListProps) {
|
||||||
|
const [items, setItems] = useState<Document[]>(initialItems);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Handles creation of a new document and adds it to the relationship
|
||||||
|
const handleCreate = async (doc: Document) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Check for existing relationship
|
||||||
|
const existing = await pb.collection("relationships").getFullList({
|
||||||
|
filter: `primary = "${root.id}" && type = "${relationshipType}"`,
|
||||||
|
});
|
||||||
|
if (existing.length > 0) {
|
||||||
|
console.debug("Adding to existing relationship");
|
||||||
|
await pb.collection("relationships").update(existing[0].id, {
|
||||||
|
"+secondary": doc.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.debug("Creating new relationship");
|
||||||
|
await pb.collection("relationships").create({
|
||||||
|
primary: root.id,
|
||||||
|
secondary: [doc.id],
|
||||||
|
type: relationshipType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setItems((prev) => [...prev, doc]);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || "Failed to add document to relationship.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
<Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentList
|
||||||
|
title={
|
||||||
|
relationshipType.charAt(0).toUpperCase() + relationshipType.slice(1)
|
||||||
|
}
|
||||||
|
items={items}
|
||||||
|
error={error}
|
||||||
|
renderRow={(document) => <DocumentRow document={document} />}
|
||||||
|
newItemForm={(onSubmit) => (
|
||||||
|
<DocumentForm
|
||||||
|
campaignId={root.campaign}
|
||||||
|
relationshipType={relationshipType}
|
||||||
|
onCreate={async (doc: Document) => {
|
||||||
|
await handleCreate(doc);
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/documents/DocumentForm.tsx
Normal file
34
src/components/documents/DocumentForm.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { RelationshipType, type CampaignId, type Document } from "@/lib/types";
|
||||||
|
import { SecretForm } from "./secret/SecretForm";
|
||||||
|
import { TreasureForm } from "./treasure/TreasureForm";
|
||||||
|
import { SceneForm } from "./scene/SceneForm";
|
||||||
|
|
||||||
|
function assertUnreachable(_x: never): never {
|
||||||
|
throw new Error("DocumentForm switch is not exhaustive");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a form for any document type depending on the relationship.
|
||||||
|
*/
|
||||||
|
export const DocumentForm = ({
|
||||||
|
campaignId,
|
||||||
|
relationshipType,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
campaignId: CampaignId;
|
||||||
|
relationshipType: RelationshipType;
|
||||||
|
onCreate: (document: Document) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
switch (relationshipType) {
|
||||||
|
case RelationshipType.Secrets:
|
||||||
|
return <SecretForm campaign={campaignId} onCreate={onCreate} />;
|
||||||
|
case RelationshipType.DiscoveredIn:
|
||||||
|
return "Form not supported here";
|
||||||
|
case RelationshipType.Treasures:
|
||||||
|
return <TreasureForm campaign={campaignId} onCreate={onCreate} />;
|
||||||
|
case RelationshipType.Scenes:
|
||||||
|
return <SceneForm campaign={campaignId} onCreate={onCreate} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assertUnreachable(relationshipType);
|
||||||
|
};
|
||||||
53
src/components/documents/DocumentRow.tsx
Normal file
53
src/components/documents/DocumentRow.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// DocumentRow.tsx
|
||||||
|
// Generic row component for displaying any document type.
|
||||||
|
import { SessionRow } from "@/components/documents/session/SessionRow";
|
||||||
|
import { SecretRow } from "@/components/documents/secret/SecretRow";
|
||||||
|
import {
|
||||||
|
isScene,
|
||||||
|
isSecret,
|
||||||
|
isSession,
|
||||||
|
isTreasure,
|
||||||
|
type Document,
|
||||||
|
type Session,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { TreasureRow } from "./treasure/TreasureRow";
|
||||||
|
import { SceneRow } from "./scene/SceneRow";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
session,
|
||||||
|
}: {
|
||||||
|
document: Document;
|
||||||
|
session?: Session;
|
||||||
|
}) => {
|
||||||
|
if (isSession(document)) {
|
||||||
|
// Use SessionRow for session documents
|
||||||
|
return <SessionRow session={document} />;
|
||||||
|
}
|
||||||
|
if (isSecret(document)) {
|
||||||
|
return <SecretRow secret={document} session={session} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isScene(document)) {
|
||||||
|
return <SceneRow scene={document} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTreasure(document)) {
|
||||||
|
return <TreasureRow treasure={document} session={session} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: show ID and creation time
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-lg text-slate-300">
|
||||||
|
Unrecognized Document
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-400 text-sm">ID: {document.id}</div>
|
||||||
|
<div className="text-slate-400 text-sm">Created: {document.created}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/components/documents/scene/SceneForm.tsx
Normal file
66
src/components/documents/scene/SceneForm.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// SceneForm.tsx
|
||||||
|
// Form for adding a new scene to a session.
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { CampaignId, Scene } from "@/lib/types";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a form to add a new scene. Calls onCreate with the new scene document.
|
||||||
|
*/
|
||||||
|
export const SceneForm = ({
|
||||||
|
campaign,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
campaign: CampaignId;
|
||||||
|
onCreate: (scene: Scene) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!text.trim()) return;
|
||||||
|
setAdding(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const sceneDoc: Scene = await pb.collection("documents").create({
|
||||||
|
campaign,
|
||||||
|
data: {
|
||||||
|
scene: {
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setText("");
|
||||||
|
await onCreate(sceneDoc);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || "Failed to add scene.");
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex items-center gap-2 mt-4" onSubmit={handleSubmit}>
|
||||||
|
<h3>Create new scene</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
placeholder="Add a new scene..."
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
disabled={adding}
|
||||||
|
aria-label="Add new scene"
|
||||||
|
/>
|
||||||
|
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
|
||||||
|
disabled={adding || !text.trim()}
|
||||||
|
>
|
||||||
|
{adding ? "Adding..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
25
src/components/documents/scene/SceneRow.tsx
Normal file
25
src/components/documents/scene/SceneRow.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
import type { Scene } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an editable scene row
|
||||||
|
*/
|
||||||
|
export const SceneRow = ({ scene }: { scene: Scene }) => {
|
||||||
|
async function saveScene(text: string) {
|
||||||
|
await pb.collection("documents").update(scene.id, {
|
||||||
|
data: {
|
||||||
|
...scene.data,
|
||||||
|
scene: {
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<AutoSaveTextarea value={scene.data.scene.text} onSave={saveScene} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/components/documents/secret/SecretForm.tsx
Normal file
66
src/components/documents/secret/SecretForm.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// SecretForm.tsx
|
||||||
|
// Form for adding a new secret to a session.
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { CampaignId, Secret } from "@/lib/types";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a form to add a new secret. Calls onCreate with the new secret document.
|
||||||
|
*/
|
||||||
|
export const SecretForm = ({
|
||||||
|
campaign,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
campaign: CampaignId;
|
||||||
|
onCreate: (secret: Secret) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const [newSecret, setNewSecret] = useState("");
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newSecret.trim()) return;
|
||||||
|
setAdding(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const secretDoc: Secret = await pb.collection("documents").create({
|
||||||
|
campaign,
|
||||||
|
data: {
|
||||||
|
secret: {
|
||||||
|
text: newSecret,
|
||||||
|
discovered: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setNewSecret("");
|
||||||
|
await onCreate(secretDoc);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || "Failed to add secret.");
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex items-center gap-2 mt-4" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
placeholder="Add a new secret..."
|
||||||
|
value={newSecret}
|
||||||
|
onChange={(e) => setNewSecret(e.target.value)}
|
||||||
|
disabled={adding}
|
||||||
|
aria-label="Add new secret"
|
||||||
|
/>
|
||||||
|
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
|
||||||
|
disabled={adding || !newSecret.trim()}
|
||||||
|
>
|
||||||
|
{adding ? "Adding..." : "Add Secret"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
78
src/components/documents/secret/SecretRow.tsx
Normal file
78
src/components/documents/secret/SecretRow.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// SecretRow.tsx
|
||||||
|
// Displays a single secret with discovered checkbox and text.
|
||||||
|
import type { Secret, Session } from "@/lib/types";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a secret row with a discovered checkbox and secret text.
|
||||||
|
* Handles updating the discovered state and discoveredIn relationship.
|
||||||
|
*/
|
||||||
|
export const SecretRow = ({
|
||||||
|
secret,
|
||||||
|
session,
|
||||||
|
}: {
|
||||||
|
secret: Secret;
|
||||||
|
session?: Session;
|
||||||
|
}) => {
|
||||||
|
const [checked, setChecked] = useState(
|
||||||
|
!!(secret.data as any)?.secret?.discovered,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const newChecked = e.target.checked;
|
||||||
|
setLoading(true);
|
||||||
|
setChecked(newChecked);
|
||||||
|
try {
|
||||||
|
await pb.collection("documents").update(secret.id, {
|
||||||
|
data: {
|
||||||
|
...secret.data,
|
||||||
|
secret: {
|
||||||
|
...(secret.data as any).secret,
|
||||||
|
discovered: newChecked,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (session || !newChecked) {
|
||||||
|
// If the session exists or the element is being unchecked, remove any
|
||||||
|
// existing discoveredIn relationship
|
||||||
|
const rels = await pb.collection("relationships").getList(1, 1, {
|
||||||
|
filter: `primary = "${secret.id}" && type = "discoveredIn"`,
|
||||||
|
});
|
||||||
|
if (rels.items.length > 0) {
|
||||||
|
await pb.collection("relationships").delete(rels.items[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session) {
|
||||||
|
if (newChecked) {
|
||||||
|
await pb.collection("relationships").create({
|
||||||
|
primary: secret.id,
|
||||||
|
secondary: [session.id],
|
||||||
|
type: "discoveredIn",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{(secret.data as any)?.secret?.text || (
|
||||||
|
<span className="italic text-slate-400">(No secret text)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
22
src/components/documents/session/SessionForm.tsx
Normal file
22
src/components/documents/session/SessionForm.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||||
|
import type { Session } from "@/lib/types";
|
||||||
|
|
||||||
|
export const SessionForm = ({
|
||||||
|
session,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
session: Session;
|
||||||
|
onSubmit: (data: Session["data"]) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<h3 className="text-lg font-bold mb-4 text-slate-100">Strong Start</h3>
|
||||||
|
<AutoSaveTextarea
|
||||||
|
value={session.data.session.strongStart}
|
||||||
|
onSave={(value) => onSubmit({ session: { strongStart: value } })}
|
||||||
|
placeholder="Enter a strong start for this session..."
|
||||||
|
aria-label="Strong Start"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
src/components/documents/session/SessionRow.tsx
Normal file
17
src/components/documents/session/SessionRow.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Session } from "@/lib/types";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const SessionRow = ({ session }: { session: Session }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to="/document/$documentId"
|
||||||
|
params={{ documentId: session.id }}
|
||||||
|
className="block font-semibold text-lg text-slate-300"
|
||||||
|
>
|
||||||
|
{session.created}
|
||||||
|
</Link>
|
||||||
|
<div className="">{session.data.session.strongStart}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/components/documents/treasure/TreasureForm.tsx
Normal file
66
src/components/documents/treasure/TreasureForm.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// TreasureForm.tsx
|
||||||
|
// Form for adding a new treasure to a session.
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { CampaignId, Treasure } from "@/lib/types";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a form to add a new treasure. Calls onCreate with the new treasure document.
|
||||||
|
*/
|
||||||
|
export const TreasureForm = ({
|
||||||
|
campaign,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
campaign: CampaignId;
|
||||||
|
onCreate: (treasure: Treasure) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const [newTreasure, setNewTreasure] = useState("");
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newTreasure.trim()) return;
|
||||||
|
setAdding(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const treasureDoc: Treasure = await pb.collection("documents").create({
|
||||||
|
campaign,
|
||||||
|
data: {
|
||||||
|
treasure: {
|
||||||
|
text: newTreasure,
|
||||||
|
discovered: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setNewTreasure("");
|
||||||
|
await onCreate(treasureDoc);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || "Failed to add treasure.");
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex items-center gap-2 mt-4" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
placeholder="Add a new treasure..."
|
||||||
|
value={newTreasure}
|
||||||
|
onChange={(e) => setNewTreasure(e.target.value)}
|
||||||
|
disabled={adding}
|
||||||
|
aria-label="Add new treasure"
|
||||||
|
/>
|
||||||
|
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
|
||||||
|
disabled={adding || !newTreasure.trim()}
|
||||||
|
>
|
||||||
|
{adding ? "Adding..." : "Add Treasure"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
78
src/components/documents/treasure/TreasureRow.tsx
Normal file
78
src/components/documents/treasure/TreasureRow.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// TreasureRow.tsx
|
||||||
|
// Displays a single treasure with discovered checkbox and text.
|
||||||
|
import type { Treasure, Session } from "@/lib/types";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a treasure row with a discovered checkbox and treasure text.
|
||||||
|
* Handles updating the discovered state and discoveredIn relationship.
|
||||||
|
*/
|
||||||
|
export const TreasureRow = ({
|
||||||
|
treasure,
|
||||||
|
session,
|
||||||
|
}: {
|
||||||
|
treasure: Treasure;
|
||||||
|
session?: Session;
|
||||||
|
}) => {
|
||||||
|
const [checked, setChecked] = useState(
|
||||||
|
!!(treasure.data as any)?.treasure?.discovered,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const newChecked = e.target.checked;
|
||||||
|
setLoading(true);
|
||||||
|
setChecked(newChecked);
|
||||||
|
try {
|
||||||
|
await pb.collection("documents").update(treasure.id, {
|
||||||
|
data: {
|
||||||
|
...treasure.data,
|
||||||
|
treasure: {
|
||||||
|
...(treasure.data as any).treasure,
|
||||||
|
discovered: newChecked,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (session || !newChecked) {
|
||||||
|
// If the session exists or the element is being unchecked, remove any
|
||||||
|
// existing discoveredIn relationship
|
||||||
|
const rels = await pb.collection("relationships").getList(1, 1, {
|
||||||
|
filter: `primary = "${treasure.id}" && type = "discoveredIn"`,
|
||||||
|
});
|
||||||
|
if (rels.items.length > 0) {
|
||||||
|
await pb.collection("relationships").delete(rels.items[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session) {
|
||||||
|
if (newChecked) {
|
||||||
|
await pb.collection("relationships").create({
|
||||||
|
primary: treasure.id,
|
||||||
|
secondary: [session.id],
|
||||||
|
type: "discoveredIn",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{(treasure.data as any)?.treasure?.text || (
|
||||||
|
<span className="italic text-slate-400">(No treasure text)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
*
|
*
|
||||||
* This includes endpoints and other environment-specific settings.
|
* 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 || "http://127.0.0.1:8090"; // Update as needed for deployment
|
||||||
|
|||||||
@@ -6,22 +6,53 @@ export type UserId = Id<"User">;
|
|||||||
export type CampaignId = Id<"Campaign">;
|
export type CampaignId = Id<"Campaign">;
|
||||||
export type DocumentId = Id<"Document">;
|
export type DocumentId = Id<"Document">;
|
||||||
|
|
||||||
|
export type ISO8601Date = string & { __type: "iso8601date" };
|
||||||
|
|
||||||
export type Campaign = RecordModel & {
|
export type Campaign = RecordModel & {
|
||||||
id: CampaignId;
|
id: CampaignId;
|
||||||
name: string;
|
name: string;
|
||||||
owner: UserId;
|
owner: UserId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Document = RecordModel & {
|
/******************************************
|
||||||
id: DocumentId;
|
* Relationships
|
||||||
campaign: Campaign;
|
******************************************/
|
||||||
data: {};
|
|
||||||
|
export const RelationshipType = {
|
||||||
|
DiscoveredIn: "discoveredIn",
|
||||||
|
Scenes: "scenes",
|
||||||
|
Secrets: "secrets",
|
||||||
|
Treasures: "treasures",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RelationshipType =
|
||||||
|
(typeof RelationshipType)[keyof typeof RelationshipType];
|
||||||
|
|
||||||
|
export type Relationship = RecordModel & {
|
||||||
|
primary: DocumentId;
|
||||||
|
secondary: DocumentId[];
|
||||||
|
type: RelationshipType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/******************************************
|
||||||
|
* Documents
|
||||||
|
******************************************/
|
||||||
|
|
||||||
export type DocumentData<K extends string, V> = {
|
export type DocumentData<K extends string, V> = {
|
||||||
data: Record<K, V>;
|
data: Record<K, V>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Document = RecordModel & {
|
||||||
|
id: DocumentId;
|
||||||
|
campaign: CampaignId;
|
||||||
|
data: {};
|
||||||
|
// These two are not in Pocketbase's types, but they seem to always be present
|
||||||
|
created: ISO8601Date;
|
||||||
|
updated: ISO8601Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Session **/
|
||||||
|
|
||||||
export type Session = Document &
|
export type Session = Document &
|
||||||
DocumentData<
|
DocumentData<
|
||||||
"session",
|
"session",
|
||||||
@@ -30,7 +61,25 @@ export type Session = Document &
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type ISO8601Date = string & { __type: "iso8601date" };
|
export function isSession(doc: Document): doc is Session {
|
||||||
|
return Object.hasOwn(doc.data, "session");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scene **/
|
||||||
|
|
||||||
|
export type Scene = Document &
|
||||||
|
DocumentData<
|
||||||
|
"scene",
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function isScene(doc: Document): doc is Scene {
|
||||||
|
return Object.hasOwn(doc.data, "scene");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Secret **/
|
||||||
|
|
||||||
export type Secret = Document &
|
export type Secret = Document &
|
||||||
DocumentData<
|
DocumentData<
|
||||||
@@ -41,8 +90,21 @@ export type Secret = Document &
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type Relationship = RecordModel & {
|
export function isSecret(doc: Document): doc is Secret {
|
||||||
primary: DocumentId;
|
return Object.hasOwn(doc.data, "secret");
|
||||||
secondary: DocumentId[];
|
}
|
||||||
type: "plannedSecrets" | "discoveredIn";
|
|
||||||
};
|
/** Treasure **/
|
||||||
|
|
||||||
|
export type Treasure = Document &
|
||||||
|
DocumentData<
|
||||||
|
"treasure",
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
discovered: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function isTreasure(doc: Document): doc is Treasure {
|
||||||
|
return Object.hasOwn(doc.data, "treasure");
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,16 +26,6 @@ function RootHeader() {
|
|||||||
>
|
>
|
||||||
Campaigns
|
Campaigns
|
||||||
</Link>
|
</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
|
<Link
|
||||||
to="/about"
|
to="/about"
|
||||||
className="no-underline text-slate-200 hover:text-violet-400 transition-colors font-medium border-b-2 border-transparent pb-1"
|
className="no-underline text-slate-200 hover:text-violet-400 transition-colors font-medium border-b-2 border-transparent pb-1"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import type { Campaign } from "@/lib/types";
|
import type { Campaign } from "@/lib/types";
|
||||||
|
import { SessionRow } from "@/components/documents/session/SessionRow";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/campaigns/$campaignId")({
|
export const Route = createFileRoute("/_authenticated/campaigns/$campaignId")({
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
@@ -31,26 +32,24 @@ function RouteComponent() {
|
|||||||
← Back to campaigns
|
← Back to campaigns
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold mb-4 text-slate-100">{campaign.name}</h2>
|
<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>
|
<h3 className="text-lg font-semibold mb-2 text-slate-200">Sessions</h3>
|
||||||
{sessions && sessions.length > 0 ? (
|
{sessions && sessions.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{sessions.map((s: any) => (
|
{sessions.map((s: any) => (
|
||||||
<li key={s.id}>
|
<li key={s.id}>
|
||||||
<Link
|
<SessionRow session={s} />
|
||||||
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-slate-400">No sessions found for this campaign.</div>
|
<div className="text-slate-400">
|
||||||
|
No sessions found for this campaign.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* More campaign details can go here */}
|
{/* More campaign details can go here */}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,203 +1,65 @@
|
|||||||
|
import _ from "lodash";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
import {
|
||||||
import { useState } from "react";
|
RelationshipType,
|
||||||
import type { Secret } from "@/lib/types";
|
type Relationship,
|
||||||
|
type Session,
|
||||||
|
type Document,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { RelationshipList } from "@/components/RelationshipList";
|
||||||
|
import { SessionForm } from "@/components/documents/session/SessionForm";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/document/$documentId")({
|
export const Route = createFileRoute("/_authenticated/document/$documentId")({
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
const doc = await pb.collection("documents").getOne(params.documentId);
|
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: Relationship[] = await pb
|
||||||
const relationships = await pb.collection("relationships").getList(1, 1, {
|
.collection("relationships")
|
||||||
filter: `primary = "${params.documentId}" && type = "plannedSecrets"`,
|
.getFullList({
|
||||||
});
|
filter: `primary = "${params.documentId}"`,
|
||||||
// Get all related secret document IDs from the secondary field
|
expand: "secondary",
|
||||||
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(" || "),
|
|
||||||
});
|
});
|
||||||
}
|
console.log("Fetched data: ", relationships);
|
||||||
return { document: doc, secrets };
|
return {
|
||||||
|
document: doc,
|
||||||
|
relationships: _.mapValues(
|
||||||
|
_.groupBy(relationships, (r) => r.type),
|
||||||
|
(rs: Relationship[]) => rs.flatMap((r) => r.expand?.secondary),
|
||||||
|
),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { document: session, secrets } = Route.useLoaderData();
|
const { document: session, relationships } = Route.useLoaderData() as {
|
||||||
const strongStart = session?.data?.session?.strongStart || "";
|
document: Session;
|
||||||
const [newSecret, setNewSecret] = useState("");
|
relationships: Record<RelationshipType, Document[]>;
|
||||||
const [adding, setAdding] = useState(false);
|
};
|
||||||
const [secretList, setSecretList] = useState(secrets);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSaveStrongStart(newValue: string) {
|
async function handleSaveSession(data: Session["data"]) {
|
||||||
await pb.collection("documents").update(session.id, {
|
await pb.collection("documents").update(session.id, {
|
||||||
data: {
|
data,
|
||||||
...session.data,
|
|
||||||
session: {
|
|
||||||
...session.data.session,
|
|
||||||
strongStart: newValue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddSecret() {
|
console.log("Parsed data: ", relationships);
|
||||||
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 (
|
return (
|
||||||
<div className="max-w-xl mx-auto py-8">
|
<div className="max-w-xl mx-auto py-8">
|
||||||
<h2 className="text-2xl font-bold mb-4 text-slate-100">
|
<SessionForm session={session as Session} onSubmit={handleSaveSession} />
|
||||||
Session Strong Start
|
{[
|
||||||
</h2>
|
RelationshipType.Scenes,
|
||||||
<AutoSaveTextarea
|
RelationshipType.Secrets,
|
||||||
value={strongStart}
|
RelationshipType.Treasures,
|
||||||
onSave={handleSaveStrongStart}
|
].map((relationshipType) => (
|
||||||
placeholder="Enter a strong start for this session..."
|
<RelationshipList
|
||||||
aria-label="Strong Start"
|
key={relationshipType}
|
||||||
/>
|
root={session}
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-slate-200">
|
relationshipType={relationshipType}
|
||||||
Planned Secrets
|
items={relationships[relationshipType] ?? []}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user