Threads done with generic forms.

This commit is contained in:
2025-09-24 15:52:02 -07:00
parent 6979bc4b8f
commit ab323798e9
6 changed files with 106 additions and 99 deletions

View File

@@ -1,6 +1,6 @@
import { makeDocumentPath, useDocumentPath } from "@/lib/documentPath"; import { makeDocumentPath } from "@/lib/documentPath";
import type { DocumentId } from "@/lib/types"; import type { DocumentId } from "@/lib/types";
import { Link, useParams, useSearch } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
export type Props = React.PropsWithChildren<{ export type Props = React.PropsWithChildren<{
childDocId: DocumentId; childDocId: DocumentId;

View File

@@ -7,6 +7,7 @@ import {
type DocumentField, type DocumentField,
type FieldType, type FieldType,
} from "@/lib/fields"; } from "@/lib/fields";
import { ToggleInput } from "../form/ToggleInput";
export type GenericFieldType = "multiline" | "singleline" | "checkbox"; export type GenericFieldType = "multiline" | "singleline" | "checkbox";
@@ -70,12 +71,11 @@ const GenericEditFormField = <T extends AnyDocument>({
); );
case "toggle": case "toggle":
return ( return (
<input <ToggleInput
type="checkbox" label={field.name}
checked={!!field.getter(data)} value={!!field.getter(data)}
onChange={(e) => saveField(!!e.target.value)} onChange={saveField}
className="accent-emerald-500 w-5 h-5" placeholder={field.name}
id={field.name}
/> />
); );
} }

View File

@@ -1,5 +1,5 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { useDocumentCache } from "@/context/document/hooks"; import { useDocumentCache } from "@/context/document/hooks";
import { DocumentTypeLabel } from "@/lib/documents";
import { import {
getFieldsForType, getFieldsForType,
type DocumentField, type DocumentField,
@@ -14,6 +14,10 @@ import {
type DocumentType, type DocumentType,
} from "@/lib/types"; } from "@/lib/types";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { BaseForm } from "../form/BaseForm";
import { MultiLineInput } from "../form/MultiLineInput";
import { SingleLineInput } from "../form/SingleLineInput";
import { ToggleInput } from "../form/ToggleInput";
export type GenericFieldType = "multiline" | "singleline" | "checkbox"; export type GenericFieldType = "multiline" | "singleline" | "checkbox";
@@ -69,83 +73,68 @@ export const GenericNewDocumentForm = <T extends DocumentType>({
setIsLoading(false); setIsLoading(false);
}, [campaignId, setIsLoading, setError, docData]); }, [campaignId, setIsLoading, setError, docData]);
// TODO: display name for docType
return ( return (
<div className=""> <BaseForm
{error && ( title={`Create new ${DocumentTypeLabel[docType]}`}
// TODO: class and style for errors onSubmit={saveData}
<div className="error">{error}</div> isLoading={isLoading}
)} error={error}
{ content={
// The type checker seems to lose the types when using Object.entries here. // The type checker seems to lose the types when using Object.entries here.
fields.map((field) => ( fields.map((field) => (
<GenericNewFormField <GenericNewFormField
field={field} field={field}
value={field.getter(docData)} value={field.getter(docData)}
isLoading={isLoading}
onUpdate={updateData(field)} onUpdate={updateData(field)}
/> />
)) ))
} }
<button disabled={isLoading} onClick={saveData}> />
Save
</button>
</div>
); );
}; };
const GenericNewFormField = <T extends DocumentType, F extends FieldType>({ const GenericNewFormField = <T extends DocumentType, F extends FieldType>({
field, field,
value, value,
isLoading,
onUpdate, onUpdate,
}: { }: {
field: DocumentField<T, F>; field: DocumentField<T, F>;
value: ValueForFieldType<F>; value: ValueForFieldType<F>;
onUpdate: (value: ValueForFieldType<F>) => void; isLoading: boolean;
}) => {
return (
<div>
<label htmlFor={field.name}>{field.name}</label>
<GenericNewFormInput field={field} value={value} onUpdate={onUpdate} />
</div>
);
};
const GenericNewFormInput = <T extends DocumentType, F extends FieldType>({
field,
value,
onUpdate,
}: {
field: DocumentField<T, F>;
value: ValueForFieldType<F>;
onUpdate: (value: ValueForFieldType<F>) => void; onUpdate: (value: ValueForFieldType<F>) => void;
}) => { }) => {
switch (field.fieldType) { switch (field.fieldType) {
case "longText": case "longText":
return ( return (
<textarea <MultiLineInput
label={field.name}
value={value as string} value={value as string}
onChange={(e) => onChange={onUpdate as (v: string) => void}
onUpdate((e.target.value || "") as ValueForFieldType<F>) disabled={isLoading}
} placeholder={field.name}
/> />
); );
case "shortText": case "shortText":
return ( return (
<input <SingleLineInput
type="text" label={field.name}
value={value as string} value={value as string}
onChange={(e) => onChange={onUpdate as (v: string) => void}
onUpdate((e.target.value || "") as ValueForFieldType<F>) disabled={isLoading}
} placeholder={field.name}
/> />
); );
case "toggle": case "toggle":
return ( return (
<input <ToggleInput
type="checkbox" label={field.name}
checked={value as boolean} value={value as boolean}
onChange={(e) => onChange={onUpdate as (v: boolean) => void}
(onUpdate as (value: boolean) => void)(!!e.target.value) disabled={isLoading}
} placeholder={field.name}
/> />
); );
} }

View File

@@ -1,5 +1,3 @@
import { BaseForm } from "@/components/form/BaseForm";
import { SingleLineInput } from "@/components/form/SingleLineInput";
import { useDocumentCache } from "@/context/document/hooks"; import { useDocumentCache } from "@/context/document/hooks";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import type { import type {
@@ -8,7 +6,8 @@ import type {
Relationship, Relationship,
Session, Session,
} from "@/lib/types"; } from "@/lib/types";
import { useCallback, useState } from "react"; import { useCallback } from "react";
import { GenericNewDocumentForm } from "../GenericNewDocumentForm";
export type Props = { export type Props = {
campaignId: CampaignId; campaignId: CampaignId;
@@ -16,14 +15,10 @@ export type Props = {
}; };
export const NewSessionForm = ({ campaignId, onCreate }: Props) => { export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState<string>("");
const { dispatch } = useDocumentCache(); const { dispatch } = useDocumentCache();
const createNewSession = useCallback(async () => { const createSessionRelations = useCallback(
setIsLoading(true); async (newSession: Session) => {
try {
// Check for a previous session // Check for a previous session
const prevSession = await pb const prevSession = await pb
.collection("documents") .collection("documents")
@@ -31,15 +26,6 @@ export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
sort: "-created", sort: "-created",
}); });
const newSession: Session = await pb.collection("documents").create({
campaign: campaignId,
type: "session",
data: {
name,
strongStart: "",
},
});
// If any relations, then copy things over // If any relations, then copy things over
if (prevSession) { if (prevSession) {
const prevRelations = await pb const prevRelations = await pb
@@ -56,38 +42,16 @@ export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
}); });
} }
} }
dispatch({ await onCreate(newSession);
type: "setDocument", },
doc: newSession, [campaignId, dispatch],
}); );
await onCreate?.(newSession);
} catch (e: unknown) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("An unknown error occurred while creating the session.");
}
}
setIsLoading(false);
}, [campaignId, name, dispatch, setIsLoading, setError]);
return ( return (
<BaseForm <GenericNewDocumentForm
title="Create new session" docType="session"
onSubmit={createNewSession} campaignId={campaignId}
isLoading={isLoading} onCreate={createSessionRelations}
error={error}
content={
<>
<SingleLineInput
label="Name"
value={name}
onChange={setName}
disabled={isLoading}
placeholder="Enter session name"
/>
</>
}
/> />
); );
}; };

View File

@@ -0,0 +1,29 @@
export type Props = {
value: boolean;
onChange: (value: boolean) => void;
label?: string;
className?: string;
} & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "className"
>;
export const ToggleInput = ({
value,
onChange,
className = "",
label,
...props
}: Props) => (
<div className="flex flex-row gap-4 p-2">
<input
type="checkbox"
checked={value}
onChange={(e) => onChange(e.target.checked)}
className={`rounded border bg-slate-800 text-slate-100 border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500 transition-colors ${className}`}
aria-label={label}
{...props}
/>
{label && <label>{label}</label>}
</div>
);

25
src/lib/documents.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { DocumentType } from "./types";
export const DocumentTypeLabel: Record<DocumentType, string> = {
session: "Session",
secret: "Secret",
npc: "NPC",
location: "Location",
thread: "Thread",
front: "Front",
monster: "Monster",
scene: "Scene",
treasure: "Treasure",
};
export const DocumentTypeLabePlural: Record<DocumentType, string> = {
session: "Sessions",
secret: "Secrets",
npc: "NPCs",
location: "Locations",
thread: "Threads",
front: "Fronts",
monster: "Monsters",
scene: "Scenes",
treasure: "Treasures",
};