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:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
},
|
||||
}));
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user