206 lines
5.8 KiB
TypeScript
206 lines
5.8 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|