From cfd63bd2b0694ae9058879f8cb889c8e744d1629 Mon Sep 17 00:00:00 2001 From: Bhavya Reddy Date: Tue, 9 Jun 2026 17:49:28 +0530 Subject: [PATCH] feat: implement interactive issues explorer page --- src/app/(app)/issues/issues-list.tsx | 798 +++++++++++++++++---------- src/app/(app)/issues/page.tsx | 30 +- src/app/actions/issues.ts | 50 +- 3 files changed, 578 insertions(+), 300 deletions(-) diff --git a/src/app/(app)/issues/issues-list.tsx b/src/app/(app)/issues/issues-list.tsx index d98d6a43..88e09d36 100644 --- a/src/app/(app)/issues/issues-list.tsx +++ b/src/app/(app)/issues/issues-list.tsx @@ -1,32 +1,98 @@ 'use client'; -import { useSearchParams, useRouter } from 'next/navigation'; -import { useState, useTransition, useCallback, useEffect, useRef } from 'react'; -import { Search, ExternalLink, ChevronLeft, ChevronRight, Copy, Check } from 'lucide-react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useMemo, useState, useTransition } from 'react'; +import { + ArrowRight, + Check, + CheckSquare, + ChevronLeft, + ChevronRight, + Clock3, + Copy, + ExternalLink, + Filter, + GitPullRequest, + LayoutDashboard, + Lock, + Search, + ShieldCheck, + SlidersHorizontal, + Sparkles, + SquareCode, + X, +} from 'lucide-react'; import { claimIssue, unclaimIssue, - type IssueWithStatus, type IssueFilter, type IssuesPageResult, + type IssueWithStatus, type RepoOption, } from '@/app/actions/issues'; -const DIFFICULTY_LABEL: Record = { E: 'L1', M: 'L2', H: 'L3' }; -const DIFFICULTY_COLOR: Record = { - E: 'border-emerald-700 text-emerald-400', - M: 'border-yellow-700 text-yellow-400', - H: 'border-red-800 text-red-400', -}; +const CATEGORIES = [ + { label: 'All', value: '' }, + { label: 'Bugs', value: 'bugs' }, + { label: 'Docs', value: 'docs' }, + { label: 'Feature', value: 'feature' }, + { label: 'Tests', value: 'tests' }, +]; + +const DIFFICULTIES = [ + { label: 'EASY', value: 'E', className: 'border-emerald-400 bg-[#00FF87] text-black' }, + { label: 'MEDIUM', value: 'M', className: 'border-yellow-300 bg-yellow-300 text-black' }, + { label: 'HARD', value: 'H', className: 'border-red-300 bg-red-300/50 text-red-950' }, +]; + +const LEVELS = [ + { label: 'L0', difficulty: '', requiredLevel: 0 }, + { label: 'L1', difficulty: 'E', requiredLevel: 0 }, + { label: 'L2', difficulty: 'M', requiredLevel: 2 }, + { label: 'L3', difficulty: 'H', requiredLevel: 3 }, +]; + +const SYSTEM_NAV = [ + { label: 'Dashboard', icon: LayoutDashboard }, + { label: 'Repositories', icon: SquareCode }, + { label: 'Issues', icon: CheckSquare, active: true }, + { label: 'Pull Requests', icon: GitPullRequest }, + { label: 'Pipelines', icon: SlidersHorizontal }, + { label: 'Security', icon: ShieldCheck }, +]; + +function parseList(value?: string) { + return [ + ...new Set( + (value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ), + ]; +} + +function toggleValue(values: string[], value: string) { + return values.includes(value) ? values.filter((item) => item !== value) : [...values, value]; +} function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); + const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); - if (days === 0) return 'today'; + if (hours < 1) return 'now'; + if (hours < 24) return `${hours}h ago`; if (days === 1) return '1d ago'; if (days < 30) return `${days}d ago`; - const months = Math.floor(days / 30); - return `${months}mo ago`; + return `${Math.floor(days / 30)}mo ago`; +} + +function truncateExcerpt(issue: IssueWithStatus) { + if (issue.bodyExcerpt?.trim()) return issue.bodyExcerpt.trim(); + const labels = issue.labels?.slice(0, 3).join(', '); + return labels + ? `Tagged with ${labels}. Open the issue to inspect scope, context, and acceptance details.` + : 'Open the issue to inspect scope, context, and acceptance details before claiming.'; } function IssueCard({ @@ -40,76 +106,52 @@ function IssueCard({ onUnclaim: (recId: number) => void; actionPending: boolean; }) { - const isClaimed = issue.userRecStatus === 'claimed'; - const repoName = issue.repoFullName.split('/')[1] ?? issue.repoFullName; - const org = issue.repoFullName.split('/')[0] ?? ''; - const [copied, setCopied] = useState(false); + const isClaimed = issue.userRecStatus === 'claimed'; + const difficulty = DIFFICULTIES.find((item) => item.value === issue.difficulty); + const mentorInitials = (issue.repoFullName.split('/')[0] ?? 'MS').slice(0, 2).toUpperCase(); - const handleCopy = async () => { + async function handleCopy() { await navigator.clipboard.writeText(issue.url); - setCopied(true); - - setTimeout(() => { - setCopied(false); - }, 1500); - }; + setTimeout(() => setCopied(false), 1500); + } return ( -
-
-
- - {org} - - / - - {repoName} - - {issue.difficulty && ( - - {DIFFICULTY_LABEL[issue.difficulty] ?? issue.difficulty} - - )} - {isClaimed && ( - - CLAIMED - - )} +
+
+
+ + {issue.repoFullName} + + #{issue.githubIssueNumber}
- - - {timeAgo(issue.fetchedAt)} - + {difficulty && ( + + {difficulty.label} + + )}
{issue.title} +

{truncateExcerpt(issue)}

+ {issue.labels && issue.labels.length > 0 && ( -
- {issue.labels.slice(0, 4).map((label) => ( +
+ {issue.labels.slice(0, 5).map((label) => ( {label} @@ -117,87 +159,177 @@ function IssueCard({
)} -
- {isClaimed ? ( - <> - - YOUR ISSUE - - - - VIEW - - - - - - - ) : ( - <> - - - - VIEW - - - - - )} - +
+ + + {timeAgo(issue.fetchedAt)} + + + {mentorInitials} + {issue.xpReward && ( - + +{issue.xpReward} XP )} + {isClaimed && ( + + )} + {!isClaimed && ( + + )} + + + View Issue +
+
+ ); +} + +function FilterPanel({ + repoOptions, + selectedRepos, + selectedDifficulties, + state, + solvedIssues, + onRepoToggle, + onDifficultyToggle, + onStateChange, + onClear, +}: { + repoOptions: RepoOption[]; + selectedRepos: string[]; + selectedDifficulties: string[]; + state: 'open' | 'closed'; + solvedIssues: number; + onRepoToggle: (repo: string) => void; + onDifficultyToggle: (difficulty: string) => void; + onStateChange: (state: 'open' | 'closed') => void; + onClear: () => void; +}) { + const progressGoal = 7; + const progress = Math.min(100, Math.round((solvedIssues / progressGoal) * 100)); + const remaining = Math.max(0, progressGoal - solvedIssues); + + return ( +
+
+
+ + L1 Progress +
+
+ Solved + + {solvedIssues} + /{progressGoal} + +
+
+
+
+
+ {remaining} issues away from L2 unlock +
+
+ +
+
+

+ + Refine Feed +

+ +
+ +
+
Repositories
+
+ {repoOptions.length === 0 ? ( +

No repositories connected yet.

+ ) : ( + repoOptions.map((repo) => { + const checked = selectedRepos.length === 0 || selectedRepos.includes(repo.value); + return ( + + ); + }) + )} +
+
+ +
+
Difficulty
+
+ {DIFFICULTIES.map((difficulty) => { + const active = + selectedDifficulties.length === 0 || selectedDifficulties.includes(difficulty.value); + return ( + + ); + })} +
+
+ +
+
Status
+
+ {(['open', 'closed'] as const).map((item) => ( + + ))} +
+
+
); } @@ -206,22 +338,35 @@ export function IssuesList({ initialData, initialFilters, repoOptions, + currentUserLevel, + solvedIssues, }: { initialData: IssuesPageResult; initialFilters: IssueFilter; repoOptions: RepoOption[]; + currentUserLevel: number; + solvedIssues: number; }) { const router = useRouter(); const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); const [actionIssueId, setActionIssueId] = useState(null); const [actionError, setActionError] = useState(null); - + const [filtersOpen, setFiltersOpen] = useState(false); const [search, setSearch] = useState(initialFilters.search ?? ''); - const [state, setState] = useState<'open' | 'closed'>(initialFilters.state ?? 'open'); - const [difficulty, setDifficulty] = useState(initialFilters.difficulty ?? ''); - const [repo, setRepo] = useState(initialFilters.repo ?? ''); - const [showClaimed, setShowClaimed] = useState(initialFilters.showClaimed ?? false); + + const state = initialFilters.state ?? 'open'; + const selectedRepos = parseList(initialFilters.repo); + const selectedDifficulties = parseList(initialFilters.difficulty); + const selectedCategory = initialFilters.category ?? ''; + const currentPage = initialData.page; + const totalPages = Math.ceil(initialData.total / initialData.pageSize); + const unlockRemaining = Math.max(0, 7 - solvedIssues); + + const activeLevel = useMemo(() => { + const diff = selectedDifficulties.length === 1 ? selectedDifficulties[0] : ''; + return LEVELS.find((level) => level.difficulty === diff)?.label ?? 'L0'; + }, [selectedDifficulties]); const navigate = useCallback( ( @@ -230,35 +375,42 @@ export function IssuesList({ state: string; difficulty: string; repo: string; + category: string; claimed: string; page: string; }>, ) => { const params = new URLSearchParams(searchParams.toString()); - const q = overrides.q ?? search; - const st = overrides.state ?? state; - const diff = overrides.difficulty ?? difficulty; - const r = overrides.repo ?? repo; - const sc = overrides.claimed ?? String(showClaimed); - const pg = overrides.page ?? '1'; - if (q) params.set('q', q); - if (st !== 'open') params.set('state', st); - if (diff) params.set('difficulty', diff); - if (r) params.set('repo', r); - if (sc === 'true') { - params.set('claimed', 'true'); - } else { - params.delete('claimed'); - } - if (pg !== '1') params.set('page', pg); + const values = { + q: overrides.q ?? search, + state: overrides.state ?? state, + difficulty: overrides.difficulty ?? selectedDifficulties.join(','), + repo: overrides.repo ?? selectedRepos.join(','), + category: overrides.category ?? selectedCategory, + page: overrides.page ?? '1', + }; + + if (values.q) params.set('q', values.q); + else params.delete('q'); + if (values.state !== 'open') params.set('state', values.state); + else params.delete('state'); + if (values.difficulty) params.set('difficulty', values.difficulty); + else params.delete('difficulty'); + if (values.repo) params.set('repo', values.repo); + else params.delete('repo'); + if (values.category) params.set('category', values.category); + else params.delete('category'); + if (values.page !== '1') params.set('page', values.page); + else params.delete('page'); + startTransition(() => { router.push(`/issues${params.size > 0 ? `?${params.toString()}` : ''}`); }); }, - [router, search, state, difficulty, repo, showClaimed], + [router, search, searchParams, selectedCategory, selectedDifficulties, selectedRepos, state], ); - const handleClaim = async (issueId: number) => { + async function handleClaim(issueId: number) { setActionIssueId(issueId); setActionError(null); const result = await claimIssue(issueId); @@ -268,9 +420,9 @@ export function IssuesList({ return; } router.refresh(); - }; + } - const handleUnclaim = async (recId: number, issueId: number) => { + async function handleUnclaim(recId: number, issueId: number) { setActionIssueId(issueId); setActionError(null); const result = await unclaimIssue(recId); @@ -280,136 +432,220 @@ export function IssuesList({ return; } router.refresh(); - }; - - const totalPages = Math.ceil(initialData.total / initialData.pageSize); - const currentPage = initialData.page; + } + + function handleRepoToggle(repo: string) { + const allRepos = repoOptions.map((option) => option.value); + const current = selectedRepos.length === 0 ? allRepos : selectedRepos; + const next = toggleValue(current, repo); + navigate({ repo: next.length === allRepos.length ? '' : next.join(','), page: '1' }); + } + + function handleDifficultyToggle(difficulty: string) { + const allDifficulties = DIFFICULTIES.map((item) => item.value); + const current = selectedDifficulties.length === 0 ? allDifficulties : selectedDifficulties; + const next = toggleValue(current, difficulty); + navigate({ + difficulty: next.length === allDifficulties.length ? '' : next.join(','), + page: '1', + }); + } + + const panel = ( + navigate({ state: nextState, page: '1' })} + onClear={() => { + setSearch(''); + navigate({ q: '', repo: '', difficulty: '', category: '', state: 'open', page: '1' }); + }} + /> + ); return ( -
- {/* Filters */} -
-
- - setSearch(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && navigate({ q: search, page: '1' })} - className="w-full border border-[#2d333b] bg-[#161b22] py-2 pl-9 pr-4 text-[11px] uppercase tracking-widest text-zinc-300 placeholder-zinc-600 outline-none focus:border-zinc-500" - /> +
+
+
+ {CATEGORIES.map((category) => ( + ))} - - )} - - - - - - -
+
+
- {actionError && ( -
- {actionError} +
+ {LEVELS.map((level) => { + const locked = currentUserLevel < level.requiredLevel; + const active = activeLevel === level.label; + return ( + + ); + })} + + + Solve {unlockRemaining} more to unlock L2 +
- )} - {/* Count */} -
- {isPending ? 'LOADING...' : `${initialData.total} ISSUES`} -
+ {actionError && ( +
+ {actionError} +
+ )} - {/* List */} -
- {initialData.issues.length === 0 ? ( -
- No issues found. +
+ {initialData.issues.length === 0 ? ( +
+ No issues found for this filter set. +
+ ) : ( + initialData.issues.map((issue) => ( + handleUnclaim(recId, issue.id)} + actionPending={actionIssueId === issue.id} + /> + )) + )} +
+ + {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + +
- ) : ( - initialData.issues.map((issue) => ( - handleUnclaim(recId, issue.id)} - actionPending={actionIssueId === issue.id} - /> - )) )} -
+ + + - {/* Pagination */} - {totalPages > 1 && ( -
+ {filtersOpen && ( +
- - {currentPage} / {totalPages} - - + className="absolute inset-0 bg-black/70" + onClick={() => setFiltersOpen(false)} + aria-label="Close filters" + /> +
+
+

Filters

+ +
+ {panel} +
)}
diff --git a/src/app/(app)/issues/page.tsx b/src/app/(app)/issues/page.tsx index eda5d2ce..bcf93bf4 100644 --- a/src/app/(app)/issues/page.tsx +++ b/src/app/(app)/issues/page.tsx @@ -12,6 +12,7 @@ type SearchParams = { state?: string; difficulty?: string; repo?: string; + category?: string; claimed?: string; page?: string; }; @@ -31,10 +32,15 @@ export default async function IssuesPage({ searchParams }: { searchParams: Searc const filters = { search: searchParams.q, state: (searchParams.state === 'closed' ? 'closed' : 'open') as 'open' | 'closed', - difficulty: (['E', 'M', 'H'].includes(searchParams.difficulty ?? '') + difficulty: (searchParams.difficulty ?? '') + .split(',') + .every((value) => !value || ['E', 'M', 'H'].includes(value)) ? searchParams.difficulty - : undefined) as 'E' | 'M' | 'H' | undefined, + : undefined, repo: searchParams.repo, + category: ['bugs', 'docs', 'feature', 'tests'].includes(searchParams.category ?? '') + ? searchParams.category + : undefined, showClaimed: searchParams.claimed === 'true', page: Math.max(1, parseInt(searchParams.page ?? '1') || 1), }; @@ -109,17 +115,11 @@ export default async function IssuesPage({ searchParams }: { searchParams: Searc : { issues: [], total: 0, page: 1, pageSize: 10 }; const repoOptions: RepoOption[] = repoResult.ok ? repoResult.data : []; + const solvedIssues = linkedRecs.filter((rec) => rec.status === 'completed').length; return ( -
-
-
-
- 02 / ISSUES -
-

Browse Issues

-
- +
+
{linkedRecs.length > 0 && ( )} - +
); diff --git a/src/app/actions/issues.ts b/src/app/actions/issues.ts index a65fad4d..bf2345b2 100644 --- a/src/app/actions/issues.ts +++ b/src/app/actions/issues.ts @@ -12,8 +12,9 @@ const PAGE_SIZE = 10; export type IssueFilter = { search?: string; state?: 'open' | 'closed'; - difficulty?: 'E' | 'M' | 'H'; + difficulty?: 'E' | 'M' | 'H' | string; repo?: string; + category?: string; showClaimed?: boolean; page?: number; }; @@ -23,6 +24,7 @@ export type IssueWithStatus = { repoFullName: string; githubIssueNumber: number; title: string; + bodyExcerpt: string | null; difficulty: 'E' | 'M' | 'H' | null; xpReward: number | null; labels: string[] | null; @@ -45,6 +47,24 @@ export type RepoOption = { value: string; // upstream repo name to filter issues by }; +const CATEGORY_LABELS: Record = { + bugs: ['bug', 'type: bug', 'kind/bug'], + docs: ['docs', 'documentation', 'type: docs', 'kind/documentation'], + feature: ['feature', 'enhancement', 'type: feature', 'kind/feature'], + tests: ['test', 'tests', 'testing', 'type: tests'], +}; + +function splitFilterList(value?: string): string[] { + return [ + ...new Set( + (value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ), + ]; +} + export async function getRepoOptions(): Promise> { const sb = await getServerSupabase(); if (!sb) return err('not_configured', 'auth not configured'); @@ -139,7 +159,7 @@ export async function getIssuesPage(filters: IssueFilter): Promise + ['E', 'M', 'H'].includes(value), + ); + if (difficulties.length === 1) { + query = query.eq('difficulty', difficulties[0]); + } else if (difficulties.length > 1) { + query = query.in('difficulty', difficulties); + } + + const repos = splitFilterList(filters.repo); + if (repos.length === 1) { + const repoPattern = repoFilterPattern(repos[0]); + if (repoPattern) query = query.ilike('repo_full_name', repoPattern); + } else if (repos.length > 1) { + query = query.in('repo_full_name', repos); } - const repoPattern = repoFilterPattern(filters.repo); - if (repoPattern) { - query = query.ilike('repo_full_name', repoPattern); + + const categoryLabels = CATEGORY_LABELS[(filters.category ?? '').toLowerCase()]; + if (categoryLabels?.length) { + query = query.overlaps('labels', categoryLabels); } const { data, count, error } = await query; @@ -167,6 +201,7 @@ export async function getIssuesPage(filters: IssueFilter): Promise