Replace project cards with sortable data table on Home page

- Replace card grid with DataTable for better handling of large project lists
- Add sortable columns: Name, Created, Updated (click header to sort)
- Show lock icon for private projects in Name column
- Display Access column with badges for authenticated users
- Add onRowClick prop to DataTable for row navigation
- Make table responsive with horizontal scroll on small screens
- Increase Home page container width to 1200px
- Keep existing visibility filter and pagination working
This commit is contained in:
Mondo Diaz
2026-01-14 22:15:09 +00:00
parent f3a817f8a5
commit 54ed41183f
5 changed files with 167 additions and 72 deletions

View File

@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Adjusted dark mode color palette to use lighter background tones for better readability and reduced eye strain (#52) - Adjusted dark mode color palette to use lighter background tones for better readability and reduced eye strain (#52)
- Replaced project card grid with sortable data table on Home page for better handling of large project lists
- Improved pod naming: Orchard pods now named `orchard-{env}-server-*` for clarity (#51) - Improved pod naming: Orchard pods now named `orchard-{env}-server-*` for clarity (#51)
### Fixed ### Fixed

View File

@@ -98,3 +98,58 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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; onSort?: (key: string) => void;
sortKey?: string; sortKey?: string;
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
onRowClick?: (item: T) => void;
} }
export function DataTable<T>({ export function DataTable<T>({
@@ -29,6 +30,7 @@ export function DataTable<T>({
onSort, onSort,
sortKey, sortKey,
sortOrder, sortOrder,
onRowClick,
}: DataTableProps<T>) { }: DataTableProps<T>) {
if (data.length === 0) { if (data.length === 0) {
return ( return (
@@ -71,7 +73,11 @@ export function DataTable<T>({
</thead> </thead>
<tbody> <tbody>
{data.map((item) => ( {data.map((item) => (
<tr key={keyExtractor(item)}> <tr
key={keyExtractor(item)}
onClick={() => onRowClick?.(item)}
className={onRowClick ? 'data-table__row--clickable' : ''}
>
{columns.map((column) => ( {columns.map((column) => (
<td key={column.key} className={column.className}> <td key={column.key} className={column.className}>
{column.render(item)} {column.render(item)}

View File

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

View File

@@ -1,9 +1,9 @@
import { useState, useEffect, useCallback } from 'react'; 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 { Project, PaginatedResponse } from '../types';
import { listProjects, createProject } from '../api'; import { listProjects, createProject } from '../api';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { SortDropdown, SortOption } from '../components/SortDropdown'; import { DataTable } from '../components/DataTable';
import { FilterDropdown, FilterOption } from '../components/FilterDropdown'; import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination'; 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[] = [ const VISIBILITY_OPTIONS: FilterOption[] = [
{ value: '', label: 'All Projects' }, { value: '', label: 'All Projects' },
{ value: 'public', label: 'Public Only' }, { value: 'public', label: 'Public Only' },
@@ -34,6 +28,7 @@ const VISIBILITY_OPTIONS: FilterOption[] = [
function Home() { function Home() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null); const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null);
@@ -101,8 +96,10 @@ function Home() {
} }
} }
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => { const handleSortChange = (columnKey: string) => {
updateParams({ sort: newSort, order: newOrder, page: '1' }); // 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) => { const handleVisibilityChange = (value: string) => {
@@ -189,7 +186,6 @@ function Home() {
value={visibility} value={visibility}
onChange={handleVisibilityChange} onChange={handleVisibilityChange}
/> />
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
@@ -204,69 +200,106 @@ function Home() {
</FilterChipGroup> </FilterChipGroup>
)} )}
{projects.length === 0 ? ( <div className="data-table--responsive">
<div className="empty-state"> <DataTable
{hasActiveFilters ? ( data={projects}
<p>No projects match your filters. Try adjusting your search.</p> keyExtractor={(project) => project.id}
) : ( onRowClick={(project) => navigate(`/project/${project.name}`)}
<p>No projects yet. Create your first project to get started!</p> onSort={handleSortChange}
)} sortKey={sort}
</div> sortOrder={order}
) : ( emptyMessage={
<> hasActiveFilters
<div className="project-grid"> ? 'No projects match your filters. Try adjusting your search.'
{projects.map((project) => ( : 'No projects yet. Create your first project to get started!'
<Link to={`/project/${project.name}`} key={project.id} className="project-card card"> }
<h3> columns={[
{
key: 'name',
header: 'Name',
sortable: true,
render: (project) => (
<span className="cell-name">
{!project.is_public && <LockIcon />} {!project.is_public && <LockIcon />}
{project.name} {project.name}
</h3> </span>
{project.description && <p>{project.description}</p>} ),
<div className="project-meta"> },
<div className="project-badges"> {
<Badge variant={project.is_public ? 'public' : 'private'}> key: 'description',
{project.is_public ? 'Public' : 'Private'} header: 'Description',
</Badge> className: 'cell-description',
{user && project.access_level && ( render: (project) => project.description || '—',
<Badge },
variant={ {
project.is_owner key: 'visibility',
? 'success' header: 'Visibility',
: project.access_level === 'admin' 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' ? 'success'
: project.access_level === 'write' : project.access_level === 'admin'
? 'info' ? 'success'
: 'default' : project.access_level === 'write'
} ? 'info'
> : 'default'
{project.is_owner ? 'Owner' : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)} }
</Badge> >
)} {project.is_owner
</div> ? 'Owner'
<div className="project-meta__dates"> : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)}
<span className="date">Created {new Date(project.created_at).toLocaleDateString()}</span> </Badge>
{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> key: 'created_at',
</Link> header: 'Created',
))} sortable: true,
</div> 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 && pagination.total_pages > 1 && (
<Pagination <Pagination
page={pagination.page} page={pagination.page}
totalPages={pagination.total_pages} totalPages={pagination.total_pages}
total={pagination.total} total={pagination.total}
limit={pagination.limit} limit={pagination.limit}
onPageChange={handlePageChange} onPageChange={handlePageChange}
/> />
)}
</>
)} )}
</div> </div>
); );