diff --git a/apps/web/src/components/dashboard/ProjectsContainer.tsx b/apps/web/src/components/dashboard/ProjectsContainer.tsx index 80e380ac..87caa806 100644 --- a/apps/web/src/components/dashboard/ProjectsContainer.tsx +++ b/apps/web/src/components/dashboard/ProjectsContainer.tsx @@ -1,5 +1,6 @@ "use client"; +import React, { useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -16,6 +17,7 @@ import Image from "next/image"; import { useFilterStore } from "@/store/useFilterStore"; import { usePathname } from "next/navigation"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { ChevronUpIcon, ChevronDownIcon, ArrowsUpDownIcon } from "@heroicons/react/24/solid"; type ProjectsContainerProps = { projects: DashboardProjectsProps[] }; @@ -42,23 +44,215 @@ const getColor = (c?: string) => languageColors[(c || "").toLowerCase()] || "bg-gray-200/10 text-gray-300"; const tableColumns = [ - "Project", - "Issues", - "Language", - "Popularity", - "Stage", - "Competition", - "Activity", + { key: "name", label: "Project", sortable: true }, + { key: "issues", label: "Issues", sortable: true }, + { key: "language", label: "Language", sortable: true }, + { key: "popularity", label: "Popularity", sortable: true }, + { key: "stage", label: "Stage", sortable: true }, + { key: "competition", label: "Competition", sortable: true }, + { key: "activity", label: "Activity", sortable: true }, ]; -export default function ProjectsContainer({ - projects, -}: ProjectsContainerProps) { +export default function ProjectsContainer({ projects }: ProjectsContainerProps) { const pathname = usePathname(); const { projectTitle } = useProjectTitleStore(); const { setShowFilters } = useFilterStore(); const isProjectsPage = pathname === "/dashboard/projects"; + // --- Sort state --- + // default sort: issues descending (more -> 0) + const [sortColumn, setSortColumn] = useState("issues"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + + const handleSort = (col: string) => { + if (sortColumn === col) { + setSortDirection((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortColumn(col); + // default direction: name -> asc, issues -> desc, others asc by default + setSortDirection(col === "issues" ? "desc" : "asc"); + } + }; + + // allow keyboard activation on header cells + const handleHeaderKey = (e: React.KeyboardEvent, colKey: string) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSort(colKey); + } + }; + + // --- Filter state --- + const [query, setQuery] = useState(""); + const [language, setLanguage] = useState("all"); + const [stage, setStage] = useState("all"); + const [competition, setCompetition] = useState("all"); + const [activity, setActivity] = useState("all"); + const [minIssues, setMinIssues] = useState(""); + const [maxIssues, setMaxIssues] = useState(""); + const [popularity, setPopularity] = useState("all"); + + // derive unique filter options from incoming projects + const options = useMemo(() => { + const langs = new Set(); + const stages = new Set(); + const comps = new Set(); + const acts = new Set(); + const pops = new Set(); + + projects.forEach((p) => { + if (p.primaryLanguage) langs.add(p.primaryLanguage); + if (p.stage) stages.add(p.stage); + if (p.competition) comps.add(p.competition); + if (p.activity) acts.add(p.activity); + if (p.popularity !== undefined && p.popularity !== null) pops.add(String(p.popularity)); + }); + + return { + languages: Array.from(langs).sort((a, b) => a.localeCompare(b)), + stages: Array.from(stages).sort((a, b) => a.localeCompare(b)), + competitions: Array.from(comps).sort((a, b) => a.localeCompare(b)), + activities: Array.from(acts).sort((a, b) => a.localeCompare(b)), + popularity: Array.from(pops).sort((a, b) => Number(a) - Number(b)), + }; + }, [projects]); + + // --- Filtered result --- + const filtered = useMemo(() => { + return projects.filter((p) => { + // Project name / repo search + if (query && !p.name.toLowerCase().includes(query.toLowerCase())) return false; + + // Language + if (language !== "all" && (p.primaryLanguage || "").toLowerCase() !== language.toLowerCase()) + return false; + + // Stage + if (stage !== "all" && (p.stage || "").toLowerCase() !== stage.toLowerCase()) return false; + + // Competition + if (competition !== "all" && (p.competition || "").toLowerCase() !== competition.toLowerCase()) + return false; + + // Activity + if (activity !== "all" && (p.activity || "").toLowerCase() !== activity.toLowerCase()) return false; + + // Issues range + const issues = Number(p.totalIssueCount ?? 0); + if (minIssues !== "") { + const mi = Number(minIssues); + if (!Number.isFinite(mi) || issues < mi) return false; + } + if (maxIssues !== "") { + const ma = Number(maxIssues); + if (!Number.isFinite(ma) || issues > ma) return false; + } + + // Popularity (string compare because options built as strings) + if (popularity !== "all" && String(p.popularity) !== popularity) return false; + + return true; + }); + }, [ + projects, + query, + language, + stage, + competition, + activity, + minIssues, + maxIssues, + popularity, + ]); + + // --- Sorted result (apply after filters) --- + const sorted = useMemo(() => { + const list = [...filtered]; + + const getStr = (v: any) => (v == null ? "" : String(v).toLowerCase()); + const getNum = (v: any) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + }; + + list.sort((a, b) => { + const dir = sortDirection === "asc" ? 1 : -1; + + switch (sortColumn) { + case "name": { + const A = getStr(a.name); + const B = getStr(b.name); + if (A < B) return -1 * dir; + if (A > B) return 1 * dir; + return 0; + } + + case "issues": { + const A = getNum(a.totalIssueCount); + const B = getNum(b.totalIssueCount); + if (A < B) return -1 * dir; + if (A > B) return 1 * dir; + // tie-break by name asc to make order deterministic + return getStr(a.name).localeCompare(getStr(b.name)); + } + + case "language": { + const A = getStr(a.primaryLanguage); + const B = getStr(b.primaryLanguage); + if (A < B) return -1 * dir; + if (A > B) return 1 * dir; + return getStr(a.name).localeCompare(getStr(b.name)); + } + + case "popularity": { + const A = getNum(a.popularity); + const B = getNum(b.popularity); + if (A < B) return -1 * dir; + if (A > B) return 1 * dir; + return getStr(a.name).localeCompare(getStr(b.name)); + } + + case "stage": + case "competition": + case "activity": { + const A = getStr((a as any)[sortColumn]); + const B = getStr((b as any)[sortColumn]); + if (A < B) return -1 * dir; + if (A > B) return 1 * dir; + return getStr(a.name).localeCompare(getStr(b.name)); + } + + default: + return 0; + } + }); + + return list; + }, [filtered, sortColumn, sortDirection]); + + const resetFilters = () => { + setQuery(""); + setLanguage("all"); + setStage("all"); + setCompetition("all"); + setActivity("all"); + setMinIssues(""); + setMaxIssues(""); + setPopularity("all"); + // Keep sort state as-is (you can reset sort here if you want) + }; + + const renderSortIcon = (colKey: string) => { + if (sortColumn !== colKey) { + return ; + } + return sortDirection === "asc" ? ( + + ) : ( + + ); + }; + return (
@@ -66,22 +260,129 @@ export default function ProjectsContainer({ {projectTitle} {isProjectsPage && ( - +
+ +
)}
- {projects && projects.length > 0 ? ( + {/* Filters bar */} + {isProjectsPage && ( +
+ setQuery(e.target.value)} + placeholder="Search projects..." + className="px-3 py-2 rounded-md border bg-ox-content text-text-primary min-w-[200px]" + aria-label="Search projects" + /> + + + + + + + + + +
+ setMinIssues(e.target.value)} + placeholder="Min issues" + className="px-2 py-1 rounded-md border w-24 bg-ox-content" + aria-label="Minimum issues" + /> + setMaxIssues(e.target.value)} + placeholder="Max issues" + className="px-2 py-1 rounded-md border w-24 bg-ox-content" + aria-label="Maximum issues" + /> +
+ + + + +
+ )} + + {sorted && sorted.length > 0 ? (
- {tableColumns.map((name, i) => ( - - {name} - - ))} + {tableColumns.map((col, i) => { + const isSortable = !!col.sortable; + const ariaSort = + isSortable && sortColumn === col.key + ? sortDirection === "asc" + ? "ascending" + : "descending" + : "none"; + + // align first column left + const alignmentClass = i === 0 ? "text-left" : "text-center"; + + return ( + isSortable && handleSort(col.key)} + onKeyDown={(e) => isSortable && handleHeaderKey(e as React.KeyboardEvent, col.key)} + role={isSortable ? "button" : undefined} + tabIndex={isSortable ? 0 : -1} + aria-sort={ariaSort} + aria-label={isSortable ? `${col.label} sortable` : col.label} + > +
+ {col.label} + {isSortable && {renderSortIcon(col.key)}} +
+
+ ); + })} - {projects.map((p) => ( + {sorted.map((p) => (
- {p.name} + {p.name}
- - {p.name} - + {p.name}
- {p.totalIssueCount} + {Number(p.totalIssueCount ?? 0)} - - {p.primaryLanguage} + + {p.primaryLanguage || "—"} - {p.popularity} + {p.popularity ?? "—"} - {p.stage} + {p.stage ?? "—"} - {p.competition} + {p.competition ?? "—"} - {p.activity} + {p.activity ?? "—"}
))} @@ -169,8 +483,7 @@ export default function ProjectsContainer({

Find Your Next Project

- Click the 'Find projects' button above to discover open - source projects that match your interests + Click the 'Find projects' button above to discover open source projects that match your interests

) : null}