fix: removed hugeicons from mod downloader

This commit is contained in:
Naterfute
2025-11-03 10:27:55 -08:00
parent 73a13e5616
commit 7f8e4cac6d
8 changed files with 186 additions and 214 deletions

View File

@@ -1,5 +1,5 @@
import { ChevronDownIcon } from '@radix-ui/react-icons';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import Button from '../../elements/ButtonV2';
@@ -40,65 +40,153 @@ interface DropdownButtonProps {
className?: string;
}
const DropdownButton = ({ versions, onVersionSelect, className = '' }: DropdownButtonProps) => {
const [selectedVersion, setSelectedVersion] = useState<Version>(versions[0]);
const [isOpen, setIsOpen] = useState(false);
const formatFileSize = (bytes: number): string => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
const handleSelect = (version: Version) => {
setSelectedVersion(version);
setIsOpen(false);
onVersionSelect?.(version);
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
const DropdownButton = ({ versions, onVersionSelect, className = '' }: DropdownButtonProps) => {
const [selectedVersion, setSelectedVersion] = useState<Version | null>(versions[0] || null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = async (version: Version) => {
setIsLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 300)); // Simulate async operation
setSelectedVersion(version);
setIsOpen(false);
onVersionSelect?.(version);
} finally {
setIsLoading(false);
}
};
// Fallback UI if no versions are provided
if (!versions.length) {
return (
<div className={`relative flex justify-center ${className}`}>
<div className='relative w-full max-w-md'>
<Button
variant='outline'
className='flex items-center justify-between w-full px-4 py-3 text-left bg-gray-900 border-gray-700 hover:bg-gray-800 transition-colors disabled:opacity-50'
disabled
>
<span className='font-medium truncate'>No versions available</span>
</Button>
</div>
</div>
);
}
return (
<div className={`relative flex justify-center ${className}`}>
<div className={`relative flex justify-center ${className}`} ref={dropdownRef}>
<div className='relative w-full max-w-md'>
<Button
ref={buttonRef}
variant='outline'
className='flex items-center justify-between w-full px-4 py-2 text-left'
className='flex items-center justify-between w-full px-4 py-3 text-left bg-gray-900 border-gray-700 hover:bg-gray-800 transition-colors disabled:opacity-50'
onClick={() => setIsOpen(!isOpen)}
aria-haspopup='listbox'
aria-expanded={isOpen}
disabled={isLoading || !selectedVersion}
>
<span className='truncate'>Selected Version: {selectedVersion.version_number}</span>
<div className='flex flex-col'>
<span className='font-medium truncate'>
Version: {selectedVersion?.version_number || 'Select a version'}
</span>
{selectedVersion?.files?.[0]?.size && (
<span className='text-xs text-gray-400'>
({formatFileSize(selectedVersion.files[0].size)})
</span>
)}
</div>
<ChevronDownIcon
className={`w-4 h-4 ml-2 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
className={`w-5 h-5 ml-2 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</Button>
{isOpen && (
<div
className='absolute z-10 w-full mt-1 bg-gray-800 border border-gray-700 rounded-md shadow-lg'
className='absolute z-20 w-full mt-2 bg-gray-800 border border-gray-700 rounded-lg shadow-xl max-h-96 overflow-y-auto'
role='listbox'
>
{versions.map((version) => (
<div
key={version.id}
role='option'
aria-selected={version.id === selectedVersion.id}
className={`px-4 py-2 cursor-pointer transition-colors ${
version.id === selectedVersion.id ? 'bg-brand text-white' : 'hover:bg-gray-700'
}`}
aria-selected={version.id === selectedVersion?.id}
className={`px-4 py-3 cursor-pointer transition-colors ${
version.id === selectedVersion?.id ? 'bg-brand text-white' : 'hover:bg-gray-700'
} focus:outline-none focus:bg-gray-700`}
onClick={() => handleSelect(version)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(version);
}
}}
tabIndex={0}
>
<div className='flex justify-between items-center'>
<span className='font-medium'>{version.version_number}</span>
<span className='text-xs text-gray-400'>
{new Date(version.date_published).toLocaleDateString()}
</span>
<div className='flex flex-col'>
<div className='flex justify-between items-center'>
<span className='font-medium'>{version.version_number}</span>
<span className='text-xs text-gray-400'>
{new Date(version.date_published).toLocaleDateString()}
</span>
</div>
{version.name && (
<span className='text-sm text-gray-300 truncate'>{version.name}</span>
)}
<div className='flex gap-2 mt-1 text-xs text-gray-400'>
{version.files?.[0]?.file_type && (
<span>Type: {version.files[0].file_type}</span>
)}
{version.files?.[0]?.size && (
<span>Size: {formatFileSize(version.files[0].size)}</span>
)}
{version.game_versions?.length > 0 && (
<span>Game: {version.game_versions[0]}</span>
)}
</div>
</div>
{version.name && <div className='text-sm text-gray-300 truncate'>{version.name}</div>}
</div>
))}
</div>
)}
</div>
{isLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-gray-900/50 rounded-lg'>
<div className='w-6 h-6 border-2 border-brand border-t-transparent rounded-full animate-spin' />
</div>
)}
</div>
);
};

View File

@@ -6,9 +6,9 @@ import Input from '@/components/elements/Input';
import { ServerContext } from '@/state/server';
import { useGlobalStateContext } from './config';
import { getAvailableLoaders, getLoaderType } from './eggfeatures.ts';
import { getAvailableLoaders, getLoaderType } from './eggfeatures';
const DEFAULT_LOADERS = ['paper', 'spigot', 'purpur', 'fabric', 'forge', 'quilt'];
const DEFAULT_LOADERS = ['paper', 'spigot', 'purpur', 'fabric', 'forge', 'quilt', 'bungeecord'];
interface LoaderSelectorProps {
maxVisible?: number;

View File

@@ -1,11 +1,12 @@
'use client';
import { ArrowDownToLine } from '@gravity-ui/icons';
import { Link } from 'react-router-dom';
import ActionButton from '@/components/elements/ActionButton';
import Button from '@/components/elements/ButtonV2';
import DownloadIcon from '@/components/elements/hugeicons/downloadIcon';
import { ServerContext } from '@/state/server';
// import { ServerContext } from '@/state/server';
import { Mod } from './config';
@@ -14,28 +15,27 @@ interface ModCardProps {
}
export const ModCard = ({ mod }: ModCardProps) => {
// const id = ServerContext.useStoreState((state) => state.server.data?.id);
const eggFeatures = ServerContext.useStoreState((state) => state.server.data?.eggFeatures);
// console.log(eggFeatures);
// const eggFeatures = ServerContext.useStoreState((state) => state.server.data?.eggFeatures);
const formatDownloads = (num: number) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
};
//if eggFeatures.map((v) -> v.toLowerCase()).includes(''){ }
return (
<div className='group bg-gradient-to-br from-[#090909] via-[#0f0f0f] to-[#131313] transition delay-50 duration-325 rounded-xl overflow-hidden border border-gray-800/70 hover:border-brand/60 transition-all duration-300 hover:shadow-2xl hover:shadow-brand/15 backdrop-blur-sm'>
<div className='p-6 flex items-start space-x-5'>
{/* Icon Container */}
<div className='flex-shrink-0 relative'>
<div className='flex-shrink-0 relative hover:cursor-pointer hover:scale-105 transition-transform duration-300'>
{mod.icon_url ? (
<div className='relative'>
<img
src={mod.icon_url}
alt={mod.title}
className='w-20 h-20 object-cover rounded-xl shadow-lg group-hover:scale-105 transition-transform duration-300 border border-gray-700/50'
/>
<div className='relative '>
<a href={`${mod.id}`}>
<img
src={mod.icon_url}
alt={mod.title}
className='w-20 h-20 object-cover rounded-xl shadow-lg group-hover:scale-105 transition-transform duration-300 border border-gray-700/50'
/>
</a>
<div className='absolute inset-0 rounded-xl bg-gradient-to-t from-black/30 to-transparent' />
</div>
) : (
@@ -50,7 +50,7 @@ export const ModCard = ({ mod }: ModCardProps) => {
<div>
<Link
to={`${mod.id}`}
className='text-xl font-bold text-white hover:text-brand transition-colors duration-200 line-clamp-1 group-hover:underline'
className='text-xl font-bold text-white hover:text-brand/50 transition-colors duration-200 line-clamp-1 group-hover:underline'
>
{mod.title}
</Link>
@@ -90,9 +90,8 @@ export const ModCard = ({ mod }: ModCardProps) => {
</div>
<div className='flex-shrink-0 self-center align-text-left'>
{/* <Button className='border-red-700/70 border-2 rounded-md hover:border-red-800 hover:text-gray-200 '> */}
<Button className='border-gray-500/70 border-2 rounded-md transition delay-50 duration-325 hover:border-brand/50 hover:text-gray-200 '>
<DownloadIcon className='px-1' />
<ArrowDownToLine width={22} height={22} className='px-1' />
Install
</Button>
</div>

View File

@@ -16,7 +16,7 @@ export const ModList = ({ showInstalled = false, showDependencies = false }: Mod
const { mods, setMods, selectedLoaders, selectedVersions, searchQuery } = useGlobalStateContext();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const fetchMods = async (resetPagination = false) => {
@@ -26,24 +26,22 @@ export const ModList = ({ showInstalled = false, showDependencies = false }: Mod
setIsLoading(true);
setError(null);
const currentPage = resetPagination ? 1 : page;
const currentPage = resetPagination ? 0 : page;
// APPROACH 1: Array of arrays (most common format)
const facets: string[][] = [['project_type:mod']];
// Add loaders as individual facet arrays
if (selectedLoaders.length > 0) {
selectedLoaders.forEach((loader) => {
facets.push([`categories:${loader}`]);
});
}
// Add versions as individual facet arrays
if (selectedVersions.length > 0) {
selectedVersions.forEach((version) => {
facets.push([`versions:${version}`]);
});
}
facets.push(['server_side:required', 'server_side:optional']);
// console.log('Fetching mods with parameters:', {
// query: searchQuery,
@@ -57,7 +55,7 @@ export const ModList = ({ showInstalled = false, showDependencies = false }: Mod
query: searchQuery || undefined,
facets: facets,
limit: 20,
offset: (currentPage - 1) * 20,
offset: currentPage,
index: 'relevance',
});
@@ -65,7 +63,7 @@ export const ModList = ({ showInstalled = false, showDependencies = false }: Mod
if (resetPagination) {
setMods(data);
setPage(1);
setPage(0);
} else {
setMods((prev) => [...prev, ...data]);
}
@@ -91,7 +89,7 @@ export const ModList = ({ showInstalled = false, showDependencies = false }: Mod
const handleLoadMore = () => {
if (!isLoading && hasMore) {
setPage((prev) => prev + 1);
setPage((prev) => prev + 20);
fetchMods(false);
}
};
@@ -123,44 +121,44 @@ export const ModList = ({ showInstalled = false, showDependencies = false }: Mod
))}
</div>
{hasMore && (
<ActionButton
onClick={handleLoadMore}
disabled={isLoading}
className={`${isLoading
? 'bg-gray-700 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-500 shadow-lg hover:shadow-blue-500/20'
} text-white font-medium`}
>
{isLoading ? (
<span className='inline-flex items-center'>
<svg
className='animate-spin -ml-1 mr-2 h-4 w-4 text-white'
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
>
<circle
className='opacity-25'
cx='12'
cy='12'
r='10'
stroke='currentColor'
strokeWidth='4'
></circle>
<path
className='opacity-75'
fill='currentColor'
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
></path>
</svg>
Loading...
</span>
) : (
'Load More'
)}
</ActionButton>
)}
{/* {hasMore && ( */}
{/* <ActionButton */}
{/* onClick={handleLoadMore} */}
{/* disabled={isLoading} */}
{/* className={`${isLoading */}
{/* ? 'bg-gray-700 cursor-not-allowed' */}
{/* : 'bg-blue-600 hover:bg-blue-500 shadow-lg hover:shadow-blue-500/20' */}
{/* } text-white font-medium`} */}
{/* > */}
{/* {isLoading ? ( */}
{/* <span className='inline-flex items-center'> */}
{/* <svg */}
{/* className='animate-spin -ml-1 mr-2 h-4 w-4 text-white' */}
{/* xmlns='http://www.w3.org/2000/svg' */}
{/* fill='none' */}
{/* viewBox='0 0 24 24' */}
{/* > */}
{/* <circle */}
{/* className='opacity-25' */}
{/* cx='12' */}
{/* cy='12' */}
{/* r='10' */}
{/* stroke='currentColor' */}
{/* strokeWidth='4' */}
{/* ></circle> */}
{/* <path */}
{/* className='opacity-75' */}
{/* fill='currentColor' */}
{/* d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z' */}
{/* ></path> */}
{/* </svg> */}
{/* Loading... */}
{/* </span> */}
{/* ) : ( */}
{/* 'Load More' */}
{/* )} */}
{/* </ActionButton> */}
{/* )} */}
</div>
);
};

View File

@@ -129,7 +129,7 @@ const ModrinthContainerInner = () => {
<ContentBox
className='p-8 bg-[#ffffff09] border-[1px] border-[#ffffff11] shadow-xs rounded-xl w-full md:w-4/5'
title='Modrinth'
title='Downloader'
>
<div className='relative w-full h-full mb-4'>
<svg

View File

@@ -268,6 +268,7 @@ export const ModrinthService = {
// Modrinth API expects facets as a JSON string
processedParams.facets = JSON.stringify(params.facets);
}
console.log(processedParams);
// Add offset if provided
if (params.offset) {

View File

@@ -9,56 +9,52 @@ export const parseEggFeatures = (features: string[]): LoaderMatch[] => {
const matches: LoaderMatch[] = [];
const allLoaders = ['forge', 'fabric', 'neoforge', 'quilt', 'paper', 'purpur', 'spigot', 'bukkit', 'pufferfish'];
features.forEach(feature => {
features.forEach((feature) => {
const normalized = feature.toLowerCase().trim();
// Exact pattern matching: mod/loader or plugin/loader
if (normalized.match(/^(mod|plugin)\/[a-zA-Z0-9]+$/)) {
const [type, loader] = normalized.split('/') as ['mod' | 'plugin', string];
const matchedLoader = allLoaders.find(l => l.toLowerCase() === loader.toLowerCase());
const matchedLoader = allLoaders.find((l) => l.toLowerCase() === loader.toLowerCase());
if (matchedLoader) {
matches.push({
type,
loader: matchedLoader,
feature,
exactMatch: true
exactMatch: true,
});
}
}
// Fuzzy matching for loader names
allLoaders.forEach(loader => {
allLoaders.forEach((loader) => {
if (normalized.includes(loader.toLowerCase())) {
const type = normalized.includes('mod') ? 'mod' : 'plugin';
matches.push({
type,
loader,
feature,
exactMatch: normalized === `${type}/${loader}`.toLowerCase()
exactMatch: normalized === `${type}/${loader}`.toLowerCase(),
});
}
});
});
// Remove duplicates and return exact matches first
return matches.filter((match, index, self) =>
index === self.findIndex(m =>
m.type === match.type && m.loader === match.loader
return matches
.filter(
(match, index, self) => index === self.findIndex((m) => m.type === match.type && m.loader === match.loader),
)
).sort((a, b) => (b.exactMatch ? 1 : 0) - (a.exactMatch ? 1 : 0));
.sort((a, b) => (b.exactMatch ? 1 : 0) - (a.exactMatch ? 1 : 0));
};
// Helper functions for specific selectors
export const getAvailableLoaders = (features: string[]): string[] => {
const matches = parseEggFeatures(features);
return [...new Set(matches.map(match => match.loader))];
return [...new Set(matches.map((match) => match.loader))];
};
export const getLoaderType = (features: string[]): 'mod' | 'plugin' | 'unknown' => {
const matches = parseEggFeatures(features);
const modCount = matches.filter(m => m.type === 'mod').length;
const pluginCount = matches.filter(m => m.type === 'plugin').length;
const modCount = matches.filter((m) => m.type === 'mod').length;
const pluginCount = matches.filter((m) => m.type === 'plugin').length;
if (modCount > pluginCount) return 'mod';
if (pluginCount > modCount) return 'plugin';

View File

@@ -1,110 +0,0 @@
'use client';
import * as React from 'react';
import HugeIconsCheck from '@/components/elements/hugeicons/Check';
import HugeIconsChevronDown from '@/components/elements/hugeicons/ChevronDown';
import HugeIconsChevronUp from '@/components/elements/hugeicons/ChevronUp';
import { cn } from '@/lib/utils';
export interface ScrollItem {
id: string | number;
label: string;
}
interface ExpandableScrollBoxProps {
placeholder?: string;
items: ScrollItem[];
maxHeight?: string;
className?: string;
buttonClassName?: string;
boxClassName?: string;
onSelect?: (item: ScrollItem) => void;
}
export function ExpandableScrollBox({
placeholder = 'Select an option',
items = [],
maxHeight = '300px',
className = '',
buttonClassName = '',
boxClassName = '',
onSelect,
}: ExpandableScrollBoxProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [selectedItem, setSelectedItem] = React.useState<ScrollItem | null>(null);
const handleSelect = (item: ScrollItem) => {
setSelectedItem(item);
setIsOpen(false);
if (onSelect) {
onSelect(item);
}
};
return (
<div className={cn('w-full', className)}>
{/* Selection Button */}
<button
className={cn(
'w-full px-6 py-3 rounded-md font-medium cursor-pointer',
'bg-custom-red text-white',
'hover:bg-custom-red-hover focus:outline-hidden focus:ring-2 focus:ring-custom-red-hover focus:ring-offset-2 focus:ring-offset-black',
'transition-colors duration-200 shadow-md',
'flex items-center justify-between',
buttonClassName,
)}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls='scroll-box'
>
<span className='truncate'>{selectedItem ? selectedItem.label : placeholder}</span>
{isOpen ? (
<HugeIconsChevronUp className='ml-2 h-5 w-5 shrink-0' />
) : (
<HugeIconsChevronDown className='ml-2 h-5 w-5 shrink-0' />
)}
</button>
{/* Expandable Box with Scroll Menu */}
<div
id='scroll-box'
className={cn(
'w-full mt-4 rounded-md overflow-hidden',
'bg-custom-medium-gray border-2 border-custom-dark-gray',
'shadow-lg transition-all duration-300 ease-in-out',
isOpen ? 'max-h-(--max-height) opacity-100 my-4' : 'max-h-0 opacity-0 my-0 border-0',
boxClassName,
)}
style={{ '--max-height': maxHeight } as React.CSSProperties}
>
{/* Scroll Container */}
<div
className={cn('overflow-y-auto transition-all', isOpen ? 'opacity-100' : 'opacity-0')}
style={{ maxHeight }}
>
<div className='py-1'>
{items.map((item) => (
<div
key={item.id}
className={cn(
'px-4 py-3 cursor-pointer transition-colors duration-150',
'flex items-center justify-between',
'hover:bg-custom-dark-gray text-white',
selectedItem?.id === item.id
? 'bg-custom-dark-gray border-l-2 border-custom-red'
: 'border-l-2 border-transparent',
)}
onClick={() => handleSelect(item)}
>
<span>{item.label}</span>
{selectedItem?.id === item.id && <HugeIconsCheck className='h-4 w-4 text-custom-red' />}
</div>
))}
</div>
</div>
</div>
</div>
);
}