feat: refactor store to discover transition and enhance UI components

- Updated package.json to include 'usehooks-ts' and upgraded 'lucide-react' to version 0.511.0.
- Replaced 'store' with 'discover' in the routing and sidebar components for improved navigation.
- Introduced new DiscoverPage and related components for better organization of content.
- Enhanced localization support by adding Chinese translations for the discover feature.
- Removed deprecated store components to streamline the codebase and improve maintainability.
This commit is contained in:
lizhixuan 2025-05-18 16:08:26 +08:00
parent 80289f1dc3
commit 475c1e38df
27 changed files with 521 additions and 781 deletions

View File

@ -183,7 +183,7 @@
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"lucide-react": "^0.509.0",
"lucide-react": "^0.511.0",
"mime": "^4.0.4",
"motion": "^12.12.1",
"next-themes": "^0.4.6",
@ -224,6 +224,7 @@
"tokenx": "^0.4.1",
"tw-animate-css": "^1.2.9",
"typescript": "^5.6.2",
"usehooks-ts": "^3.1.1",
"uuid": "^10.0.0",
"vite": "6.2.6",
"vitest": "^3.1.1"

View File

@ -12,14 +12,13 @@ import StyleSheetManager from './context/StyleSheetManager'
import { SyntaxHighlighterProvider } from './context/SyntaxHighlighterProvider'
import { ThemeProvider } from './context/ThemeProvider'
import NavigationHandler from './handler/NavigationHandler'
import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage'
import DiscoverPage from './pages/discover'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import StorePage from './pages/store'
import TranslatePage from './pages/translate/TranslatePage'
function App(): React.ReactElement {
@ -36,14 +35,13 @@ function App(): React.ReactElement {
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/store/*" element={<StorePage />} />
<Route path="/discover/*" element={<DiscoverPage />} />
</Routes>
</HashRouter>
</TopViewContainer>

View File

@ -145,7 +145,7 @@ const MainMenus: FC = () => {
minapp: <LayoutGrid size={18} className="icon" />,
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={17} className="icon" />,
store: <Compass size={18} className="icon" />
discover: <Compass size={18} className="icon" />
}
const pathMap = {
@ -156,7 +156,7 @@ const MainMenus: FC = () => {
minapp: '/apps',
knowledge: '/knowledge',
files: '/files',
store: '/store'
discover: '/discover'
}
return sidebarIcons.visible.map((icon) => {

View File

@ -1546,8 +1546,8 @@
"enable_privacy_mode": "匿名发送错误报告和数据统计"
}
},
"store": {
"title": "应用商店",
"discover": {
"title": "发现",
"install": "安装",
"uninstall": "卸载",
"update": "更新",

View File

@ -1,5 +1,4 @@
import { ImportOutlined, PlusOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import CustomTag from '@renderer/components/CustomTag'
import ListItem from '@renderer/components/ListItem'
import Scrollbar from '@renderer/components/Scrollbar'
@ -7,9 +6,8 @@ import { useAgents } from '@renderer/hooks/useAgents'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { Agent } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Button, Empty, Flex, Input } from 'antd'
import { Button, Empty, Flex } from 'antd'
import { omit } from 'lodash'
import { Search } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
@ -152,7 +150,7 @@ const AgentsPage: FC = () => {
return (
<Container>
<Navbar>
{/* <Navbar>
<NavbarCenter style={{ borderRight: 'none', justifyContent: 'space-between' }}>
{t('agents.title')}
<Input
@ -171,7 +169,7 @@ const AgentsPage: FC = () => {
/>
<div style={{ width: 80 }} />
</NavbarCenter>
</Navbar>
</Navbar> */}
<Main id="content-container">
<AgentsGroupList>

View File

@ -1,9 +1,7 @@
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { Center } from '@renderer/components/Layout'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { Empty, Input } from 'antd'
import { Empty } from 'antd'
import { isEmpty } from 'lodash'
import { Search } from 'lucide-react'
import React, { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -34,7 +32,7 @@ const AppsPage: FC = () => {
return (
<Container onContextMenu={handleContextMenu}>
<Navbar>
{/* <Navbar>
<NavbarCenter style={{ borderRight: 'none', justifyContent: 'space-between' }}>
{t('minapp.title')}
<Input
@ -49,7 +47,7 @@ const AppsPage: FC = () => {
/>
<div style={{ width: 80 }} />
</NavbarCenter>
</Navbar>
</Navbar> */}
<ContentContainer id="content-container">
{isEmpty(filteredApps) ? (
<Center>

View File

@ -0,0 +1,46 @@
import { Category } from '@renderer/types/cherryStore'
import React from 'react'
import { Navigate, Route, Routes, useLocation } from 'react-router-dom'
// 实际的 AgentsPage 组件 - 请确保路径正确
import AgentsPage from '../../agents/AgentsPage'
import AppsPage from '../../apps/AppsPage'
// import AssistantDetailsPage from '../../agents/AssistantDetailsPage'; // 示例详情页
// 其他分类的页面组件 (如果需要)
// const MiniAppPagePlaceholder = ({ categoryId, subcategoryId }: { categoryId?: string; subcategoryId?: string }) => (
// <div className="p-4">
// MiniApp Placeholder for Category: {categoryId || 'N/A'}, Subcategory: {subcategoryId || 'N/A'}
// </div>
// )
export interface DiscoverContentProps {
activeTabId: string // This should be one of the CherryStoreType values, e.g., "Assistant"
// selectedSubcategoryId: string
currentCategory: Category | undefined
}
const DiscoverContent: React.FC<DiscoverContentProps> = ({ activeTabId, currentCategory }) => {
const location = useLocation() // To see the current path for debugging or more complex logic
if (!currentCategory || !activeTabId) {
return <div className="p-4 text-center">Loading: Category or Tab ID missing...</div>
}
if (!activeTabId && !location.pathname.startsWith('/discover/')) {
return <Navigate to="/discover/assistant?subcategory=all" replace /> // Fallback redirect, adjust as needed
}
return (
<Routes>
{/* Path for Assistant category */}
<Route path="assistant" element={<AgentsPage />} />
{/* Path for Mini-App category */}
<Route path="mini-app" element={<AppsPage />} />
<Route path="*" element={<div>Discover Feature Not Found at {location.pathname}</div>} />
</Routes>
)
}
export default DiscoverContent

View File

@ -0,0 +1,64 @@
import { SubCategoryItem } from '@renderer/types/cherryStore'
import { Badge } from '@renderer/ui/badge'
import {
Sidebar,
SidebarContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuSubItem,
SidebarProvider
} from '@renderer/ui/sidebar'
import { InternalCategory } from '../hooks/useDiscoverCategories'
interface DiscoverSidebarProps {
activeCategory: InternalCategory | undefined
selectedSubcategory: string
onSelectSubcategory: (subcategoryId: string, row?: SubCategoryItem) => void
}
export default function DiscoverSidebar({
activeCategory,
selectedSubcategory,
onSelectSubcategory
}: DiscoverSidebarProps) {
if (!activeCategory) {
return (
<Sidebar className="absolute top-0 left-0 h-full border-r">
<SidebarContent>
<p className="p-4 text-sm text-gray-500">No active category selected.</p>
</SidebarContent>
</Sidebar>
)
}
return (
<SidebarProvider className="relative h-full min-h-full w-full">
<Sidebar className="absolute top-0 left-0 h-full border-r">
<SidebarContent>
<SidebarMenu>
{activeCategory.items &&
activeCategory.items.length > 0 &&
activeCategory.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.id}>
<SidebarMenuButton
isActive={subItem.id === selectedSubcategory}
onClick={() => {
onSelectSubcategory(subItem.id, subItem)
}}
size="sm">
<span className="truncate">{subItem.name}</span>
{typeof subItem.count === 'number' && (
<Badge variant="secondary" className="ml-auto shrink-0">
{subItem.count}
</Badge>
)}
</SidebarMenuButton>
</SidebarMenuSubItem>
))}
</SidebarMenu>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

View File

@ -0,0 +1,166 @@
import { Category, CherryStoreType } from '@renderer/types/cherryStore'
import { useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
// Extended Category type for internal use in hook, including path and sidebar flag
// Export this interface so other files can import it
export interface InternalCategory extends Category {
path: string
hasSidebar?: boolean // Optional: defaults to true if not specified, or handle explicitly
}
// Initial category data with path and hasSidebar
const initialCategories: InternalCategory[] = [
{
id: CherryStoreType.ASSISTANT,
title: 'Assistants',
path: 'assistant',
hasSidebar: false,
items: [
{ id: 'all', name: 'All Assistants' },
{ id: 'gpt3', name: 'GPT-3' }
]
},
{
id: CherryStoreType.MINI_APP,
title: 'Mini Apps',
path: 'mini-app',
hasSidebar: false,
items: [
{ id: 'all', name: 'All Mini Apps' }
// Add other mini_app subcategories here if any
]
}
// Add more categories as needed
]
// Helper to find category by path
const findCategoryByPath = (path: string | undefined): InternalCategory | undefined =>
initialCategories.find((cat) => cat.path === path)
// Helper to find category by id (activeTab)
const findCategoryById = (id: string | undefined): InternalCategory | undefined =>
initialCategories.find((cat) => cat.id === id)
export function useDiscoverCategories() {
const [categories, setCategories] = useState<InternalCategory[]>(initialCategories)
const [activeTab, setActiveTab] = useState<string>('')
const [selectedSubcategory, setSelectedSubcategory] = useState<string>('all')
const navigate = useNavigate()
const location = useLocation()
// Effect to initialize activeTab from URL path segment or navigate to default
useEffect(() => {
const pathSegments = location.pathname.split('/').filter(Boolean) // e.g., ["discover", "assistant"]
// Expects URL like /discover/:categoryPathSegment/...
const currentCategoryPath = pathSegments.length >= 2 && pathSegments[0] === 'discover' ? pathSegments[1] : undefined
const categoryFromPath = findCategoryByPath(currentCategoryPath)
if (categoryFromPath) {
if (activeTab !== categoryFromPath.id) {
setActiveTab(categoryFromPath.id)
}
} else if (location.pathname === '/discover' || location.pathname === '/discover/') {
// If URL is exactly /discover or /discover/ and no specific category path is matched
if (categories.length > 0) {
const firstCategory = categories[0]
if (firstCategory?.path) {
navigate(`/discover/${firstCategory.path}?subcategory=all`, { replace: true })
}
}
} else if (!currentCategoryPath && categories.length > 0 && !activeTab) {
// Fallback if no category path in URL and no active tab set yet (e.g. initial load to a bad /discover/xxx url)
const firstCategory = categories[0]
if (firstCategory?.path) {
navigate(`/discover/${firstCategory.path}?subcategory=all`, { replace: true })
}
}
// If categoryFromPath is undefined, and it's not /discover, it means it's an invalid path like /discover/unknown
// In this case, we don't navigate from here; ideally App.tsx has a NotFound route, or DiscoverContent shows a message.
}, [location.pathname, categories, activeTab, navigate])
// Effect to initialize selectedSubcategory from URL query param or default to 'all'
useEffect(() => {
const searchParams = new URLSearchParams(location.search)
const subcategoryIdFromQuery = searchParams.get('subcategory')
const currentCatDetails = findCategoryById(activeTab) // Use the helper here
if (subcategoryIdFromQuery && currentCatDetails) {
// Check if the subcategory from query is valid for the current active category
if (currentCatDetails.items.some((item) => item.id === subcategoryIdFromQuery)) {
if (selectedSubcategory !== subcategoryIdFromQuery) {
setSelectedSubcategory(subcategoryIdFromQuery)
}
return // Valid subcategory from URL is set, no further action needed in this effect iteration
}
}
// If no valid subcategory in query, or if activeTab has changed and subcategory needs reset/defaulting
if (activeTab && currentCatDetails) {
const defaultSub = currentCatDetails.items.find((item) => item.id === 'all') || currentCatDetails.items[0]
if (defaultSub) {
// Ensure defaultSub exists
// Set selectedSubcategory state first
if (selectedSubcategory !== defaultSub.id) {
setSelectedSubcategory(defaultSub.id)
}
// Then, if URL doesn't match this default, update URL to reflect the default subcategory
// This ensures the URL is the source of truth / always consistent.
if (!subcategoryIdFromQuery || subcategoryIdFromQuery !== defaultSub.id) {
const newSearchParams = new URLSearchParams() // Start with clean params for this path
newSearchParams.set('subcategory', defaultSub.id)
// Ensure we use the current actual path from currentCatDetails if available for navigation
// This avoids issues if location.pathname is briefly out of sync during transitions.
const basePath = currentCatDetails.path
? `/discover/${currentCatDetails.path}`
: location.pathname.split('?')[0]
navigate(`${basePath}?${newSearchParams.toString()}`, { replace: true })
}
}
}
}, [activeTab, location.search, categories, navigate, selectedSubcategory]) // location.pathname removed as basePath logic handles path part
const currentCategory = useMemo(() => {
return findCategoryById(activeTab) // Use the helper here
}, [activeTab]) // categories removed from deps as findCategoryById uses stable initialCategories
const handleSelectTab = (tabId: string) => {
const categoryToSelect = findCategoryById(tabId)
if (categoryToSelect && categoryToSelect.path && activeTab !== tabId) {
navigate(`/discover/${categoryToSelect.path}?subcategory=all`)
}
}
const handleSelectSubcategory = (subcategoryId: string) => {
const currentCatDetails = findCategoryById(activeTab)
if (selectedSubcategory !== subcategoryId && currentCatDetails?.path) {
const newSearchParams = new URLSearchParams()
newSearchParams.set('subcategory', subcategoryId)
navigate(`/discover/${currentCatDetails.path}?${newSearchParams.toString()}`, { replace: false })
}
}
// Ensure each category has an "All" subcategory (runs once on mount)
useEffect(() => {
setCategories((prev) =>
prev.map((cat) => {
if (!cat.items.some((item) => item.id === 'all')) {
return { ...cat, items: [{ id: 'all', name: `All ${cat.title}` }, ...cat.items] }
}
return cat
})
)
}, [])
return {
categories,
activeTab,
selectedSubcategory,
currentCategory,
handleSelectTab,
handleSelectSubcategory,
setActiveTab
}
}

View File

@ -0,0 +1,86 @@
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
// import { useRuntime } from '@renderer/hooks/useRuntime' // No longer needed if resourcesPath is not used
import { Tabs as VercelTabs } from '@renderer/ui/vercel-tabs'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
// Import Context and the main Dialog Manager component
import { DialogManagerProvider } from './components/dialog/DialogManagerContext'
import Dialogs from './components/dialog/index'
import DiscoverContent from './components/DiscoverContent' // Removed DiscoverContent import
import DiscoverSidebar from './components/DiscoverSidebar'
import { InternalCategory, useDiscoverCategories } from './hooks/useDiscoverCategories'
// Function to adapt categories for VercelTabs
const adaptCategoriesForVercelTabs = (categories: InternalCategory[]) => {
return categories.map((category) => ({
id: category.id, // VercelTabs expects `id`
label: category.title // VercelTabs expects `label`
}))
}
export default function DiscoverPage() {
const { t } = useTranslation()
const {
categories,
activeTab,
selectedSubcategory,
currentCategory,
handleSelectTab,
handleSelectSubcategory,
setActiveTab
} = useDiscoverCategories()
// Path like /discover/:categoryIdFromUrl. categoryIdFromUrl is lowercase from URL.
const { categoryIdFromUrl } = useParams<{ categoryIdFromUrl: string }>()
useEffect(() => {
const matchedCategory = categories.find((cat) => cat.id.toLowerCase() === categoryIdFromUrl?.toLowerCase())
if (matchedCategory && activeTab !== matchedCategory.id) {
setActiveTab(matchedCategory.id)
}
}, [categoryIdFromUrl, categories, activeTab, setActiveTab])
const vercelTabsData = adaptCategoriesForVercelTabs(categories)
return (
<DialogManagerProvider>
<div className="flex h-full w-full flex-col overflow-hidden">
<Navbar className="h-auto flex-shrink-0">
<NavbarCenter>{t('discover.title')}</NavbarCenter>
</Navbar>
{categories.length > 0 && (
<div className="border-b px-4 py-2">
<VercelTabs tabs={vercelTabsData} onTabChange={handleSelectTab} />
</div>
)}
<div className="flex flex-grow flex-row overflow-auto">
{currentCategory?.hasSidebar && (
<div className="w-64 flex-shrink-0 border-r">
<DiscoverSidebar
activeCategory={currentCategory}
selectedSubcategory={selectedSubcategory}
onSelectSubcategory={handleSelectSubcategory}
/>
</div>
)}
{/* {!currentCategory && categories.length > 0 && (
<div className="w-64 flex-shrink-0 border-r p-4 text-muted-foreground">Select a category...</div>
)} */}
<main className="flex-grow overflow-hidden">
<DiscoverContent
activeTabId={activeTab}
// selectedSubcategoryId={selectedSubcategory}
currentCategory={currentCategory}
/>
</main>
</div>
<Dialogs />
</div>
</DialogManagerProvider>
)
}

View File

@ -0,0 +1,7 @@
import { Category } from '@renderer/types/cherryStore'
export interface DiscoverContextType {
selectedSubcategory: string
activeTabId: string
currentCategory?: Category // currentCategory might be undefined initially
}

View File

@ -1,39 +0,0 @@
import { AssistantItem } from '@renderer/types/cherryStore'
import { Badge } from '@renderer/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/ui/card'
import { BlurFade } from '@renderer/ui/third-party/BlurFade'
import { cn } from '@renderer/utils'
import { useDialogManager } from '../dialog/DialogManagerContext'
export default function AssistantCard({ item }: { item: AssistantItem }) {
const { openDialog } = useDialogManager()
const handleCardClick = () => {
openDialog('install', item)
}
return (
<BlurFade key={item.id} delay={0.2} inView className="mb-4 cursor-pointer">
<Card className="overflow-hidden transition-transform hover:scale-105" onClick={handleCardClick}>
<CardHeader className="p-0">
<div className="flex h-full w-full items-center justify-center text-4xl" role="img" aria-label={item.title}>
{item.icon}
</div>
</CardHeader>
<CardContent className={cn('px-4', 'min-h-[120px]', 'space-y-1')}>
<CardTitle className="line-clamp-2 text-base">{item.title}</CardTitle>
<p className="text-sm text-muted-foreground">{item.author}</p>
<div className="space-x-2">
{item.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
<p className={cn('mt-2 text-sm text-muted-foreground', 'line-clamp-4 xl:line-clamp-7')}>{item.description}</p>
</CardContent>
</Card>
</BlurFade>
)
}

View File

@ -1,46 +0,0 @@
import { AssistantItem, CherryStoreItem, CherryStoreType, MiniAppItem } from '@renderer/types/cherryStore'
import { Fragment, useMemo } from 'react'
import AssistantCard from './Assistant/AssistantCard'
import MiniAppCard from './MiniApp/MiniAppCard'
interface GridViewProps {
items: CherryStoreItem[]
selectedCategory: string
className?: string
}
const CardComponent = (selectedCategory: string, item: CherryStoreItem) => {
switch (selectedCategory) {
case CherryStoreType.ASSISTANT:
return <AssistantCard item={item as AssistantItem} />
case CherryStoreType.MINI_APP:
return <MiniAppCard item={item as MiniAppItem} />
default:
return null
}
}
export function GridView({ items, selectedCategory }: GridViewProps) {
const effectiveGridClass = useMemo(() => {
let gridClass = 'columns-4 gap-4 '
switch (selectedCategory) {
case CherryStoreType.ASSISTANT:
gridClass += '2xl:columns-6'
break
case CherryStoreType.MINI_APP:
gridClass = 'grid grid-cols-8 gap-4 2xl:grid-cols-10'
break
}
return gridClass
}, [selectedCategory])
return (
<div className={effectiveGridClass}>
{items.map((item) => (
<Fragment key={item.id}>{CardComponent(selectedCategory, item)}</Fragment>
))}
</div>
)
}

View File

@ -1,83 +0,0 @@
import { CherryStoreItem } from '@renderer/types/cherryStore'
import { Badge } from '@renderer/ui/badge'
import { Card } from '@renderer/ui/card'
import { useState } from 'react'
// import { ItemDetailDialog } from './ItemDetailDialog'
export function ListView({ items }: { items: CherryStoreItem[] }) {
const [selectedItemForDetail, setSelectedItemForDetail] = useState<CherryStoreItem | null>(null)
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false)
const handleCardClick = (item: CherryStoreItem) => {
setSelectedItemForDetail(item)
setIsDetailDialogOpen(true)
}
return (
<>
<div className="space-y-4">
{items.map((item) => (
<Card
key={item.id}
className="cursor-pointer p-0 transition-shadow hover:shadow-lg"
onClick={() => handleCardClick(item)}>
<div className="flex flex-col sm:flex-row">
<div className="flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-l-lg bg-muted sm:h-auto">
{item.icon ? (
<div
className="flex h-full w-full items-center justify-center text-3xl"
role="img"
aria-label={item.title}>
{item.icon}
</div>
) : (
<img
src={item.image || '/placeholder.svg'} // Use placeholder if image missing
alt={item.title}
className="h-full w-full object-cover"
/>
)}
</div>
<div className="flex flex-1 flex-col justify-between p-4">
<div>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold">{item.title}</h3>
<p className="text-sm text-muted-foreground">{item.author}</p>
</div>
<Badge variant="outline">{item.type}</Badge>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{item.description}</p>
<div className="mt-2 flex flex-wrap gap-1">
{item.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</div>
{/* <div className="flex items-center justify-between border-t p-4 sm:w-48 sm:flex-col sm:items-end sm:justify-center sm:border-l sm:border-t-0 sm:p-4"> */}
{/* <div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
<span className="text-sm">{item?.rating}</span>
<span className="text-xs text-muted-foreground">({item?.downloads})</span>
</div> */}
{/* <Button size="sm" className="mt-2">
<Download className="mr-2 h-3.5 w-3.5 text-white! dark:text-black!" />
Install
</Button>
</div> */}
</div>
</Card>
))}
</div>
{/* <ItemDetailDialog
item={selectedItemForDetail}
isOpen={isDetailDialogOpen}
onClose={() => setIsDetailDialogOpen(false)}
/> */}
</>
)
}

View File

@ -1,27 +0,0 @@
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { MiniAppItem } from '@renderer/types/cherryStore'
import { BlurFade } from '@renderer/ui/third-party/BlurFade'
import logoList from './logoList'
export default function MiniAppCard({ item }: { item: MiniAppItem }) {
const { openMinappKeepAlive } = useMinappPopup()
const handleClick = () => {
openMinappKeepAlive({
id: item.id,
name: item.title,
url: item.url,
logo: item.image,
style: item.style
})
}
return (
<BlurFade key={item.id} delay={0.2} inView className="mb-4 cursor-pointer">
<div className="flex h-full w-full flex-col items-center justify-between" onClick={handleClick}>
<img src={logoList[item.image]} alt={item.title} className="w-full rounded-2xl" style={item.style} />
<div className="mt-2 flex flex-col items-center justify-center">
<p className="text-base text-[clamp(12px,1.1vw,16px)] font-medium text-muted-foreground">{item.title}</p>
</div>
</div>
</BlurFade>
)
}

View File

@ -1,111 +0,0 @@
import ThreeMinTopAppLogo from '@renderer/assets/images/apps/3mintop.png?url'
import AbacusLogo from '@renderer/assets/images/apps/abacus.webp?url'
import AIStudioLogo from '@renderer/assets/images/apps/aistudio.svg?url'
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
import BaiduAiSearchLogo from '@renderer/assets/images/apps/baidu-ai-search.webp?url'
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp?url'
import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg?url'
import CiciAppLogo from '@renderer/assets/images/apps/cici.webp?url'
import CozeAppLogo from '@renderer/assets/images/apps/coze.webp?url'
import DangbeiLogo from '@renderer/assets/images/apps/dangbei.jpg?url'
import DevvAppLogo from '@renderer/assets/images/apps/devv.png?url'
import DifyAppLogo from '@renderer/assets/images/apps/dify.svg?url'
import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png?url'
import DuckDuckGoAppLogo from '@renderer/assets/images/apps/duckduckgo.webp?url'
import FeloAppLogo from '@renderer/assets/images/apps/felo.png?url'
import FlowithAppLogo from '@renderer/assets/images/apps/flowith.svg?url'
import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png?url'
import GensparkLogo from '@renderer/assets/images/apps/genspark.jpg?url'
import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?url'
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.png?url'
import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
import KimiAppLogo from '@renderer/assets/images/apps/kimi.webp?url'
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
import NamiAiLogo from '@renderer/assets/images/apps/nm.png?url'
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm-search.webp?url'
import NotebookLMAppLogo from '@renderer/assets/images/apps/notebooklm.svg?url'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
import ZhipuProviderLogo from '@renderer/assets/images/apps/qingyan.png?url'
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?url'
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
import ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
import HailuoModelLogo from '@renderer/assets/images/models/hailuo.png?url'
import QwenModelLogo from '@renderer/assets/images/models/qwen.png?url'
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png?url'
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url'
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url'
export default {
ThreeMinTopAppLogo,
AbacusLogo,
AIStudioLogo,
BaiduAiAppLogo,
BaiduAiSearchLogo,
BaicuanAppLogo,
BoltAppLogo,
CiciAppLogo,
CozeAppLogo,
DangbeiLogo,
DevvAppLogo,
DifyAppLogo,
DoubaoAppLogo,
DuckDuckGoAppLogo,
FeloAppLogo,
FlowithAppLogo,
GeminiAppLogo,
GensparkLogo,
GithubCopilotLogo,
GrokAppLogo,
GrokXAppLogo,
HikaLogo,
HuggingChatLogo,
KimiAppLogo,
LambdaChatLogo,
LeChatLogo,
MetasoAppLogo,
MonicaLogo,
NamiAiLogo,
NamiAiSearchLogo,
NotebookLMAppLogo,
PerplexityAppLogo,
PoeAppLogo,
QwenlmAppLogo,
SensetimeAppLogo,
SparkDeskAppLogo,
ThinkAnyLogo,
TiangongAiLogo,
WanZhiAppLogo,
WPSLingXiLogo,
XiaoYiAppLogo,
YouLogo,
TencentYuanbaoAppLogo,
YuewenAppLogo,
ZaiAppLogo,
ZhihuAppLogo,
ClaudeAppLogo,
HailuoModelLogo,
QwenModelLogo,
DeepSeekProviderLogo,
GroqProviderLogo,
OpenAiProviderLogo,
SiliconFlowProviderLogo,
ZhipuProviderLogo
}

View File

@ -1,103 +0,0 @@
import { CherryStoreItem } from '@renderer/types/cherryStore'
import { Button } from '@renderer/ui/button'
import { Input } from '@renderer/ui/input'
import { cn } from '@renderer/utils'
import { Grid3X3, List, Search } from 'lucide-react'
import React from 'react' // Import React for ComponentType
// Import the card components
// Define the type for a store item based on store_list.json
import { GridView } from './GridView'
import { ListView } from './ListView'
interface StoreContentProps {
viewMode: 'grid' | 'list'
searchQuery: string
selectedCategory: string
items: CherryStoreItem[]
onSearchQueryChange: (query: string) => void
onViewModeChange: (mode: 'grid' | 'list') => void
}
export function StoreContent({
viewMode,
searchQuery,
selectedCategory, // This prop will drive the component choice
items,
onSearchQueryChange,
onViewModeChange
}: StoreContentProps) {
return (
<div className="w-full flex-1 overflow-auto">
{/* Sticky Header for Search, Filter, View Mode, and Category Tabs */}
<div className="sticky top-0 z-10 border-b bg-background p-4">
<div className="flex flex-col gap-4">
{/* Top row: Search, Filter, View buttons */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search store..."
className="pl-8"
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
/>
</div>
{/* <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Most Popular</DropdownMenuItem>
<DropdownMenuItem>Newest</DropdownMenuItem>
<DropdownMenuItem>Highest Rated</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu> */}
<Button
variant="outline"
size="icon"
onClick={() => onViewModeChange('grid')}
className={cn(viewMode === 'grid' && 'bg-accent text-accent-foreground')}>
<Grid3X3 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onViewModeChange('list')}
className={cn(viewMode === 'list' && 'bg-accent text-accent-foreground')}>
<List className="h-4 w-4" />
</Button>
</div>
{/* Bottom row: Category Tabs */}
{/* <Tabs value={selectedCategory} onValueChange={onCategoryChange}>
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="mcp">MCP Services</TabsTrigger>
<TabsTrigger value="plugins">Plugins</TabsTrigger>
<TabsTrigger value="apps">Applications</TabsTrigger>
</TabsList>
</Tabs> */}
</div>
</div>
{/* Main Content Area: Grid or List View */}
<div className="w-full p-4">
{items.length === 0 ? (
<div className="flex h-[400px] items-center justify-center">
<p className="text-center text-muted-foreground">No items found matching your criteria.</p>
</div>
) : viewMode === 'grid' ? (
<GridView items={items} selectedCategory={selectedCategory} />
) : (
<ListView items={items} />
)}
</div>
</div>
)
}
// List View Component

View File

@ -1,101 +0,0 @@
import { Category, SubCategoryItem } from '@renderer/types/cherryStore'
import { Badge } from '@renderer/ui/badge'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@renderer/ui/collapsible'
import {
Sidebar,
SidebarContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubItem
} from '@renderer/ui/sidebar'
interface StoreSidebarProps {
categories: Category[]
selectedCategory: string
selectedSubcategory: string
onSelectCategory: (categoryId: string, subcategoryId: string, row?: SubCategoryItem) => void
}
export function StoreSidebar({
categories,
selectedCategory,
selectedSubcategory,
onSelectCategory
}: StoreSidebarProps) {
if (!categories || categories.length === 0) {
return (
<Sidebar className="absolute top-0 left-0 h-full border-r">
<SidebarContent>
<p className="p-4 text-sm text-gray-500">No categories loaded.</p>
</SidebarContent>
</Sidebar>
)
}
return (
<Sidebar className="absolute top-0 left-0 h-full border-r">
<SidebarContent>
<SidebarMenu className="gap-0">
{categories.map((category, index) =>
category.items?.length ? (
<Collapsible key={category.id} defaultOpen={index === 0} className="group/collapsible w-full">
<SidebarMenuItem className="w-full px-0 py-0">
<CollapsibleTrigger asChild>
<SidebarMenuButton
variant="outline"
className="rounded-none hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none">
<span className="truncate">{category.title}</span>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden text-sm transition-all data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<SidebarMenuSub className="py-1 pr-1 pl-4">
{category.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.id}>
<SidebarMenuButton
isActive={category.id === selectedCategory && subItem.id === selectedSubcategory}
className="w-full justify-between"
onClick={() => {
onSelectCategory(category.id, subItem.id, subItem)
}}
size="sm">
<span className="truncate">{subItem.name}</span>
{typeof subItem.count === 'number' && (
<Badge variant="secondary" className="ml-auto shrink-0">
{subItem.count}
</Badge>
)}
</SidebarMenuButton>
</SidebarMenuSubItem>
))}
{/* {category.items.length === 0 && (
<SidebarMenuSubItem className="px-3 py-1.5 text-xs text-muted-foreground">
No items
</SidebarMenuSubItem>
)} */}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
) : (
<SidebarMenuItem key={category.id}>
<SidebarMenuButton
asChild
isActive={category.id === selectedCategory}
variant="outline"
className="rounded-none hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none"
onClick={() => {
onSelectCategory(category.id, '')
}}>
{/* <item.icon /> */}
<span>{category.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
)}
</SidebarMenu>
</SidebarContent>
</Sidebar>
)
}

View File

@ -1,125 +0,0 @@
import store from '@renderer/store'
import { Category, CherryStoreItem } from '@renderer/types/cherryStore'
// 移除 LoadedStoreData 和 LoadedStoreDataByType因为我们将按需加载
// export interface LoadedStoreData {
// categories: Category[]
// allItems: CherryStoreItem[]
// }
// export interface LoadedStoreDataByType {
// categories: Category[]
// assistantItems?: CherryStoreItem[]
// knowledgeItems?: CherryStoreItem[] // Example for another type
// mcpServerItems?: CherryStoreItem[] // Example for another type
// // Add other item types here as you create their list_*.json files
// }
const getResourcesPath = (path: string) => {
const { resourcesPath } = store.getState().runtime
return resourcesPath + path
}
// 缓存变量
let cachedCategories: Category[] | null = null
const cachedItemsByFile: Record<string, CherryStoreItem[]> = {}
// Helper function to read and parse JSON files safely
async function readFileSafe<T>(filePath: string): Promise<T | undefined> {
try {
if (!window.api?.fs?.read) {
console.error('window.api.fs.read is not available. Ensure preload script is set up correctly.')
return undefined
}
const fileContent = await window.api.fs.read(filePath)
if (typeof fileContent === 'string') {
return JSON.parse(fileContent) as T
}
console.warn(
`Content read from ${filePath} was not a string or file might be empty/missing. Received:`,
fileContent
)
return undefined
} catch (error) {
console.error(`Error reading or parsing file ${filePath}:`, error)
return undefined
}
}
export async function loadCategories(resourcesPath: string): Promise<Category[]> {
if (cachedCategories) {
console.log('Returning cached categories:', cachedCategories.length)
return cachedCategories
}
const categoriesFilePath = resourcesPath + '/data/store_categories.json'
console.log('categoriesFilePath', categoriesFilePath)
const categories = (await readFileSafe<Category[]>(categoriesFilePath)) || []
if (categories.length > 0) {
cachedCategories = categories
console.log('Categories loaded and cached:', categories.length)
} else {
console.log('No categories found or error loading categories.')
}
return categories
}
// 新函数:根据分类、子分类和搜索查询加载和筛选商品
export async function loadAndFilterItems(
categoryId: string,
subcategoryId: string,
searchQuery: string
): Promise<CherryStoreItem[]> {
let itemsFilePath = ''
if (categoryId === 'all' || !categoryId) {
console.warn("loadAndFilterItems called with 'all' or invalid categoryId. Returning empty for now.")
return []
} else {
itemsFilePath = getResourcesPath(`/data/store_list_${categoryId}.json`)
}
if (!itemsFilePath) {
console.error(`No item file path determined for categoryId: ${categoryId}`)
return []
}
let items: CherryStoreItem[] = []
if (cachedItemsByFile[itemsFilePath]) {
items = cachedItemsByFile[itemsFilePath]
console.log(`Returning cached items for ${itemsFilePath}:`, items.length)
} else {
const loadedItems = await readFileSafe<CherryStoreItem[]>(itemsFilePath)
if (loadedItems) {
items = loadedItems
cachedItemsByFile[itemsFilePath] = loadedItems
console.log(`Items loaded and cached for ${itemsFilePath}:`, items.length)
} else {
console.log(`No items found or error loading items for: ${itemsFilePath}`)
// 确保在文件读取失败或为空时items 仍然是空数组
items = []
// 也可以选择缓存一个空数组,以避免重复尝试读取不存在或错误的文件
// cachedItemsByFile[itemsFilePath] = [];
}
}
if (!items.length) {
// 如果缓存中是空数组或者新加载的是空数组,直接返回,避免不必要的筛选
return []
}
let filteredItems = items
if (searchQuery || subcategoryId) {
const query = searchQuery.toLowerCase()
filteredItems = filteredItems.filter((item) => {
const searchableText = `${item.subcategoryId} ${item.title.toLowerCase()} ${item.author?.toLowerCase() || ''} ${item.tags?.join(' ')?.toLowerCase() || ''}`
return searchableText.includes(query)
})
}
console.log(
`Filtered items for ${categoryId} - ${subcategoryId} - "${searchQuery}". Found: ${filteredItems.length} (from ${items.length} initial)`
)
return filteredItems
}

View File

@ -1,121 +0,0 @@
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { Category, CherryStoreItem, SubCategoryItem } from '@renderer/types/cherryStore'
import { SidebarProvider } from '@renderer/ui/sidebar'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
// Import Context and the main Dialog Manager component
import { DialogManagerProvider } from './components/dialog/DialogManagerContext'
import Dialogs from './components/dialog/index'
import { StoreContent } from './components/StoreContent'
import { StoreSidebar } from './components/StoreSidebar'
import { loadAndFilterItems, loadCategories } from './data'
export default function StoreLayout() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [selectedSubcategory, setSelectedSubcategory] = useState<string>('all')
const { t } = useTranslation()
const { resourcesPath } = useRuntime()
const [categories, setCategories] = useState<Category[]>([])
const [items, setItems] = useState<CherryStoreItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isLoadingItems, setIsLoadingItems] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchCategories = async () => {
try {
setIsLoading(true)
setError(null)
const loadedCategories = await loadCategories(resourcesPath)
setCategories(loadedCategories)
if (loadedCategories.length > 0 && !selectedCategory) {
setSelectedCategory(loadedCategories[0].id)
setSelectedSubcategory(loadedCategories[0].items[0].id)
}
} catch (err) {
console.error('Error in StoreLayout fetchCategories:', err)
setError('Failed to load store categories. Check console for details.')
} finally {
setIsLoading(false)
}
}
resourcesPath && fetchCategories()
}, [resourcesPath])
useEffect(() => {
if (!selectedCategory) {
setItems([])
return
}
const fetchItems = async () => {
try {
setIsLoadingItems(true)
setError(null)
const filteredItems = await loadAndFilterItems(selectedCategory, selectedSubcategory, searchQuery)
setItems(filteredItems)
} catch (err) {
console.error('Error in StoreLayout fetchItems:', err)
setError('Failed to load store items. Check console for details.')
setItems([])
} finally {
setIsLoadingItems(false)
}
}
fetchItems()
}, [selectedCategory, selectedSubcategory, searchQuery])
const handleSelectCategory = (categoryId: string, subcategoryId: string, row?: SubCategoryItem) => {
setSelectedCategory(categoryId)
setSelectedSubcategory(subcategoryId)
setSearchQuery(row?.name || '')
}
if (isLoading) {
return <div className="p-4 text-center">Loading store categories...</div>
}
if (error) {
return <div className="p-4 text-center text-red-500">Error: {error}</div>
}
console.log('categories', categories)
return (
<DialogManagerProvider>
<div className="h-[calc(100vh_-_var(--navbar-height))] w-full">
<Navbar className="h-full">
<NavbarCenter>{t('store.title')}</NavbarCenter>
</Navbar>
<div id="content-container" className="h-full w-full">
<SidebarProvider className="relative h-full min-h-full w-full">
<StoreSidebar
categories={categories}
selectedCategory={selectedCategory}
selectedSubcategory={selectedSubcategory}
onSelectCategory={handleSelectCategory}
/>
{isLoadingItems ? (
// TODO: 添加 loading 动画
<div className="p-4 text-center">Loading items...</div>
) : (
<StoreContent
viewMode={viewMode}
searchQuery={searchQuery}
selectedCategory={selectedCategory}
items={items}
onSearchQueryChange={setSearchQuery}
onViewModeChange={setViewMode}
/>
)}
</SidebarProvider>
</div>
<Dialogs />
</div>
</DialogManagerProvider>
)
}

View File

@ -1261,7 +1261,7 @@ const migrateConfig = {
...state.settings,
sidebarIcons: {
...state.settings.sidebarIcons,
visible: [...state.settings.sidebarIcons.visible, 'store']
visible: [...state.settings.sidebarIcons.visible, 'discover']
}
}
}

View File

@ -0,0 +1,113 @@
import { cn } from '@renderer/utils'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
interface Tab {
id: string
label: string
}
interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
tabs: Tab[]
activeTab?: string
onTabChange?: (tabId: string) => void
}
const Tabs = ({
ref,
className,
tabs,
activeTab: _,
onTabChange,
...props
}: TabsProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [hoverStyle, setHoverStyle] = useState({})
const [activeStyle, setActiveStyle] = useState({ left: '0px', width: '0px' })
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
if (hoveredIndex !== null) {
const hoveredElement = tabRefs.current[hoveredIndex]
if (hoveredElement) {
const { offsetLeft, offsetWidth } = hoveredElement
setHoverStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`
})
}
}
}, [hoveredIndex])
useEffect(() => {
const activeElement = tabRefs.current[activeIndex]
if (activeElement) {
const { offsetLeft, offsetWidth } = activeElement
setActiveStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`
})
}
}, [activeIndex])
useEffect(() => {
requestAnimationFrame(() => {
const firstElement = tabRefs.current[0]
if (firstElement) {
const { offsetLeft, offsetWidth } = firstElement
setActiveStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`
})
}
})
}, [])
return (
<div ref={ref} className={cn('relative', className)} {...props}>
<div className="relative">
{/* Hover Highlight */}
<div
className="absolute flex h-[30px] items-center rounded-[6px] bg-[#0e0f1114] transition-all duration-300 ease-out dark:bg-[#ffffff1a]"
style={{
...hoverStyle,
opacity: hoveredIndex !== null ? 1 : 0
}}
/>
{/* Active Indicator */}
<div
className="absolute bottom-[-6px] h-[2px] bg-[#0e0f11] transition-all duration-300 ease-out dark:bg-white"
style={activeStyle}
/>
{/* Tabs */}
<div className="relative flex items-center space-x-[6px]">
{tabs.map((tab, index) => (
<div
key={tab.id}
ref={(el) => (tabRefs.current[index] = el)}
className={cn(
'h-[30px] cursor-pointer px-3 py-2 transition-colors duration-300',
index === activeIndex ? 'text-[#0e0e10] dark:text-white' : 'text-[#0e0f1199] dark:text-[#ffffff99]'
)}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => {
setActiveIndex(index)
onTabChange?.(tab.id)
}}>
<div className="flex h-full items-center justify-center text-sm leading-5 font-medium whitespace-nowrap">
{tab.label}
</div>
</div>
))}
</div>
</div>
</div>
)
}
Tabs.displayName = 'Tabs'
export { Tabs }

View File

@ -5335,7 +5335,7 @@ __metadata:
lint-staged: "npm:^15.5.0"
lodash: "npm:^4.17.21"
lru-cache: "npm:^11.1.0"
lucide-react: "npm:^0.509.0"
lucide-react: "npm:^0.511.0"
markdown-it: "npm:^14.1.0"
mime: "npm:^4.0.4"
motion: "npm:^12.12.1"
@ -5386,6 +5386,7 @@ __metadata:
tw-animate-css: "npm:^1.2.9"
typescript: "npm:^5.6.2"
undici: "npm:^7.4.0"
usehooks-ts: "npm:^3.1.1"
uuid: "npm:^10.0.0"
vite: "npm:6.2.6"
vitest: "npm:^3.1.1"
@ -11949,6 +11950,13 @@ __metadata:
languageName: node
linkType: hard
"lodash.debounce@npm:^4.0.8":
version: 4.0.8
resolution: "lodash.debounce@npm:4.0.8"
checksum: 10c0/762998a63e095412b6099b8290903e0a8ddcb353ac6e2e0f2d7e7d03abd4275fe3c689d88960eb90b0dde4f177554d51a690f22a343932ecbc50a5d111849987
languageName: node
linkType: hard
"lodash.escaperegexp@npm:^4.1.2":
version: 4.1.2
resolution: "lodash.escaperegexp@npm:4.1.2"
@ -12085,12 +12093,12 @@ __metadata:
languageName: node
linkType: hard
"lucide-react@npm:^0.509.0":
version: 0.509.0
resolution: "lucide-react@npm:0.509.0"
"lucide-react@npm:^0.511.0":
version: 0.511.0
resolution: "lucide-react@npm:0.511.0"
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/eb7434a99738d2a3b3a193c4e0cf51691a06223ec8eda514ea7fd96cdbd311069d25353cd9e720810e5c7ba3891186de6a0463c3ac5382cc1d981938847b9b95
checksum: 10c0/bf09dd73cf2233abea90506ad31a91739555d761062722acbe045cb73e274f035b196472de0971a8a8f0645b2b54e3f21b8c1980fe87c909ca93171a9c28428a
languageName: node
linkType: hard
@ -18503,6 +18511,17 @@ __metadata:
languageName: node
linkType: hard
"usehooks-ts@npm:^3.1.1":
version: 3.1.1
resolution: "usehooks-ts@npm:3.1.1"
dependencies:
lodash.debounce: "npm:^4.0.8"
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
checksum: 10c0/8bbebf52b063f2e705eb27364f08ec2eff987b9e8d28d82a28248652dd89ef53cb514e1f2ee1cbc6ac6e083a8dce86310301c3e92a99902b98c32a26381202d7
languageName: node
linkType: hard
"utf8-byte-length@npm:^1.0.1":
version: 1.0.5
resolution: "utf8-byte-length@npm:1.0.5"