feat: add multiple package sync sources and API integration

- Added undici dependency and implemented three package fetcher variants (simple text parsing, v2 with better error handling, and official repository sync)
- Integrated real API calls in PlatformSelector and PackageBrowser components with fallback to mock data
- Extended sync API route to support multiple data sources (debian-simple, ubuntu-simple, debian-official-v2, etc.) with platform initialization
This commit is contained in:
Yusuf İpek
2025-11-10 19:12:46 +03:00
parent 596bba0832
commit 08f35aedbc
25 changed files with 2152 additions and 102 deletions
+87 -28
View File
@@ -1,11 +1,11 @@
"use client"
import { useState, useMemo } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { mockPackages, categories, licenses } from '@/data/mockData'
import { apiClient } from '@/lib/api/client'
import { useLocale } from '@/contexts/LocaleContext'
import { Package, FilterOptions, Platform } from '@/types'
import { Search, Package as PackageIcon, Terminal, Monitor } from 'lucide-react'
@@ -24,18 +24,53 @@ export function PackageBrowser({
onFiltersChange
}: PackageBrowserProps) {
const { t } = useLocale()
const [packages, setPackages] = useState<Package[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [filters, setFilters] = useState<FilterOptions>({
platforms: [],
categories: [],
licenses: [],
types: [],
repositories: [],
searchQuery: ''
platform_id: selectedPlatform?.id || '',
type: '',
repository: '',
search: '',
limit: 50,
offset: 0
})
// Load packages when platform changes
useEffect(() => {
const loadPackages = async () => {
if (!selectedPlatform) {
setPackages([])
setLoading(false)
return
}
setLoading(true)
try {
const result = await apiClient.getPackages({
platform_id: selectedPlatform.id,
search: searchQuery,
type: filters.type || undefined,
repository: filters.repository || undefined,
limit: filters.limit,
offset: filters.offset
})
setPackages(result.packages)
} catch (error) {
console.error('Failed to load packages:', error)
// Fallback to mock data if API fails
const { mockPackages } = await import('@/data/mockData')
setPackages(mockPackages.filter(pkg => pkg.platform === selectedPlatform.id))
} finally {
setLoading(false)
}
}
loadPackages()
}, [selectedPlatform, searchQuery, filters.type, filters.repository])
const filteredPackages = useMemo(() => {
return mockPackages.filter(pkg => {
return packages.filter(pkg => {
// Platform filter
if (selectedPlatform && pkg.platform !== selectedPlatform.id) {
return false
@@ -149,28 +184,52 @@ export function PackageBrowser({
<SelectValue placeholder={t('packages.filters.repository')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="official">{t('packages.filters.official')}</SelectItem>
<SelectItem value="third_party">{t('packages.filters.third_party')}</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Package List */}
<Card>
<CardHeader>
<CardTitle>{t('packages.browse')} ({filteredPackages.length})</CardTitle>
<CardDescription>
{t('packages.description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{filteredPackages.map((pkg) => (
<div
key={pkg.id}
className={`p-4 border rounded-lg transition-colors ${
<CardContent className="pt-0">
<div className="text-center py-8 text-muted-foreground">
Loading packages...
</div>
</CardContent>
</Card>
)
}
return (
<Card className="w-full">
<CardHeader className="pb-4">
<CardTitle className="text-lg flex items-center gap-2">
<PackageIcon className="h-5 w-5" />
{t('packages.title')}
{selectedPlatform && (
<span className="text-sm font-normal text-muted-foreground">
({packages.length} packages for {selectedPlatform.name})
</span>
)}
</CardTitle>
<CardDescription className="text-sm">
{t('packages.description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder={t('packages.search')}
className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
isPackageSelected(pkg)
? 'border-primary bg-primary/5'
: 'border-border hover:bg-secondary/50'
+366
View File
@@ -0,0 +1,366 @@
"use client"
import { useState, useEffect, useMemo, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Search, Monitor, Terminal, Package as PackageIcon } from 'lucide-react'
import { Package, FilterOptions, Platform } from '@/types'
import { apiClient } from '@/lib/api/client'
import { useLocale } from '@/contexts/LocaleContext'
interface PackageBrowserProps {
selectedPlatform: Platform | null
selectedPackages: Package[]
onPackageToggle: (pkg: Package) => void
onFiltersChange: (filters: FilterOptions) => void
}
export function PackageBrowserV2({
selectedPlatform,
selectedPackages,
onPackageToggle,
onFiltersChange
}: PackageBrowserProps) {
const { t } = useLocale()
const [packages, setPackages] = useState<Package[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [totalCount, setTotalCount] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [typeFilter, setTypeFilter] = useState<string>('')
const [repositoryFilter, setRepositoryFilter] = useState<string>('')
const [scrollPosition, setScrollPosition] = useState(0)
const searchInputRef = useRef<HTMLInputElement>(null)
// Load packages when platform changes
useEffect(() => {
const loadPackages = async () => {
if (!selectedPlatform) {
setPackages([])
setLoading(false)
return
}
setLoading(true)
setPackages([])
setHasMore(true)
try {
console.log('🔍 Frontend: Fetching initial packages for', selectedPlatform.id)
const params: any = {
platform_id: selectedPlatform.id,
limit: 50,
offset: 0
}
if (searchQuery && searchQuery.trim()) {
params.search = searchQuery.trim()
}
if (typeFilter && typeFilter !== 'all') {
params.type = typeFilter as 'gui' | 'cli'
}
if (repositoryFilter && repositoryFilter !== 'all') {
params.repository = repositoryFilter as 'official' | 'third-party'
}
console.log('🔍 Frontend: API params:', params)
const result = await apiClient.getPackages(params)
console.log('📦 Frontend: Received initial packages:', {
total: result.total,
packageCount: result.packages.length,
firstPackage: result.packages[0]?.name || 'None'
})
setPackages(result.packages)
setTotalCount(result.total)
setHasMore(result.packages.length < result.total)
} catch (error) {
console.error('Failed to load packages:', error)
setPackages([])
} finally {
setLoading(false)
}
}
loadPackages()
}, [selectedPlatform, typeFilter, repositoryFilter])
// Debounced search to prevent focus loss
useEffect(() => {
if (!selectedPlatform) return
const timeoutId = setTimeout(() => {
const loadPackages = async () => {
// DON'T set loading to true - it causes re-render and focus loss
// setLoading(true)
try {
const params: any = {
platform_id: selectedPlatform.id,
limit: 50,
offset: 0
}
if (searchQuery && searchQuery.trim()) {
params.search = searchQuery.trim()
}
if (typeFilter && typeFilter !== 'all') {
params.type = typeFilter as 'gui' | 'cli'
}
if (repositoryFilter && repositoryFilter !== 'all') {
params.repository = repositoryFilter as 'official' | 'third-party'
}
console.log('🔍 Frontend: Debounced API params:', params)
const result = await apiClient.getPackages(params)
console.log('📦 Frontend: Debounced result:', result.packages.length)
// Update packages without triggering loading state
setPackages(result.packages)
setTotalCount(result.total)
setHasMore(result.packages.length < result.total)
} catch (error) {
console.error('Failed to load packages:', error)
// Don't clear packages on error during search
}
}
loadPackages()
}, 300) // 300ms debounce
return () => clearTimeout(timeoutId)
}, [searchQuery])
// Load more packages
const loadMore = async () => {
if (!selectedPlatform || loadingMore || !hasMore) return
setLoadingMore(true)
try {
const params: any = {
platform_id: selectedPlatform.id,
limit: 50,
offset: packages.length
}
if (searchQuery && searchQuery.trim()) {
params.search = searchQuery.trim()
}
if (typeFilter && typeFilter !== 'all') {
params.type = typeFilter as 'gui' | 'cli'
}
if (repositoryFilter && repositoryFilter !== 'all') {
params.repository = repositoryFilter as 'official' | 'third-party'
}
const result = await apiClient.getPackages(params)
setPackages(prev => [...prev, ...result.packages])
setHasMore(packages.length + result.packages.length < result.total)
} catch (error) {
console.error('Failed to load more packages:', error)
} finally {
setLoadingMore(false)
}
}
const isPackageSelected = (pkg: Package) => {
return selectedPackages.some(selected => selected.id === pkg.id)
}
const getPackageIcon = (type: string) => {
return type === 'gui' ? <Monitor className="h-4 w-4" /> : <Terminal className="h-4 w-4" />
}
if (loading) {
return (
<Card className="w-full">
<CardHeader className="pb-4">
<CardTitle className="text-lg flex items-center gap-2">
<PackageIcon className="h-5 w-5" />
{t('packages.title')}
</CardTitle>
<CardDescription className="text-sm">
{t('packages.description')}
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="text-center py-8 text-muted-foreground">
Loading packages...
</div>
</CardContent>
</Card>
)
}
if (!selectedPlatform) {
return (
<Card className="w-full">
<CardHeader className="pb-4">
<CardTitle className="text-lg flex items-center gap-2">
<PackageIcon className="h-5 w-5" />
{t('packages.title')}
</CardTitle>
<CardDescription className="text-sm">
{t('packages.description')}
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="text-center py-8 text-muted-foreground">
Please select a platform first
</div>
</CardContent>
</Card>
)
}
return (
<Card className="w-full">
<CardHeader className="pb-4">
<CardTitle className="text-lg flex items-center gap-2">
<PackageIcon className="h-5 w-5" />
{t('packages.title')}
<span className="text-sm font-normal text-muted-foreground">
({packages.length} of {totalCount} packages for {selectedPlatform?.name || 'Unknown Platform'})
</span>
</CardTitle>
<CardDescription className="text-sm">
{t('packages.description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<input
ref={searchInputRef}
type="text"
placeholder={t('packages.search')}
className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => searchInputRef.current?.focus()}
/>
</div>
{/* Type Filter */}
<Select value={typeFilter || "all"} onValueChange={(value) => setTypeFilter(value === "all" ? "" : value)}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="gui">GUI</SelectItem>
<SelectItem value="cli">CLI</SelectItem>
</SelectContent>
</Select>
{/* Repository Filter */}
<Select value={repositoryFilter || "all"} onValueChange={(value) => setRepositoryFilter(value === "all" ? "" : value)}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Repository" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Repositories</SelectItem>
<SelectItem value="official">Official</SelectItem>
<SelectItem value="third-party">Third Party</SelectItem>
</SelectContent>
</Select>
</div>
{/* Package List */}
<div className="space-y-2 max-h-96 overflow-y-auto min-h-[400px]">
{loading && packages.length === 0 ? (
// Skeleton loading to prevent layout shift
Array.from({ length: 10 }).map((_, index) => (
<div key={`skeleton-${index}`} className="flex items-center space-x-3 p-3 border border-border rounded-lg">
<div className="w-4 h-4 bg-muted animate-pulse rounded"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted animate-pulse rounded w-1/3"></div>
<div className="h-3 bg-muted animate-pulse rounded w-2/3"></div>
</div>
</div>
))
) : packages.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('packages.no_packages')}
</div>
) : (
packages.map((pkg) => (
<div
key={pkg.id}
className={`flex items-start space-x-3 p-3 rounded-lg border transition-colors cursor-pointer ${
isPackageSelected(pkg)
? 'border-primary bg-primary/5'
: 'border-border hover:bg-secondary/50'
}`}
onClick={() => onPackageToggle(pkg)}
>
<Checkbox
checked={isPackageSelected(pkg)}
onCheckedChange={() => onPackageToggle(pkg)}
/>
<div className="flex items-start space-x-2 flex-1">
<div className="mt-0.5">
{getPackageIcon(pkg.type || 'cli')}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium truncate">{pkg.name}</h4>
<span className="text-xs text-muted-foreground bg-secondary px-2 py-1 rounded">
{pkg.version}
</span>
</div>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{pkg.description || 'No description available'}
</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">
{pkg.type?.toUpperCase() || 'CLI'}
</span>
<span className="text-xs text-muted-foreground">
{pkg.repository || 'official'}
</span>
</div>
</div>
</div>
</div>
))
)}
{/* Load More Button */}
{hasMore && (
<div className="text-center py-4">
<Button
variant="outline"
onClick={loadMore}
disabled={loadingMore}
className="min-w-32"
>
{loadingMore ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary mr-2"></div>
Loading...
</>
) : (
`Load More (${Math.min(50, totalCount - packages.length)} remaining)`
)}
</Button>
</div>
)}
</div>
</div>
</CardContent>
</Card>
)
}
+40 -2
View File
@@ -1,9 +1,9 @@
"use client"
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { platforms } from '@/data/mockData'
import { apiClient } from '@/lib/api/client'
import { useLocale } from '@/contexts/LocaleContext'
import { Platform } from '@/types'
@@ -14,6 +14,44 @@ interface PlatformSelectorProps {
export function PlatformSelector({ selectedPlatform, onPlatformSelect }: PlatformSelectorProps) {
const { t } = useLocale()
const [platforms, setPlatforms] = useState<Platform[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadPlatforms = async () => {
try {
const platformsData = await apiClient.getPlatforms()
setPlatforms(platformsData)
} catch (error) {
console.error('Failed to load platforms:', error)
// Fallback to mock data if API fails
const { platforms: mockPlatforms } = await import('@/data/mockData')
setPlatforms(mockPlatforms)
} finally {
setLoading(false)
}
}
loadPlatforms()
}, [])
if (loading) {
return (
<Card className="w-full">
<CardHeader className="pb-4">
<CardTitle className="text-lg">{t('platform.select')}</CardTitle>
<CardDescription className="text-sm">
{t('platform.description')}
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="text-center py-8 text-muted-foreground">
Loading platforms...
</div>
</CardContent>
</Card>
)
}
return (
<Card className="w-full">
+5 -5
View File
@@ -2,10 +2,10 @@
import { useState } from 'react'
import { LocaleProvider } from '@/contexts/LocaleContext'
import { Header } from '@/components/Header'
import { PlatformSelector } from '@/components/PlatformSelector'
import { PackageBrowser } from '@/components/PackageBrowser'
import { SelectionManager } from '@/components/SelectionManager'
import { Header } from './Header'
import { PlatformSelector } from './PlatformSelector'
import { PackageBrowserV2 } from './PackageBrowserV2'
import { SelectionManager } from './SelectionManager'
import { ScriptPreview } from '@/components/ScriptPreview'
import { generateScript } from '@/lib/scriptGenerator'
import { useLocale } from '@/contexts/LocaleContext'
@@ -86,7 +86,7 @@ function RepoHubAppContent() {
/>
{/* Package Browser */}
<PackageBrowser
<PackageBrowserV2
selectedPlatform={selectedPlatform}
selectedPackages={selectedPackages}
onPackageToggle={handlePackageToggle}
+9 -5
View File
@@ -40,7 +40,7 @@ export function ScriptPreview({
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `install-packages-${selectedPlatform.id}.sh`
a.download = `install-packages-${selectedPlatform?.id || 'unknown'}.sh`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
@@ -48,18 +48,22 @@ export function ScriptPreview({
}
const getScriptLanguage = () => {
switch (selectedPlatform.id) {
switch (selectedPlatform?.id) {
case 'windows':
return 'powershell'
case 'macos':
return 'bash'
default:
return 'bash'
}
}
const getScriptExtension = () => {
switch (selectedPlatform.id) {
switch (selectedPlatform?.id) {
case 'windows':
return '.ps1'
case 'macos':
return '.sh'
default:
return '.sh'
}
@@ -76,7 +80,7 @@ export function ScriptPreview({
<span>Installation Script</span>
</CardTitle>
<CardDescription>
Idempotent script for {selectedPlatform.name} using {selectedPlatform.packageManager}
Idempotent script for {selectedPlatform?.name || 'Unknown Platform'} using {selectedPlatform?.packageManager || 'Unknown Package Manager'}
</CardDescription>
</div>
<Button variant="outline" onClick={onClose}>
@@ -162,7 +166,7 @@ export function ScriptPreview({
<h4 className="font-medium text-blue-900 mb-2">How to use:</h4>
<ol className="text-sm text-blue-800 space-y-1 list-decimal list-inside">
<li>Download the script file to your target machine</li>
<li>Make it executable (for Linux/macOS): <code className="bg-blue-100 px-1 rounded">chmod +x install-packages-{selectedPlatform.id}{getScriptExtension()}</code></li>
<li>Make it executable (for Linux/macOS): <code className="bg-blue-100 px-1 rounded">chmod +x install-packages-{selectedPlatform?.id || 'unknown'}{getScriptExtension()}</code></li>
<li>Run the script with appropriate permissions</li>
<li>The script will automatically handle repository setup and package installation</li>
</ol>
+2 -4
View File
@@ -73,11 +73,9 @@ export function SelectionManager({
{pkg.description}
</p>
<div className="flex items-center space-x-2 text-xs text-muted-foreground mt-1">
<span>{pkg.platform}</span>
<span>{pkg.type?.toUpperCase() || 'CLI'}</span>
<span></span>
<span>{pkg.category}</span>
<span></span>
<span>{pkg.license}</span>
<span>{pkg.repository || 'official'}</span>
</div>
</div>
<Button