import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; import { TagDetail, Package, PaginatedResponse } from '../types'; import { listTags, uploadArtifact, getDownloadUrl, getPackage } from '../api'; import { Breadcrumb } from '../components/Breadcrumb'; import { Badge } from '../components/Badge'; import { SearchInput } from '../components/SearchInput'; import { SortDropdown, SortOption } from '../components/SortDropdown'; import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { DataTable } from '../components/DataTable'; import { Pagination } from '../components/Pagination'; import './Home.css'; import './PackagePage.css'; const SORT_OPTIONS: SortOption[] = [ { value: 'name', label: 'Name' }, { value: 'created_at', label: 'Created' }, ]; function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const handleCopy = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); }; return ( ); } function PackagePage() { const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const [pkg, setPkg] = useState(null); const [tagsData, setTagsData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [uploading, setUploading] = useState(false); const [uploadResult, setUploadResult] = useState(null); const [tag, setTag] = useState(''); const fileInputRef = useRef(null); // Get params from URL const page = parseInt(searchParams.get('page') || '1', 10); const search = searchParams.get('search') || ''; const sort = searchParams.get('sort') || 'name'; const order = (searchParams.get('order') || 'asc') as 'asc' | 'desc'; const updateParams = useCallback( (updates: Record) => { const newParams = new URLSearchParams(searchParams); Object.entries(updates).forEach(([key, value]) => { if (value === undefined || value === '' || (key === 'page' && value === '1')) { newParams.delete(key); } else { newParams.set(key, value); } }); setSearchParams(newParams); }, [searchParams, setSearchParams] ); const loadData = useCallback(async () => { if (!projectName || !packageName) return; try { setLoading(true); const [pkgData, tagsResult] = await Promise.all([ getPackage(projectName, packageName), listTags(projectName, packageName, { page, search, sort, order }), ]); setPkg(pkgData); setTagsData(tagsResult); setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load data'); } finally { setLoading(false); } }, [projectName, packageName, page, search, sort, order]); useEffect(() => { loadData(); }, [loadData]); // Keyboard navigation - go back with backspace useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Backspace' && !['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) { e.preventDefault(); navigate(`/project/${projectName}`); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [navigate, projectName]); async function handleUpload(e: React.FormEvent) { e.preventDefault(); const file = fileInputRef.current?.files?.[0]; if (!file) { setError('Please select a file'); return; } try { setUploading(true); setError(null); const result = await uploadArtifact(projectName!, packageName!, file, tag || undefined); setUploadResult(`Uploaded successfully! Artifact ID: ${result.artifact_id}`); setTag(''); if (fileInputRef.current) { fileInputRef.current.value = ''; } loadData(); } catch (err) { setError(err instanceof Error ? err.message : 'Upload failed'); } finally { setUploading(false); } } const handleSearchChange = (value: string) => { updateParams({ search: value, page: '1' }); }; const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => { updateParams({ sort: newSort, order: newOrder, page: '1' }); }; const handlePageChange = (newPage: number) => { updateParams({ page: String(newPage) }); }; const clearFilters = () => { setSearchParams({}); }; const hasActiveFilters = search !== ''; const tags = tagsData?.items || []; const pagination = tagsData?.pagination; const columns = [ { key: 'name', header: 'Tag', sortable: true, render: (t: TagDetail) => {t.name}, }, { key: 'artifact_id', header: 'Artifact ID', render: (t: TagDetail) => (
{t.artifact_id.substring(0, 12)}...
), }, { key: 'size', header: 'Size', render: (t: TagDetail) => {formatBytes(t.artifact_size)}, }, { key: 'content_type', header: 'Type', render: (t: TagDetail) => ( {t.artifact_content_type || '-'} ), }, { key: 'original_name', header: 'Filename', className: 'cell-truncate', render: (t: TagDetail) => ( {t.artifact_original_name || '-'} ), }, { key: 'created_at', header: 'Created', sortable: true, render: (t: TagDetail) => (
{new Date(t.created_at).toLocaleString()} by {t.created_by}
), }, { key: 'actions', header: 'Actions', render: (t: TagDetail) => ( Download ), }, ]; if (loading && !tagsData) { return
Loading...
; } return (

{packageName}

{pkg && {pkg.format}}
{pkg?.description &&

{pkg.description}

}
in {projectName} {pkg && ( <> Created {new Date(pkg.created_at).toLocaleDateString()} {pkg.updated_at !== pkg.created_at && ( Updated {new Date(pkg.updated_at).toLocaleDateString()} )} )}
{pkg && (pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && (
{pkg.tag_count !== undefined && ( {pkg.tag_count} tags )} {pkg.artifact_count !== undefined && ( {pkg.artifact_count} artifacts )} {pkg.total_size !== undefined && pkg.total_size > 0 && ( {formatBytes(pkg.total_size)} total )} {pkg.latest_tag && ( Latest: {pkg.latest_tag} )}
)}
{error &&
{error}
} {uploadResult &&
{uploadResult}
}

Upload Artifact

setTag(e.target.value)} placeholder="v1.0.0, latest, stable..." />

Tags / Versions

{hasActiveFilters && ( {search && handleSearchChange('')} />} )} t.id} emptyMessage={ hasActiveFilters ? 'No tags match your filters. Try adjusting your search.' : 'No tags yet. Upload an artifact with a tag to create one!' } onSort={(key) => { if (key === sort) { handleSortChange(key, order === 'asc' ? 'desc' : 'asc'); } else { handleSortChange(key, 'asc'); } }} sortKey={sort} sortOrder={order} /> {pagination && pagination.total_pages > 1 && ( )}

Usage

Download artifacts using:

          curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/latest
        

Or with a specific tag:

          curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/v1.0.0
        
); } export default PackagePage;