Replace project cards with sortable data table on Home page
This commit is contained in:
@@ -98,3 +98,58 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Clickable rows */
|
||||
.data-table__row--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-table__row--clickable:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Responsive table wrapper */
|
||||
.data-table--responsive {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.data-table--responsive table {
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
/* Cell with name and icon */
|
||||
.data-table .cell-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.data-table .cell-name:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Date cells */
|
||||
.data-table .cell-date {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Description cell */
|
||||
.data-table .cell-description {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Owner cell */
|
||||
.data-table .cell-owner {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface DataTableProps<T> {
|
||||
onSort?: (key: string) => void;
|
||||
sortKey?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
onRowClick?: (item: T) => void;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
@@ -29,6 +30,7 @@ export function DataTable<T>({
|
||||
onSort,
|
||||
sortKey,
|
||||
sortOrder,
|
||||
onRowClick,
|
||||
}: DataTableProps<T>) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
@@ -71,7 +73,11 @@ export function DataTable<T>({
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr key={keyExtractor(item)}>
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={onRowClick ? 'data-table__row--clickable' : ''}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className={column.className}>
|
||||
{column.render(item)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Page Layout */
|
||||
.home {
|
||||
max-width: 1000px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Project, PaginatedResponse } from '../types';
|
||||
import { listProjects, createProject } from '../api';
|
||||
import { Badge } from '../components/Badge';
|
||||
import { SortDropdown, SortOption } from '../components/SortDropdown';
|
||||
import { DataTable } from '../components/DataTable';
|
||||
import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
|
||||
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
@@ -20,12 +20,6 @@ function LockIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'created_at', label: 'Created' },
|
||||
{ value: 'updated_at', label: 'Updated' },
|
||||
];
|
||||
|
||||
const VISIBILITY_OPTIONS: FilterOption[] = [
|
||||
{ value: '', label: 'All Projects' },
|
||||
{ value: 'public', label: 'Public Only' },
|
||||
@@ -34,6 +28,7 @@ const VISIBILITY_OPTIONS: FilterOption[] = [
|
||||
|
||||
function Home() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null);
|
||||
@@ -101,8 +96,10 @@ function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
|
||||
updateParams({ sort: newSort, order: newOrder, page: '1' });
|
||||
const handleSortChange = (columnKey: string) => {
|
||||
// Toggle order if clicking the same column, otherwise default to asc
|
||||
const newOrder = columnKey === sort ? (order === 'asc' ? 'desc' : 'asc') : 'asc';
|
||||
updateParams({ sort: columnKey, order: newOrder, page: '1' });
|
||||
};
|
||||
|
||||
const handleVisibilityChange = (value: string) => {
|
||||
@@ -189,7 +186,6 @@ function Home() {
|
||||
value={visibility}
|
||||
onChange={handleVisibilityChange}
|
||||
/>
|
||||
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
@@ -204,69 +200,106 @@ function Home() {
|
||||
</FilterChipGroup>
|
||||
)}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{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>
|
||||
<div className="data-table--responsive">
|
||||
<DataTable
|
||||
data={projects}
|
||||
keyExtractor={(project) => project.id}
|
||||
onRowClick={(project) => navigate(`/project/${project.name}`)}
|
||||
onSort={handleSortChange}
|
||||
sortKey={sort}
|
||||
sortOrder={order}
|
||||
emptyMessage={
|
||||
hasActiveFilters
|
||||
? 'No projects match your filters. Try adjusting your search.'
|
||||
: 'No projects yet. Create your first project to get started!'
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
render: (project) => (
|
||||
<span className="cell-name">
|
||||
{!project.is_public && <LockIcon />}
|
||||
{project.name}
|
||||
</h3>
|
||||
{project.description && <p>{project.description}</p>}
|
||||
<div className="project-meta">
|
||||
<div className="project-badges">
|
||||
<Badge variant={project.is_public ? 'public' : 'private'}>
|
||||
{project.is_public ? 'Public' : 'Private'}
|
||||
</Badge>
|
||||
{user && project.access_level && (
|
||||
<Badge
|
||||
variant={
|
||||
project.is_owner
|
||||
? 'success'
|
||||
: project.access_level === 'admin'
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: 'Description',
|
||||
className: 'cell-description',
|
||||
render: (project) => project.description || '—',
|
||||
},
|
||||
{
|
||||
key: 'visibility',
|
||||
header: 'Visibility',
|
||||
render: (project) => (
|
||||
<Badge variant={project.is_public ? 'public' : 'private'}>
|
||||
{project.is_public ? 'Public' : 'Private'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_by',
|
||||
header: 'Owner',
|
||||
className: 'cell-owner',
|
||||
render: (project) => project.created_by,
|
||||
},
|
||||
...(user
|
||||
? [
|
||||
{
|
||||
key: 'access_level',
|
||||
header: 'Access',
|
||||
render: (project: Project) =>
|
||||
project.access_level ? (
|
||||
<Badge
|
||||
variant={
|
||||
project.is_owner
|
||||
? 'success'
|
||||
: project.access_level === 'write'
|
||||
? 'info'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{project.is_owner ? 'Owner' : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
: project.access_level === 'admin'
|
||||
? 'success'
|
||||
: project.access_level === 'write'
|
||||
? 'info'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{project.is_owner
|
||||
? 'Owner'
|
||||
: project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Created',
|
||||
sortable: true,
|
||||
className: 'cell-date',
|
||||
render: (project) => new Date(project.created_at).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
header: 'Updated',
|
||||
sortable: true,
|
||||
className: 'cell-date',
|
||||
render: (project) => new Date(project.updated_at).toLocaleDateString(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.total_pages}
|
||||
total={pagination.total}
|
||||
limit={pagination.limit}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.total_pages}
|
||||
total={pagination.total}
|
||||
limit={pagination.limit}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedE
|
||||
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';
|
||||
@@ -14,11 +13,6 @@ import { useAuth } from '../contexts/AuthContext';
|
||||
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;
|
||||
@@ -164,8 +158,9 @@ function PackagePage() {
|
||||
updateParams({ search: value, page: '1' });
|
||||
};
|
||||
|
||||
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
|
||||
updateParams({ sort: newSort, order: newOrder, 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) => {
|
||||
@@ -198,19 +193,19 @@ function PackagePage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
key: 'artifact_size',
|
||||
header: 'Size',
|
||||
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'content_type',
|
||||
key: 'artifact_content_type',
|
||||
header: 'Type',
|
||||
render: (t: TagDetail) => (
|
||||
<span className="content-type">{t.artifact_content_type || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'original_name',
|
||||
key: 'artifact_original_name',
|
||||
header: 'Filename',
|
||||
className: 'cell-truncate',
|
||||
render: (t: TagDetail) => (
|
||||
@@ -376,7 +371,6 @@ function PackagePage() {
|
||||
placeholder="Filter tags..."
|
||||
className="list-controls__search"
|
||||
/>
|
||||
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
@@ -385,25 +379,21 @@ function PackagePage() {
|
||||
</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');
|
||||
<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!'
|
||||
}
|
||||
}}
|
||||
sortKey={sort}
|
||||
sortOrder={order}
|
||||
/>
|
||||
onSort={handleSortChange}
|
||||
sortKey={sort}
|
||||
sortOrder={order}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
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 { SortDropdown, SortOption } from '../components/SortDropdown';
|
||||
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 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 {
|
||||
@@ -140,8 +134,9 @@ function ProjectPage() {
|
||||
updateParams({ search: value, page: '1' });
|
||||
};
|
||||
|
||||
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
|
||||
updateParams({ sort: newSort, order: newOrder, 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) => {
|
||||
@@ -294,7 +289,6 @@ function ProjectPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
@@ -304,70 +298,78 @@ function ProjectPage() {
|
||||
</FilterChipGroup>
|
||||
)}
|
||||
|
||||
{packages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{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">
|
||||
<div className="package-card__header">
|
||||
<h3>{pkg.name}</h3>
|
||||
<Badge variant="default">{pkg.format}</Badge>
|
||||
</div>
|
||||
{pkg.description && <p>{pkg.description}</p>}
|
||||
<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>
|
||||
|
||||
{(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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.total_pages}
|
||||
total={pagination.total}
|
||||
limit={pagination.limit}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canAdmin && projectName && (
|
||||
|
||||
Reference in New Issue
Block a user