Switches over to the relationship list
This commit is contained in:
42
pb_migrations/1748733166_updated_relationships.js
Normal file
42
pb_migrations/1748733166_updated_relationships.js
Normal 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)
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ import { Fragment, useState } from "react";
|
|||||||
|
|
||||||
type Props<T extends Document> = {
|
type Props<T extends Document> = {
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
|
error?: React.ReactNode;
|
||||||
items: T[];
|
items: T[];
|
||||||
renderRow: (item: T) => React.ReactNode;
|
renderRow: (item: T) => React.ReactNode;
|
||||||
newItemForm: (onSubmit: () => void) => React.ReactNode;
|
newItemForm: (onSubmit: () => void) => React.ReactNode;
|
||||||
@@ -25,6 +26,7 @@ type Props<T extends Document> = {
|
|||||||
*/
|
*/
|
||||||
export function DocumentList<T extends Document>({
|
export function DocumentList<T extends Document>({
|
||||||
title,
|
title,
|
||||||
|
error,
|
||||||
items,
|
items,
|
||||||
renderRow,
|
renderRow,
|
||||||
newItemForm,
|
newItemForm,
|
||||||
@@ -62,6 +64,9 @@ export function DocumentList<T extends Document>({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-900 rounded p-4 text-slate-100">{error}</div>
|
||||||
|
)}
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li key={item.id} className="bg-slate-800 rounded p-4 text-slate-100">
|
<li key={item.id} className="bg-slate-800 rounded p-4 text-slate-100">
|
||||||
|
|||||||
116
src/components/RelationshipList.tsx
Normal file
116
src/components/RelationshipList.tsx
Normal 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();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,125 +3,36 @@ import { pb } from "@/lib/pocketbase";
|
|||||||
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { RelationshipType, type Secret } from "@/lib/types";
|
import { RelationshipType, type Secret } from "@/lib/types";
|
||||||
import { DocumentList } from "@/components/DocumentList";
|
import { RelationshipList } from "@/components/RelationshipList";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/document/$documentId")({
|
export const Route = createFileRoute("/_authenticated/document/$documentId")({
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
const doc = await pb.collection("documents").getOne(params.documentId);
|
const doc = await pb.collection("documents").getOne(params.documentId);
|
||||||
// Fetch the unique relationship where this document is the primary and type is "plannedSecrets"
|
return { document: doc };
|
||||||
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 };
|
|
||||||
},
|
},
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { document: session, secrets } = Route.useLoaderData();
|
const { document: session } = Route.useLoaderData();
|
||||||
const strongStart = session?.data?.session?.strongStart || "";
|
const doc = session as import("@/lib/types").Document;
|
||||||
|
const strongStart = (doc.data as any)?.session?.strongStart || "";
|
||||||
const [newSecret, setNewSecret] = useState("");
|
const [newSecret, setNewSecret] = useState("");
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [secretList, setSecretList] = useState<Secret[]>(secrets);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleSaveStrongStart(newValue: string) {
|
async function handleSaveStrongStart(newValue: string) {
|
||||||
await pb.collection("documents").update(session.id, {
|
await pb.collection("documents").update(doc.id, {
|
||||||
data: {
|
data: {
|
||||||
...session.data,
|
...doc.data,
|
||||||
session: {
|
session: {
|
||||||
...session.data.session,
|
...(doc.data as any).session,
|
||||||
strongStart: newValue,
|
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 (
|
return (
|
||||||
<div className="max-w-xl mx-auto py-8">
|
<div className="max-w-xl mx-auto py-8">
|
||||||
<h2 className="text-2xl font-bold mb-4 text-slate-100">
|
<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..."
|
placeholder="Enter a strong start for this session..."
|
||||||
aria-label="Strong Start"
|
aria-label="Strong Start"
|
||||||
/>
|
/>
|
||||||
{secretList && (
|
<RelationshipList
|
||||||
<DocumentList
|
root={doc}
|
||||||
title="Secrets and Clues"
|
relationshipType={RelationshipType.Secrets}
|
||||||
items={secretList}
|
renderRow={(secret) => (
|
||||||
newItemForm={(onSubmit) => (
|
<div className="flex items-center gap-3">
|
||||||
<form
|
<input
|
||||||
className="flex items-center gap-2 mt-4"
|
type="checkbox"
|
||||||
onSubmit={(e) => {
|
checked={!!(secret.data as any)?.secret?.discovered}
|
||||||
e.preventDefault();
|
onChange={async (e) => {
|
||||||
handleAddSecret();
|
const checked = e.target.checked;
|
||||||
onSubmit();
|
await pb.collection("documents").update(secret.id, {
|
||||||
}}
|
data: {
|
||||||
>
|
...secret.data,
|
||||||
<input
|
secret: {
|
||||||
type="text"
|
...(secret.data as any).secret,
|
||||||
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"
|
discovered: checked,
|
||||||
placeholder="Add a new secret..."
|
},
|
||||||
value={newSecret}
|
},
|
||||||
onChange={(e) => setNewSecret(e.target.value)}
|
});
|
||||||
disabled={adding}
|
// Remove any existing discoveredIn relationship
|
||||||
/>
|
const rels = await pb
|
||||||
{error && (
|
.collection("relationships")
|
||||||
<div className="text-red-400 mt-2 text-sm">{error}</div>
|
.getList(1, 1, {
|
||||||
)}
|
filter: `primary = "${secret.id}" && type = "discoveredIn"`,
|
||||||
<button
|
});
|
||||||
type="submit"
|
if (rels.items.length > 0) {
|
||||||
className="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 text-white font-semibold transition-colors disabled:opacity-60"
|
await pb.collection("relationships").delete(rels.items[0].id);
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
className="accent-emerald-500 w-5 h-5"
|
if (checked) {
|
||||||
aria-label="Discovered"
|
await pb.collection("relationships").create({
|
||||||
/>
|
primary: secret.id,
|
||||||
<span>
|
secondary: [doc.id],
|
||||||
{secret.data?.secret?.text || (
|
type: "discoveredIn",
|
||||||
<span className="italic text-slate-400">
|
});
|
||||||
(No secret text)
|
}
|
||||||
</span>
|
}}
|
||||||
)}
|
className="accent-emerald-500 w-5 h-5"
|
||||||
</span>
|
aria-label="Discovered"
|
||||||
</li>
|
/>
|
||||||
)}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user