From c64a692a36ab985b999f5b22648a048a09eeed29 Mon Sep 17 00:00:00 2001 From: Coder-soft Date: Wed, 21 Oct 2026 07:01:38 +0500 Subject: [PATCH] fix(resources): restore the music mood filter panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mood sidebar vanished for every production visitor. /music_moods was classified as an asset path by the api-router, so it was sent to assets-api-worker, which does not implement it and answers 404. The client then failed to parse "Not found", left the mood list empty, and MusicMoodFilter returned null — no panel, no error. - router: route /music_moods to the resources pool (e3 / hamburger-api) and allow it for partner read keys; also fail over to the next origin when a JSON API path returns 404 instead of passing that 404 to the client - e3: serve music/music_moods.json from disk on the signed API path so the primary origin implements the endpoint too - web: surface loading/error states in the panel (with retry) instead of silently rendering nothing when the request fails --- src/components/resources/MusicMoodFilter.tsx | 46 +++++++++++++++++--- src/pages/ResourcesHub.tsx | 33 +++++++++++--- workers/api-router/src/index.ts | 24 +++++++++- 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/src/components/resources/MusicMoodFilter.tsx b/src/components/resources/MusicMoodFilter.tsx index 167c7ad0..77c37053 100644 --- a/src/components/resources/MusicMoodFilter.tsx +++ b/src/components/resources/MusicMoodFilter.tsx @@ -2,16 +2,20 @@ import { useState, useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { IconSearch, IconX, IconMoodHappy } from '@tabler/icons-react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { IconSearch, IconX, IconMoodHappy, IconAlertTriangle } from '@tabler/icons-react'; import { MusicMood } from '@/types/music'; interface MusicMoodFilterProps { selectedMoods: string[]; onMoodChange: (moods: string[]) => void; moodsData: MusicMood[]; + isLoading?: boolean; + error?: string | null; + onRetry?: () => void; } -const MusicMoodFilter = ({ selectedMoods, onMoodChange, moodsData }: MusicMoodFilterProps) => { +const MusicMoodFilter = ({ selectedMoods, onMoodChange, moodsData, isLoading = false, error = null, onRetry }: MusicMoodFilterProps) => { const [moodSearch, setMoodSearch] = useState(''); const allMoods = useMemo(() => { @@ -41,7 +45,10 @@ const MusicMoodFilter = ({ selectedMoods, onMoodChange, moodsData }: MusicMoodFi setMoodSearch(''); }; - if (allMoods.length === 0) return null; + // The panel used to return null whenever the master mood list came back empty, + // so a failed /music_moods request hid the whole filter with no explanation. + const hasMoods = allMoods.length > 0; + const showBody = !isLoading && !error && hasMoods; return (
@@ -58,8 +65,9 @@ const MusicMoodFilter = ({ selectedMoods, onMoodChange, moodsData }: MusicMoodFi value={moodSearch} onChange={(e) => setMoodSearch(e.target.value)} className="pl-8 h-8 text-sm " + disabled={!showBody} /> - {moodSearch && ( + {moodSearch && showBody && ( + )} +
+ )} + + {showBody && filteredMoods.map(mood => (
))} - {filteredMoods.length === 0 && moodSearch && ( + {showBody && filteredMoods.length === 0 && moodSearch && (
No moods match your search
)} + + {!isLoading && !error && !hasMoods && ( +
+ No mood tags available yet. +
+ )}
diff --git a/src/pages/ResourcesHub.tsx b/src/pages/ResourcesHub.tsx index 0ca3bc72..bc76579a 100644 --- a/src/pages/ResourcesHub.tsx +++ b/src/pages/ResourcesHub.tsx @@ -86,19 +86,32 @@ const ResourcesHub = () => { const minecraftMusic = useMinecraftMusic(isMinecraftMusicView); const [musicMoodsData, setMusicMoodsData] = useState([]); + const [moodsStatus, setMoodsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle'); useEffect(() => { ensurePlaylistCached(); }, []); - useEffect(() => { - if (isMusicView && musicMoodsData.length === 0) { - fetchFromAssetsApi('/music_moods') - .then(res => res.json()) - .then(data => setMusicMoodsData(data)) - .catch(err => console.error('Failed to load music moods:', err)); + const loadMusicMoods = useCallback(async () => { + setMoodsStatus('loading'); + try { + const response = await fetchFromAssetsApi('/music_moods'); + if (!response.ok) throw new Error(`music moods request failed (${response.status})`); + const data = await response.json(); + if (!Array.isArray(data)) throw new Error('music moods response was not a list'); + setMusicMoodsData(data); + setMoodsStatus('ready'); + } catch (err) { + console.error('Failed to load music moods:', err); + setMoodsStatus('error'); } - }, [isMusicView, musicMoodsData.length]); + }, []); + + useEffect(() => { + if (!isMusicView || isMinecraftMusicView) return; + if (moodsStatus !== 'idle') return; + void loadMusicMoods(); + }, [isMusicView, isMinecraftMusicView, moodsStatus, loadMusicMoods]); const moodFilteredResources = useMemo(() => { if (!isMusicView || isMinecraftMusicView || selectedMoods.length === 0) return filteredResources; @@ -351,6 +364,9 @@ const ResourcesHub = () => { } }} moodsData={musicMoodsData} + isLoading={moodsStatus === 'loading' || moodsStatus === 'idle'} + error={moodsStatus === 'error' ? 'Mood filters could not be loaded.' : null} + onRetry={() => setMoodsStatus('idle')} /> @@ -522,6 +538,9 @@ const ResourcesHub = () => { selectedMoods={selectedMoods} onMoodChange={setSelectedMoods} moodsData={musicMoodsData} + isLoading={moodsStatus === 'loading' || moodsStatus === 'idle'} + error={moodsStatus === 'error' ? 'Mood filters could not be loaded.' : null} + onRetry={() => setMoodsStatus('idle')} /> diff --git a/workers/api-router/src/index.ts b/workers/api-router/src/index.ts index 29d4f2a4..f41059cb 100644 --- a/workers/api-router/src/index.ts +++ b/workers/api-router/src/index.ts @@ -49,7 +49,15 @@ const E3_ROUTER_CLIENT_ID = "api-router"; type Group = "assets" | "resources"; -const RESOURCE_PREFIXES = ["/categories", "/category", "/all", "/fonts", "/mcicons"]; +const RESOURCE_PREFIXES = ["/categories", "/category", "/all", "/fonts", "/mcicons", "/music_moods"]; + +// JSON API paths (as opposed to file paths). A 404 here means the origin does not +// implement the route at all, so the next origin should be tried instead of +// returning the 404 to the client — that is how /music_moods silently disappeared. +const JSON_API_PATHS = ["/all", "/categories", "/mcicons", "/fonts", "/music_moods"]; + +const isJsonApiPath = (pathname: string): boolean => + JSON_API_PATHS.includes(pathname) || pathname.startsWith("/category/"); // Per-origin passive health: an origin that fails is skipped for a cooldown, then retried. const originDownUntil = new Map(); @@ -104,7 +112,7 @@ const classify = (pathname: string): Group => : "assets"; // Read-only endpoints a partner-scoped key (`assets:read`) may request. -const PARTNER_READ_PREFIXES = ["/all", "/categories", "/category", "/mcicons", "/fonts"]; +const PARTNER_READ_PREFIXES = ["/all", "/categories", "/category", "/mcicons", "/fonts", "/music_moods"]; const isPartnerReadPath = (pathname: string): boolean => PARTNER_READ_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); @@ -255,6 +263,12 @@ async function fetchUpstream(env: Env, path: string): Promise<{ ok: boolean; sta last = { ok: false, status: upstream.status, text: "" }; continue; } + if (upstream.status === 404 && !isLast && isJsonApiPath(path)) { + // Healthy origin that does not implement this JSON route (e.g. an older + // e3 build): ask the next origin rather than handing the 404 to the client. + last = { ok: false, status: 404, text: "" }; + continue; + } markOriginUp(origin); return { ok: upstream.ok, status: upstream.status, text: await upstream.text() }; } catch { @@ -403,6 +417,12 @@ export default { continue; } + if (upstream.status === 404 && !isLast && isJsonApiPath(url.pathname)) { + console.warn(`${origin} does not implement ${url.pathname} (404); trying next origin`); + markOriginUp(origin); + continue; + } + markOriginUp(origin); if (!isBodyless && upstream.status < 400) ctx.waitUntil(invalidateVersion(env)); return applyCors(upstream, cors, origin === primary ? "e3" : "fallback");