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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Page Layout */
|
/* Page Layout */
|
||||||
.home {
|
.home {
|
||||||
max-width: 1000px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user