diff --git a/packages/frontend/src/components/gff/AreaEditor.tsx b/packages/frontend/src/components/gff/AreaEditor.tsx
new file mode 100644
index 0000000..e00d2d4
--- /dev/null
+++ b/packages/frontend/src/components/gff/AreaEditor.tsx
@@ -0,0 +1,197 @@
+import { useMemo } from "react";
+import {
+ GffEditor,
+ getFieldValue,
+ type FieldOverrideProps,
+} from "./GffEditor";
+
+interface AreaEditorProps {
+ repo: string;
+ filePath: string;
+ content: string;
+ onSave?: (content: string) => void;
+ onSwitchToRaw?: () => void;
+}
+
+function intToHexColor(value: number): string {
+ const r = value & 0xff;
+ const g = (value >> 8) & 0xff;
+ const b = (value >> 16) & 0xff;
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
+}
+
+function hexColorToInt(hex: string): number {
+ const clean = hex.replace("#", "");
+ const r = parseInt(clean.slice(0, 2), 16);
+ const g = parseInt(clean.slice(2, 4), 16);
+ const b = parseInt(clean.slice(4, 6), 16);
+ return r | (g << 8) | (b << 16);
+}
+
+function FlagsOverride({ data, onChange }: FieldOverrideProps) {
+ const raw = getFieldValue(data, "Flags");
+ const flags = typeof raw === "number" ? raw : 0;
+
+ const bits = [
+ { bit: 0, label: "Interior" },
+ { bit: 1, label: "Underground" },
+ { bit: 2, label: "Natural" },
+ ];
+
+ return (
+
+
+
+ {bits.map(({ bit, label }) => {
+ const checked = (flags & (1 << bit)) !== 0;
+ return (
+
+ );
+ })}
+
+
+ Raw value: {flags}
+
+
+ );
+}
+
+function ColorOverride({ field, value, onChange }: FieldOverrideProps) {
+ const num = typeof value === "number" ? value : 0;
+ const hex = intToHexColor(num);
+
+ return (
+
+
+
+ onChange(field.label, hexColorToInt(e.target.value))}
+ className="h-8 w-10 cursor-pointer rounded border-0"
+ style={{ backgroundColor: "var(--forge-bg)" }}
+ />
+
+ {hex}
+
+
+
+ );
+}
+
+function DimensionsOverride({ data, onChange }: FieldOverrideProps) {
+ const width = getFieldValue(data, "Width");
+ const height = getFieldValue(data, "Height");
+ const tileset = getFieldValue(data, "Tileset");
+ const w = typeof width === "number" ? width : 0;
+ const h = typeof height === "number" ? height : 0;
+ const ts = typeof tileset === "string" ? tileset : "";
+
+ return (
+
+
+
+
+ onChange("Width", parseInt(e.target.value, 10))}
+ className="w-20 rounded border px-2 py-1.5 text-sm"
+ style={{
+ backgroundColor: "var(--forge-bg)",
+ borderColor: "var(--forge-border)",
+ color: "var(--forge-text)",
+ }}
+ />
+
+
×
+
+
+ onChange("Height", parseInt(e.target.value, 10))}
+ className="w-20 rounded border px-2 py-1.5 text-sm"
+ style={{
+ backgroundColor: "var(--forge-bg)",
+ borderColor: "var(--forge-border)",
+ color: "var(--forge-text)",
+ }}
+ />
+
+
tiles
+
+
+
+ onChange("Tileset", e.target.value)}
+ className="w-44 rounded border px-2 py-1.5 font-mono text-sm"
+ style={{
+ backgroundColor: "var(--forge-bg)",
+ borderColor: "var(--forge-border)",
+ color: "var(--forge-text)",
+ }}
+ />
+
+
+ );
+}
+
+const COLOR_FIELDS = [
+ "SunAmbientColor", "SunDiffuseColor", "SunFogColor",
+ "MoonAmbientColor", "MoonDiffuseColor", "MoonFogColor",
+];
+
+export function AreaEditor({ repo, filePath, content, onSave, onSwitchToRaw }: AreaEditorProps) {
+ const fieldOverrides = useMemo(() => {
+ const overrides = new Map React.ReactNode>();
+
+ overrides.set("Flags", (props) => );
+
+ overrides.set("Width", (props) => );
+ overrides.set("Height", () => null);
+ overrides.set("Tileset", () => null);
+
+ for (const cf of COLOR_FIELDS) {
+ overrides.set(cf, (props) => );
+ }
+
+ return overrides;
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/packages/frontend/src/components/gff/CreatureEditor.tsx b/packages/frontend/src/components/gff/CreatureEditor.tsx
new file mode 100644
index 0000000..99917ed
--- /dev/null
+++ b/packages/frontend/src/components/gff/CreatureEditor.tsx
@@ -0,0 +1,185 @@
+import { useMemo } from "react";
+import {
+ GffEditor,
+ getFieldValue,
+ type FieldOverrideProps,
+} from "./GffEditor";
+
+interface CreatureEditorProps {
+ repo: string;
+ filePath: string;
+ content: string;
+ onSave?: (content: string) => void;
+ onSwitchToRaw?: () => void;
+}
+
+function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) {
+ const abilities = [
+ ["Str", "Dex", "Con"],
+ ["Int", "Wis", "Cha"],
+ ];
+
+ const displayNames: Record = {
+ Str: "STR", Dex: "DEX", Con: "CON",
+ Int: "INT", Wis: "WIS", Cha: "CHA",
+ };
+
+ return (
+
+
+
+ {abilities.flat().map((ab) => {
+ const val = getFieldValue(data, ab);
+ const num = typeof val === "number" ? val : 0;
+ return (
+
+
+ {displayNames[ab]}
+
+ onChange(ab, parseInt(e.target.value, 10))}
+ className="mt-1 w-16 rounded border px-1 py-1 text-center text-lg font-semibold"
+ style={{
+ backgroundColor: "var(--forge-surface)",
+ borderColor: "var(--forge-border)",
+ color: "var(--forge-text)",
+ }}
+ />
+
+ mod {num >= 10 ? "+" : ""}{Math.floor((num - 10) / 2)}
+
+
+ );
+ })}
+
+
+ );
+}
+
+function RaceGenderOverride({ data, onChange }: FieldOverrideProps) {
+ const race = getFieldValue(data, "Race");
+ const gender = getFieldValue(data, "Gender");
+ const raceNum = typeof race === "number" ? race : 0;
+ const genderNum = typeof gender === "number" ? gender : 0;
+
+ return (
+
+
+
+ onChange("Race", parseInt(e.target.value, 10))}
+ className="w-20 rounded border px-2 py-1.5 text-sm"
+ style={{
+ backgroundColor: "var(--forge-bg)",
+ borderColor: "var(--forge-border)",
+ color: "var(--forge-text)",
+ }}
+ />
+
+ (racialtypes.2da)
+
+
+
+
+
+
+
+ );
+}
+
+function ScriptsOverride({ data, onChange }: FieldOverrideProps) {
+ const scripts = [
+ { label: "ScriptHeartbeat", display: "Heartbeat" },
+ { label: "ScriptOnDamaged", display: "On Damaged" },
+ { label: "ScriptDeath", display: "On Death" },
+ { label: "ScriptSpawn", display: "On Spawn" },
+ ];
+
+ return (
+
+ {scripts.map((s) => {
+ const val = getFieldValue(data, s.label);
+ const str = typeof val === "string" ? val : "";
+ return (
+
+
+ onChange(s.label, e.target.value)}
+ className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
+ style={{
+ backgroundColor: "var(--forge-bg)",
+ borderColor: "var(--forge-border)",
+ color: "var(--forge-text)",
+ }}
+ placeholder="(none)"
+ />
+
+ );
+ })}
+
+ );
+}
+
+export function CreatureEditor({ repo, filePath, content, onSave, onSwitchToRaw }: CreatureEditorProps) {
+ const fieldOverrides = useMemo(() => {
+ const overrides = new Map React.ReactNode>();
+
+ overrides.set("Str", (props) => );
+ overrides.set("Dex", () => null);
+ overrides.set("Con", () => null);
+ overrides.set("Int", () => null);
+ overrides.set("Wis", () => null);
+ overrides.set("Cha", () => null);
+
+ overrides.set("Race", (props) => );
+ overrides.set("Gender", () => null);
+
+ overrides.set("ScriptHeartbeat", (props) => );
+ overrides.set("ScriptOnDamaged", () => null);
+ overrides.set("ScriptDeath", () => null);
+ overrides.set("ScriptSpawn", () => null);
+
+ return overrides;
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/packages/frontend/src/components/gff/DialogEditor.tsx b/packages/frontend/src/components/gff/DialogEditor.tsx
new file mode 100644
index 0000000..02d1620
--- /dev/null
+++ b/packages/frontend/src/components/gff/DialogEditor.tsx
@@ -0,0 +1,437 @@
+import { useState, useMemo, useCallback, useEffect } from "react";
+import { api } from "../../services/api";
+import {
+ type GffTypeSchema,
+ GffFieldType,
+ gffTypeFromPath,
+ getLocStringText,
+} from "./GffEditor";
+
+interface DialogEditorProps {
+ repo: string;
+ filePath: string;
+ content: string;
+ onSave?: (content: string) => void;
+ onSwitchToRaw?: () => void;
+}
+
+interface DialogNode {
+ __struct_id?: number;
+ Text?: unknown;
+ Speaker?: unknown;
+ Animation?: unknown;
+ Sound?: unknown;
+ Script?: unknown;
+ Active?: unknown;
+ RepliesList?: unknown[];
+ EntriesList?: unknown[];
+ [key: string]: unknown;
+}
+
+function getStringVal(node: DialogNode, key: string): string {
+ const v = node[key];
+ if (v && typeof v === "object" && "value" in (v as Record)) {
+ return String((v as Record).value ?? "");
+ }
+ return typeof v === "string" ? v : "";
+}
+
+function getTextVal(node: DialogNode): string {
+ return getLocStringText(node.Text);
+}
+
+function NodeDetail({ node, type }: { node: DialogNode; type: "entry" | "reply" }) {
+ const text = getTextVal(node);
+ const speaker = getStringVal(node, "Speaker");
+ const script = getStringVal(node, "Script");
+ const active = getStringVal(node, "Active");
+ const sound = getStringVal(node, "Sound");
+
+ return (
+
+
+
+
+ {text || (empty)}
+
+
+
+ {type === "entry" && speaker && (
+
+ )}
+ {script && (
+
+
+
{script}
+
+ )}
+ {active && (
+
+ )}
+ {sound && (
+
+ )}
+
+
+ );
+}
+
+function DialogNodeItem({
+ node,
+ index,
+ type,
+ entries,
+ replies,
+ depth,
+}: {
+ node: DialogNode;
+ index: number;
+ type: "entry" | "reply";
+ entries: DialogNode[];
+ replies: DialogNode[];
+ depth: number;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const text = getTextVal(node);
+ const truncated = text.length > 60 ? text.slice(0, 60) + "..." : text;
+
+ const childLinks = type === "entry"
+ ? (node.RepliesList as Array<{ Index?: unknown; value?: unknown }> | undefined)
+ : (node.EntriesList as Array<{ Index?: unknown; value?: unknown }> | undefined);
+
+ const childType = type === "entry" ? "reply" : "entry";
+ const childPool = childType === "entry" ? entries : replies;
+
+ return (
+ 0 ? 16 : 0 }}>
+
+
+ {expanded && (
+
+
+
+
+
+ {childLinks && childLinks.length > 0 && depth < 4 && (
+
+ {childLinks.map((link, li) => {
+ const idx = typeof link === "object" && link !== null
+ ? (typeof link.Index === "number" ? link.Index :
+ typeof link.value === "number" ? link.value :
+ typeof (link as Record).Index === "object"
+ ? ((link as Record).Index as Record)?.value as number
+ : li)
+ : li;
+ const childIdx = typeof idx === "number" ? idx : li;
+ const child = childPool[childIdx];
+ if (!child) return null;
+ return (
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ );
+}
+
+export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }: DialogEditorProps) {
+ const [schema, setSchema] = useState(null);
+ const [data, setData] = useState>({});
+ const [activeTab, setActiveTab] = useState<"tree" | "properties">("tree");
+ const [dirty, setDirty] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ const gffType = useMemo(() => gffTypeFromPath(filePath), [filePath]);
+
+ useEffect(() => {
+ try {
+ setData(JSON.parse(content));
+ } catch {
+ setError("Failed to parse dialog JSON");
+ }
+ }, [content]);
+
+ useEffect(() => {
+ if (!gffType) return;
+ api.editor.gffSchema(gffType).then(setSchema).catch(() => {});
+ }, [gffType]);
+
+ const entries = useMemo(() => {
+ const raw = data.EntryList;
+ if (raw && typeof raw === "object" && "value" in (raw as Record)) {
+ const v = (raw as Record).value;
+ return Array.isArray(v) ? (v as DialogNode[]) : [];
+ }
+ return Array.isArray(raw) ? (raw as DialogNode[]) : [];
+ }, [data]);
+
+ const replies = useMemo(() => {
+ const raw = data.ReplyList;
+ if (raw && typeof raw === "object" && "value" in (raw as Record)) {
+ const v = (raw as Record).value;
+ return Array.isArray(v) ? (v as DialogNode[]) : [];
+ }
+ return Array.isArray(raw) ? (raw as DialogNode[]) : [];
+ }, [data]);
+
+ const startingEntries = useMemo(() => {
+ const raw = data.StartingList;
+ if (raw && typeof raw === "object" && "value" in (raw as Record)) {
+ const v = (raw as Record).value;
+ return Array.isArray(v) ? v : [];
+ }
+ return Array.isArray(raw) ? raw : [];
+ }, [data]);
+
+ const handleSave = useCallback(async () => {
+ setSaving(true);
+ try {
+ const newContent = JSON.stringify(data, null, 4) + "\n";
+ await api.editor.writeFile(repo, filePath, newContent);
+ setDirty(false);
+ onSave?.(newContent);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Save failed");
+ } finally {
+ setSaving(false);
+ }
+ }, [data, repo, filePath, onSave]);
+
+ const propertyFields = useMemo(() => {
+ if (!schema) return [];
+ return schema.fields.filter(
+ (f) => f.category === "Properties" && f.type !== GffFieldType.List,
+ );
+ }, [schema]);
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+ Dialog Editor
+
+
+ {entries.length} entries, {replies.length} replies
+
+ {dirty && (
+
+ (unsaved changes)
+
+ )}
+
+
+ {onSwitchToRaw && (
+
+ )}
+
+
+
+
+ {/* Tabs */}
+
+ {(["tree", "properties"] as const).map((tab) => (
+
+ ))}
+
+
+ {/* Content */}
+
+ {error && (
+
{error}
+ )}
+
+ {activeTab === "tree" && (
+
+ {startingEntries.length > 0 ? (
+ startingEntries.map((link, i) => {
+ const idx = typeof link === "object" && link !== null
+ ? ((link as Record
).Index as number ??
+ ((link as Record>).Index?.value as number) ??
+ i)
+ : i;
+ const entry = entries[typeof idx === "number" ? idx : i];
+ if (!entry) return null;
+ return (
+
+ );
+ })
+ ) : entries.length > 0 ? (
+
+ ) : (
+
+ No dialog entries found
+
+ )}
+
+ )}
+
+ {activeTab === "properties" && (
+
+ )}
+
+
+ );
+}
diff --git a/packages/frontend/src/components/gff/ItemEditor.tsx b/packages/frontend/src/components/gff/ItemEditor.tsx
new file mode 100644
index 0000000..665da74
--- /dev/null
+++ b/packages/frontend/src/components/gff/ItemEditor.tsx
@@ -0,0 +1,208 @@
+import { useMemo } from "react";
+import {
+ GffEditor,
+ GffFieldType,
+ getFieldValue,
+ getLocStringText,
+ type FieldOverrideProps,
+} from "./GffEditor";
+
+interface ItemEditorProps {
+ repo: string;
+ filePath: string;
+ content: string;
+ onSave?: (content: string) => void;
+ onSwitchToRaw?: () => void;
+}
+
+function BaseItemOverride({ value, onChange, field }: FieldOverrideProps) {
+ const num = typeof value === "number" ? value : 0;
+ return (
+
+
+ onChange(field.label, parseInt(e.target.value, 10))}
+ className="w-24 rounded border px-2 py-1.5 text-sm"
+ style={{
+ backgroundColor: "var(--forge-bg)",
+ borderColor: "var(--forge-border)",
+ color: "var(--forge-text)",
+ }}
+ />
+
+ (baseitems.2da row)
+
+
+ );
+}
+
+function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideProps) {
+ const stackSize = typeof getFieldValue(data, "StackSize") === "number"
+ ? (getFieldValue(data, "StackSize") as number) : 0;
+ const cost = typeof getFieldValue(data, "Cost") === "number"
+ ? (getFieldValue(data, "Cost") as number) : 0;
+ const charges = typeof getFieldValue(data, "Charges") === "number"
+ ? (getFieldValue(data, "Charges") as number) : 0;
+
+ if (field.label !== "StackSize") return null;
+
+ return (
+
+ {[
+ { label: "StackSize", display: "Stack", value: stackSize, max: 99 },
+ { label: "Cost", display: "Cost (gp)", value: cost, max: 999999 },
+ { label: "Charges", display: "Charges", value: charges, max: 255 },
+ ].map((item) => (
+
+
+ onChange(item.label, parseInt(e.target.value, 10))}
+ className="w-24 rounded border px-2 py-1.5 text-sm"
+ style={{
+ backgroundColor: "var(--forge-bg)",
+ borderColor: "var(--forge-border)",
+ color: "var(--forge-text)",
+ }}
+ />
+
+ ))}
+
+ );
+}
+
+function BooleanFlagsOverride({ data, onChange }: FieldOverrideProps) {
+ const flags = [
+ { label: "Identified", display: "Identified" },
+ { label: "Plot", display: "Plot" },
+ { label: "Stolen", display: "Stolen" },
+ { label: "Cursed", display: "Cursed" },
+ ];
+
+ return (
+
+ {flags.map((flag) => {
+ const val = getFieldValue(data, flag.label);
+ const checked = typeof val === "number" ? val !== 0 : Boolean(val);
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function PropertiesListOverride({ value }: FieldOverrideProps) {
+ const list = Array.isArray(value) ? value : [];
+
+ return (
+
+
+
+ Item Properties
+
+
+
+
+
+ {list.map((prop, i) => (
+
+
+ {typeof prop === "object" && prop !== null
+ ? JSON.stringify(prop).slice(0, 80)
+ : String(prop)}
+
+
+
+ ))}
+ {list.length === 0 && (
+
+ No item properties
+
+ )}
+
+ );
+}
+
+export function ItemEditor({ repo, filePath, content, onSave, onSwitchToRaw }: ItemEditorProps) {
+ const fieldOverrides = useMemo(() => {
+ const overrides = new Map React.ReactNode>();
+
+ overrides.set("BaseItem", (props) => );
+ overrides.set("StackSize", (props) => );
+ overrides.set("Cost", () => null);
+ overrides.set("Charges", () => null);
+ overrides.set("Identified", (props) => );
+ overrides.set("Plot", () => null);
+ overrides.set("Stolen", () => null);
+ overrides.set("Cursed", () => null);
+ overrides.set("PropertiesList", (props) => );
+
+ return overrides;
+ }, []);
+
+ const itemName = useMemo(() => {
+ try {
+ const data = JSON.parse(content);
+ const nameField = data.LocalizedName;
+ return getLocStringText(nameField) || "(unnamed item)";
+ } catch {
+ return "(unnamed item)";
+ }
+ }, [content]);
+
+ const headerSlot = (
+
+
+ {itemName}
+
+
+ );
+
+ return (
+
+ );
+}
diff --git a/packages/frontend/src/pages/Editor.tsx b/packages/frontend/src/pages/Editor.tsx
index 2af5a9c..cfa6565 100644
--- a/packages/frontend/src/pages/Editor.tsx
+++ b/packages/frontend/src/pages/Editor.tsx
@@ -2,13 +2,28 @@ import { useCallback, useMemo, useState } from "react";
import { MonacoEditor } from "../components/editor/MonacoEditor";
import { EditorTabs } from "../components/editor/EditorTabs";
import { GffEditor } from "../components/gff/GffEditor";
+import { ItemEditor } from "../components/gff/ItemEditor";
+import { CreatureEditor } from "../components/gff/CreatureEditor";
+import { AreaEditor } from "../components/gff/AreaEditor";
+import { DialogEditor } from "../components/gff/DialogEditor";
const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"];
+type GffEditorType = "uti" | "utc" | "are" | "dlg" | "generic" | null;
+
function isGffFile(tabKey: string): boolean {
return GFF_EXTENSIONS.some((ext) => tabKey.endsWith(ext));
}
+function getGffEditorType(tabKey: string): GffEditorType {
+ if (tabKey.endsWith(".uti.json")) return "uti";
+ if (tabKey.endsWith(".utc.json")) return "utc";
+ if (tabKey.endsWith(".are.json")) return "are";
+ if (tabKey.endsWith(".dlg.json")) return "dlg";
+ if (tabKey.endsWith(".utp.json") || tabKey.endsWith(".utm.json")) return "generic";
+ return null;
+}
+
function repoFromTabKey(tabKey: string): string {
const idx = tabKey.indexOf(":");
return idx > 0 ? tabKey.slice(0, idx) : "";
@@ -99,16 +114,28 @@ export function Editor({ editorState }: EditorProps) {
}
if (isActiveGff && activeMode === "visual") {
- return (
-
- );
+ const editorType = getGffEditorType(activeTab);
+ const commonProps = {
+ key: activeTab,
+ repo: activeRepo,
+ filePath: activeFilePath,
+ content: activeContent,
+ onSave: handleGffSave,
+ onSwitchToRaw: handleSwitchToRaw,
+ };
+
+ switch (editorType) {
+ case "uti":
+ return ;
+ case "utc":
+ return ;
+ case "are":
+ return ;
+ case "dlg":
+ return ;
+ default:
+ return ;
+ }
}
return (