From 0bd369fd8633bd7d211dd5971c2b920fc612d99b Mon Sep 17 00:00:00 2001 From: Harkirat Date: Fri, 5 Jun 2026 17:36:09 +0530 Subject: [PATCH 1/2] feat: add org wishlist with heart toggle, localStorage persistence and filter chip --- .../student/opensource/GSoCReposPage.tsx | 297 +++++++++++++++--- 1 file changed, 249 insertions(+), 48 deletions(-) diff --git a/client/src/module/student/opensource/GSoCReposPage.tsx b/client/src/module/student/opensource/GSoCReposPage.tsx index cb0519b9a..d82e6aeca 100644 --- a/client/src/module/student/opensource/GSoCReposPage.tsx +++ b/client/src/module/student/opensource/GSoCReposPage.tsx @@ -18,6 +18,7 @@ import { Lightbulb, BookOpen, ArrowUpRight, + Heart, } from "lucide-react"; import api from "../../../lib/axios"; import { queryKeys } from "../../../lib/query-keys"; @@ -27,6 +28,31 @@ import { SEO } from "../../../components/SEO"; import { canonicalUrl } from "../../../lib/seo.utils"; import type { GSoCOrganization, GSoCStats } from "../../../lib/types"; +const WISHLIST_KEY = "gsoc_wishlist"; + +function useWishlist() { + const [wishlist, setWishlist] = useState(() => { + try { + return JSON.parse(localStorage.getItem(WISHLIST_KEY) ?? "[]"); + } catch { + return []; + } + }); + + const toggle = (id: number) => { + setWishlist((prev) => { + const next = prev.includes(id) + ? prev.filter((x) => x !== id) + : [...prev, id]; + localStorage.setItem(WISHLIST_KEY, JSON.stringify(next)); + return next; + }); + }; + + const has = (id: number) => wishlist.includes(id); + return { wishlist, toggle, has }; +} + const cardBase = "group relative flex h-full w-full flex-col rounded-md border border-stone-200 bg-white p-5 text-left transition-colors hover:border-stone-400 dark:border-white/10 dark:bg-stone-900 dark:hover:border-white/30"; @@ -57,7 +83,13 @@ function OrgMark({ org }: { org: GSoCOrganization }) { ); } -function MetaChip({ icon, children }: { icon: ReactNode; children: ReactNode }) { +function MetaChip({ + icon, + children, +}: { + icon: ReactNode; + children: ReactNode; +}) { return ( {icon} @@ -66,7 +98,13 @@ function MetaChip({ icon, children }: { icon: ReactNode; children: ReactNode }) ); } -function PlainChip({ children, accent = false }: { children: ReactNode; accent?: boolean }) { +function PlainChip({ + children, + accent = false, +}: { + children: ReactNode; + accent?: boolean; +}) { return ( -

No organizations found

+

+ No organizations found +

try different search criteria

@@ -146,7 +186,17 @@ function FilterDropdown({ ); } -function GSoCOrgCard({ org, onClick }: { org: GSoCOrganization; onClick: () => void }) { +function GSoCOrgCard({ + org, + onClick, + wishlisted, + onWishlistToggle, +}: { + org: GSoCOrganization; + onClick: () => void; + wishlisted: boolean; + onWishlistToggle: (e: React.MouseEvent) => void; +}) { const years = [...org.yearsParticipated].sort((a, b) => b - a); return ( @@ -171,7 +221,9 @@ function GSoCOrgCard({ org, onClick }: { org: GSoCOrganization; onClick: () => v
}> {years[0] ?? "new"} - {years.length > 1 && +{years.length - 1}} + {years.length > 1 && ( + +{years.length - 1} + )} }> {org.technologies.length || 0} tech @@ -183,10 +235,30 @@ function GSoCOrgCard({ org, onClick }: { org: GSoCOrganization; onClick: () => v {org.technologies.slice(0, 4).map((tech) => ( {tech} ))} - {org.technologies.length > 4 && +{org.technologies.length - 4}} + {org.technologies.length > 4 && ( + +{org.technologies.length - 4} + )}
)} + +
inspect org @@ -203,12 +275,23 @@ interface GSoCOrgModalProps { githubRepos: { title: string; url: string }[]; gsocPageUrl: string | null; reposLoading: boolean; + wishlisted: boolean; + onWishlistToggle: () => void; } -function GSoCOrgModal({ org, onClose, githubRepos, gsocPageUrl, reposLoading }: GSoCOrgModalProps) { +function GSoCOrgModal({ + org, + onClose, + githubRepos, + gsocPageUrl, + reposLoading, + wishlisted, + onWishlistToggle, +}: GSoCOrgModalProps) { const [selectedYear, setSelectedYear] = useState(null); const years = [...org.yearsParticipated].sort((a, b) => b - a); const activeYear = selectedYear || (years[0] ? String(years[0]) : null); - const yearData = activeYear && org.projectsData ? org.projectsData[activeYear] : null; + const yearData = + activeYear && org.projectsData ? org.projectsData[activeYear] : null; return (
+ ); })} @@ -325,7 +428,10 @@ function GSoCOrgModal({ org, onClose, githubRepos, gsocPageUrl, reposLoading }: {yearData?.projects && yearData.projects.length > 0 && (
{yearData.projects.map((project, index) => ( -
+
@@ -362,25 +468,43 @@ function GSoCOrgModal({ org, onClose, githubRepos, gsocPageUrl, reposLoading }:
{org.contactEmail && ( - + Contact )} {org.mailingList && ( - + Mailing List )} {org.ideasUrl && ( - + Project Ideas )} {org.guideUrl && ( - + Contributor Guide @@ -455,7 +579,12 @@ export default function GSoCReposPage() { const [selectedYear, setSelectedYear] = useState("All"); const [page, setPage] = useState(1); const [selectedOrg, setSelectedOrg] = useState(null); - const [timer, setTimer] = useState | null>(null); + const [timer, setTimer] = useState | null>( + null, + ); + const { wishlist, toggle, has } = useWishlist(); + const [showWishlist, setShowWishlist] = useState(false); + const limit = 18; const handleSearch = (value: string) => { @@ -465,7 +594,7 @@ export default function GSoCReposPage() { setTimeout(() => { setDebouncedSearch(value); setPage(1); - }, 400) + }, 400), ); }; @@ -483,34 +612,58 @@ export default function GSoCReposPage() { const { data, isLoading } = useQuery({ queryKey: queryKeys.gsoc.list(params), - queryFn: () => api.get("/gsoc/organizations", { params }).then((res) => res.data), + queryFn: () => + api.get("/gsoc/organizations", { params }).then((res) => res.data), }); const organizations: GSoCOrganization[] = data?.organizations ?? []; + const filteredOrganizations = showWishlist + ? organizations.filter((org) => has(org.id)) + : organizations; const pagination = data?.pagination ?? { page: 1, total: 0, totalPages: 1 }; const { data: detailData } = useQuery({ queryKey: queryKeys.gsoc.detail(selectedOrg?.slug ?? ""), - queryFn: () => api.get(`/gsoc/organizations/${selectedOrg!.slug}`).then((res) => res.data), + queryFn: () => + api + .get(`/gsoc/organizations/${selectedOrg!.slug}`) + .then((res) => res.data), enabled: !!selectedOrg, }); - const detailOrg: GSoCOrganization | null = detailData?.organization ?? selectedOrg; + const detailOrg: GSoCOrganization | null = + detailData?.organization ?? selectedOrg; const { data: reposData, isLoading: reposLoading } = useQuery({ queryKey: queryKeys.gsoc.repos(selectedOrg?.slug ?? ""), - queryFn: () => api.get(`/gsoc/organizations/${selectedOrg!.slug}/repos`).then((res) => res.data), + queryFn: () => + api + .get(`/gsoc/organizations/${selectedOrg!.slug}/repos`) + .then((res) => res.data), enabled: !!selectedOrg, staleTime: 1000 * 60 * 60, }); - const githubRepos: { title: string; url: string }[] = reposData?.githubRepos ?? []; + const githubRepos: { title: string; url: string }[] = + reposData?.githubRepos ?? []; const gsocPageUrl: string | null = reposData?.gsocPageUrl ?? null; - const categoryOptions = ["All", ...(stats?.categories.map((category) => category.name) ?? [])]; - const yearOptions = ["All", ...(stats?.years.map((year) => String(year.year)) ?? [])]; - const techOptions = ["All", ...(stats?.technologies.slice(0, 30).map((tech) => tech.name) ?? [])]; + const categoryOptions = [ + "All", + ...(stats?.categories.map((category) => category.name) ?? []), + ]; + const yearOptions = [ + "All", + ...(stats?.years.map((year) => String(year.year)) ?? []), + ]; + const techOptions = [ + "All", + ...(stats?.technologies.slice(0, 30).map((tech) => tech.name) ?? []), + ]; const hasFilters = - Boolean(debouncedSearch) || selectedCategory !== "All" || selectedTech !== "All" || selectedYear !== "All"; + Boolean(debouncedSearch) || + selectedCategory !== "All" || + selectedTech !== "All" || + selectedYear !== "All"; const clearFilters = () => { setSearch(""); @@ -549,20 +702,30 @@ export default function GSoCReposPage() {

- Search accepted organizations, study their past projects, and find a community where your stack fits. + Search accepted organizations, study their past projects, and + find a community where your stack fits.

- {stats?.total ?? "-"} orgs + + {stats?.total ?? "-"} + {" "} + orgs - {stats?.categories.length ?? "-"} categories + + {stats?.categories.length ?? "-"} + {" "} + categories - 2016-2026 years + + 2016-2026 + {" "} + years
@@ -619,17 +782,43 @@ export default function GSoCReposPage() { clear )} +

- {pagination.total} organizations + + {pagination.total} + {" "} + organizations {hasFilters && ( <> - {" "} / filtered + {" "} + /{" "} + + filtered + + + )} + {pagination.totalPages > 1 && ( + <> + {" "} + / page {pagination.page} of {pagination.totalPages} )} - {pagination.totalPages > 1 && <> / page {pagination.page} of {pagination.totalPages}}

@@ -638,9 +827,12 @@ export default function GSoCReposPage() { ) : organizations.length === 0 ? ( ) : ( - + - {organizations.map((org, index) => ( + {filteredOrganizations.map((org, index) => ( - setSelectedOrg(org)} /> + setSelectedOrg(org)} + wishlisted={has(org.id)} + onWishlistToggle={(e) => { + e.stopPropagation(); + toggle(org.id); + }} + /> ))} @@ -662,19 +862,20 @@ export default function GSoCReposPage() { onPageChange={setPage} showingInfo={{ total: pagination.total, limit }} /> -
{detailOrg && selectedOrg && ( - setSelectedOrg(null)} - githubRepos={githubRepos} - gsocPageUrl={gsocPageUrl} - reposLoading={reposLoading} - /> - )} + setSelectedOrg(null)} + githubRepos={githubRepos} + gsocPageUrl={gsocPageUrl} + reposLoading={reposLoading} + wishlisted={has(detailOrg.id)} + onWishlistToggle={() => toggle(detailOrg.id)} + /> + )}
); From 7e516d0c56fda1b5200e509ccfc2acee18b2592c Mon Sep 17 00:00:00 2001 From: Harkirat Date: Fri, 5 Jun 2026 17:50:14 +0530 Subject: [PATCH 2/2] fix: replace nested button with div role=button to fix invalid HTML structure --- client/src/module/student/opensource/GSoCReposPage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/module/student/opensource/GSoCReposPage.tsx b/client/src/module/student/opensource/GSoCReposPage.tsx index 60adac096..e9edb9e77 100644 --- a/client/src/module/student/opensource/GSoCReposPage.tsx +++ b/client/src/module/student/opensource/GSoCReposPage.tsx @@ -34,7 +34,8 @@ const WISHLIST_KEY = "gsoc_wishlist"; function useWishlist() { const [wishlist, setWishlist] = useState(() => { try { - return JSON.parse(localStorage.getItem(WISHLIST_KEY) ?? "[]"); + const parsed = JSON.parse(localStorage.getItem(WISHLIST_KEY) ?? "[]"); + return Array.isArray(parsed) ? parsed : []; } catch { return []; } @@ -226,7 +227,7 @@ function GSoCOrgCard({ org, onClick }: { org: GSoCOrganization; onClick: () => v const years = [...org.yearsParticipated].sort((a, b) => b - a); return ( - + ); }