((acc, node) => {
+ const nameMatches = node.name.toLowerCase().includes(query);
+ const filteredChildren = filterNodes(node.children);
+
+ if (nameMatches || filteredChildren.length > 0) {
+ acc.push({
+ ...node,
+ children: nameMatches ? node.children : filteredChildren,
+ });
+ }
+
+ return acc;
+ }, []);
+ };
+
+ return filterNodes(categoryTree);
+ }, [categoryTree, searchQuery]);
+
+ const handleToggle = useCallback((path: string) => {
+ setExpandedPaths(prev => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleSelect = useCallback((path: string | null) => {
+ onSubcategoryChange(path);
+ }, [onSubcategoryChange]);
+
+ const handleClearSelection = useCallback(() => {
+ onSubcategoryChange(null);
+ }, [onSubcategoryChange]);
+
+ const expandAll = useCallback(() => {
+ const getAllPaths = (nodes: CategoryNode[]): string[] => {
+ return nodes.flatMap(node =>
+ node.children.length > 0
+ ? [node.fullPath, ...getAllPaths(node.children)]
+ : []
+ );
+ };
+ setExpandedPaths(new Set(getAllPaths(categoryTree)));
+ }, [categoryTree]);
+
+ const collapseAll = useCallback(() => {
+ setExpandedPaths(new Set());
+ }, []);
+
+ const totalResources = useMemo(() => {
+ return subcategories.reduce((sum, sub) => sum + (resourceCount?.[sub] || 0), 0);
+ }, [subcategories, resourceCount]);
+
+ return (
+
+
+
+
+ MC Sounds Browser
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-8 h-8 text-sm pixel-input"
+ />
+ {searchQuery && (
+
+ )}
+
+
+
+
+
+
+
+
+ {selectedSubcategory && (
+
+
+ Selected: {formatCategoryName(selectedSubcategory.split('/').pop() || '')}
+
+
+
+ )}
+
+
+
+
handleSelect(null)}
+ whileHover={{ x: 2 }}
+ >
+
+
+ All Sounds
+
+ {subcategories.length} categories
+
+
+
+ {filteredTree.map(node => (
+
+ ))}
+
+ {filteredTree.length === 0 && searchQuery && (
+
+ No categories match your search
+
+ )}
+
+
+
+ );
+};
+
+export default McSoundsBrowser;
diff --git a/src/components/resources/ResourceCard.tsx b/src/components/resources/ResourceCard.tsx
index 558806f..0e6156a 100644
--- a/src/components/resources/ResourceCard.tsx
+++ b/src/components/resources/ResourceCard.tsx
@@ -110,6 +110,8 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => {
return ;
case "minecraft-icons":
return ;
+ case "mcsounds":
+ return ;
default:
return ;
}
@@ -131,6 +133,8 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => {
return "bg-gray-500/10 text-gray-500";
case "minecraft-icons":
return "bg-green-500/10 text-green-600";
+ case "mcsounds":
+ return "bg-teal-500/10 text-teal-500";
default:
return "bg-gray-500/10 text-gray-500";
}
@@ -216,6 +220,19 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => {
/>
);
+ case "mcsounds":
+ return (
+
+ );
case "animations":
return (isHovered && hoverToPlayEnabled) ? (
;
case 'presets':
return ;
+ case 'mcsounds':
+ return ;
default:
return ;
}
@@ -129,6 +131,8 @@ const ResourceDetailDialog = ({
return 'bg-green-500/10 text-green-500';
case 'presets':
return 'bg-gray-500/10 text-gray-500';
+ case 'mcsounds':
+ return 'bg-teal-500/10 text-teal-500';
default:
return 'bg-gray-500/10 text-gray-500';
}
@@ -231,8 +235,8 @@ const ResourceDetailDialog = ({
- {!isFavoritesView && (
-
+
+ {!isFavoritesView && (
+ )}
-
+
+ {!isFavoritesView && (
Next resource
-
- )}
+ )}
+
By downloading, you agree to our terms of use. Crediting
diff --git a/src/components/resources/ResourceFilters.tsx b/src/components/resources/ResourceFilters.tsx
index 4979efe..9f8122a 100644
--- a/src/components/resources/ResourceFilters.tsx
+++ b/src/components/resources/ResourceFilters.tsx
@@ -73,6 +73,27 @@ const groupMciconsSubcategories = (subcategories: string[]): { parent: string; l
return result;
};
+const groupMcsoundsSubcategories = (subcategories: string[]): { parent: string; label: string; value: string }[] => {
+ const result: { parent: string; label: string; value: string }[] = [];
+
+ const formatLabel = (sub: string): string => {
+ return sub.split('/').map(part =>
+ part.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
+ ).join(' > ');
+ };
+
+ subcategories.sort().forEach(sub => {
+ const parts = sub.split('/');
+ result.push({
+ parent: parts.length > 1 ? parts[0].replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) : '',
+ label: formatLabel(sub),
+ value: sub,
+ });
+ });
+
+ return result;
+};
+
const ResourceFilters = ({
searchQuery,
selectedCategory,
@@ -153,11 +174,16 @@ const MobileFilters = ({
onCategoryChange: (category: string | null) => void;
onSubcategoryChange: (subcategory: string | null) => void;
}) => {
- const groupedSubcategories = useMemo(() =>
+ const groupedMciconsSubcategories = useMemo(() =>
selectedCategory === 'minecraft-icons' ? groupMciconsSubcategories(availableSubcategories) : [],
[availableSubcategories, selectedCategory]
);
+ const groupedMcsoundsSubcategories = useMemo(() =>
+ selectedCategory === 'mcsounds' ? groupMcsoundsSubcategories(availableSubcategories) : [],
+ [availableSubcategories, selectedCategory]
+ );
+
return (
@@ -235,6 +261,13 @@ const MobileFilters = ({
{ e.currentTarget.style.display = 'none' }} />
Minecraft Icons
+
{selectedCategory === 'presets' && (
@@ -255,7 +288,7 @@ const MobileFilters = ({
)}
- {selectedCategory === 'minecraft-icons' && groupedSubcategories.length > 0 && (
+ {selectedCategory === 'minecraft-icons' && groupedMciconsSubcategories.length > 0 && (
)}
+
+ {selectedCategory === 'mcsounds' && groupedMcsoundsSubcategories.length > 0 && (
+
+
+
+ )}
@@ -295,11 +349,16 @@ const DesktopFilters = ({
onCategoryChange: (category: string | null) => void;
onSubcategoryChange: (subcategory: string | null) => void;
}) => {
- const groupedSubcategories = useMemo(() =>
+ const groupedMciconsSubcategories = useMemo(() =>
selectedCategory === 'minecraft-icons' ? groupMciconsSubcategories(availableSubcategories) : [],
[availableSubcategories, selectedCategory]
);
+ const groupedMcsoundsSubcategories = useMemo(() =>
+ selectedCategory === 'mcsounds' ? groupMcsoundsSubcategories(availableSubcategories) : [],
+ [availableSubcategories, selectedCategory]
+ );
+
return (
+
@@ -385,7 +452,7 @@ const DesktopFilters = ({
)}
- {selectedCategory === 'minecraft-icons' && groupedSubcategories.length > 0 && (
+ {selectedCategory === 'minecraft-icons' && groupedMciconsSubcategories.length > 0 && (
)}
+
+ {selectedCategory === 'mcsounds' && groupedMcsoundsSubcategories.length > 0 && (
+
+ )}
);
};
diff --git a/src/components/resources/ResourcePreview.tsx b/src/components/resources/ResourcePreview.tsx
index 82acde1..9075ebe 100644
--- a/src/components/resources/ResourcePreview.tsx
+++ b/src/components/resources/ResourcePreview.tsx
@@ -52,7 +52,7 @@ const ResourcePreview = ({ resource }: ResourcePreviewProps) => {
return No preview available
;
}
- if (resource.category === 'music' || resource.category === 'sfx') {
+ if (resource.category === 'music' || resource.category === 'sfx' || resource.category === 'mcsounds') {
return ;
}
diff --git a/src/hooks/useResources.ts b/src/hooks/useResources.ts
index 1d5de09..3d90a79 100644
--- a/src/hooks/useResources.ts
+++ b/src/hooks/useResources.ts
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback, useMemo } from "react";
+import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Resource } from "@/types/resources";
import { useDownloadCounts } from "@/hooks/useDownloadCounts";
import {
@@ -32,34 +32,47 @@ export const useResources = () => {
const [lastAction, setLastAction] = useState("");
const [loadedFonts, setLoadedFonts] = useState([]);
const [availableCategories, setAvailableCategories] = useState([]);
+ const [refreshKey, setRefreshKey] = useState(0);
+ const fetchIdRef = useRef(0);
- const fetchResources = useCallback(async () => {
- try {
+ useEffect(() => {
+ const currentFetchId = ++fetchIdRef.current;
+
+ const loadResources = async () => {
setIsLoading(true);
+ setResources([]);
- const categories = getAvailableCategories();
- if (categories.length > 0) {
- setAvailableCategories(categories);
- }
+ try {
+ const categories = getAvailableCategories();
+ if (categories.length > 0) {
+ setAvailableCategories(categories);
+ }
- if (selectedCategory === null || selectedCategory === "favorites") {
- const all = await fetchAllResources();
- setResources(all);
- return;
- }
+ let data: Resource[];
+
+ if (selectedCategory === null || selectedCategory === "favorites") {
+ data = await fetchAllResources();
+ } else {
+ data = await fetchCategory(selectedCategory as string);
+ }
- const categoryItems = await fetchCategory(selectedCategory as string);
- setResources(categoryItems);
- } catch (error) {
- console.error("Error fetching resources:", error);
- } finally {
- setIsLoading(false);
- }
- }, [selectedCategory]);
+ if (currentFetchId === fetchIdRef.current) {
+ setResources(data);
+ }
+ } catch (error) {
+ console.error("Error fetching resources:", error);
+ if (currentFetchId === fetchIdRef.current) {
+ setResources([]);
+ }
+ } finally {
+ if (currentFetchId === fetchIdRef.current) {
+ setIsLoading(false);
+ }
+ }
+ };
- useEffect(() => {
- fetchResources();
- }, [fetchResources]);
+ loadResources();
+ }, [selectedCategory, refreshKey]);
const handleSearchSubmit = useCallback((e?: React.FormEvent) => {
e?.preventDefault();
@@ -75,6 +88,7 @@ export const useResources = () => {
const handleCategoryChange = useCallback(
(category: Category | null | "favorites") => {
+ setIsLoading(true);
setSelectedCategory(category);
setSelectedSubcategory(null);
setLastAction("category");
@@ -140,7 +154,7 @@ export const useResources = () => {
if (selectedCategory && selectedCategory !== "favorites") {
result = result.filter((r) => r.category === selectedCategory);
} else if (selectedCategory === null) {
- result = result.filter((r) => r.category !== "minecraft-icons");
+ result = result.filter((r) => r.category !== "minecraft-icons" && r.category !== "mcsounds");
}
if (selectedCategory === "minecraft-icons") {
@@ -150,7 +164,12 @@ export const useResources = () => {
}
if (selectedSubcategory && selectedSubcategory !== "all") {
- if (availableSubcategories.includes(selectedSubcategory)) {
+ if (selectedCategory === "mcsounds") {
+ result = result.filter((r) =>
+ r.subcategory === selectedSubcategory ||
+ r.subcategory?.startsWith(selectedSubcategory + "/")
+ );
+ } else if (availableSubcategories.includes(selectedSubcategory)) {
result = result.filter((r) => r.subcategory === selectedSubcategory);
}
}
@@ -275,6 +294,10 @@ export const useResources = () => {
loadWaveform,
loadCachedAudio,
loadCachedImage,
- refreshResources: fetchResources,
+ refreshResources: () => {
+ fetchIdRef.current++;
+ setRefreshKey(prev => prev + 1);
+ setSelectedCategory(null);
+ },
};
};
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 7c0abf3..f99da77 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -1,4 +1,4 @@
-import { readCache, writeCache } from "@/lib/cache";
+import { readCache, writeCache, clearCache } from "@/lib/cache";
import { Resource } from "@/types/resources";
const API_BASE = "https://hamburger-api.powernplant101-c6b.workers.dev";
@@ -36,21 +36,27 @@ const fetchJson = async (url: string): Promise => {
Accept: "application/json",
},
});
- if (!res.ok) return null;
+ if (!res.ok) {
+ console.error(`API error: ${res.status} ${res.statusText} for ${url}`);
+ return null;
+ }
return (await res.json()) as T;
- } catch {
+ } catch (error) {
+ console.error(`Fetch error for ${url}:`, error);
return null;
}
};
const normalizeCategory = (category: string): Resource["category"] => {
if (category === "mcicons") return "minecraft-icons";
+ if (category === "mcsounds") return "mcsounds";
if (category === "resources") return "images";
return category as Resource["category"];
};
const toApiCategory = (category: string): string => {
if (category === "minecraft-icons") return "mcicons";
+ if (category === "mcsounds") return "mcsounds";
if (category === "images") return "images";
return category;
};
@@ -102,7 +108,7 @@ export const fetchCategories = async (): Promise => {
export const fetchCategory = async (category: string): Promise => {
const cacheKey = `${CATEGORY_CACHE_PREFIX}${category}`;
const cached = readCache(cacheKey);
- if (cached) {
+ if (cached && cached.length > 0) {
return cached.map(item => normalizeApiResource(item, category));
}
@@ -110,28 +116,60 @@ export const fetchCategory = async (category: string): Promise => {
const data = await fetchJson<{ category: string; files: ApiResource[] }>(
`${API_BASE}/category/${apiCategory}`
);
- if (data?.files) {
- writeCache(cacheKey, data.files);
+ if (data?.files && data.files.length > 0) {
+ try {
+ writeCache(cacheKey, data.files);
+ } catch (e) {
+ console.warn(`Failed to cache category ${category}:`, e);
+ }
return data.files.map(item => normalizeApiResource(item, category));
}
+ console.error(`Failed to fetch category ${category} from API`);
return [];
};
export const fetchAllResources = async (): Promise => {
const cached = readCache(ALL_RESOURCES_CACHE_KEY);
- if (cached) {
+ if (cached && cached.categories && Object.keys(cached.categories).length > 0) {
+ const categoryNames = Object.keys(cached.categories);
+ const existingCategoriesCache = readCache(CATEGORIES_CACHE_KEY);
+ if (!existingCategoriesCache) {
+ writeCache(CATEGORIES_CACHE_KEY, {
+ categories: categoryNames,
+ total: categoryNames.length
+ });
+ }
return Object.entries(cached.categories).flatMap(([category, items]) =>
items.map(item => normalizeApiResource(item, category))
);
}
const data = await fetchJson(`${API_BASE}/all`);
- if (data?.categories) {
- writeCache(ALL_RESOURCES_CACHE_KEY, data);
+ if (data?.categories && Object.keys(data.categories).length > 0) {
+ try {
+ writeCache(ALL_RESOURCES_CACHE_KEY, data);
+ const categoryNames = Object.keys(data.categories);
+ writeCache(CATEGORIES_CACHE_KEY, {
+ categories: categoryNames,
+ total: categoryNames.length
+ });
+ Object.entries(data.categories).forEach(([category, items]) => {
+ if (items && items.length > 0) {
+ try {
+ writeCache(`${CATEGORY_CACHE_PREFIX}${normalizeCategory(category)}`, items);
+ } catch {
+ // Ignore quota errors for individual category caches
+ }
+ }
+ });
+ } catch (e) {
+ console.warn("Failed to cache all resources (likely quota exceeded):", e);
+ }
return Object.entries(data.categories).flatMap(([category, items]) =>
items.map(item => normalizeApiResource(item, category))
);
}
+ console.error("Failed to fetch all resources from API");
return [];
};
@@ -162,3 +200,16 @@ export const getAvailableCategories = (): string[] => {
const cached = readCache(CATEGORIES_CACHE_KEY);
return cached?.categories || [];
};
+
+export const clearResourceCache = (): void => {
+ clearCache(ALL_RESOURCES_CACHE_KEY);
+ clearCache(CATEGORIES_CACHE_KEY);
+ clearCache(`${CATEGORY_CACHE_PREFIX}mcsounds`);
+ clearCache(`${CATEGORY_CACHE_PREFIX}minecraft-icons`);
+ clearCache(`${CATEGORY_CACHE_PREFIX}music`);
+ clearCache(`${CATEGORY_CACHE_PREFIX}sfx`);
+ clearCache(`${CATEGORY_CACHE_PREFIX}images`);
+ clearCache(`${CATEGORY_CACHE_PREFIX}animations`);
+ clearCache(`${CATEGORY_CACHE_PREFIX}fonts`);
+ clearCache(`${CATEGORY_CACHE_PREFIX}presets`);
+};
diff --git a/src/pages/ResourcesHub.tsx b/src/pages/ResourcesHub.tsx
index f4652c6..52ef3ff 100644
--- a/src/pages/ResourcesHub.tsx
+++ b/src/pages/ResourcesHub.tsx
@@ -1,4 +1,4 @@
-import { useRef, useEffect, useState, lazy, Suspense } from 'react';
+import { useRef, useEffect, useState, lazy, Suspense, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
@@ -10,6 +10,7 @@ import ResourceFilters from '@/components/resources/ResourceFilters';
import SortSelector from '@/components/resources/SortSelector';
import ResourcesList from '@/components/resources/ResourcesList';
import FavoritesTab from '@/components/resources/FavoritesTab';
+import McSoundsBrowser from '@/components/resources/McSoundsBrowser';
import AuthDialog from '@/components/auth/AuthDialog';
import { Button } from '@/components/ui/button';
import { IconArrowUp, IconHeart, IconSearch } from '@tabler/icons-react';
@@ -58,6 +59,19 @@ const ResourcesHub = () => {
const inputRef = useRef(null);
const isMobile = useIsMobile();
+ const isMcSoundsView = selectedCategory === 'mcsounds';
+
+ const mcsoundsResourceCount = useMemo(() => {
+ if (!isMcSoundsView) return {};
+ const countMap: Record = {};
+ resources.forEach(r => {
+ if (r.subcategory) {
+ countMap[r.subcategory] = (countMap[r.subcategory] || 0) + 1;
+ }
+ });
+ return countMap;
+ }, [resources, isMcSoundsView]);
+
useEffect(() => {
const handleScroll = () => {
const scrolled = window.pageYOffset > 400;
@@ -77,7 +91,6 @@ const ResourcesHub = () => {
};
}, []);
- // Check URL params for favorites tab
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('tab') === 'favorites') {
@@ -104,6 +117,45 @@ const ResourcesHub = () => {
}
};
+ const renderContent = () => (
+ <>
+
+
+ {(selectedCategory === 'minecraft-icons' || selectedCategory === 'mcsounds') && (
+
+ Powered by Hamburger API
+
+ )}
+
+
+ >
+ );
+
return (
@@ -121,102 +173,86 @@ const ResourcesHub = () => {
-
-
- Resources Hub
- Discover and download a wide range of resources to enhance your RenderDragon experience.
-
-
-
+
+ Resources Hub
+ Discover and download a wide range of resources to enhance your RenderDragon experience.
+
-
-
-
-
setShowFavorites(true)}
- className="pixel-corners"
+
+
+ ) : (
+
-
- Favorites
-
-
-
- {showFavorites ? (
-
-
-
- ) : (
-
-
-
- {selectedCategory === 'minecraft-icons' && (
-
- Powered by Hamburger API
-
- )}
-
-
-
- )}
-
-
-
+ {isMcSoundsView && !isMobile ? (
+
+
+ {renderContent()}
+
+
+
+ ) : (
+
+ {renderContent()}
+
+ )}
+
+ )}
+
+
diff --git a/src/types/resources.ts b/src/types/resources.ts
index f6bdd7b..dcb45cf 100644
--- a/src/types/resources.ts
+++ b/src/types/resources.ts
@@ -3,7 +3,7 @@
export interface Resource {
id: number | string;
title: string;
- category: 'music' | 'sfx' | 'images' | 'animations' | 'fonts' | 'presets' | 'minecraft-icons';
+ category: 'music' | 'sfx' | 'images' | 'animations' | 'fonts' | 'presets' | 'minecraft-icons' | 'mcsounds';
subcategory?: string;
credit?: string;
filetype?: string;