Compare commits

...

2 Commits

Author SHA1 Message Date
1c26daa828 Adding UI for threads 2025-08-09 15:49:36 -07:00
135debdf7f Adds new campaign form. Adds fronts and thread types 2025-08-03 14:27:06 -07:00
17 changed files with 443 additions and 75 deletions

View File

@@ -0,0 +1,54 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update field
collection.fields.addAt(3, new Field({
"hidden": false,
"id": "select2363381545",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"location",
"monster",
"npc",
"scene",
"secret",
"session",
"treasure",
"thread",
"front"
]
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update field
collection.fields.addAt(3, new Field({
"hidden": false,
"id": "select2363381545",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"location",
"monster",
"npc",
"scene",
"secret",
"session",
"treasure"
]
}))
return app.save(collection)
})

View File

@@ -1,6 +1,7 @@
import { import {
type AnyDocument, type AnyDocument,
type CampaignId, type CampaignId,
type DocumentId,
type DocumentType, type DocumentType,
} from "@/lib/types"; } from "@/lib/types";
import { useDocumentCache } from "@/context/document/hooks"; import { useDocumentCache } from "@/context/document/hooks";
@@ -9,6 +10,7 @@ import { getAllDocumentsOfType } from "@/context/document/state";
import { DocumentRow } from "../documents/DocumentRow"; import { DocumentRow } from "../documents/DocumentRow";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { useEffect } from "react"; import { useEffect } from "react";
import { NewCampaignDocumentForm } from "../documents/NewCampaignDocumentForm";
export type Props = { export type Props = {
campaignId: CampaignId; campaignId: CampaignId;
@@ -40,12 +42,28 @@ export const CampaignDocuments = ({ campaignId, docType }: Props) => {
fetchDocuments(); fetchDocuments();
}, [campaignId, docType]); }, [campaignId, docType]);
const handleRemove = (id: DocumentId) => {
pb.collection("documents").delete(id);
dispatch({
type: "removeDocument",
docId: id,
});
};
return ( return (
<DocumentList <DocumentList
items={items} items={items}
renderRow={(doc) => <DocumentRow document={doc} />} renderRow={(doc) => <DocumentRow document={doc} />}
newItemForm={() => <div>New Item Form</div>} newItemForm={(onSubmit) => (
removeItem={() => console.error("TODO")} <NewCampaignDocumentForm
campaignId={campaignId}
docType={docType}
onCreate={async () => {
onSubmit();
}}
/>
)}
removeItem={handleRemove}
/> />
); );
}; };

View File

@@ -6,6 +6,7 @@ import { SceneEditForm } from "./scene/SceneEditForm";
import { SecretEditForm } from "./secret/SecretEditForm"; import { SecretEditForm } from "./secret/SecretEditForm";
import { SessionEditForm } from "./session/SessionEditForm"; import { SessionEditForm } from "./session/SessionEditForm";
import { TreasureEditForm } from "./treasure/TreasureEditForm"; import { TreasureEditForm } from "./treasure/TreasureEditForm";
import { GenericEditForm } from "./GenericEditForm";
/** /**
* Renders a form for any document type depending on the relationship. * Renders a form for any document type depending on the relationship.
@@ -26,5 +27,15 @@ export const DocumentEditForm = ({ document }: { document: AnyDocument }) => {
return <SessionEditForm session={document} />; return <SessionEditForm session={document} />;
case "treasure": case "treasure":
return <TreasureEditForm treasure={document} />; return <TreasureEditForm treasure={document} />;
case "thread":
return (
<GenericEditForm
doc={document}
fields={{
text: "multiline",
resolved: "checkbox",
}}
/>
);
} }
}; };

View File

@@ -32,6 +32,15 @@ export const DocumentPreview = ({ doc }: { doc: AnyDocument }) => {
const ShowDocument = ({ doc }: { doc: AnyDocument }) => { const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
switch (doc.type) { switch (doc.type) {
case "front":
return (
<BasicPreview
id={doc.id}
title={doc.data.name}
description={doc.data.description}
/>
);
case "location": case "location":
return ( return (
<BasicPreview <BasicPreview
@@ -57,7 +66,7 @@ const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
return ( return (
<BasicPreview <BasicPreview
id={doc.id} id={doc.id}
title={doc.created} title={doc.data.name ?? doc.created}
description={doc.data.strongStart} description={doc.data.strongStart}
/> />
); );
@@ -68,6 +77,9 @@ const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
case "scene": case "scene":
return <BasicPreview id={doc.id} description={doc.data.text} />; return <BasicPreview id={doc.id} description={doc.data.text} />;
case "thread":
return <BasicPreview id={doc.id} title={doc.data.text} />;
case "treasure": case "treasure":
return <BasicPreview id={doc.id} title={doc.data.text} />; return <BasicPreview id={doc.id} title={doc.data.text} />;
} }

View File

@@ -1,7 +1,7 @@
// DocumentRow.tsx // DocumentRow.tsx
// Generic row component for displaying any document type. // Generic row component for displaying any document type.
import { SecretToggleRow } from "@/components/documents/secret/SecretToggleRow"; import { SecretToggleRow } from "@/components/documents/secret/SecretToggleRow";
import { type AnyDocument, type Session } from "@/lib/types"; import { type AnyDocument } from "@/lib/types";
import { BasicRow } from "./BasicRow"; import { BasicRow } from "./BasicRow";
import { TreasureToggleRow } from "./treasure/TreasureToggleRow"; import { TreasureToggleRow } from "./treasure/TreasureToggleRow";
@@ -17,6 +17,15 @@ export const DocumentRow = ({
root?: AnyDocument; root?: AnyDocument;
}) => { }) => {
switch (document.type) { switch (document.type) {
case "front":
return (
<BasicRow
doc={document}
title={document.data.name}
description={document.data.description}
/>
);
case "location": case "location":
return ( return (
<BasicRow <BasicRow
@@ -42,7 +51,7 @@ export const DocumentRow = ({
return ( return (
<BasicRow <BasicRow
doc={document} doc={document}
title={document.created} title={document.data.name || document.created}
description={document.data.strongStart} description={document.data.strongStart}
/> />
); );
@@ -53,6 +62,9 @@ export const DocumentRow = ({
case "scene": case "scene":
return <BasicRow doc={document} description={document.data.text} />; return <BasicRow doc={document} description={document.data.text} />;
case "thread":
return <BasicRow doc={document} description={document.data.text} />;
case "treasure": case "treasure":
return <TreasureToggleRow treasure={document} root={root} />; return <TreasureToggleRow treasure={document} root={root} />;
} }

View File

@@ -1,24 +1,28 @@
import { type AnyDocument, type Session } from "@/lib/types"; import { type AnyDocument } from "@/lib/types";
import { FormattedDate } from "../FormattedDate"; import { FormattedDate } from "../FormattedDate";
/** /**
* Renders the document title to go at the top a document page. * Renders the document title to go at the top a document page.
*/ */
export const DocumentTitle = ({ export const DocumentTitle = ({ doc }: { doc: AnyDocument }) => {
document,
}: {
document: AnyDocument;
session?: Session;
}) => {
switch (document.type) {
case "session":
return ( return (
<h1> <h1 className="text-2xl font-bold">
<FormattedDate date={document.created} /> <TitleText doc={doc} />
</h1> </h1>
); );
};
const TitleText = ({ doc }: { doc: AnyDocument }) => {
switch (doc.type) {
case "session":
if (doc.data.name) {
return doc.data.name;
}
return <FormattedDate date={doc.created} />;
default: default:
return <h1>document.type</h1>; // TODO: Put in proper names for other document types
return doc.type;
} }
}; };

View File

@@ -48,16 +48,17 @@ export function DocumentView({
> >
Back to campaign Back to campaign
</Link> </Link>
<Link {/* Print link isn't currently working */}
to="/document/$documentId/print" {/* <Link */}
params={{ documentId: doc.id }} {/* to="/document/$documentId/print" */}
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors" {/* params={{ documentId: doc.id }} */}
> {/* className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors" */}
Print {/* > */}
</Link> {/* Print */}
{/* </Link> */}
</> </>
} }
title={<DocumentTitle document={doc} />} title={<DocumentTitle doc={doc} />}
tabs={[ tabs={[
<Tab <Tab
to="/document/$documentId" to="/document/$documentId"

View File

@@ -0,0 +1,87 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase";
import type { AnyDocument, Location } from "@/lib/types";
import { useDocumentCache } from "@/context/document/hooks";
export type GenericFieldType = "multiline" | "singleline" | "checkbox";
export type Props<T extends AnyDocument> = {
doc: T;
fields: { [K in keyof T["data"]]: GenericFieldType };
};
export const GenericEditForm = <T extends AnyDocument>({
doc,
fields,
}: Props<T>) => {
return (
<div className="">
{
// The type checker seems to lose the types when using Object.entries here.
Object.entries(fields).map(
([fieldName, fieldType]: [string, unknown]) => (
<GenericEditFormField
key={fieldName}
doc={doc}
fieldName={fieldName as keyof T["data"]}
fieldType={fieldType as GenericFieldType}
/>
),
)
}
</div>
);
};
const GenericEditFormField = <T extends AnyDocument>({
doc,
fieldName,
fieldType,
}: {
doc: T;
fieldName: keyof T["data"];
fieldType: GenericFieldType;
}) => {
const { dispatch } = useDocumentCache();
// The type checker really doesn't like indexing into this type implicitly, so we'll store it in a temporary to give it the right hints.
const data = doc.data as T["data"];
async function saveField(value: string) {
const updated: T = await pb.collection("documents").update(doc.id, {
data: {
...doc.data,
[fieldName]: value,
},
});
dispatch({ type: "setDocument", doc: updated });
}
switch (fieldType) {
case "multiline":
return (
<AutoSaveTextarea
multiline={true}
value={data[fieldName] as string}
onSave={saveField}
/>
);
case "singleline":
return (
<AutoSaveTextarea
multiline={false}
value={data[fieldName] as string}
onSave={saveField}
/>
);
case "checkbox":
return (
<input
type="checkbox"
checked={data[fieldName] as boolean}
onChange={(e) => saveField(e.target.value)}
className="accent-emerald-500 w-5 h-5"
/>
);
}
};

View File

@@ -0,0 +1,30 @@
import {
type AnyDocument,
type CampaignId,
type DocumentType,
} from "@/lib/types";
import { NewSessionForm } from "./session/NewSessionForm";
/**
* Renders a form for any document type depending on the relationship.
*/
export const NewCampaignDocumentForm = ({
campaignId,
docType,
onCreate,
}: {
campaignId: CampaignId;
docType: DocumentType;
onCreate: (doc: AnyDocument) => Promise<void>;
}) => {
switch (docType) {
case "session":
return <NewSessionForm campaignId={campaignId} onCreate={onCreate} />;
case "thread":
return <NewThreadForm campaignId={campaignId} onCreate={onCreate} />;
default:
throw new Error(
`Rendered NewCampaignDocumentForm with unsupported docType: ${docType}`,
);
}
};

View File

@@ -0,0 +1,96 @@
import { BaseForm } from "@/components/form/BaseForm";
import { SingleLineInput } from "@/components/form/SingleLineInput";
import { useDocumentCache } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase";
import type {
AnyDocument,
CampaignId,
Relationship,
Session,
} from "@/lib/types";
import { useCallback, useState } from "react";
export type Props = {
campaignId: CampaignId;
onCreate: (doc: AnyDocument) => Promise<void>;
};
export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState<string>("");
const { dispatch } = useDocumentCache();
console.log("Rendering with name: ", name);
const createNewSession = useCallback(async () => {
setIsLoading(true);
console.log("Creating session: ", name);
try {
// Check for a previous session
const prevSession = await pb
.collection("documents")
.getFirstListItem(`campaign = "${campaignId}" && type = 'session'`, {
sort: "-created",
});
const newSession: Session = await pb.collection("documents").create({
campaign: campaignId,
type: "session",
data: {
name,
strongStart: "",
},
});
// If any relations, then copy things over
if (prevSession) {
const prevRelations = await pb
.collection<Relationship>("relationships")
.getFullList({
filter: `primary = "${prevSession.id}"`,
});
for (const relation of prevRelations) {
await pb.collection("relationships").create({
primary: newSession.id,
type: relation.type,
secondary: relation.secondary,
});
}
}
dispatch({
type: "setDocument",
doc: newSession,
});
await onCreate?.(newSession);
} catch (e: unknown) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("An unknown error occurred while creating the session.");
}
}
setIsLoading(false);
}, [campaignId, name, dispatch, setIsLoading, setError]);
return (
<BaseForm
title="Create new session"
onSubmit={createNewSession}
isLoading={isLoading}
error={error}
content={
<>
<SingleLineInput
label="Name"
value={name}
onChange={setName}
disabled={isLoading}
placeholder="Enter session name"
/>
</>
}
/>
);
};

View File

@@ -11,7 +11,7 @@ export const SessionRow = ({ session }: { session: Session }) => {
search={{ tab: "sessions", docId: session.id }} search={{ tab: "sessions", docId: session.id }}
className="block font-semibold text-lg text-slate-300" className="block font-semibold text-lg text-slate-300"
> >
<FormattedDate date={session.created} /> {session.name ? session.name : <FormattedDate date={session.created} />}
</Link> </Link>
<div className="">{session.data.strongStart}</div> <div className="">{session.data.strongStart}</div>
</div> </div>

View File

@@ -16,7 +16,13 @@ export const BaseForm = ({
onSubmit, onSubmit,
}: Props) => { }: Props) => {
return ( return (
<form className="flex flex-col items-left gap-2" onSubmit={onSubmit}> <form
className="flex flex-col items-left gap-2"
onSubmit={(e) => {
e.preventDefault();
onSubmit(e);
}}
>
<h3 className="text-lg font-semibold text-slate-100">{title}</h3> <h3 className="text-lg font-semibold text-slate-100">{title}</h3>
<div className="flex flex-col gap-2 w-full items-stretch">{content}</div> <div className="flex flex-col gap-2 w-full items-stretch">{content}</div>
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>} {error && <div className="text-red-400 mt-2 text-sm">{error}</div>}

View File

@@ -19,4 +19,8 @@ export type DocumentAction =
doc: AnyDocument; doc: AnyDocument;
relationships: Relationship[]; relationships: Relationship[];
relatedDocuments: AnyDocument[]; relatedDocuments: AnyDocument[];
}
| {
type: "removeDocument";
docId: DocumentId;
}; };

View File

@@ -1,6 +1,13 @@
import _ from "lodash";
import type { AnyDocument, DocumentId, Relationship } from "@/lib/types"; import type { AnyDocument, DocumentId, Relationship } from "@/lib/types";
import type { DocumentAction } from "./actions"; import type { DocumentAction } from "./actions";
import { ready, loading, unloaded, type DocumentState } from "./state"; import {
ready,
loading,
unloaded,
type DocumentState,
mapResult,
} from "./state";
import { relationshipsForDocument } from "@/lib/relationships"; import { relationshipsForDocument } from "@/lib/relationships";
function setLoadingDocument( function setLoadingDocument(
@@ -65,6 +72,36 @@ function setRelationship(
}; };
} }
function removeDocument(
docId: DocumentId,
state: DocumentState,
): DocumentState {
const remainingDocs: DocumentState["documents"] = _.omit(state.documents, [
docId,
]);
return {
...state,
documents: _.mapValues(remainingDocs, (result) => {
if (result.type !== "ready") {
return result;
}
return ready({
doc: result.value.doc,
relationships: _.mapValues(
result.value.relationships,
(relationshipResult) =>
mapResult(relationshipResult, (relationship) => ({
...relationship,
secondary: relationship.secondary.filter(
(relatedId) => relatedId !== docId,
),
})),
),
});
}),
};
}
export function reducer( export function reducer(
state: DocumentState, state: DocumentState,
action: DocumentAction, action: DocumentAction,
@@ -84,5 +121,7 @@ export function reducer(
setDocument(state, action.doc), setDocument(state, action.doc),
), ),
); );
case "removeDocument":
return removeDocument(action.docId, state);
} }
} }

View File

@@ -17,6 +17,16 @@ export const error = (err: unknown): Result<any> => ({ type: "error", err });
export const loading = (): Result<any> => ({ type: "loading" }); export const loading = (): Result<any> => ({ type: "loading" });
export const ready = <V>(value: V): Result<V> => ({ type: "ready", value }); export const ready = <V>(value: V): Result<V> => ({ type: "ready", value });
export const mapResult = <A, B>(
result: Result<A>,
f: (a: A) => B,
): Result<B> => {
if (result.type === "ready") {
return ready(f(result.value));
}
return result;
};
export type DocumentState = { export type DocumentState = {
documents: Record< documents: Record<
DocumentId, DocumentId,

View File

@@ -65,12 +65,14 @@ export type Relationship = RecordModel & {
******************************************/ ******************************************/
export type DocumentType = export type DocumentType =
| "front"
| "location" | "location"
| "monster" | "monster"
| "npc" | "npc"
| "scene" | "scene"
| "secret" | "secret"
| "session" | "session"
| "thread"
| "treasure"; | "treasure";
export type DocumentData<Type extends DocumentType, Data> = { export type DocumentData<Type extends DocumentType, Data> = {
@@ -90,12 +92,14 @@ export type Document<Type extends DocumentType, Data> = RecordModel & {
}; };
export type AnyDocument = export type AnyDocument =
| Front
| Location | Location
| Monster | Monster
| Npc | Npc
| Scene | Scene
| Secret | Secret
| Session | Session
| Thread
| Treasure; | Treasure;
export function getDocumentType(doc: AnyDocument): DocumentType { export function getDocumentType(doc: AnyDocument): DocumentType {
@@ -135,6 +139,7 @@ export type Npc = Document<
export type Session = Document< export type Session = Document<
"session", "session",
{ {
name?: string;
strongStart: string; strongStart: string;
} }
>; >;
@@ -167,3 +172,24 @@ export type Treasure = Document<
discovered: boolean; discovered: boolean;
} }
>; >;
/** Thread **/
export type Thread = Document<
"thread",
{
text: string;
resolved: boolean;
}
>;
/** Front **/
export type Front = Document<
"front",
{
name: string;
description: string;
resolved: boolean;
}
>;

View File

@@ -15,6 +15,8 @@ const CampaignTabs = {
secrets: { label: "Secrets", docType: "secret" }, secrets: { label: "Secrets", docType: "secret" },
npcs: { label: "NPCs", docType: "npc" }, npcs: { label: "NPCs", docType: "npc" },
locations: { label: "Locations", docType: "location" }, locations: { label: "Locations", docType: "location" },
threads: { label: "Threads", docType: "thread" },
fronts: { label: "Fronts", docType: "front" },
} as const; } as const;
const campaignSearchSchema = z.object({ const campaignSearchSchema = z.object({
@@ -38,7 +40,6 @@ function RouteComponent() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [campaign, setCampaign] = useState<Campaign | null>(null); const [campaign, setCampaign] = useState<Campaign | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
@@ -46,54 +47,11 @@ function RouteComponent() {
const campaign = await pb const campaign = await pb
.collection("campaigns") .collection("campaigns")
.getOne(params.campaignId); .getOne(params.campaignId);
// Fetch all documents for this campaign
// const sessions = await pb.collection("documents").getFullList({
// filter: `campaign = "${params.campaignId}" && type = 'session'`,
// sort: "-created",
// });
// setSessions(sessions as Session[]);
setCampaign(campaign as Campaign); setCampaign(campaign as Campaign);
setLoading(false); setLoading(false);
} }
fetchData(); fetchData();
}, [setCampaign, setSessions, setLoading]); }, [setCampaign, setLoading]);
const createNewSession = useCallback(async () => {
if (campaign === null) {
return;
}
// Check for a previous session
const prevSession = await pb
.collection("documents")
.getFirstListItem(`campaign = "${campaign.id}" && type = 'session'`, {
sort: "-created",
});
const newSession = await pb.collection("documents").create({
campaign: campaign.id,
type: "session",
data: {
strongStart: "",
},
});
// If any relations, then copy things over
if (prevSession) {
const prevRelations = await pb
.collection<Relationship>("relationships")
.getFullList({
filter: `primary = "${prevSession.id}"`,
});
for (const relation of prevRelations) {
await pb.collection("relationships").create({
primary: newSession.id,
type: relation.type,
secondary: relation.secondary,
});
}
}
}, [campaign]);
if (loading || campaign === null) { if (loading || campaign === null) {
return <Loader />; return <Loader />;