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:
lizhixuan 2025-05-12 22:58:54 +08:00
parent 0a0956cfc4
commit 184713dba8
14 changed files with 534 additions and 264 deletions

View File

@ -11,7 +11,7 @@
"prefix": ""
},
"aliases": {
"components": "@renderer",
"components": "@renderer/ui/third-party",
"utils": "@renderer/utils",
"ui": "@renderer/ui",
"lib": "@renderer/lib",

View File

@ -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",

View File

@ -136,7 +136,7 @@ body[theme-mode='light'] {
*::before,
*::after {
box-sizing: border-box;
margin: 0;
// margin: 0;
font-weight: normal;
}

View File

@ -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;
}
}
}

View File

@ -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 = {

View 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>
)
}

View 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>
)
}

View File

@ -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>

View 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 }

View 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>
)
}

View File

@ -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/*"]
}
}
}

View File

@ -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"