feat(web): add iconify tag icons for object classes

Replace the text class tag in the tree and inspector with one or more
MDI icons (hover shows the class name). Bundle only the used icons via
a generator script instead of the full set.
This commit is contained in:
2026-08-08 12:32:11 +08:00
parent 3b66e82b67
commit 4f37dabbe7
8 changed files with 235 additions and 4 deletions
+3
View File
@@ -12,6 +12,9 @@
"lint": "echo \"no lint configured\"" "lint": "echo \"no lint configured\""
}, },
"dependencies": { "dependencies": {
"@iconify-json/mdi": "^1.2.3",
"@iconify/react": "^6.0.2",
"@iconify/utils": "^3.1.4",
"@tts/extract": "workspace:*", "@tts/extract": "workspace:*",
"@tts/shared": "workspace:*", "@tts/shared": "workspace:*",
"react": "^19.2.8", "react": "^19.2.8",
+9 -2
View File
@@ -1,5 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Icon } from '@iconify/react';
import type { ObjectTreeNode } from '@tts/extract'; import type { ObjectTreeNode } from '@tts/extract';
import { iconsForObject } from './objectIcons';
interface Props { interface Props {
nodes: ObjectTreeNode[]; nodes: ObjectTreeNode[];
@@ -70,8 +72,13 @@ function TreeNode({
onClick={() => onSelect(node.object.GUID)} onClick={() => onSelect(node.object.GUID)}
className="block min-w-0 flex-1 truncate py-1 text-left text-sm" className="block min-w-0 flex-1 truncate py-1 text-left text-sm"
> >
<span className="mr-1.5 rounded bg-zinc-800 px-1 py-0.5 text-xs uppercase text-zinc-500"> <span
{node.object.Name} title={node.object.Name}
className="mr-1.5 inline-flex items-center gap-0.5 align-middle text-zinc-400"
>
{iconsForObject(node.object.Name).map((icon) => (
<Icon key={icon} icon={icon} className="h-4 w-4" />
))}
</span> </span>
<span className="truncate">{node.label}</span> <span className="truncate">{node.label}</span>
</button> </button>
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { iconsForObject } from './objectIcons';
describe('iconsForObject', () => {
it('maps known classes to icons', () => {
expect(iconsForObject('Card')).toEqual(['mdi:cards-outline']);
expect(iconsForObject('Custom_Model_Bag')).toEqual([
'mdi:bag-personal-outline',
'mdi:cube-outline',
]);
});
it('falls back to the help icon for unknown classes', () => {
expect(iconsForObject('Unknown_Thing')).toEqual(['mdi:help-circle-outline']);
});
});
+28
View File
@@ -0,0 +1,28 @@
import './objectIconsData';
/**
* Map of TTS object class (`TTSObject.Name`) to one or more MDI icon names.
* Icons are registered in `objectIcons.ts` (generated) and referenced here by
* name. Unknown classes fall back to a help icon.
*/
const OBJECT_ICONS: Record<string, string[]> = {
Card: ['mdi:cards-outline'],
Deck: ['mdi:cards'],
Custom_Deck: ['mdi:cards'],
Bag: ['mdi:bag-personal-outline'],
Custom_Model_Bag: ['mdi:bag-personal-outline', 'mdi:cube-outline'],
Custom_Model: ['mdi:cube-outline'],
Custom_Token: ['mdi:circle-outline'],
Custom_Image: ['mdi:image-outline'],
Custom_PDF: ['mdi:file-pdf-box'],
Dice: ['mdi:dice-6'],
Figurine: ['mdi:chess-knight'],
Board: ['mdi:table-large'],
};
const FALLBACK = ['mdi:help-circle-outline'];
/** Resolve the icon name(s) for an object class. */
export function iconsForObject(name: string): string[] {
return OBJECT_ICONS[name] ?? FALLBACK;
}
@@ -0,0 +1,49 @@
// Generated by scripts/generate-object-icons.mjs — do not edit by hand.
// Re-run `node scripts/generate-object-icons.mjs` after changing ICON_NAMES.
import { addCollection } from '@iconify/react';
const mdiSubset = {
"prefix": "mdi",
"width": 24,
"height": 24,
"icons": {
"cards-outline": {
"body": "<path fill=\"currentColor\" d=\"M11.19 2.25c-.26 0-.52.06-.77.15L3.06 5.45a1.994 1.994 0 0 0-1.09 2.6L6.93 20a2 2 0 0 0 1.81 1.25c.26 0 .53-.03.79-.15l7.37-3.05a2.02 2.02 0 0 0 1.23-1.8c.01-.25-.04-.54-.13-.8L13 3.5a1.95 1.95 0 0 0-1.81-1.25m3.48 0l3.45 8.35V4.25a2 2 0 0 0-2-2m4.01 1.54v9.03l2.43-5.86a1.99 1.99 0 0 0-1.09-2.6m-10.28-.14l4.98 12.02l-7.39 3.06L3.8 7.29\"/>"
},
"cards": {
"body": "<path fill=\"currentColor\" d=\"m21.47 4.35l-1.34-.56v9.03l2.43-5.86c.41-1.02-.06-2.19-1.09-2.61m-19.5 3.7L6.93 20a2.01 2.01 0 0 0 1.81 1.26c.26 0 .53-.05.79-.16l7.37-3.05c.75-.31 1.21-1.05 1.23-1.79c.01-.26-.04-.55-.13-.81L13 3.5a1.95 1.95 0 0 0-1.81-1.25c-.26 0-.52.06-.77.15L3.06 5.45a1.994 1.994 0 0 0-1.09 2.6m16.15-3.8a2 2 0 0 0-2-2h-1.45l3.45 8.34\"/>"
},
"bag-personal-outline": {
"body": "<path fill=\"currentColor\" d=\"M16 5V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v1a4 4 0 0 0-4 4v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9a4 4 0 0 0-4-4m-6-1h4v1h-4zm2 5l2 2l-2 2l-2-2zm6 11H6v-4h2v2h1v-2h9zm0-5H6V9a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z\"/>"
},
"cube-outline": {
"body": "<path fill=\"currentColor\" d=\"M21 16.5c0 .38-.21.71-.53.88l-7.9 4.44c-.16.12-.36.18-.57.18s-.41-.06-.57-.18l-7.9-4.44A.99.99 0 0 1 3 16.5v-9c0-.38.21-.71.53-.88l7.9-4.44c.16-.12.36-.18.57-.18s.41.06.57.18l7.9 4.44c.32.17.53.5.53.88zM12 4.15L6.04 7.5L12 10.85l5.96-3.35zM5 15.91l6 3.38v-6.71L5 9.21zm14 0v-6.7l-6 3.37v6.71z\"/>"
},
"cube": {
"body": "<path fill=\"currentColor\" d=\"M21 16.5c0 .38-.21.71-.53.88l-7.9 4.44c-.16.12-.36.18-.57.18s-.41-.06-.57-.18l-7.9-4.44A.99.99 0 0 1 3 16.5v-9c0-.38.21-.71.53-.88l7.9-4.44c.16-.12.36-.18.57-.18s.41.06.57.18l7.9 4.44c.32.17.53.5.53.88zM12 4.15L6.04 7.5L12 10.85l5.96-3.35z\"/>"
},
"circle-outline": {
"body": "<path fill=\"currentColor\" d=\"M12 20a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8m0-18A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>"
},
"image-outline": {
"body": "<path fill=\"currentColor\" d=\"M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-5.04 9.29l-2.75 3.54l-1.96-2.36L6.5 17h11z\"/>"
},
"file-pdf-box": {
"body": "<path fill=\"currentColor\" d=\"M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m-9.5 8.5c0 .8-.7 1.5-1.5 1.5H7v2H5.5V9H8c.8 0 1.5.7 1.5 1.5zm5 2c0 .8-.7 1.5-1.5 1.5h-2.5V9H13c.8 0 1.5.7 1.5 1.5zm4-3H17v1h1.5V13H17v2h-1.5V9h3zm-6.5 0h1v3h-1zm-5 0h1v1H7z\"/>"
},
"dice-6": {
"body": "<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m2 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m10 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m0-5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m0-5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2M7 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m0 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2\"/>"
},
"chess-knight": {
"body": "<path fill=\"currentColor\" d=\"M19 22H5v-2h14zM13 2c-1.25 0-2.42.62-3.11 1.66L7 8l2 2l2.06-1.37c.44-.31 1.08-.19 1.39.27c.02.03.05.06.05.1c.3.59.19 1.3-.28 1.77l-4.8 4.8c-.55.56-.55 1.46.01 2.01c.26.26.62.42.99.42H17V6a4 4 0 0 0-4-4\"/>"
},
"table-large": {
"body": "<path fill=\"currentColor\" d=\"M4 3h16a2 2 0 0 1 2 2v15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m0 4v3h4V7zm6 0v3h4V7zm10 3V7h-4v3zM4 12v3h4v-3zm0 8h4v-3H4zm6-8v3h4v-3zm0 8h4v-3h-4zm10 0v-3h-4v3zm0-8h-4v3h4z\"/>"
},
"help-circle-outline": {
"body": "<path fill=\"currentColor\" d=\"M11 18h2v-2h-2zm1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-14a4 4 0 0 0-4 4h2a2 2 0 0 1 2-2a2 2 0 0 1 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5a4 4 0 0 0-4-4\"/>"
}
}
};
addCollection(mdiSubset);
+9 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { buildTree, collectRefs } from '@tts/extract'; import { buildTree, collectRefs } from '@tts/extract';
import { useModStore } from '../stores/modStore'; import { useModStore } from '../stores/modStore';
@@ -6,6 +7,7 @@ import { useSearchStore } from '../stores/searchStore';
import { modFileUrl } from '../api'; import { modFileUrl } from '../api';
import ObjectTree from '../components/ObjectTree'; import ObjectTree from '../components/ObjectTree';
import { resolveViewer } from '../components/viewers'; import { resolveViewer } from '../components/viewers';
import { iconsForObject } from '../components/objectIcons';
export default function ModPage() { export default function ModPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -60,8 +62,13 @@ export default function ModPage() {
{selected ? ( {selected ? (
<> <>
<header className="mb-4"> <header className="mb-4">
<span className="rounded bg-zinc-800 px-1.5 py-0.5 text-xs uppercase text-zinc-400"> <span
{selected.object.Name} title={selected.object.Name}
className="inline-flex items-center gap-1 text-zinc-400"
>
{iconsForObject(selected.object.Name).map((icon) => (
<Icon key={icon} icon={icon} className="h-5 w-5" />
))}
</span> </span>
<h2 className="mt-1 text-lg font-semibold">{selected.label}</h2> <h2 className="mt-1 text-lg font-semibold">{selected.label}</h2>
<p className="font-mono text-xs text-zinc-500"> <p className="font-mono text-xs text-zinc-500">
+58
View File
@@ -45,6 +45,15 @@ importers:
apps/web: apps/web:
dependencies: dependencies:
'@iconify-json/mdi':
specifier: ^1.2.3
version: 1.2.3
'@iconify/react':
specifier: ^6.0.2
version: 6.0.2(react@19.2.8)
'@iconify/utils':
specifier: ^3.1.4
version: 3.1.4
'@tts/extract': '@tts/extract':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/extract version: link:../../packages/extract
@@ -124,6 +133,9 @@ importers:
packages: packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -286,6 +298,20 @@ packages:
peerDependencies: peerDependencies:
hono: ^4 hono: ^4
'@iconify-json/mdi@1.2.3':
resolution: {integrity: sha512-O3cLwbDOK7NNDf2ihaQOH5F9JglnulNDFV7WprU2dSoZu3h3cWH//h74uQAB87brHmvFVxIOkuBX2sZSzYhScg==}
'@iconify/react@6.0.2':
resolution: {integrity: sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg==}
peerDependencies:
react: '>=16'
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/utils@3.1.4':
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -623,6 +649,9 @@ packages:
resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==} resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==}
engines: {node: '>=16.9.0'} engines: {node: '>=16.9.0'}
import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
jiti@2.7.0: jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
@@ -787,6 +816,9 @@ packages:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
package-manager-detector@1.8.0:
resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==}
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -998,6 +1030,11 @@ packages:
snapshots: snapshots:
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.8.0
tinyexec: 1.3.0
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
optional: true optional: true
@@ -1080,6 +1117,23 @@ snapshots:
dependencies: dependencies:
hono: 4.13.1 hono: 4.13.1
'@iconify-json/mdi@1.2.3':
dependencies:
'@iconify/types': 2.0.0
'@iconify/react@6.0.2(react@19.2.8)':
dependencies:
'@iconify/types': 2.0.0
react: 19.2.8
'@iconify/types@2.0.0': {}
'@iconify/utils@3.1.4':
dependencies:
'@antfu/install-pkg': 1.1.0
'@iconify/types': 2.0.0
import-meta-resolve: 4.2.0
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -1349,6 +1403,8 @@ snapshots:
hono@4.13.1: {} hono@4.13.1: {}
import-meta-resolve@4.2.0: {}
jiti@2.7.0: {} jiti@2.7.0: {}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
@@ -1457,6 +1513,8 @@ snapshots:
obug@2.1.4: {} obug@2.1.4: {}
package-manager-detector@1.8.0: {}
pathe@2.0.3: {} pathe@2.0.3: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
+63
View File
@@ -0,0 +1,63 @@
// Generates apps/web/src/components/objectIcons.ts from the MDI icon set.
// Extracts only the icons referenced in ICON_NAMES so the bundle stays small,
// then registers them with Iconify's offline storage at import time.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { createRequire } from 'node:module';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Resolve deps from apps/web, where the iconify packages are hoisted.
const webPkg = resolve(__dirname, '../apps/web/package.json');
const require = createRequire(webPkg);
// Resolve the mdi icons.json via the package entry (works from the pnpm store).
const mdiPkg = require.resolve('@iconify-json/mdi/icons.json');
const icons = JSON.parse(readFileSync(mdiPkg, 'utf8'));
const ICON_NAMES = [
'cards-outline',
'cards',
'bag-personal-outline',
'cube-outline',
'cube',
'circle-outline',
'image-outline',
'file-pdf-box',
'dice-6',
'chess-knight',
'table-large',
'help-circle-outline',
];
// Extract just the icons we need, keeping the set's prefix/dimensions.
const subset = {
prefix: icons.prefix,
width: icons.width,
height: icons.height,
icons: Object.fromEntries(
ICON_NAMES.filter((name) => icons.icons[name]).map((name) => [
name,
icons.icons[name],
]),
),
};
const missing = ICON_NAMES.filter((name) => !icons.icons[name]);
if (missing.length > 0) {
throw new Error(`Missing icons in mdi set: ${missing.join(', ')}`);
}
const out = resolve(__dirname, '../apps/web/src/components/objectIconsData.ts');
const body = `// Generated by scripts/generate-object-icons.mjs — do not edit by hand.
// Re-run \`node scripts/generate-object-icons.mjs\` after changing ICON_NAMES.
import { addCollection } from '@iconify/react';
const mdiSubset = ${JSON.stringify(subset, null, 2)};
addCollection(mdiSubset);
`;
writeFileSync(out, body);
console.log(`Wrote ${out} (${ICON_NAMES.length} icons)`);