refactor: split package manager components

This commit is contained in:
2025-12-27 17:50:50 +00:00
parent 4a12a6f2dd
commit df9193ffe6
5 changed files with 368 additions and 249 deletions

View File

@@ -1,60 +1,44 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui'
import { Input } from '@/components/ui'
import { Badge } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Separator } from '@/components/ui'
import { useState } from 'react'
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, ScrollArea, Separator } from '@/components/ui'
import { toast } from 'sonner'
import { PACKAGE_CATALOG, type PackageCatalogData } from '@/lib/packages/core/package-catalog'
import type { PackageManifest, InstalledPackage } from '@/lib/package-types'
import { installPackage, listInstalledPackages, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
import { Package, Download, Trash, Power, MagnifyingGlass, Star, Tag, User, TrendUp, Funnel, Export, ArrowSquareIn } from '@phosphor-icons/react'
import { installPackage, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
import { ArrowSquareIn, Download, Export, Package, Star, Tag, Trash, User } from '@phosphor-icons/react'
import { PackageImportExport } from './PackageImportExport'
import { PackageFilters } from './package-manager/PackageFilters'
import { PackageTabs } from './package-manager/PackageTabs'
import { usePackages } from './package-manager/usePackages'
interface PackageManagerProps {
onClose?: () => void
}
export function PackageManager({ onClose }: PackageManagerProps) {
const [packages, setPackages] = useState<PackageManifest[]>([])
const [installedPackages, setInstalledPackages] = useState<InstalledPackage[]>([])
const {
filteredPackages,
installedList,
availableList,
installedPackages,
categories,
searchQuery,
categoryFilter,
sortBy,
setSearchQuery,
setCategoryFilter,
setSortBy,
loadPackages,
getCatalogEntry,
} = usePackages()
const [selectedPackage, setSelectedPackage] = useState<PackageCatalogData | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [sortBy, setSortBy] = useState<'name' | 'downloads' | 'rating'>('downloads')
const [showDetails, setShowDetails] = useState(false)
const [installing, setInstalling] = useState(false)
const [showImportExport, setShowImportExport] = useState(false)
const [importExportMode, setImportExportMode] = useState<'import' | 'export'>('export')
useEffect(() => {
loadPackages()
}, [])
const loadPackages = async () => {
const installed = await listInstalledPackages()
setInstalledPackages(installed)
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => {
const packageData = pkg()
return {
...packageData.manifest,
installed: installed.some(ip => ip.packageId === packageData.manifest.id),
}
})
setPackages(allPackages)
}
const handleInstallPackage = async (packageId: string) => {
setInstalling(true)
try {
const packageEntry = PACKAGE_CATALOG[packageId]?.()
const packageEntry = getCatalogEntry(packageId)
if (!packageEntry) {
toast.error('Package not found')
return
@@ -75,7 +59,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
const handleUninstallPackage = async (packageId: string) => {
try {
const packageEntry = PACKAGE_CATALOG[packageId]?.()
const packageEntry = getCatalogEntry(packageId)
if (!packageEntry) {
toast.error('Package not found')
return
@@ -103,28 +87,18 @@ export function PackageManager({ onClose }: PackageManagerProps) {
}
}
const filteredPackages = packages
.filter(pkg => {
const matchesSearch =
pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
pkg.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
pkg.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
const openPackageDetails = (packageId: string) => {
const catalogEntry = getCatalogEntry(packageId)
if (!catalogEntry) return
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
const installedPackage = installedPackages.find(pkg => pkg.packageId === packageId)
return matchesSearch && matchesCategory
setSelectedPackage({
...catalogEntry,
manifest: { ...catalogEntry.manifest, installed: Boolean(installedPackage) },
})
.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name)
if (sortBy === 'downloads') return b.downloadCount - a.downloadCount
if (sortBy === 'rating') return b.rating - a.rating
return 0
})
const categories = ['all', ...Array.from(new Set(packages.map(p => p.category)))]
const installedList = packages.filter(p => p.installed)
const availableList = packages.filter(p => !p.installed)
setShowDetails(true)
}
return (
<div className="flex flex-col h-full">
@@ -139,8 +113,8 @@ export function PackageManager({ onClose }: PackageManagerProps) {
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
<Button
variant="outline"
onClick={() => {
setImportExportMode('import')
setShowImportExport(true)
@@ -149,8 +123,8 @@ export function PackageManager({ onClose }: PackageManagerProps) {
<ArrowSquareIn size={16} className="mr-2" />
Import
</Button>
<Button
variant="outline"
<Button
variant="outline"
onClick={() => {
setImportExportMode('export')
setShowImportExport(true)
@@ -167,127 +141,25 @@ export function PackageManager({ onClose }: PackageManagerProps) {
</div>
</div>
<PackageFilters
searchQuery={searchQuery}
categoryFilter={categoryFilter}
sortBy={sortBy}
categories={categories}
onSearchChange={setSearchQuery}
onCategoryChange={setCategoryFilter}
onSortChange={setSortBy}
/>
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="all" className="h-full flex flex-col">
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="all">All Packages</TabsTrigger>
<TabsTrigger value="installed">
Installed ({installedList.length})
</TabsTrigger>
<TabsTrigger value="available">
Available ({availableList.length})
</TabsTrigger>
</TabsList>
</div>
<div className="px-6 py-4 space-y-3 border-b">
<div className="relative">
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={20} />
<Input
placeholder="Search packages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-3">
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[180px]">
<Funnel size={16} className="mr-2" />
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat} value={cat}>
{cat === 'all' ? 'All Categories' : cat.charAt(0).toUpperCase() + cat.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(v) => setSortBy(v as any)}>
<SelectTrigger className="w-[180px]">
<TrendUp size={16} className="mr-2" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="downloads">Most Downloaded</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
<SelectItem value="name">Name</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<TabsContent value="all" className="flex-1 m-0">
<ScrollArea className="h-full">
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredPackages.map(pkg => (
<PackageCard
key={pkg.id}
package={pkg}
isInstalled={pkg.installed}
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
onViewDetails={() => {
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
setShowDetails(true)
}}
onToggle={handleTogglePackage}
/>
))}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="installed" className="flex-1 m-0">
<ScrollArea className="h-full">
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{installedList.length === 0 ? (
<div className="col-span-full text-center py-12">
<Package size={48} className="mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">No packages installed yet</p>
</div>
) : (
installedList.map(pkg => (
<PackageCard
key={pkg.id}
package={pkg}
isInstalled={true}
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
onViewDetails={() => {
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
setShowDetails(true)
}}
onToggle={handleTogglePackage}
/>
))
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="available" className="flex-1 m-0">
<ScrollArea className="h-full">
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{availableList.map(pkg => (
<PackageCard
key={pkg.id}
package={pkg}
isInstalled={false}
installedPackage={undefined}
onViewDetails={() => {
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
setShowDetails(true)
}}
onToggle={handleTogglePackage}
/>
))}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
<PackageTabs
filteredPackages={filteredPackages}
installedList={installedList}
availableList={availableList}
installedPackages={installedPackages}
onSelectPackage={openPackageDetails}
onTogglePackage={handleTogglePackage}
/>
</div>
<Dialog open={showDetails} onOpenChange={setShowDetails}>
@@ -416,7 +288,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
</DialogContent>
</Dialog>
<PackageImportExport
<PackageImportExport
open={showImportExport}
onOpenChange={(open) => {
setShowImportExport(open)
@@ -429,67 +301,3 @@ export function PackageManager({ onClose }: PackageManagerProps) {
</div>
)
}
interface PackageCardProps {
package: PackageManifest
isInstalled: boolean
installedPackage?: InstalledPackage
onViewDetails: () => void
onToggle: (packageId: string, enabled: boolean) => void
}
function PackageCard({ package: pkg, isInstalled, installedPackage, onViewDetails, onToggle }: PackageCardProps) {
return (
<Card className="flex flex-col hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-2xl flex-shrink-0">
{pkg.icon}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">{pkg.name}</CardTitle>
<CardDescription className="line-clamp-2 mt-1">{pkg.description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1">
<div className="flex items-center gap-2 mb-3">
<Badge variant="secondary">{pkg.category}</Badge>
{isInstalled && (
<Badge variant={installedPackage?.enabled ? 'default' : 'outline'}>
{installedPackage?.enabled ? 'Active' : 'Disabled'}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Download size={14} />
<span>{pkg.downloadCount.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1">
<Star size={14} weight="fill" className="text-yellow-500" />
<span>{pkg.rating}</span>
</div>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={onViewDetails} className="flex-1">
View Details
</Button>
{isInstalled && installedPackage && (
<Button
variant="ghost"
size="icon"
onClick={() => onToggle(pkg.id, !installedPackage.enabled)}
title={installedPackage.enabled ? 'Disable' : 'Enable'}
>
<Power size={18} weight={installedPackage.enabled ? 'fill' : 'regular'} />
</Button>
)}
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,67 @@
import { Badge, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui'
import type { InstalledPackage, PackageManifest } from '@/lib/package-types'
import { Download, Power, Star } from '@phosphor-icons/react'
interface PackageCardProps {
package: PackageManifest
isInstalled: boolean
installedPackage?: InstalledPackage
onViewDetails: () => void
onToggle: (packageId: string, enabled: boolean) => void
}
export function PackageCard({ package: pkg, isInstalled, installedPackage, onViewDetails, onToggle }: PackageCardProps) {
return (
<Card className="flex flex-col hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-2xl flex-shrink-0">
{pkg.icon}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">{pkg.name}</CardTitle>
<CardDescription className="line-clamp-2 mt-1">{pkg.description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1">
<div className="flex items-center gap-2 mb-3">
<Badge variant="secondary">{pkg.category}</Badge>
{isInstalled && (
<Badge variant={installedPackage?.enabled ? 'default' : 'outline'}>
{installedPackage?.enabled ? 'Active' : 'Disabled'}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Download size={14} />
<span>{pkg.downloadCount.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1">
<Star size={14} weight="fill" className="text-yellow-500" />
<span>{pkg.rating}</span>
</div>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={onViewDetails} className="flex-1">
View Details
</Button>
{isInstalled && installedPackage && (
<Button
variant="ghost"
size="icon"
onClick={() => onToggle(pkg.id, !installedPackage.enabled)}
title={installedPackage.enabled ? 'Disable' : 'Enable'}
>
<Power size={18} weight={installedPackage.enabled ? 'fill' : 'regular'} />
</Button>
)}
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,65 @@
import { Funnel, MagnifyingGlass, TrendUp } from '@phosphor-icons/react'
import { Input } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
interface PackageFiltersProps {
searchQuery: string
categoryFilter: string
sortBy: 'name' | 'downloads' | 'rating'
categories: string[]
onSearchChange: (value: string) => void
onCategoryChange: (value: string) => void
onSortChange: (value: 'name' | 'downloads' | 'rating') => void
}
export function PackageFilters({
searchQuery,
categoryFilter,
sortBy,
categories,
onSearchChange,
onCategoryChange,
onSortChange,
}: PackageFiltersProps) {
return (
<div className="px-6 py-4 space-y-3 border-b">
<div className="relative">
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={20} />
<Input
placeholder="Search packages..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-3">
<Select value={categoryFilter} onValueChange={onCategoryChange}>
<SelectTrigger className="w-[180px]">
<Funnel size={16} className="mr-2" />
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat} value={cat}>
{cat === 'all' ? 'All Categories' : cat.charAt(0).toUpperCase() + cat.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value) => onSortChange(value as any)}>
<SelectTrigger className="w-[180px]">
<TrendUp size={16} className="mr-2" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="downloads">Most Downloaded</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
<SelectItem value="name">Name</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import type { InstalledPackage, PackageManifest } from '@/lib/package-types'
import { Package } from '@phosphor-icons/react'
import { PackageCard } from './PackageCard'
interface PackageTabsProps {
filteredPackages: PackageManifest[]
installedList: PackageManifest[]
availableList: PackageManifest[]
installedPackages: InstalledPackage[]
onSelectPackage: (packageId: string) => void
onTogglePackage: (packageId: string, enabled: boolean) => Promise<void>
}
export function PackageTabs({
filteredPackages,
installedList,
availableList,
installedPackages,
onSelectPackage,
onTogglePackage,
}: PackageTabsProps) {
const renderPackageCards = (packages: PackageManifest[], isInstalled: (pkg: PackageManifest) => boolean) => (
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{packages.map(pkg => (
<PackageCard
key={pkg.id}
package={pkg}
isInstalled={isInstalled(pkg)}
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
onViewDetails={() => onSelectPackage(pkg.id)}
onToggle={onTogglePackage}
/>
))}
</div>
)
return (
<Tabs defaultValue="all" className="h-full flex flex-col">
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="all">All Packages</TabsTrigger>
<TabsTrigger value="installed">Installed ({installedList.length})</TabsTrigger>
<TabsTrigger value="available">Available ({availableList.length})</TabsTrigger>
</TabsList>
</div>
<TabsContent value="all" className="flex-1 m-0">
<ScrollArea className="h-full">
{renderPackageCards(filteredPackages, (pkg) => pkg.installed)}
</ScrollArea>
</TabsContent>
<TabsContent value="installed" className="flex-1 m-0">
<ScrollArea className="h-full">
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{installedList.length === 0 ? (
<div className="col-span-full text-center py-12">
<Package size={48} className="mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">No packages installed yet</p>
</div>
) : (
installedList.map(pkg => (
<PackageCard
key={pkg.id}
package={pkg}
isInstalled
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
onViewDetails={() => onSelectPackage(pkg.id)}
onToggle={onTogglePackage}
/>
))
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="available" className="flex-1 m-0">
<ScrollArea className="h-full">
{renderPackageCards(availableList, () => false)}
</ScrollArea>
</TabsContent>
</Tabs>
)
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useMemo, useState } from 'react'
import { PACKAGE_CATALOG, type PackageCatalogData } from '@/lib/packages/core/package-catalog'
import type { InstalledPackage, PackageManifest } from '@/lib/package-types'
import { listInstalledPackages } from '@/lib/api/packages'
export interface UsePackagesResult {
packages: PackageManifest[]
installedPackages: InstalledPackage[]
filteredPackages: PackageManifest[]
installedList: PackageManifest[]
availableList: PackageManifest[]
categories: string[]
searchQuery: string
categoryFilter: string
sortBy: 'name' | 'downloads' | 'rating'
setSearchQuery: (query: string) => void
setCategoryFilter: (category: string) => void
setSortBy: (sort: 'name' | 'downloads' | 'rating') => void
loadPackages: () => Promise<void>
getCatalogEntry: (packageId: string) => PackageCatalogData | null
}
export function usePackages(): UsePackagesResult {
const [packages, setPackages] = useState<PackageManifest[]>([])
const [installedPackages, setInstalledPackages] = useState<InstalledPackage[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [sortBy, setSortBy] = useState<'name' | 'downloads' | 'rating'>('downloads')
useEffect(() => {
loadPackages()
}, [])
const loadPackages = async () => {
const installed = await listInstalledPackages()
setInstalledPackages(installed)
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => {
const packageData = pkg()
return {
...packageData.manifest,
installed: installed.some(ip => ip.packageId === packageData.manifest.id),
}
})
setPackages(allPackages)
}
const filteredPackages = useMemo(() => {
const matchesSearch = (pkg: PackageManifest) =>
pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
pkg.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
pkg.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
const matchesCategory = (pkg: PackageManifest) => categoryFilter === 'all' || pkg.category === categoryFilter
return packages
.filter(pkg => matchesSearch(pkg) && matchesCategory(pkg))
.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name)
if (sortBy === 'downloads') return b.downloadCount - a.downloadCount
if (sortBy === 'rating') return b.rating - a.rating
return 0
})
}, [packages, searchQuery, categoryFilter, sortBy])
const categories = useMemo(
() => ['all', ...Array.from(new Set(packages.map(p => p.category)))],
[packages],
)
const installedList = useMemo(() => packages.filter(p => p.installed), [packages])
const availableList = useMemo(() => packages.filter(p => !p.installed), [packages])
const getCatalogEntry = (packageId: string) => PACKAGE_CATALOG[packageId]?.() ?? null
return {
packages,
installedPackages,
filteredPackages,
installedList,
availableList,
categories,
searchQuery,
categoryFilter,
sortBy,
setSearchQuery,
setCategoryFilter,
setSortBy,
loadPackages,
getCatalogEntry,
}
}