feat: return workshop metadata with mod details

Include the Workshop title and preview image in the items response so the mod header survives a page refresh, not just navigation from search. Resolve both from the same Steam API call.
This commit is contained in:
2026-08-14 10:59:14 +08:00
parent 7163451d1f
commit 30ef76632f
7 changed files with 85 additions and 40 deletions
+20 -17
View File
@@ -1,10 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
import type { TTSMod } from '@tts/shared';
import type { ModDetails } from '@tts/shared';
import items from './items.js';
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
const mod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [],
};
const details: ModDetails = { mod };
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -17,7 +25,7 @@ describe('items route', () => {
});
it('returns 500 when the API key is missing and no fileUrl is given', async () => {
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
new TtsError('STEAM_API_KEY is not configured', 500),
);
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
@@ -26,11 +34,6 @@ describe('items route', () => {
});
it('loads from a fileUrl without an API key', async () => {
const mod: TTSMod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [],
};
const fetchModFromUrl = vi
.spyOn(await import('@tts/tts'), 'fetchModFromUrl')
.mockResolvedValue(mod);
@@ -40,26 +43,26 @@ describe('items route', () => {
{ STEAM_API_KEY: '', PORT: 3000 },
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual(mod);
expect(await res.json()).toEqual(details);
expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
});
it('returns the parsed mod on success', async () => {
const mod: TTSMod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [],
it('returns the parsed mod with metadata on success', async () => {
const withMeta: ModDetails = {
mod,
title: 'My Mod',
previewImageUrl: 'https://example.com/preview.png',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')));
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockResolvedValue(mod);
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockResolvedValue(withMeta);
const res = await items.request('/123', {}, env);
expect(res.status).toBe(200);
expect(await res.json()).toEqual(mod);
expect(await res.json()).toEqual(withMeta);
});
it('maps TtsError subclasses to their status', async () => {
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
new ItemNotFoundError('123'),
);
const res = await items.request('/123', {}, env);
@@ -70,7 +73,7 @@ describe('items route', () => {
});
it('returns 500 for unexpected errors', async () => {
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
new Error('boom'),
);
const res = await items.request('/123', {}, env);
+8 -7
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { itemIdSchema, type TTSMod } from '@tts/shared';
import { itemIdSchema, type ModDetails } from '@tts/shared';
import {
fetchMod,
fetchModDetails,
fetchModFile,
fetchModFileFromUrl,
fetchModFromUrl,
@@ -20,11 +20,12 @@ app.get('/:id', async (c) => {
try {
const fileUrl = c.req.query('fileUrl');
// With no Steam API key, the caller must supply the save URL (e.g. from a
// search result). Otherwise resolve it via the Steam API.
const mod: TTSMod = fileUrl
? await fetchModFromUrl(fileUrl)
: await fetchMod(id, c.env.STEAM_API_KEY ?? '');
return c.json<TTSMod>(mod);
// search result). Otherwise resolve it via the Steam API, which also
// yields the Workshop title and preview image.
const details: ModDetails = fileUrl
? { mod: await fetchModFromUrl(fileUrl) }
: await fetchModDetails(id, c.env.STEAM_API_KEY ?? '');
return c.json<ModDetails>(details);
} catch (err) {
if (err instanceof TtsError) {
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
+4 -4
View File
@@ -1,4 +1,4 @@
import type { SearchResult, TTSMod } from '@tts/shared';
import type { ModDetails, SearchResult } from '@tts/shared';
import { traceImage } from '@tts/http';
export { traceImage };
@@ -21,13 +21,13 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
}
/**
* Fetch a full parsed TTS save.
* Fetch a full parsed TTS save plus its Workshop metadata.
*/
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
export function fetchMod(id: string, fileUrl?: string): Promise<ModDetails> {
const params = new URLSearchParams();
if (fileUrl) params.set('fileUrl', fileUrl);
const qs = params.toString();
return getJson<TTSMod>(`/items/${id}${qs ? `?${qs}` : ''}`);
return getJson<ModDetails>(`/items/${id}${qs ? `?${qs}` : ''}`);
}
/** Build a URL for the raw save file download. */
+2 -2
View File
@@ -25,8 +25,8 @@ export default function ModPage() {
if (id) load(id, item?.fileUrl);
}, [id, item?.fileUrl, load]);
const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]);
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
const selected = useMemo(
() => (mod ? findInTree(tree, selectedPath) : undefined),
+5 -5
View File
@@ -1,11 +1,11 @@
import { create } from 'zustand';
import type { TTSMod } from '@tts/shared';
import type { ModDetails } from '@tts/shared';
import { fetchMod } from '../api';
interface ModState {
id: string | null;
fileUrl?: string;
mod: TTSMod | null;
mod: ModDetails | null;
loading: boolean;
error: string | null;
load: (id: string, fileUrl?: string) => Promise<void>;
@@ -21,11 +21,11 @@ export const useModStore = create<ModState>((set) => ({
load: async (id, fileUrl) => {
set({ loading: true, error: null, id, fileUrl });
try {
const mod = await fetchMod(id, fileUrl);
set({ mod, loading: false });
const details = await fetchMod(id, fileUrl);
set({ mod: details, loading: false });
} catch (err) {
set({ loading: false, error: String(err) });
}
},
clear: () => set({ id: null, fileUrl: undefined, mod: null, error: null, loading: false }),
clear: () => set({ id: null, fileUrl: undefined, mod: null, loading: false, error: null }),
}));