Develop Frontend Components for Project, Package, and Instance Views

This commit is contained in:
Mondo Diaz
2025-12-12 10:23:44 -06:00
parent 8b7b523aa8
commit e89947f3d3
25 changed files with 2123 additions and 170 deletions

View File

@@ -272,6 +272,179 @@
color: var(--text-muted);
}
.owner {
color: var(--text-muted);
font-size: 0.75rem;
}
.project-meta__dates {
display: flex;
flex-direction: column;
gap: 2px;
text-align: right;
}
.project-meta__owner {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-primary);
}
/* List Controls */
.list-controls {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.list-controls__search {
flex: 1;
min-width: 200px;
max-width: 400px;
}
/* Stats in project cards */
.project-stats {
display: flex;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-primary);
}
.project-stats__item {
display: flex;
flex-direction: column;
gap: 2px;
}
.project-stats__value {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.project-stats__label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Page header enhancements */
.page-header__info {
flex: 1;
}
.page-header__title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.page-header__meta {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 0.8125rem;
color: var(--text-muted);
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
/* Package card styles */
.package-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.package-card__header h3 {
margin-bottom: 0;
}
.package-stats {
display: flex;
gap: 20px;
margin: 16px 0;
padding: 12px 0;
border-top: 1px solid var(--border-primary);
border-bottom: 1px solid var(--border-primary);
}
.package-stats__item {
display: flex;
flex-direction: column;
gap: 2px;
}
.package-stats__value {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.package-stats__label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.latest-tag {
font-size: 0.75rem;
color: var(--text-secondary);
}
.latest-tag strong {
color: var(--accent-primary);
}
/* List controls select */
.list-controls__select {
padding: 8px 32px 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.8125rem;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
.list-controls__select:hover {
background-color: var(--bg-hover);
border-color: var(--border-secondary);
}
.list-controls__select:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}
/* Form row for side-by-side inputs */
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
/* Breadcrumb */
.breadcrumb {
display: flex;

View File

@@ -1,33 +1,67 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Project } from '../types';
import { useState, useEffect, useCallback } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Project, PaginatedResponse } from '../types';
import { listProjects, createProject } from '../api';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination';
import './Home.css';
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
];
function Home() {
const [projects, setProjects] = useState<Project[]>([]);
const [searchParams, setSearchParams] = useSearchParams();
const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [newProject, setNewProject] = useState({ name: '', description: '', is_public: true });
const [creating, setCreating] = useState(false);
useEffect(() => {
loadProjects();
}, []);
// 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';
async function loadProjects() {
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 loadProjects = useCallback(async () => {
try {
setLoading(true);
const data = await listProjects();
setProjects(data);
const data = await listProjects({ page, search, sort, order });
setProjectsData(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load projects');
} finally {
setLoading(false);
}
}
}, [page, search, sort, order]);
useEffect(() => {
loadProjects();
}, [loadProjects]);
async function handleCreateProject(e: React.FormEvent) {
e.preventDefault();
@@ -44,7 +78,27 @@ function Home() {
}
}
if (loading) {
const handleSearchChange = (value: string) => {
updateParams({ search: value, page: '1' });
};
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
updateParams({ sort: newSort, order: newOrder, page: '1' });
};
const handlePageChange = (newPage: number) => {
updateParams({ page: String(newPage) });
};
const clearFilters = () => {
setSearchParams({});
};
const hasActiveFilters = search !== '';
const projects = projectsData?.items || [];
const pagination = projectsData?.pagination;
if (loading && !projectsData) {
return <div className="loading">Loading projects...</div>;
}
@@ -99,27 +153,65 @@ function Home() {
</form>
)}
<div className="list-controls">
<SearchInput
value={search}
onChange={handleSearchChange}
placeholder="Search projects..."
className="list-controls__search"
/>
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
</div>
{hasActiveFilters && (
<FilterChipGroup onClearAll={clearFilters}>
{search && <FilterChip label="Search" value={search} onRemove={() => handleSearchChange('')} />}
</FilterChipGroup>
)}
{projects.length === 0 ? (
<div className="empty-state">
<p>No projects yet. Create your first project to get started!</p>
{hasActiveFilters ? (
<p>No projects match your filters. Try adjusting your search.</p>
) : (
<p>No projects yet. Create your first project to get started!</p>
)}
</div>
) : (
<div className="project-grid">
{projects.map((project) => (
<Link to={`/project/${project.name}`} key={project.id} className="project-card card">
<h3>{project.name}</h3>
{project.description && <p>{project.description}</p>}
<div className="project-meta">
<span className={`badge ${project.is_public ? 'badge-public' : 'badge-private'}`}>
{project.is_public ? 'Public' : 'Private'}
</span>
<span className="date">
Created {new Date(project.created_at).toLocaleDateString()}
</span>
</div>
</Link>
))}
</div>
<>
<div className="project-grid">
{projects.map((project) => (
<Link to={`/project/${project.name}`} key={project.id} className="project-card card">
<h3>{project.name}</h3>
{project.description && <p>{project.description}</p>}
<div className="project-meta">
<Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'}
</Badge>
<div className="project-meta__dates">
<span className="date">Created {new Date(project.created_at).toLocaleDateString()}</span>
{project.updated_at !== project.created_at && (
<span className="date">Updated {new Date(project.updated_at).toLocaleDateString()}</span>
)}
</div>
</div>
<div className="project-meta__owner">
<span className="owner">by {project.created_by}</span>
</div>
</Link>
))}
</div>
{pagination && pagination.total_pages > 1 && (
<Pagination
page={pagination.page}
totalPages={pagination.total_pages}
total={pagination.total}
limit={pagination.limit}
onPageChange={handlePageChange}
/>
)}
</>
)}
</div>
);

View File

@@ -206,6 +206,92 @@ h2 {
color: var(--text-primary);
}
/* Section header */
.section-header {
margin-bottom: 16px;
}
.section-header h2 {
margin-bottom: 0;
}
/* Package header stats */
.package-header-stats {
display: flex;
gap: 20px;
margin-top: 12px;
font-size: 0.875rem;
color: var(--text-secondary);
}
.stat-item strong {
color: var(--text-primary);
}
.stat-item strong.accent {
color: var(--accent-primary);
}
/* Artifact ID cell */
.artifact-id-cell {
display: flex;
align-items: center;
gap: 8px;
}
/* Copy button */
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: all var(--transition-fast);
}
.artifact-id-cell:hover .copy-btn,
tr:hover .copy-btn {
opacity: 1;
}
.copy-btn:hover {
background: var(--bg-hover);
color: var(--accent-primary);
}
/* Content type */
.content-type {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Created cell */
.created-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.created-by {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Cell truncate */
.cell-truncate {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.upload-form {
@@ -217,11 +303,8 @@ h2 {
min-width: 100%;
}
.tags-table {
overflow-x: auto;
}
.tags-table table {
min-width: 500px;
.package-header-stats {
flex-wrap: wrap;
gap: 12px;
}
}

View File

@@ -1,13 +1,64 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Tag } from '../types';
import { listTags, uploadArtifact, getDownloadUrl } from '../api';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { TagDetail, Package, PaginatedResponse } from '../types';
import { listTags, uploadArtifact, getDownloadUrl, getPackage } from '../api';
import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { DataTable } from '../components/DataTable';
import { Pagination } from '../components/Pagination';
import './Home.css';
import './PackagePage.css';
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
];
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 [tags, setTags] = useState<Tag[]>([]);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
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 [uploading, setUploading] = useState(false);
@@ -15,24 +66,61 @@ function PackagePage() {
const [tag, setTag] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (projectName && packageName) {
loadTags();
}
}, [projectName, packageName]);
// 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;
async function loadTags() {
try {
setLoading(true);
const data = await listTags(projectName!, packageName!);
setTags(data);
const [pkgData, tagsResult] = await Promise.all([
getPackage(projectName, packageName),
listTags(projectName, packageName, { page, search, sort, order }),
]);
setPkg(pkgData);
setTagsData(tagsResult);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load tags');
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}
}, [projectName, packageName, page, search, sort, order]);
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]);
async function handleUpload(e: React.FormEvent) {
e.preventDefault();
@@ -51,7 +139,7 @@ function PackagePage() {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
loadTags();
loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
} finally {
@@ -59,18 +147,148 @@ function PackagePage() {
}
}
if (loading) {
const handleSearchChange = (value: string) => {
updateParams({ search: value, page: '1' });
};
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
updateParams({ sort: newSort, 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>;
}
return (
<div className="home">
<nav className="breadcrumb">
<Link to="/">Projects</Link> / <Link to={`/project/${projectName}`}>{projectName}</Link> / <span>{packageName}</span>
</nav>
<Breadcrumb
items={[
{ label: 'Projects', href: '/' },
{ label: projectName!, href: `/project/${projectName}` },
{ label: packageName! },
]}
/>
<div className="page-header">
<h1>{packageName}</h1>
<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>}
@@ -81,12 +299,7 @@ function PackagePage() {
<form onSubmit={handleUpload} className="upload-form">
<div className="form-group">
<label htmlFor="file">File</label>
<input
id="file"
type="file"
ref={fileInputRef}
required
/>
<input id="file" type="file" ref={fileInputRef} required />
</div>
<div className="form-group">
<label htmlFor="tag">Tag (optional)</label>
@@ -104,42 +317,54 @@ function PackagePage() {
</form>
</div>
<h2>Tags / Versions</h2>
{tags.length === 0 ? (
<div className="empty-state">
<p>No tags yet. Upload an artifact with a tag to create one!</p>
</div>
) : (
<div className="tags-table">
<table>
<thead>
<tr>
<th>Tag</th>
<th>Artifact ID</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{tags.map((t) => (
<tr key={t.id}>
<td><strong>{t.name}</strong></td>
<td className="artifact-id">{t.artifact_id.substring(0, 12)}...</td>
<td>{new Date(t.created_at).toLocaleString()}</td>
<td>
<a
href={getDownloadUrl(projectName!, packageName!, t.name)}
className="btn btn-secondary btn-small"
download
>
Download
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="section-header">
<h2>Tags / Versions</h2>
</div>
<div className="list-controls">
<SearchInput
value={search}
onChange={handleSearchChange}
placeholder="Search tags..."
className="list-controls__search"
/>
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
</div>
{hasActiveFilters && (
<FilterChipGroup onClearAll={clearFilters}>
{search && <FilterChip label="Search" value={search} onRemove={() => handleSearchChange('')} />}
</FilterChipGroup>
)}
<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={(key) => {
if (key === sort) {
handleSortChange(key, order === 'asc' ? 'desc' : 'asc');
} else {
handleSortChange(key, 'asc');
}
}}
sortKey={sort}
sortOrder={order}
/>
{pagination && pagination.total_pages > 1 && (
<Pagination
page={pagination.page}
totalPages={pagination.total_pages}
total={pagination.total}
limit={pagination.limit}
onPageChange={handlePageChange}
/>
)}
<div className="usage-section card">

View File

@@ -1,48 +1,107 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Project, Package } from '../types';
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom';
import { Project, Package, PaginatedResponse } from '../types';
import { getProject, listPackages, createPackage } from '../api';
import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination';
import './Home.css';
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
];
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 [searchParams, setSearchParams] = useSearchParams();
const [project, setProject] = useState<Project | null>(null);
const [packages, setPackages] = useState<Package[]>([]);
const [packagesData, setPackagesData] = useState<PaginatedResponse<Package> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [newPackage, setNewPackage] = useState({ name: '', description: '' });
const [newPackage, setNewPackage] = useState({ name: '', description: '', format: 'generic', platform: 'any' });
const [creating, setCreating] = useState(false);
useEffect(() => {
if (projectName) {
loadData();
}
}, [projectName]);
// 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;
async function loadData() {
try {
setLoading(true);
const [projectData, packagesData] = await Promise.all([
getProject(projectName!),
listPackages(projectName!),
const [projectData, packagesResult] = await Promise.all([
getProject(projectName),
listPackages(projectName, { page, search, sort, order, format: format || undefined }),
]);
setProject(projectData);
setPackages(packagesData);
setPackagesData(packagesResult);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}
}, [projectName, page, search, sort, order, format]);
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: '' });
setNewPackage({ name: '', description: '', format: 'generic', platform: 'any' });
setShowForm(false);
loadData();
} catch (err) {
@@ -52,7 +111,31 @@ function ProjectPage() {
}
}
if (loading) {
const handleSearchChange = (value: string) => {
updateParams({ search: value, page: '1' });
};
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
updateParams({ sort: newSort, 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>;
}
@@ -62,14 +145,29 @@ function ProjectPage() {
return (
<div className="home">
<nav className="breadcrumb">
<Link to="/">Projects</Link> / <span>{project.name}</span>
</nav>
<Breadcrumb
items={[
{ label: 'Projects', href: '/' },
{ label: project.name },
]}
/>
<div className="page-header">
<div>
<h1>{project.name}</h1>
<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>
</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>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Package'}
@@ -81,16 +179,32 @@ function ProjectPage() {
{showForm && (
<form className="form card" onSubmit={handleCreatePackage}>
<h3>Create New Package</h3>
<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 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>
@@ -108,24 +222,99 @@ function ProjectPage() {
</form>
)}
<div className="list-controls">
<SearchInput
value={search}
onChange={handleSearchChange}
placeholder="Search 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>
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
</div>
{hasActiveFilters && (
<FilterChipGroup onClearAll={clearFilters}>
{search && <FilterChip label="Search" value={search} onRemove={() => handleSearchChange('')} />}
{format && <FilterChip label="Format" value={format} onRemove={() => handleFormatChange('')} />}
</FilterChipGroup>
)}
{packages.length === 0 ? (
<div className="empty-state">
<p>No packages yet. Create your first package to start uploading artifacts!</p>
{hasActiveFilters ? (
<p>No packages match your filters. Try adjusting your search.</p>
) : (
<p>No packages yet. Create your first package to start uploading artifacts!</p>
)}
</div>
) : (
<div className="project-grid">
{packages.map((pkg) => (
<Link to={`/project/${projectName}/${pkg.name}`} key={pkg.id} className="project-card card">
<h3>{pkg.name}</h3>
{pkg.description && <p>{pkg.description}</p>}
<div className="project-meta">
<span className="date">
Created {new Date(pkg.created_at).toLocaleDateString()}
</span>
</div>
</Link>
))}
</div>
<>
<div className="project-grid">
{packages.map((pkg) => (
<Link to={`/project/${projectName}/${pkg.name}`} key={pkg.id} className="project-card card">
<div className="package-card__header">
<h3>{pkg.name}</h3>
<Badge variant="default">{pkg.format}</Badge>
</div>
{pkg.description && <p>{pkg.description}</p>}
{(pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && (
<div className="package-stats">
{pkg.tag_count !== undefined && (
<div className="package-stats__item">
<span className="package-stats__value">{pkg.tag_count}</span>
<span className="package-stats__label">Tags</span>
</div>
)}
{pkg.artifact_count !== undefined && (
<div className="package-stats__item">
<span className="package-stats__value">{pkg.artifact_count}</span>
<span className="package-stats__label">Artifacts</span>
</div>
)}
{pkg.total_size !== undefined && pkg.total_size > 0 && (
<div className="package-stats__item">
<span className="package-stats__value">{formatBytes(pkg.total_size)}</span>
<span className="package-stats__label">Size</span>
</div>
)}
</div>
)}
<div className="project-meta">
{pkg.latest_tag && (
<span className="latest-tag">
Latest: <strong>{pkg.latest_tag}</strong>
</span>
)}
<span className="date">Created {new Date(pkg.created_at).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
{pagination && pagination.total_pages > 1 && (
<Pagination
page={pagination.page}
totalPages={pagination.total_pages}
total={pagination.total}
limit={pagination.limit}
onPageChange={handlePageChange}
/>
)}
</>
)}
</div>
);