WIP: Linking in document list. Working on copying relationships to new sessions
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { Document } from "@/lib/types";
|
||||
import type { Document, DocumentId } from "@/lib/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from "@headlessui/react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { Fragment, useCallback, useState } from "react";
|
||||
import * as Icons from "@/components/Icons.tsx";
|
||||
|
||||
type Props<T extends Document> = {
|
||||
title: React.ReactNode;
|
||||
@@ -14,6 +15,7 @@ type Props<T extends Document> = {
|
||||
items: T[];
|
||||
renderRow: (item: T) => React.ReactNode;
|
||||
newItemForm: (onSubmit: () => void) => React.ReactNode;
|
||||
removeItem: (itemId: DocumentId) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,8 +32,15 @@ export function DocumentList<T extends Document>({
|
||||
items,
|
||||
renderRow,
|
||||
newItemForm,
|
||||
removeItem,
|
||||
}: Props<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const toggleEditMode = useCallback(
|
||||
() => setIsEditing((x) => !x),
|
||||
[setIsEditing],
|
||||
);
|
||||
|
||||
// Handles closing the dialog after form submission
|
||||
const handleFormSubmit = (): void => {
|
||||
@@ -42,35 +51,50 @@ export function DocumentList<T extends Document>({
|
||||
<section className="w-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between my-4">
|
||||
<h2 className="text-xl font-bold text-slate-100">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||
aria-label="Add new item"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
<div className="flex gap-2">
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||
aria-label="Add new item"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Icons.Cross />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||
aria-label={isEditing ? "Exit edit mode" : "Enter edit mode"}
|
||||
onClick={toggleEditMode}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{isEditing ? <Icons.Done /> : <Icons.Edit />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="bg-red-900 rounded p-4 text-slate-100">{error}</div>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="bg-slate-800 rounded p-4 text-slate-100">
|
||||
{renderRow(item)}
|
||||
<li
|
||||
key={item.id}
|
||||
className="bg-slate-800 rounded p-4 text-slate-100 flex flex-row justify-between items-center"
|
||||
>
|
||||
<div>{renderRow(item)}</div>
|
||||
|
||||
{isEditing && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||
aria-label="Remove item"
|
||||
onClick={() => removeItem(item.id)}
|
||||
>
|
||||
<Icons.Remove />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
3
src/components/FormattedDate.tsx
Normal file
3
src/components/FormattedDate.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const FormattedDate = ({ date }: { date: string }) => (
|
||||
<span>{new Date(date).toLocaleString()}</span>
|
||||
);
|
||||
56
src/components/Icons.tsx
Normal file
56
src/components/Icons.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
export const Edit = () => (
|
||||
// Pencil icon
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 5.487a2.25 2.25 0 1 1 3.182 3.182L8.25 20.463 3 21.75l1.287-5.25 12.575-11.013z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Cross = () => (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Remove = () => (
|
||||
<svg
|
||||
className="w-5 h-5 rotate-45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Done = () => (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
@@ -34,7 +34,7 @@ export function RelationshipList({
|
||||
useEffect(() => {
|
||||
async function fetchItems() {
|
||||
const { items } = await queryClient.fetchQuery({
|
||||
queryKey: [root.id, "relationship", relationshipType],
|
||||
queryKey: ["relationship", relationshipType, root.id],
|
||||
staleTime: 5 * 60 * 1000, // 5 mintues
|
||||
queryFn: async () => {
|
||||
setLoading(true);
|
||||
@@ -81,7 +81,7 @@ export function RelationshipList({
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [root.id, "relationship", relationshipType],
|
||||
queryKey: ["relationship", relationshipType, root.id],
|
||||
});
|
||||
setItems((prev) => [...prev, doc]);
|
||||
} catch (e: any) {
|
||||
@@ -101,6 +101,7 @@ export function RelationshipList({
|
||||
items={items}
|
||||
error={error}
|
||||
renderRow={(document) => <DocumentRow document={document} />}
|
||||
removeItem={() => {}}
|
||||
newItemForm={(onSubmit) => (
|
||||
<NewRelatedDocumentForm
|
||||
campaignId={root.campaign}
|
||||
|
||||
26
src/components/documents/BasicRow.tsx
Normal file
26
src/components/documents/BasicRow.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { AnyDocument } from "@/lib/types";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export type Props = {
|
||||
doc: AnyDocument;
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a simple row that links to the document
|
||||
*/
|
||||
export const BasicRow = ({ doc, title, description }: Props) => {
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
to="/document/$documentId"
|
||||
params={{ documentId: doc.id }}
|
||||
className="text-lg"
|
||||
>
|
||||
<h4>{title}</h4>
|
||||
</Link>
|
||||
{description && <p>{description}</p>}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -12,11 +12,7 @@ import {
|
||||
type Document,
|
||||
type Session,
|
||||
} from "@/lib/types";
|
||||
import { LocationPrintRow } from "./location/LocationPrintRow";
|
||||
import { MonsterPrintRow } from "./monsters/MonsterPrintRow";
|
||||
import { NpcPrintRow } from "./npc/NpcPrintRow";
|
||||
import { ScenePrintRow } from "./scene/ScenePrintRow";
|
||||
import { SessionPrintRow } from "./session/SessionPrintRow";
|
||||
import { BasicRow } from "./BasicRow";
|
||||
import { TreasureToggleRow } from "./treasure/TreasureToggleRow";
|
||||
|
||||
/**
|
||||
@@ -31,19 +27,37 @@ export const DocumentRow = ({
|
||||
session?: Session;
|
||||
}) => {
|
||||
if (isLocation(document)) {
|
||||
return <LocationPrintRow location={document} />;
|
||||
return (
|
||||
<BasicRow
|
||||
doc={document}
|
||||
title={document.data.location.name}
|
||||
description={document.data.location.description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMonster(document)) {
|
||||
return <MonsterPrintRow monster={document} />;
|
||||
return <BasicRow doc={document} title={document.data.monster.name} />;
|
||||
}
|
||||
|
||||
if (isNpc(document)) {
|
||||
return <NpcPrintRow npc={document} />;
|
||||
return (
|
||||
<BasicRow
|
||||
doc={document}
|
||||
title={document.data.npc.name}
|
||||
description={document.data.npc.description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSession(document)) {
|
||||
return <SessionPrintRow session={document} />;
|
||||
return (
|
||||
<BasicRow
|
||||
doc={document}
|
||||
title={document.created}
|
||||
description={document.data.session.strongStart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSecret(document)) {
|
||||
@@ -51,7 +65,7 @@ export const DocumentRow = ({
|
||||
}
|
||||
|
||||
if (isScene(document)) {
|
||||
return <ScenePrintRow scene={document} />;
|
||||
return <BasicRow doc={document} title={document.data.scene.text} />;
|
||||
}
|
||||
|
||||
if (isTreasure(document)) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { Scene } from "@/lib/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Renders an editable scene form
|
||||
*/
|
||||
export const SceneEditForm = ({ scene }: { scene: Scene }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
async function saveScene(text: string) {
|
||||
await pb.collection("documents").update(scene.id, {
|
||||
data: {
|
||||
@@ -15,6 +18,9 @@ export const SceneEditForm = ({ scene }: { scene: Scene }) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["relationship"],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FormattedDate } from "@/components/FormattedDate";
|
||||
import type { Session } from "@/lib/types";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
@@ -9,7 +10,7 @@ export const SessionRow = ({ session }: { session: Session }) => {
|
||||
params={{ documentId: session.id }}
|
||||
className="block font-semibold text-lg text-slate-300"
|
||||
>
|
||||
{session.created}
|
||||
<FormattedDate date={session.created} />
|
||||
</Link>
|
||||
<div className="">{session.data.session.strongStart}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { RelationshipType } from "./types";
|
||||
import { getDocumentType, RelationshipType, type AnyDocument } from "./types";
|
||||
|
||||
export function displayName(relationshipType: RelationshipType) {
|
||||
return relationshipType.charAt(0).toUpperCase() + relationshipType.slice(1);
|
||||
}
|
||||
|
||||
export function relationshipsForDocument(doc: AnyDocument): RelationshipType[] {
|
||||
switch (getDocumentType(doc)) {
|
||||
case "session":
|
||||
return [
|
||||
RelationshipType.Scenes,
|
||||
RelationshipType.Secrets,
|
||||
RelationshipType.Locations,
|
||||
RelationshipType.Npcs,
|
||||
RelationshipType.Monsters,
|
||||
RelationshipType.Treasures,
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,25 @@ export type AnyDocument =
|
||||
| Session
|
||||
| Treasure;
|
||||
|
||||
export function getDocumentType(doc: AnyDocument): DocumentType {
|
||||
if (isLocation(doc)) {
|
||||
return "location";
|
||||
} else if (isMonster(doc)) {
|
||||
return "monster";
|
||||
} else if (isNpc(doc)) {
|
||||
return "npc";
|
||||
} else if (isScene(doc)) {
|
||||
return "scene";
|
||||
} else if (isSecret(doc)) {
|
||||
return "secret";
|
||||
} else if (isSession(doc)) {
|
||||
return "session";
|
||||
} else if (isTreasure(doc)) {
|
||||
return "treasure";
|
||||
}
|
||||
throw new Error(`Document type not found: ${JSON.stringify(doc)}`);
|
||||
}
|
||||
|
||||
/** Locations **/
|
||||
|
||||
export type Location = Document &
|
||||
|
||||
@@ -5,8 +5,11 @@ import { SessionRow } from "@/components/documents/session/SessionRow";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import type { Relationship } from "@/lib/types";
|
||||
|
||||
export const Route = createFileRoute("/_app/_authenticated/campaigns/$campaignId")({
|
||||
export const Route = createFileRoute(
|
||||
"/_app/_authenticated/campaigns/$campaignId",
|
||||
)({
|
||||
component: RouteComponent,
|
||||
pendingComponent: Loader,
|
||||
});
|
||||
@@ -26,6 +29,7 @@ function RouteComponent() {
|
||||
// Fetch all documents for this campaign
|
||||
const docs = await pb.collection("documents").getFullList({
|
||||
filter: `campaign = "${params.campaignId}"`,
|
||||
sort: "-created",
|
||||
});
|
||||
// Filter to only those with data.session
|
||||
const sessions = docs.filter((doc: any) => doc.data && doc.data.session);
|
||||
@@ -37,7 +41,22 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
const createNewSession = useCallback(async () => {
|
||||
await pb.collection("documents").create({
|
||||
// Check for a previous session
|
||||
const prevSession = await pb
|
||||
.collection("documents")
|
||||
.getFirstListItem(
|
||||
`campaign = "${campaign.id}" && json_extract(data, '$.session') IS NOT NULL`,
|
||||
{
|
||||
sort: "-created",
|
||||
},
|
||||
);
|
||||
|
||||
console.log("Previous session: ", {
|
||||
id: prevSession.id,
|
||||
created: prevSession.created,
|
||||
});
|
||||
|
||||
const newSession = await pb.collection("documents").create({
|
||||
campaign: campaign.id,
|
||||
data: {
|
||||
session: {
|
||||
@@ -45,6 +64,31 @@ function RouteComponent() {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["campaign"] });
|
||||
|
||||
// If any, then copy things over
|
||||
if (prevSession) {
|
||||
const prevRelations = await pb
|
||||
.collection<Relationship>("relationships")
|
||||
.getFullList({
|
||||
filter: `primary = "${prevSession.id}"`,
|
||||
});
|
||||
|
||||
console.log(`Found ${prevRelations.length} previous relations`);
|
||||
|
||||
for (const relation of prevRelations) {
|
||||
console.log(
|
||||
`Adding ${relation.secondary.length} items to ${relation.type}`,
|
||||
);
|
||||
await pb.collection("relationships").create({
|
||||
primary: newSession.id,
|
||||
type: relation.type,
|
||||
seciondary: relation.secondary,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["campaign"] });
|
||||
}, [campaign]);
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_app/_authenticated/campaigns/")({
|
||||
loader: async () => {
|
||||
const records = await pb.collection("campaigns").getFullList();
|
||||
const records = await pb.collection("campaigns").getFullList({
|
||||
sort: "-created",
|
||||
});
|
||||
return {
|
||||
campaigns: records.map((rec: any) => ({
|
||||
id: rec.id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RelationshipList } from "@/components/RelationshipList";
|
||||
import { DocumentEditForm } from "@/components/documents/DocumentEditForm";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { displayName } from "@/lib/relationships";
|
||||
import { displayName, relationshipsForDocument } from "@/lib/relationships";
|
||||
import { RelationshipType, type AnyDocument } from "@/lib/types";
|
||||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
@@ -23,17 +23,10 @@ function RouteComponent() {
|
||||
document: AnyDocument;
|
||||
};
|
||||
|
||||
const relationshipList = [
|
||||
RelationshipType.Scenes,
|
||||
RelationshipType.Secrets,
|
||||
RelationshipType.Locations,
|
||||
RelationshipType.Npcs,
|
||||
RelationshipType.Monsters,
|
||||
RelationshipType.Treasures,
|
||||
];
|
||||
const relationshipList = relationshipsForDocument(document);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-2 px-4">
|
||||
<div key={document.id} className="max-w-xl mx-auto py-2 px-4">
|
||||
<Link
|
||||
to="/document/$documentId/print"
|
||||
params={{ documentId: document.id }}
|
||||
|
||||
Reference in New Issue
Block a user