Final UI V3
# UI V3 Changelog Major updates and improvements in this release: ## Core Changes - Complete NEW REWRITTEN UI system overhaul (V3) with semantic design tokens - New settings management system with drag-and-drop capabilities - Enhanced provider system supporting multiple AI services - Improved theme system with better dark mode support - New component library with consistent design patterns ## Technical Updates - Reorganized project architecture for better maintainability - Performance optimizations and bundle size improvements - Enhanced security features and access controls - Improved developer experience with better tooling - Comprehensive testing infrastructure ## New Features - Background rays effect for improved visual feedback - Advanced tab management system - Automatic and manual update support - Enhanced error handling and visualization - Improved accessibility across all components For detailed information about all changes and improvements, please see the full changelog.
This commit is contained in:
163
app/components/@settings/shared/components/DraggableTabList.tsx
Normal file
163
app/components/@settings/shared/components/DraggableTabList.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { motion } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
|
||||
import { TAB_LABELS } from '~/components/@settings/core/types';
|
||||
import { Switch } from '~/components/ui/Switch';
|
||||
|
||||
interface DraggableTabListProps {
|
||||
tabs: TabVisibilityConfig[];
|
||||
onReorder: (tabs: TabVisibilityConfig[]) => void;
|
||||
onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
|
||||
onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
|
||||
showControls?: boolean;
|
||||
}
|
||||
|
||||
interface DraggableTabItemProps {
|
||||
tab: TabVisibilityConfig;
|
||||
index: number;
|
||||
moveTab: (dragIndex: number, hoverIndex: number) => void;
|
||||
showControls?: boolean;
|
||||
onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
|
||||
onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
|
||||
}
|
||||
|
||||
interface DragItem {
|
||||
type: string;
|
||||
index: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const DraggableTabItem = ({
|
||||
tab,
|
||||
index,
|
||||
moveTab,
|
||||
showControls,
|
||||
onWindowChange,
|
||||
onVisibilityChange,
|
||||
}: DraggableTabItemProps) => {
|
||||
const [{ isDragging }, dragRef] = useDrag({
|
||||
type: 'tab',
|
||||
item: { type: 'tab', index, id: tab.id },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [, dropRef] = useDrop({
|
||||
accept: 'tab',
|
||||
hover: (item: DragItem, monitor) => {
|
||||
if (!monitor.isOver({ shallow: true })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.index === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.id === tab.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveTab(item.index, index);
|
||||
item.index = index;
|
||||
},
|
||||
});
|
||||
|
||||
const ref = (node: HTMLDivElement | null) => {
|
||||
dragRef(node);
|
||||
dropRef(node);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: isDragging ? 1.02 : 1,
|
||||
boxShadow: isDragging ? '0 8px 16px rgba(0,0,0,0.1)' : 'none',
|
||||
}}
|
||||
className={classNames(
|
||||
'flex items-center justify-between p-4 rounded-lg',
|
||||
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
isDragging ? 'z-50' : '',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="cursor-grab">
|
||||
<div className="i-ph:dots-six-vertical w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-bolt-elements-textPrimary">{TAB_LABELS[tab.id]}</div>
|
||||
{showControls && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary">
|
||||
Order: {tab.order}, Window: {tab.window}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showControls && !tab.locked && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={tab.visible}
|
||||
onCheckedChange={(checked: boolean) => onVisibilityChange?.(tab, checked)}
|
||||
className="data-[state=checked]:bg-purple-500"
|
||||
aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`}
|
||||
/>
|
||||
<label className="text-sm text-bolt-elements-textSecondary">Visible</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-bolt-elements-textSecondary">User</label>
|
||||
<Switch
|
||||
checked={tab.window === 'developer'}
|
||||
onCheckedChange={(checked: boolean) => onWindowChange?.(tab, checked ? 'developer' : 'user')}
|
||||
className="data-[state=checked]:bg-purple-500"
|
||||
aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`}
|
||||
/>
|
||||
<label className="text-sm text-bolt-elements-textSecondary">Dev</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DraggableTabList = ({
|
||||
tabs,
|
||||
onReorder,
|
||||
onWindowChange,
|
||||
onVisibilityChange,
|
||||
showControls = false,
|
||||
}: DraggableTabListProps) => {
|
||||
const moveTab = (dragIndex: number, hoverIndex: number) => {
|
||||
const items = Array.from(tabs);
|
||||
const [reorderedItem] = items.splice(dragIndex, 1);
|
||||
items.splice(hoverIndex, 0, reorderedItem);
|
||||
|
||||
// Update order numbers based on position
|
||||
const reorderedTabs = items.map((tab, index) => ({
|
||||
...tab,
|
||||
order: index + 1,
|
||||
}));
|
||||
|
||||
onReorder(reorderedTabs);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{tabs.map((tab, index) => (
|
||||
<DraggableTabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
index={index}
|
||||
moveTab={moveTab}
|
||||
showControls={showControls}
|
||||
onWindowChange={onWindowChange}
|
||||
onVisibilityChange={onVisibilityChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
259
app/components/@settings/shared/components/TabManagement.tsx
Normal file
259
app/components/@settings/shared/components/TabManagement.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { Switch } from '@radix-ui/react-switch';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { tabConfigurationStore } from '~/lib/stores/settings';
|
||||
import { TAB_LABELS } from '~/components/@settings/core/constants';
|
||||
import type { TabType } from '~/components/@settings/core/types';
|
||||
import { toast } from 'react-toastify';
|
||||
import { TbLayoutGrid } from 'react-icons/tb';
|
||||
|
||||
// Define tab icons mapping
|
||||
const TAB_ICONS: Record<TabType, string> = {
|
||||
profile: 'i-ph:user-circle-fill',
|
||||
settings: 'i-ph:gear-six-fill',
|
||||
notifications: 'i-ph:bell-fill',
|
||||
features: 'i-ph:star-fill',
|
||||
data: 'i-ph:database-fill',
|
||||
'cloud-providers': 'i-ph:cloud-fill',
|
||||
'local-providers': 'i-ph:desktop-fill',
|
||||
'service-status': 'i-ph:activity-fill',
|
||||
connection: 'i-ph:wifi-high-fill',
|
||||
debug: 'i-ph:bug-fill',
|
||||
'event-logs': 'i-ph:list-bullets-fill',
|
||||
update: 'i-ph:arrow-clockwise-fill',
|
||||
'task-manager': 'i-ph:chart-line-fill',
|
||||
'tab-management': 'i-ph:squares-four-fill',
|
||||
};
|
||||
|
||||
// Define which tabs are default in user mode
|
||||
const DEFAULT_USER_TABS: TabType[] = [
|
||||
'features',
|
||||
'data',
|
||||
'cloud-providers',
|
||||
'local-providers',
|
||||
'connection',
|
||||
'notifications',
|
||||
'event-logs',
|
||||
];
|
||||
|
||||
// Define which tabs can be added to user mode
|
||||
const OPTIONAL_USER_TABS: TabType[] = ['profile', 'settings', 'task-manager', 'service-status', 'debug', 'update'];
|
||||
|
||||
// All available tabs for user mode
|
||||
const ALL_USER_TABS = [...DEFAULT_USER_TABS, ...OPTIONAL_USER_TABS];
|
||||
|
||||
export const TabManagement = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const tabConfiguration = useStore(tabConfigurationStore);
|
||||
|
||||
const handleTabVisibilityChange = (tabId: TabType, checked: boolean) => {
|
||||
// Get current tab configuration
|
||||
const currentTab = tabConfiguration.userTabs.find((tab) => tab.id === tabId);
|
||||
|
||||
// If tab doesn't exist in configuration, create it
|
||||
if (!currentTab) {
|
||||
const newTab = {
|
||||
id: tabId,
|
||||
visible: checked,
|
||||
window: 'user' as const,
|
||||
order: tabConfiguration.userTabs.length,
|
||||
};
|
||||
|
||||
const updatedTabs = [...tabConfiguration.userTabs, newTab];
|
||||
|
||||
tabConfigurationStore.set({
|
||||
...tabConfiguration,
|
||||
userTabs: updatedTabs,
|
||||
});
|
||||
|
||||
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if tab can be enabled in user mode
|
||||
const canBeEnabled = DEFAULT_USER_TABS.includes(tabId) || OPTIONAL_USER_TABS.includes(tabId);
|
||||
|
||||
if (!canBeEnabled && checked) {
|
||||
toast.error('This tab cannot be enabled in user mode');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tab visibility
|
||||
const updatedTabs = tabConfiguration.userTabs.map((tab) => {
|
||||
if (tab.id === tabId) {
|
||||
return { ...tab, visible: checked };
|
||||
}
|
||||
|
||||
return tab;
|
||||
});
|
||||
|
||||
// Update store
|
||||
tabConfigurationStore.set({
|
||||
...tabConfiguration,
|
||||
userTabs: updatedTabs,
|
||||
});
|
||||
|
||||
// Show success message
|
||||
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
|
||||
};
|
||||
|
||||
// Create a map of existing tab configurations
|
||||
const tabConfigMap = new Map(tabConfiguration.userTabs.map((tab) => [tab.id, tab]));
|
||||
|
||||
// Generate the complete list of tabs, including those not in the configuration
|
||||
const allTabs = ALL_USER_TABS.map((tabId) => {
|
||||
return (
|
||||
tabConfigMap.get(tabId) || {
|
||||
id: tabId,
|
||||
visible: false,
|
||||
window: 'user' as const,
|
||||
order: -1,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Filter tabs based on search query
|
||||
const filteredTabs = allTabs.filter((tab) => TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-8 h-8 flex items-center justify-center rounded-lg',
|
||||
'bg-bolt-elements-background-depth-3',
|
||||
'text-purple-500',
|
||||
)}
|
||||
>
|
||||
<TbLayoutGrid className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Tab Management</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Configure visible tabs and their order</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-64">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<div className="i-ph:magnifying-glass w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search tabs..."
|
||||
className={classNames(
|
||||
'w-full pl-10 pr-4 py-2 rounded-lg',
|
||||
'bg-bolt-elements-background-depth-2',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filteredTabs.map((tab, index) => (
|
||||
<motion.div
|
||||
key={tab.id}
|
||||
className={classNames(
|
||||
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
|
||||
'bg-bolt-elements-background-depth-2',
|
||||
'hover:bg-bolt-elements-background-depth-3',
|
||||
'transition-all duration-200',
|
||||
'relative overflow-hidden group',
|
||||
)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
{/* Status Badges */}
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
{DEFAULT_USER_TABS.includes(tab.id) && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
{OPTIONAL_USER_TABS.includes(tab.id) && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium">
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-4">
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'w-10 h-10 flex items-center justify-center rounded-xl',
|
||||
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
||||
'transition-all duration-200',
|
||||
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
||||
)}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<div className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}>
|
||||
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
||||
{TAB_LABELS[tab.id]}
|
||||
</h4>
|
||||
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
||||
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={tab.visible}
|
||||
onCheckedChange={(checked) => handleTabVisibilityChange(tab.id, checked)}
|
||||
disabled={!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id)}
|
||||
className={classNames(
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full',
|
||||
'transition-colors duration-200',
|
||||
tab.visible ? 'bg-purple-500' : 'bg-bolt-elements-background-depth-4',
|
||||
{
|
||||
'opacity-50 cursor-not-allowed':
|
||||
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
||||
animate={{
|
||||
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
||||
scale: tab.visible ? 1 : 0.98,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
162
app/components/@settings/shared/components/TabTile.tsx
Normal file
162
app/components/@settings/shared/components/TabTile.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
|
||||
import { TAB_LABELS, TAB_ICONS } from '~/components/@settings/core/constants';
|
||||
|
||||
interface TabTileProps {
|
||||
tab: TabVisibilityConfig;
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
hasUpdate?: boolean;
|
||||
statusMessage?: string;
|
||||
description?: string;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TabTile = ({
|
||||
tab,
|
||||
onClick,
|
||||
isActive,
|
||||
hasUpdate,
|
||||
statusMessage,
|
||||
description,
|
||||
isLoading,
|
||||
className,
|
||||
}: TabTileProps) => {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<motion.div
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'relative flex flex-col items-center p-6 rounded-xl',
|
||||
'w-full h-full min-h-[160px]',
|
||||
'bg-white dark:bg-[#141414]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'group',
|
||||
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
||||
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
||||
isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '',
|
||||
isLoading ? 'cursor-wait opacity-70' : '',
|
||||
className || '',
|
||||
)}
|
||||
>
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col items-center justify-center flex-1 w-full">
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'relative',
|
||||
'w-14 h-14',
|
||||
'flex items-center justify-center',
|
||||
'rounded-xl',
|
||||
'bg-gray-100 dark:bg-gray-800',
|
||||
'ring-1 ring-gray-200 dark:ring-gray-700',
|
||||
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
|
||||
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
|
||||
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
className={classNames(
|
||||
TAB_ICONS[tab.id],
|
||||
'w-8 h-8',
|
||||
'text-gray-600 dark:text-gray-300',
|
||||
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
|
||||
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Label and Description */}
|
||||
<div className="flex flex-col items-center mt-5 w-full">
|
||||
<h3
|
||||
className={classNames(
|
||||
'text-[15px] font-medium leading-snug mb-2',
|
||||
'text-gray-700 dark:text-gray-200',
|
||||
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
|
||||
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
||||
)}
|
||||
>
|
||||
{TAB_LABELS[tab.id]}
|
||||
</h3>
|
||||
{description && (
|
||||
<p
|
||||
className={classNames(
|
||||
'text-[13px] leading-relaxed',
|
||||
'text-gray-500 dark:text-gray-400',
|
||||
'max-w-[85%]',
|
||||
'text-center',
|
||||
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
|
||||
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
{hasUpdate && (
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'absolute top-3 right-3',
|
||||
'w-2.5 h-2.5 rounded-full',
|
||||
'bg-purple-500',
|
||||
'ring-4 ring-purple-500',
|
||||
)}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', bounce: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'absolute inset-0 rounded-xl z-10',
|
||||
'bg-white dark:bg-black',
|
||||
'flex items-center justify-center',
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className={classNames('w-8 h-8 rounded-full', 'border-2 border-purple-500', 'border-t-purple-500')}
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className={classNames(
|
||||
'px-3 py-1.5 rounded-lg',
|
||||
'bg-[#18181B] text-white',
|
||||
'text-sm font-medium',
|
||||
'select-none',
|
||||
'z-[100]',
|
||||
)}
|
||||
side="top"
|
||||
sideOffset={5}
|
||||
>
|
||||
{statusMessage || TAB_LABELS[tab.id]}
|
||||
<Tooltip.Arrow className="fill-[#18181B]" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user