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,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) });
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user