Skip to content

Commit

Permalink
Merge pull request #170 from xch-dev/asset-dropdown
Browse files Browse the repository at this point in the history
Asset dropdown
  • Loading branch information
Rigidity authored Dec 20, 2024
2 parents 88ed0f8 + a1be3ae commit 753fd02
Show file tree
Hide file tree
Showing 5 changed files with 448 additions and 71 deletions.
4 changes: 2 additions & 2 deletions src/components/NftOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
Clock2,
CopyCheck,
CopyPlus,
EyeIcon,
EyeOff,
Images,
Expand Down Expand Up @@ -68,7 +68,7 @@ export function NftOptions({
size='icon'
onClick={() => setMultiSelect(!multiSelect)}
>
<CopyCheck
<CopyPlus
className={`h-4 w-4 ${multiSelect ? 'text-green-600 dark:text-green-400' : ''}`}
/>
</Button>
Expand Down
125 changes: 125 additions & 0 deletions src/components/selectors/DropdownSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import { PropsWithChildren } from 'react';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';

export interface DropdownSelectorProps<T> extends PropsWithChildren {
totalItems: number;
loadedItems: T[];
page: number;
setPage?: (page: number) => void;
renderItem: (item: T) => React.ReactNode;
onSelect: (item: T) => void;
isDisabled?: (item: T) => boolean;
pageSize?: number;
width?: string;
className?: string;
manualInput?: React.ReactNode;
}

export function DropdownSelector<T>({
totalItems,
loadedItems,
page,
setPage,
renderItem,
onSelect,
isDisabled,
pageSize = 8,
width = 'w-[300px]',
className,
children,
manualInput,
}: DropdownSelectorProps<T>) {
const pages = Math.max(1, Math.ceil(totalItems / pageSize));

return (
<div className='min-w-0 flex-grow'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
className={`w-full justify-start p-2 h-12 ${className ?? ''}`}
>
<div className='flex items-center gap-2 w-full justify-between min-w-0'>
{children}
<ChevronDown className='h-4 w-4 opacity-50 mr-2 flex-shrink-0' />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' className={width}>
{manualInput && (
<>
<div className='p-2'>{manualInput}</div>
<DropdownMenuSeparator />
</>
)}
{setPage && (
<>
<DropdownMenuLabel>
<div className='flex items-center justify-between'>
<span>
Page {page + 1} / {pages}
</span>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
onClick={(e) => {
e.preventDefault();
setPage(Math.max(0, page - 1));
}}
disabled={page === 0}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
onClick={(e) => {
e.preventDefault();
setPage(Math.min(pages - 1, page + 1));
}}
disabled={page === pages - 1}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<div className='max-h-[300px] overflow-y-auto'>
{loadedItems.length === 0 ? (
<div className='p-4 text-center text-sm text-muted-foreground'>
No items available
</div>
) : (
loadedItems.map((item, i) => {
const disabled = isDisabled?.(item) ?? false;
return (
<DropdownMenuItem
key={i}
onClick={() => onSelect(item)}
disabled={disabled}
className={disabled ? 'opacity-50 cursor-not-allowed' : ''}
>
{renderItem(item)}
</DropdownMenuItem>
);
})
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
127 changes: 127 additions & 0 deletions src/components/selectors/NftSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { commands, NftRecord } from '@/bindings';
import { useErrors } from '@/hooks/useErrors';
import { nftUri } from '@/lib/nftUri';
import { useWalletState } from '@/state';
import { useEffect, useState } from 'react';
import { DropdownSelector } from './DropdownSelector';

export interface NftSelectorProps {
value: string | null;
onChange: (value: string) => void;
disabled?: string[];
className?: string;
}

export function NftSelector({
value,
onChange,
disabled = [],
className,
}: NftSelectorProps) {
const walletState = useWalletState();
const { addError } = useErrors();

const [page, setPage] = useState(0);
const [nfts, setNfts] = useState<NftRecord[]>([]);
const [selectedNft, setSelectedNft] = useState<NftRecord | null>(null);
const [nftThumbnails, setNftThumbnails] = useState<Record<string, string>>(
{},
);

const pageSize = 8;

useEffect(() => {
commands
.getNfts({
offset: page * pageSize,
limit: pageSize,
include_hidden: false,
collection_id: 'all',
sort_mode: 'name',
})
.then((data) => setNfts(data.nfts))
.catch(addError);
}, [addError, page]);

useEffect(() => {
if (value && selectedNft?.launcher_id !== value) {
commands
.getNft({ nft_id: value })
.then((data) => setSelectedNft(data.nft))
.catch(addError);
} else if (!value) {
setSelectedNft(null);
}
}, [value, selectedNft?.launcher_id, addError]);

useEffect(() => {
const nftsToFetch = [...nfts.map((nft) => nft.launcher_id)];
if (value && !nfts.find((nft) => nft.launcher_id === value)) {
nftsToFetch.push(value);
}

Promise.all(
nftsToFetch.map((nftId) =>
commands
.getNftData({ nft_id: nftId })
.then((response) => [nftId, response.data] as const),
),
).then((thumbnails) => {
const map: Record<string, string> = {};
thumbnails.forEach(([id, thumbnail]) => {
if (thumbnail !== null)
map[id] = nftUri(thumbnail.mime_type, thumbnail.blob);
});
setNftThumbnails(map);
});
}, [nfts, value]);

const defaultNftImage = nftUri(null, null);

return (
<DropdownSelector
totalItems={walletState.nfts.visible_nfts}
loadedItems={nfts}
page={page}
setPage={setPage}
isDisabled={(nft) => disabled.includes(nft.launcher_id)}
onSelect={(nft) => {
onChange(nft.launcher_id);
setSelectedNft(nft);
}}
className={className}
renderItem={(nft) => (
<div className='flex items-center gap-2 w-full'>
<img
src={nftThumbnails[nft.launcher_id] ?? defaultNftImage}
className='w-10 h-10 rounded object-cover'
alt={nft.name ?? 'Unknown'}
/>
<div className='flex flex-col truncate'>
<span className='flex-grow truncate'>{nft.name}</span>
<span className='text-xs text-muted-foreground truncate'>
{nft.launcher_id}
</span>
</div>
</div>
)}
>
<div className='flex items-center gap-2 min-w-0'>
<img
src={
selectedNft
? (nftThumbnails[selectedNft.launcher_id] ?? defaultNftImage)
: defaultNftImage
}
className='w-8 h-8 rounded object-cover'
/>
<div className='flex flex-col truncate text-left'>
<span className='truncate'>{selectedNft?.name ?? 'Select NFT'}</span>
<span className='text-xs text-muted-foreground truncate'>
{selectedNft?.launcher_id}
</span>
</div>
</div>
</DropdownSelector>
);
}
115 changes: 115 additions & 0 deletions src/components/selectors/TokenSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { CatRecord, commands } from '@/bindings';
import { useErrors } from '@/hooks/useErrors';
import { useWalletState } from '@/state';
import { useEffect, useState } from 'react';
import { Input } from '../ui/input';
import { DropdownSelector } from './DropdownSelector';

export interface TokenSelectorProps {
value: string | null;
onChange: (value: string) => void;
disabled?: string[];
className?: string;
allowManualInput?: boolean;
}

export function TokenSelector({
value,
onChange,
disabled = [],
className,
allowManualInput = false,
}: TokenSelectorProps) {
const walletState = useWalletState();
const { addError } = useErrors();

const [tokens, setTokens] = useState<CatRecord[]>([]);
const [selectedToken, setSelectedToken] = useState<CatRecord | null>(null);

useEffect(() => {
commands
.getCats({})
.then((data) => {
if (tokens.length) return;

setTokens(data.cats);

if (value && !selectedToken) {
setSelectedToken(
data.cats.find((token) => token.asset_id === value) ?? null,
);
}
})
.catch(addError);
}, [addError, tokens.length, value, selectedToken]);

return (
<DropdownSelector
totalItems={tokens.length}
loadedItems={tokens}
page={0}
isDisabled={(token) => disabled.includes(token.asset_id)}
onSelect={(token) => {
onChange(token.asset_id);
setSelectedToken(token);
}}
className={className}
manualInput={
allowManualInput && (
<Input
placeholder='Enter asset id'
value={value || ''}
onChange={(e) => {
onChange(e.target.value);
setSelectedToken(
tokens.find((token) => token.asset_id === e.target.value) ?? {
name: 'Unknown',
asset_id: e.target.value,
icon_url: null,
balance: 0,
ticker: null,
description: null,
visible: true,
},
);
}}
/>
)
}
renderItem={(token) => (
<div className='flex items-center gap-2 w-full'>
{token.icon_url && (
<img
src={token.icon_url}
className='w-10 h-10 rounded object-cover'
alt={token.name ?? 'Unknown'}
/>
)}
<div className='flex flex-col truncate'>
<span className='flex-grow truncate'>{token.name}</span>
<span className='text-xs text-muted-foreground truncate'>
{token.asset_id}
</span>
</div>
</div>
)}
>
<div className='flex items-center gap-2 min-w-0'>
{selectedToken?.icon_url && (
<img
src={selectedToken.icon_url}
className='w-8 h-8 rounded object-cover'
/>
)}
<div className='flex flex-col truncate text-left'>
<span className='truncate'>
{selectedToken?.name ?? 'Select Token'}
</span>
<span className='text-xs text-muted-foreground truncate'>
{selectedToken?.asset_id}
</span>
</div>
</div>
</DropdownSelector>
);
}
Loading

0 comments on commit 753fd02

Please sign in to comment.