mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 20:12:38 +08:00
feat: add store categories and items with enhanced filtering functionality
- Introduced new JSON files for store categories and assistant items to improve organization and accessibility. - Implemented a conversion script to dynamically generate the assistant items list from agents data. - Refactored store components to utilize the new data structure, enhancing the store layout and user experience. - Added loading states and error handling for category and item fetching processes. - Created new GridView and ListView components for displaying store items in different formats.
This commit is contained in:
parent
37482bca7b
commit
802402e922
90
resources/data/store_categories.json
Normal file
90
resources/data/store_categories.json
Normal file
@ -0,0 +1,90 @@
|
||||
[
|
||||
{
|
||||
"id": "all",
|
||||
"title": "Categories",
|
||||
"items": [
|
||||
{ "id": "featured", "name": "Featured", "count": 24 },
|
||||
{ "id": "new", "name": "New Releases", "count": 18 },
|
||||
{ "id": "top", "name": "Top Rated", "count": 32 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "assistant",
|
||||
"title": "助手",
|
||||
"items": [
|
||||
{ "id": "assistant-job", "name": "职业" },
|
||||
{ "id": "assistant-business", "name": "商业" },
|
||||
{ "id": "assistant-tools", "name": "工具" },
|
||||
{ "id": "assistant-language", "name": "语言" },
|
||||
{ "id": "assistant-office", "name": "办公" },
|
||||
{ "id": "assistant-general", "name": "通用" },
|
||||
{ "id": "assistant-writing", "name": "写作" },
|
||||
{ "id": "assistant-coding", "name": "编程" },
|
||||
{ "id": "assistant-emotion", "name": "情感" },
|
||||
{ "id": "assistant-education", "name": "教育" },
|
||||
{ "id": "assistant-creative", "name": "创意" },
|
||||
{ "id": "assistant-academic", "name": "学术" },
|
||||
{ "id": "assistant-design", "name": "设计" },
|
||||
{ "id": "assistant-art", "name": "艺术" },
|
||||
{ "id": "assistant-entertainment", "name": "娱乐" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mini-app",
|
||||
"title": "小程序",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"id": "knowledge",
|
||||
"title": "知识库",
|
||||
"items": [
|
||||
{ "id": "knowledge-history", "name": "历史" },
|
||||
{ "id": "knowledge-literature", "name": "文学" },
|
||||
{ "id": "knowledge-education", "name": "教育" },
|
||||
{ "id": "knowledge-law", "name": "法律" },
|
||||
{ "id": "knowledge-science", "name": "科学" },
|
||||
{ "id": "knowledge-medicine", "name": "医学" },
|
||||
{ "id": "knowledge-economics", "name": "经济" },
|
||||
{ "id": "knowledge-art", "name": "艺术" },
|
||||
{ "id": "knowledge-geography", "name": "地理" },
|
||||
{ "id": "knowledge-social", "name": "社会" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mcp-server",
|
||||
"title": "MCP 服务器",
|
||||
"items": [
|
||||
{ "id": "mcp-dev-tools", "name": "Developer Tools" },
|
||||
{ "id": "mcp-research-data", "name": "Research And Data" },
|
||||
{ "id": "mcp-cloud", "name": "Cloud Platforms" },
|
||||
{ "id": "mcp-communication", "name": "Communication" },
|
||||
{ "id": "mcp-browser-auto", "name": "Browser Automation" },
|
||||
{ "id": "mcp-finance", "name": "Finance" },
|
||||
{ "id": "mcp-security", "name": "Security" },
|
||||
{ "id": "mcp-os-auto", "name": "Os Automation" },
|
||||
{ "id": "mcp-databases", "name": "Databases" },
|
||||
{ "id": "mcp-cloud-storage", "name": "Cloud Storage" },
|
||||
{ "id": "mcp-monitoring", "name": "Monitoring" },
|
||||
{ "id": "mcp-media", "name": "Entertainment And Media" },
|
||||
{ "id": "mcp-knowledge-mem", "name": "Knowledge And Memory" },
|
||||
{ "id": "mcp-file-systems", "name": "File Systems" },
|
||||
{ "id": "mcp-location", "name": "Location Services" },
|
||||
{ "id": "mcp-calendar", "name": "Calendar Management" },
|
||||
{ "id": "mcp-customer-data", "name": "Customer Data Platforms" },
|
||||
{ "id": "mcp-ai-chatbot", "name": "AI Chatbot" },
|
||||
{ "id": "mcp-virtualization", "name": "Virtualization" },
|
||||
{ "id": "mcp-official-servers", "name": "Official Servers" },
|
||||
{ "id": "mcp-database", "name": "Database" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "model-provider",
|
||||
"title": "模型服务",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"id": "agent",
|
||||
"title": "智能体",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
13798
resources/data/store_list_assistant.json
Normal file
13798
resources/data/store_list_assistant.json
Normal file
File diff suppressed because it is too large
Load Diff
124
resources/js/conver_agents.js
Normal file
124
resources/js/conver_agents.js
Normal file
@ -0,0 +1,124 @@
|
||||
// convert_agents.js
|
||||
// 将 agents.json 转换为 list_assistant.json
|
||||
// 一次性的(如何后面不扩展agents.json), 则不需要再运行这个脚本
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// --- 配置路径 ---
|
||||
const agentsJsonPath = path.resolve(__dirname, '../data/agents.json')
|
||||
const outputDir = path.resolve(__dirname, '../data')
|
||||
const outputJsonPath = path.resolve(outputDir, 'list_assistant.json')
|
||||
|
||||
// --- 映射和默认值配置 ---
|
||||
const CATEGORY_ID_ASSISTANT = 'assistant'
|
||||
|
||||
// 映射 agents.json 的 group 名称 到 store_categories.json 中 "助手" 分类的二级分类 ID
|
||||
// Key: agent.group 中的项 (请确保大小写和字符与 agents.json 中的 group 值一致)
|
||||
// Value: 二级分类 ID (subcategoryId)
|
||||
const groupToSubcategoryMap = {
|
||||
职业: 'assistant-job',
|
||||
商业: 'assistant-business',
|
||||
工具: 'assistant-tools',
|
||||
语言: 'assistant-language',
|
||||
办公: 'assistant-office',
|
||||
通用: 'assistant-general',
|
||||
写作: 'assistant-writing',
|
||||
编程: 'assistant-coding',
|
||||
情感: 'assistant-emotion',
|
||||
教育: 'assistant-education',
|
||||
创意: 'assistant-creative',
|
||||
学术: 'assistant-academic',
|
||||
设计: 'assistant-design',
|
||||
艺术: 'assistant-art',
|
||||
娱乐: 'assistant-entertainment',
|
||||
精选: 'assistant-general',
|
||||
生活: 'assistant-general',
|
||||
医疗: 'assistant-health',
|
||||
文案: 'assistant-writing',
|
||||
健康: 'assistant-health',
|
||||
点评: 'assistant-review',
|
||||
百科: 'assistant-knowledge',
|
||||
旅游: 'assistant-travel',
|
||||
翻译: 'assistant-language'
|
||||
}
|
||||
|
||||
// 从 agent.group 数组中获取 subcategoryId
|
||||
// 策略:取第一个在 groupToSubcategoryMap 中能找到匹配的 group 名称
|
||||
function getSubcategoryIdFromGroup(groupArray = []) {
|
||||
if (!Array.isArray(groupArray)) return 'assistant-general'
|
||||
|
||||
for (const groupName of groupArray) {
|
||||
const key = String(groupName)
|
||||
if (groupToSubcategoryMap[key]) {
|
||||
return groupToSubcategoryMap[key]
|
||||
}
|
||||
}
|
||||
// 如果 group 中没有一项能精确映射,打印警告并返回通用默认值
|
||||
// (避免为仅包含 "精选" 且 "精选" 本身无特定映射的情况重复打印警告, featured 字段会处理它)
|
||||
if (!groupArray.includes('精选') || groupArray.length > 1 || !groupToSubcategoryMap['精选']) {
|
||||
console.warn(
|
||||
`No specific subcategory mapping found for group: ${JSON.stringify(groupArray)} (excluding '精选' if it has no specific map other than setting featured flag). Defaulting to 'assistant-general'.`
|
||||
)
|
||||
}
|
||||
return 'assistant-general'
|
||||
}
|
||||
|
||||
// --- 主转换逻辑 ---
|
||||
try {
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
console.log(`Created output directory: ${outputDir}`)
|
||||
}
|
||||
|
||||
const agentsDataRaw = fs.readFileSync(agentsJsonPath, 'utf-8')
|
||||
const agents = JSON.parse(agentsDataRaw)
|
||||
|
||||
// 假设 agents.json 的根是一个直接的数组
|
||||
if (!Array.isArray(agents)) {
|
||||
throw new Error(
|
||||
`agents.json (path: ${agentsJsonPath}) is not an array. Please ensure it is a JSON array of agent objects.`
|
||||
)
|
||||
}
|
||||
console.log(`Read ${agents.length} raw agent objects from ${agentsJsonPath}`)
|
||||
|
||||
const storeAssistants = agents
|
||||
.map((agent) => {
|
||||
if (!agent || typeof agent.id === 'undefined' || !agent.name) {
|
||||
console.warn(
|
||||
'Skipping invalid agent object (missing id or name):',
|
||||
agent && agent.id ? `ID: ${agent.id}` : agent
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// 从 agent.group 获取 subcategoryId,同时将 agent.group 用作 StoreItem.tags
|
||||
const agentGroups = Array.isArray(agent.group) ? agent.group : []
|
||||
const subcategoryId = getSubcategoryIdFromGroup(agentGroups)
|
||||
|
||||
// 检查 group 是否包含 "精选" 来设置 featured 标志
|
||||
const isFeaturedByGroup = agentGroups.includes('精选')
|
||||
|
||||
return {
|
||||
id: String(agent.id), // 使用 agent.id (顶层)
|
||||
title: agent.name, // 使用 agent.name (顶层)
|
||||
description: agent.description || 'No description available.', // 使用 agent.description (顶层)
|
||||
type: 'Assistant', // 固定类型
|
||||
categoryId: CATEGORY_ID_ASSISTANT, // 固定一级分类
|
||||
subcategoryId: subcategoryId, // 从 agent.group 动态获取
|
||||
author: 'Cherry Studio', // agent.author 可能不存在, 提供默认 'Cherry Studio'
|
||||
rating: parseFloat(agent.rating) || 4.0, // agent.rating 可能不存在, 提供默认 4.0
|
||||
downloads: '0',
|
||||
image: agent.emoji || '🤖', // 使用 agent.emoji (顶层), 若无则用默认
|
||||
tags: agentGroups, // 使用 agent.group (顶层) 作为 StoreItem.tags
|
||||
// 如果 group 含 "精选",则 isFeaturedByGroup 为 true。
|
||||
featured: isFeaturedByGroup || (typeof agent.featured === 'boolean' ? agent.featured : false)
|
||||
}
|
||||
})
|
||||
.filter((item) => item !== null)
|
||||
|
||||
fs.writeFileSync(outputJsonPath, JSON.stringify(storeAssistants, null, 2), 'utf-8')
|
||||
console.log(`Successfully converted ${storeAssistants.length} agents to ${outputJsonPath}`)
|
||||
} catch (error) {
|
||||
console.error('Error during conversion:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
48
src/renderer/src/pages/store/components/GridView.tsx
Normal file
48
src/renderer/src/pages/store/components/GridView.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { CherryStoreItem } from '@renderer/types/cherryStore'
|
||||
import { Badge } from '@renderer/ui/badge'
|
||||
import { Button } from '@renderer/ui/button'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@renderer/ui/card'
|
||||
import { BlurFade } from '@renderer/ui/third-party/BlurFade'
|
||||
import { Download, Star } from 'lucide-react'
|
||||
|
||||
export function GridView({ items }: { items: CherryStoreItem[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4 ">
|
||||
{items.map((item) => (
|
||||
<BlurFade key={item.id} delay={0.25} inView className="overflow-hidden flex flex-col">
|
||||
<Card className="overflow-hidden p-0 flex flex-col">
|
||||
<CardHeader className="p-0">
|
||||
<div className="aspect-square w-full overflow-hidden bg-muted">
|
||||
<img
|
||||
src={item.image || '/placeholder.svg'} // Use placeholder if image missing
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover transition-transform hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 flex-grow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="line-clamp-1 text-base">{item.title}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{item.author}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{item.type}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">{item.description}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between p-4 pt-0">
|
||||
<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>
|
||||
</div>
|
||||
<Button size="sm">
|
||||
<Download className="mr-2 h-3.5 w-3.5 text-white dark:text-black" />
|
||||
Install
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</BlurFade>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
src/renderer/src/pages/store/components/ListView.tsx
Normal file
54
src/renderer/src/pages/store/components/ListView.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { CherryStoreItem } from '@renderer/types/cherryStore'
|
||||
import { Badge } from '@renderer/ui/badge'
|
||||
import { Button } from '@renderer/ui/button'
|
||||
import { Card } from '@renderer/ui/card'
|
||||
import { Download, Star } from 'lucide-react'
|
||||
export function ListView({ items }: { items: CherryStoreItem[] }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} className="p-0">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-l-lg bg-muted sm:h-auto">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@ -1,32 +1,19 @@
|
||||
import { Badge } from '@renderer/ui/badge'
|
||||
import { CherryStoreItem } from '@renderer/types/cherryStore'
|
||||
import { Button } from '@renderer/ui/button'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@renderer/ui/card'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@renderer/ui/dropdown-menu'
|
||||
import { Input } from '@renderer/ui/input'
|
||||
import { BlurFade } from '@renderer/ui/third-party/BlurFade'
|
||||
import { cn } from '@renderer/utils'
|
||||
import { Download, Filter, Grid3X3, List, Search, Star } from 'lucide-react'
|
||||
import { Filter, Grid3X3, List, Search } from 'lucide-react'
|
||||
|
||||
// Define the type for a store item based on store_list.json
|
||||
interface StoreItem {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
type: string
|
||||
categoryId: string
|
||||
subcategoryId: string
|
||||
author: string
|
||||
rating: number
|
||||
downloads: string
|
||||
image?: string
|
||||
tags: string[]
|
||||
featured: boolean
|
||||
}
|
||||
import { GridView } from './GridView'
|
||||
import { ListView } from './ListView'
|
||||
|
||||
interface StoreContentProps {
|
||||
viewMode: 'grid' | 'list'
|
||||
searchQuery: string
|
||||
selectedCategory: string
|
||||
items: StoreItem[]
|
||||
items: CherryStoreItem[]
|
||||
onSearchQueryChange: (query: string) => void
|
||||
onViewModeChange: (mode: 'grid' | 'list') => void
|
||||
}
|
||||
@ -39,7 +26,7 @@ export function StoreContent({
|
||||
onViewModeChange
|
||||
}: StoreContentProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex-1 overflow-auto w-full">
|
||||
{/* 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">
|
||||
@ -97,7 +84,7 @@ export function StoreContent({
|
||||
</div>
|
||||
|
||||
{/* Main Content Area: Grid or List View */}
|
||||
<div className="p-4">
|
||||
<div className="p-4 w-full">
|
||||
{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>
|
||||
@ -112,96 +99,4 @@ export function StoreContent({
|
||||
)
|
||||
}
|
||||
|
||||
// Grid View Component
|
||||
function GridView({ items }: { items: StoreItem[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((item, idx) => (
|
||||
<BlurFade key={item.id} delay={0.25 + idx * 0.05} inView>
|
||||
<Card key={item.id} className="overflow-hidden p-0">
|
||||
<CardHeader className="p-0">
|
||||
<div className="aspect-square w-full overflow-hidden bg-muted">
|
||||
<img
|
||||
src={item.image || '/placeholder.svg'} // Use placeholder if image missing
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover transition-transform hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="line-clamp-1 text-base">{item.title}</CardTitle>
|
||||
<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>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between p-4 pt-0">
|
||||
<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>
|
||||
</div>
|
||||
<Button size="sm">
|
||||
<Download className="mr-2 h-3.5 w-3.5 text-white dark:text-black" />
|
||||
Install
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</BlurFade>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// List View Component
|
||||
function ListView({ items }: { items: StoreItem[] }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} className="p-0">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-l-lg bg-muted sm:h-auto">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -10,23 +10,36 @@ import {
|
||||
SidebarMenuItem
|
||||
} from '@renderer/ui/sidebar'
|
||||
|
||||
import categories from '../data/store_categories.json'
|
||||
import { Category } from '../data'
|
||||
|
||||
interface StoreSidebarProps {
|
||||
categories: Category[]
|
||||
selectedCategory: string
|
||||
selectedSubcategory: string
|
||||
onSelectCategory: (categoryId: string, subcategoryId: string) => void
|
||||
}
|
||||
|
||||
export function StoreSidebar({ selectedCategory, selectedSubcategory, onSelectCategory }: StoreSidebarProps) {
|
||||
console.log('selectedCategory', selectedCategory)
|
||||
console.log('selectedSubcategory', selectedSubcategory)
|
||||
export function StoreSidebar({
|
||||
categories,
|
||||
selectedCategory,
|
||||
selectedSubcategory,
|
||||
onSelectCategory
|
||||
}: StoreSidebarProps) {
|
||||
if (!categories || categories.length === 0) {
|
||||
return (
|
||||
<Sidebar className="absolute left-0 top-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 left-0 top-0 h-full border-r">
|
||||
<SidebarContent>
|
||||
{categories.map((category) => (
|
||||
<SidebarGroup key={category.title}>
|
||||
{/* Only render label if it's not the 'all' category wrapper */}
|
||||
<SidebarGroup key={category.id}>
|
||||
{category.id !== 'all' && <SidebarGroupLabel>{category.title}</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
@ -36,14 +49,14 @@ export function StoreSidebar({ selectedCategory, selectedSubcategory, onSelectCa
|
||||
isActive={category.id === selectedCategory && item.id === selectedSubcategory}
|
||||
className="justify-between"
|
||||
onClick={() => {
|
||||
console.log('category', category)
|
||||
// Special handling for top-level 'all' categories vs nested ones
|
||||
onSelectCategory(category.id, item.id) // Selecting 'all', 'featured', 'new', 'top' uses the item.id as category
|
||||
onSelectCategory(category.id, item.id)
|
||||
}}>
|
||||
{item.name}
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{item.count}
|
||||
</Badge>
|
||||
{typeof item.count === 'number' && (
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{item.count}
|
||||
</Badge>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
|
||||
168
src/renderer/src/pages/store/data/index.ts
Normal file
168
src/renderer/src/pages/store/data/index.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import store from '@renderer/store'
|
||||
import { CherryStoreItem } from '@renderer/types/cherryStore'
|
||||
|
||||
// 定义 Category 和 SubCategory 的类型 (基于您 store_categories.json 的结构)
|
||||
// 您可能已经在别处定义了这些类型,如果是,请使用它们。
|
||||
export interface SubCategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
count?: number // count 是可选的,因为并非所有二级分类都有
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
title: string
|
||||
items: SubCategoryItem[]
|
||||
}
|
||||
|
||||
// 移除 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 (subcategoryId) {
|
||||
filteredItems = filteredItems.filter((item) => {
|
||||
return item.subcategoryId === subcategoryId
|
||||
})
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
filteredItems = filteredItems.filter((item) => {
|
||||
const searchableText = `${item.title.toLowerCase()} ${item.description?.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
|
||||
}
|
||||
|
||||
// 原始的 loadStoreDataSeparately 现在可以移除或重构
|
||||
// 如果还需要一次性加载所有数据(例如用于初始全局搜索或特定情况),可以保留并调整
|
||||
// 但根据需求,我们现在倾向于按需加载
|
||||
/*
|
||||
export async function loadStoreDataSeparately(): Promise<LoadedStoreDataByType> {
|
||||
const categories = (await readFileSafe<Category[]>(getResourcesPath('/data/store_categories.json'))) || []
|
||||
// 示例: 不再默认加载所有类型的 items
|
||||
// const assistantItems = await readFileSafe<CherryStoreItem[]>(getResourcesPath('/data/store_list_assistant.json'))
|
||||
|
||||
console.log('Store data loaded (categories only by default):', {
|
||||
categoriesCount: categories.length,
|
||||
})
|
||||
|
||||
return {
|
||||
categories,
|
||||
// assistantItems: undefined, // Items 会按需加载
|
||||
// knowledgeItems: undefined,
|
||||
// mcpServerItems: undefined,
|
||||
}
|
||||
}
|
||||
*/
|
||||
@ -1,42 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "all",
|
||||
"title": "Categories",
|
||||
"items": [
|
||||
{ "id": "all", "name": "All", "count": 120, "isActive": true },
|
||||
{ "id": "featured", "name": "Featured", "count": 24 },
|
||||
{ "id": "new", "name": "New Releases", "count": 18 },
|
||||
{ "id": "top", "name": "Top Rated", "count": 32 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mcp",
|
||||
"title": "MCP Services",
|
||||
"items": [
|
||||
{ "id": "mcp-text", "name": "Text Generation", "count": 15 },
|
||||
{ "id": "mcp-image", "name": "Image Generation", "count": 8 },
|
||||
{ "id": "mcp-audio", "name": "Audio Processing", "count": 6 },
|
||||
{ "id": "mcp-code", "name": "Code Assistance", "count": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "plugins",
|
||||
"title": "Plugins",
|
||||
"items": [
|
||||
{ "id": "plugin-productivity", "name": "Productivity", "count": 14 },
|
||||
{ "id": "plugin-development", "name": "Development", "count": 22 },
|
||||
{ "id": "plugin-design", "name": "Design", "count": 9 },
|
||||
{ "id": "plugin-utilities", "name": "Utilities", "count": 18 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "apps",
|
||||
"title": "Applications",
|
||||
"items": [
|
||||
{ "id": "app-desktop", "name": "Desktop", "count": 7 },
|
||||
{ "id": "app-web", "name": "Web", "count": 11 },
|
||||
{ "id": "app-mobile", "name": "Mobile", "count": 5 },
|
||||
{ "id": "app-cli", "name": "CLI", "count": 8 }
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -1,114 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "GPT-4 Turbo",
|
||||
"description": "Advanced language model with improved reasoning capabilities",
|
||||
"type": "MCP Service",
|
||||
"categoryId": "mcp",
|
||||
"subcategoryId": "mcp-text",
|
||||
"author": "OpenAI",
|
||||
"rating": 4.9,
|
||||
"downloads": "1.2M",
|
||||
"image": "/placeholder.svg?height=200&width=200",
|
||||
"tags": ["Text Generation", "Featured"],
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Claude 3 Opus",
|
||||
"description": "High-performance model for complex reasoning and content generation",
|
||||
"type": "MCP Service",
|
||||
"categoryId": "mcp",
|
||||
"subcategoryId": "mcp-text",
|
||||
"author": "Anthropic",
|
||||
"rating": 4.8,
|
||||
"downloads": "850K",
|
||||
"image": "/placeholder.svg?height=200&width=200",
|
||||
"tags": ["Text Generation", "Featured"],
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Midjourney Connect",
|
||||
"description": "Integration plugin for Midjourney image generation",
|
||||
"type": "Plugin",
|
||||
"categoryId": "plugins",
|
||||
"subcategoryId": "plugin-design",
|
||||
"author": "Cherry Studio",
|
||||
"rating": 4.7,
|
||||
"downloads": "620K",
|
||||
"image": "/placeholder.svg?height=200&width=200",
|
||||
"tags": ["Image Generation", "Design"],
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Code Interpreter",
|
||||
"description": "Execute and analyze code within your conversations",
|
||||
"type": "Plugin",
|
||||
"categoryId": "plugins",
|
||||
"subcategoryId": "plugin-development",
|
||||
"author": "Cherry Studio",
|
||||
"rating": 4.9,
|
||||
"downloads": "1.5M",
|
||||
"image": "/placeholder.svg?height=200&width=200",
|
||||
"tags": ["Development", "Code Assistance", "Featured"],
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Voice Assistant",
|
||||
"description": "Add voice interaction capabilities to Cherry Studio",
|
||||
"type": "Application",
|
||||
"categoryId": "apps",
|
||||
"subcategoryId": "app-desktop",
|
||||
"author": "Cherry Audio",
|
||||
"rating": 4.6,
|
||||
"downloads": "780K",
|
||||
"image": "/placeholder.svg?height=200&width=200",
|
||||
"tags": ["Audio Processing", "Desktop"],
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Stable Diffusion XL",
|
||||
"description": "High-quality image generation model",
|
||||
"type": "MCP Service",
|
||||
"categoryId": "mcp",
|
||||
"subcategoryId": "mcp-image",
|
||||
"author": "Stability AI",
|
||||
"rating": 4.8,
|
||||
"downloads": "920K",
|
||||
"image": "/placeholder.svg?height=200&width=200",
|
||||
"tags": ["Image Generation", "Featured"],
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Knowledge Base",
|
||||
"description": "Create and manage custom knowledge bases for your LLMs",
|
||||
"type": "Plugin",
|
||||
"categoryId": "plugins",
|
||||
"subcategoryId": "plugin-utilities",
|
||||
"author": "Cherry Studio",
|
||||
"rating": 4.7,
|
||||
"downloads": "540K",
|
||||
"image": "/placeholder.svg?height=200&width=200",
|
||||
"tags": ["Productivity", "Utilities"],
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Workflow Automator",
|
||||
"description": "Create automated workflows with LLMs and other tools",
|
||||
"type": "Application",
|
||||
"categoryId": "apps",
|
||||
"subcategoryId": "app-desktop",
|
||||
"author": "Cherry Automation",
|
||||
"rating": 4.5,
|
||||
"downloads": "320K",
|
||||
"image": "/placeholder.svg?height=200&width=200",
|
||||
"tags": ["Productivity", "Desktop", "Web"],
|
||||
"featured": false
|
||||
}
|
||||
]
|
||||
55
src/renderer/src/pages/store/hooks/useFilteredStoreItems.ts
Normal file
55
src/renderer/src/pages/store/hooks/useFilteredStoreItems.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { CherryStoreItem } from '@renderer/types/cherryStore'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
// 假设 Item 类型定义,您可以从 store_list.json 的结构推断或在项目中共享
|
||||
// 如果没有明确的类型定义,可以使用 any,但强烈建议定义类型
|
||||
|
||||
export function useFilteredStoreItems(
|
||||
storeList: CherryStoreItem[],
|
||||
searchQuery: string,
|
||||
selectedCategory: string,
|
||||
selectedSubcategory: string
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!storeList) {
|
||||
return []
|
||||
}
|
||||
return storeList.filter((item) => {
|
||||
const lowerSearchQuery = searchQuery.toLowerCase()
|
||||
const matchesSearch =
|
||||
searchQuery === '' ||
|
||||
item.title.toLowerCase().includes(lowerSearchQuery) ||
|
||||
item.description.toLowerCase().includes(lowerSearchQuery) ||
|
||||
item.author.toLowerCase().includes(lowerSearchQuery) ||
|
||||
item.tags.some((tag) => tag.toLowerCase().includes(lowerSearchQuery))
|
||||
|
||||
let matchesCategory = false
|
||||
if (selectedCategory === 'all') {
|
||||
matchesCategory = true
|
||||
} else if (['featured', 'new', 'top'].includes(selectedCategory)) {
|
||||
// 当前的筛选逻辑中 'featured', 'new', 'top' 是特殊处理的
|
||||
// 'new' 和 'top' 还没有具体的实现逻辑,这里保持和原组件一致
|
||||
if (selectedCategory === 'featured') {
|
||||
matchesCategory = item.featured === true
|
||||
} else {
|
||||
// 如果 selectedCategory 是 'new' 或 'top' 但不是 'featured'
|
||||
// 并且 item 没有 .featured = true, 那么 matchesCategory 仍然是 false
|
||||
// 这可能需要根据实际需求调整,例如:
|
||||
// if (selectedCategory === 'new') matchesCategory = item.isNew === true; (假设有 isNew 字段)
|
||||
// if (selectedCategory === 'top') matchesCategory = item.isTop === true; (假设有 isTop 字段)
|
||||
// 为了保持和原逻辑一致,这里暂时不修改这部分行为,但提示您可能需要完善
|
||||
matchesCategory = item.featured === true && ['featured'].includes(selectedCategory) // 或者更复杂的逻辑
|
||||
}
|
||||
} else {
|
||||
matchesCategory = item.categoryId === selectedCategory
|
||||
}
|
||||
|
||||
const matchesSubcategory =
|
||||
['all', 'featured', 'new', 'top'].includes(selectedCategory) || // If a special category is selected, subcategory filter might be bypassed or handled differently
|
||||
selectedSubcategory === 'all' ||
|
||||
item.subcategoryId === selectedSubcategory
|
||||
|
||||
return matchesSearch && matchesCategory && matchesSubcategory
|
||||
})
|
||||
}, [storeList, searchQuery, selectedCategory, selectedSubcategory])
|
||||
}
|
||||
@ -1,58 +1,83 @@
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { CherryStoreItem } from '@renderer/types/cherryStore'
|
||||
import { SidebarProvider } from '@renderer/ui/sidebar'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StoreContent } from './components/StoreContent'
|
||||
import { StoreSidebar } from './components/StoreSidebar'
|
||||
import storeList from './data/store_list.json'
|
||||
|
||||
import { Category, loadAndFilterItems, loadCategories } from './data'
|
||||
export default function StoreLayout() {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('')
|
||||
const [selectedSubcategory, setSelectedSubcategory] = useState<string>('all')
|
||||
const { t } = useTranslation()
|
||||
const { resourcesPath } = useRuntime()
|
||||
|
||||
const filteredItems = storeList.filter((item) => {
|
||||
const matchesSearch =
|
||||
searchQuery === '' ||
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.author.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
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)
|
||||
|
||||
let matchesCategory = false
|
||||
if (selectedCategory === 'all') {
|
||||
matchesCategory = true
|
||||
} else if (['featured', 'new', 'top'].includes(selectedCategory)) {
|
||||
if (selectedCategory === 'featured') {
|
||||
matchesCategory = item.featured === true
|
||||
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)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in StoreLayout fetchCategories:', err)
|
||||
setError('Failed to load store categories. Check console for details.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
} else {
|
||||
matchesCategory = item.categoryId === selectedCategory
|
||||
}
|
||||
fetchCategories()
|
||||
}, [resourcesPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCategory) {
|
||||
setItems([])
|
||||
return
|
||||
}
|
||||
|
||||
const matchesSubcategory =
|
||||
['all', 'featured', 'new', 'top'].includes(selectedCategory) ||
|
||||
selectedSubcategory === 'all' ||
|
||||
item.subcategoryId === selectedSubcategory
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return matchesSearch && matchesCategory && matchesSubcategory
|
||||
})
|
||||
fetchItems()
|
||||
}, [selectedCategory, selectedSubcategory, searchQuery])
|
||||
|
||||
const handleSelectCategory = (categoryId: string, subcategoryId: string) => {
|
||||
console.log('categoryId', categoryId)
|
||||
console.log('subcategoryId', subcategoryId)
|
||||
setSelectedCategory(categoryId)
|
||||
setSelectedSubcategory(subcategoryId)
|
||||
// setSelectedSubcategory('all')
|
||||
}
|
||||
|
||||
// const handleTabCategoryChange = (categoryId: string) => {
|
||||
// setSelectedCategory(categoryId)
|
||||
// setSelectedSubcategory('all')
|
||||
// }
|
||||
if (isLoading) {
|
||||
return <div className="p-4 text-center">Loading store categories...</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-4 text-red-500 text-center">Error: {error}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh_-_var(--navbar-height))] w-full">
|
||||
@ -62,18 +87,23 @@ export default function StoreLayout() {
|
||||
<div id="content-container" className="h-full w-full">
|
||||
<SidebarProvider className="h-full w-full relative min-h-full">
|
||||
<StoreSidebar
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
selectedSubcategory={selectedSubcategory}
|
||||
onSelectCategory={handleSelectCategory}
|
||||
/>
|
||||
<StoreContent
|
||||
viewMode={viewMode}
|
||||
searchQuery={searchQuery}
|
||||
selectedCategory={selectedCategory}
|
||||
items={filteredItems}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
{isLoadingItems ? (
|
||||
<div className="p-4 text-center">Loading items...</div>
|
||||
) : (
|
||||
<StoreContent
|
||||
viewMode={viewMode}
|
||||
searchQuery={searchQuery}
|
||||
selectedCategory={selectedCategory}
|
||||
items={items}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
14
src/renderer/src/types/cherryStore.ts
Normal file
14
src/renderer/src/types/cherryStore.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export interface CherryStoreItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
type: string
|
||||
categoryId: string
|
||||
subcategoryId: string
|
||||
author: string
|
||||
rating: number
|
||||
downloads: string
|
||||
image: string
|
||||
tags: string[]
|
||||
featured?: boolean
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user