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
+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) });
}
},
}));