Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions frontend/app/[locale]/market/MarketContent.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* Custom styles for MarketContent component */

/* Hide scrollbars for featured row with subtle hover reveal */
.noScrollbar {
/* Modern browsers: hide scrollbar but keep functionality */
scrollbar-width: thin;
scrollbar-color: transparent transparent;
-ms-overflow-style: none; /* IE/Edge */
}

.noScrollbar::-webkit-scrollbar {
height: 4px;
}

.noScrollbar::-webkit-scrollbar-track {
background: transparent;
}

.noScrollbar::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 2px;
transition: background-color 0.2s ease;
}

/* Show subtle scrollbar on hover for better UX */
@media (hover: hover) {
.noScrollbar:hover::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
}

.noScrollbar:hover {
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
}
87 changes: 54 additions & 33 deletions frontend/app/[locale]/market/components/AgentMarketCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface AgentMarketCardProps {
agent: MarketAgentListItem;
onDownload: (agent: MarketAgentListItem) => void;
onViewDetails: (agent: MarketAgentListItem) => void;
variant?: "featured" | "default";
}

/**
Expand All @@ -22,6 +23,7 @@ export function AgentMarketCard({
agent,
onDownload,
onViewDetails,
variant = "default",
}: AgentMarketCardProps) {
const { t, i18n } = useTranslation("common");
const isZh = i18n.language === "zh" || i18n.language === "zh-CN";
Expand All @@ -40,12 +42,29 @@ export function AgentMarketCard({
? agent.category.icon || getCategoryIcon(agent.category.name)
: "📦";


return (
<motion.div
whileHover={{ y: -4 }}
whileHover={{
y: -4,
boxShadow: "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05)"
}}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
onClick={handleCardClick}
className="group h-full bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 hover:shadow-lg transition-all duration-300 overflow-hidden flex flex-col cursor-pointer"
className="group z-10 hover:z-0 h-full min-h-[320px] rounded-lg border transition-all duration-300 overflow-visible flex flex-col cursor-pointer relative bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 hover:shadow-lg"
>
{variant === "featured" && (
// Full-card subtle purple gradient background overlay
<div
aria-hidden
className="absolute inset-0 rounded-lg pointer-events-none"
style={{
background:
"linear-gradient(180deg, rgba(139,92,246,0.06), rgba(99,102,241,0.04))",
zIndex: 0,
}}
/>
)}
{/* Card header with category */}
<div className="px-4 pt-4 pb-3 border-b border-slate-100 dark:border-slate-700">
<div className="flex items-center justify-between mb-2">
Expand All @@ -70,56 +89,58 @@ export function AgentMarketCard({
<h3 className="text-lg font-semibold text-slate-800 dark:text-slate-100 line-clamp-1 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors">
{agent.display_name}
</h3>
{agent.author ? (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{t("market.by", { defaultValue: "By {{author}}", author: agent.author })}
</p>
) : (
<div className="h-5" aria-hidden />
)}
<div className="h-5 flex items-center">
{agent.author ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t("market.by", { defaultValue: "By {{author}}", author: agent.author })}
</p>
) : null}
</div>
</div>

{/* Card body */}
<div className="flex-1 px-4 py-3 flex flex-col gap-3">
<div className="flex-1 px-4 py-3 flex flex-col gap-3 relative z-10 pb-20 min-h-[120px]">
{/* Description */}
<p className="text-sm text-slate-600 dark:text-slate-300 line-clamp-3 flex-1">
{agent.description}
</p>

{/* Tags */}
{agent.tags && agent.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{agent.tags.slice(0, 3).map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
>
<Tag className="h-3 w-3" />
{getGenericLabel(tag.display_name, t)}
</span>
))}
{agent.tags.length > 3 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300">
+{agent.tags.length - 3}
</span>
)}
</div>
)}
{/* Tags - always show container for consistent height */}
<div className="min-h-[24px]">
{agent.tags && agent.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 max-h-6 overflow-hidden">
{agent.tags.slice(0, 3).map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
>
<Tag className="h-3 w-3" />
{getGenericLabel(tag.display_name, t)}
</span>
))}
{agent.tags.length > 3 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300">
+{agent.tags.length - 3}
</span>
)}
</div>
)}
</div>

{/* Tool count */}
<div className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Wrench className="h-3.5 w-3.5" />
<span>
{agent.tool_count} {t("market.tools", "tools")}
{agent.tool_count || 0} {t("market.tools", "tools")}
</span>
</div>
</div>

{/* Card footer */}
<div className="px-4 py-3 border-t border-slate-100 dark:border-slate-700">
{/* Card footer - pinned to bottom to keep all cards aligned */}
<div className="absolute left-0 right-0 bottom-0 px-4 py-3 border-t border-slate-100 dark:border-slate-700 bg-transparent z-10">
<button
onClick={handleDownload}
className="w-full px-4 py-2 rounded-md bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-sm font-medium transition-all duration-300 flex items-center justify-center gap-2 group-hover:shadow-md"
className="w-full px-4 py-2 rounded-md bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-sm font-medium transition-all duration-300 flex items-center justify-center gap-2"
>
<Download className="h-4 w-4" />
{t("market.download", "Download")}
Expand Down
119 changes: 112 additions & 7 deletions frontend/app/[locale]/market/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"use client";

import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import { ShoppingBag, Search, RefreshCw } from "lucide-react";
import { ShoppingBag, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { Tabs, Input, Spin, Empty, Pagination, App } from "antd";
import log from "@/lib/logger";

Expand All @@ -20,6 +20,7 @@ import MarketAgentDetailModal from "./components/MarketAgentDetailModal";
import AgentImportWizard from "@/components/agent/AgentImportWizard";
import { ImportAgentData } from "@/hooks/useAgentImport";
import MarketErrorState from "./components/MarketErrorState";
import "./MarketContent.css";

/**
* MarketContent - Agent marketplace page
Expand All @@ -36,6 +37,7 @@ export default function MarketContent() {
// State management
const [categories, setCategories] = useState<MarketCategory[]>([]);
const [agents, setAgents] = useState<MarketAgentListItem[]>([]);
const [featuredItems, setFeaturedItems] = useState<MarketAgentListItem[]>([]);
const [isLoadingCategories, setIsLoadingCategories] = useState(true);
const [isLoadingAgents, setIsLoadingAgents] = useState(false);
const [currentCategory, setCurrentCategory] = useState<string>("all");
Expand All @@ -54,6 +56,7 @@ export default function MarketContent() {
);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);


// Install modal state
const [installModalVisible, setInstallModalVisible] = useState(false);
const [installAgent, setInstallAgent] = useState<MarketAgentDetail | null>(
Expand All @@ -66,6 +69,33 @@ export default function MarketContent() {
loadAgents(); // Auto-refresh on page load
}, []);

// Refs and state for featured card width calculation
const contentRef = useRef<HTMLDivElement | null>(null);
const featuredRowRef = useRef<HTMLDivElement | null>(null);
const [featuredCardWidth, setFeaturedCardWidth] = useState<number | null>(null);

// Calculate featured card width so it matches grid column width (accounting for gaps)
useEffect(() => {
const calc = () => {
const container = contentRef.current;
if (!container) return;
const containerWidth = container.clientWidth;
const w = window.innerWidth;
let columns = 4;
if (w < 768) columns = 1;
else if (w < 1024) columns = 2;
else if (w < 1280) columns = 3;
else ;
const gap = 16; // tailwind gap-4 == 16px
const totalGap = gap * (columns - 1);
const cardW = Math.floor((containerWidth - totalGap) / columns);
setFeaturedCardWidth(cardW);
};
calc();
window.addEventListener("resize", calc);
return () => window.removeEventListener("resize", calc);
}, [featuredItems]);

// Load agents when category, page, or search changes (but not on initial mount)
useEffect(() => {
loadAgents();
Expand Down Expand Up @@ -113,9 +143,18 @@ export default function MarketContent() {
params.search = searchKeyword.trim();
}

// Backend returns all items in pagination, with is_featured flag
const data = await marketService.fetchMarketAgentList(params);
setAgents(data.items);
setTotalAgents(data.pagination.total);
const allItems = data.items || [];

// Separate featured and regular items
const featured = allItems.filter((a) => a.is_featured);
const items = allItems.filter((a) => !a.is_featured);

setFeaturedItems(featured);
setAgents(items);
// Use pagination total as is - it represents total items across both featured and regular
setTotalAgents(data.pagination?.total || 0);
} catch (error) {
log.error("Failed to load market agents:", error);

Expand Down Expand Up @@ -255,7 +294,7 @@ export default function MarketContent() {
className="w-full h-full overflow-auto"
>
<div className="w-full px-4 md:px-8 lg:px-16 py-8">
<div className="max-w-7xl mx-auto">
<div ref={contentRef} className="max-w-7xl mx-auto">
{/* Page header */}
<div className="flex items-center justify-between mb-6">
<motion.div
Expand Down Expand Up @@ -354,7 +393,7 @@ export default function MarketContent() {
<div className="flex justify-center py-16">
<Spin size="large" />
</div>
) : agents.length === 0 ? (
) : agents.length === 0 && featuredItems.length === 0 ? (
<Empty
description={t(
"market.noAgents",
Expand All @@ -364,7 +403,71 @@ export default function MarketContent() {
/>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
{/* Featured row per category (show only if there are featured items) */}
{featuredItems.length > 0 && (
<div className="mb-6">
<div className="flex items-center justify-between mb-5">
<h2 className="text-2xl font-bold">
{t("market.featuredTitle")}
</h2>
<div className="hidden md:flex items-center gap-2">
<button
aria-label="Prev featured"
onClick={() => {
const el = document.getElementById("featured-row");
if (el) el.scrollBy({ left: -Math.floor(el.clientWidth * 0.9), behavior: "smooth" });
}}
className="px-2 py-1 hover:opacity-90"
style={{ background: "transparent" }}
>
<ChevronLeft className="w-6 h-6 text-slate-500" />
</button>
<button
aria-label="Next featured"
onClick={() => {
const el = document.getElementById("featured-row");
if (el) el.scrollBy({ left: Math.floor(el.clientWidth * 0.9), behavior: "smooth" });
}}
className="px-2 py-1 hover:opacity-90"
style={{ background: "transparent" }}
>
<ChevronRight className="w-6 h-6 text-slate-500" />
</button>
</div>
</div>
<div
id="featured-row"
ref={featuredRowRef}
className={`flex gap-4 overflow-x-auto noScrollbar pt-2 pb-2`}
>
{featuredItems.map((agent, index) => (
<div
key={`featured-${agent.id}`}
className="flex-shrink-0 h-full"
style={featuredCardWidth ? { width: `${featuredCardWidth}px` } : undefined}
>
<AgentMarketCard
agent={agent}
onDownload={handleDownload}
onViewDetails={handleViewDetails}
variant="featured"
/>
</div>
))}
</div>
</div>
)}

{/* Separator between featured and main list (only when both exist) */}
{featuredItems.length > 0 && agents.length > 0 && (
<div className="mt-4 mb-8">
<div className="w-full h-[0.5px] bg-slate-200 dark:bg-slate-700 rounded" />
</div>
)}

{agents.length > 0 && (
<>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
{agents.map((agent, index) => (
<motion.div
key={agent.id}
Expand Down Expand Up @@ -402,6 +505,8 @@ export default function MarketContent() {
}
/>
</div>
)}
</>
)}
</>
)}
Expand Down
1 change: 1 addition & 0 deletions frontend/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,7 @@
"market.downloadSuccess": "Agent downloaded successfully!",
"market.downloadFailed": "Failed to download agent",
"market.tools": "tools",
"market.featuredTitle": "Featured Agents",
"market.noAgents": "No agents found in this category",
"market.totalAgents": "Total {{total}} agents",
"market.error.loadCategories": "Failed to load categories",
Expand Down
1 change: 1 addition & 0 deletions frontend/public/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,7 @@
"market.downloadSuccess": "智能体下载成功!",
"market.downloadFailed": "下载智能体失败",
"market.tools": "工具",
"market.featuredTitle": "精选智能体",
"market.noAgents": "此分类下未找到智能体",
"market.totalAgents": "共 {{total}} 个智能体",
"market.error.loadCategories": "加载分类失败",
Expand Down
1 change: 1 addition & 0 deletions frontend/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
scrollbar-color: rgba(0, 0, 0, 0.4) transparent;
}


/* Tool Pool Tabs scroll fix */
.tool-pool-tabs .ant-tabs-content-holder {
overflow: hidden;
Expand Down
Loading