Develop Frontend Components for Project, Package, and Instance Views
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user