2025-08-20 04:12:49 -06:00

314 lines
12 KiB
TypeScript

'use client';
import Fuse from 'fuse.js';
import { index } from '@/__registry__';
import { MotionHighlight } from '@/registry/effects/motion-highlight';
import { AnimateIcon, staticAnimations } from '@/registry/icons/icon';
import { X } from '@/registry/icons/x';
import { Input } from '@workspace/ui/components/ui/input';
import { cn } from '@workspace/ui/lib/utils';
import { useEffect, useMemo, useState } from 'react';
import { motion } from 'motion/react';
import { CodeTabs } from '@/registry/components/code-tabs';
import { DynamicCodeBlock } from './dynamic-codeblock';
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
TabsContents,
} from '@/registry/components/tabs';
import ReactIcon from '@workspace/ui/components/icons/react-icon';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/ui/select';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/registry/components/tooltip';
import { Button } from '@workspace/ui/components/ui/button';
import { RotateCcw } from '@/registry/icons/rotate-ccw';
const staticAnimationsLength = Object.keys(staticAnimations).length;
export const Icons = () => {
const [search, setSearch] = useState('');
const [animationKey, setAnimationKey] = useState(0);
const [activeIcon, setActiveIcon] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>('cli');
const [isCopied, setIsCopied] = useState(false);
const [activeAnimation, setActiveAnimation] = useState<string>('default');
const icons = Object.values(index).filter((icon) =>
icon.name.endsWith('-icon'),
);
const fuse = useMemo(() => {
return new Fuse(icons, {
keys: ['name', 'keywords'],
threshold: 0.3,
ignoreLocation: true,
});
}, [icons]);
const filteredIcons = useMemo(() => {
const q = search.trim();
if (!q) return icons;
return fuse.search(q).map((result) => result.item);
}, [search, fuse, icons]);
const icon = useMemo(
() => icons.find((icon) => icon.name === activeIcon),
[activeIcon, icons],
);
const iconName = useMemo(
() =>
icon?.name
.replace('-icon', '')
.split('-')
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
.join(''),
[icon],
);
useEffect(() => {
setActiveAnimation('default');
}, [activeIcon]);
return (
<div className="-mt-4.5 text-black dark:text-white">
<p className="text-sm text-muted-foreground">
{filteredIcons.length} icons {search.length ? 'found' : 'available'}
</p>
<Input
placeholder="Search icons"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div>
{filteredIcons.length ? (
<div className="grid lg:grid-cols-11 sm:grid-cols-9 xs:grid-cols-7 grid-cols-5 gap-4 mt-6">
<TooltipProvider>
<MotionHighlight
hover
className="ring-2 ring-blue-500 bg-transparent rounded-lg -z-1"
>
{filteredIcons.map((icon) => {
const animationsLength = Object.keys(
icon?.component?.animations ?? {},
).length;
return (
<Tooltip side="bottom" key={icon.name}>
<TooltipTrigger>
<div>
<AnimateIcon animateOnHover>
<button
data-value={icon.name}
onClick={() => setActiveIcon(icon.name)}
className="relative group flex items-center justify-center size-full aspect-square rounded-lg p-3.5"
>
{icon?.component && (
<icon.component className="text-current size-full" />
)}
<div
className={cn(
'absolute inset-0 bg-muted rounded-lg -z-2 transition-colors duration-200',
activeIcon === icon.name && 'bg-blue-500/20',
)}
/>
<div className="absolute -bottom-2.5 -right-2.5 flex items-center justify-center text-muted-foreground font-medium size-5 bg-background border group-hover:border-blue-500 group-hover:ring group-hover:ring-blue-500 transition-colors duration-200 rounded-full">
<span className="text-[11px] leading-none">
{staticAnimationsLength + animationsLength}
</span>
</div>
</button>
</AnimateIcon>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{icon.name.replace('-icon', '')}</p>
</TooltipContent>
</Tooltip>
);
})}
</MotionHighlight>
</TooltipProvider>
</div>
) : (
<div className="flex items-center justify-center h-[200px]">
<p className="text-sm text-muted-foreground">No icons found</p>
</div>
)}
</div>
<motion.div
className="fixed z-50 w-[325px] right-0 inset-y-12 rounded-l-2xl border-l border-y bg-background shadow-sm p-4"
initial={{ opacity: 0, x: '100%' }}
animate={activeIcon ? { opacity: 1, x: 0 } : { opacity: 0, x: '100%' }}
exit={{ opacity: 0, x: '100%' }}
transition={{ type: 'spring', stiffness: 150, damping: 25 }}
>
<h2 className="text-lg font-medium mt-1.5">
{activeIcon?.replace('-icon', '')}
</h2>
<AnimateIcon animateOnHover>
<button
onClick={() => setActiveIcon(null)}
className="absolute cursor-pointer top-5 right-5 size-8 rounded-full flex items-center justify-center bg-background hover:bg-muted transition-colors duration-200"
>
<X className="size-5 text-neutral-500" />
</button>
</AnimateIcon>
<div className="h-[calc(100%-3.25rem)] overflow-y-auto">
<div className="h-full flex flex-col justify-between gap-y-4">
<div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value)}
className="gap-0"
>
<div className="w-full flex justify-between items-center mb-3">
<h3 className="text-base font-medium pt-0 pb-0 mt-0 mb-0">
Installation
</h3>
<TabsList>
<TabsTrigger value="cli" className="w-[70px]">
CLI
</TabsTrigger>
<TabsTrigger value="manual" className="w-[70px]">
Manual
</TabsTrigger>
</TabsList>
</div>
<TabsContents>
<TabsContent value="cli">
<CodeTabs
codes={{
npm: `npx shadcn@latest add "https://animate-ui.com/r/${activeIcon}.json"`,
pnpm: `pnpm dlx shadcn@latest add "https://animate-ui.com/r/${activeIcon}.json"`,
yarn: `npx shadcn@latest add "https://animate-ui.com/r/${activeIcon}.json"`,
bun: `bun x --bun shadcn@latest add "https://animate-ui.com/r/${activeIcon}.json"`,
}}
/>
</TabsContent>
<TabsContent value="manual" className="relative group">
{activeIcon && (
<DynamicCodeBlock
code={icon?.files?.[0]?.content}
lang="jsx"
title={`${icon?.name}.tsx`}
icon={<ReactIcon />}
className="max-h-[92px]"
/>
)}
<div
role="button"
onClick={() => {
navigator.clipboard.writeText(
icon?.files?.[0]?.content ?? '',
);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 2000);
}}
className="absolute cursor-pointer inset-px top-[41px] rounded-b-[13px] bg-black/20 invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all duration-200 flex items-center justify-center"
>
<p className="text-sm font-medium text-white">
{isCopied ? 'Copied' : 'Copy'}
</p>
</div>
</TabsContent>
</TabsContents>
</Tabs>
<h3 className="text-base font-medium mt-4">Usage</h3>
{activeIcon && (
<DynamicCodeBlock
code={`<${iconName} animateOnHover />
// Or use with the AnimateIcon component
<AnimateIcon animateOnHover>
<${iconName} />
</AnimateIcon>`}
lang="jsx"
/>
)}
</div>
<div className="space-y-4">
{activeIcon && (
<>
<div className="relative h-[200px] w-full mx-auto rounded-2xl aspect-square bg-muted/50 border flex items-center justify-center">
{icon?.component && (
<icon.component
key={`${activeAnimation}-${activeIcon}-${animationKey}`}
animate
animation={activeAnimation}
className="text-current size-[100px]"
/>
)}
<AnimateIcon animateOnHover>
<Button
size="icon-sm"
variant="ghost"
className="absolute right-2 top-2 z-[2] backdrop-blur-md bg-transparent hover:bg-black/5 dark:hover:bg-white/10 size-6"
onClick={() => setAnimationKey((prev) => prev + 1)}
>
<RotateCcw className="size-3.5" />
</Button>
</AnimateIcon>
</div>
<Select
value={activeAnimation}
onValueChange={(value) => setActiveAnimation(value)}
>
<SelectTrigger className="w-full !h-11 px-1.5 rounded-lg">
<SelectValue placeholder="Select an animation" />
</SelectTrigger>
<SelectContent>
<div className="space-y-1.5 p-0.5">
{Object.keys({
...staticAnimations,
...(icon?.component?.animations ?? {}),
}).map((animation) => (
<SelectItem
key={animation}
value={animation}
className="!h-8 rounded-md px-0 focus:bg-muted"
>
<div className="gap-2 flex items-center">
<div className="size-8 rounded-md p-1.5 bg-muted">
{icon?.component && (
<icon.component className="text-current size-full" />
)}
</div>
<span>{animation}</span>
</div>
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</>
)}
</div>
</div>
</div>
</motion.div>
</div>
);
};