Makes campaigns load all types of docs and then link to the docs

This commit is contained in:
2025-08-03 12:50:52 -07:00
parent 3310be9e9b
commit 2fbc2c853f
13 changed files with 170 additions and 75 deletions

View File

@@ -9,7 +9,7 @@ import {
import { Fragment, useCallback, useState } from "react"; import { Fragment, useCallback, useState } from "react";
type Props<T extends AnyDocument> = { type Props<T extends AnyDocument> = {
title: React.ReactNode; title?: React.ReactNode;
error?: React.ReactNode; error?: React.ReactNode;
items: T[]; items: T[];
renderRow: (item: T) => React.ReactNode; renderRow: (item: T) => React.ReactNode;
@@ -49,7 +49,7 @@ export function DocumentList<T extends AnyDocument>({
return ( return (
<section className="w-full"> <section className="w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-100">{title}</h2> {title && <h2 className="text-xl font-bold text-slate-100">{title}</h2>}
<div className="flex gap-2"> <div className="flex gap-2">
{isEditing && ( {isEditing && (
<button <button

View File

@@ -0,0 +1,51 @@
import {
type AnyDocument,
type CampaignId,
type DocumentType,
} from "@/lib/types";
import { useDocumentCache } from "@/context/document/hooks";
import { DocumentList } from "../DocumentList";
import { getAllDocumentsOfType } from "@/context/document/state";
import { DocumentRow } from "../documents/DocumentRow";
import { pb } from "@/lib/pocketbase";
import { useEffect } from "react";
export type Props = {
campaignId: CampaignId;
docType: DocumentType;
};
export const CampaignDocuments = ({ campaignId, docType }: Props) => {
const { cache, dispatch } = useDocumentCache();
const items = getAllDocumentsOfType(docType, cache);
useEffect(() => {
async function fetchDocuments() {
const documents: AnyDocument[] = await pb
.collection("documents")
.getFullList({
filter: `campaign = "${campaignId}" && type = "${docType}"`,
sort: "created",
});
for (const doc of documents) {
dispatch({
type: "setDocument",
doc,
});
}
}
fetchDocuments();
}, [campaignId, docType]);
return (
<DocumentList
items={items}
renderRow={(doc) => <DocumentRow document={doc} />}
newItemForm={() => <div>New Item Form</div>}
removeItem={() => console.error("TODO")}
/>
);
};

View File

@@ -1,13 +1,25 @@
import { Link } from "@tanstack/react-router";
import { FormattedText } from "../FormattedText"; import { FormattedText } from "../FormattedText";
import type { DocumentId } from "@/lib/types";
export type Props = { export type Props = {
id: DocumentId;
title?: string; title?: string;
description?: string; description?: string;
}; };
export const BasicPreview = ({ title, description }: Props) => { export const BasicPreview = ({ id, title, description }: Props) => {
return ( return (
<div> <div>
<Link
to="/document/$documentId/$"
params={{
documentId: id,
}}
className="!no-underline text-violet-400 hover:underline hover:text-violet-500"
>
View
</Link>
{title && <h4 className="font-bold">{title}</h4>} {title && <h4 className="font-bold">{title}</h4>}
{description && <FormattedText>{description}</FormattedText>} {description && <FormattedText>{description}</FormattedText>}
</div> </div>

View File

@@ -1,6 +1,6 @@
import { makeDocumentPath, useDocumentPath } from "@/lib/documentPath"; import { makeDocumentPath, useDocumentPath } from "@/lib/documentPath";
import type { DocumentId, RelationshipType } from "@/lib/types"; import type { DocumentId } from "@/lib/types";
import { Link } from "@tanstack/react-router"; import { Link, useParams, useSearch } from "@tanstack/react-router";
export type Props = React.PropsWithChildren<{ export type Props = React.PropsWithChildren<{
childDocId: DocumentId; childDocId: DocumentId;
@@ -8,13 +8,37 @@ export type Props = React.PropsWithChildren<{
}>; }>;
export function DocumentLink({ childDocId, className, children }: Props) { export function DocumentLink({ childDocId, className, children }: Props) {
const { documentId, relationshipType } = useDocumentPath(); const docPath = useDocumentPath();
const params = useParams({
strict: false,
});
const campaignSearch = useSearch({
from: "/_app/_authenticated/campaigns/$campaignId",
shouldThrow: false,
});
const to = params.campaignId
? `/campaigns/${params.campaignId}`
: docPath
? makeDocumentPath(
docPath.documentId,
docPath?.relationshipType,
childDocId,
)
: undefined;
const search = campaignSearch
? { tab: campaignSearch.tab, docId: childDocId }
: undefined;
if (to === undefined) {
throw new Error("Not in a document or campaign context");
}
return ( return (
<Link <Link to={to} search={search} className={className}>
to={makeDocumentPath(documentId, relationshipType, childDocId)}
className={className}
>
{children} {children}
</Link> </Link>
); );

View File

@@ -35,17 +35,19 @@ const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
case "location": case "location":
return ( return (
<BasicPreview <BasicPreview
id={doc.id}
title={doc.data.name} title={doc.data.name}
description={doc.data.description} description={doc.data.description}
/> />
); );
case "monster": case "monster":
return <BasicPreview title={doc.data.name} />; return <BasicPreview id={doc.id} title={doc.data.name} />;
case "npc": case "npc":
return ( return (
<BasicPreview <BasicPreview
id={doc.id}
title={doc.data.name} title={doc.data.name}
description={doc.data.description} description={doc.data.description}
/> />
@@ -53,16 +55,20 @@ const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
case "session": case "session":
return ( return (
<BasicPreview title={doc.created} description={doc.data.strongStart} /> <BasicPreview
id={doc.id}
title={doc.created}
description={doc.data.strongStart}
/>
); );
case "secret": case "secret":
return <BasicPreview title={doc.data.text} />; return <BasicPreview id={doc.id} title={doc.data.text} />;
case "scene": case "scene":
return <BasicPreview description={doc.data.text} />; return <BasicPreview id={doc.id} description={doc.data.text} />;
case "treasure": case "treasure":
return <BasicPreview title={doc.data.text} />; return <BasicPreview id={doc.id} title={doc.data.text} />;
} }
}; };

View File

@@ -41,8 +41,9 @@ export function DocumentView({
navigation={ navigation={
<> <>
<Link <Link
to={CampaignRoute.to} to="/campaigns/$campaignId"
params={{ campaignId: doc.campaign }} params={{ campaignId: doc.campaign }}
search={{ tab: "sessions" }}
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors" className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
> >
Back to campaign Back to campaign

View File

@@ -23,7 +23,7 @@ export function TabbedLayout({
{tabs} {tabs}
</div> </div>
<div <div
className={`grow p-2 bg-slate-800 border-t border-b border-r border-slate-700 ${flyout && "hidden"} md:block`} className={`grow md:w-md p-2 bg-slate-800 border-t border-b border-r border-slate-700 ${flyout && "hidden"} md:block`}
> >
{content} {content}
</div> </div>

View File

@@ -29,7 +29,9 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined);
*/ */
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [user, setUser] = useState<AuthRecord | null>(pb.authStore.record); const [user, setUser] = useState<AuthRecord | null>(
pb.authStore.isValid ? pb.authStore.record : null,
);
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -1,6 +1,7 @@
import type { import type {
AnyDocument, AnyDocument,
DocumentId, DocumentId,
DocumentType,
Relationship, Relationship,
RelationshipType, RelationshipType,
} from "@/lib/types"; } from "@/lib/types";
@@ -30,3 +31,13 @@ export const initialState = (): DocumentState =>
({ ({
documents: {}, documents: {},
}) as DocumentState; }) as DocumentState;
export const getAllDocumentsOfType = <T extends DocumentType>(
docType: T,
state: DocumentState,
): (AnyDocument & { type: T })[] =>
Object.values(state.documents).flatMap((docRecord) =>
docRecord.type === "ready" && docRecord.value.doc.type === docType
? [docRecord.value.doc as AnyDocument & { type: T }]
: [],
);

View File

@@ -24,22 +24,30 @@ const documentParams = z
}), }),
); );
export function useDocumentPath(): { export function useDocumentPath():
documentId: DocumentId; | {
relationshipType: RelationshipType | null; documentId: DocumentId;
childDocId: DocumentId | null; relationshipType: RelationshipType | null;
} { childDocId: DocumentId | null;
}
| undefined {
const params = useParams({ const params = useParams({
from: "/_app/_authenticated/document/$documentId/$", from: "/_app/_authenticated/document/$documentId/$",
shouldThrow: false,
}); });
const { relationshipType, childDocId } = documentParams.parse(params._splat); if (params) {
const { relationshipType, childDocId } = documentParams.parse(
params._splat,
);
return {
documentId: params.documentId as DocumentId,
relationshipType,
childDocId,
};
}
return { return undefined;
documentId: params.documentId as DocumentId,
relationshipType,
childDocId,
};
} }
export function makeDocumentPath( export function makeDocumentPath(

View File

@@ -1,4 +1,9 @@
import { getDocumentType, RelationshipType, type AnyDocument } from "./types"; import {
getDocumentType,
type DocumentType,
RelationshipType,
type AnyDocument,
} from "./types";
export function displayName(relationshipType: RelationshipType) { export function displayName(relationshipType: RelationshipType) {
return relationshipType.charAt(0).toUpperCase() + relationshipType.slice(1); return relationshipType.charAt(0).toUpperCase() + relationshipType.slice(1);

View File

@@ -1,23 +1,20 @@
import { CampaignDocuments } from "@/components/campaign/CampaignDocuments";
import { DocumentPreview } from "@/components/documents/DocumentPreview"; import { DocumentPreview } from "@/components/documents/DocumentPreview";
import { DocumentRow } from "@/components/documents/DocumentRow";
import { SessionRow } from "@/components/documents/session/SessionRow";
import { Tab, TabbedLayout } from "@/components/layout/TabbedLayout"; import { Tab, TabbedLayout } from "@/components/layout/TabbedLayout";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { DocumentLoader } from "@/context/document/DocumentLoader"; import { DocumentLoader } from "@/context/document/DocumentLoader";
import { useDocument } from "@/context/document/hooks"; import { useDocument } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import type { Campaign, DocumentId, Relationship, Session } from "@/lib/types"; import type { Campaign, DocumentId, Relationship, Session } from "@/lib/types";
import { Button } from "@headlessui/react";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { z } from "zod"; import { z } from "zod";
const CampaignTabs = { const CampaignTabs = {
sessions: "Sessions", sessions: { label: "Sessions", docType: "session" },
npcs: "NPCs", secrets: { label: "Secrets", docType: "secret" },
locations: "Locations", npcs: { label: "NPCs", docType: "npc" },
factions: "Factions", locations: { label: "Locations", docType: "location" },
threads: "Threads",
} as const; } as const;
const campaignSearchSchema = z.object({ const campaignSearchSchema = z.object({
@@ -50,11 +47,11 @@ function RouteComponent() {
.collection("campaigns") .collection("campaigns")
.getOne(params.campaignId); .getOne(params.campaignId);
// Fetch all documents for this campaign // Fetch all documents for this campaign
const sessions = await pb.collection("documents").getFullList({ // const sessions = await pb.collection("documents").getFullList({
filter: `campaign = "${params.campaignId}" && type = 'session'`, // filter: `campaign = "${params.campaignId}" && type = 'session'`,
sort: "-created", // sort: "-created",
}); // });
setSessions(sessions as Session[]); // setSessions(sessions as Session[]);
setCampaign(campaign as Campaign); setCampaign(campaign as Campaign);
setLoading(false); setLoading(false);
} }
@@ -115,8 +112,9 @@ function RouteComponent() {
Back to campaigns Back to campaigns
</Link> </Link>
} }
tabs={Object.entries(CampaignTabs).map(([key, label]) => ( tabs={Object.entries(CampaignTabs).map(([key, { label }]) => (
<Tab <Tab
key={key}
label={label} label={label}
active={tab === key} active={tab === key}
to={Route.to} to={Route.to}
@@ -129,36 +127,10 @@ function RouteComponent() {
/> />
))} ))}
content={ content={
<div> <CampaignDocuments
<div className="flex justify-between"> campaignId={campaign.id}
<h3 className="text-lg font-semibold mb-2 text-slate-200"> docType={CampaignTabs[tab].docType}
Sessions />
</h3>
<div>
<Button
onClick={() => createNewSession()}
className="inline-flex items-center justify-center rounded bg-violet-600 hover:bg-violet-700 text-white px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-violet-400"
>
New Session
</Button>
</div>
</div>
{sessions && sessions.length > 0 ? (
<div>
<ul className="space-y-2">
{sessions.map((s: any) => (
<li key={s.id}>
<SessionRow session={s} />
</li>
))}
</ul>
</div>
) : (
<div className="text-slate-400">
No sessions found for this campaign.
</div>
)}
</div>
} }
flyout={docId && <Flyout key={docId} docId={docId} />} flyout={docId && <Flyout key={docId} docId={docId} />}
/> />

View File

@@ -11,7 +11,10 @@ export const Route = createFileRoute(
}); });
function RouteComponent() { function RouteComponent() {
const { documentId, relationshipType, childDocId } = useDocumentPath(); const path = useDocumentPath();
const documentId = path?.documentId;
const relationshipType = path?.relationshipType ?? null;
const childDocId = path?.childDocId ?? null;
return ( return (
<DocumentLoader documentId={documentId as DocumentId}> <DocumentLoader documentId={documentId as DocumentId}>