import { useState, useEffect, useCallback } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; import { TagDetail, Package, PaginatedResponse, AccessLevel } from '../types'; import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api'; import { Breadcrumb } from '../components/Breadcrumb'; import { Badge } from '../components/Badge'; import { SearchInput } from '../components/SearchInput'; import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { DataTable } from '../components/DataTable'; import { Pagination } from '../components/Pagination'; import { DragDropUpload, UploadResult } from '../components/DragDropUpload'; import { useAuth } from '../contexts/AuthContext'; import './Home.css'; import './PackagePage.css'; 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 location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const { user } = useAuth(); const [pkg, setPkg] = useState(null); const [tagsData, setTagsData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [accessDenied, setAccessDenied] = useState(false); const [uploadTag, setUploadTag] = useState(''); const [uploadSuccess, setUploadSuccess] = useState(null); const [artifactIdInput, setArtifactIdInput] = useState(''); const [accessLevel, setAccessLevel] = useState(null); // Derived permissions const canWrite = accessLevel === 'write' || accessLevel === 'admin'; // 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); setAccessDenied(false); const [pkgData, tagsResult, accessResult] = await Promise.all([ getPackage(projectName, packageName), listTags(projectName, packageName, { page, search, sort, order }), getMyProjectAccess(projectName), ]); setPkg(pkgData); setTagsData(tagsResult); setAccessLevel(accessResult.access_level); setError(null); } catch (err) { if (err instanceof UnauthorizedError) { navigate('/login', { state: { from: location.pathname } }); return; } if (err instanceof ForbiddenError) { setAccessDenied(true); setError('You do not have access to this package'); setLoading(false); return; } setError(err instanceof Error ? err.message : 'Failed to load data'); } finally { setLoading(false); } }, [projectName, packageName, page, search, sort, order, navigate, location.pathname]); 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]); const handleUploadComplete = useCallback((results: UploadResult[]) => { const count = results.length; const message = count === 1 ? `Uploaded successfully! Artifact ID: ${results[0].artifact_id}` : `${count} files uploaded successfully!`; setUploadSuccess(message); setUploadTag(''); loadData(); // Auto-dismiss success message after 5 seconds setTimeout(() => setUploadSuccess(null), 5000); }, [loadData]); const handleUploadError = useCallback((errorMsg: string) => { setError(errorMsg); }, []); const handleSearchChange = (value: string) => { updateParams({ search: value, page: '1' }); }; const handleSortChange = (columnKey: string) => { const newOrder = columnKey === sort ? (order === 'asc' ? 'desc' : 'asc') : 'asc'; updateParams({ sort: columnKey, 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...
; } if (accessDenied) { return (

Access Denied

You do not have permission to view this package.

{!user && (

Sign in

)}
); } 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}
} {uploadSuccess &&
{uploadSuccess}
} {user && (

Upload Artifact

{canWrite ? (
setUploadTag(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={handleSortChange} sortKey={sort} sortOrder={order} />
{pagination && pagination.total_pages > 1 && ( )}

Download by Artifact ID

setArtifactIdInput(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))} placeholder="Enter SHA256 artifact ID (64 hex characters)" className="artifact-id-input" /> { if (artifactIdInput.length !== 64) { e.preventDefault(); } }} > Download
{artifactIdInput.length > 0 && artifactIdInput.length !== 64 && (

Artifact ID must be exactly 64 hex characters ({artifactIdInput.length}/64)

)}

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;