Converts secrets list to something more generic
This commit is contained in:
115
src/components/DocumentList.tsx
Normal file
115
src/components/DocumentList.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Document } from "@/lib/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from "@headlessui/react";
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
type Props<T extends Document> = {
|
||||
title: React.ReactNode;
|
||||
items: T[];
|
||||
renderRow: (item: T) => React.ReactNode;
|
||||
newItemForm: (onSubmit: () => void) => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* DocumentList is a generic list component for displaying document items with a dialog for adding new items.
|
||||
*
|
||||
* @param title - The title displayed above the list (left-aligned)
|
||||
* @param items - The array of document items to display
|
||||
* @param renderRow - Function to render each row's content
|
||||
* @param newItemForm - Function that renders a form for creating a new item; receives an onSubmit callback
|
||||
*/
|
||||
export function DocumentList<T extends Document>({
|
||||
title,
|
||||
items,
|
||||
renderRow,
|
||||
newItemForm,
|
||||
}: Props<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Handles closing the dialog after form submission
|
||||
const handleFormSubmit = (): void => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="w-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-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-9 h-9 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"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={setOpen}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" />
|
||||
</TransitionChild>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="bg-slate-900 rounded-lg shadow-xl max-w-md w-full p-6 border border-slate-700 relative">
|
||||
<DialogTitle className="text-lg font-semibold text-slate-100 mb-4">
|
||||
Add New
|
||||
</DialogTitle>
|
||||
{newItemForm(handleFormSubmit)}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3 right-3 text-slate-400 hover:text-red-400 focus:outline-none"
|
||||
aria-label="Close dialog"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -41,8 +41,13 @@ export type Secret = Document &
|
||||
}
|
||||
>;
|
||||
|
||||
export const RelationshipType = {
|
||||
Secrets: "secrets",
|
||||
DiscoveredIn: "discoveredIn",
|
||||
} as const;
|
||||
|
||||
export type Relationship = RecordModel & {
|
||||
primary: DocumentId;
|
||||
secondary: DocumentId[];
|
||||
type: "plannedSecrets" | "discoveredIn";
|
||||
type: (typeof RelationshipType)[keyof typeof RelationshipType];
|
||||
};
|
||||
|
||||
@@ -2,14 +2,15 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
|
||||
import { useState } from "react";
|
||||
import type { Secret } from "@/lib/types";
|
||||
import { RelationshipType, type Secret } from "@/lib/types";
|
||||
import { DocumentList } from "@/components/DocumentList";
|
||||
|
||||
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 = "plannedSecrets"`,
|
||||
filter: `primary = "${params.documentId}" && type = "${RelationshipType.Secrets}"`,
|
||||
});
|
||||
// Get all related secret document IDs from the secondary field
|
||||
const secretIds =
|
||||
@@ -31,7 +32,7 @@ function RouteComponent() {
|
||||
const strongStart = session?.data?.session?.strongStart || "";
|
||||
const [newSecret, setNewSecret] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [secretList, setSecretList] = useState(secrets);
|
||||
const [secretList, setSecretList] = useState<Secret[]>(secrets);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSaveStrongStart(newValue: string) {
|
||||
@@ -63,7 +64,7 @@ function RouteComponent() {
|
||||
});
|
||||
// 2. Check for existing relationship
|
||||
const existing = await pb.collection("relationships").getFullList({
|
||||
filter: `primary = "${session.id}" && type = "plannedSecrets"`,
|
||||
filter: `primary = "${session.id}" && type = "${RelationshipType.Secrets}"`,
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
// Update existing relationship to add new secret to secondary array
|
||||
@@ -75,7 +76,7 @@ function RouteComponent() {
|
||||
await pb.collection("relationships").create({
|
||||
primary: session.id,
|
||||
secondary: [secretDoc.id],
|
||||
type: "plannedSecrets",
|
||||
type: RelationshipType.Secrets,
|
||||
});
|
||||
}
|
||||
setSecretList([...secretList, secretDoc]);
|
||||
@@ -132,12 +133,40 @@ function RouteComponent() {
|
||||
placeholder="Enter a strong start for this session..."
|
||||
aria-label="Strong Start"
|
||||
/>
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-slate-200">
|
||||
Planned Secrets
|
||||
</h3>
|
||||
{secretList && secretList.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{secretList.map((secret: any) => (
|
||||
{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"
|
||||
@@ -159,37 +188,9 @@ function RouteComponent() {
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-slate-400">
|
||||
No planned secrets for this session.
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
className="flex items-center gap-2 mt-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddSecret();
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
{error && <div className="text-red-400 mt-2 text-sm">{error}</div>}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user