Replace project cards with sortable data table on Home page

This commit is contained in:
Mondo Diaz
2026-01-15 14:17:56 -06:00
parent f3a817f8a5
commit 5d5a054452
17 changed files with 446 additions and 198 deletions

View File

@@ -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;
}

View File

@@ -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)}

View File

@@ -1,6 +1,6 @@
/* Page Layout */
.home {
max-width: 1000px;
max-width: 1200px;
margin: 0 auto;
}

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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 && (