diff --git a/package-lock.json b/package-lock.json
index 3a15b57..c9af873 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,8 @@
"pocketbase": "^0.26.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "tailwindcss": "^4.0.6"
+ "tailwindcss": "^4.0.6",
+ "zod": "^4.0.5"
},
"devDependencies": {
"@astrojs/ts-plugin": "^1.10.4",
@@ -1926,6 +1927,15 @@
}
}
},
+ "node_modules/@tanstack/router-generator/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/@tanstack/router-plugin": {
"version": "1.120.10",
"resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.120.10.tgz",
@@ -1982,6 +1992,15 @@
}
}
},
+ "node_modules/@tanstack/router-plugin/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/@tanstack/router-utils": {
"version": "1.115.0",
"resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.115.0.tgz",
@@ -4503,9 +4522,9 @@
}
},
"node_modules/zod": {
- "version": "3.25.28",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.28.tgz",
- "integrity": "sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q==",
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.5.tgz",
+ "integrity": "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
diff --git a/package.json b/package.json
index 57e6a4a..7f6548e 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,8 @@
"pocketbase": "^0.26.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "tailwindcss": "^4.0.6"
+ "tailwindcss": "^4.0.6",
+ "zod": "^4.0.5"
},
"devDependencies": {
"@astrojs/ts-plugin": "^1.10.4",
diff --git a/src/components/documents/DocumentView.tsx b/src/components/documents/DocumentView.tsx
index 9e6eb06..4d2550e 100644
--- a/src/components/documents/DocumentView.tsx
+++ b/src/components/documents/DocumentView.tsx
@@ -1,15 +1,22 @@
-import { DocumentEditForm } from "@/components/documents/DocumentEditForm";
import { useDocument } from "@/context/document/hooks";
import { displayName, relationshipsForDocument } from "@/lib/relationships";
-import type { DocumentId } from "@/lib/types";
+import { RelationshipType, type DocumentId } from "@/lib/types";
import { Route as CampaignRoute } from "@/routes/_app/_authenticated/campaigns.$campaignId";
import { Link } from "@tanstack/react-router";
import _ from "lodash";
import { Loader } from "../Loader";
-import { Route as RelationshipRoute } from "@/routes/_app/_authenticated/document.$documentId/$relationshipType";
import { DocumentTitle } from "./DocumentTitle";
+import { TabbedLayout } from "../layout/TabbedLayout";
+import { DocumentEditForm } from "./DocumentEditForm";
+import { RelatedDocumentList } from "./RelatedDocumentList";
-export function DocumentView({ documentId }: { documentId: DocumentId }) {
+export function DocumentView({
+ documentId,
+ relationshipType,
+}: {
+ documentId: DocumentId;
+ relationshipType: RelationshipType | null;
+}) {
const { docResult } = useDocument(documentId);
if (docResult?.type !== "ready") {
@@ -19,52 +26,72 @@ export function DocumentView({ documentId }: { documentId: DocumentId }) {
const doc = docResult.value.doc;
const relationshipCounts = _.mapValues(docResult.value.relationships, (v) => {
if (v.type === "ready") {
- return v.value.secondary.length;
+ return v.value.secondary.length.toString();
}
- return 0;
+ return "...";
});
const relationshipList = relationshipsForDocument(doc);
return (
-
-
-
+
+
+ Back to campaign
+
+
+ Print
+
+ >
+ }
+ title={}
+ tabs={[
- Back to campaign
-
-
- Print
-
-
-
-
-
+
+ Attributes
+
+ ,
+ ...relationshipList.map((relationshipType) => (
+
+
+ {displayName(relationshipType)} (
+ {relationshipCounts[relationshipType] ?? 0})
+
+
+ )),
+ ]}
+ content={
+ relationshipType === null ? (
+
+ ) : (
+
+ )
+ }
+ />
);
}
diff --git a/src/components/layout/TabbedLayout.tsx b/src/components/layout/TabbedLayout.tsx
new file mode 100644
index 0000000..e34782e
--- /dev/null
+++ b/src/components/layout/TabbedLayout.tsx
@@ -0,0 +1,16 @@
+export type Props = {
+ title: React.ReactNode;
+ navigation: React.ReactNode;
+ tabs: React.ReactNode[];
+ content: React.ReactNode;
+};
+export function TabbedLayout({ navigation, title, tabs, content }: Props) {
+ return (
+
+
{navigation}
+
{title}
+
{tabs}
+
{content}
+
+ );
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index eddc217..02ee140 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -17,10 +17,8 @@ import { Route as AppLoginImport } from './routes/_app/login'
import { Route as AppAboutImport } from './routes/_app/about'
import { Route as AppAuthenticatedImport } from './routes/_app/_authenticated'
import { Route as AppAuthenticatedCampaignsIndexImport } from './routes/_app/_authenticated/campaigns.index'
-import { Route as AppAuthenticatedDocumentDocumentIdImport } from './routes/_app/_authenticated/document.$documentId'
import { Route as AppAuthenticatedCampaignsCampaignIdImport } from './routes/_app/_authenticated/campaigns.$campaignId'
-import { Route as AppauthenticatedDocumentDocumentIdPrintImport } from './routes/_app_._authenticated.document_.$documentId.print'
-import { Route as AppAuthenticatedDocumentDocumentIdRelationshipTypeImport } from './routes/_app/_authenticated/document.$documentId/$relationshipType'
+import { Route as AppAuthenticatedDocumentDocumentIdSplatImport } from './routes/_app/_authenticated/document.$documentId.$'
// Create/Update Routes
@@ -59,13 +57,6 @@ const AppAuthenticatedCampaignsIndexRoute =
getParentRoute: () => AppAuthenticatedRoute,
} as any)
-const AppAuthenticatedDocumentDocumentIdRoute =
- AppAuthenticatedDocumentDocumentIdImport.update({
- id: '/document/$documentId',
- path: '/document/$documentId',
- getParentRoute: () => AppAuthenticatedRoute,
- } as any)
-
const AppAuthenticatedCampaignsCampaignIdRoute =
AppAuthenticatedCampaignsCampaignIdImport.update({
id: '/campaigns/$campaignId',
@@ -73,18 +64,11 @@ const AppAuthenticatedCampaignsCampaignIdRoute =
getParentRoute: () => AppAuthenticatedRoute,
} as any)
-const AppauthenticatedDocumentDocumentIdPrintRoute =
- AppauthenticatedDocumentDocumentIdPrintImport.update({
- id: '/_app_/_authenticated/document_/$documentId/print',
- path: '/document/$documentId/print',
- getParentRoute: () => rootRoute,
- } as any)
-
-const AppAuthenticatedDocumentDocumentIdRelationshipTypeRoute =
- AppAuthenticatedDocumentDocumentIdRelationshipTypeImport.update({
- id: '/$relationshipType',
- path: '/$relationshipType',
- getParentRoute: () => AppAuthenticatedDocumentDocumentIdRoute,
+const AppAuthenticatedDocumentDocumentIdSplatRoute =
+ AppAuthenticatedDocumentDocumentIdSplatImport.update({
+ id: '/document/$documentId/$',
+ path: '/document/$documentId/$',
+ getParentRoute: () => AppAuthenticatedRoute,
} as any)
// Populate the FileRoutesByPath interface
@@ -133,13 +117,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppAuthenticatedCampaignsCampaignIdImport
parentRoute: typeof AppAuthenticatedImport
}
- '/_app/_authenticated/document/$documentId': {
- id: '/_app/_authenticated/document/$documentId'
- path: '/document/$documentId'
- fullPath: '/document/$documentId'
- preLoaderRoute: typeof AppAuthenticatedDocumentDocumentIdImport
- parentRoute: typeof AppAuthenticatedImport
- }
'/_app/_authenticated/campaigns/': {
id: '/_app/_authenticated/campaigns/'
path: '/campaigns'
@@ -147,52 +124,30 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppAuthenticatedCampaignsIndexImport
parentRoute: typeof AppAuthenticatedImport
}
- '/_app/_authenticated/document/$documentId/$relationshipType': {
- id: '/_app/_authenticated/document/$documentId/$relationshipType'
- path: '/$relationshipType'
- fullPath: '/document/$documentId/$relationshipType'
- preLoaderRoute: typeof AppAuthenticatedDocumentDocumentIdRelationshipTypeImport
- parentRoute: typeof AppAuthenticatedDocumentDocumentIdImport
- }
- '/_app_/_authenticated/document_/$documentId/print': {
- id: '/_app_/_authenticated/document_/$documentId/print'
- path: '/document/$documentId/print'
- fullPath: '/document/$documentId/print'
- preLoaderRoute: typeof AppauthenticatedDocumentDocumentIdPrintImport
- parentRoute: typeof rootRoute
+ '/_app/_authenticated/document/$documentId/$': {
+ id: '/_app/_authenticated/document/$documentId/$'
+ path: '/document/$documentId/$'
+ fullPath: '/document/$documentId/$'
+ preLoaderRoute: typeof AppAuthenticatedDocumentDocumentIdSplatImport
+ parentRoute: typeof AppAuthenticatedImport
}
}
}
// Create and export the route tree
-interface AppAuthenticatedDocumentDocumentIdRouteChildren {
- AppAuthenticatedDocumentDocumentIdRelationshipTypeRoute: typeof AppAuthenticatedDocumentDocumentIdRelationshipTypeRoute
-}
-
-const AppAuthenticatedDocumentDocumentIdRouteChildren: AppAuthenticatedDocumentDocumentIdRouteChildren =
- {
- AppAuthenticatedDocumentDocumentIdRelationshipTypeRoute:
- AppAuthenticatedDocumentDocumentIdRelationshipTypeRoute,
- }
-
-const AppAuthenticatedDocumentDocumentIdRouteWithChildren =
- AppAuthenticatedDocumentDocumentIdRoute._addFileChildren(
- AppAuthenticatedDocumentDocumentIdRouteChildren,
- )
-
interface AppAuthenticatedRouteChildren {
AppAuthenticatedCampaignsCampaignIdRoute: typeof AppAuthenticatedCampaignsCampaignIdRoute
- AppAuthenticatedDocumentDocumentIdRoute: typeof AppAuthenticatedDocumentDocumentIdRouteWithChildren
AppAuthenticatedCampaignsIndexRoute: typeof AppAuthenticatedCampaignsIndexRoute
+ AppAuthenticatedDocumentDocumentIdSplatRoute: typeof AppAuthenticatedDocumentDocumentIdSplatRoute
}
const AppAuthenticatedRouteChildren: AppAuthenticatedRouteChildren = {
AppAuthenticatedCampaignsCampaignIdRoute:
AppAuthenticatedCampaignsCampaignIdRoute,
- AppAuthenticatedDocumentDocumentIdRoute:
- AppAuthenticatedDocumentDocumentIdRouteWithChildren,
AppAuthenticatedCampaignsIndexRoute: AppAuthenticatedCampaignsIndexRoute,
+ AppAuthenticatedDocumentDocumentIdSplatRoute:
+ AppAuthenticatedDocumentDocumentIdSplatRoute,
}
const AppAuthenticatedRouteWithChildren =
@@ -220,10 +175,8 @@ export interface FileRoutesByFullPath {
'/login': typeof AppLoginRoute
'/': typeof AppIndexRoute
'/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute
- '/document/$documentId': typeof AppAuthenticatedDocumentDocumentIdRouteWithChildren
'/campaigns': typeof AppAuthenticatedCampaignsIndexRoute
- '/document/$documentId/$relationshipType': typeof AppAuthenticatedDocumentDocumentIdRelationshipTypeRoute
- '/document/$documentId/print': typeof AppauthenticatedDocumentDocumentIdPrintRoute
+ '/document/$documentId/$': typeof AppAuthenticatedDocumentDocumentIdSplatRoute
}
export interface FileRoutesByTo {
@@ -232,10 +185,8 @@ export interface FileRoutesByTo {
'/login': typeof AppLoginRoute
'/': typeof AppIndexRoute
'/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute
- '/document/$documentId': typeof AppAuthenticatedDocumentDocumentIdRouteWithChildren
'/campaigns': typeof AppAuthenticatedCampaignsIndexRoute
- '/document/$documentId/$relationshipType': typeof AppAuthenticatedDocumentDocumentIdRelationshipTypeRoute
- '/document/$documentId/print': typeof AppauthenticatedDocumentDocumentIdPrintRoute
+ '/document/$documentId/$': typeof AppAuthenticatedDocumentDocumentIdSplatRoute
}
export interface FileRoutesById {
@@ -246,10 +197,8 @@ export interface FileRoutesById {
'/_app/login': typeof AppLoginRoute
'/_app/': typeof AppIndexRoute
'/_app/_authenticated/campaigns/$campaignId': typeof AppAuthenticatedCampaignsCampaignIdRoute
- '/_app/_authenticated/document/$documentId': typeof AppAuthenticatedDocumentDocumentIdRouteWithChildren
'/_app/_authenticated/campaigns/': typeof AppAuthenticatedCampaignsIndexRoute
- '/_app/_authenticated/document/$documentId/$relationshipType': typeof AppAuthenticatedDocumentDocumentIdRelationshipTypeRoute
- '/_app_/_authenticated/document_/$documentId/print': typeof AppauthenticatedDocumentDocumentIdPrintRoute
+ '/_app/_authenticated/document/$documentId/$': typeof AppAuthenticatedDocumentDocumentIdSplatRoute
}
export interface FileRouteTypes {
@@ -260,10 +209,8 @@ export interface FileRouteTypes {
| '/login'
| '/'
| '/campaigns/$campaignId'
- | '/document/$documentId'
| '/campaigns'
- | '/document/$documentId/$relationshipType'
- | '/document/$documentId/print'
+ | '/document/$documentId/$'
fileRoutesByTo: FileRoutesByTo
to:
| ''
@@ -271,10 +218,8 @@ export interface FileRouteTypes {
| '/login'
| '/'
| '/campaigns/$campaignId'
- | '/document/$documentId'
| '/campaigns'
- | '/document/$documentId/$relationshipType'
- | '/document/$documentId/print'
+ | '/document/$documentId/$'
id:
| '__root__'
| '/_app'
@@ -283,22 +228,17 @@ export interface FileRouteTypes {
| '/_app/login'
| '/_app/'
| '/_app/_authenticated/campaigns/$campaignId'
- | '/_app/_authenticated/document/$documentId'
| '/_app/_authenticated/campaigns/'
- | '/_app/_authenticated/document/$documentId/$relationshipType'
- | '/_app_/_authenticated/document_/$documentId/print'
+ | '/_app/_authenticated/document/$documentId/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AppRoute: typeof AppRouteWithChildren
- AppauthenticatedDocumentDocumentIdPrintRoute: typeof AppauthenticatedDocumentDocumentIdPrintRoute
}
const rootRouteChildren: RootRouteChildren = {
AppRoute: AppRouteWithChildren,
- AppauthenticatedDocumentDocumentIdPrintRoute:
- AppauthenticatedDocumentDocumentIdPrintRoute,
}
export const routeTree = rootRoute
@@ -311,8 +251,7 @@ export const routeTree = rootRoute
"__root__": {
"filePath": "__root.tsx",
"children": [
- "/_app",
- "/_app_/_authenticated/document_/$documentId/print"
+ "/_app"
]
},
"/_app": {
@@ -329,8 +268,8 @@ export const routeTree = rootRoute
"parent": "/_app",
"children": [
"/_app/_authenticated/campaigns/$campaignId",
- "/_app/_authenticated/document/$documentId",
- "/_app/_authenticated/campaigns/"
+ "/_app/_authenticated/campaigns/",
+ "/_app/_authenticated/document/$documentId/$"
]
},
"/_app/about": {
@@ -349,23 +288,13 @@ export const routeTree = rootRoute
"filePath": "_app/_authenticated/campaigns.$campaignId.tsx",
"parent": "/_app/_authenticated"
},
- "/_app/_authenticated/document/$documentId": {
- "filePath": "_app/_authenticated/document.$documentId.tsx",
- "parent": "/_app/_authenticated",
- "children": [
- "/_app/_authenticated/document/$documentId/$relationshipType"
- ]
- },
"/_app/_authenticated/campaigns/": {
"filePath": "_app/_authenticated/campaigns.index.tsx",
"parent": "/_app/_authenticated"
},
- "/_app/_authenticated/document/$documentId/$relationshipType": {
- "filePath": "_app/_authenticated/document.$documentId/$relationshipType.tsx",
- "parent": "/_app/_authenticated/document/$documentId"
- },
- "/_app_/_authenticated/document_/$documentId/print": {
- "filePath": "_app_._authenticated.document_.$documentId.print.tsx"
+ "/_app/_authenticated/document/$documentId/$": {
+ "filePath": "_app/_authenticated/document.$documentId.$.tsx",
+ "parent": "/_app/_authenticated"
}
}
}
diff --git a/src/routes/_app/_authenticated/document.$documentId.$.tsx b/src/routes/_app/_authenticated/document.$documentId.$.tsx
new file mode 100644
index 0000000..60146b3
--- /dev/null
+++ b/src/routes/_app/_authenticated/document.$documentId.$.tsx
@@ -0,0 +1,49 @@
+import { DocumentView } from "@/components/documents/DocumentView";
+import { DocumentLoader } from "@/context/document/DocumentLoader";
+import type { DocumentId } from "@/lib/types";
+import { RelationshipType } from "@/lib/types";
+import { createFileRoute } from "@tanstack/react-router";
+import * as z from "zod";
+
+export const Route = createFileRoute(
+ "/_app/_authenticated/document/$documentId/$",
+)({
+ component: RouteComponent,
+});
+
+const documentParams = z
+ .templateLiteral([
+ z.string(),
+ z.optional(z.literal("/")),
+ z.optional(z.string()),
+ ])
+ .pipe(
+ z.transform((path: string) => {
+ if (path === "") {
+ return {
+ relationshipType: null,
+ childDoc: null,
+ };
+ }
+ const [relationshipType, childDoc] = path.split("/");
+ return {
+ relationshipType: (relationshipType ?? null) as RelationshipType | null,
+ childDoc: (childDoc ?? null) as DocumentId | null,
+ };
+ }),
+ );
+
+function RouteComponent() {
+ const { documentId, _splat } = Route.useParams();
+
+ const { relationshipType, childDoc } = documentParams.parse(_splat);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/routes/_app/_authenticated/document.$documentId.tsx b/src/routes/_app/_authenticated/document.$documentId.tsx
deleted file mode 100644
index e2fd599..0000000
--- a/src/routes/_app/_authenticated/document.$documentId.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { DocumentView } from "@/components/documents/DocumentView";
-import { DocumentLoader } from "@/context/document/DocumentLoader";
-import type { DocumentId } from "@/lib/types";
-import { createFileRoute, Outlet } from "@tanstack/react-router";
-
-export const Route = createFileRoute(
- "/_app/_authenticated/document/$documentId",
-)({
- component: RouteComponent,
-});
-
-function RouteComponent() {
- const { documentId } = Route.useParams();
-
- return (
-
-
-
-
- );
-}
diff --git a/src/routes/_app/_authenticated/document.$documentId/$relationshipType.tsx b/src/routes/_app/_authenticated/document.$documentId/$relationshipType.tsx
deleted file mode 100644
index 7d49a7a..0000000
--- a/src/routes/_app/_authenticated/document.$documentId/$relationshipType.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { RelatedDocumentList } from "@/components/documents/RelatedDocumentList";
-import type { DocumentId, RelationshipType } from "@/lib/types";
-import { createFileRoute } from "@tanstack/react-router";
-
-export const Route = createFileRoute(
- "/_app/_authenticated/document/$documentId/$relationshipType",
-)({
- component: RouteComponent,
-});
-
-function RouteComponent() {
- const { documentId, relationshipType } = Route.useParams();
- return (
-
- );
-}
diff --git a/src/routes/_app_._authenticated.document_.$documentId.print.tsx b/src/routes/_app_._authenticated.document_.$documentId.print.tsx
deleted file mode 100644
index 9f2dc96..0000000
--- a/src/routes/_app_._authenticated.document_.$documentId.print.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { DocumentPrintRow } from "@/components/documents/DocumentPrintRow";
-import { SessionPrintRow } from "@/components/documents/session/SessionPrintRow";
-import { Loader } from "@/components/Loader";
-import { useDocument, useDocumentCache } from "@/context/document/hooks";
-import { RelationshipType, type DocumentId, type Session } from "@/lib/types";
-import { createFileRoute } from "@tanstack/react-router";
-import _ from "lodash";
-
-export const Route = createFileRoute(
- "/_app_/_authenticated/document_/$documentId/print",
-)({
- component: RouteComponent,
- pendingComponent: Loader,
-});
-
-function RouteComponent() {
- const params = Route.useParams();
-
- const { cache } = useDocumentCache();
- const { docResult } = useDocument(params.documentId as DocumentId);
-
- if (docResult.type !== "ready") {
- return ;
- }
-
- const session = docResult.value.doc as Session;
- const relationships = _.mapValues(
- docResult.value.relationships,
- (relResult) => {
- if (relResult.type != "ready") {
- return [];
- }
- return relResult.value.secondary
- .map((id) => cache.documents[id])
- .flatMap((docResult) =>
- docResult.type === "ready" ? [docResult.value.doc] : [],
- );
- },
- );
-
- return (
-
-
- {[
- RelationshipType.Scenes,
- RelationshipType.Secrets,
- RelationshipType.Locations,
- RelationshipType.Npcs,
- RelationshipType.Monsters,
- RelationshipType.Treasures,
- ].map((relationshipType) => (
-
-
- {relationshipType.charAt(0).toUpperCase() +
- relationshipType.slice(1)}
-
-
-
- {(relationships[relationshipType] ?? []).map((item) => (
-
- ))}
-
-
- ))}
-
- );
-}