diff --git a/apps/web/src/components/ObjectTree.tsx b/apps/web/src/components/ObjectTree.tsx
new file mode 100644
index 0000000..257ccff
--- /dev/null
+++ b/apps/web/src/components/ObjectTree.tsx
@@ -0,0 +1,68 @@
+import type { ObjectTreeNode } from '@tts/extract';
+
+interface Props {
+ nodes: ObjectTreeNode[];
+ selectedGuid: string | null;
+ onSelect: (guid: string) => void;
+}
+
+export default function ObjectTree({ nodes, selectedGuid, onSelect }: Props) {
+ return (
+
+ {nodes.map((node) => (
+
+ ))}
+
+ );
+}
+
+function TreeNode({
+ node,
+ depth,
+ selectedGuid,
+ onSelect,
+}: {
+ node: ObjectTreeNode;
+ depth: number;
+ selectedGuid: string | null;
+ onSelect: (guid: string) => void;
+}) {
+ const selected = node.object.GUID === selectedGuid;
+ return (
+
+ onSelect(node.object.GUID)}
+ style={{ paddingLeft: `${depth * 16 + 8}px` }}
+ className={`block w-full truncate rounded px-2 py-1 text-left text-sm ${
+ selected
+ ? 'bg-zinc-700 text-zinc-100'
+ : 'text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100'
+ }`}
+ >
+
+ {node.object.Name}
+
+ {node.label}
+
+ {node.children.length > 0 && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/viewers.tsx b/apps/web/src/components/viewers.tsx
new file mode 100644
index 0000000..428f36a
--- /dev/null
+++ b/apps/web/src/components/viewers.tsx
@@ -0,0 +1,46 @@
+import type { ReactNode } from 'react';
+import type { TTSObject } from '@tts/shared';
+
+/**
+ * A viewer renders a single selected object. Custom viewers can be registered
+ * per object class (`Name`) and will take precedence over the default viewer.
+ */
+export interface ObjectViewer {
+ /** The object class this viewer handles, e.g. `Card`, `Bag`. */
+ name: string;
+ component: (props: { object: TTSObject }) => ReactNode;
+}
+
+const registry = new Map();
+
+/** Register a viewer for a specific object class. */
+export function registerViewer(name: string, component: ObjectViewer['component']) {
+ registry.set(name, component);
+}
+
+/** Resolve the viewer for an object, falling back to the default viewer. */
+export function resolveViewer(object: TTSObject): ObjectViewer['component'] {
+ return registry.get(object.Name) ?? DefaultViewer;
+}
+
+/** The default viewer: a plain inspection of the object's fields. */
+export function DefaultViewer({ object }: { object: TTSObject }) {
+ return (
+
+ {Object.entries(object).map(([key, value]) => (
+
+
{key}
+
+ {typeof value === 'string' || typeof value === 'number' ? (
+ value
+ ) : (
+
+ {JSON.stringify(value, null, 2)}
+
+ )}
+
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/pages/ModPage.tsx b/apps/web/src/pages/ModPage.tsx
index 9e74fd5..5a76309 100644
--- a/apps/web/src/pages/ModPage.tsx
+++ b/apps/web/src/pages/ModPage.tsx
@@ -1,32 +1,42 @@
-import { useEffect } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
-import { collectRefs, flattenObjects } from '@tts/extract';
+import { buildTree, collectRefs } from '@tts/extract';
import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore';
import { modFileUrl } from '../api';
+import ObjectTree from '../components/ObjectTree';
+import { resolveViewer } from '../components/viewers';
export default function ModPage() {
const { id } = useParams<{ id: string }>();
const { mod, loading, error, load } = useModStore();
const item = useSearchStore((s) => s.items.find((i) => i.id === id));
+ const [selectedGuid, setSelectedGuid] = useState(null);
useEffect(() => {
if (id) load(id, item?.fileUrl);
}, [id, item?.fileUrl, load]);
+ const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]);
+ const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
+
+ const selected = useMemo(
+ () => (mod ? findInTree(tree, selectedGuid) : undefined),
+ [tree, selectedGuid],
+ );
+
if (loading) return Loading mod…
;
if (error) return {error}
;
if (!mod) return No mod loaded.
;
- const objects = flattenObjects(mod);
- const refs = collectRefs(mod);
+ const Viewer = selected ? resolveViewer(selected.object) : null;
return (
Mod {id}
- {mod.GameMode} · {mod.Date} · {objects.length} objects · {refs.length}{' '}
+ {mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '}
asset refs
-
- Asset references
- {refs.length === 0 ? (
- No external assets found.
- ) : (
-
- {refs.map((ref) => (
-
-
- {ref.kind}
-
-
- {ref.url}
-
-
- ))}
-
- )}
-
+
+
-
- Objects
-
- {objects.map((o) => (
-
- {o.Name}
- {o.GUID}
-
- ))}
-
-
+
+ {selected ? (
+ <>
+
+ {Viewer && }
+ >
+ ) : (
+
+ Select an object from the tree to inspect it.
+
+ )}
+
+
);
+}
+
+function findInTree(
+ nodes: ReturnType,
+ guid: string | null,
+): ReturnType[number] | undefined {
+ if (!guid) return undefined;
+ for (const node of nodes) {
+ if (node.object.GUID === guid) return node;
+ const found = findInTree(node.children, guid);
+ if (found) return found;
+ }
+ return undefined;
}
\ No newline at end of file