Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions src/components/resources/MusicMoodFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 (
<div className="h-full flex flex-col bg-card border border-border rounded-lg overflow-hidden">
Expand All @@ -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 && (
<Button
variant="ghost"
size="icon"
Expand Down Expand Up @@ -90,7 +98,27 @@ const MusicMoodFilter = ({ selectedMoods, onMoodChange, moodsData }: MusicMoodFi

<ScrollArea className="flex-1">
<div className="p-2">
{filteredMoods.map(mood => (
{isLoading && (
<div className="space-y-2 py-1" aria-busy="true" aria-label="Loading moods">
{Array.from({ length: 10 }).map((_, index) => (
<Skeleton key={index} className="h-6 w-full" />
))}
</div>
)}

{!isLoading && error && (
<div className="text-center px-2 py-8 text-muted-foreground text-sm">
<IconAlertTriangle className="h-5 w-5 mx-auto mb-2 text-destructive" />
<p>{error}</p>
{onRetry && (
<Button variant="outline" size="sm" className="mt-3" onClick={onRetry}>
Try again
</Button>
)}
</div>
)}

{showBody && filteredMoods.map(mood => (
<div
key={mood}
className={`
Expand All @@ -107,11 +135,17 @@ const MusicMoodFilter = ({ selectedMoods, onMoodChange, moodsData }: MusicMoodFi
</div>
))}

{filteredMoods.length === 0 && moodSearch && (
{showBody && filteredMoods.length === 0 && moodSearch && (
<div className="text-center py-8 text-muted-foreground text-sm">
No moods match your search
</div>
)}

{!isLoading && !error && !hasMoods && (
<div className="text-center py-8 px-2 text-muted-foreground text-sm">
No mood tags available yet.
</div>
)}
</div>
</ScrollArea>
</div>
Expand Down
33 changes: 26 additions & 7 deletions src/pages/ResourcesHub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,32 @@ const ResourcesHub = () => {
const minecraftMusic = useMinecraftMusic(isMinecraftMusicView);

const [musicMoodsData, setMusicMoodsData] = useState<MusicMood[]>([]);
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;
Expand Down Expand Up @@ -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')}
/>
</div>
</SheetContent>
Expand Down Expand Up @@ -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')}
/>
</div>
</div>
Expand Down
24 changes: 22 additions & 2 deletions workers/api-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
Expand Down Expand Up @@ -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}/`));
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
Loading