import { useState, useEffect, useCallback } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation, Link } from 'react-router-dom'; import { TagDetail, Package, PaginatedResponse, AccessLevel, Dependency, DependentInfo } from '../types'; import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, createTag, getArtifactDependencies, getReverseDependencies, getEnsureFile, 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 DependencyGraph from '../components/DependencyGraph'; 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 [accessLevel, setAccessLevel] = useState(null); const [createTagName, setCreateTagName] = useState(''); const [createTagArtifactId, setCreateTagArtifactId] = useState(''); const [createTagLoading, setCreateTagLoading] = useState(false); // UI state const [showUploadModal, setShowUploadModal] = useState(false); const [showCreateTagModal, setShowCreateTagModal] = useState(false); const [openMenuId, setOpenMenuId] = useState(null); const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); // Dependencies state const [selectedTag, setSelectedTag] = useState(null); const [dependencies, setDependencies] = useState([]); const [depsLoading, setDepsLoading] = useState(false); const [depsError, setDepsError] = useState(null); // Reverse dependencies state const [reverseDeps, setReverseDeps] = useState([]); const [reverseDepsLoading, setReverseDepsLoading] = useState(false); const [reverseDepsError, setReverseDepsError] = useState(null); const [reverseDepsPage, setReverseDepsPage] = useState(1); const [reverseDepsTotal, setReverseDepsTotal] = useState(0); const [reverseDepsHasMore, setReverseDepsHasMore] = useState(false); // Dependency graph modal state const [showGraph, setShowGraph] = useState(false); // Dependencies modal state const [showDepsModal, setShowDepsModal] = useState(false); // Artifact ID modal state const [showArtifactIdModal, setShowArtifactIdModal] = useState(false); const [viewArtifactId, setViewArtifactId] = useState(null); // Ensure file modal state const [showEnsureFile, setShowEnsureFile] = useState(false); const [ensureFileContent, setEnsureFileContent] = useState(null); const [ensureFileLoading, setEnsureFileLoading] = useState(false); const [ensureFileError, setEnsureFileError] = useState(null); const [ensureFileTagName, setEnsureFileTagName] = useState(null); // Derived permissions const canWrite = accessLevel === 'write' || accessLevel === 'admin'; // Detect system projects (convention: name starts with "_") const isSystemProject = projectName?.startsWith('_') ?? false; // 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]); // Auto-select tag when tags are loaded (prefer version from URL, then first tag) // Re-run when package changes to pick up new tags useEffect(() => { if (tagsData?.items && tagsData.items.length > 0) { const versionParam = searchParams.get('version'); if (versionParam) { // Find tag matching the version parameter const matchingTag = tagsData.items.find(t => t.version === versionParam); if (matchingTag) { setSelectedTag(matchingTag); setDependencies([]); return; } } // Fall back to first tag setSelectedTag(tagsData.items[0]); setDependencies([]); } }, [tagsData, searchParams, projectName, packageName]); // Fetch dependencies when selected tag changes const fetchDependencies = useCallback(async (artifactId: string) => { setDepsLoading(true); setDepsError(null); try { const result = await getArtifactDependencies(artifactId); setDependencies(result.dependencies); } catch (err) { setDepsError(err instanceof Error ? err.message : 'Failed to load dependencies'); setDependencies([]); } finally { setDepsLoading(false); } }, []); useEffect(() => { if (selectedTag) { fetchDependencies(selectedTag.artifact_id); } }, [selectedTag, fetchDependencies]); // Fetch reverse dependencies const fetchReverseDeps = useCallback(async (pageNum: number = 1) => { if (!projectName || !packageName) return; setReverseDepsLoading(true); setReverseDepsError(null); try { const result = await getReverseDependencies(projectName, packageName, { page: pageNum, limit: 10 }); setReverseDeps(result.dependents); setReverseDepsTotal(result.pagination.total); setReverseDepsHasMore(result.pagination.has_more); setReverseDepsPage(pageNum); } catch (err) { setReverseDepsError(err instanceof Error ? err.message : 'Failed to load reverse dependencies'); setReverseDeps([]); } finally { setReverseDepsLoading(false); } }, [projectName, packageName]); useEffect(() => { if (projectName && packageName && !loading) { fetchReverseDeps(1); } }, [projectName, packageName, loading, fetchReverseDeps]); // Fetch ensure file for a specific tag const fetchEnsureFileForTag = useCallback(async (tagName: string) => { if (!projectName || !packageName) return; setEnsureFileTagName(tagName); setEnsureFileLoading(true); setEnsureFileError(null); try { const content = await getEnsureFile(projectName, packageName, tagName); setEnsureFileContent(content); setShowEnsureFile(true); } catch (err) { setEnsureFileError(err instanceof Error ? err.message : 'Failed to load ensure file'); setShowEnsureFile(true); } finally { setEnsureFileLoading(false); } }, [projectName, packageName]); // Fetch ensure file for selected tag const fetchEnsureFile = useCallback(async () => { if (!selectedTag) return; fetchEnsureFileForTag(selectedTag.name); }, [selectedTag, fetchEnsureFileForTag]); // 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 handleCreateTag = async (e: React.FormEvent) => { e.preventDefault(); if (!createTagName.trim() || createTagArtifactId.length !== 64) return; setCreateTagLoading(true); setError(null); try { await createTag(projectName!, packageName!, { name: createTagName.trim(), artifact_id: createTagArtifactId, }); setUploadSuccess(`Tag "${createTagName}" created successfully!`); setCreateTagName(''); setCreateTagArtifactId(''); loadData(); setTimeout(() => setUploadSuccess(null), 5000); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create tag'); } finally { setCreateTagLoading(false); } }; 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 handleTagSelect = (tag: TagDetail) => { setSelectedTag(tag); }; const handleMenuOpen = (e: React.MouseEvent, tagId: string) => { e.stopPropagation(); if (openMenuId === tagId) { setOpenMenuId(null); setMenuPosition(null); } else { const rect = e.currentTarget.getBoundingClientRect(); setMenuPosition({ top: rect.bottom + 4, left: rect.right - 180 }); setOpenMenuId(tagId); } }; // System projects show Version first, regular projects show Tag first const columns = isSystemProject ? [ // System project columns: Version first, then Filename { key: 'version', header: 'Version', sortable: true, render: (t: TagDetail) => ( handleTagSelect(t)} style={{ cursor: 'pointer' }} > {t.version || t.name} ), }, { key: 'artifact_original_name', header: 'Filename', className: 'cell-truncate', render: (t: TagDetail) => ( {t.artifact_original_name || t.name} ), }, { key: 'artifact_size', header: 'Size', render: (t: TagDetail) => {formatBytes(t.artifact_size)}, }, { key: 'created_at', header: 'Cached', sortable: true, render: (t: TagDetail) => ( {new Date(t.created_at).toLocaleDateString()} ), }, { key: 'actions', header: '', render: (t: TagDetail) => (
), }, ] : [ // Regular project columns: Tag, Version, Filename { key: 'name', header: 'Tag', sortable: true, render: (t: TagDetail) => ( handleTagSelect(t)} style={{ cursor: 'pointer' }} > {t.name} ), }, { key: 'version', header: 'Version', render: (t: TagDetail) => ( {t.version || '—'} ), }, { key: 'artifact_original_name', header: 'Filename', className: 'cell-truncate', render: (t: TagDetail) => ( {t.artifact_original_name || '—'} ), }, { key: 'artifact_size', header: 'Size', render: (t: TagDetail) => {formatBytes(t.artifact_size)}, }, { key: 'created_at', header: 'Created', sortable: true, render: (t: TagDetail) => ( {new Date(t.created_at).toLocaleDateString()} ), }, { key: 'actions', header: '', render: (t: TagDetail) => (
), }, ]; // Find the tag for the open menu const openMenuTag = tags.find(t => t.id === openMenuId); // Close menu when clicking outside const handleClickOutside = () => { if (openMenuId) { setOpenMenuId(null); setMenuPosition(null); } }; // Render dropdown menu as a portal-like element const renderActionMenu = () => { if (!openMenuId || !menuPosition || !openMenuTag) return null; const t = openMenuTag; return (
e.stopPropagation()} > {canWrite && !isSystemProject && ( )}
); }; 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}} {user && canWrite && !isSystemProject && ( )}
{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) && (
{!isSystemProject && pkg.tag_count !== undefined && ( {pkg.tag_count} tags )} {pkg.artifact_count !== undefined && ( {pkg.artifact_count} {isSystemProject ? 'versions' : 'artifacts'} )} {pkg.total_size !== undefined && pkg.total_size > 0 && ( {formatBytes(pkg.total_size)} total )} {!isSystemProject && pkg.latest_tag && ( Latest: {pkg.latest_tag} )}
)}
{error &&
{error}
} {uploadSuccess &&
{uploadSuccess}
}

{isSystemProject ? 'Versions' : '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 && ( )} {/* Used By (Reverse Dependencies) Section */}

Used By

{reverseDepsLoading ? (
Loading reverse dependencies...
) : reverseDepsError ? (
{reverseDepsError}
) : reverseDeps.length === 0 ? (
No packages depend on this package
) : (
{reverseDepsTotal} {reverseDepsTotal === 1 ? 'package depends' : 'packages depend'} on this:
    {reverseDeps.map((dep) => (
  • {dep.project}/{dep.package} {dep.version && ( v{dep.version} )} requires @ {dep.constraint_value}
  • ))}
{(reverseDepsHasMore || reverseDepsPage > 1) && (
Page {reverseDepsPage}
)}
)}

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
        
{/* Dependency Graph Modal */} {showGraph && selectedTag && ( setShowGraph(false)} /> )} {/* Upload Modal */} {showUploadModal && (
setShowUploadModal(false)}>
e.stopPropagation()}>

Upload Artifact

setUploadTag(e.target.value)} placeholder="v1.0.0, latest, stable..." />
{ handleUploadComplete(result); setShowUploadModal(false); setUploadTag(''); }} onUploadError={handleUploadError} />
)} {/* Create/Update Tag Modal */} {showCreateTagModal && (
setShowCreateTagModal(false)}>
e.stopPropagation()}>

Create / Update Tag

Point a tag at an artifact by its ID

{ handleCreateTag(e); setShowCreateTagModal(false); }}>
setCreateTagName(e.target.value)} placeholder="latest, stable, v1.0.0..." disabled={createTagLoading} />
setCreateTagArtifactId(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))} placeholder="SHA256 hash (64 hex characters)" className="artifact-id-input" disabled={createTagLoading} /> {createTagArtifactId.length > 0 && createTagArtifactId.length !== 64 && (

{createTagArtifactId.length}/64 characters

)}
)} {/* Ensure File Modal */} {showEnsureFile && (
setShowEnsureFile(false)}>
e.stopPropagation()}>

orchard.ensure for {ensureFileTagName}

{ensureFileContent && ( )}
{ensureFileLoading ? (
Loading...
) : ensureFileError ? (
{ensureFileError}
) : ensureFileContent ? (
{ensureFileContent}
) : (
No dependencies defined for this artifact.
)}

Save this as orchard.ensure in your project root to declare dependencies.

)} {/* Dependencies Modal */} {showDepsModal && selectedTag && (
setShowDepsModal(false)}>
e.stopPropagation()}>

Dependencies for {selectedTag.version || selectedTag.name}

{depsLoading ? (
Loading dependencies...
) : depsError ? (
{depsError}
) : dependencies.length === 0 ? (
No dependencies
) : (
{dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'}:
    {dependencies.map((dep) => (
  • setShowDepsModal(false)} > {dep.project}/{dep.package} @ {dep.version || dep.tag}
  • ))}
)}
)} {/* Artifact ID Modal */} {showArtifactIdModal && viewArtifactId && (
setShowArtifactIdModal(false)}>
e.stopPropagation()}>

Artifact ID

SHA256 hash identifying this artifact:

{viewArtifactId}
)} {/* Action Menu Dropdown */} {renderActionMenu()}
); } export default PackagePage;