From 08f35aedbca514caa313573a616a716d1ee9568c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Mon, 10 Nov 2025 19:12:46 +0300 Subject: [PATCH] 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 --- fix-ubuntu-name.sql | 7 + package.json | 3 +- pnpm-lock.yaml | 9 + src/app/api/debug-packages/route.ts | 74 ++++++ src/app/api/init-platforms/route.ts | 18 ++ src/app/api/sync-progress/route.ts | 93 +++++++ src/app/api/sync-status/route.ts | 40 +++ src/app/api/sync/route.ts | 80 +++++- src/app/api/test-db/route.ts | 46 ++++ src/app/api/test-sync/route.ts | 84 ++++++ src/components/PackageBrowser.tsx | 115 +++++++-- src/components/PackageBrowserV2.tsx | 366 +++++++++++++++++++++++++++ src/components/PlatformSelector.tsx | 42 ++- src/components/RepoHubApp.tsx | 10 +- src/components/ScriptPreview.tsx | 14 +- src/components/SelectionManager.tsx | 6 +- src/contexts/LocaleContext.tsx | 2 + src/lib/api/client.ts | 90 +++++++ src/lib/scriptGenerator.ts | 9 +- src/services/debianPackageFetcher.ts | 295 +++++++++++++++++++++ src/services/packageFetcherV2.ts | 341 +++++++++++++++++++++++++ src/services/packageService.ts | 58 ++--- src/services/platformInitializer.ts | 66 +++++ src/services/simplePackageFetcher.ts | 358 ++++++++++++++++++++++++++ src/types/index.ts | 28 +- 25 files changed, 2152 insertions(+), 102 deletions(-) create mode 100644 fix-ubuntu-name.sql create mode 100644 src/app/api/debug-packages/route.ts create mode 100644 src/app/api/init-platforms/route.ts create mode 100644 src/app/api/sync-progress/route.ts create mode 100644 src/app/api/sync-status/route.ts create mode 100644 src/app/api/test-db/route.ts create mode 100644 src/app/api/test-sync/route.ts create mode 100644 src/components/PackageBrowserV2.tsx create mode 100644 src/lib/api/client.ts create mode 100644 src/services/debianPackageFetcher.ts create mode 100644 src/services/packageFetcherV2.ts create mode 100644 src/services/platformInitializer.ts create mode 100644 src/services/simplePackageFetcher.ts diff --git a/fix-ubuntu-name.sql b/fix-ubuntu-name.sql new file mode 100644 index 0000000..e8602fe --- /dev/null +++ b/fix-ubuntu-name.sql @@ -0,0 +1,7 @@ +-- Fix Ubuntu platform name +UPDATE platforms +SET name = 'Ubuntu' +WHERE id = 'ubuntu' AND name = 'Ubuntu/Debian'; + +-- Verify the change +SELECT id, name, package_manager FROM platforms WHERE id IN ('ubuntu', 'debian'); diff --git a/package.json b/package.json index a0c1335..8aeb170 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "tailwindcss-animate": "^1.0.7", "react-query": "^3.39.3", "zustand": "^4.4.7", - "pg": "^8.11.3" + "pg": "^8.11.3", + "undici": "^6.6.2" }, "devDependencies": { "typescript": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0190825..41dd768 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.18) + undici: + specifier: ^6.6.2 + version: 6.22.0 zustand: specifier: ^4.4.7 version: 4.5.7(@types/react@18.3.26)(react@18.3.1) @@ -2258,6 +2261,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@6.22.0: + resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} + engines: {node: '>=18.17'} + unload@2.2.0: resolution: {integrity: sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==} @@ -4713,6 +4720,8 @@ snapshots: undici-types@6.21.0: {} + undici@6.22.0: {} + unload@2.2.0: dependencies: '@babel/runtime': 7.28.4 diff --git a/src/app/api/debug-packages/route.ts b/src/app/api/debug-packages/route.ts new file mode 100644 index 0000000..28242f2 --- /dev/null +++ b/src/app/api/debug-packages/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PackageService } from '@/services/packageService' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const platform_id = searchParams.get('platform_id') || 'debian' + + console.log('πŸ› Debug: Fetching packages for platform:', platform_id) + + // Test 1: Simple query without filters + const result1 = await PackageService.getMany({ + platform_id, + limit: 10 + }) + + console.log('πŸ› Debug: Simple query result:', { + total: result1.total, + packageCount: result1.packages.length, + firstPackage: result1.packages[0]?.name || 'None' + }) + + // Test 2: Query with search + const result2 = await PackageService.getMany({ + platform_id, + search: 'firefox', + limit: 10 + }) + + console.log('πŸ› Debug: Search query result:', { + total: result2.total, + packageCount: result2.packages.length, + firstPackage: result2.packages[0]?.name || 'None' + }) + + // Test 3: Raw SQL query + const rawQuery = ` + SELECT COUNT(*) as total, + MIN(p.name) as first_package + FROM packages p + WHERE p.platform_id = $1 AND p.is_active = true + ` + + const rawResult = await query(rawQuery, [platform_id]) + console.log('πŸ› Debug: Raw SQL result:', rawResult.rows[0]) + + return NextResponse.json({ + simpleQuery: { + total: result1.total, + packages: result1.packages.slice(0, 3).map(p => ({ + id: p.id, + name: p.name, + version: p.version + })) + }, + searchQuery: { + total: result2.total, + packages: result2.packages.slice(0, 3).map(p => ({ + id: p.id, + name: p.name, + version: p.version + })) + }, + rawSql: rawResult.rows[0] + }) + + } catch (error) { + console.error('πŸ› Debug: Error:', error) + return NextResponse.json({ + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined + }, { status: 500 }) + } +} diff --git a/src/app/api/init-platforms/route.ts b/src/app/api/init-platforms/route.ts new file mode 100644 index 0000000..0bc1ca7 --- /dev/null +++ b/src/app/api/init-platforms/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server' +import { PlatformInitializer } from '@/services/platformInitializer' + +export async function POST() { + try { + await PlatformInitializer.initializePlatforms() + return NextResponse.json({ + success: true, + message: 'Platforms initialized successfully' + }) + } catch (error) { + console.error('Error initializing platforms:', error) + return NextResponse.json( + { error: 'Failed to initialize platforms' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/sync-progress/route.ts b/src/app/api/sync-progress/route.ts new file mode 100644 index 0000000..a44f20e --- /dev/null +++ b/src/app/api/sync-progress/route.ts @@ -0,0 +1,93 @@ +import { NextRequest } from 'next/server' +import { SimplePackageFetcher } from '@/services/simplePackageFetcher' +import { PlatformInitializer } from '@/services/platformInitializer' +import { setSyncInProgress, setSyncProgress } from '@/app/api/sync-status/route' + +export async function GET(request: NextRequest) { + const encoder = new TextEncoder() + let cancelled = false + + // Set sync as in progress + setSyncInProgress(true) + + // Create a readable stream for SSE + const stream = new ReadableStream({ + async start(controller) { + try { + // Helper function to send progress updates + const sendProgress = (message: string, progress?: number, total?: number) => { + if (cancelled) return + + const data = JSON.stringify({ + message, + progress, + total, + timestamp: new Date().toISOString() + }) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + setSyncProgress(message, progress, total) + } + + sendProgress('πŸ”„ Starting package synchronization...') + + // Initialize platforms + sendProgress('πŸ“‹ Initializing platforms...') + await PlatformInitializer.initializePlatforms() + sendProgress('βœ… Platforms initialized') + + // Fetch Debian packages + sendProgress('🐧 Fetching Debian packages...') + await SimplePackageFetcher.fetchDebianPackagesWithProgress(sendProgress) + + if (cancelled) { + sendProgress('❌ Sync cancelled during Debian processing') + controller.close() + return + } + + sendProgress('βœ… Debian packages completed') + + // Fetch Ubuntu packages + sendProgress('🐧 Fetching Ubuntu packages...') + await SimplePackageFetcher.fetchUbuntuPackagesWithProgress(sendProgress) + + if (cancelled) { + sendProgress('❌ Sync cancelled during Ubuntu processing') + controller.close() + return + } + + sendProgress('βœ… Ubuntu packages completed') + + // Final success message + sendProgress('πŸŽ‰ All packages synchronized successfully!') + + // Close the stream + controller.close() + setSyncInProgress(false) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorData = JSON.stringify({ + error: errorMessage, + timestamp: new Date().toISOString() + }) + controller.enqueue(encoder.encode(`data: ${errorData}\n\n`)) + controller.close() + setSyncInProgress(false) + } + }, + + cancel() { + cancelled = true + setSyncInProgress(false) + } + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }) +} diff --git a/src/app/api/sync-status/route.ts b/src/app/api/sync-status/route.ts new file mode 100644 index 0000000..905a087 --- /dev/null +++ b/src/app/api/sync-status/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' + +// Global sync state (in production, use Redis or database) +let syncInProgress = false +let syncProgress = { message: '', progress: 0, total: 100 } + +export async function GET() { + return NextResponse.json({ + inProgress: syncInProgress, + progress: syncProgress + }) +} + +export async function POST(request: NextRequest) { + const body = await request.json() + const { action } = body + + if (action === 'cancel') { + syncInProgress = false + syncProgress = { message: 'Sync cancelled', progress: 0, total: 100 } + return NextResponse.json({ message: 'Sync cancelled successfully' }) + } + + if (action === 'start') { + syncInProgress = true + syncProgress = { message: 'Starting sync...', progress: 0, total: 100 } + return NextResponse.json({ message: 'Sync started' }) + } + + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) +} + +// Export functions for other endpoints to use +export function setSyncProgress(message: string, progress?: number, total?: number) { + syncProgress = { message, progress: progress || 0, total: total || 100 } +} + +export function setSyncInProgress(inProgress: boolean) { + syncInProgress = inProgress +} diff --git a/src/app/api/sync/route.ts b/src/app/api/sync/route.ts index 0ad0c61..f5cb855 100644 --- a/src/app/api/sync/route.ts +++ b/src/app/api/sync/route.ts @@ -1,20 +1,90 @@ import { NextRequest, NextResponse } from 'next/server' import { MetadataFetcher } from '@/services/metadataFetcher' +import { DebianPackageFetcher } from '@/services/debianPackageFetcher' +import { PackageFetcherV2 } from '@/services/packageFetcherV2' +import { SimplePackageFetcher } from '@/services/simplePackageFetcher' +import { PlatformInitializer } from '@/services/platformInitializer' export async function POST(request: NextRequest) { try { const body = await request.json() - const { platform_id, all_platforms } = body + const { platform_id, all_platforms, source } = body - if (all_platforms) { - // Sync all platforms + // Initialize platforms first + await PlatformInitializer.initializePlatforms() + + if (source === 'debian-simple') { + // Sync Debian packages (simple text parsing, no gzip) + await SimplePackageFetcher.fetchDebianPackages() + return NextResponse.json({ + message: 'Debian packages synced (simple text parsing)', + timestamp: new Date().toISOString() + }) + } else if (source === 'ubuntu-simple') { + // Sync Ubuntu packages (simple text parsing, no gzip) + await SimplePackageFetcher.fetchUbuntuPackages() + return NextResponse.json({ + message: 'Ubuntu packages synced (simple text parsing)', + timestamp: new Date().toISOString() + }) + } else if (source === 'all-simple') { + // Sync all Debian-based packages (simple text parsing) + await SimplePackageFetcher.syncAll() + return NextResponse.json({ + message: 'All Debian-based packages synced (simple text parsing)', + timestamp: new Date().toISOString() + }) + } else if (source === 'debian-official-v2') { + // Sync Debian packages from official repository (v2 with better error handling) + await PackageFetcherV2.fetchDebianPackages() + return NextResponse.json({ + message: 'Debian packages synced from official repository (v2)', + timestamp: new Date().toISOString() + }) + } else if (source === 'ubuntu-official-v2') { + // Sync Ubuntu packages from official repository (v2 with better error handling) + await PackageFetcherV2.fetchUbuntuPackages() + return NextResponse.json({ + message: 'Ubuntu packages synced from official repository (v2)', + timestamp: new Date().toISOString() + }) + } else if (source === 'all-official-v2') { + // Sync all Debian-based packages from official repositories (v2) + await PackageFetcherV2.syncAll() + return NextResponse.json({ + message: 'All Debian-based packages synced from official repositories (v2)', + timestamp: new Date().toISOString() + }) + } else if (source === 'debian-official') { + // Sync Debian packages from official repository + await DebianPackageFetcher.fetchDebianPackages() + return NextResponse.json({ + message: 'Debian packages synced from official repository', + timestamp: new Date().toISOString() + }) + } else if (source === 'ubuntu-official') { + // Sync Ubuntu packages from official repository + await DebianPackageFetcher.fetchUbuntuPackages() + return NextResponse.json({ + message: 'Ubuntu packages synced from official repository', + timestamp: new Date().toISOString() + }) + } else if (source === 'all-official') { + // Sync all Debian-based packages from official repositories + await DebianPackageFetcher.syncAll() + return NextResponse.json({ + message: 'All Debian-based packages synced from official repositories', + timestamp: new Date().toISOString() + }) + } else if (all_platforms) { + // Sync all platforms (legacy method) await MetadataFetcher.syncAllPlatforms() return NextResponse.json({ message: 'All platforms synced successfully', timestamp: new Date().toISOString() }) } else if (platform_id) { - // Sync specific platform + // Sync specific platform (legacy method) await MetadataFetcher.syncPlatform(platform_id) return NextResponse.json({ message: `Platform ${platform_id} synced successfully`, @@ -22,7 +92,7 @@ export async function POST(request: NextRequest) { }) } else { return NextResponse.json( - { error: 'Either platform_id or all_platforms must be specified' }, + { error: 'Either platform_id, all_platforms, or source must be specified' }, { status: 400 } ) } diff --git a/src/app/api/test-db/route.ts b/src/app/api/test-db/route.ts new file mode 100644 index 0000000..ce5572d --- /dev/null +++ b/src/app/api/test-db/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server' +import { query } from '@/lib/database/config' + +export async function GET() { + try { + // Test 1: Database connection + const dbTest = await query('SELECT NOW() as current_time', []) + + // Test 2: Packages table count + const packageCount = await query('SELECT COUNT(*) as count FROM packages', []) + + // Test 3: Debian packages count + const debianCount = await query('SELECT COUNT(*) as count FROM packages WHERE platform_id = $1', ['debian']) + + // Test 4: Active packages count + const activeCount = await query('SELECT COUNT(*) as count FROM packages WHERE is_active = true', []) + + // Test 5: Sample packages + const samplePackages = await query(` + SELECT id, name, version, platform_id + FROM packages + WHERE platform_id = $1 AND is_active = true + LIMIT 5 + `, ['debian']) + + return NextResponse.json({ + database: { + connected: true, + currentTime: dbTest.rows[0].current_time + }, + packages: { + total: packageCount.rows[0].count, + debian: debianCount.rows[0].count, + active: activeCount.rows[0].count, + sample: samplePackages.rows + } + }) + + } catch (error) { + console.error('Database test error:', error) + return NextResponse.json({ + error: error instanceof Error ? error.message : 'Unknown error', + database: { connected: false } + }, { status: 500 }) + } +} diff --git a/src/app/api/test-sync/route.ts b/src/app/api/test-sync/route.ts new file mode 100644 index 0000000..6888554 --- /dev/null +++ b/src/app/api/test-sync/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PackageFetcherV2 } from '@/services/packageFetcherV2' +import { PlatformInitializer } from '@/services/platformInitializer' + +export async function GET() { + try { + console.log('πŸ§ͺ Starting test sync...') + + // Test 1: Initialize platforms + await PlatformInitializer.initializePlatforms() + console.log('βœ… Platforms initialized') + + // Test 2: Try to fetch a small sample + console.log('πŸ”„ Testing Debian package fetch...') + + // Just test the fetch and parse, don't store in DB + const response = await fetch('https://packages.debian.org/stable/allpackages?format=txt.gz', { + headers: { + 'Accept-Encoding': 'gzip, deflate', + 'User-Agent': 'RepoHub-Package-Fetcher/1.0' + } + }) + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`) + } + + console.log('βœ… Response received, size:', response.headers.get('content-length')) + + const buffer = await response.arrayBuffer() + console.log('βœ… Buffer received, size:', buffer.byteLength) + + // Check magic bytes + const bytes = new Uint8Array(buffer.slice(0, 2)) + console.log('πŸ” Magic bytes:', bytes[0].toString(16), bytes[1].toString(16)) + + // Try decompression + const { gunzip } = await import('zlib') + const decompressed = await new Promise((resolve, reject) => { + gunzip(new Uint8Array(buffer), (err, result) => { + if (err) { + console.error('❌ Gunzip error:', err) + reject(err) + } else { + console.log('βœ… Gunzip successful, size:', result.length) + resolve(result) + } + }) + }) + + const text = decompressed.toString('utf-8') + console.log('βœ… Text decoded, length:', text.length) + + // Parse first 10 lines + const lines = text.split('\n').slice(0, 20) + console.log('πŸ“‹ First 20 lines:') + lines.forEach((line, i) => console.log(`${i + 1}: ${line}`)) + + // Test parsing + const samplePackages = PackageFetcherV2['parseDebianPackageList'](text) + console.log('πŸ“Š Sample packages parsed:', samplePackages.length) + + if (samplePackages.length > 0) { + console.log('πŸ“¦ First package:', samplePackages[0]) + } + + return NextResponse.json({ + success: true, + message: 'Test completed successfully', + bufferSize: buffer.byteLength, + textSize: text.length, + samplePackages: samplePackages.length, + firstPackage: samplePackages[0] || null + }) + + } catch (error) { + console.error('❌ Test failed:', error) + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined + }, { status: 500 }) + } +} diff --git a/src/components/PackageBrowser.tsx b/src/components/PackageBrowser.tsx index ede2fcf..d82d970 100644 --- a/src/components/PackageBrowser.tsx +++ b/src/components/PackageBrowser.tsx @@ -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([]) + const [loading, setLoading] = useState(true) const [searchQuery, setSearchQuery] = useState('') const [filters, setFilters] = useState({ - 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({ - {t('packages.filters.official')} - {t('packages.filters.third_party')} - - - - - - - {/* Package List */} - - - {t('packages.browse')} ({filteredPackages.length}) - {t('packages.description')} - -
- {filteredPackages.map((pkg) => ( -
+
+ Loading packages... +
+ + + ) + } + + return ( + + + + + {t('packages.title')} + {selectedPlatform && ( + + ({packages.length} packages for {selectedPlatform.name}) + + )} + + + {t('packages.description')} + + + +
+ {/* Filters */} + + + Filters + + +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} isPackageSelected(pkg) ? 'border-primary bg-primary/5' : 'border-border hover:bg-secondary/50' diff --git a/src/components/PackageBrowserV2.tsx b/src/components/PackageBrowserV2.tsx new file mode 100644 index 0000000..d9f1a23 --- /dev/null +++ b/src/components/PackageBrowserV2.tsx @@ -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([]) + 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('') + const [repositoryFilter, setRepositoryFilter] = useState('') + const [scrollPosition, setScrollPosition] = useState(0) + const searchInputRef = useRef(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' ? : + } + + if (loading) { + return ( + + + + + {t('packages.title')} + + + {t('packages.description')} + + + +
+ Loading packages... +
+
+
+ ) + } + + if (!selectedPlatform) { + return ( + + + + + {t('packages.title')} + + + {t('packages.description')} + + + +
+ Please select a platform first +
+
+
+ ) + } + + return ( + + + + + {t('packages.title')} + + ({packages.length} of {totalCount} packages for {selectedPlatform?.name || 'Unknown Platform'}) + + + + {t('packages.description')} + + + +
+ {/* Filters */} +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + onFocus={() => searchInputRef.current?.focus()} + /> +
+ + {/* Type Filter */} + + + {/* Repository Filter */} + +
+ + {/* Package List */} +
+ {loading && packages.length === 0 ? ( + // Skeleton loading to prevent layout shift + Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+
+
+
+ )) + ) : packages.length === 0 ? ( +
+ {t('packages.no_packages')} +
+ ) : ( + packages.map((pkg) => ( +
onPackageToggle(pkg)} + > + onPackageToggle(pkg)} + /> +
+
+ {getPackageIcon(pkg.type || 'cli')} +
+
+
+

{pkg.name}

+ + {pkg.version} + +
+

+ {pkg.description || 'No description available'} +

+
+ + {pkg.type?.toUpperCase() || 'CLI'} + + + {pkg.repository || 'official'} + +
+
+
+
+ )) + )} + + {/* Load More Button */} + {hasMore && ( +
+ +
+ )} +
+
+
+
+ ) +} diff --git a/src/components/PlatformSelector.tsx b/src/components/PlatformSelector.tsx index 087db6e..9161450 100644 --- a/src/components/PlatformSelector.tsx +++ b/src/components/PlatformSelector.tsx @@ -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([]) + 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 ( + + + {t('platform.select')} + + {t('platform.description')} + + + +
+ Loading platforms... +
+
+
+ ) + } return ( diff --git a/src/components/RepoHubApp.tsx b/src/components/RepoHubApp.tsx index bbc1284..2434a84 100644 --- a/src/components/RepoHubApp.tsx +++ b/src/components/RepoHubApp.tsx @@ -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 */} - { - 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({ Installation Script - Idempotent script for {selectedPlatform.name} using {selectedPlatform.packageManager} + Idempotent script for {selectedPlatform?.name || 'Unknown Platform'} using {selectedPlatform?.packageManager || 'Unknown Package Manager'}