Files
orchard/frontend/src/pages/Home.tsx
Mondo Diaz 30774e429b Add frontend components for project/package/tag hierarchy views
- Create reusable UI components: Badge, Breadcrumb, Card, DataTable,
  FilterChip, Pagination, SearchInput, SortDropdown
- Update Home page with search, sort, filter, and pagination
- Update ProjectPage with package stats, format filtering, keyboard nav
- Update PackagePage with DataTable for tags, copy artifact ID, metadata
- Add TypeScript types for paginated responses and list params
- Update API client with pagination and filter query param support
- URL-based state persistence for all filter/sort/page params
- Keyboard navigation (Backspace to go up hierarchy)
2025-12-12 10:10:10 -06:00

221 lines
7.4 KiB
TypeScript

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 [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);
// 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 loadProjects = useCallback(async () => {
try {
setLoading(true);
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();
try {
setCreating(true);
await createProject(newProject);
setNewProject({ name: '', description: '', is_public: true });
setShowForm(false);
loadProjects();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create project');
} finally {
setCreating(false);
}
}
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>;
}
return (
<div className="home">
<div className="page-header">
<h1>Projects</h1>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Project'}
</button>
</div>
{error && <div className="error-message">{error}</div>}
{showForm && (
<form className="form card" onSubmit={handleCreateProject}>
<h3>Create New Project</h3>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
value={newProject.name}
onChange={(e) => setNewProject({ ...newProject, name: e.target.value })}
placeholder="my-project"
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<input
id="description"
type="text"
value={newProject.description}
onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
placeholder="Optional description"
/>
</div>
<div className="form-group checkbox">
<label>
<input
type="checkbox"
checked={newProject.is_public}
onChange={(e) => setNewProject({ ...newProject, is_public: e.target.checked })}
/>
Public
</label>
</div>
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create Project'}
</button>
</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">
{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">
<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>
);
}
export default Home;