feat(library): enhance library management with folder support and UI improvements

- Added functionality for managing library folders, allowing users to organize agents more effectively.
- Implemented new API endpoints for creating, updating, and deleting folders, as well as moving agents between them.
- Updated the UI to include folder selection and display, enhancing user experience with a tabbed interface for favorites and all agents.
- Integrated drag-and-drop functionality for moving agents into folders.
- Improved loading states and added animations for favorite actions.

This update significantly enhances the organization and usability of the library feature, making it easier for users to manage their agents.
This commit is contained in:
abhi1992002
2026-01-24 13:03:06 +05:30
parent 62bc325d79
commit 4337b67149
18 changed files with 1525 additions and 122 deletions

View File

@@ -84,6 +84,7 @@
"dotenv": "17.2.3",
"elliptic": "6.6.1",
"embla-carousel-react": "8.6.0",
"emoji-picker-react": "4.17.1",
"flatbush": "4.5.0",
"framer-motion": "12.23.24",
"geist": "1.5.1",

View File

@@ -174,6 +174,9 @@ importers:
embla-carousel-react:
specifier: 8.6.0
version: 8.6.0(react@18.3.1)
emoji-picker-react:
specifier: 4.17.1
version: 4.17.1(react@18.3.1)
flatbush:
specifier: 4.5.0
version: 4.5.0
@@ -4989,6 +4992,12 @@ packages:
embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==}
emoji-picker-react@4.17.1:
resolution: {integrity: sha512-DxGGPxHRcH/PnGFZEVkWSNZoFg8UO2/kikZrp/OZ8CBz5F/mKJbKLcd1anxeV8Hu1ZzY8MBuNnFG2wSJYkf1Ug==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -5366,6 +5375,9 @@ packages:
resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==}
engines: {node: '>=18'}
flairup@1.0.0:
resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==}
flat-cache@3.2.0:
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
engines: {node: ^10.12.0 || >=12.0.0}
@@ -13743,6 +13755,11 @@ snapshots:
embla-carousel@8.6.0: {}
emoji-picker-react@4.17.1(react@18.3.1):
dependencies:
flairup: 1.0.0
react: 18.3.1
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@@ -14313,6 +14330,8 @@ snapshots:
path-exists: 5.0.0
unicorn-magic: 0.1.0
flairup@1.0.0: {}
flat-cache@3.2.0:
dependencies:
flatted: 3.3.3

View File

@@ -1,17 +1,25 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { Text } from "@/components/atoms/Text/Text";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { HeartIcon } from "@phosphor-icons/react";
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
import { LibraryTabs, Tab } from "../LibraryTabs/LibraryTabs";
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
interface Props {
searchTerm: string;
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
setLibrarySort: (value: LibraryAgentSort) => void;
}
export function FavoritesSection({ searchTerm }: Props) {
export function FavoritesSection({ searchTerm, tabs, activeTab, onTabChange, setLibrarySort }: Props) {
const {
allAgents: favoriteAgents,
agentLoading: isLoading,
@@ -21,38 +29,26 @@ export function FavoritesSection({ searchTerm }: Props) {
isFetchingNextPage,
} = useFavoriteAgents({ searchTerm });
if (isLoading || favoriteAgents.length === 0) {
return null;
}
return (
<div className="!mb-8">
<div className="mb-3 flex items-center gap-2 p-2">
<HeartIcon className="h-5 w-5" weight="fill" />
<div className="flex items-baseline gap-2">
<Text variant="h4">Favorites</Text>
{!isLoading && (
<Text
variant="body"
data-testid="agents-count"
className="relative bottom-px text-zinc-500"
>
{agentCount}
</Text>
)}
</div>
</div>
<>
<LibraryActionSubHeader agentCount={agentCount} setLibrarySort={setLibrarySort} />
<LibraryTabs tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} />
<div className="relative">
{isLoading ? (
<div className="flex h-[200px] items-center justify-center">
<LoadingSpinner size="large" />
</div>
) : favoriteAgents.length === 0 ? (
<div className="flex h-[200px] flex-col items-center justify-center gap-2 text-zinc-500">
<HeartIcon className="h-10 w-10" />
<Text variant="body">No favorite agents yet</Text>
</div>
) : (
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
loader={
<div className="flex h-8 w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800" />
</div>
}
loader={<LoadingSpinner size="medium" />}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{favoriteAgents.map((agent: LibraryAgent) => (
@@ -60,9 +56,7 @@ export function FavoritesSection({ searchTerm }: Props) {
))}
</div>
</InfiniteScroll>
</div>
{favoriteAgents.length > 0 && <div className="!mt-10 border-t" />}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { HeartIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
interface FlyingHeartProps {
startPosition: { x: number; y: number } | null;
targetPosition: { x: number; y: number } | null;
onAnimationComplete: () => void;
}
export function FlyingHeart({
startPosition,
targetPosition,
onAnimationComplete,
}: FlyingHeartProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (startPosition && targetPosition) {
setIsVisible(true);
}
}, [startPosition, targetPosition]);
if (!startPosition || !targetPosition) return null;
return (
<AnimatePresence>
{isVisible && (
<motion.div
className="pointer-events-none fixed z-50"
initial={{
x: startPosition.x,
y: startPosition.y,
scale: 1,
opacity: 1,
}}
animate={{
x: targetPosition.x,
y: targetPosition.y,
scale: 0.5,
opacity: 0,
}}
exit={{ opacity: 0 }}
transition={{
type: "spring",
damping: 20,
stiffness: 200,
duration: 0.5,
}}
onAnimationComplete={() => {
setIsVisible(false);
onAnimationComplete();
}}
>
<HeartIcon
size={24}
weight="fill"
className="text-red-500 drop-shadow-md"
/>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -13,7 +13,7 @@ export function LibraryActionSubHeader({ agentCount, setLibrarySort }: Props) {
return (
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-4">
<Text variant="h4">My agents</Text>
<Text variant="h5">My agents</Text>
<Text
variant="body"
data-testid="agents-count"

View File

@@ -4,6 +4,7 @@ import { Text } from "@/components/atoms/Text/Text";
import { CaretCircleRightIcon } from "@phosphor-icons/react";
import Image from "next/image";
import NextLink from "next/link";
import { motion } from "framer-motion";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import Avatar, {
@@ -14,13 +15,24 @@ import { Link } from "@/components/atoms/Link/Link";
import { AgentCardMenu } from "./components/AgentCardMenu";
import { FavoriteButton } from "./components/FavoriteButton";
import { useLibraryAgentCard } from "./useLibraryAgentCard";
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
interface Props {
agent: LibraryAgent;
draggable?: boolean;
}
export function LibraryAgentCard({ agent }: Props) {
export function LibraryAgentCard({
agent,
draggable = true,
}: Props) {
const { id, name, graph_id, can_access_graph, image_url } = agent;
const { triggerFavoriteAnimation } = useFavoriteAnimation();
function handleDragStart(e: React.DragEvent<HTMLDivElement>) {
e.dataTransfer.setData("application/agent-id", id);
e.dataTransfer.effectAllowed = "move";
}
const {
isFromMarketplace,
@@ -28,14 +40,29 @@ export function LibraryAgentCard({ agent }: Props) {
profile,
creator_image_url,
handleToggleFavorite,
} = useLibraryAgentCard({ agent });
} = useLibraryAgentCard({
agent,
onFavoriteAdd: triggerFavoriteAnimation,
});
return (
<div
data-testid="library-agent-card"
data-agent-id={id}
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white transition-all duration-300 hover:shadow-md"
draggable={draggable}
onDragStart={handleDragStart}
className="cursor-grab active:cursor-grabbing"
>
<motion.div
layoutId={`agent-card-${id}`}
data-testid="library-agent-card"
data-agent-id={id}
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white hover:shadow-md"
transition={{
type: "spring",
damping: 25,
stiffness: 300,
}}
style={{ willChange: "transform" }}
>
<NextLink href={`/library/agents/${id}`} className="flex-shrink-0">
<div className="relative flex items-center gap-2 px-4 pt-3">
<Avatar className="h-4 w-4 rounded-full">
@@ -125,6 +152,7 @@ export function LibraryAgentCard({ agent }: Props) {
)}
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -3,10 +3,12 @@
import { cn } from "@/lib/utils";
import { HeartIcon } from "@phosphor-icons/react";
import type { MouseEvent } from "react";
import { useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface FavoriteButtonProps {
isFavorite: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
onClick: (e: MouseEvent<HTMLButtonElement>, position: { x: number; y: number }) => void;
className?: string;
}
@@ -15,25 +17,46 @@ export function FavoriteButton({
onClick,
className,
}: FavoriteButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
function handleClick(e: MouseEvent<HTMLButtonElement>) {
const rect = buttonRef.current?.getBoundingClientRect();
const position = rect
? { x: rect.left + rect.width / 2 - 12, y: rect.top + rect.height / 2 - 12 }
: { x: 0, y: 0 };
onClick(e, position);
}
return (
<button
onClick={onClick}
ref={buttonRef}
onClick={handleClick}
className={cn(
"rounded-full p-2 transition-all duration-200",
"hover:scale-110",
"hover:scale-110 active:scale-95",
!isFavorite && "opacity-0 group-hover:opacity-100",
className,
)}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
<HeartIcon
size={20}
weight={isFavorite ? "fill" : "regular"}
className={cn(
"transition-colors duration-200",
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
)}
/>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={isFavorite ? "filled" : "empty"}
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ type: "spring", damping: 15, stiffness: 300 }}
>
<HeartIcon
size={20}
weight={isFavorite ? "fill" : "regular"}
className={cn(
"transition-colors duration-200",
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
)}
/>
</motion.div>
</AnimatePresence>
</button>
);
}

View File

@@ -14,11 +14,11 @@ import { updateFavoriteInQueries } from "./helpers";
interface Props {
agent: LibraryAgent;
onFavoriteAdd?: (position: { x: number; y: number }) => void;
}
export function useLibraryAgentCard({ agent }: Props) {
const { id, name, is_favorite, creator_image_url, marketplace_listing } =
agent;
export function useLibraryAgentCard({ agent, onFavoriteAdd }: Props) {
const { id, is_favorite, creator_image_url, marketplace_listing } = agent;
const isFromMarketplace = Boolean(marketplace_listing);
const [isFavorite, setIsFavorite] = useState(is_favorite);
@@ -49,26 +49,31 @@ export function useLibraryAgentCard({ agent }: Props) {
});
}
async function handleToggleFavorite(e: React.MouseEvent) {
async function handleToggleFavorite(
e: React.MouseEvent,
position: { x: number; y: number }
) {
e.preventDefault();
e.stopPropagation();
const newIsFavorite = !isFavorite;
// Optimistic update - update UI immediately
setIsFavorite(newIsFavorite);
updateQueryData(newIsFavorite);
// Trigger animation immediately for adding to favorites
if (newIsFavorite && onFavoriteAdd) {
onFavoriteAdd(position);
}
try {
await updateLibraryAgent({
libraryAgentId: id,
data: { is_favorite: newIsFavorite },
});
toast({
title: newIsFavorite ? "Added to favorites" : "Removed from favorites",
description: `${name} has been ${newIsFavorite ? "added to" : "removed from"} your favorites.`,
});
} catch {
// Revert on failure
setIsFavorite(!newIsFavorite);
updateQueryData(!newIsFavorite);

View File

@@ -6,18 +6,53 @@ import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionS
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
import { useLibraryAgentList } from "./useLibraryAgentList";
import { LibraryFolder } from "../LibraryFolder/LibraryFolder";
import {
useGetV2ListLibraryFolders,
usePostV2BulkMoveAgents,
getGetV2ListLibraryFoldersQueryKey,
} from "@/app/api/__generated__/endpoints/folders/folders";
import { okData } from "@/app/api/helpers";
import { LibrarySubSection } from "../LibrarySubSection/LibrarySubSection";
import { useQueryClient } from "@tanstack/react-query";
import { getGetV2ListLibraryAgentsQueryKey } from "@/app/api/__generated__/endpoints/library/library";
import { Button } from "@/components/atoms/Button/Button";
import { ArrowLeftIcon, HeartIcon } from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
import { Tab } from "../LibraryTabs/LibraryTabs";
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
import { LayoutGroup } from "framer-motion";
interface Props {
searchTerm: string;
librarySort: LibraryAgentSort;
setLibrarySort: (value: LibraryAgentSort) => void;
selectedFolderId: string | null;
onFolderSelect: (folderId: string | null) => void;
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export function LibraryAgentList({
searchTerm,
librarySort,
setLibrarySort,
selectedFolderId,
onFolderSelect,
tabs,
activeTab,
onTabChange,
}: Props) {
const isFavoritesTab = activeTab === "favorites";
const allAgentsData = useLibraryAgentList({
searchTerm,
librarySort,
folderId: selectedFolderId,
});
const favoriteAgentsData = useFavoriteAgents({ searchTerm });
const {
agentLoading,
agentCount,
@@ -25,7 +60,40 @@ export function LibraryAgentList({
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useLibraryAgentList({ searchTerm, librarySort });
} = isFavoritesTab ? favoriteAgentsData : allAgentsData;
const { data: foldersData } = useGetV2ListLibraryFolders(undefined, {
query: { select: okData },
});
const queryClient = useQueryClient();
const { mutate: moveAgentToFolder } = usePostV2BulkMoveAgents({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
},
},
});
function handleAgentDrop(agentId: string, folderId: string) {
moveAgentToFolder({
data: {
agent_ids: [agentId],
folder_id: folderId,
},
});
}
const currentFolder = selectedFolderId
? foldersData?.folders.find((f) => f.id === selectedFolderId)
: null;
const showFolders = !isFavoritesTab && !selectedFolderId;
return (
<>
@@ -33,11 +101,42 @@ export function LibraryAgentList({
agentCount={agentCount}
setLibrarySort={setLibrarySort}
/>
<div className="px-2">
{!selectedFolderId && (
<LibrarySubSection
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
/>
)}
<div>
{selectedFolderId && (
<div className="mb-4 flex items-center gap-3">
<Button
variant="ghost"
size="small"
onClick={() => onFolderSelect(null)}
className="gap-2"
>
<ArrowLeftIcon className="h-4 w-4" />
Back to Library
</Button>
{currentFolder && (
<Text variant="h4" className="text-zinc-700">
{currentFolder.icon} {currentFolder.name}
</Text>
)}
</div>
)}
{agentLoading ? (
<div className="flex h-[200px] items-center justify-center">
<LoadingSpinner size="large" />
</div>
) : isFavoritesTab && agents.length === 0 ? (
<div className="flex h-[200px] flex-col items-center justify-center gap-2 text-zinc-500">
<HeartIcon className="h-10 w-10" />
<Text variant="body">No favorite agents yet</Text>
</div>
) : (
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
@@ -45,16 +144,26 @@ export function LibraryAgentList({
hasNextPage={hasNextPage}
loader={<LoadingSpinner size="medium" />}
>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<LibraryFolder name="Github Agents" agentCount={34} color="blue" icon="🤨"/>
<LibraryFolder name="Linear Agents" agentCount={3} color="green" icon="☘️"/>
<LibraryFolder name="Discord Agents" agentCount={32} color="red" icon="🚀"/>
<LibraryFolder name="Telegram Agents" agentCount={12} color="purple" icon="💬"/>
<LibraryFolder name="Email Agents" agentCount={10} color="yellow" icon="👍"/>
{agents.map((agent) => (
<LibraryAgentCard key={agent.id} agent={agent} />
))}
</div>
<LayoutGroup>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{showFolders &&
foldersData?.folders.map((folder) => (
<LibraryFolder
key={folder.id}
id={folder.id}
name={folder.name}
agentCount={folder.agent_count ?? 0}
color={folder.color ?? undefined}
icon={folder.icon ?? "📁"}
onAgentDrop={handleAgentDrop}
onClick={() => onFolderSelect(folder.id)}
/>
))}
{agents.map((agent) => (
<LibraryAgentCard key={agent.id} agent={agent} />
))}
</div>
</LayoutGroup>
</InfiniteScroll>
)}
</div>

View File

@@ -13,9 +13,14 @@ import { useEffect, useRef } from "react";
interface Props {
searchTerm: string;
librarySort: LibraryAgentSort;
folderId?: string | null;
}
export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
export function useLibraryAgentList({
searchTerm,
librarySort,
folderId,
}: Props) {
const queryClient = getQueryClient();
const prevSortRef = useRef<LibraryAgentSort | null>(null);
@@ -31,6 +36,8 @@ export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
page_size: 20,
search_term: searchTerm || undefined,
sort_by: librarySort,
folder_id: folderId ?? undefined,
include_root_only: folderId === null ? true : undefined,
},
{
query: {

View File

@@ -3,7 +3,7 @@ import { motion } from "framer-motion";
import { Text } from "@/components/atoms/Text/Text";
type FolderSize = "xs" | "sm" | "md" | "lg" | "xl";
export type FolderColor =
export type FolderColorName =
| "neutral"
| "slate"
| "zinc"
@@ -26,6 +26,28 @@ export type FolderColor =
| "pink"
| "rose";
export type FolderColor = FolderColorName | (string & {});
const hexToColorName: Record<string, FolderColorName> = {
"#3B82F6": "blue",
"#3b82f6": "blue",
"#A855F7": "purple",
"#a855f7": "purple",
"#10B981": "emerald",
"#10b981": "emerald",
"#F97316": "orange",
"#f97316": "orange",
"#EC4899": "pink",
"#ec4899": "pink",
};
function resolveColor(color: FolderColor | undefined): FolderColorName {
if (!color) return "blue";
if (color in hexToColorName) return hexToColorName[color];
if (color in colorMap) return color as FolderColorName;
return "blue";
}
interface Props {
className?: string;
size?: FolderSize | number;
@@ -35,7 +57,7 @@ interface Props {
}
const sizeMap: Record<FolderSize, number> = {
xs: 0.5,
xs: 0.4,
sm: 0.75,
md: 1,
lg: 1.25,
@@ -43,30 +65,162 @@ const sizeMap: Record<FolderSize, number> = {
};
const colorMap: Record<
FolderColor,
{ bg: string; border: string; borderLight: string; fill: string; stroke: string }
FolderColorName,
{
bg: string;
border: string;
borderLight: string;
fill: string;
stroke: string;
}
> = {
neutral: { bg: "bg-neutral-300", border: "border-neutral-300", borderLight: "border-neutral-200", fill: "fill-neutral-300", stroke: "stroke-neutral-400" },
slate: { bg: "bg-slate-300", border: "border-slate-300", borderLight: "border-slate-200", fill: "fill-slate-300", stroke: "stroke-slate-400" },
zinc: { bg: "bg-zinc-300", border: "border-zinc-300", borderLight: "border-zinc-200", fill: "fill-zinc-300", stroke: "stroke-zinc-400" },
stone: { bg: "bg-stone-300", border: "border-stone-300", borderLight: "border-stone-200", fill: "fill-stone-300", stroke: "stroke-stone-400" },
red: { bg: "bg-red-300", border: "border-red-300", borderLight: "border-red-200", fill: "fill-red-300", stroke: "stroke-red-400" },
orange: { bg: "bg-orange-200", border: "border-orange-200", borderLight: "border-orange-200", fill: "fill-orange-200", stroke: "stroke-orange-400" },
amber: { bg: "bg-amber-200", border: "border-amber-200", borderLight: "border-amber-200", fill: "fill-amber-200", stroke: "stroke-amber-400" },
yellow: { bg: "bg-yellow-200", border: "border-yellow-200", borderLight: "border-yellow-200", fill: "fill-yellow-200", stroke: "stroke-yellow-400" },
lime: { bg: "bg-lime-300", border: "border-lime-300", borderLight: "border-lime-200", fill: "fill-lime-300", stroke: "stroke-lime-400" },
green: { bg: "bg-green-200", border: "border-green-200", borderLight: "border-green-200", fill: "fill-green-200", stroke: "stroke-green-400" },
emerald: { bg: "bg-emerald-300", border: "border-emerald-300", borderLight: "border-emerald-200", fill: "fill-emerald-300", stroke: "stroke-emerald-400" },
teal: { bg: "bg-teal-300", border: "border-teal-300", borderLight: "border-teal-200", fill: "fill-teal-300", stroke: "stroke-teal-400" },
cyan: { bg: "bg-cyan-300", border: "border-cyan-300", borderLight: "border-cyan-200", fill: "fill-cyan-300", stroke: "stroke-cyan-400" },
sky: { bg: "bg-sky-300", border: "border-sky-300", borderLight: "border-sky-200", fill: "fill-sky-300", stroke: "stroke-sky-400" },
blue: { bg: "bg-blue-300", border: "border-blue-300", borderLight: "border-blue-200", fill: "fill-blue-300", stroke: "stroke-blue-400" },
indigo: { bg: "bg-indigo-300", border: "border-indigo-300", borderLight: "border-indigo-200", fill: "fill-indigo-300", stroke: "stroke-indigo-400" },
violet: { bg: "bg-violet-300", border: "border-violet-300", borderLight: "border-violet-200", fill: "fill-violet-300", stroke: "stroke-violet-400" },
purple: { bg: "bg-purple-200", border: "border-purple-200", borderLight: "border-purple-200", fill: "fill-purple-200", stroke: "stroke-purple-400" },
fuchsia: { bg: "bg-fuchsia-300", border: "border-fuchsia-300", borderLight: "border-fuchsia-200", fill: "fill-fuchsia-300", stroke: "stroke-fuchsia-400" },
pink: { bg: "bg-pink-300", border: "border-pink-300", borderLight: "border-pink-200", fill: "fill-pink-300", stroke: "stroke-pink-400" },
rose: { bg: "bg-rose-300", border: "border-rose-300", borderLight: "border-rose-200", fill: "fill-rose-300", stroke: "stroke-rose-400" },
neutral: {
bg: "bg-neutral-300",
border: "border-neutral-300",
borderLight: "border-neutral-200",
fill: "fill-neutral-300",
stroke: "stroke-neutral-400",
},
slate: {
bg: "bg-slate-300",
border: "border-slate-300",
borderLight: "border-slate-200",
fill: "fill-slate-300",
stroke: "stroke-slate-400",
},
zinc: {
bg: "bg-zinc-300",
border: "border-zinc-300",
borderLight: "border-zinc-200",
fill: "fill-zinc-300",
stroke: "stroke-zinc-400",
},
stone: {
bg: "bg-stone-300",
border: "border-stone-300",
borderLight: "border-stone-200",
fill: "fill-stone-300",
stroke: "stroke-stone-400",
},
red: {
bg: "bg-red-300",
border: "border-red-300",
borderLight: "border-red-200",
fill: "fill-red-300",
stroke: "stroke-red-400",
},
orange: {
bg: "bg-orange-200",
border: "border-orange-200",
borderLight: "border-orange-200",
fill: "fill-orange-200",
stroke: "stroke-orange-400",
},
amber: {
bg: "bg-amber-200",
border: "border-amber-200",
borderLight: "border-amber-200",
fill: "fill-amber-200",
stroke: "stroke-amber-400",
},
yellow: {
bg: "bg-yellow-200",
border: "border-yellow-200",
borderLight: "border-yellow-200",
fill: "fill-yellow-200",
stroke: "stroke-yellow-400",
},
lime: {
bg: "bg-lime-300",
border: "border-lime-300",
borderLight: "border-lime-200",
fill: "fill-lime-300",
stroke: "stroke-lime-400",
},
green: {
bg: "bg-green-200",
border: "border-green-200",
borderLight: "border-green-200",
fill: "fill-green-200",
stroke: "stroke-green-400",
},
emerald: {
bg: "bg-emerald-300",
border: "border-emerald-300",
borderLight: "border-emerald-200",
fill: "fill-emerald-300",
stroke: "stroke-emerald-400",
},
teal: {
bg: "bg-teal-300",
border: "border-teal-300",
borderLight: "border-teal-200",
fill: "fill-teal-300",
stroke: "stroke-teal-400",
},
cyan: {
bg: "bg-cyan-300",
border: "border-cyan-300",
borderLight: "border-cyan-200",
fill: "fill-cyan-300",
stroke: "stroke-cyan-400",
},
sky: {
bg: "bg-sky-300",
border: "border-sky-300",
borderLight: "border-sky-200",
fill: "fill-sky-300",
stroke: "stroke-sky-400",
},
blue: {
bg: "bg-blue-300",
border: "border-blue-300",
borderLight: "border-blue-200",
fill: "fill-blue-300",
stroke: "stroke-blue-400",
},
indigo: {
bg: "bg-indigo-300",
border: "border-indigo-300",
borderLight: "border-indigo-200",
fill: "fill-indigo-300",
stroke: "stroke-indigo-400",
},
violet: {
bg: "bg-violet-300",
border: "border-violet-300",
borderLight: "border-violet-200",
fill: "fill-violet-300",
stroke: "stroke-violet-400",
},
purple: {
bg: "bg-purple-200",
border: "border-purple-200",
borderLight: "border-purple-200",
fill: "fill-purple-200",
stroke: "stroke-purple-400",
},
fuchsia: {
bg: "bg-fuchsia-300",
border: "border-fuchsia-300",
borderLight: "border-fuchsia-200",
fill: "fill-fuchsia-300",
stroke: "stroke-fuchsia-400",
},
pink: {
bg: "bg-pink-300",
border: "border-pink-300",
borderLight: "border-pink-200",
fill: "fill-pink-300",
stroke: "stroke-pink-400",
},
rose: {
bg: "bg-rose-300",
border: "border-rose-300",
borderLight: "border-rose-200",
fill: "fill-rose-300",
stroke: "stroke-rose-400",
},
};
export function FolderIcon({
@@ -77,7 +231,8 @@ export function FolderIcon({
isOpen = false,
}: Props) {
const scale = typeof size === "number" ? size : sizeMap[size];
const colors = colorMap[color];
const resolvedColor = resolveColor(color);
const colors = colorMap[resolvedColor];
return (
<div
@@ -138,7 +293,7 @@ export function FolderIcon({
transition={page.transition}
className={`absolute top-2 h-fit w-32 rounded-xl shadow-lg ${page.className}`}
>
<Page color={color} />
<Page color={resolvedColor} />
</motion.div>
))}
</div>
@@ -152,7 +307,7 @@ export function FolderIcon({
style={{ transformStyle: "preserve-3d" }}
>
<svg
className="h-auto w-full "
className="h-auto w-full"
viewBox="0 0 173 109"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -162,10 +317,9 @@ export function FolderIcon({
className={`${colors.fill} ${colors.stroke}`}
d="M15.0423 0.500003C0.5 0.500009 0.5 14.2547 0.5 14.2547V92.5C0.5 101.337 7.66344 108.5 16.5 108.5H156.5C165.337 108.5 172.5 101.337 172.5 92.5V34.3302C172.5 25.4936 165.355 18.3302 156.519 18.3302H108.211C98.1341 18.3302 91.2921 5.57144 82.0156 1.63525C80.3338 0.921645 78.2634 0.500002 75.7187 0.500003H15.0423Z"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center text-7xl">
{icon}
{icon}
</div>
</motion.div>
</div>
@@ -174,7 +328,7 @@ export function FolderIcon({
}
interface PageProps {
color: FolderColor;
color: FolderColorName;
}
function Page({ color = "blue" }: PageProps) {
@@ -184,7 +338,9 @@ function Page({ color = "blue" }: PageProps) {
className={`h-full w-full rounded-xl border bg-white p-4 ${colors.borderLight}`}
>
<div className="flex flex-col gap-2">
<Text variant="h5" className="text-black">agent.json</Text>
<Text variant="h5" className="text-black">
agent.json
</Text>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex gap-2">
<div className="h-1.5 flex-1 rounded-full bg-neutral-100" />

View File

@@ -4,24 +4,24 @@ import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import { FolderIcon, FolderColor } from "./FolderIcon";
import { useState } from "react";
import {
PencilSimpleIcon,
TrashIcon,
StarIcon,
} from "@phosphor-icons/react";
import { PencilSimpleIcon, TrashIcon, HeartIcon } from "@phosphor-icons/react";
interface Props {
id: string;
name: string;
agentCount: number;
color: FolderColor;
color?: FolderColor;
icon: string;
onEdit?: () => void;
onDelete?: () => void;
onFavorite?: () => void;
onAgentDrop?: (agentId: string, folderId: string) => void;
onClick?: () => void;
isFavorite?: boolean;
}
export function LibraryFolder({
id,
name,
agentCount,
color,
@@ -29,16 +29,49 @@ export function LibraryFolder({
onEdit,
onDelete,
onFavorite,
onAgentDrop,
onClick,
isFavorite = false,
}: Props) {
const [isHovered, setIsHovered] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
if (e.dataTransfer.types.includes("application/agent-id")) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsDragOver(true);
}
}
function handleDragLeave() {
setIsDragOver(false);
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDragOver(false);
const agentId = e.dataTransfer.getData("application/agent-id");
if (agentId && onAgentDrop) {
onAgentDrop(agentId, id);
}
}
return (
<div
data-testid="library-folder"
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] cursor-pointer flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white p-4 transition-all duration-200 hover:shadow-md"
data-folder-id={id}
className={`group relative inline-flex h-[10.625rem] w-full max-w-[25rem] cursor-pointer flex-col items-start justify-start gap-2.5 rounded-medium border bg-white p-4 transition-all duration-200 hover:shadow-md ${
isDragOver
? "border-blue-400 bg-blue-50 ring-2 ring-blue-200"
: "border-zinc-100"
}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={onClick}
>
<div className="flex w-full items-start justify-between gap-4">
{/* Left side - Folder name and agent count */}
@@ -67,7 +100,7 @@ export function LibraryFolder({
{/* Action buttons - visible on hover */}
<div
className="flex items-center justify-end gap-2"
className="flex items-center justify-end gap-2"
data-testid="library-folder-actions"
>
<Button
@@ -80,7 +113,7 @@ export function LibraryFolder({
}}
className="h-8 w-8 p-2"
>
<StarIcon
<HeartIcon
className="h-4 w-4"
weight={isFavorite ? "fill" : "regular"}
color={isFavorite ? "#facc15" : "currentColor"}

View File

@@ -0,0 +1,230 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Select } from "@/components/atoms/Select/Select";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/molecules/Form/Form";
import { zodResolver } from "@hookform/resolvers/zod";
import { FolderSimpleIcon } from "@phosphor-icons/react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { EmojiStyle } from "emoji-picker-react";
import { usePostV2CreateFolder } from "@/app/api/__generated__/endpoints/folders/folders";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
// Dynamically import EmojiPicker to avoid SSR issues
const EmojiPicker = dynamic(() => import("emoji-picker-react"), {
ssr: false,
loading: () => (
<div className="flex h-[350px] items-center justify-center">
<Text variant="small" className="text-zinc-400">
Loading emoji picker...
</Text>
</div>
),
});
const FOLDER_COLORS = [
{ value: "#3B82F6", label: "Blue" },
{ value: "#A855F7", label: "Purple" },
{ value: "#10B981", label: "Green" },
{ value: "#F97316", label: "Orange" },
{ value: "#EC4899", label: "Pink" },
];
export const libraryFolderCreationFormSchema = z.object({
folderName: z.string().min(1, "Folder name is required"),
folderColor: z.string().min(1, "Folder color is required"),
folderIcon: z.string().min(1, "Folder icon is required"),
});
export default function LibraryFolderCreationDialog() {
const [isOpen, setIsOpen] = useState(false);
const queryClient = useQueryClient();
const { toast } = useToast();
const { mutate: createFolder, isPending } = usePostV2CreateFolder({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["getV2ListLibraryFolders"] });
setIsOpen(false);
form.reset();
toast({
title: "Folder created",
description: "Your folder has been created successfully.",
});
},
onError: () => {
toast({
title: "Error",
description: "Failed to create folder. Please try again.",
variant: "destructive",
});
},
},
});
const form = useForm<z.infer<typeof libraryFolderCreationFormSchema>>({
resolver: zodResolver(libraryFolderCreationFormSchema),
defaultValues: {
folderName: "",
folderColor: "",
folderIcon: "",
},
});
function onSubmit(values: z.infer<typeof libraryFolderCreationFormSchema>) {
createFolder({
data: {
name: values.folderName,
color: values.folderColor,
icon: values.folderIcon,
},
});
}
return (
<Dialog
title="Create Folder"
styling={{ maxWidth: "30rem" }}
controlled={{
isOpen,
set: setIsOpen,
}}
onClose={() => {
setIsOpen(false);
}}
>
<Dialog.Trigger>
<Button
data-testid="upload-agent-button"
variant="secondary"
className="h-fit w-fit"
size="small"
>
<FolderSimpleIcon width={18} height={18} />
<span className="create-folder">Create folder</span>
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Form
form={form}
onSubmit={(values) => onSubmit(values)}
className="flex flex-col justify-center gap-4 px-1"
>
<FormField
control={form.control}
name="folderName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
id={field.name}
label="Folder name"
placeholder="Enter folder name"
className="w-full rounded-[10px]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="folderColor"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
id="folderColor"
label="Folder color"
placeholder="Select a color"
value={field.value}
onValueChange={field.onChange}
options={FOLDER_COLORS.map((color) => ({
value: color.value,
label: color.label,
icon: (
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: color.value }}
/>
),
}))}
renderItem={(option) => (
<div className="flex items-center gap-2">
{option.icon}
<span>{option.label}</span>
</div>
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="folderIcon"
render={({ field }) => (
<FormItem>
<div className="flex flex-col gap-2">
<Text variant="large-medium" as="span" className="text-black">
Folder icon
</Text>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Text variant="small" className="text-zinc-500">
Selected:
</Text>
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 text-2xl">
{form.watch("folderIcon") || (
<span className="text-sm text-zinc-400"></span>
)}
</div>
</div>
<EmojiPicker
onEmojiClick={(emojiData) => {
field.onChange(emojiData.emoji);
}}
width="100%"
height="295px"
style={{ borderRadius: "20px" }}
emojiStyle={EmojiStyle.APPLE}
searchPlaceHolder="Search emoji..."
previewConfig={{ showPreview: false }}
/>
</div>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button
type="submit"
variant="primary"
className="mt-2 min-w-[18rem]"
disabled={!form.formState.isValid || isPending}
loading={isPending}
>
Create
</Button>
</Form>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,17 @@
import LibraryFolderCreationDialog from "../LibraryFolderCreationDialog/LibraryFolderCreationDialog";
import { LibraryTabs, Tab } from "../LibraryTabs/LibraryTabs";
interface Props {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export function LibrarySubSection({ tabs, activeTab, onTabChange }: Props) {
return (
<div className="flex justify-between items-center gap-4">
<LibraryTabs tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} />
<LibraryFolderCreationDialog />
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { Icon } from "@phosphor-icons/react";
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
export interface Tab {
id: string;
title: string;
icon: Icon;
}
interface Props {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
layoutId?: string;
}
export function LibraryTabs({ tabs, activeTab, onTabChange, layoutId = "library-tabs" }: Props) {
const { registerFavoritesTabRef } = useFavoriteAnimation();
return (
<div className="flex gap-2 items-center">
{tabs.map((tab) => (
<TabButton
key={tab.id}
tab={tab}
isActive={activeTab === tab.id}
onSelect={onTabChange}
layoutId={layoutId}
onRefReady={tab.id === "favorites" ? registerFavoritesTabRef : undefined}
/>
))}
</div>
);
}
interface TabButtonProps {
tab: Tab;
isActive: boolean;
onSelect: (tabId: string) => void;
layoutId: string;
onRefReady?: (element: HTMLElement | null) => void;
}
function TabButton({ tab, isActive, onSelect, layoutId, onRefReady }: TabButtonProps) {
const [isLoaded, setIsLoaded] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isActive && !isLoaded) {
setIsLoaded(true);
}
}, [isActive, isLoaded]);
useEffect(() => {
if (onRefReady) {
onRefReady(buttonRef.current);
}
}, [onRefReady]);
const ButtonIcon = tab.icon;
const activeColor = "text-primary";
return (
<motion.div
ref={buttonRef}
layoutId={`${layoutId}-button-${tab.id}`}
transition={{
layout: {
type: "spring",
damping: 20,
stiffness: 230,
mass: 1.2,
ease: [0.215, 0.61, 0.355, 1],
},
}}
onClick={() => {
onSelect(tab.id);
setIsLoaded(true);
}}
className="w-fit h-fit flex"
style={{ willChange: "transform" }}
>
<motion.div
layout
transition={{
layout: {
type: "spring",
damping: 20,
stiffness: 230,
mass: 1.2,
},
}}
className={cn(
"flex items-center gap-1.5 bg-zinc-200 border-zinc-200 text-black hover:bg-zinc-300 hover:border-zinc-300 overflow-hidden transition-colors duration-75 ease-out py-2 px-3 cursor-pointer h-fit",
isActive && activeColor,
isActive ? "px-4" : "px-3"
)}
style={{
borderRadius: "25px",
}}
>
<motion.div
layoutId={`${layoutId}-icon-${tab.id}`}
className="shrink-0"
>
<ButtonIcon size={18} />
</motion.div>
{isActive && (
<motion.div
className="flex items-center"
initial={isLoaded ? { opacity: 0, filter: "blur(4px)" } : false}
animate={{ opacity: 1, filter: "blur(0px)" }}
transition={{
duration: isLoaded ? 0.2 : 0,
ease: [0.86, 0, 0.07, 1],
}}
>
<motion.span
layoutId={`${layoutId}-text-${tab.id}`}
className="font-sans font-medium text-sm text-black"
>
{tab.title}
</motion.span>
</motion.div>
)}
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { createContext, useContext, useState, useCallback, useRef } from "react";
import { FlyingHeart } from "../components/FlyingHeart/FlyingHeart";
interface FavoriteAnimationContextType {
triggerFavoriteAnimation: (startPosition: { x: number; y: number }) => void;
registerFavoritesTabRef: (element: HTMLElement | null) => void;
}
const FavoriteAnimationContext = createContext<FavoriteAnimationContextType | null>(null);
interface FavoriteAnimationProviderProps {
children: React.ReactNode;
onAnimationComplete?: () => void;
}
export function FavoriteAnimationProvider({
children,
onAnimationComplete,
}: FavoriteAnimationProviderProps) {
const [animationState, setAnimationState] = useState<{
startPosition: { x: number; y: number } | null;
targetPosition: { x: number; y: number } | null;
}>({
startPosition: null,
targetPosition: null,
});
const favoritesTabRef = useRef<HTMLElement | null>(null);
const registerFavoritesTabRef = useCallback((element: HTMLElement | null) => {
favoritesTabRef.current = element;
}, []);
const triggerFavoriteAnimation = useCallback(
(startPosition: { x: number; y: number }) => {
if (favoritesTabRef.current) {
const rect = favoritesTabRef.current.getBoundingClientRect();
const targetPosition = {
x: rect.left + rect.width / 2 - 12,
y: rect.top + rect.height / 2 - 12,
};
setAnimationState({ startPosition, targetPosition });
}
},
[]
);
function handleAnimationComplete() {
setAnimationState({ startPosition: null, targetPosition: null });
onAnimationComplete?.();
}
return (
<FavoriteAnimationContext.Provider
value={{ triggerFavoriteAnimation, registerFavoritesTabRef }}
>
{children}
<FlyingHeart
startPosition={animationState.startPosition}
targetPosition={animationState.targetPosition}
onAnimationComplete={handleAnimationComplete}
/>
</FavoriteAnimationContext.Provider>
);
}
export function useFavoriteAnimation() {
const context = useContext(FavoriteAnimationContext);
if (!context) {
throw new Error(
"useFavoriteAnimation must be used within FavoriteAnimationProvider"
);
}
return context;
}

View File

@@ -1,28 +1,53 @@
"use client";
import { useEffect } from "react";
import { FavoritesSection } from "./components/FavoritesSection/FavoritesSection";
import { useEffect, useState, useCallback } from "react";
import { HeartIcon, ListIcon } from "@phosphor-icons/react";
import { LibraryActionHeader } from "./components/LibraryActionHeader/LibraryActionHeader";
import { LibraryAgentList } from "./components/LibraryAgentList/LibraryAgentList";
import { Tab } from "./components/LibraryTabs/LibraryTabs";
import { useLibraryListPage } from "./components/useLibraryListPage";
import { FavoriteAnimationProvider } from "./context/FavoriteAnimationContext";
const LIBRARY_TABS: Tab[] = [
{ id: "all", title: "All", icon: ListIcon },
{ id: "favorites", title: "Favorites", icon: HeartIcon },
];
export default function LibraryPage() {
const { searchTerm, setSearchTerm, librarySort, setLibrarySort } =
useLibraryListPage();
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(LIBRARY_TABS[0].id);
useEffect(() => {
document.title = "Library AutoGPT Platform";
}, []);
function handleTabChange(tabId: string) {
setActiveTab(tabId);
setSelectedFolderId(null);
}
const handleFavoriteAnimationComplete = useCallback(() => {
setActiveTab("favorites");
setSelectedFolderId(null);
}, []);
return (
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
<LibraryActionHeader setSearchTerm={setSearchTerm} />
<FavoritesSection searchTerm={searchTerm} />
<LibraryAgentList
searchTerm={searchTerm}
librarySort={librarySort}
setLibrarySort={setLibrarySort}
/>
</main>
<FavoriteAnimationProvider onAnimationComplete={handleFavoriteAnimationComplete}>
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
<LibraryActionHeader setSearchTerm={setSearchTerm} />
<LibraryAgentList
searchTerm={searchTerm}
librarySort={librarySort}
setLibrarySort={setLibrarySort}
selectedFolderId={selectedFolderId}
onFolderSelect={setSelectedFolderId}
tabs={LIBRARY_TABS}
activeTab={activeTab}
onTabChange={handleTabChange}
/>
</main>
</FavoriteAnimationProvider>
);
}

View File

@@ -3588,6 +3588,29 @@
"title": "Page Size"
},
"description": "Number of agents per page (must be >= 1)"
},
{
"name": "folder_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"description": "Filter by folder ID",
"title": "Folder Id"
},
"description": "Filter by folder ID"
},
{
"name": "include_root_only",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"description": "Only return agents without a folder (root-level agents)",
"default": false,
"title": "Include Root Only"
},
"description": "Only return agents without a folder (root-level agents)"
}
],
"responses": {
@@ -3955,6 +3978,340 @@
}
}
},
"/api/library/folders": {
"get": {
"tags": ["v2", "library", "folders", "private"],
"summary": "List Library Folders",
"description": "List folders for the authenticated user.\n\nArgs:\n user_id: ID of the authenticated user.\n parent_id: Optional parent folder ID to filter by.\n include_counts: Whether to include agent and subfolder counts.\n\nReturns:\n A FolderListResponse containing folders.",
"operationId": "getV2List library folders",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "parent_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"description": "Filter by parent folder ID. If not provided, returns root-level folders.",
"title": "Parent Id"
},
"description": "Filter by parent folder ID. If not provided, returns root-level folders."
},
{
"name": "include_counts",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"description": "Include agent and subfolder counts",
"default": true,
"title": "Include Counts"
},
"description": "Include agent and subfolder counts"
}
],
"responses": {
"200": {
"description": "List of folders",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/FolderListResponse" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
},
"post": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Create Folder",
"description": "Create a new folder.\n\nArgs:\n payload: The folder creation request.\n user_id: ID of the authenticated user.\n\nReturns:\n The created LibraryFolder.",
"operationId": "postV2Create folder",
"security": [{ "HTTPBearerJWT": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/FolderCreateRequest" }
}
}
},
"responses": {
"201": {
"description": "Folder created successfully",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
}
}
},
"400": { "description": "Validation error" },
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Parent folder not found" },
"409": { "description": "Folder name conflict" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
}
},
"/api/library/folders/agents/bulk-move": {
"post": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Bulk Move Agents",
"description": "Move multiple agents to a folder.\n\nArgs:\n payload: The bulk move request with agent IDs and target folder.\n user_id: ID of the authenticated user.\n\nReturns:\n The updated LibraryAgents.",
"operationId": "postV2Bulk move agents",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/BulkMoveAgentsRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Agents moved successfully",
"content": {
"application/json": {
"schema": {
"items": { "$ref": "#/components/schemas/LibraryAgent" },
"type": "array",
"title": "Response Postv2Bulk Move Agents"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Folder not found" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/library/folders/tree": {
"get": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Get Folder Tree",
"description": "Get the full folder tree for the authenticated user.\n\nArgs:\n user_id: ID of the authenticated user.\n\nReturns:\n A FolderTreeResponse containing the nested folder structure.",
"operationId": "getV2Get folder tree",
"responses": {
"200": {
"description": "Folder tree structure",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/FolderTreeResponse" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"500": { "description": "Server error" }
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/library/folders/{folder_id}": {
"delete": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Delete Folder",
"description": "Soft-delete a folder and all its contents.\n\nArgs:\n folder_id: ID of the folder to delete.\n user_id: ID of the authenticated user.\n\nReturns:\n 204 No Content if successful.",
"operationId": "deleteV2Delete folder",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "folder_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Folder Id" }
}
],
"responses": {
"204": { "description": "Folder deleted successfully" },
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Folder not found" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
},
"get": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Get Folder",
"description": "Get a specific folder.\n\nArgs:\n folder_id: ID of the folder to retrieve.\n user_id: ID of the authenticated user.\n\nReturns:\n The requested LibraryFolder.",
"operationId": "getV2Get folder",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "folder_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Folder Id" }
}
],
"responses": {
"200": {
"description": "Folder details",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Folder not found" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
},
"patch": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Update Folder",
"description": "Update a folder's properties.\n\nArgs:\n folder_id: ID of the folder to update.\n payload: The folder update request.\n user_id: ID of the authenticated user.\n\nReturns:\n The updated LibraryFolder.",
"operationId": "patchV2Update folder",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "folder_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Folder Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/FolderUpdateRequest" }
}
}
},
"responses": {
"200": {
"description": "Folder updated successfully",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
}
}
},
"400": { "description": "Validation error" },
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Folder not found" },
"409": { "description": "Folder name conflict" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
}
},
"/api/library/folders/{folder_id}/move": {
"post": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Move Folder",
"description": "Move a folder to a new parent.\n\nArgs:\n folder_id: ID of the folder to move.\n payload: The move request with target parent.\n user_id: ID of the authenticated user.\n\nReturns:\n The moved LibraryFolder.",
"operationId": "postV2Move folder",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "folder_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Folder Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/FolderMoveRequest" }
}
}
},
"responses": {
"200": {
"description": "Folder moved successfully",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
}
}
},
"400": {
"description": "Validation error (circular reference, depth exceeded)"
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Folder or target parent not found" },
"409": { "description": "Folder name conflict in target location" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
}
},
"/api/library/presets": {
"get": {
"tags": ["v2", "presets"],
@@ -7336,6 +7693,23 @@
"required": ["file"],
"title": "Body_postV2Upload submission media"
},
"BulkMoveAgentsRequest": {
"properties": {
"agent_ids": {
"items": { "type": "string" },
"type": "array",
"title": "Agent Ids"
},
"folder_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Folder Id"
}
},
"type": "object",
"required": ["agent_ids"],
"title": "BulkMoveAgentsRequest",
"description": "Request model for moving multiple agents to a folder."
},
"ChangelogEntry": {
"properties": {
"version": { "type": "string", "title": "Version" },
@@ -8908,6 +9282,14 @@
"title": "Is Latest Version"
},
"is_favorite": { "type": "boolean", "title": "Is Favorite" },
"folder_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Folder Id"
},
"folder_name": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Folder Name"
},
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
@@ -9175,12 +9557,109 @@
{ "type": "null" }
],
"description": "User-specific settings for this library agent"
},
"folder_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Folder Id",
"description": "Folder ID to move agent to (empty string for root)"
}
},
"type": "object",
"title": "LibraryAgentUpdateRequest",
"description": "Schema for updating a library agent via PUT.\n\nIncludes flags for auto-updating version, marking as favorite,\narchiving, or deleting."
},
"LibraryFolder": {
"properties": {
"id": { "type": "string", "title": "Id" },
"user_id": { "type": "string", "title": "User Id" },
"name": { "type": "string", "title": "Name" },
"icon": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Icon"
},
"color": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Color"
},
"parent_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Parent Id"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"updated_at": {
"type": "string",
"format": "date-time",
"title": "Updated At"
},
"agent_count": {
"type": "integer",
"title": "Agent Count",
"default": 0
},
"subfolder_count": {
"type": "integer",
"title": "Subfolder Count",
"default": 0
}
},
"type": "object",
"required": ["id", "user_id", "name", "created_at", "updated_at"],
"title": "LibraryFolder",
"description": "Represents a folder for organizing library agents."
},
"LibraryFolderTree": {
"properties": {
"id": { "type": "string", "title": "Id" },
"user_id": { "type": "string", "title": "User Id" },
"name": { "type": "string", "title": "Name" },
"icon": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Icon"
},
"color": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Color"
},
"parent_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Parent Id"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"updated_at": {
"type": "string",
"format": "date-time",
"title": "Updated At"
},
"agent_count": {
"type": "integer",
"title": "Agent Count",
"default": 0
},
"subfolder_count": {
"type": "integer",
"title": "Subfolder Count",
"default": 0
},
"children": {
"items": { "$ref": "#/components/schemas/LibraryFolderTree" },
"type": "array",
"title": "Children",
"default": []
}
},
"type": "object",
"required": ["id", "user_id", "name", "created_at", "updated_at"],
"title": "LibraryFolderTree",
"description": "Folder with nested children for tree view."
},
"Link": {
"properties": {
"id": { "type": "string", "title": "Id" },