- Hide tag count stat for system projects (show "versions" instead of "artifacts") - Hide "Latest" tag stat for system projects - Change "Create/Update Tag" to only show for non-system projects - Add "View Artifact ID" menu option with modal showing the SHA256 hash - Move dependencies section to a modal (opened via "View Dependencies" menu) - Add deps-modal and artifact-id-modal CSS styles
1030 lines
38 KiB
TypeScript
1030 lines
38 KiB
TypeScript
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 (
|
|
<button className="copy-btn" onClick={handleCopy} title="Copy to clipboard">
|
|
{copied ? (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
) : (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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<Package | null>(null);
|
|
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [accessDenied, setAccessDenied] = useState(false);
|
|
const [uploadTag, setUploadTag] = useState('');
|
|
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
|
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(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<string | null>(null);
|
|
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
|
|
|
// Dependencies state
|
|
const [selectedTag, setSelectedTag] = useState<TagDetail | null>(null);
|
|
const [dependencies, setDependencies] = useState<Dependency[]>([]);
|
|
const [depsLoading, setDepsLoading] = useState(false);
|
|
const [depsError, setDepsError] = useState<string | null>(null);
|
|
|
|
// Reverse dependencies state
|
|
const [reverseDeps, setReverseDeps] = useState<DependentInfo[]>([]);
|
|
const [reverseDepsLoading, setReverseDepsLoading] = useState(false);
|
|
const [reverseDepsError, setReverseDepsError] = useState<string | null>(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<string | null>(null);
|
|
|
|
// Ensure file modal state
|
|
const [showEnsureFile, setShowEnsureFile] = useState(false);
|
|
const [ensureFileContent, setEnsureFileContent] = useState<string | null>(null);
|
|
const [ensureFileLoading, setEnsureFileLoading] = useState(false);
|
|
const [ensureFileError, setEnsureFileError] = useState<string | null>(null);
|
|
const [ensureFileTagName, setEnsureFileTagName] = useState<string | null>(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<string, string | undefined>) => {
|
|
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) => (
|
|
<strong
|
|
className={`tag-name-link ${selectedTag?.id === t.id ? 'selected' : ''}`}
|
|
onClick={() => handleTagSelect(t)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<span className="version-badge">{t.version || t.name}</span>
|
|
</strong>
|
|
),
|
|
},
|
|
{
|
|
key: 'artifact_original_name',
|
|
header: 'Filename',
|
|
className: 'cell-truncate',
|
|
render: (t: TagDetail) => (
|
|
<span title={t.artifact_original_name || t.name}>{t.artifact_original_name || t.name}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'artifact_size',
|
|
header: 'Size',
|
|
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
header: 'Cached',
|
|
sortable: true,
|
|
render: (t: TagDetail) => (
|
|
<span>{new Date(t.created_at).toLocaleDateString()}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (t: TagDetail) => (
|
|
<div className="action-buttons">
|
|
<a
|
|
href={getDownloadUrl(projectName!, packageName!, t.name)}
|
|
className="btn btn-icon"
|
|
download
|
|
title="Download"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="7 10 12 15 17 10" />
|
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
</a>
|
|
<button
|
|
className="btn btn-icon"
|
|
onClick={(e) => handleMenuOpen(e, t.id)}
|
|
title="More actions"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="12" cy="12" r="1" />
|
|
<circle cx="12" cy="5" r="1" />
|
|
<circle cx="12" cy="19" r="1" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
]
|
|
: [
|
|
// Regular project columns: Tag, Version, Filename
|
|
{
|
|
key: 'name',
|
|
header: 'Tag',
|
|
sortable: true,
|
|
render: (t: TagDetail) => (
|
|
<strong
|
|
className={`tag-name-link ${selectedTag?.id === t.id ? 'selected' : ''}`}
|
|
onClick={() => handleTagSelect(t)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
{t.name}
|
|
</strong>
|
|
),
|
|
},
|
|
{
|
|
key: 'version',
|
|
header: 'Version',
|
|
render: (t: TagDetail) => (
|
|
<span className="version-badge">{t.version || '—'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'artifact_original_name',
|
|
header: 'Filename',
|
|
className: 'cell-truncate',
|
|
render: (t: TagDetail) => (
|
|
<span title={t.artifact_original_name || undefined}>{t.artifact_original_name || '—'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'artifact_size',
|
|
header: 'Size',
|
|
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
header: 'Created',
|
|
sortable: true,
|
|
render: (t: TagDetail) => (
|
|
<span title={`by ${t.created_by}`}>{new Date(t.created_at).toLocaleDateString()}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (t: TagDetail) => (
|
|
<div className="action-buttons">
|
|
<a
|
|
href={getDownloadUrl(projectName!, packageName!, t.name)}
|
|
className="btn btn-icon"
|
|
download
|
|
title="Download"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="7 10 12 15 17 10" />
|
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
</a>
|
|
<button
|
|
className="btn btn-icon"
|
|
onClick={(e) => handleMenuOpen(e, t.id)}
|
|
title="More actions"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="12" cy="12" r="1" />
|
|
<circle cx="12" cy="5" r="1" />
|
|
<circle cx="12" cy="19" r="1" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
// 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 (
|
|
<div
|
|
className="action-menu-backdrop"
|
|
onClick={handleClickOutside}
|
|
>
|
|
<div
|
|
className="action-menu-dropdown"
|
|
style={{ top: menuPosition.top, left: menuPosition.left }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button onClick={() => { setViewArtifactId(t.artifact_id); setShowArtifactIdModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
|
|
View Artifact ID
|
|
</button>
|
|
<button onClick={() => { navigator.clipboard.writeText(t.artifact_id); setOpenMenuId(null); setMenuPosition(null); }}>
|
|
Copy Artifact ID
|
|
</button>
|
|
<button onClick={() => { fetchEnsureFileForTag(t.name); setOpenMenuId(null); setMenuPosition(null); }}>
|
|
View Ensure File
|
|
</button>
|
|
{canWrite && !isSystemProject && (
|
|
<button onClick={() => { setCreateTagArtifactId(t.artifact_id); setShowCreateTagModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
|
|
Create/Update Tag
|
|
</button>
|
|
)}
|
|
<button onClick={() => { handleTagSelect(t); setShowDepsModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
|
|
View Dependencies
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (loading && !tagsData) {
|
|
return <div className="loading">Loading...</div>;
|
|
}
|
|
|
|
if (accessDenied) {
|
|
return (
|
|
<div className="home">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: 'Projects', href: '/' },
|
|
{ label: projectName!, href: `/project/${projectName}` },
|
|
]}
|
|
/>
|
|
<div className="error-message" style={{ textAlign: 'center', padding: '48px 24px' }}>
|
|
<h2>Access Denied</h2>
|
|
<p>You do not have permission to view this package.</p>
|
|
{!user && (
|
|
<p style={{ marginTop: '16px' }}>
|
|
<a href="/login" className="btn btn-primary">Sign in</a>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="home">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: 'Projects', href: '/' },
|
|
{ label: projectName!, href: `/project/${projectName}` },
|
|
{ label: packageName! },
|
|
]}
|
|
/>
|
|
|
|
<div className="page-header">
|
|
<div className="page-header__info">
|
|
<div className="page-header__title-row">
|
|
<h1>{packageName}</h1>
|
|
{pkg && <Badge variant="default">{pkg.format}</Badge>}
|
|
{user && canWrite && !isSystemProject && (
|
|
<button
|
|
className="btn btn-primary btn-small header-upload-btn"
|
|
onClick={() => setShowUploadModal(true)}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '6px' }}>
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="17 8 12 3 7 8" />
|
|
<line x1="12" y1="3" x2="12" y2="15" />
|
|
</svg>
|
|
Upload
|
|
</button>
|
|
)}
|
|
</div>
|
|
{pkg?.description && <p className="description">{pkg.description}</p>}
|
|
<div className="page-header__meta">
|
|
<span className="meta-item">
|
|
in <a href={`/project/${projectName}`}>{projectName}</a>
|
|
</span>
|
|
{pkg && (
|
|
<>
|
|
<span className="meta-item">Created {new Date(pkg.created_at).toLocaleDateString()}</span>
|
|
{pkg.updated_at !== pkg.created_at && (
|
|
<span className="meta-item">Updated {new Date(pkg.updated_at).toLocaleDateString()}</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{pkg && (pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && (
|
|
<div className="package-header-stats">
|
|
{!isSystemProject && pkg.tag_count !== undefined && (
|
|
<span className="stat-item">
|
|
<strong>{pkg.tag_count}</strong> tags
|
|
</span>
|
|
)}
|
|
{pkg.artifact_count !== undefined && (
|
|
<span className="stat-item">
|
|
<strong>{pkg.artifact_count}</strong> {isSystemProject ? 'versions' : 'artifacts'}
|
|
</span>
|
|
)}
|
|
{pkg.total_size !== undefined && pkg.total_size > 0 && (
|
|
<span className="stat-item">
|
|
<strong>{formatBytes(pkg.total_size)}</strong> total
|
|
</span>
|
|
)}
|
|
{!isSystemProject && pkg.latest_tag && (
|
|
<span className="stat-item">
|
|
Latest: <strong className="accent">{pkg.latest_tag}</strong>
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className="error-message">{error}</div>}
|
|
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
|
|
|
|
|
|
<div className="section-header">
|
|
<h2>{isSystemProject ? 'Versions' : 'Tags / Versions'}</h2>
|
|
</div>
|
|
|
|
<div className="list-controls">
|
|
<SearchInput
|
|
value={search}
|
|
onChange={handleSearchChange}
|
|
placeholder="Filter tags..."
|
|
className="list-controls__search"
|
|
/>
|
|
</div>
|
|
|
|
{hasActiveFilters && (
|
|
<FilterChipGroup onClearAll={clearFilters}>
|
|
{search && <FilterChip label="Filter" value={search} onRemove={() => handleSearchChange('')} />}
|
|
</FilterChipGroup>
|
|
)}
|
|
|
|
<div className="data-table--responsive">
|
|
<DataTable
|
|
data={tags}
|
|
columns={columns}
|
|
keyExtractor={(t) => 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}
|
|
/>
|
|
</div>
|
|
|
|
{pagination && pagination.total_pages > 1 && (
|
|
<Pagination
|
|
page={pagination.page}
|
|
totalPages={pagination.total_pages}
|
|
total={pagination.total}
|
|
limit={pagination.limit}
|
|
onPageChange={handlePageChange}
|
|
/>
|
|
)}
|
|
|
|
{/* Used By (Reverse Dependencies) Section */}
|
|
<div className="used-by-section card">
|
|
<h3>Used By</h3>
|
|
|
|
{reverseDepsLoading ? (
|
|
<div className="deps-loading">Loading reverse dependencies...</div>
|
|
) : reverseDepsError ? (
|
|
<div className="deps-error">{reverseDepsError}</div>
|
|
) : reverseDeps.length === 0 ? (
|
|
<div className="deps-empty">No packages depend on this package</div>
|
|
) : (
|
|
<div className="reverse-deps-list">
|
|
<div className="deps-summary">
|
|
{reverseDepsTotal} {reverseDepsTotal === 1 ? 'package depends' : 'packages depend'} on this:
|
|
</div>
|
|
<ul className="deps-items">
|
|
{reverseDeps.map((dep) => (
|
|
<li key={dep.artifact_id} className="dep-item reverse-dep-item">
|
|
<Link
|
|
to={`/project/${dep.project}/${dep.package}${dep.version ? `?version=${dep.version}` : ''}`}
|
|
className="dep-link"
|
|
>
|
|
{dep.project}/{dep.package}
|
|
{dep.version && (
|
|
<span className="dep-version">v{dep.version}</span>
|
|
)}
|
|
</Link>
|
|
<span className="dep-requires">
|
|
requires @ {dep.constraint_value}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{(reverseDepsHasMore || reverseDepsPage > 1) && (
|
|
<div className="reverse-deps-pagination">
|
|
<button
|
|
className="btn btn-secondary btn-small"
|
|
onClick={() => fetchReverseDeps(reverseDepsPage - 1)}
|
|
disabled={reverseDepsPage <= 1 || reverseDepsLoading}
|
|
>
|
|
Previous
|
|
</button>
|
|
<span className="pagination-info">Page {reverseDepsPage}</span>
|
|
<button
|
|
className="btn btn-secondary btn-small"
|
|
onClick={() => fetchReverseDeps(reverseDepsPage + 1)}
|
|
disabled={!reverseDepsHasMore || reverseDepsLoading}
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="usage-section card">
|
|
<h3>Usage</h3>
|
|
<p>Download artifacts using:</p>
|
|
<pre>
|
|
<code>curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/latest</code>
|
|
</pre>
|
|
<p>Or with a specific tag:</p>
|
|
<pre>
|
|
<code>curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/v1.0.0</code>
|
|
</pre>
|
|
</div>
|
|
|
|
{/* Dependency Graph Modal */}
|
|
{showGraph && selectedTag && (
|
|
<DependencyGraph
|
|
projectName={projectName!}
|
|
packageName={packageName!}
|
|
tagName={selectedTag.name}
|
|
onClose={() => setShowGraph(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Upload Modal */}
|
|
{showUploadModal && (
|
|
<div className="modal-overlay" onClick={() => setShowUploadModal(false)}>
|
|
<div className="upload-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3>Upload Artifact</h3>
|
|
<button
|
|
className="modal-close"
|
|
onClick={() => setShowUploadModal(false)}
|
|
title="Close"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<div className="form-group">
|
|
<label htmlFor="upload-tag">Tag (optional)</label>
|
|
<input
|
|
id="upload-tag"
|
|
type="text"
|
|
value={uploadTag}
|
|
onChange={(e) => setUploadTag(e.target.value)}
|
|
placeholder="v1.0.0, latest, stable..."
|
|
/>
|
|
</div>
|
|
<DragDropUpload
|
|
projectName={projectName!}
|
|
packageName={packageName!}
|
|
tag={uploadTag || undefined}
|
|
onUploadComplete={(result) => {
|
|
handleUploadComplete(result);
|
|
setShowUploadModal(false);
|
|
setUploadTag('');
|
|
}}
|
|
onUploadError={handleUploadError}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create/Update Tag Modal */}
|
|
{showCreateTagModal && (
|
|
<div className="modal-overlay" onClick={() => setShowCreateTagModal(false)}>
|
|
<div className="create-tag-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3>Create / Update Tag</h3>
|
|
<button
|
|
className="modal-close"
|
|
onClick={() => { setShowCreateTagModal(false); setCreateTagName(''); setCreateTagArtifactId(''); }}
|
|
title="Close"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<p className="modal-description">Point a tag at an artifact by its ID</p>
|
|
<form onSubmit={(e) => { handleCreateTag(e); setShowCreateTagModal(false); }}>
|
|
<div className="form-group">
|
|
<label htmlFor="modal-tag-name">Tag Name</label>
|
|
<input
|
|
id="modal-tag-name"
|
|
type="text"
|
|
value={createTagName}
|
|
onChange={(e) => setCreateTagName(e.target.value)}
|
|
placeholder="latest, stable, v1.0.0..."
|
|
disabled={createTagLoading}
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="modal-artifact-id">Artifact ID</label>
|
|
<input
|
|
id="modal-artifact-id"
|
|
type="text"
|
|
value={createTagArtifactId}
|
|
onChange={(e) => 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 && (
|
|
<p className="validation-hint">{createTagArtifactId.length}/64 characters</p>
|
|
)}
|
|
</div>
|
|
<div className="modal-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => { setShowCreateTagModal(false); setCreateTagName(''); setCreateTagArtifactId(''); }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary"
|
|
disabled={createTagLoading || !createTagName.trim() || createTagArtifactId.length !== 64}
|
|
>
|
|
{createTagLoading ? 'Creating...' : 'Create Tag'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Ensure File Modal */}
|
|
{showEnsureFile && (
|
|
<div className="modal-overlay" onClick={() => setShowEnsureFile(false)}>
|
|
<div className="ensure-file-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="ensure-file-header">
|
|
<h3>orchard.ensure for {ensureFileTagName}</h3>
|
|
<div className="ensure-file-actions">
|
|
{ensureFileContent && (
|
|
<CopyButton text={ensureFileContent} />
|
|
)}
|
|
<button
|
|
className="modal-close"
|
|
onClick={() => setShowEnsureFile(false)}
|
|
title="Close"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="ensure-file-content">
|
|
{ensureFileLoading ? (
|
|
<div className="ensure-file-loading">Loading...</div>
|
|
) : ensureFileError ? (
|
|
<div className="ensure-file-error">{ensureFileError}</div>
|
|
) : ensureFileContent ? (
|
|
<pre className="ensure-file-yaml"><code>{ensureFileContent}</code></pre>
|
|
) : (
|
|
<div className="ensure-file-empty">No dependencies defined for this artifact.</div>
|
|
)}
|
|
</div>
|
|
<div className="ensure-file-footer">
|
|
<p className="ensure-file-hint">
|
|
Save this as <code>orchard.ensure</code> in your project root to declare dependencies.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Dependencies Modal */}
|
|
{showDepsModal && selectedTag && (
|
|
<div className="modal-overlay" onClick={() => setShowDepsModal(false)}>
|
|
<div className="deps-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3>Dependencies for {selectedTag.version || selectedTag.name}</h3>
|
|
<button
|
|
className="modal-close"
|
|
onClick={() => setShowDepsModal(false)}
|
|
title="Close"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<div className="deps-modal-controls">
|
|
<button
|
|
className="btn btn-secondary btn-small"
|
|
onClick={fetchEnsureFile}
|
|
disabled={ensureFileLoading}
|
|
>
|
|
View Ensure File
|
|
</button>
|
|
<button
|
|
className="btn btn-secondary btn-small"
|
|
onClick={() => { setShowDepsModal(false); setShowGraph(true); }}
|
|
>
|
|
View Graph
|
|
</button>
|
|
</div>
|
|
{depsLoading ? (
|
|
<div className="deps-loading">Loading dependencies...</div>
|
|
) : depsError ? (
|
|
<div className="deps-error">{depsError}</div>
|
|
) : dependencies.length === 0 ? (
|
|
<div className="deps-empty">No dependencies</div>
|
|
) : (
|
|
<div className="deps-list">
|
|
<div className="deps-summary">
|
|
{dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'}:
|
|
</div>
|
|
<ul className="deps-items">
|
|
{dependencies.map((dep) => (
|
|
<li key={dep.id} className="dep-item">
|
|
<Link
|
|
to={`/project/${dep.project}/${dep.package}`}
|
|
className="dep-link"
|
|
onClick={() => setShowDepsModal(false)}
|
|
>
|
|
{dep.project}/{dep.package}
|
|
</Link>
|
|
<span className="dep-constraint">
|
|
@ {dep.version || dep.tag}
|
|
</span>
|
|
<span className="dep-status dep-status--ok" title="Package exists">
|
|
✓
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Artifact ID Modal */}
|
|
{showArtifactIdModal && viewArtifactId && (
|
|
<div className="modal-overlay" onClick={() => setShowArtifactIdModal(false)}>
|
|
<div className="artifact-id-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3>Artifact ID</h3>
|
|
<button
|
|
className="modal-close"
|
|
onClick={() => setShowArtifactIdModal(false)}
|
|
title="Close"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<p className="modal-description">SHA256 hash identifying this artifact:</p>
|
|
<div className="artifact-id-display">
|
|
<code>{viewArtifactId}</code>
|
|
<CopyButton text={viewArtifactId} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Menu Dropdown */}
|
|
{renderActionMenu()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PackagePage;
|