From 0ed2066b17f23901bc121b1a477d7fc13dddf8b4 Mon Sep 17 00:00:00 2001 From: Drew Haven Date: Sat, 31 May 2025 18:11:26 -0700 Subject: [PATCH] Adds scenes --- package-lock.json | 15 +++++ package.json | 2 + .../1748740224_updated_relationships.js | 45 +++++++++++++ src/components/RelationshipList.tsx | 48 ++------------ src/components/documents/DocumentForm.tsx | 3 + src/components/documents/DocumentRow.tsx | 7 ++ src/components/documents/scene/SceneForm.tsx | 66 +++++++++++++++++++ src/components/documents/scene/SceneRow.tsx | 25 +++++++ src/lib/types.ts | 23 ++++++- .../_authenticated/document.$documentId.tsx | 50 ++++++++++---- 10 files changed, 230 insertions(+), 54 deletions(-) create mode 100644 pb_migrations/1748740224_updated_relationships.js create mode 100644 src/components/documents/scene/SceneForm.tsx create mode 100644 src/components/documents/scene/SceneRow.tsx diff --git a/package-lock.json b/package-lock.json index 3fd8d46..b2e411a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-router": "^1.114.3", "@tanstack/react-router-devtools": "^1.114.3", "@tanstack/router-plugin": "^1.114.3", + "lodash": "^4.17.21", "pocketbase": "^0.26.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -20,6 +21,7 @@ "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", @@ -2047,6 +2049,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", @@ -3215,6 +3224,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", diff --git a/package.json b/package.json index a033320..85f47d7 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@tanstack/react-router": "^1.114.3", "@tanstack/react-router-devtools": "^1.114.3", "@tanstack/router-plugin": "^1.114.3", + "lodash": "^4.17.21", "pocketbase": "^0.26.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -27,6 +28,7 @@ "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", diff --git a/pb_migrations/1748740224_updated_relationships.js b/pb_migrations/1748740224_updated_relationships.js new file mode 100644 index 0000000..1a3ac41 --- /dev/null +++ b/pb_migrations/1748740224_updated_relationships.js @@ -0,0 +1,45 @@ +/// +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) +}) diff --git a/src/components/RelationshipList.tsx b/src/components/RelationshipList.tsx index 0729da2..e86c3e2 100644 --- a/src/components/RelationshipList.tsx +++ b/src/components/RelationshipList.tsx @@ -1,13 +1,14 @@ -import { useEffect, useState } from "react"; +import { DocumentList } from "@/components/DocumentList"; import { pb } from "@/lib/pocketbase"; import type { Document, RelationshipType } from "@/lib/types"; -import { DocumentList } from "@/components/DocumentList"; +import { useState } from "react"; import { Loader } from "./Loader"; -import { DocumentRow } from "./documents/DocumentRow"; import { DocumentForm } from "./documents/DocumentForm"; +import { DocumentRow } from "./documents/DocumentRow"; interface RelationshipListProps { root: Document; + items: Document[]; relationshipType: RelationshipType; } @@ -17,50 +18,13 @@ interface RelationshipListProps { */ export function RelationshipList({ root, + items: initialItems, relationshipType, }: RelationshipListProps) { - const [items, setItems] = useState([]); + const [items, setItems] = useState(initialItems); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Fetch related documents on mount or when root/relationshipType changes - useEffect(() => { - let cancelled = false; - async function fetchRelated() { - setLoading(true); - setError(null); - try { - const relationships = await pb - .collection("relationships") - .getList(1, 1, { - filter: `primary = "${root.id}" && type = "${relationshipType}"`, - }); - const secondaryIds = - relationships.items.length > 0 - ? relationships.items[0].secondary - : []; - let docs: Document[] = []; - if (Array.isArray(secondaryIds) && secondaryIds.length > 0) { - docs = (await pb.collection("documents").getFullList({ - filter: secondaryIds - .map((id: string) => `id = "${id}"`) - .join(" || "), - })) as Document[]; - } - if (!cancelled) setItems(docs); - } catch (e: any) { - if (!cancelled) - setError(e?.message || "Failed to load related documents."); - } finally { - if (!cancelled) setLoading(false); - } - } - fetchRelated(); - return () => { - cancelled = true; - }; - }, [root.id, relationshipType]); - // Handles creation of a new document and adds it to the relationship const handleCreate = async (doc: Document) => { setLoading(true); diff --git a/src/components/documents/DocumentForm.tsx b/src/components/documents/DocumentForm.tsx index f01a212..e3215c5 100644 --- a/src/components/documents/DocumentForm.tsx +++ b/src/components/documents/DocumentForm.tsx @@ -1,6 +1,7 @@ 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"); @@ -25,6 +26,8 @@ export const DocumentForm = ({ return "Form not supported here"; case RelationshipType.Treasures: return ; + case RelationshipType.Scenes: + return ; } return assertUnreachable(relationshipType); diff --git a/src/components/documents/DocumentRow.tsx b/src/components/documents/DocumentRow.tsx index 74d11d3..f38952f 100644 --- a/src/components/documents/DocumentRow.tsx +++ b/src/components/documents/DocumentRow.tsx @@ -3,6 +3,7 @@ import { SessionRow } from "@/components/documents/session/SessionRow"; import { SecretRow } from "@/components/documents/secret/SecretRow"; import { + isScene, isSecret, isSession, isTreasure, @@ -10,6 +11,7 @@ import { 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. @@ -29,6 +31,11 @@ export const DocumentRow = ({ if (isSecret(document)) { return ; } + + if (isScene(document)) { + return ; + } + if (isTreasure(document)) { return ; } diff --git a/src/components/documents/scene/SceneForm.tsx b/src/components/documents/scene/SceneForm.tsx new file mode 100644 index 0000000..bc09490 --- /dev/null +++ b/src/components/documents/scene/SceneForm.tsx @@ -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; +}) => { + const [text, setText] = useState(""); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(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 ( +
+

Create new scene

+ setText(e.target.value)} + disabled={adding} + aria-label="Add new scene" + /> + {error &&
{error}
} + +
+ ); +}; diff --git a/src/components/documents/scene/SceneRow.tsx b/src/components/documents/scene/SceneRow.tsx new file mode 100644 index 0000000..b91c244 --- /dev/null +++ b/src/components/documents/scene/SceneRow.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/src/lib/types.ts b/src/lib/types.ts index 2ee7e8e..b64c94c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,8 +19,9 @@ export type Campaign = RecordModel & { ******************************************/ export const RelationshipType = { - Secrets: "secrets", DiscoveredIn: "discoveredIn", + Scenes: "scenes", + Secrets: "secrets", Treasures: "treasures", } as const; @@ -50,6 +51,8 @@ export type Document = RecordModel & { updated: ISO8601Date; }; +/** Session **/ + export type Session = Document & DocumentData< "session", @@ -62,6 +65,22 @@ 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 & DocumentData< "secret", @@ -75,6 +94,8 @@ export function isSecret(doc: Document): doc is Secret { return Object.hasOwn(doc.data, "secret"); } +/** Treasure **/ + export type Treasure = Document & DocumentData< "treasure", diff --git a/src/routes/_authenticated/document.$documentId.tsx b/src/routes/_authenticated/document.$documentId.tsx index a80dd07..be6d087 100644 --- a/src/routes/_authenticated/document.$documentId.tsx +++ b/src/routes/_authenticated/document.$documentId.tsx @@ -1,19 +1,41 @@ +import _ from "lodash"; import { createFileRoute } from "@tanstack/react-router"; import { pb } from "@/lib/pocketbase"; -import { RelationshipType, type Session } from "@/lib/types"; +import { + RelationshipType, + 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")({ loader: async ({ params }) => { const doc = await pb.collection("documents").getOne(params.documentId); - return { document: doc }; + const relationships: Relationship[] = await pb + .collection("relationships") + .getFullList({ + filter: `primary = "${params.documentId}"`, + expand: "secondary", + }); + console.log("Fetched data: ", relationships); + return { + document: doc, + relationships: _.mapValues( + _.groupBy(relationships, (r) => r.type), + (rs: Relationship[]) => rs.flatMap((r) => r.expand?.secondary), + ), + }; }, component: RouteComponent, }); function RouteComponent() { - const { document: session }: { document: Session } = Route.useLoaderData(); + const { document: session, relationships } = Route.useLoaderData() as { + document: Session; + relationships: Record; + }; async function handleSaveSession(data: Session["data"]) { await pb.collection("documents").update(session.id, { @@ -21,17 +43,23 @@ function RouteComponent() { }); } + console.log("Parsed data: ", relationships); + return (
- - + {[ + RelationshipType.Scenes, + RelationshipType.Secrets, + RelationshipType.Treasures, + ].map((relationshipType) => ( + + ))}
); }