Files
PubScript/apps/web/src/features/entities/EntityFields.tsx
T

206 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* EntityFields — generic field renderer for the entity editor.
*
* Renders the fields defined in the core `ENTITY_FIELDS` registry for the
* entity's kind. Field values live at the top level for characters/notes and
* inside the `fields` record for world items; `getFieldValue`/`setFieldValue`
* abstract that away so the renderer is kind-agnostic.
*/
import React from "react";
import type { Entity, FieldDef } from "@pubscript/core";
import { ENTITY_FIELDS } from "@pubscript/core";
/** A short display label for an entity, regardless of kind. */
export function entityLabel(e: Entity): string {
if ("body" in e) {
return e.body.trim().slice(0, 40) || "(untitled note)";
}
return e.name;
}
/** Read a field value from an entity, regardless of where it is stored. */
export function getFieldValue(e: Entity, key: string): string | string[] {
if ("fields" in e) {
if (key === "name") return e.name;
const v = e.fields[key];
if (Array.isArray(v)) return v;
return typeof v === "string" ? v : "";
}
const v = (e as unknown as Record<string, unknown>)[key];
if (Array.isArray(v)) return v;
return typeof v === "string" ? v : "";
}
/** Return a copy of `e` with `key` set to `value`. */
export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
if ("fields" in e) {
if (key === "name" && typeof value === "string") {
return { ...e, name: value };
}
return { ...e, fields: { ...e.fields, [key]: value } };
}
return { ...e, [key]: value };
}
/** The field definitions for an entity's kind. */
export function fieldsFor(e: Entity): FieldDef[] {
const kind = "fields" in e ? e.kind : "body" in e ? "note" : "character";
return ENTITY_FIELDS[kind]?.fields ?? [];
}
interface EntityFieldsProps {
entity: Entity;
onChange: (patch: Partial<Entity>) => void;
/** All entities in the book, used to resolve `link` field references. */
entities?: Entity[];
}
function renderFieldControl(
field: FieldDef,
value: string | string[],
onChange: (v: string | string[]) => void,
currentEntityId: string,
entities?: Entity[],
) {
switch (field.type) {
case "textarea":
return (
<textarea
className="ps-textarea ps-input"
rows={3}
value={typeof value === "string" ? value : ""}
onChange={(ev) => onChange(ev.target.value)}
/>
);
case "select":
return (
<select
className="ps-select"
value={typeof value === "string" ? value : ""}
onChange={(ev) => onChange(ev.target.value)}
>
{(field.options ?? []).map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
);
case "list":
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
case "link": {
const opts = (entities ?? [])
.filter((e) => e.id !== currentEntityId && !("body" in e))
.map((e) => ({ value: e.id, label: entityLabel(e) }));
return (
<select
className="ps-select"
value={typeof value === "string" ? value : ""}
onChange={(ev) => onChange(ev.target.value)}
>
<option value="">— None —</option>
{opts.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
case "text":
return (
<input
type="text"
className="ps-input"
value={typeof value === "string" ? value : ""}
onChange={(ev) => onChange(ev.target.value)}
/>
);
}
}
/** A small inline tag editor (add on Enter, remove on ×). */
function TagField({
value,
onChange,
}: {
value: string[];
onChange: (v: string[]) => void;
}) {
const [draft, setDraft] = React.useState("");
const add = () => {
const t = draft.trim();
if (t && !value.includes(t)) {
onChange([...value, t]);
}
setDraft("");
};
return (
<div className="ps-tag-field">
{value.length > 0 && (
<div className="ps-tag-list">
{value.map((t, i) => (
<span key={`${t}-${i}`} className="ps-tag-chip">
{t}
<button
type="button"
className="ps-tag-remove"
aria-label={`Remove ${t}`}
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
>
×
</button>
</span>
))}
</div>
)}
<input
className="ps-input"
value={draft}
placeholder="Add item + press Enter"
onChange={(ev) => setDraft(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
add();
}
}}
/>
</div>
);
}
export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) {
const fields = fieldsFor(entity);
const handleFieldChange = (key: string, val: string | string[]) => {
const updated = setFieldValue(entity, key, val);
onChange(updated);
};
return (
<div className="ps-entity-fields">
{fields.map((field) => {
const isCompact = field.type === "select" || field.type === "link";
return (
<div
key={field.key}
className={`ps-field${isCompact ? " ps-field-half" : " ps-field-full"}`}
>
<label className="ps-field-label">{field.label}</label>
{renderFieldControl(
field,
getFieldValue(entity, field.key),
(v) => handleFieldChange(field.key, v),
entity.id,
entities,
)}
</div>
);
})}
</div>
);
}