mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
fix: removed hugeicons from mod downloader
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user