diff --git a/package-lock.json b/package-lock.json index 77aea82..285e630 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "lazy-dm", "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.7.4", "@headlessui/react": "^2.2.4", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-query": "^5.79.0", @@ -67,6 +68,17 @@ "dev": true, "license": "ISC" }, + "node_modules/@atlaskit/pragmatic-drag-and-drop": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.4.tgz", + "integrity": "sha512-lZHnO9BJdHPKnwB0uvVUCyDnIhL+WAHzXQ2EXX0qacogOsnvIUiCgY0BLKhBqTCWln3/f/Ox5jU54MKO6ayh9A==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^3.0.0", + "raf-schd": "^4.0.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -312,7 +324,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2352,6 +2363,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bind-event-listener": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", + "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==", + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -3510,6 +3527,12 @@ "node": ">=6" } }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", diff --git a/package.json b/package.json index 95df9f2..24d6715 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "docker:build": "npm run docker:build:app && npm run docker:build:pocketbase" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.7.4", "@headlessui/react": "^2.2.4", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-query": "^5.79.0", diff --git a/src/components/DocumentList.tsx b/src/components/DocumentList.tsx index 2127cca..71b7356 100644 --- a/src/components/DocumentList.tsx +++ b/src/components/DocumentList.tsx @@ -1,4 +1,4 @@ -import type { Document } from "@/lib/types"; +import type { Document, DocumentId } from "@/lib/types"; import { Dialog, DialogPanel, @@ -6,7 +6,8 @@ import { Transition, TransitionChild, } from "@headlessui/react"; -import { Fragment, useState } from "react"; +import { Fragment, useCallback, useState } from "react"; +import * as Icons from "@/components/Icons.tsx"; type Props = { title: React.ReactNode; @@ -14,6 +15,7 @@ type Props = { items: T[]; renderRow: (item: T) => React.ReactNode; newItemForm: (onSubmit: () => void) => React.ReactNode; + removeItem: (itemId: DocumentId) => void; }; /** @@ -30,8 +32,15 @@ export function DocumentList({ items, renderRow, newItemForm, + removeItem, }: Props) { const [open, setOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + const toggleEditMode = useCallback( + () => setIsEditing((x) => !x), + [setIsEditing], + ); // Handles closing the dialog after form submission const handleFormSubmit = (): void => { @@ -42,35 +51,50 @@ export function DocumentList({

{title}

- + {isEditing ? : } + +
{error && (
{error}
)}
    {items.map((item) => ( -
  • - {renderRow(item)} +
  • +
    {renderRow(item)}
    + + {isEditing && ( +
    + +
    + )}
  • ))}
diff --git a/src/components/FormattedDate.tsx b/src/components/FormattedDate.tsx new file mode 100644 index 0000000..be9105e --- /dev/null +++ b/src/components/FormattedDate.tsx @@ -0,0 +1,3 @@ +export const FormattedDate = ({ date }: { date: string }) => ( + {new Date(date).toLocaleString()} +); diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx new file mode 100644 index 0000000..f9b0f20 --- /dev/null +++ b/src/components/Icons.tsx @@ -0,0 +1,56 @@ +export const Edit = () => ( + // Pencil icon + +); + +export const Cross = () => ( + +); + +export const Remove = () => ( + +); + +export const Done = () => ( + +); diff --git a/src/components/RelationshipList.tsx b/src/components/RelationshipList.tsx index 461104f..07cce65 100644 --- a/src/components/RelationshipList.tsx +++ b/src/components/RelationshipList.tsx @@ -34,7 +34,7 @@ export function RelationshipList({ useEffect(() => { async function fetchItems() { const { items } = await queryClient.fetchQuery({ - queryKey: [root.id, "relationship", relationshipType], + queryKey: ["relationship", relationshipType, root.id], staleTime: 5 * 60 * 1000, // 5 mintues queryFn: async () => { setLoading(true); @@ -81,7 +81,7 @@ export function RelationshipList({ }); } queryClient.invalidateQueries({ - queryKey: [root.id, "relationship", relationshipType], + queryKey: ["relationship", relationshipType, root.id], }); setItems((prev) => [...prev, doc]); } catch (e: any) { @@ -101,6 +101,7 @@ export function RelationshipList({ items={items} error={error} renderRow={(document) => } + removeItem={() => {}} newItemForm={(onSubmit) => ( { + return ( +
  • + +

    {title}

    + + {description &&

    {description}

    } +
  • + ); +}; diff --git a/src/components/documents/DocumentRow.tsx b/src/components/documents/DocumentRow.tsx index 36b6e72..9919171 100644 --- a/src/components/documents/DocumentRow.tsx +++ b/src/components/documents/DocumentRow.tsx @@ -12,11 +12,7 @@ import { type Document, type Session, } from "@/lib/types"; -import { LocationPrintRow } from "./location/LocationPrintRow"; -import { MonsterPrintRow } from "./monsters/MonsterPrintRow"; -import { NpcPrintRow } from "./npc/NpcPrintRow"; -import { ScenePrintRow } from "./scene/ScenePrintRow"; -import { SessionPrintRow } from "./session/SessionPrintRow"; +import { BasicRow } from "./BasicRow"; import { TreasureToggleRow } from "./treasure/TreasureToggleRow"; /** @@ -31,19 +27,37 @@ export const DocumentRow = ({ session?: Session; }) => { if (isLocation(document)) { - return ; + return ( + + ); } if (isMonster(document)) { - return ; + return ; } if (isNpc(document)) { - return ; + return ( + + ); } if (isSession(document)) { - return ; + return ( + + ); } if (isSecret(document)) { @@ -51,7 +65,7 @@ export const DocumentRow = ({ } if (isScene(document)) { - return ; + return ; } if (isTreasure(document)) { diff --git a/src/components/documents/scene/SceneEditForm.tsx b/src/components/documents/scene/SceneEditForm.tsx index bcb6660..407c11e 100644 --- a/src/components/documents/scene/SceneEditForm.tsx +++ b/src/components/documents/scene/SceneEditForm.tsx @@ -1,11 +1,14 @@ import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; import { pb } from "@/lib/pocketbase"; import type { Scene } from "@/lib/types"; +import { useQueryClient } from "@tanstack/react-query"; /** * Renders an editable scene form */ export const SceneEditForm = ({ scene }: { scene: Scene }) => { + const queryClient = useQueryClient(); + async function saveScene(text: string) { await pb.collection("documents").update(scene.id, { data: { @@ -15,6 +18,9 @@ export const SceneEditForm = ({ scene }: { scene: Scene }) => { }, }, }); + queryClient.invalidateQueries({ + queryKey: ["relationship"], + }); } return ( diff --git a/src/components/documents/session/SessionRow.tsx b/src/components/documents/session/SessionRow.tsx index ac14785..94bbe83 100644 --- a/src/components/documents/session/SessionRow.tsx +++ b/src/components/documents/session/SessionRow.tsx @@ -1,3 +1,4 @@ +import { FormattedDate } from "@/components/FormattedDate"; import type { Session } from "@/lib/types"; import { Link } from "@tanstack/react-router"; @@ -9,7 +10,7 @@ export const SessionRow = ({ session }: { session: Session }) => { params={{ documentId: session.id }} className="block font-semibold text-lg text-slate-300" > - {session.created} +
    {session.data.session.strongStart}
    diff --git a/src/lib/relationships.ts b/src/lib/relationships.ts index af376b6..f864081 100644 --- a/src/lib/relationships.ts +++ b/src/lib/relationships.ts @@ -1,5 +1,21 @@ -import type { RelationshipType } from "./types"; +import { getDocumentType, RelationshipType, type AnyDocument } from "./types"; export function displayName(relationshipType: RelationshipType) { return relationshipType.charAt(0).toUpperCase() + relationshipType.slice(1); } + +export function relationshipsForDocument(doc: AnyDocument): RelationshipType[] { + switch (getDocumentType(doc)) { + case "session": + return [ + RelationshipType.Scenes, + RelationshipType.Secrets, + RelationshipType.Locations, + RelationshipType.Npcs, + RelationshipType.Monsters, + RelationshipType.Treasures, + ]; + default: + return []; + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 824c40b..6fc65f1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -74,6 +74,25 @@ export type AnyDocument = | Session | Treasure; +export function getDocumentType(doc: AnyDocument): DocumentType { + if (isLocation(doc)) { + return "location"; + } else if (isMonster(doc)) { + return "monster"; + } else if (isNpc(doc)) { + return "npc"; + } else if (isScene(doc)) { + return "scene"; + } else if (isSecret(doc)) { + return "secret"; + } else if (isSession(doc)) { + return "session"; + } else if (isTreasure(doc)) { + return "treasure"; + } + throw new Error(`Document type not found: ${JSON.stringify(doc)}`); +} + /** Locations **/ export type Location = Document & diff --git a/src/routes/_app/_authenticated/campaigns.$campaignId.tsx b/src/routes/_app/_authenticated/campaigns.$campaignId.tsx index a008734..a2980b5 100644 --- a/src/routes/_app/_authenticated/campaigns.$campaignId.tsx +++ b/src/routes/_app/_authenticated/campaigns.$campaignId.tsx @@ -5,8 +5,11 @@ import { SessionRow } from "@/components/documents/session/SessionRow"; import { Button } from "@headlessui/react"; import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { Loader } from "@/components/Loader"; +import type { Relationship } from "@/lib/types"; -export const Route = createFileRoute("/_app/_authenticated/campaigns/$campaignId")({ +export const Route = createFileRoute( + "/_app/_authenticated/campaigns/$campaignId", +)({ component: RouteComponent, pendingComponent: Loader, }); @@ -26,6 +29,7 @@ function RouteComponent() { // Fetch all documents for this campaign const docs = await pb.collection("documents").getFullList({ filter: `campaign = "${params.campaignId}"`, + sort: "-created", }); // Filter to only those with data.session const sessions = docs.filter((doc: any) => doc.data && doc.data.session); @@ -37,7 +41,22 @@ function RouteComponent() { }); const createNewSession = useCallback(async () => { - await pb.collection("documents").create({ + // Check for a previous session + const prevSession = await pb + .collection("documents") + .getFirstListItem( + `campaign = "${campaign.id}" && json_extract(data, '$.session') IS NOT NULL`, + { + sort: "-created", + }, + ); + + console.log("Previous session: ", { + id: prevSession.id, + created: prevSession.created, + }); + + const newSession = await pb.collection("documents").create({ campaign: campaign.id, data: { session: { @@ -45,6 +64,31 @@ function RouteComponent() { }, }, }); + + queryClient.invalidateQueries({ queryKey: ["campaign"] }); + + // If any, then copy things over + if (prevSession) { + const prevRelations = await pb + .collection("relationships") + .getFullList({ + filter: `primary = "${prevSession.id}"`, + }); + + console.log(`Found ${prevRelations.length} previous relations`); + + for (const relation of prevRelations) { + console.log( + `Adding ${relation.secondary.length} items to ${relation.type}`, + ); + await pb.collection("relationships").create({ + primary: newSession.id, + type: relation.type, + seciondary: relation.secondary, + }); + } + } + queryClient.invalidateQueries({ queryKey: ["campaign"] }); }, [campaign]); diff --git a/src/routes/_app/_authenticated/campaigns.index.tsx b/src/routes/_app/_authenticated/campaigns.index.tsx index 170ca22..b23ea31 100644 --- a/src/routes/_app/_authenticated/campaigns.index.tsx +++ b/src/routes/_app/_authenticated/campaigns.index.tsx @@ -8,7 +8,9 @@ import { useRouter } from "@tanstack/react-router"; export const Route = createFileRoute("/_app/_authenticated/campaigns/")({ loader: async () => { - const records = await pb.collection("campaigns").getFullList(); + const records = await pb.collection("campaigns").getFullList({ + sort: "-created", + }); return { campaigns: records.map((rec: any) => ({ id: rec.id, diff --git a/src/routes/_app/_authenticated/document.$documentId.tsx b/src/routes/_app/_authenticated/document.$documentId.tsx index 8c1cefc..7bb151e 100644 --- a/src/routes/_app/_authenticated/document.$documentId.tsx +++ b/src/routes/_app/_authenticated/document.$documentId.tsx @@ -1,7 +1,7 @@ import { RelationshipList } from "@/components/RelationshipList"; import { DocumentEditForm } from "@/components/documents/DocumentEditForm"; import { pb } from "@/lib/pocketbase"; -import { displayName } from "@/lib/relationships"; +import { displayName, relationshipsForDocument } from "@/lib/relationships"; import { RelationshipType, type AnyDocument } from "@/lib/types"; import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react"; import { createFileRoute, Link } from "@tanstack/react-router"; @@ -23,17 +23,10 @@ function RouteComponent() { document: AnyDocument; }; - const relationshipList = [ - RelationshipType.Scenes, - RelationshipType.Secrets, - RelationshipType.Locations, - RelationshipType.Npcs, - RelationshipType.Monsters, - RelationshipType.Treasures, - ]; + const relationshipList = relationshipsForDocument(document); return ( -
    +