Compare commits

...

4 Commits

10 changed files with 399 additions and 132 deletions

View File

@@ -28,14 +28,6 @@ export const DocumentEditForm = ({ document }: { document: AnyDocument }) => {
case "treasure":
return <TreasureEditForm treasure={document} />;
case "thread":
return (
<GenericEditForm
doc={document}
fields={{
text: "multiline",
resolved: "checkbox",
}}
/>
);
return <GenericEditForm doc={document} />;
}
};

View File

@@ -1,6 +1,6 @@
import { makeDocumentPath, useDocumentPath } from "@/lib/documentPath";
import { makeDocumentPath } from "@/lib/documentPath";
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<{
childDocId: DocumentId;
@@ -8,37 +8,45 @@ export type Props = React.PropsWithChildren<{
}>;
export function DocumentLink({ childDocId, className, children }: Props) {
const docPath = useDocumentPath();
// const docPath = useDocumentPath();
//
// const params = useParams({
// strict: false,
// });
//
// const campaignSearch = useSearch({
// from: "/_app/_authenticated/campaigns/$campaignId",
// shouldThrow: false,
// });
//
// const to = params.campaignId
// ? `/campaigns/${params.campaignId}`
// : docPath
// ? makeDocumentPath(
// docPath.documentId,
// docPath?.relationshipType,
// childDocId,
// )
// : undefined;
//
// const search = campaignSearch
// ? { tab: campaignSearch.tab, docId: childDocId }
// : undefined;
//
// if (to === undefined) {
// throw new Error("Not in a document or campaign context");
// }
//
// return (
// <Link to={to} search={search} className={className}>
// {children}
// </Link>
// );
const params = useParams({
strict: false,
});
const campaignSearch = useSearch({
from: "/_app/_authenticated/campaigns/$campaignId",
shouldThrow: false,
});
const to = params.campaignId
? `/campaigns/${params.campaignId}`
: docPath
? makeDocumentPath(
docPath.documentId,
docPath?.relationshipType,
childDocId,
)
: undefined;
const search = campaignSearch
? { tab: campaignSearch.tab, docId: childDocId }
: undefined;
if (to === undefined) {
throw new Error("Not in a document or campaign context");
}
const to = makeDocumentPath(childDocId);
return (
<Link to={to} search={search} className={className}>
<Link to={to} className={className}>
{children}
</Link>
);

View File

@@ -1,33 +1,31 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { pb } from "@/lib/pocketbase";
import type { AnyDocument, Location } from "@/lib/types";
import { getDocumentType, type AnyDocument } from "@/lib/types";
import { useDocumentCache } from "@/context/document/hooks";
import {
getFieldsForType,
type DocumentField,
type FieldType,
} from "@/lib/fields";
import { ToggleInput } from "../form/ToggleInput";
export type GenericFieldType = "multiline" | "singleline" | "checkbox";
export type Props<T extends AnyDocument> = {
doc: T;
fields: { [K in keyof T["data"]]: GenericFieldType };
};
export const GenericEditForm = <T extends AnyDocument>({
doc,
fields,
}: Props<T>) => {
export const GenericEditForm = <T extends AnyDocument>({ doc }: Props<T>) => {
const docType = getDocumentType(doc) as T["type"];
const fields = getFieldsForType(docType);
return (
<div className="">
{
// The type checker seems to lose the types when using Object.entries here.
Object.entries(fields).map(
([fieldName, fieldType]: [string, unknown]) => (
<GenericEditFormField
key={fieldName}
doc={doc}
fieldName={fieldName as keyof T["data"]}
fieldType={fieldType as GenericFieldType}
/>
),
)
fields.map((documentField) => (
<GenericEditFormField doc={doc} field={documentField} />
))
}
</div>
);
@@ -35,52 +33,49 @@ export const GenericEditForm = <T extends AnyDocument>({
const GenericEditFormField = <T extends AnyDocument>({
doc,
fieldName,
fieldType,
field,
}: {
doc: T;
fieldName: keyof T["data"];
fieldType: GenericFieldType;
field: DocumentField<T["type"], FieldType>;
}) => {
const { dispatch } = useDocumentCache();
// The type checker really doesn't like indexing into this type implicitly, so we'll store it in a temporary to give it the right hints.
const data = doc.data as T["data"];
async function saveField(value: string) {
async function saveField(value: string | boolean) {
const updated: T = await pb.collection("documents").update(doc.id, {
data: {
...doc.data,
[fieldName]: value,
},
data: field.setter(value, doc.data),
});
dispatch({ type: "setDocument", doc: updated });
}
switch (fieldType) {
case "multiline":
switch (field.fieldType) {
case "longText":
return (
<AutoSaveTextarea
multiline={true}
value={data[fieldName] as string}
value={field.getter(data) as string}
onSave={saveField}
id={field.name}
/>
);
case "singleline":
case "shortText":
return (
<AutoSaveTextarea
multiline={false}
value={data[fieldName] as string}
value={field.getter(data) as string}
onSave={saveField}
id={field.name}
/>
);
case "checkbox":
case "toggle":
return (
<input
type="checkbox"
checked={data[fieldName] as boolean}
onChange={(e) => saveField(e.target.value)}
className="accent-emerald-500 w-5 h-5"
<ToggleInput
label={field.name}
value={!!field.getter(data)}
onChange={saveField}
placeholder={field.name}
/>
);
}

View File

@@ -0,0 +1,141 @@
import { useDocumentCache } from "@/context/document/hooks";
import { DocumentTypeLabel } from "@/lib/documents";
import {
getFieldsForType,
type DocumentField,
type FieldType,
type ValueForFieldType,
} from "@/lib/fields";
import { pb } from "@/lib/pocketbase";
import {
type CampaignId,
type DocumentData,
type DocumentsByType,
type DocumentType,
} from "@/lib/types";
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 Props<T extends DocumentType> = {
docType: T;
campaignId: CampaignId;
onCreate: (doc: DocumentsByType[T]) => Promise<void>;
};
export const GenericNewDocumentForm = <T extends DocumentType>({
docType,
campaignId,
onCreate,
}: Props<T>) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { dispatch } = useDocumentCache();
const fields = getFieldsForType(docType);
const [docData, setDocData] = useState<DocumentData<T>>(
fields.reduce((d, f) => f.setDefault(d), {} as DocumentData<T>),
);
const updateData =
<F extends FieldType>(field: DocumentField<T, F>) =>
(value: ValueForFieldType<F>) =>
setDocData(field.setter(value, docData));
const saveData = useCallback(async () => {
setIsLoading(true);
console.log(`Creating ${docType}: `, docData);
try {
const newDocument: DocumentsByType[T] = await pb
.collection("documents")
.create({
campaign: campaignId,
type: docType,
data: docData,
});
await onCreate(newDocument);
dispatch({
type: "setDocument",
doc: newDocument,
});
} catch (e: unknown) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("An unknown error occurred while creating the session.");
}
}
setIsLoading(false);
}, [campaignId, setIsLoading, setError, docData]);
// TODO: display name for docType
return (
<BaseForm
title={`Create new ${DocumentTypeLabel[docType]}`}
onSubmit={saveData}
isLoading={isLoading}
error={error}
content={
// The type checker seems to lose the types when using Object.entries here.
fields.map((field) => (
<GenericNewFormField
field={field}
value={field.getter(docData)}
isLoading={isLoading}
onUpdate={updateData(field)}
/>
))
}
/>
);
};
const GenericNewFormField = <T extends DocumentType, F extends FieldType>({
field,
value,
isLoading,
onUpdate,
}: {
field: DocumentField<T, F>;
value: ValueForFieldType<F>;
isLoading: boolean;
onUpdate: (value: ValueForFieldType<F>) => void;
}) => {
switch (field.fieldType) {
case "longText":
return (
<MultiLineInput
label={field.name}
value={value as string}
onChange={onUpdate as (v: string) => void}
disabled={isLoading}
placeholder={field.name}
/>
);
case "shortText":
return (
<SingleLineInput
label={field.name}
value={value as string}
onChange={onUpdate as (v: string) => void}
disabled={isLoading}
placeholder={field.name}
/>
);
case "toggle":
return (
<ToggleInput
label={field.name}
value={value as boolean}
onChange={onUpdate as (v: boolean) => void}
disabled={isLoading}
placeholder={field.name}
/>
);
}
};

View File

@@ -4,6 +4,7 @@ import {
type DocumentType,
} from "@/lib/types";
import { NewSessionForm } from "./session/NewSessionForm";
import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
/**
* Renders a form for any document type depending on the relationship.
@@ -21,7 +22,14 @@ export const NewCampaignDocumentForm = ({
case "session":
return <NewSessionForm campaignId={campaignId} onCreate={onCreate} />;
case "thread":
return <NewThreadForm campaignId={campaignId} onCreate={onCreate} />;
case "location":
return (
<GenericNewDocumentForm
docType={docType}
campaignId={campaignId}
onCreate={onCreate}
/>
);
default:
throw new Error(
`Rendered NewCampaignDocumentForm with unsupported docType: ${docType}`,

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 { pb } from "@/lib/pocketbase";
import type {
@@ -8,7 +6,8 @@ import type {
Relationship,
Session,
} from "@/lib/types";
import { useCallback, useState } from "react";
import { useCallback } from "react";
import { GenericNewDocumentForm } from "../GenericNewDocumentForm";
export type Props = {
campaignId: CampaignId;
@@ -16,17 +15,10 @@ export type 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();
console.log("Rendering with name: ", name);
const createNewSession = useCallback(async () => {
setIsLoading(true);
console.log("Creating session: ", name);
try {
const createSessionRelations = useCallback(
async (newSession: Session) => {
// Check for a previous session
const prevSession = await pb
.collection("documents")
@@ -34,15 +26,6 @@ export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
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 (prevSession) {
const prevRelations = await pb
@@ -59,38 +42,16 @@ export const NewSessionForm = ({ campaignId, onCreate }: Props) => {
});
}
}
dispatch({
type: "setDocument",
doc: newSession,
});
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]);
await onCreate(newSession);
},
[campaignId, dispatch],
);
return (
<BaseForm
title="Create new session"
onSubmit={createNewSession}
isLoading={isLoading}
error={error}
content={
<>
<SingleLineInput
label="Name"
value={name}
onChange={setName}
disabled={isLoading}
placeholder="Enter session name"
/>
</>
}
<GenericNewDocumentForm
docType="session"
campaignId={campaignId}
onCreate={createSessionRelations}
/>
);
};

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",
};

96
src/lib/fields.ts Normal file
View File

@@ -0,0 +1,96 @@
import { type DocumentData, type DocumentType } from "./types";
export type FieldType = "shortText" | "longText" | "toggle";
export type ValueForFieldType<F extends FieldType> = {
shortText: string;
longText: string;
toggle: boolean;
}[F];
function defaultValue<F extends FieldType>(fieldType: F): ValueForFieldType<F> {
switch (fieldType) {
case "shortText":
case "longText":
return "" as ValueForFieldType<F>;
case "toggle":
return false as ValueForFieldType<F>;
}
}
export type DocumentField<D extends DocumentType, F extends FieldType> = {
name: string;
fieldType: F;
getter: (doc: DocumentData<D>) => ValueForFieldType<F>;
setter: (
value: ValueForFieldType<F>,
doc: DocumentData<D>,
) => DocumentData<D>;
setDefault: (doc: DocumentData<D>) => DocumentData<D>;
};
const simpleField = <D extends DocumentType, F extends FieldType>(
name: string,
key: keyof DocumentData<D>,
fieldType: F,
): DocumentField<D, F> => ({
name,
fieldType,
getter: (doc) => doc[key] as unknown as ValueForFieldType<F>,
setter: (value, doc) => ({ ...doc, [key]: value }),
setDefault: (doc) => ({ ...doc, [key]: defaultValue(fieldType) }),
});
const simpleFields = <D extends DocumentType>(
fields: Record<string, [keyof DocumentData<D>, FieldType]>,
): DocumentField<D, FieldType>[] =>
Object.entries(fields).map(([name, [key, fieldType]]) =>
simpleField(name, key, fieldType),
);
export function getFieldsForType<D extends DocumentType>(
docType: D,
): DocumentField<D, FieldType>[] {
switch (docType) {
case "front":
return simpleFields<"front">({
Name: ["name", "shortText"],
Description: ["description", "longText"],
Resolved: ["resolved", "toggle"],
});
case "location":
return [
simpleField("Name", "name", "shortText"),
simpleField("Description", "description", "longText"),
];
case "monster":
return [simpleField("Name", "name", "shortText")];
case "npc":
return [
simpleField("Name", "name", "shortText"),
simpleField("Description", "description", "longText"),
];
case "scene":
return [simpleField("Text", "text", "longText")];
case "secret":
return [
simpleField("Discovered", "discovered", "toggle"),
simpleField("Text", "text", "shortText"),
];
case "session":
return [
simpleField("Name", "name", "shortText"),
simpleField("Strong Start", "strongStart", "longText"),
];
case "thread":
return [
simpleField("Resolved", "resolved", "toggle"),
simpleField("Text", "text", "shortText"),
];
case "treasure":
return [
simpleField("Discovered", "discovered", "toggle"),
simpleField("Text", "text", "shortText"),
];
}
}

View File

@@ -75,11 +75,6 @@ export type DocumentType =
| "thread"
| "treasure";
export type DocumentData<Type extends DocumentType, Data> = {
type: Type;
data: Data;
};
export type Document<Type extends DocumentType, Data> = RecordModel & {
id: DocumentId;
collectionName: typeof CollectionIds.Documents;
@@ -102,6 +97,23 @@ export type AnyDocument =
| Thread
| Treasure;
export type DocumentsByType = {
front: Front;
location: Location;
monster: Monster;
npc: Npc;
scene: Scene;
secret: Secret;
session: Session;
thread: Thread;
treasure: Treasure;
};
export type DocumentData<Type extends DocumentType> =
DocumentsByType[Type]["data"];
export type GetDocumentType<D extends AnyDocument> = D["type"];
export function getDocumentType(doc: AnyDocument): DocumentType {
return doc.type;
}