feat(web): replace flat lists with tree sidebar and object inspector
Add a containment-tree sidebar with class tags and display labels, plus an inspector pane backed by a viewer registry with a default viewer. Custom per-class viewers can be registered later.
This commit is contained in:
@@ -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 (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<TreeNode
|
||||||
|
key={node.object.GUID}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
selectedGuid={selectedGuid}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeNode({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
selectedGuid,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
node: ObjectTreeNode;
|
||||||
|
depth: number;
|
||||||
|
selectedGuid: string | null;
|
||||||
|
onSelect: (guid: string) => void;
|
||||||
|
}) {
|
||||||
|
const selected = node.object.GUID === selectedGuid;
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-1.5 rounded bg-zinc-800 px-1 py-0.5 text-xs uppercase text-zinc-500">
|
||||||
|
{node.object.Name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{node.label}</span>
|
||||||
|
</button>
|
||||||
|
{node.children.length > 0 && (
|
||||||
|
<ul>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.object.GUID}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
selectedGuid={selectedGuid}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, ObjectViewer['component']>();
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||||
|
{Object.entries(object).map(([key, value]) => (
|
||||||
|
<div key={key} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<dt className="font-mono text-xs uppercase text-zinc-500">{key}</dt>
|
||||||
|
<dd className="mt-1 break-words text-zinc-200">
|
||||||
|
{typeof value === 'string' || typeof value === 'number' ? (
|
||||||
|
value
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap font-mono text-xs">
|
||||||
|
{JSON.stringify(value, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,42 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { collectRefs, flattenObjects } from '@tts/extract';
|
import { buildTree, collectRefs } from '@tts/extract';
|
||||||
import { useModStore } from '../stores/modStore';
|
import { useModStore } from '../stores/modStore';
|
||||||
import { useSearchStore } from '../stores/searchStore';
|
import { useSearchStore } from '../stores/searchStore';
|
||||||
import { modFileUrl } from '../api';
|
import { modFileUrl } from '../api';
|
||||||
|
import ObjectTree from '../components/ObjectTree';
|
||||||
|
import { resolveViewer } from '../components/viewers';
|
||||||
|
|
||||||
export default function ModPage() {
|
export default function ModPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { mod, loading, error, load } = useModStore();
|
const { mod, loading, error, load } = useModStore();
|
||||||
const item = useSearchStore((s) => s.items.find((i) => i.id === id));
|
const item = useSearchStore((s) => s.items.find((i) => i.id === id));
|
||||||
|
const [selectedGuid, setSelectedGuid] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) load(id, item?.fileUrl);
|
if (id) load(id, item?.fileUrl);
|
||||||
}, [id, item?.fileUrl, load]);
|
}, [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 <p className="text-sm text-zinc-400">Loading mod…</p>;
|
if (loading) return <p className="text-sm text-zinc-400">Loading mod…</p>;
|
||||||
if (error) return <p className="text-sm text-red-400">{error}</p>;
|
if (error) return <p className="text-sm text-red-400">{error}</p>;
|
||||||
if (!mod) return <p className="text-sm text-zinc-500">No mod loaded.</p>;
|
if (!mod) return <p className="text-sm text-zinc-500">No mod loaded.</p>;
|
||||||
|
|
||||||
const objects = flattenObjects(mod);
|
const Viewer = selected ? resolveViewer(selected.object) : null;
|
||||||
const refs = collectRefs(mod);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Mod {id}</h1>
|
<h1 className="text-2xl font-semibold">Mod {id}</h1>
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
{mod.GameMode} · {mod.Date} · {objects.length} objects · {refs.length}{' '}
|
{mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '}
|
||||||
asset refs
|
asset refs
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
@@ -37,42 +47,49 @@ export default function ModPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
||||||
<h2 className="mb-2 text-lg font-semibold">Asset references</h2>
|
<aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2">
|
||||||
{refs.length === 0 ? (
|
<ObjectTree
|
||||||
<p className="text-sm text-zinc-500">No external assets found.</p>
|
nodes={tree}
|
||||||
) : (
|
selectedGuid={selectedGuid}
|
||||||
<ul className="space-y-1">
|
onSelect={setSelectedGuid}
|
||||||
{refs.map((ref) => (
|
/>
|
||||||
<li key={ref.url} className="flex items-center gap-2 text-sm">
|
</aside>
|
||||||
<span className="rounded bg-zinc-800 px-1.5 py-0.5 text-xs uppercase text-zinc-400">
|
|
||||||
{ref.kind}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href={ref.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="truncate text-zinc-300 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
{ref.url}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<section className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||||
<h2 className="mb-2 text-lg font-semibold">Objects</h2>
|
{selected ? (
|
||||||
<ul className="divide-y divide-zinc-800 rounded-lg border border-zinc-800">
|
<>
|
||||||
{objects.map((o) => (
|
<header className="mb-4">
|
||||||
<li key={o.GUID} className="flex items-center justify-between px-3 py-2 text-sm">
|
<span className="rounded bg-zinc-800 px-1.5 py-0.5 text-xs uppercase text-zinc-400">
|
||||||
<span className="font-medium">{o.Name}</span>
|
{selected.object.Name}
|
||||||
<span className="font-mono text-xs text-zinc-500">{o.GUID}</span>
|
</span>
|
||||||
</li>
|
<h2 className="mt-1 text-lg font-semibold">{selected.label}</h2>
|
||||||
))}
|
<p className="font-mono text-xs text-zinc-500">
|
||||||
</ul>
|
{selected.object.GUID}
|
||||||
</section>
|
</p>
|
||||||
|
</header>
|
||||||
|
{Viewer && <Viewer object={selected.object} />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Select an object from the tree to inspect it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInTree(
|
||||||
|
nodes: ReturnType<typeof buildTree>,
|
||||||
|
guid: string | null,
|
||||||
|
): ReturnType<typeof buildTree>[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;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user