Remove SortDropdown in favor of clickable table headers for consistency with Home and Project pages. Add responsive wrapper for horizontal scroll.
452 lines
15 KiB
TypeScript
452 lines
15 KiB
TypeScript
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 (
|
|
<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 [artifactIdInput, setArtifactIdInput] = useState('');
|
|
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(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<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]);
|
|
|
|
// 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) => <strong>{t.name}</strong>,
|
|
},
|
|
{
|
|
key: 'artifact_id',
|
|
header: 'Artifact ID',
|
|
render: (t: TagDetail) => (
|
|
<div className="artifact-id-cell">
|
|
<code className="artifact-id">{t.artifact_id.substring(0, 12)}...</code>
|
|
<CopyButton text={t.artifact_id} />
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'size',
|
|
header: 'Size',
|
|
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
|
|
},
|
|
{
|
|
key: 'content_type',
|
|
header: 'Type',
|
|
render: (t: TagDetail) => (
|
|
<span className="content-type">{t.artifact_content_type || '-'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'original_name',
|
|
header: 'Filename',
|
|
className: 'cell-truncate',
|
|
render: (t: TagDetail) => (
|
|
<span title={t.artifact_original_name || undefined}>{t.artifact_original_name || '-'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
header: 'Created',
|
|
sortable: true,
|
|
render: (t: TagDetail) => (
|
|
<div className="created-cell">
|
|
<span>{new Date(t.created_at).toLocaleString()}</span>
|
|
<span className="created-by">by {t.created_by}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: 'Actions',
|
|
render: (t: TagDetail) => (
|
|
<a
|
|
href={getDownloadUrl(projectName!, packageName!, t.name)}
|
|
className="btn btn-secondary btn-small"
|
|
download
|
|
>
|
|
Download
|
|
</a>
|
|
),
|
|
},
|
|
];
|
|
|
|
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>}
|
|
</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">
|
|
{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> artifacts
|
|
</span>
|
|
)}
|
|
{pkg.total_size !== undefined && pkg.total_size > 0 && (
|
|
<span className="stat-item">
|
|
<strong>{formatBytes(pkg.total_size)}</strong> total
|
|
</span>
|
|
)}
|
|
{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>}
|
|
|
|
{user && (
|
|
<div className="upload-section card">
|
|
<h3>Upload Artifact</h3>
|
|
{canWrite ? (
|
|
<div className="upload-form">
|
|
<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={handleUploadComplete}
|
|
onUploadError={handleUploadError}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<DragDropUpload
|
|
projectName={projectName!}
|
|
packageName={packageName!}
|
|
disabled={true}
|
|
disabledReason="You have read-only access to this project and cannot upload artifacts."
|
|
onUploadComplete={handleUploadComplete}
|
|
onUploadError={handleUploadError}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="section-header">
|
|
<h2>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}
|
|
/>
|
|
)}
|
|
|
|
<div className="download-by-id-section card">
|
|
<h3>Download by Artifact ID</h3>
|
|
<div className="download-by-id-form">
|
|
<input
|
|
type="text"
|
|
value={artifactIdInput}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<a
|
|
href={artifactIdInput.length === 64 ? getDownloadUrl(projectName!, packageName!, `artifact:${artifactIdInput}`) : '#'}
|
|
className={`btn btn-primary ${artifactIdInput.length !== 64 ? 'btn-disabled' : ''}`}
|
|
download
|
|
onClick={(e) => {
|
|
if (artifactIdInput.length !== 64) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
>
|
|
Download
|
|
</a>
|
|
</div>
|
|
{artifactIdInput.length > 0 && artifactIdInput.length !== 64 && (
|
|
<p className="validation-hint">Artifact ID must be exactly 64 hex characters ({artifactIdInput.length}/64)</p>
|
|
)}
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PackagePage;
|