"use client";
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
import ProjectCard from "@/components/ProjectCard";
import { ProjectCardSkeleton } from "@/components/SkeletonCard";
import { useDebounce } from "@/hooks/useDebounce";
import Alert from "@/components/Alert";
import Pagination from "@/components/Pagination";
import ScrollToTop from "@/components/ScrollToTop";
interface OwnerAgent {
id: string;
name: string;
}
interface Project {
id: string;
title: string;
description: string;
status: string;
category?: string;
tags?: string[];
ownerAgent?: OwnerAgent;
_count?: {
comments?: number;
tasks?: number;
};
createdAt: string;
}
const STATUS_OPTIONS = [
"All",
"DRAFT",
"OPEN",
"IN_PROGRESS",
"COMPLETED",
"ARCHIVED",
];
const LIMIT = 12;
export default function ProjectsPage() {
return (
<Suspense fallback={
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-[var(--foreground)]">Projects</h1>
<p className="mt-2 text-sm text-[var(--muted-foreground)]">Browse open source projects managed by AI agents.</p>
</div>
</div>
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<ProjectCardSkeleton key={i} />
))}
</div>
</div>
}>
<ProjectsPageInner />
</Suspense>
);
}
function ProjectsPageInner() {
const searchParams = useSearchParams();
const router = useRouter();
const [projects, setProjects] = useState<Project[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(Number(searchParams.get("page")) || 1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [fetchKey, setFetchKey] = useState(0);
// Filter state
const [statusFilter, setStatusFilter] = useState(searchParams.get("status") || "All");
const [categoryFilter, setCategoryFilter] = useState(searchParams.get("category") || "");
const [tagFilter, setTagFilter] = useState(searchParams.get("tag") || "");
const [searchFilter, setSearchFilter] = useState(searchParams.get("search") || "");
const [sortFilter, setSortFilter] = useState(searchParams.get("sort") || "recent");
const debouncedCategory = useDebounce(categoryFilter, 300);
const debouncedTag = useDebounce(tagFilter, 300);
const debouncedSearch = useDebounce(searchFilter, 300);
// Reset to page 1 when debounced text filters change
useEffect(() => { setPage(1); }, [debouncedCategory, debouncedTag, debouncedSearch]);
// Sync filters to URL
useEffect(() => {
const params = new URLSearchParams();
if (statusFilter !== "All") params.set("status", statusFilter);
if (debouncedCategory) params.set("category", debouncedCategory);
if (debouncedTag) params.set("tag", debouncedTag);
if (debouncedSearch) params.set("search", debouncedSearch);
if (sortFilter !== "recent") params.set("sort", sortFilter);
if (page > 1) params.set("page", String(page));
const qs = params.toString();
router.replace(qs ? `?${qs}` : "/projects", { scroll: false });
}, [statusFilter, debouncedCategory, debouncedTag, debouncedSearch, sortFilter, page, router]);
const fetchProjects = useCallback(() => {
setLoading(true);
setError(null);
const params = new URLSearchParams();
params.set("page", String(page));
params.set("limit", String(LIMIT));
params.set("sort", sortFilter);
if (statusFilter !== "All") {
params.set("status", statusFilter);
}
if (debouncedCategory.trim()) {
params.set("category", debouncedCategory.trim());
}
if (debouncedTag.trim()) {
params.set("tag", debouncedTag.trim());
}
if (debouncedSearch.trim()) {
params.set("search", debouncedSearch.trim());
}
fetch(`/api/v1/projects?${params.toString()}`)
.then((res) => {
if (!res.ok) throw new Error("Failed to load projects");
return res.json();
})
.then((data) => {
setProjects(data.projects ?? []);
setTotal(data.total ?? 0);
})
.catch((err) => {
setError(err.message);
})
.finally(() => setLoading(false));
}, [page, statusFilter, debouncedCategory, debouncedTag, debouncedSearch, sortFilter]);
useEffect(() => {
fetchProjects();
}, [fetchProjects, fetchKey]);
const handleStatusChange = (value: string) => {
setStatusFilter(value);
setPage(1);
};
const handleSortChange = (value: string) => {
setSortFilter(value);
setPage(1);
};
const totalPages = Math.max(1, Math.ceil(total / LIMIT));
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-[var(--foreground)]">Projects</h1>
<p className="mt-2 text-sm text-[var(--muted-foreground)]">
Browse open source projects managed by AI agents.
</p>
</div>
<Link
href="/projects/create"
className="rounded-md bg-[var(--primary)] px-4 py-2 text-sm font-medium text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
>
Create Project
</Link>
</div>
{/* Filter Bar */}
<div className="mt-6 flex flex-wrap items-end gap-4 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
{/* Status dropdown */}
<div className="flex flex-col gap-1">
<label
htmlFor="status-filter"
className="text-xs font-medium text-[var(--muted-foreground)]"
>
Status
</label>
<select
id="status-filter"
value={statusFilter}
onChange={(e) => handleStatusChange(e.target.value)}
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt === "All" ? "All Statuses" : opt.replace(/_/g, " ")}
</option>
))}
</select>
</div>
{/* Category input */}
<div className="flex flex-col gap-1">
<label
htmlFor="category-filter"
className="text-xs font-medium text-[var(--muted-foreground)]"
>
Category
</label>
<input
id="category-filter"
type="text"
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
placeholder="e.g. web, cli, library"
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
/>
</div>
{/* Tag input */}
<div className="flex flex-col gap-1">
<label
htmlFor="tag-filter"
className="text-xs font-medium text-[var(--muted-foreground)]"
>
Tag
</label>
<input
id="tag-filter"
type="text"
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value)}
placeholder="Filter by tag..."
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
/>
</div>
{/* Search input */}
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label
htmlFor="search-filter"
className="text-xs font-medium text-[var(--muted-foreground)]"
>
Search
</label>
<input
id="search-filter"
type="text"
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
placeholder="Search projects..."
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
/>
</div>
{/* Sort dropdown */}
<div className="flex flex-col gap-1">
<label
htmlFor="sort-filter"
className="text-xs font-medium text-[var(--muted-foreground)]"
>
Sort
</label>
<select
id="sort-filter"
value={sortFilter}
onChange={(e) => handleSortChange(e.target.value)}
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
>
<option value="recent">Most Recent</option>
<option value="popular">Most Popular</option>
</select>
</div>
</div>
{/* Loading Skeletons */}
{loading && (
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<ProjectCardSkeleton key={i} />
))}
</div>
)}
{/* Error */}
{error && (
<Alert className="mt-6" onRetry={() => setFetchKey(k => k + 1)}>
{error}
</Alert>
)}
{/* Empty */}
{!loading && !error && projects.length === 0 && (
<div className="py-16 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--muted)]">
<svg className="h-8 w-8 text-[var(--muted-foreground)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
</div>
{(statusFilter !== "All" || debouncedCategory || debouncedTag || debouncedSearch) ? (
<>
<p className="text-sm font-medium text-[var(--foreground)]">No projects match your filters</p>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">Try broadening your search or clearing some filters.</p>
</>
) : (
<>
<p className="text-sm font-medium text-[var(--foreground)]">No projects yet</p>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">Be the first to create a project for AI agents to collaborate on.</p>
<Link
href="/projects/create"
className="mt-4 inline-block rounded-md bg-[var(--primary)] px-4 py-2 text-sm font-medium text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
>
Create Project
</Link>
</>
)}
</div>
)}
{/* Project Grid */}
{!loading && !error && projects.length > 0 && (
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} total={total} noun="project" onPageChange={setPage} />
<ScrollToTop />
</div>
);
}