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";
type Props<T extends AnyDocument> = {
title: React.ReactNode;
title?: React.ReactNode;
error?: React.ReactNode;
items: T[];
renderRow: (item: T) => React.ReactNode;
@@ -49,7 +49,7 @@ export function DocumentList<T extends AnyDocument>({
return (
<section className="w-full">
<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">
{isEditing && (
<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 type { DocumentId } from "@/lib/types";
export type Props = {
id: DocumentId;
title?: string;
description?: string;
};
export const BasicPreview = ({ title, description }: Props) => {
export const BasicPreview = ({ id, title, description }: Props) => {
return (
<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>}
{description && <FormattedText>{description}</FormattedText>}
</div>

View File

@@ -1,6 +1,6 @@
import { makeDocumentPath, useDocumentPath } from "@/lib/documentPath";
import type { DocumentId, RelationshipType } from "@/lib/types";
import { Link } from "@tanstack/react-router";
import type { DocumentId } from "@/lib/types";
import { Link, useParams, useSearch } from "@tanstack/react-router";
export type Props = React.PropsWithChildren<{
childDocId: DocumentId;
@@ -8,13 +8,37 @@ export type Props = React.PropsWithChildren<{
}>;
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 (
<Link
to={makeDocumentPath(documentId, relationshipType, childDocId)}
className={className}
>
<Link to={to} search={search} className={className}>
{children}
</Link>
);

View File

@@ -35,17 +35,19 @@ const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
case "location":
return (
<BasicPreview
id={doc.id}
title={doc.data.name}
description={doc.data.description}
/>
);
case "monster":
return <BasicPreview title={doc.data.name} />;
return <BasicPreview id={doc.id} title={doc.data.name} />;
case "npc":
return (
<BasicPreview
id={doc.id}
title={doc.data.name}
description={doc.data.description}
/>
@@ -53,16 +55,20 @@ const ShowDocument = ({ doc }: { doc: AnyDocument }) => {
case "session":
return (
<BasicPreview title={doc.created} description={doc.data.strongStart} />
<BasicPreview
id={doc.id}
title={doc.created}
description={doc.data.strongStart}
/>
);
case "secret":
return <BasicPreview title={doc.data.text} />;
return <BasicPreview id={doc.id} title={doc.data.text} />;
case "scene":
return <BasicPreview description={doc.data.text} />;
return <BasicPreview id={doc.id} description={doc.data.text} />;
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={
<>
<Link
to={CampaignRoute.to}
to="/campaigns/$campaignId"
params={{ campaignId: doc.campaign }}
search={{ tab: "sessions" }}
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
>
Back to campaign

View File

@@ -23,7 +23,7 @@ export function TabbedLayout({
{tabs}
</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}
</div>

View File

@@ -29,7 +29,9 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined);
*/
export function AuthProvider({ children }: { children: ReactNode }) {
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();

View File

@@ -1,6 +1,7 @@
import type {
AnyDocument,
DocumentId,
DocumentType,
Relationship,
RelationshipType,
} from "@/lib/types";
@@ -30,3 +31,13 @@ export const initialState = (): DocumentState =>
({
documents: {},
}) 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(): {
documentId: DocumentId;
relationshipType: RelationshipType | null;
childDocId: DocumentId | null;
} {
export function useDocumentPath():
| {
documentId: DocumentId;
relationshipType: RelationshipType | null;
childDocId: DocumentId | null;
}
| undefined {
const params = useParams({
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 {
documentId: params.documentId as DocumentId,
relationshipType,
childDocId,
};
return undefined;
}
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) {
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 { DocumentRow } from "@/components/documents/DocumentRow";
import { SessionRow } from "@/components/documents/session/SessionRow";
import { Tab, TabbedLayout } from "@/components/layout/TabbedLayout";
import { Loader } from "@/components/Loader";
import { DocumentLoader } from "@/context/document/DocumentLoader";
import { useDocument } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase";
import type { Campaign, DocumentId, Relationship, Session } from "@/lib/types";
import { Button } from "@headlessui/react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useCallback, useEffect, useState } from "react";
import { z } from "zod";
const CampaignTabs = {
sessions: "Sessions",
npcs: "NPCs",
locations: "Locations",
factions: "Factions",
threads: "Threads",
sessions: { label: "Sessions", docType: "session" },
secrets: { label: "Secrets", docType: "secret" },
npcs: { label: "NPCs", docType: "npc" },
locations: { label: "Locations", docType: "location" },
} as const;
const campaignSearchSchema = z.object({
@@ -50,11 +47,11 @@ function RouteComponent() {
.collection("campaigns")
.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[]);
// const sessions = await pb.collection("documents").getFullList({
// filter: `campaign = "${params.campaignId}" && type = 'session'`,
// sort: "-created",
// });
// setSessions(sessions as Session[]);
setCampaign(campaign as Campaign);
setLoading(false);
}
@@ -115,8 +112,9 @@ function RouteComponent() {
Back to campaigns
</Link>
}
tabs={Object.entries(CampaignTabs).map(([key, label]) => (
tabs={Object.entries(CampaignTabs).map(([key, { label }]) => (
<Tab
key={key}
label={label}
active={tab === key}
to={Route.to}
@@ -129,36 +127,10 @@ function RouteComponent() {
/>
))}
content={
<div>
<div className="flex justify-between">
<h3 className="text-lg font-semibold mb-2 text-slate-200">
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>
<CampaignDocuments
campaignId={campaign.id}
docType={CampaignTabs[tab].docType}
/>
}
flyout={docId && <Flyout key={docId} docId={docId} />}
/>

View File

@@ -11,7 +11,10 @@ export const Route = createFileRoute(
});
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 (
<DocumentLoader documentId={documentId as DocumentId}>