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 ( + + ); +} + +function TreeNode({ + node, + depth, + selectedGuid, + onSelect, +}: { + node: ObjectTreeNode; + depth: number; + selectedGuid: string | null; + onSelect: (guid: string) => void; +}) { + const selected = node.object.GUID === selectedGuid; + return ( +
  • + + {node.children.length > 0 && ( + + )} +
  • + ); +} \ 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.

    - ) : ( -
    - )} -
    +
    + -
    -

    Objects

    -
      - {objects.map((o) => ( -
    • - {o.Name} - {o.GUID} -
    • - ))} -
    -
    +
    + {selected ? ( + <> +
    + + {selected.object.Name} + +

    {selected.label}

    +

    + {selected.object.GUID} +

    +
    + {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