383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
|
import { Project, Package, PaginatedResponse, AccessLevel } from '../types';
|
|
import { getProject, listPackages, createPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
|
|
import { Breadcrumb } from '../components/Breadcrumb';
|
|
import { Badge } from '../components/Badge';
|
|
import { DataTable } from '../components/DataTable';
|
|
import { SearchInput } from '../components/SearchInput';
|
|
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
|
import { Pagination } from '../components/Pagination';
|
|
import { AccessManagement } from '../components/AccessManagement';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import './Home.css';
|
|
|
|
const FORMAT_OPTIONS = ['generic', 'npm', 'pypi', 'docker', 'deb', 'rpm', 'maven', 'nuget', 'helm'];
|
|
|
|
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 ProjectPage() {
|
|
const { projectName } = useParams<{ projectName: string }>();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const { user } = useAuth();
|
|
|
|
const [project, setProject] = useState<Project | null>(null);
|
|
const [packagesData, setPackagesData] = useState<PaginatedResponse<Package> | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [accessDenied, setAccessDenied] = useState(false);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [newPackage, setNewPackage] = useState({ name: '', description: '', format: 'generic', platform: 'any' });
|
|
const [creating, setCreating] = useState(false);
|
|
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
|
|
const [isOwner, setIsOwner] = useState(false);
|
|
|
|
// Derived permissions
|
|
const canWrite = accessLevel === 'write' || accessLevel === 'admin';
|
|
const canAdmin = 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 format = searchParams.get('format') || '';
|
|
|
|
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) return;
|
|
|
|
try {
|
|
setLoading(true);
|
|
setAccessDenied(false);
|
|
const [projectData, packagesResult, accessResult] = await Promise.all([
|
|
getProject(projectName),
|
|
listPackages(projectName, { page, search, sort, order, format: format || undefined }),
|
|
getMyProjectAccess(projectName),
|
|
]);
|
|
setProject(projectData);
|
|
setPackagesData(packagesResult);
|
|
setAccessLevel(accessResult.access_level);
|
|
setIsOwner(accessResult.is_owner);
|
|
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 project');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setError(err instanceof Error ? err.message : 'Failed to load data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectName, page, search, sort, order, format, 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('/');
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [navigate]);
|
|
|
|
async function handleCreatePackage(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
try {
|
|
setCreating(true);
|
|
await createPackage(projectName!, newPackage);
|
|
setNewPackage({ name: '', description: '', format: 'generic', platform: 'any' });
|
|
setShowForm(false);
|
|
loadData();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create package');
|
|
} finally {
|
|
setCreating(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 handleFormatChange = (value: string) => {
|
|
updateParams({ format: value, page: '1' });
|
|
};
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
updateParams({ page: String(newPage) });
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setSearchParams({});
|
|
};
|
|
|
|
const hasActiveFilters = search !== '' || format !== '';
|
|
const packages = packagesData?.items || [];
|
|
const pagination = packagesData?.pagination;
|
|
|
|
if (loading && !packagesData) {
|
|
return <div className="loading">Loading...</div>;
|
|
}
|
|
|
|
if (accessDenied) {
|
|
return (
|
|
<div className="home">
|
|
<Breadcrumb items={[{ label: 'Projects', href: '/' }]} />
|
|
<div className="error-message" style={{ textAlign: 'center', padding: '48px 24px' }}>
|
|
<h2>Access Denied</h2>
|
|
<p>You do not have permission to view this project.</p>
|
|
{!user && (
|
|
<p style={{ marginTop: '16px' }}>
|
|
<a href="/login" className="btn btn-primary">Sign in</a>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!project) {
|
|
return <div className="error-message">Project not found</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="home">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: 'Projects', href: '/' },
|
|
{ label: project.name },
|
|
]}
|
|
/>
|
|
|
|
<div className="page-header">
|
|
<div className="page-header__info">
|
|
<div className="page-header__title-row">
|
|
<h1>{project.name}</h1>
|
|
<Badge variant={project.is_public ? 'public' : 'private'}>
|
|
{project.is_public ? 'Public' : 'Private'}
|
|
</Badge>
|
|
{accessLevel && (
|
|
<Badge variant={accessLevel === 'admin' ? 'success' : accessLevel === 'write' ? 'info' : 'default'}>
|
|
{isOwner ? 'Owner' : accessLevel.charAt(0).toUpperCase() + accessLevel.slice(1)}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{project.description && <p className="description">{project.description}</p>}
|
|
<div className="page-header__meta">
|
|
<span className="meta-item">Created {new Date(project.created_at).toLocaleDateString()}</span>
|
|
{project.updated_at !== project.created_at && (
|
|
<span className="meta-item">Updated {new Date(project.updated_at).toLocaleDateString()}</span>
|
|
)}
|
|
<span className="meta-item">by {project.created_by}</span>
|
|
</div>
|
|
</div>
|
|
{canWrite ? (
|
|
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
|
{showForm ? 'Cancel' : '+ New Package'}
|
|
</button>
|
|
) : user ? (
|
|
<span className="text-muted" title="You have read-only access to this project">
|
|
Read-only access
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{error && <div className="error-message">{error}</div>}
|
|
|
|
{showForm && canWrite && (
|
|
<form className="form card" onSubmit={handleCreatePackage}>
|
|
<h3>Create New Package</h3>
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label htmlFor="name">Name</label>
|
|
<input
|
|
id="name"
|
|
type="text"
|
|
value={newPackage.name}
|
|
onChange={(e) => setNewPackage({ ...newPackage, name: e.target.value })}
|
|
placeholder="releases"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="format">Format</label>
|
|
<select
|
|
id="format"
|
|
value={newPackage.format}
|
|
onChange={(e) => setNewPackage({ ...newPackage, format: e.target.value })}
|
|
>
|
|
{FORMAT_OPTIONS.map((f) => (
|
|
<option key={f} value={f}>
|
|
{f}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="description">Description</label>
|
|
<input
|
|
id="description"
|
|
type="text"
|
|
value={newPackage.description}
|
|
onChange={(e) => setNewPackage({ ...newPackage, description: e.target.value })}
|
|
placeholder="Optional description"
|
|
/>
|
|
</div>
|
|
<button type="submit" className="btn btn-primary" disabled={creating}>
|
|
{creating ? 'Creating...' : 'Create Package'}
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
<div className="list-controls">
|
|
<SearchInput
|
|
value={search}
|
|
onChange={handleSearchChange}
|
|
placeholder="Filter packages..."
|
|
className="list-controls__search"
|
|
/>
|
|
<select
|
|
className="list-controls__select"
|
|
value={format}
|
|
onChange={(e) => handleFormatChange(e.target.value)}
|
|
>
|
|
<option value="">All formats</option>
|
|
{FORMAT_OPTIONS.map((f) => (
|
|
<option key={f} value={f}>
|
|
{f}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{hasActiveFilters && (
|
|
<FilterChipGroup onClearAll={clearFilters}>
|
|
{search && <FilterChip label="Filter" value={search} onRemove={() => handleSearchChange('')} />}
|
|
{format && <FilterChip label="Format" value={format} onRemove={() => handleFormatChange('')} />}
|
|
</FilterChipGroup>
|
|
)}
|
|
|
|
<div className="data-table--responsive">
|
|
<DataTable
|
|
data={packages}
|
|
keyExtractor={(pkg) => pkg.id}
|
|
onRowClick={(pkg) => navigate(`/project/${projectName}/${pkg.name}`)}
|
|
onSort={handleSortChange}
|
|
sortKey={sort}
|
|
sortOrder={order}
|
|
emptyMessage={
|
|
hasActiveFilters
|
|
? 'No packages match your filters. Try adjusting your search.'
|
|
: 'No packages yet. Create your first package to start uploading artifacts!'
|
|
}
|
|
columns={[
|
|
{
|
|
key: 'name',
|
|
header: 'Name',
|
|
sortable: true,
|
|
render: (pkg) => <span className="cell-name">{pkg.name}</span>,
|
|
},
|
|
{
|
|
key: 'description',
|
|
header: 'Description',
|
|
className: 'cell-description',
|
|
render: (pkg) => pkg.description || '—',
|
|
},
|
|
{
|
|
key: 'format',
|
|
header: 'Format',
|
|
render: (pkg) => <Badge variant="default">{pkg.format}</Badge>,
|
|
},
|
|
{
|
|
key: 'tag_count',
|
|
header: 'Tags',
|
|
render: (pkg) => pkg.tag_count ?? '—',
|
|
},
|
|
{
|
|
key: 'artifact_count',
|
|
header: 'Artifacts',
|
|
render: (pkg) => pkg.artifact_count ?? '—',
|
|
},
|
|
{
|
|
key: 'total_size',
|
|
header: 'Size',
|
|
render: (pkg) =>
|
|
pkg.total_size !== undefined && pkg.total_size > 0 ? formatBytes(pkg.total_size) : '—',
|
|
},
|
|
{
|
|
key: 'latest_tag',
|
|
header: 'Latest',
|
|
render: (pkg) =>
|
|
pkg.latest_tag ? <strong style={{ color: 'var(--accent-primary)' }}>{pkg.latest_tag}</strong> : '—',
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
header: 'Created',
|
|
sortable: true,
|
|
className: 'cell-date',
|
|
render: (pkg) => new Date(pkg.created_at).toLocaleDateString(),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{pagination && pagination.total_pages > 1 && (
|
|
<Pagination
|
|
page={pagination.page}
|
|
totalPages={pagination.total_pages}
|
|
total={pagination.total}
|
|
limit={pagination.limit}
|
|
onPageChange={handlePageChange}
|
|
/>
|
|
)}
|
|
|
|
{canAdmin && projectName && (
|
|
<AccessManagement projectName={projectName} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ProjectPage;
|