diff --git a/.gitignore b/.gitignore index 6e30cc02..c7aaecb6 100644 --- a/.gitignore +++ b/.gitignore @@ -43,5 +43,4 @@ next-env.d.ts .idea/ *.swp *.swo - - +screenshots/ diff --git a/src/app/(app)/issues/[id]/issue-detail-client.tsx b/src/app/(app)/issues/[id]/issue-detail-client.tsx new file mode 100644 index 00000000..958c8b2a --- /dev/null +++ b/src/app/(app)/issues/[id]/issue-detail-client.tsx @@ -0,0 +1,441 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import Link from 'next/link'; +import { + ArrowLeft, + ExternalLink, + GitBranch, + Settings, + BookOpen, + Activity, + Clock, + Zap, + MessageCircle, + CheckCircle2, + Loader2, + ChevronDown, + Code2, + Plus, + Minus, +} from 'lucide-react'; +import type { IssueDetail } from '@/app/actions/issues'; +import { claimIssue, unclaimIssue } from '@/app/actions/issues'; + +const DIFFICULTY_BADGE: Record = { + E: { label: 'EASY', color: 'border-emerald-700 text-emerald-400 bg-emerald-950/20' }, + M: { label: 'MEDIUM', color: 'border-yellow-700 text-yellow-400 bg-yellow-950/20' }, + H: { label: 'HARD', color: 'border-red-800 text-red-400 bg-red-950/20' }, +}; + +interface Props { + issue: IssueDetail; + currentUserLevel: number; + currentUserHandle: string | null; + currentUserAvatar: string | null; +} + +export function IssueDetailClient({ + issue, + currentUserLevel, + currentUserHandle, + currentUserAvatar, +}: Props) { + const router = useRouter(); + const [claimPending, setClaimPending] = useState(false); + const [claimResult, setClaimResult] = useState<'idle' | 'claimed' | 'error'>('idle'); + const [showBranchModal, setShowBranchModal] = useState(false); + const [branchName, setBranchName] = useState(''); + + const isClaimed = issue.userRecStatus === 'claimed'; + const repoName = issue.repoFullName.split('/')[1] ?? issue.repoFullName; + const org = issue.repoFullName.split('/')[0] ?? ''; + const difficultyBadge = DIFFICULTY_BADGE[issue.difficulty ?? ''] ?? null; + + const handleClaim = async () => { + setClaimPending(true); + try { + const res = await claimIssue(issue.id); + if (res.ok) { + setClaimResult('claimed'); + router.refresh(); + } else { + setClaimResult('error'); + } + } catch { + setClaimResult('error'); + } finally { + setClaimPending(false); + } + }; + + const handleUnclaim = async () => { + if (!issue.userRecId) return; + setClaimPending(true); + try { + await unclaimIssue(issue.userRecId); + setClaimResult('idle'); + router.refresh(); + } catch { + setClaimResult('error'); + } finally { + setClaimPending(false); + } + }; + + const handleCreateBranch = () => { + const defaultBranch = `fix/${issue.githubIssueNumber}-${issue.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + .slice(0, 50)}`; + setBranchName(defaultBranch); + setShowBranchModal(true); + }; + + const confirmBranch = () => { + setShowBranchModal(false); + }; + + const timeAgo = (dateStr: string) => { + const diff = Date.now() - new Date(dateStr).getTime(); + const days = Math.floor(diff / 86400000); + if (days === 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; + }; + + const formatXp = (xp: number) => `+${xp} XP`; + + return ( +
+
+ {/* Left Action Sidebar */} + + + {/* Main Content */} +
+
+ {/* Breadcrumbs */} +
+ + / + + {org}/{repoName} + + / + #{issue.githubIssueNumber} +
+ + {/* Metadata Header Card */} +
+
+ + {issue.state === 'open' ? 'OPEN' : 'CLOSED'} + + {difficultyBadge && ( + + {difficultyBadge.label} + + )} + {issue.xpReward && ( + + + {formatXp(issue.xpReward)} + + )} +
+ +

+ {issue.title} +

+ +
+
+ {issue.authorAvatar ? ( + + ) : ( +
+ {issue.authorHandle.substring(0, 2).toUpperCase()} +
+ )} + {issue.authorHandle} +
+ opened {timeAgo(issue.createdAt)} + + + View on GitHub + +
+
+ + {/* Body / Description */} + {issue.body && ( +
+
+ {issue.body.split('\n').map((line, i) => ( +

+ {line || '\u00A0'} +

+ ))} +
+
+ )} + + {/* Code Context Widget */} +
+
+ + + Code Context + +
+
+
+ + src/lib/runner/cleanup.go + +
+
+
+
+ + Current +
+
+ + Proposed +
+
+
+
+                      {`func Cleanup() {
+  os.RemoveAll("/tmp/data")
+  return nil
+}`}
+                    
+
+                      {`func Cleanup() error {
+  return os.RemoveAll("/tmp/data")
+}`}
+                    
+
+
+
+
+ + {/* Discussion / Comments */} +
+
+ + + Discussion ({issue.comments.length}) + +
+ + {issue.comments.length === 0 ? ( +
+ No comments yet. Be the first to discuss this issue. +
+ ) : ( +
+ {issue.comments.map((comment) => ( +
+
+ {comment.avatarUrl ? ( + + ) : ( +
+ {comment.username.substring(0, 2).toUpperCase()} +
+ )} +
+
+ + @{comment.username} + + + L{comment.level} + +
+
+ {timeAgo(comment.createdAt)} +
+
+
+
{comment.body}
+
+ ))} +
+ )} +
+
+
+ + {/* Right Sidebar - Claiming Widget */} + +
+ + {/* Branch Name Modal */} + {showBranchModal && ( +
+
+

Create Branch

+

+ A new branch will be created from the latest commit on the default branch. +

+ setBranchName(e.target.value)} + className="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#00FF87]" + placeholder="branch-name" + /> +
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/app/(app)/issues/[id]/page.tsx b/src/app/(app)/issues/[id]/page.tsx new file mode 100644 index 00000000..1da1fe73 --- /dev/null +++ b/src/app/(app)/issues/[id]/page.tsx @@ -0,0 +1,64 @@ +import { getServerSupabase } from '@/lib/supabase/server'; +import { getServiceSupabase } from '@/lib/supabase/service'; +import { redirect } from 'next/navigation'; +import { getIssueDetail } from '@/app/actions/issues'; +import { isOk } from '@/lib/result'; +import { IssueDetailClient } from './issue-detail-client'; + +export const dynamic = 'force-dynamic'; + +export default async function IssueDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const issueId = parseInt(id, 10); + if (isNaN(issueId)) redirect('/issues'); + + const sb = await getServerSupabase(); + if (!sb) { + return ( +
Not configured
+ ); + } + + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) redirect('/'); + + const service = getServiceSupabase(); + + let currentUserLevel = 0; + let currentUserHandle: string | null = null; + let currentUserAvatar: string | null = null; + if (service) { + const { data: profile } = await service + .from('profiles') + .select('level, github_handle') + .eq('id', user.id) + .maybeSingle(); + if (profile) { + currentUserLevel = profile.level ?? 0; + currentUserHandle = profile.github_handle; + } + const identity = user.identities?.find((i) => i.provider === 'github'); + currentUserAvatar = (identity?.identity_data?.['avatar_url'] as string) ?? null; + } + + const result = await getIssueDetail(issueId); + if (!isOk(result)) { + return ( +
+
404
+
Issue not found
+
+ ); + } + + return ( + + ); +} diff --git a/src/app/(app)/issues/issues-list.tsx b/src/app/(app)/issues/issues-list.tsx index a5de111b..c1f9a829 100644 --- a/src/app/(app)/issues/issues-list.tsx +++ b/src/app/(app)/issues/issues-list.tsx @@ -326,7 +326,6 @@ function IssueCard({ - {/* Title now opens the detail drawer instead of navigating to GitHub */}