Switches over to the relationship list

This commit is contained in:
2025-05-31 16:30:18 -07:00
parent 6336b150a7
commit 5eba132bda
4 changed files with 266 additions and 154 deletions

View File

@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_617371094")
// update field
collection.fields.addAt(3, new Field({
"hidden": false,
"id": "select2363381545",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"discoveredIn",
"secrets"
]
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_617371094")
// update field
collection.fields.addAt(3, new Field({
"hidden": false,
"id": "select2363381545",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"discoveredIn",
"plannedSecrets"
]
}))
return app.save(collection)
})

View File

@@ -10,6 +10,7 @@ import { Fragment, useState } from "react";
type Props<T extends Document> = {
title: React.ReactNode;
error?: React.ReactNode;
items: T[];
renderRow: (item: T) => React.ReactNode;
newItemForm: (onSubmit: () => void) => React.ReactNode;
@@ -25,6 +26,7 @@ type Props<T extends Document> = {
*/
export function DocumentList<T extends Document>({
title,
error,
items,
renderRow,
newItemForm,
@@ -62,6 +64,9 @@ export function DocumentList<T extends Document>({
</svg>
</button>
</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">

View File

@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { pb } from "@/lib/pocketbase";
import type { Document } from "@/lib/types";
import { DocumentList } from "@/components/DocumentList";
import { Loader } from "./Loader";
interface RelationshipListProps<T extends Document> {
root: Document;
relationshipType: string;
renderRow: (item: T) => React.ReactNode;
newItemForm: (onCreate: (doc: T) => Promise<void>) => React.ReactNode;
}
/**
* RelationshipList manages a list of documents related to a root document via a relationship type.
* It handles fetching, creation, and relationship management, and renders a DocumentList.
*/
export function RelationshipList<T extends Document>({
root,
relationshipType,
renderRow,
newItemForm,
}: RelationshipListProps<T>) {
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch related documents on mount or when root/relationshipType changes
useEffect(() => {
let cancelled = false;
async function fetchRelated() {
setLoading(true);
setError(null);
try {
const relationships = await pb
.collection("relationships")
.getList(1, 1, {
filter: `primary = "${root.id}" && type = "${relationshipType}"`,
});
const secondaryIds =
relationships.items.length > 0
? relationships.items[0].secondary
: [];
let docs: T[] = [];
if (Array.isArray(secondaryIds) && secondaryIds.length > 0) {
docs = (await pb.collection("documents").getFullList({
filter: secondaryIds
.map((id: string) => `id = "${id}"`)
.join(" || "),
})) as T[];
}
if (!cancelled) setItems(docs);
} catch (e: any) {
if (!cancelled)
setError(e?.message || "Failed to load related documents.");
} finally {
if (!cancelled) setLoading(false);
}
}
fetchRelated();
return () => {
cancelled = true;
};
}, [root.id, relationshipType]);
// Handles creation of a new document and adds it to the relationship
const handleCreate = async (doc: T) => {
setLoading(true);
setError(null);
try {
// Check for existing relationship
const existing = await pb.collection("relationships").getFullList({
filter: `primary = "${root.id}" && type = "${relationshipType}"`,
});
if (existing.length > 0) {
console.debug("Adding to existing relationship");
await pb.collection("relationships").update(existing[0].id, {
"+secondary": doc.id,
});
} else {
console.debug("Creating new relationship");
await pb.collection("relationships").create({
primary: root.id,
secondary: [doc.id],
type: relationshipType,
});
}
setItems((prev) => [...prev, doc]);
} catch (e: any) {
setError(e?.message || "Failed to add document to relationship.");
} finally {
setLoading(false);
}
};
if (loading) {
<Loader />;
}
return (
<DocumentList
title={
relationshipType.charAt(0).toUpperCase() + relationshipType.slice(1)
}
items={items}
error={error}
renderRow={renderRow}
newItemForm={(onSubmit) =>
newItemForm(async (doc: T) => {
await handleCreate(doc);
onSubmit();
})
}
/>
);
}

View File

@@ -3,125 +3,36 @@ import { pb } from "@/lib/pocketbase";
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { useState } from "react";
import { RelationshipType, type Secret } from "@/lib/types";
import { DocumentList } from "@/components/DocumentList";
import { RelationshipList } from "@/components/RelationshipList";
export const Route = createFileRoute("/_authenticated/document/$documentId")({
loader: async ({ params }) => {
const doc = await pb.collection("documents").getOne(params.documentId);
// Fetch the unique relationship where this document is the primary and type is "plannedSecrets"
const relationships = await pb.collection("relationships").getList(1, 1, {
filter: `primary = "${params.documentId}" && type = "${RelationshipType.Secrets}"`,
});
// Get all related secret document IDs from the secondary field
const secretIds =
relationships.items.length > 0 ? relationships.items[0].secondary : [];
// Fetch all related secret documents
let secrets: any[] = [];
if (Array.isArray(secretIds) && secretIds.length > 0) {
secrets = await pb.collection("documents").getFullList({
filter: secretIds.map((id) => `id = "${id}"`).join(" || "),
});
}
return { document: doc, secrets };
return { document: doc };
},
component: RouteComponent,
});
function RouteComponent() {
const { document: session, secrets } = Route.useLoaderData();
const strongStart = session?.data?.session?.strongStart || "";
const { document: session } = Route.useLoaderData();
const doc = session as import("@/lib/types").Document;
const strongStart = (doc.data as any)?.session?.strongStart || "";
const [newSecret, setNewSecret] = useState("");
const [adding, setAdding] = useState(false);
const [secretList, setSecretList] = useState<Secret[]>(secrets);
const [error, setError] = useState<string | null>(null);
async function handleSaveStrongStart(newValue: string) {
await pb.collection("documents").update(session.id, {
await pb.collection("documents").update(doc.id, {
data: {
...session.data,
...doc.data,
session: {
...session.data.session,
...(doc.data as any).session,
strongStart: newValue,
},
},
});
}
async function handleAddSecret() {
if (!newSecret.trim()) return;
setAdding(true);
setError(null);
try {
// 1. Create the secret document
const secretDoc = await pb.collection("documents").create({
campaign: session.campaign, // assuming campaign is an id or object
data: {
secret: {
text: newSecret,
discoveredOn: null,
},
},
});
// 2. Check for existing relationship
const existing = await pb.collection("relationships").getFullList({
filter: `primary = "${session.id}" && type = "${RelationshipType.Secrets}"`,
});
if (existing.length > 0) {
// Update existing relationship to add new secret to secondary array
await pb.collection("relationships").update(existing[0].id, {
"+secondary": secretDoc.id,
});
} else {
// Create new relationship
await pb.collection("relationships").create({
primary: session.id,
secondary: [secretDoc.id],
type: RelationshipType.Secrets,
});
}
setSecretList([...secretList, secretDoc]);
setNewSecret("");
} catch (e: any) {
setError(e?.message || "Failed to add secret.");
} finally {
setAdding(false);
}
}
async function handleToggleDiscovered(secret: Secret, checked: boolean) {
// 1. Update the discovered field in the secret document
const updatedSecret: Secret = await pb
.collection("documents")
.update(secret.id, {
data: {
...secret.data,
secret: {
text: secret.data.secret.text,
discovered: checked,
},
},
});
// 2. Remove any existing discoveredIn relationship
const rels = await pb.collection("relationships").getList(1, 1, {
filter: `primary = "${secret.id}" && type = "discoveredIn"`,
});
if (rels.items.length > 0) {
await pb.collection("relationships").delete(rels.items[0].id);
}
// 3. If marking as discovered, add a new discoveredIn relationship
if (checked) {
await pb.collection("relationships").create({
primary: secret.id,
secondary: [session.id],
type: "discoveredIn",
});
}
// 4. Update local state
setSecretList(
secretList.map((s: any) => (s.id === secret.id ? updatedSecret : s)),
);
}
return (
<div className="max-w-xl mx-auto py-8">
<h2 className="text-2xl font-bold mb-4 text-slate-100">
@@ -133,64 +44,102 @@ function RouteComponent() {
placeholder="Enter a strong start for this session..."
aria-label="Strong Start"
/>
{secretList && (
<DocumentList
title="Secrets and Clues"
items={secretList}
newItemForm={(onSubmit) => (
<form
className="flex items-center gap-2 mt-4"
onSubmit={(e) => {
e.preventDefault();
handleAddSecret();
onSubmit();
}}
>
<input
type="text"
className="flex-1 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"
placeholder="Add a new secret..."
value={newSecret}
onChange={(e) => setNewSecret(e.target.value)}
disabled={adding}
/>
{error && (
<div className="text-red-400 mt-2 text-sm">{error}</div>
)}
<button
type="submit"
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
disabled={adding || !newSecret.trim()}
>
{adding ? "Adding..." : "Add Secret"}
</button>
</form>
)}
renderRow={(secret) => (
<li
key={secret.id}
className="bg-slate-800 rounded p-4 text-slate-100 flex items-center gap-3"
>
<input
type="checkbox"
checked={!!secret.data?.secret?.discovered}
onChange={(e) =>
handleToggleDiscovered(secret, e.target.checked)
<RelationshipList
root={doc}
relationshipType={RelationshipType.Secrets}
renderRow={(secret) => (
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={!!(secret.data as any)?.secret?.discovered}
onChange={async (e) => {
const checked = e.target.checked;
await pb.collection("documents").update(secret.id, {
data: {
...secret.data,
secret: {
...(secret.data as any).secret,
discovered: checked,
},
},
});
// Remove any existing discoveredIn relationship
const rels = await pb
.collection("relationships")
.getList(1, 1, {
filter: `primary = "${secret.id}" && type = "discoveredIn"`,
});
if (rels.items.length > 0) {
await pb.collection("relationships").delete(rels.items[0].id);
}
className="accent-emerald-500 w-5 h-5"
aria-label="Discovered"
/>
<span>
{secret.data?.secret?.text || (
<span className="italic text-slate-400">
(No secret text)
</span>
)}
</span>
</li>
)}
/>
)}
if (checked) {
await pb.collection("relationships").create({
primary: secret.id,
secondary: [doc.id],
type: "discoveredIn",
});
}
}}
className="accent-emerald-500 w-5 h-5"
aria-label="Discovered"
/>
<span>
{(secret.data as any)?.secret?.text || (
<span className="italic text-slate-400">(No secret text)</span>
)}
</span>
</div>
)}
newItemForm={(onCreate) => (
<form
className="flex items-center gap-2 mt-4"
onSubmit={async (e) => {
e.preventDefault();
if (!newSecret.trim()) return;
setAdding(true);
setError(null);
try {
console.debug("Creating new secret");
// Create the secret document
const secretDoc: Secret = await pb
.collection("documents")
.create({
campaign: doc.campaign,
data: {
secret: {
text: newSecret,
discovered: false,
},
},
});
setNewSecret("");
await onCreate(secretDoc);
} catch (e: any) {
setError(e?.message || "Failed to add secret.");
} finally {
setAdding(false);
}
}}
>
<input
type="text"
className="flex-1 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"
placeholder="Add a new secret..."
value={newSecret}
onChange={(e) => setNewSecret(e.target.value)}
disabled={adding}
/>
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
<button
type="submit"
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
disabled={adding || !newSecret.trim()}
>
{adding ? "Adding..." : "Add Secret"}
</button>
</form>
)}
/>
</div>
);
}