feat: add React frontend with search and mod pages

Add apps/web (Vite + React Router + Tailwind v4 + Zustand) with a search page and a mod page that analyzes saves via @tts/extract. Document the frontend in the README and docs.
This commit is contained in:
2026-08-08 11:22:09 +08:00
parent b32cb53c67
commit 7c812a109c
21 changed files with 1067 additions and 30 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TTS Workshop</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@tts/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"lint": "echo \"no lint configured\""
},
"dependencies": {
"@tts/extract": "workspace:*",
"@tts/shared": "workspace:*",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"tailwindcss": "^4.3.3",
"typescript": "^5.7.2",
"vite": "^8.2.1",
"vitest": "^4.1.10"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Link, Route, Routes } from 'react-router-dom';
import SearchPage from './pages/SearchPage';
import ModPage from './pages/ModPage';
export default function App() {
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100">
<header className="border-b border-zinc-800">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
<Link to="/" className="text-lg font-semibold tracking-tight">
TTS Workshop
</Link>
<nav className="flex gap-4 text-sm text-zinc-400">
<Link to="/" className="hover:text-zinc-100">
Search
</Link>
</nav>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8">
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/mod/:id" element={<ModPage />} />
</Routes>
</main>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { SearchResult, TTSMod } from '@tts/shared';
const BASE = '';
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`);
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
/** Search the Workshop. */
export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
const params = new URLSearchParams({ q, page: String(page) });
return getJson<SearchResult>(`/search?${params.toString()}`);
}
/** Fetch a full parsed TTS save. */
export function fetchMod(id: string): Promise<TTSMod> {
return getJson<TTSMod>(`/items/${id}`);
}
/** Build a URL for the raw save file download. */
export function modFileUrl(id: string): string {
return `${BASE}/items/${id}/file`;
}
+57
View File
@@ -0,0 +1,57 @@
import { Link } from 'react-router-dom';
import { useSearchStore } from '../stores/searchStore';
export default function SearchResults() {
const { items, page, hasMore, loading, search } = useSearchStore();
if (loading) return null;
if (items.length === 0) {
return <p className="text-sm text-zinc-500">No results yet.</p>;
}
return (
<div className="space-y-4">
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<li key={item.id}>
<Link
to={`/mod/${item.id}`}
className="block overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 hover:border-zinc-600"
>
{item.previewImageUrl && (
<img
src={item.previewImageUrl}
alt={item.title}
className="h-40 w-full object-cover"
loading="lazy"
/>
)}
<div className="p-3">
<h3 className="truncate font-medium">{item.title}</h3>
<p className="truncate text-sm text-zinc-400">{item.author}</p>
</div>
</Link>
</li>
))}
</ul>
<div className="flex items-center justify-between text-sm">
<button
onClick={() => search(useSearchStore.getState().query, page - 1)}
disabled={page <= 1 || loading}
className="rounded-lg border border-zinc-700 px-3 py-1.5 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<span className="text-zinc-400">Page {page}</span>
<button
onClick={() => search(useSearchStore.getState().query, page + 1)}
disabled={!hasMore || loading}
className="rounded-lg border border-zinc-700 px-3 py-1.5 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+76
View File
@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { collectRefs, flattenObjects } from '@tts/extract';
import { useModStore } from '../stores/modStore';
import { modFileUrl } from '../api';
export default function ModPage() {
const { id } = useParams<{ id: string }>();
const { mod, loading, error, load } = useModStore();
useEffect(() => {
if (id) load(id);
}, [id, load]);
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 (!mod) return <p className="text-sm text-zinc-500">No mod loaded.</p>;
const objects = flattenObjects(mod);
const refs = collectRefs(mod);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Mod {id}</h1>
<p className="mt-1 text-sm text-zinc-400">
{mod.GameMode} · {mod.Date} · {objects.length} objects · {refs.length}{' '}
asset refs
</p>
<a
href={modFileUrl(id!)}
className="mt-3 inline-block rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
>
Download save file
</a>
</div>
<section>
<h2 className="mb-2 text-lg font-semibold">Asset references</h2>
{refs.length === 0 ? (
<p className="text-sm text-zinc-500">No external assets found.</p>
) : (
<ul className="space-y-1">
{refs.map((ref) => (
<li key={ref.url} className="flex items-center gap-2 text-sm">
<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>
<h2 className="mb-2 text-lg font-semibold">Objects</h2>
<ul className="divide-y divide-zinc-800 rounded-lg border border-zinc-800">
{objects.map((o) => (
<li key={o.GUID} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="font-medium">{o.Name}</span>
<span className="font-mono text-xs text-zinc-500">{o.GUID}</span>
</li>
))}
</ul>
</section>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import { useSearchStore } from '../stores/searchStore';
import SearchResults from '../components/SearchResults';
export default function SearchPage() {
const { query, search, loading, error } = useSearchStore();
const [input, setInput] = useState(query);
useEffect(() => {
if (query) search(query);
}, [query, search]);
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
search(input);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Search the Workshop</h1>
<p className="mt-1 text-sm text-zinc-400">
Find Tabletop Simulator Workshop items by name.
</p>
</div>
<form onSubmit={onSubmit} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="e.g. wingspan"
className="flex-1 rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm outline-none focus:border-zinc-500"
/>
<button
type="submit"
disabled={loading}
className="rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300 disabled:opacity-50"
>
Search
</button>
</form>
{error && <p className="text-sm text-red-400">{error}</p>}
{loading && <p className="text-sm text-zinc-400">Searching</p>}
<SearchResults />
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { create } from 'zustand';
import type { TTSMod } from '@tts/shared';
import { fetchMod } from '../api';
interface ModState {
id: string | null;
mod: TTSMod | null;
loading: boolean;
error: string | null;
load: (id: string) => Promise<void>;
clear: () => void;
}
export const useModStore = create<ModState>((set) => ({
id: null,
mod: null,
loading: false,
error: null,
load: async (id) => {
set({ loading: true, error: null, id });
try {
const mod = await fetchMod(id);
set({ mod, loading: false });
} catch (err) {
set({ loading: false, error: String(err) });
}
},
clear: () => set({ id: null, mod: null, error: null, loading: false }),
}));
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { SearchResult } from '@tts/shared';
import { useSearchStore } from './searchStore';
const result: SearchResult = {
items: [
{
id: '1',
title: 'Wingspan',
author: 'someone',
previewImageUrl: 'https://example.com/p.png',
},
],
page: 1,
hasMore: false,
};
afterEach(() => {
vi.unstubAllGlobals();
useSearchStore.setState({
query: '',
page: 1,
items: [],
hasMore: false,
loading: false,
error: null,
});
});
describe('searchStore', () => {
it('stores results from a successful search', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(result))));
await useSearchStore.getState().search('wingspan');
const state = useSearchStore.getState();
expect(state.items).toHaveLength(1);
expect(state.items[0]!.title).toBe('Wingspan');
expect(state.loading).toBe(false);
expect(state.error).toBeNull();
});
it('records an error on failure', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'boom' }), { status: 502 })),
);
await useSearchStore.getState().search('wingspan');
const state = useSearchStore.getState();
expect(state.error).toContain('boom');
expect(state.items).toHaveLength(0);
});
it('ignores empty queries', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await useSearchStore.getState().search(' ');
expect(fetchMock).not.toHaveBeenCalled();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { create } from 'zustand';
import type { SearchResult, WorkshopItem } from '@tts/shared';
import { searchWorkshop } from '../api';
interface SearchState {
query: string;
page: number;
items: WorkshopItem[];
hasMore: boolean;
loading: boolean;
error: string | null;
search: (query: string, page?: number) => Promise<void>;
}
export const useSearchStore = create<SearchState>((set) => ({
query: '',
page: 1,
items: [],
hasMore: false,
loading: false,
error: null,
search: async (query, page = 1) => {
if (!query.trim()) return;
set({ loading: true, error: null, query, page });
try {
const result: SearchResult = await searchWorkshop(query, page);
set({
items: result.items,
hasMore: result.hasMore,
loading: false,
});
} catch (err) {
set({ loading: false, error: String(err) });
}
},
}));
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
// Proxy API calls to the Hono backend during development.
proxy: {
'/search': 'http://localhost:3000',
'/items': 'http://localhost:3000',
'/health': 'http://localhost:3000',
},
},
});