mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-28 22:28:51 +01:00
feat: Implement animated category transitions in AgentMarketplace and update NewChat component layout
This commit is contained in:
parent
3361236870
commit
ee33084848
3 changed files with 266 additions and 80 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
|
@ -48,6 +48,16 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
const searchQuery = searchParams.get('q') || '';
|
||||
const selectedAgentId = searchParams.get('agent_id') || '';
|
||||
|
||||
// Animation state
|
||||
type Direction = 'left' | 'right';
|
||||
const [displayCategory, setDisplayCategory] = useState<string>(activeTab);
|
||||
const [nextCategory, setNextCategory] = useState<string | null>(null);
|
||||
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
|
||||
const [animationDirection, setAnimationDirection] = useState<Direction>('right');
|
||||
|
||||
// Keep a ref of initial mount to avoid animating first sync
|
||||
const didInitRef = useRef(false);
|
||||
|
||||
// Local state
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
|
||||
|
|
@ -110,22 +120,89 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
};
|
||||
|
||||
/**
|
||||
* Handle category tab selection changes
|
||||
*
|
||||
* @param tabValue - The selected category value
|
||||
* Determine ordered tabs to compute indices for direction
|
||||
*/
|
||||
const orderedTabs = useMemo<string[]>(() => {
|
||||
const dynamic = (categoriesQuery.data || []).map((c) => c.value);
|
||||
// Ensure unique and stable order
|
||||
const set = new Set<string>(['promoted', 'all', ...dynamic]);
|
||||
return Array.from(set);
|
||||
}, [categoriesQuery.data]);
|
||||
|
||||
const getTabIndex = (tab: string): number => {
|
||||
const idx = orderedTabs.indexOf(tab);
|
||||
return idx >= 0 ? idx : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle category tab selection changes with directional animation
|
||||
*/
|
||||
const handleTabChange = (tabValue: string) => {
|
||||
if (tabValue === activeTab || isTransitioning) {
|
||||
// Ignore redundant or rapid clicks during transition
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = getTabIndex(displayCategory);
|
||||
const newIndex = getTabIndex(tabValue);
|
||||
const direction: Direction = newIndex > currentIndex ? 'right' : 'left';
|
||||
|
||||
setAnimationDirection(direction);
|
||||
setNextCategory(tabValue);
|
||||
setIsTransitioning(true);
|
||||
|
||||
// Update URL immediately, preserving current search params
|
||||
const currentSearchParams = searchParams.toString();
|
||||
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
||||
|
||||
// Navigate to the selected category
|
||||
if (tabValue === 'promoted') {
|
||||
navigate(`/agents${searchParamsStr}`);
|
||||
} else {
|
||||
navigate(`/agents/${tabValue}${searchParamsStr}`);
|
||||
}
|
||||
|
||||
// Complete transition after 300ms
|
||||
window.setTimeout(() => {
|
||||
setDisplayCategory(tabValue);
|
||||
setNextCategory(null);
|
||||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync animation when URL changes externally (back/forward or deep links)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!didInitRef.current) {
|
||||
// First render: do not animate; just set display to current active tab
|
||||
didInitRef.current = true;
|
||||
setDisplayCategory(activeTab);
|
||||
return;
|
||||
}
|
||||
if (isTransitioning || activeTab === displayCategory) {
|
||||
return;
|
||||
}
|
||||
// Compute direction vs current displayCategory and animate
|
||||
const currentIndex = getTabIndex(displayCategory);
|
||||
const newIndex = getTabIndex(activeTab);
|
||||
const direction: Direction = newIndex > currentIndex ? 'right' : 'left';
|
||||
|
||||
setAnimationDirection(direction);
|
||||
setNextCategory(activeTab);
|
||||
setIsTransitioning(true);
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDisplayCategory(activeTab);
|
||||
setNextCategory(null);
|
||||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [activeTab, displayCategory, isTransitioning, orderedTabs]);
|
||||
|
||||
// No longer needed with keyframes
|
||||
|
||||
/**
|
||||
* Handle search query changes
|
||||
*
|
||||
|
|
@ -285,63 +362,156 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
|
||||
{/* Scrollable content area */}
|
||||
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-6 mt-6">
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (activeTab === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
{/* Two-pane animated container wrapping category header + grid */}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Current content pane */}
|
||||
<div
|
||||
className={cn(
|
||||
isTransitioning &&
|
||||
(animationDirection === 'right'
|
||||
? 'motion-safe:animate-slide-out-left'
|
||||
: 'motion-safe:animate-slide-out-right'),
|
||||
)}
|
||||
key={`pane-current-${displayCategory}`}
|
||||
>
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-6 mt-6">
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (displayCategory === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
};
|
||||
}
|
||||
if (displayCategory === 'all') {
|
||||
return {
|
||||
name: 'All Agents',
|
||||
description: 'Browse all shared agents across all categories',
|
||||
};
|
||||
}
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === displayCategory,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label,
|
||||
description: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name:
|
||||
displayCategory.charAt(0).toUpperCase() +
|
||||
displayCategory.slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
}
|
||||
if (activeTab === 'all') {
|
||||
return {
|
||||
name: 'All Agents',
|
||||
description: 'Browse all shared agents across all categories',
|
||||
};
|
||||
}
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === activeTab,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label,
|
||||
description: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
const { name, description } = getCategoryData();
|
||||
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
return (
|
||||
<div className="text-left">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-text-secondary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
const { name, description } = getCategoryData();
|
||||
|
||||
return (
|
||||
<div className="text-left">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-text-secondary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
key={`grid-${displayCategory}`}
|
||||
category={displayCategory}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
category={activeTab}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
/>
|
||||
{/* Next content pane, only during transition */}
|
||||
{isTransitioning && nextCategory && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0',
|
||||
animationDirection === 'right'
|
||||
? 'motion-safe:animate-slide-in-right'
|
||||
: 'motion-safe:animate-slide-in-left',
|
||||
)}
|
||||
key={`pane-next-${nextCategory}-${animationDirection}`}
|
||||
>
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-6 mt-6">
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (nextCategory === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
};
|
||||
}
|
||||
if (nextCategory === 'all') {
|
||||
return {
|
||||
name: 'All Agents',
|
||||
description: 'Browse all shared agents across all categories',
|
||||
};
|
||||
}
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === nextCategory,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label,
|
||||
description: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name:
|
||||
(nextCategory || '').charAt(0).toUpperCase() +
|
||||
(nextCategory || '').slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
|
||||
const { name, description } = getCategoryData();
|
||||
|
||||
return (
|
||||
<div className="text-left">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-text-secondary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
key={`grid-${nextCategory}`}
|
||||
category={nextCategory}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent detail dialog */}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,26 @@ export default function NewChat({
|
|||
}
|
||||
/>
|
||||
<div className="flex">
|
||||
{showAgentMarketplace && (
|
||||
<div className="flex">
|
||||
<TooltipAnchor
|
||||
description={localize('com_agents_marketplace')}
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="nav-agents-marketplace-button"
|
||||
aria-label={localize('com_agents_marketplace')}
|
||||
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
|
||||
onClick={handleAgentMarketplace}
|
||||
>
|
||||
<LayoutGrid className="icon-md md:h-6 md:w-6" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{headerButtons}
|
||||
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_chat')}
|
||||
render={
|
||||
|
|
@ -110,29 +129,6 @@ export default function NewChat({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Marketplace button - separate row like ChatGPT */}
|
||||
{showAgentMarketplace && (
|
||||
<div className="flex">
|
||||
<TooltipAnchor
|
||||
description={localize('com_agents_marketplace')}
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="nav-agents-marketplace-button"
|
||||
aria-label={localize('com_agents_marketplace')}
|
||||
className="flex w-full items-center justify-start gap-3 rounded-xl border-none bg-transparent p-3 text-left hover:bg-surface-hover"
|
||||
onClick={handleAgentMarketplace}
|
||||
>
|
||||
<LayoutGrid className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{localize('com_agents_marketplace')}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{subHeaders != null ? subHeaders : null}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,11 +31,31 @@ module.exports = {
|
|||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: 0 },
|
||||
},
|
||||
'slide-in-right': {
|
||||
'0%': { transform: 'translateX(100%)' },
|
||||
'100%': { transform: 'translateX(0)' },
|
||||
},
|
||||
'slide-in-left': {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(0)' },
|
||||
},
|
||||
'slide-out-left': {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(-100%)' },
|
||||
},
|
||||
'slide-out-right': {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'slide-in-right': 'slide-in-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
'slide-in-left': 'slide-in-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
'slide-out-left': 'slide-out-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
'slide-out-right': 'slide-out-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
},
|
||||
colors: {
|
||||
gray: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue