WIP: Linking in document list. Working on copying relationships to new sessions

This commit is contained in:
2025-06-27 17:52:57 -07:00
parent 93536b0ac2
commit 611eaca5b6
15 changed files with 281 additions and 52 deletions

View File

@@ -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<T extends Document> = {
title: React.ReactNode;
@@ -14,6 +15,7 @@ type Props<T extends Document> = {
items: T[];
renderRow: (item: T) => React.ReactNode;
newItemForm: (onSubmit: () => void) => React.ReactNode;
removeItem: (itemId: DocumentId) => void;
};
/**
@@ -30,8 +32,15 @@ export function DocumentList<T extends Document>({
items,
renderRow,
newItemForm,
removeItem,
}: Props<T>) {
const [open, setOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const toggleEditMode = useCallback(
() => setIsEditing((x) => !x),
[setIsEditing],
);
// Handles closing the dialog after form submission
const handleFormSubmit = (): void => {
@@ -42,35 +51,50 @@ export function DocumentList<T extends Document>({
<section className="w-full max-w-2xl mx-auto">
<div className="flex items-center justify-between my-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-8 h-8 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"
<div className="flex gap-2">
{isEditing && (
<button
type="button"
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
aria-label="Add new item"
onClick={() => setOpen(true)}
>
<Icons.Cross />
</button>
)}
<button
type="button"
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
aria-label={isEditing ? "Exit edit mode" : "Enter edit mode"}
onClick={toggleEditMode}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
{isEditing ? <Icons.Done /> : <Icons.Edit />}
</button>
</div>
</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
key={item.id}
className="bg-slate-800 rounded p-4 text-slate-100 flex flex-row justify-between items-center"
>
<div>{renderRow(item)}</div>
{isEditing && (
<div>
<button
type="button"
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
aria-label="Remove item"
onClick={() => removeItem(item.id)}
>
<Icons.Remove />
</button>
</div>
)}
</li>
))}
</ul>

View File

@@ -0,0 +1,3 @@
export const FormattedDate = ({ date }: { date: string }) => (
<span>{new Date(date).toLocaleString()}</span>
);

56
src/components/Icons.tsx Normal file
View File

@@ -0,0 +1,56 @@
export const Edit = () => (
// Pencil icon
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 5.487a2.25 2.25 0 1 1 3.182 3.182L8.25 20.463 3 21.75l1.287-5.25 12.575-11.013z"
/>
</svg>
);
export const Cross = () => (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
);
export const Remove = () => (
<svg
className="w-5 h-5 rotate-45"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
);
export const Done = () => (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);

View File

@@ -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) => <DocumentRow document={document} />}
removeItem={() => {}}
newItemForm={(onSubmit) => (
<NewRelatedDocumentForm
campaignId={root.campaign}

View File

@@ -0,0 +1,26 @@
import type { AnyDocument } from "@/lib/types";
import { Link } from "@tanstack/react-router";
export type Props = {
doc: AnyDocument;
title: string;
description?: string;
};
/**
* Renders a simple row that links to the document
*/
export const BasicRow = ({ doc, title, description }: Props) => {
return (
<li>
<Link
to="/document/$documentId"
params={{ documentId: doc.id }}
className="text-lg"
>
<h4>{title}</h4>
</Link>
{description && <p>{description}</p>}
</li>
);
};

View File

@@ -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 <LocationPrintRow location={document} />;
return (
<BasicRow
doc={document}
title={document.data.location.name}
description={document.data.location.description}
/>
);
}
if (isMonster(document)) {
return <MonsterPrintRow monster={document} />;
return <BasicRow doc={document} title={document.data.monster.name} />;
}
if (isNpc(document)) {
return <NpcPrintRow npc={document} />;
return (
<BasicRow
doc={document}
title={document.data.npc.name}
description={document.data.npc.description}
/>
);
}
if (isSession(document)) {
return <SessionPrintRow session={document} />;
return (
<BasicRow
doc={document}
title={document.created}
description={document.data.session.strongStart}
/>
);
}
if (isSecret(document)) {
@@ -51,7 +65,7 @@ export const DocumentRow = ({
}
if (isScene(document)) {
return <ScenePrintRow scene={document} />;
return <BasicRow doc={document} title={document.data.scene.text} />;
}
if (isTreasure(document)) {

View File

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

View File

@@ -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}
<FormattedDate date={session.created} />
</Link>
<div className="">{session.data.session.strongStart}</div>
</div>

View File

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

View File

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

View File

@@ -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<Relationship>("relationships")
.getFullList({
filter: `primary = "${prevSession.id}"`,
});
console.log(`Found ${prevRelations.length} previous relations`);
for (const relation of prevRelations) {
console.log(
`Adding ${relation.secondary.length} items to ${relation.type}`,
);
await pb.collection("relationships").create({
primary: newSession.id,
type: relation.type,
seciondary: relation.secondary,
});
}
}
queryClient.invalidateQueries({ queryKey: ["campaign"] });
}, [campaign]);

View File

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

View File

@@ -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 (
<div className="max-w-xl mx-auto py-2 px-4">
<div key={document.id} className="max-w-xl mx-auto py-2 px-4">
<Link
to="/document/$documentId/print"
params={{ documentId: document.id }}