Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 140 additions & 5 deletions frontend/app/explore/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
"use client"

import { useState, useEffect, useMemo, useCallback, Suspense } from "react"
import {
useState,
useEffect,
useMemo,
useCallback,
useRef,
Suspense,
type KeyboardEvent,
type MouseEvent,
} from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
Expand Down Expand Up @@ -98,16 +107,36 @@ function PoolCard({
pool,
onRequestJoin,
isJoining,
tabIndex,
cardRef,
onFocus,
onClick,
onKeyDown,
}: {
pool: Pool
onRequestJoin: (poolId: string) => void
isJoining: boolean
tabIndex: number
cardRef: (node: HTMLDivElement | null) => void
onFocus: () => void
onClick: (event: MouseEvent<HTMLDivElement>) => void
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void
}) {
const typeLabel = pool.type.charAt(0).toUpperCase() + pool.type.slice(1)
const statusLabel = pool.status === "active" ? "Active" : pool.status === "completed" ? "Completed" : "Paused"

return (
<motion.div variants={itemAnim}>
<motion.div
ref={cardRef}
variants={itemAnim}
tabIndex={tabIndex}
role="link"
aria-label={`View details for ${pool.name}`}
onFocus={onFocus}
onClick={onClick}
onKeyDown={onKeyDown}
className="h-full cursor-pointer rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Card className="p-6 hover:shadow-lg transition-all duration-300 hover:-translate-y-1 h-full flex flex-col">
<div className="flex items-start justify-between mb-4">
<div className="min-w-0">
Expand Down Expand Up @@ -162,7 +191,10 @@ function PoolCard({
variant="default"
size="sm"
className="flex-1"
onClick={() => onRequestJoin(pool.id)}
onClick={(event) => {
event.stopPropagation()
onRequestJoin(pool.id)
}}
disabled={isJoining || pool.status !== "active"}
>
{isJoining ? (
Expand All @@ -173,7 +205,12 @@ function PoolCard({
Request to Join
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/dashboard/group/${pool.id}`}>View</Link>
<Link
href={`/dashboard/group/${pool.id}`}
onClick={(event) => event.stopPropagation()}
>
View
</Link>
</Button>
</div>
</Card>
Expand All @@ -188,11 +225,14 @@ function ExploreContent() {
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
const gridRef = useRef<HTMLDivElement | null>(null)
const poolCardRefs = useRef<Array<HTMLDivElement | null>>([])

const [pools, setPools] = useState<Pool[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [joining, setJoining] = useState<string | null>(null)
const [focusedPoolIndex, setFocusedPoolIndex] = useState(0)

// Filter state is derived from the URL so it survives refresh and can be shared.
const search = searchParams.get("search") || ""
Expand Down Expand Up @@ -283,6 +323,91 @@ function ExploreContent() {
})
}, [pools, search, filterType, filterStatus])

const activePoolIndex =
filteredPools.length > 0
? Math.min(focusedPoolIndex, filteredPools.length - 1)
: 0

useEffect(() => {
poolCardRefs.current = poolCardRefs.current.slice(0, filteredPools.length)
setFocusedPoolIndex((index) =>
Math.min(index, Math.max(filteredPools.length - 1, 0))
)
}, [filteredPools.length])

const getGridColumnCount = useCallback(() => {
if (!gridRef.current) return 1

const columns = window
.getComputedStyle(gridRef.current)
.gridTemplateColumns.split(" ")
.filter(Boolean).length

return Math.max(columns, 1)
}, [])

const focusPoolCard = useCallback((index: number) => {
poolCardRefs.current[index]?.focus()
}, [])

const handleViewPool = useCallback(
(poolId: string) => {
router.push(`/dashboard/group/${poolId}`)
},
[router]
)

const handlePoolCardClick = useCallback(
(event: MouseEvent<HTMLDivElement>, poolId: string) => {
if ((event.target as HTMLElement).closest("a,button")) return
handleViewPool(poolId)
},
[handleViewPool]
)

const handlePoolCardKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>, index: number, poolId: string) => {
if (event.target !== event.currentTarget) return

const columnCount = getGridColumnCount()
const columnIndex = index % columnCount
let nextIndex = index

if (event.key === "ArrowLeft") {
event.preventDefault()
if (columnIndex > 0) nextIndex = index - 1
} else if (event.key === "ArrowRight") {
event.preventDefault()
if (columnIndex < columnCount - 1 && index < filteredPools.length - 1) {
nextIndex = index + 1
}
} else if (event.key === "ArrowUp") {
event.preventDefault()
if (index - columnCount >= 0) nextIndex = index - columnCount
} else if (event.key === "ArrowDown") {
event.preventDefault()
if (index + columnCount < filteredPools.length) {
nextIndex = index + columnCount
}
} else if (event.key === "Enter") {
handleViewPool(poolId)
return
} else if (event.key === " " || event.key === "Spacebar") {
event.preventDefault()
handleViewPool(poolId)
return
} else {
return
}

if (nextIndex !== index) {
setFocusedPoolIndex(nextIndex)
focusPoolCard(nextIndex)
}
},
[filteredPools.length, focusPoolCard, getGridColumnCount, handleViewPool]
)

// Join request handler
const handleRequestJoin = useCallback(
async (poolId: string) => {
Expand Down Expand Up @@ -404,17 +529,27 @@ function ExploreContent() {
{/* Pool Grid */}
{!loading && filteredPools.length > 0 && (
<motion.div
ref={gridRef}
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
{filteredPools.map((pool) => (
{filteredPools.map((pool, index) => (
<PoolCard
key={pool.id}
pool={pool}
onRequestJoin={handleRequestJoin}
isJoining={joining === pool.id}
tabIndex={index === activePoolIndex ? 0 : -1}
cardRef={(node) => {
poolCardRefs.current[index] = node
}}
onFocus={() => setFocusedPoolIndex(index)}
onClick={(event) => handlePoolCardClick(event, pool.id)}
onKeyDown={(event) =>
handlePoolCardKeyDown(event, index, pool.id)
}
/>
))}
</motion.div>
Expand Down
Loading