309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
|
import { Project, PaginatedResponse } from '../types';
|
|
import { listProjects, createProject } from '../api';
|
|
import { Badge } from '../components/Badge';
|
|
import { DataTable } from '../components/DataTable';
|
|
import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
|
|
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
|
import { Pagination } from '../components/Pagination';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import './Home.css';
|
|
|
|
// Lock icon SVG component
|
|
function LockIcon() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lock-icon">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
const VISIBILITY_OPTIONS: FilterOption[] = [
|
|
{ value: '', label: 'All Projects' },
|
|
{ value: 'public', label: 'Public Only' },
|
|
{ value: 'private', label: 'Private Only' },
|
|
];
|
|
|
|
function Home() {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
|
|
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 sort = searchParams.get('sort') || 'name';
|
|
const order = (searchParams.get('order') || 'asc') as 'asc' | 'desc';
|
|
const visibility = searchParams.get('visibility') || '';
|
|
|
|
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,
|
|
sort,
|
|
order,
|
|
visibility: visibility as 'public' | 'private' | undefined || undefined,
|
|
});
|
|
setProjectsData(data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load projects');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, sort, order, visibility]);
|
|
|
|
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 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) => {
|
|
updateParams({ visibility: value, page: '1' });
|
|
};
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
updateParams({ page: String(newPage) });
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setSearchParams({});
|
|
};
|
|
|
|
const hasActiveFilters = visibility !== '';
|
|
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>
|
|
{user ? (
|
|
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
|
{showForm ? 'Cancel' : '+ New Project'}
|
|
</button>
|
|
) : (
|
|
<Link to="/login" className="btn btn-secondary">
|
|
Login to create projects
|
|
</Link>
|
|
)}
|
|
</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">
|
|
<FilterDropdown
|
|
label="Visibility"
|
|
options={VISIBILITY_OPTIONS}
|
|
value={visibility}
|
|
onChange={handleVisibilityChange}
|
|
/>
|
|
</div>
|
|
|
|
{hasActiveFilters && (
|
|
<FilterChipGroup onClearAll={clearFilters}>
|
|
{visibility && (
|
|
<FilterChip
|
|
label="Visibility"
|
|
value={visibility === 'public' ? 'Public' : 'Private'}
|
|
onRemove={() => handleVisibilityChange('')}
|
|
/>
|
|
)}
|
|
</FilterChipGroup>
|
|
)}
|
|
|
|
<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}
|
|
</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 === '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}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Home;
|