mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
feat: enhance store page with new components and functionality
- Updated component paths in components.json for better organization. - Added 'motion' library to package.json for animations. - Refactored TypeScript configuration to include new renderer paths. - Implemented new StoreContent and StoreSidebar components for improved store layout. - Integrated store categories and items with filtering capabilities. - Enhanced UI with Tailwind CSS animations and styles for a better user experience.
This commit is contained in:
parent
0a0956cfc4
commit
184713dba8
@ -11,7 +11,7 @@
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@renderer",
|
||||
"components": "@renderer/ui/third-party",
|
||||
"utils": "@renderer/utils",
|
||||
"ui": "@renderer/ui",
|
||||
"lib": "@renderer/lib",
|
||||
|
||||
@ -188,6 +188,7 @@
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-react": "^0.509.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.11.0",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"p-queue": "^8.1.0",
|
||||
|
||||
@ -136,7 +136,7 @@ body[theme-mode='light'] {
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
// margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
|
||||
@ -109,6 +109,24 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--animate-marquee: marquee var(--duration) infinite linear;
|
||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
@keyframes marquee-vertical {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@ -118,4 +136,4 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,8 +11,8 @@ import { isEmoji } from '@renderer/utils'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Avatar, Dropdown, Tooltip } from 'antd'
|
||||
import {
|
||||
Box,
|
||||
CircleHelp,
|
||||
Compass,
|
||||
FileSearch,
|
||||
Folder,
|
||||
Languages,
|
||||
@ -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: <Box size={18} className="icon" />
|
||||
store: <Compass size={18} className="icon" />
|
||||
}
|
||||
|
||||
const pathMap = {
|
||||
|
||||
207
src/renderer/src/pages/store/components/StoreContent.tsx
Normal file
207
src/renderer/src/pages/store/components/StoreContent.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
import { Badge } from '@renderer/ui/badge'
|
||||
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'
|
||||
// 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
|
||||
}
|
||||
|
||||
interface StoreContentProps {
|
||||
viewMode: 'grid' | 'list'
|
||||
searchQuery: string
|
||||
selectedCategory: string
|
||||
items: StoreItem[]
|
||||
onSearchQueryChange: (query: string) => void
|
||||
onViewModeChange: (mode: 'grid' | 'list') => void
|
||||
}
|
||||
|
||||
export function StoreContent({
|
||||
viewMode,
|
||||
searchQuery,
|
||||
items,
|
||||
onSearchQueryChange,
|
||||
onViewModeChange
|
||||
}: StoreContentProps) {
|
||||
return (
|
||||
<div className="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 left-2.5 top-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">
|
||||
{/* Add actual filtering logic later */}
|
||||
<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="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} />
|
||||
) : (
|
||||
<ListView items={items} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
57
src/renderer/src/pages/store/components/StoreSidebar.tsx
Normal file
57
src/renderer/src/pages/store/components/StoreSidebar.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { Badge } from '@renderer/ui/badge'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@renderer/ui/sidebar'
|
||||
|
||||
import categories from '../data/store_categories.json'
|
||||
|
||||
interface StoreSidebarProps {
|
||||
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)
|
||||
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 */}
|
||||
{category.id !== 'all' && <SidebarGroupLabel>{category.title}</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{category.items.map((item) => (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
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
|
||||
}}>
|
||||
{item.name}
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{item.count}
|
||||
</Badge>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@ -1,72 +1,11 @@
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { Badge } from '@renderer/ui/badge'
|
||||
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 {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider
|
||||
} from '@renderer/ui/sidebar'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/ui/tabs'
|
||||
import { cn } from '@renderer/utils'
|
||||
import { Download, Filter, Grid3X3, List, Search, Star } from 'lucide-react'
|
||||
import { SidebarProvider } from '@renderer/ui/sidebar'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import categories from '../../../../../resources/data/store_categories.json'
|
||||
import storeList from '../../../../../resources/data/store_list.json'
|
||||
|
||||
// const categories = [
|
||||
// {
|
||||
// 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 }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
import { StoreContent } from './components/StoreContent'
|
||||
import { StoreSidebar } from './components/StoreSidebar'
|
||||
import storeList from './data/store_list.json'
|
||||
|
||||
export default function StoreLayout() {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
@ -74,9 +13,8 @@ export default function StoreLayout() {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
const [selectedSubcategory, setSelectedSubcategory] = useState<string>('all')
|
||||
const { t } = useTranslation()
|
||||
// Update the filteredItems logic to use the new category IDs
|
||||
|
||||
const filteredItems = storeList.filter((item) => {
|
||||
// First apply search filter
|
||||
const matchesSearch =
|
||||
searchQuery === '' ||
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@ -84,17 +22,38 @@ export default function StoreLayout() {
|
||||
item.author.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
|
||||
// Then apply category filters
|
||||
const matchesCategory =
|
||||
selectedCategory === 'all' ||
|
||||
item.categoryId === selectedCategory ||
|
||||
(selectedCategory === 'featured' && item.featured)
|
||||
let matchesCategory = false
|
||||
if (selectedCategory === 'all') {
|
||||
matchesCategory = true
|
||||
} else if (['featured', 'new', 'top'].includes(selectedCategory)) {
|
||||
if (selectedCategory === 'featured') {
|
||||
matchesCategory = item.featured === true
|
||||
}
|
||||
} else {
|
||||
matchesCategory = item.categoryId === selectedCategory
|
||||
}
|
||||
|
||||
const matchesSubcategory = selectedSubcategory === 'all' || item.subcategoryId === selectedSubcategory
|
||||
const matchesSubcategory =
|
||||
['all', 'featured', 'new', 'top'].includes(selectedCategory) ||
|
||||
selectedSubcategory === 'all' ||
|
||||
item.subcategoryId === selectedSubcategory
|
||||
|
||||
return matchesSearch && matchesCategory && matchesSubcategory
|
||||
})
|
||||
|
||||
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')
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh_-_var(--navbar-height))] w-full">
|
||||
<Navbar className="h-full">
|
||||
@ -102,190 +61,19 @@ export default function StoreLayout() {
|
||||
</Navbar>
|
||||
<div id="content-container" className="h-full w-full">
|
||||
<SidebarProvider className="h-full w-full relative min-h-full">
|
||||
<Sidebar className="absolute left-0 top-0 h-full border-r">
|
||||
<SidebarHeader className="border-b px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">Cherry Store</h2>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{categories.map((category) => (
|
||||
<SidebarGroup key={category.title}>
|
||||
<SidebarGroupLabel>{category.title}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{category.items.map((item) => (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={category.id === selectedCategory && item.id === selectedSubcategory}
|
||||
className="justify-between"
|
||||
onClick={() => {
|
||||
setSelectedCategory(category.id)
|
||||
setSelectedSubcategory(item.id)
|
||||
}}>
|
||||
{item.name}
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{item.count}
|
||||
</Badge>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="sticky top-0 z-10 border-b bg-background p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search store..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(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={() => setViewMode('grid')}
|
||||
className={cn(viewMode === 'grid' && 'bg-accent text-accent-foreground')}>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewMode('list')}
|
||||
className={cn(viewMode === 'list' && 'bg-accent text-accent-foreground')}>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue="all"
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCategory(value)
|
||||
setSelectedSubcategory('all')
|
||||
}}>
|
||||
<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>
|
||||
|
||||
<div className="p-4">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
<p className="text-center text-muted-foreground">No items found matching your search.</p>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{filteredItems.map((item) => (
|
||||
<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'}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredItems.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'}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StoreSidebar
|
||||
selectedCategory={selectedCategory}
|
||||
selectedSubcategory={selectedSubcategory}
|
||||
onSelectCategory={handleSelectCategory}
|
||||
/>
|
||||
<StoreContent
|
||||
viewMode={viewMode}
|
||||
searchQuery={searchQuery}
|
||||
selectedCategory={selectedCategory}
|
||||
items={filteredItems}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
66
src/renderer/src/ui/alert.tsx
Normal file
66
src/renderer/src/ui/alert.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@renderer/utils/index"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
73
src/renderer/src/ui/third-party/BlurFade.tsx
vendored
Normal file
73
src/renderer/src/ui/third-party/BlurFade.tsx
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion, MotionProps, useInView, UseInViewOptions, Variants } from 'motion/react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
type MarginType = UseInViewOptions['margin']
|
||||
|
||||
interface BlurFadeProps extends MotionProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
variant?: {
|
||||
hidden: { y: number }
|
||||
visible: { y: number }
|
||||
}
|
||||
duration?: number
|
||||
delay?: number
|
||||
offset?: number
|
||||
direction?: 'up' | 'down' | 'left' | 'right'
|
||||
inView?: boolean
|
||||
inViewMargin?: MarginType
|
||||
blur?: string
|
||||
}
|
||||
|
||||
export function BlurFade({
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
duration = 0.4,
|
||||
delay = 0,
|
||||
offset = 6,
|
||||
direction = 'down',
|
||||
inView = false,
|
||||
inViewMargin = '-50px',
|
||||
blur = '6px',
|
||||
...props
|
||||
}: BlurFadeProps) {
|
||||
const ref = useRef(null)
|
||||
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
|
||||
const isInView = !inView || inViewResult
|
||||
const defaultVariants: Variants = {
|
||||
hidden: {
|
||||
[direction === 'left' || direction === 'right' ? 'x' : 'y']:
|
||||
direction === 'right' || direction === 'down' ? -offset : offset,
|
||||
opacity: 0,
|
||||
filter: `blur(${blur})`
|
||||
},
|
||||
visible: {
|
||||
[direction === 'left' || direction === 'right' ? 'x' : 'y']: 0,
|
||||
opacity: 1,
|
||||
filter: `blur(0px)`
|
||||
}
|
||||
}
|
||||
const combinedVariants = variant || defaultVariants
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
exit="hidden"
|
||||
variants={combinedVariants}
|
||||
transition={{
|
||||
delay: 0.04 + delay,
|
||||
duration,
|
||||
ease: 'easeOut'
|
||||
}}
|
||||
className={className}
|
||||
{...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@ -18,8 +18,8 @@
|
||||
"paths": {
|
||||
"@main/*": ["src/main/*"],
|
||||
"@types": ["src/renderer/src/types/index.ts"],
|
||||
"@components/*": ["packages/components/*"],
|
||||
"@shared/*": ["packages/shared/*"]
|
||||
"@shared/*": ["packages/shared/*"],
|
||||
"@renderer/*": ["src/renderer/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
yarn.lock
60
yarn.lock
@ -5311,6 +5311,7 @@ __metadata:
|
||||
lucide-react: "npm:^0.509.0"
|
||||
markdown-it: "npm:^14.1.0"
|
||||
mime: "npm:^4.0.4"
|
||||
motion: "npm:^12.11.0"
|
||||
next-themes: "npm:^0.4.6"
|
||||
node-stream-zip: "npm:^1.15.0"
|
||||
npx-scope-finder: "npm:^1.2.0"
|
||||
@ -9435,6 +9436,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"framer-motion@npm:^12.11.0":
|
||||
version: 12.11.0
|
||||
resolution: "framer-motion@npm:12.11.0"
|
||||
dependencies:
|
||||
motion-dom: "npm:^12.11.0"
|
||||
motion-utils: "npm:^12.9.4"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
"@emotion/is-prop-valid": "*"
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@emotion/is-prop-valid":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10c0/369026997d12e51ba5ca12ecf507ccf2108941c9e634d0d57ff93524d56d72dc38cd909ce1272a155d15b92faf0ddf9268942908be29032e36cc535a353fd497
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fresh@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "fresh@npm:2.0.0"
|
||||
@ -13409,6 +13432,43 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion-dom@npm:^12.11.0":
|
||||
version: 12.11.0
|
||||
resolution: "motion-dom@npm:12.11.0"
|
||||
dependencies:
|
||||
motion-utils: "npm:^12.9.4"
|
||||
checksum: 10c0/9fd7441c38b28560ea2db0f4dbd6f412873e777f5d32e623792cc8ff32c0bbff761f68102115060af81325227cc639e548f6123bdced50722f55bc2abda76b55
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion-utils@npm:^12.9.4":
|
||||
version: 12.9.4
|
||||
resolution: "motion-utils@npm:12.9.4"
|
||||
checksum: 10c0/b6783babfd1282ad320585f7cdac9fe7a1f97b39e07d12a500d3709534441bd9d49b556fa1cd838d1bde188570d4ab6b4c5aa9d297f7f5aa9dc16d600c17afdc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion@npm:^12.11.0":
|
||||
version: 12.11.0
|
||||
resolution: "motion@npm:12.11.0"
|
||||
dependencies:
|
||||
framer-motion: "npm:^12.11.0"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
"@emotion/is-prop-valid": "*"
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@emotion/is-prop-valid":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10c0/1066de76a5d8a7a6898d8b47afe4363098466c67ed79cb989c0f05c11aa62a2cc83f61cc283ec07b452ae0622f6cd1c4584f1a0a68174798d67ba8811c52ac11
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mri@npm:1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "mri@npm:1.1.4"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user