Compare commits

..

2 Commits

Author SHA1 Message Date
4c2ebdc292 Adds markdown formatting. Layout and style improvements. 2025-07-23 15:37:44 -07:00
8533f63a22 Completes the three-panel layout 2025-07-21 20:50:18 -07:00
16 changed files with 258 additions and 87 deletions

View File

@@ -11,7 +11,7 @@
<title>Dungeon Master's Companion</title> <title>Dungeon Master's Companion</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app" class="flex flex-col h-full w-full"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

30
package-lock.json generated
View File

@@ -13,7 +13,9 @@
"@tanstack/react-router": "^1.114.3", "@tanstack/react-router": "^1.114.3",
"@tanstack/react-router-devtools": "^1.114.3", "@tanstack/react-router-devtools": "^1.114.3",
"@tanstack/router-plugin": "^1.114.3", "@tanstack/router-plugin": "^1.114.3",
"dompurify": "^3.2.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "^16.1.1",
"pocketbase": "^0.26.0", "pocketbase": "^0.26.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -2182,6 +2184,13 @@
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz",
@@ -2754,6 +2763,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.157", "version": "1.5.157",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz",
@@ -3406,6 +3424,18 @@
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
} }
}, },
"node_modules/marked": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.1.1.tgz",
"integrity": "sha512-ij/2lXfCRT71L6u0M29tJPhP0bM5shLL3u5BePhFwPELj2blMJ6GDtD7PfJhRLhJ/c2UwrK17ySVcDzy2YHjHQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minipass": { "node_modules/minipass": {
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",

View File

@@ -20,7 +20,9 @@
"@tanstack/react-router": "^1.114.3", "@tanstack/react-router": "^1.114.3",
"@tanstack/react-router-devtools": "^1.114.3", "@tanstack/react-router-devtools": "^1.114.3",
"@tanstack/router-plugin": "^1.114.3", "@tanstack/router-plugin": "^1.114.3",
"dompurify": "^3.2.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "^16.1.1",
"pocketbase": "^0.26.0", "pocketbase": "^0.26.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -47,8 +47,8 @@ export function DocumentList<T extends AnyDocument>({
}; };
return ( return (
<section className="w-full max-w-2xl mx-auto"> <section className="w-full">
<div className="flex items-center justify-between my-4"> <div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-100">{title}</h2> <h2 className="text-xl font-bold text-slate-100">{title}</h2>
<div className="flex gap-2"> <div className="flex gap-2">
{isEditing && ( {isEditing && (
@@ -78,7 +78,7 @@ export function DocumentList<T extends AnyDocument>({
{items.map((item) => ( {items.map((item) => (
<li <li
key={item.id} key={item.id}
className="p-4 bg-slate-800 rounded text-slate-100 flex flex-row justify-between items-center" className="p-2 m-0 border-b-1 last:border-0 border-slate-700 flex flex-row justify-between items-center"
> >
{renderRow(item)} {renderRow(item)}

View File

@@ -0,0 +1,22 @@
import DOMPurify from "dompurify";
import * as Marked from "marked";
export type Props = {
value: string;
};
function formatText(text: React.ReactNode): { __html: string } {
if (typeof text === "string") {
return {
__html: DOMPurify.sanitize(
Marked.parse(text, { async: false }) as string,
),
};
}
throw new Error("Attempted to safe-render a non-string.");
}
export function FormattedText({ children }: React.PropsWithChildren) {
return <div dangerouslySetInnerHTML={formatText(children)}></div>;
}

View File

@@ -118,7 +118,7 @@ export function RelationshipList({
title={displayName(relationshipType)} title={displayName(relationshipType)}
items={items} items={items}
error={error} error={error}
renderRow={(document) => <DocumentRow document={document} />} renderRow={(document) => <DocumentRow document={document} root={root} />}
removeItem={handleRemove} removeItem={handleRemove}
newItemForm={(onSubmit) => ( newItemForm={(onSubmit) => (
<NewRelatedDocumentForm <NewRelatedDocumentForm

View File

@@ -1,9 +1,10 @@
import type { AnyDocument } from "@/lib/types"; import type { AnyDocument } from "@/lib/types";
import { Link } from "@tanstack/react-router"; import { FormattedText } from "../FormattedText";
import { DocumentLink } from "./DocumentLink";
export type Props = { export type Props = {
doc: AnyDocument; doc: AnyDocument;
title: string; title?: string;
description?: string; description?: string;
}; };
@@ -13,14 +14,13 @@ export type Props = {
export const BasicRow = ({ doc, title, description }: Props) => { export const BasicRow = ({ doc, title, description }: Props) => {
return ( return (
<div> <div>
<Link <DocumentLink
to="/document/$documentId" childDocId={doc.id}
params={{ documentId: doc.id }} className="!no-underline text-slate-100 hover:underline hover:text-violet-400"
className="text-lg !no-underline text-slate-100 hover:underline hover:text-violet-400"
> >
<h4>{title}</h4> {title && <h4 className="font-bold">{title}</h4>}
</Link> {description && <FormattedText>{description}</FormattedText>}
{description && <p>{description}</p>} </DocumentLink>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,21 @@
import { makeDocumentPath, useDocumentPath } from "@/lib/documentPath";
import type { DocumentId, RelationshipType } from "@/lib/types";
import { Link } from "@tanstack/react-router";
export type Props = React.PropsWithChildren<{
childDocId: DocumentId;
className?: string;
}>;
export function DocumentLink({ childDocId, className, children }: Props) {
const { documentId, relationshipType } = useDocumentPath();
return (
<Link
to={makeDocumentPath(documentId, relationshipType, childDocId)}
className={className}
>
{children}
</Link>
);
}

View File

@@ -11,10 +11,10 @@ import { TreasureToggleRow } from "./treasure/TreasureToggleRow";
*/ */
export const DocumentRow = ({ export const DocumentRow = ({
document, document,
session, root,
}: { }: {
document: AnyDocument; document: AnyDocument;
session?: Session; root?: AnyDocument;
}) => { }) => {
switch (document.type) { switch (document.type) {
case "location": case "location":
@@ -48,12 +48,12 @@ export const DocumentRow = ({
); );
case "secret": case "secret":
return <SecretToggleRow secret={document} session={session} />; return <SecretToggleRow secret={document} root={root} />;
case "scene": case "scene":
return <BasicRow doc={document} title={document.data.text} />; return <BasicRow doc={document} description={document.data.text} />;
case "treasure": case "treasure":
return <TreasureToggleRow treasure={document} session={session} />; return <TreasureToggleRow treasure={document} root={root} />;
} }
}; };

View File

@@ -6,16 +6,18 @@ import { Link } from "@tanstack/react-router";
import _ from "lodash"; import _ from "lodash";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { DocumentTitle } from "./DocumentTitle"; import { DocumentTitle } from "./DocumentTitle";
import { TabbedLayout } from "../layout/TabbedLayout"; import { Tab, TabbedLayout } from "../layout/TabbedLayout";
import { DocumentEditForm } from "./DocumentEditForm"; import { DocumentEditForm } from "./DocumentEditForm";
import { RelatedDocumentList } from "./RelatedDocumentList"; import { RelatedDocumentList } from "./RelatedDocumentList";
export function DocumentView({ export function DocumentView({
documentId, documentId,
relationshipType, relationshipType,
childDocId,
}: { }: {
documentId: DocumentId; documentId: DocumentId;
relationshipType: RelationshipType | null; relationshipType: RelationshipType | null;
childDocId: DocumentId | null;
}) { }) {
const { docResult } = useDocument(documentId); const { docResult } = useDocument(documentId);
@@ -40,14 +42,14 @@ export function DocumentView({
<Link <Link
to={CampaignRoute.to} to={CampaignRoute.to}
params={{ campaignId: doc.campaign }} params={{ campaignId: doc.campaign }}
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors mb-4" className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
> >
Back to campaign Back to campaign
</Link> </Link>
<Link <Link
to="/document/$documentId/print" to="/document/$documentId/print"
params={{ documentId: doc.id }} params={{ documentId: doc.id }}
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors mb-4" className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
> >
Print Print
</Link> </Link>
@@ -55,31 +57,24 @@ export function DocumentView({
} }
title={<DocumentTitle document={doc} />} title={<DocumentTitle document={doc} />}
tabs={[ tabs={[
<Link <Tab
key={""}
to="/document/$documentId" to="/document/$documentId"
params={{ params={{
documentId, documentId,
}} }}
> label="Attributes"
<div className="px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500 data-selected:bg-violet-900 data-selected:border-violet-700 whitespace-nowrap"> active={relationshipType === null}
Attributes />,
</div> ...relationshipList.map((relationshipEntry) => (
</Link>, <Tab
...relationshipList.map((relationshipType) => (
<Link
key={relationshipType}
to="/document/$documentId/$relationshipType" to="/document/$documentId/$relationshipType"
params={{ params={{
documentId, documentId,
relationshipType, relationshipType: relationshipEntry,
}} }}
> label={`${displayName(relationshipEntry)} (${relationshipCounts[relationshipEntry] ?? 0})`}
<div className="px-3 py-2 rounded bg-slate-800 text-slate-100 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500 data-selected:bg-violet-900 data-selected:border-violet-700 whitespace-nowrap"> active={relationshipEntry === relationshipType}
{displayName(relationshipType)} ( />
{relationshipCounts[relationshipType] ?? 0})
</div>
</Link>
)), )),
]} ]}
content={ content={
@@ -92,6 +87,19 @@ export function DocumentView({
/> />
) )
} }
flyout={childDocId && <Flyout key={childDocId} docId={childDocId} />}
/> />
); );
} }
function Flyout({ docId }: { docId: DocumentId }) {
const { docResult } = useDocument(docId);
if (docResult?.type !== "ready") {
return <Loader />;
}
const doc = docResult.value.doc;
return <DocumentEditForm document={doc} />;
}

View File

@@ -1,8 +1,9 @@
// SecretRow.tsx // SecretRow.tsx
// Displays a single secret with discovered checkbox and text. // Displays a single secret with discovered checkbox and text.
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import type { Secret, Session } from "@/lib/types"; import type { AnyDocument, Secret } from "@/lib/types";
import { useState } from "react"; import { useState } from "react";
import { DocumentLink } from "../DocumentLink";
/** /**
* Renders a secret row with a discovered checkbox and secret text. * Renders a secret row with a discovered checkbox and secret text.
@@ -10,10 +11,10 @@ import { useState } from "react";
*/ */
export const SecretToggleRow = ({ export const SecretToggleRow = ({
secret, secret,
session, root,
}: { }: {
secret: Secret; secret: Secret;
session?: Session; root?: AnyDocument;
}) => { }) => {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
!!(secret.data as any)?.secret?.discovered, !!(secret.data as any)?.secret?.discovered,
@@ -36,7 +37,7 @@ export const SecretToggleRow = ({
}, },
}, },
}); });
if (session || !newChecked) { if (root || !newChecked) {
// If the session exists or the element is being unchecked, remove any // If the session exists or the element is being unchecked, remove any
// existing discoveredIn relationship // existing discoveredIn relationship
const rels = await pb.collection("relationships").getList(1, 1, { const rels = await pb.collection("relationships").getList(1, 1, {
@@ -46,11 +47,11 @@ export const SecretToggleRow = ({
await pb.collection("relationships").delete(rels.items[0].id); await pb.collection("relationships").delete(rels.items[0].id);
} }
} }
if (session) { if (root) {
if (newChecked) { if (newChecked) {
await pb.collection("relationships").create({ await pb.collection("relationships").create({
primary: secret.id, primary: secret.id,
secondary: [session.id], secondary: [root.id],
type: "discoveredIn", type: "discoveredIn",
}); });
} }
@@ -70,7 +71,12 @@ export const SecretToggleRow = ({
aria-label="Discovered" aria-label="Discovered"
disabled={loading} disabled={loading}
/> />
<DocumentLink
childDocId={secret.id}
className="!no-underline text-slate-100 hover:underline hover:text-violet-400"
>
{secret.data.text} {secret.data.text}
</DocumentLink>
</div> </div>
); );
}; };

View File

@@ -1,7 +1,7 @@
// TreasureRow.tsx // TreasureRow.tsx
// Displays a single treasure with discovered checkbox and text. // Displays a single treasure with discovered checkbox and text.
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import type { Session, Treasure } from "@/lib/types"; import type { AnyDocument, Treasure } from "@/lib/types";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
@@ -11,10 +11,10 @@ import { useState } from "react";
*/ */
export const TreasureToggleRow = ({ export const TreasureToggleRow = ({
treasure, treasure,
session, root,
}: { }: {
treasure: Treasure; treasure: Treasure;
session?: Session; root?: AnyDocument;
}) => { }) => {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
!!(treasure.data as any)?.treasure?.discovered, !!(treasure.data as any)?.treasure?.discovered,
@@ -35,7 +35,7 @@ export const TreasureToggleRow = ({
}, },
}, },
}); });
if (session || !newChecked) { if (root || !newChecked) {
// If the session exists or the element is being unchecked, remove any // If the session exists or the element is being unchecked, remove any
// existing discoveredIn relationship // existing discoveredIn relationship
const rels = await pb.collection("relationships").getList(1, 1, { const rels = await pb.collection("relationships").getList(1, 1, {
@@ -45,11 +45,11 @@ export const TreasureToggleRow = ({
await pb.collection("relationships").delete(rels.items[0].id); await pb.collection("relationships").delete(rels.items[0].id);
} }
} }
if (session) { if (root) {
if (newChecked) { if (newChecked) {
await pb.collection("relationships").create({ await pb.collection("relationships").create({
primary: treasure.id, primary: treasure.id,
secondary: [session.id], secondary: [root.id],
type: "discoveredIn", type: "discoveredIn",
}); });
} }

View File

@@ -1,16 +1,62 @@
import { Link } from "@tanstack/react-router";
export type Props = { export type Props = {
title: React.ReactNode; title: React.ReactNode;
navigation: React.ReactNode; navigation: React.ReactNode;
tabs: React.ReactNode[]; tabs: React.ReactNode[];
content: React.ReactNode; content: React.ReactNode;
flyout?: React.ReactNode;
}; };
export function TabbedLayout({ navigation, title, tabs, content }: Props) { export function TabbedLayout({
navigation,
title,
tabs,
content,
flyout,
}: Props) {
return ( return (
<div className="grow p-2 flex flex-col">
<div> <div>
<div>{navigation}</div> <div className="flex flex-row gap-2">{navigation}</div>
<div>{title}</div> <div>{title}</div>
<div>{tabs}</div> </div>
<div>{content}</div> <div className="flex flex-row justify-start m-2 grow">
<div className="shrink-0 grow-0 w-40 p-0">{tabs}</div>
<div
className={`grow p-2 bg-slate-800 border-t border-b border-r border-slate-700`}
>
{content}
</div>
{flyout && (
<div className="w-md p-2 bg-slate-800 border border-slate-700">
{flyout}
</div>
)}
</div>
</div> </div>
); );
} }
export type TabProps = {
label: string;
to: string;
params: Record<string, any>;
active?: boolean;
};
const activeTabClass =
"text-slate-100 font-bold bg-slate-800 border-t border-b border-l";
const inactiveTabClass = "text-slate-300 bg-slate-900 border";
export function Tab({ label, to, params, active }: TabProps) {
return (
<Link
key={label}
to={to}
params={params}
className={`block p-2 border-slate-700 whitespace-nowrap ${active ? activeTabClass : inactiveTabClass}`}
>
{label}
</Link>
);
}

54
src/lib/documentPath.ts Normal file
View File

@@ -0,0 +1,54 @@
import { useParams } from "@tanstack/react-router";
import * as z from "zod";
import type { RelationshipType, DocumentId } from "./types";
const documentParams = z
.templateLiteral([
z.string(),
z.optional(z.literal("/")),
z.optional(z.string()),
])
.pipe(
z.transform((path: string) => {
if (path === "") {
return {
relationshipType: null,
childDocId: null,
};
}
const [relationshipType, childDocId] = path.split("/");
return {
relationshipType: (relationshipType ?? null) as RelationshipType | null,
childDocId: (childDocId ?? null) as DocumentId | null,
};
}),
);
export function useDocumentPath(): {
documentId: DocumentId;
relationshipType: RelationshipType | null;
childDocId: DocumentId | null;
} {
const params = useParams({
from: "/_app/_authenticated/document/$documentId/$",
});
const { relationshipType, childDocId } = documentParams.parse(params._splat);
return {
documentId: params.documentId as DocumentId,
relationshipType,
childDocId,
};
}
export function makeDocumentPath(
documentId: DocumentId,
relationshipType?: RelationshipType | null,
childDocId?: DocumentId | null,
) {
return (
"/document/" +
[documentId, relationshipType, childDocId].filter((x) => x).join("/")
);
}

View File

@@ -1,9 +1,8 @@
import { DocumentView } from "@/components/documents/DocumentView"; import { DocumentView } from "@/components/documents/DocumentView";
import { DocumentLoader } from "@/context/document/DocumentLoader"; import { DocumentLoader } from "@/context/document/DocumentLoader";
import { useDocumentPath } from "@/lib/documentPath";
import type { DocumentId } from "@/lib/types"; import type { DocumentId } from "@/lib/types";
import { RelationshipType } from "@/lib/types";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import * as z from "zod";
export const Route = createFileRoute( export const Route = createFileRoute(
"/_app/_authenticated/document/$documentId/$", "/_app/_authenticated/document/$documentId/$",
@@ -11,38 +10,15 @@ export const Route = createFileRoute(
component: RouteComponent, 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() { function RouteComponent() {
const { documentId, _splat } = Route.useParams(); const { documentId, relationshipType, childDocId } = useDocumentPath();
const { relationshipType, childDoc } = documentParams.parse(_splat);
return ( return (
<DocumentLoader documentId={documentId as DocumentId}> <DocumentLoader documentId={documentId as DocumentId}>
<DocumentView <DocumentView
documentId={documentId as DocumentId} documentId={documentId as DocumentId}
relationshipType={relationshipType} relationshipType={relationshipType}
childDocId={childDocId}
/> />
</DocumentLoader> </DocumentLoader>
); );

View File

@@ -1,5 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@tailwind utilities; @import "tailwindcss/utilities";
html, html,
body { body {
@@ -17,6 +17,12 @@ body {
min-width: 320px; min-width: 320px;
} }
/* The container for all content */
#app {
height: 100%;
width: 100%;
}
code, code,
pre { pre {
font-family: "Fira Mono", "Menlo", "Monaco", "Consolas", monospace; font-family: "Fira Mono", "Menlo", "Monaco", "Consolas", monospace;