1// User profile page (/user/:id). Fetches an identity by prefix and shows:
2// - avatar, display name, login, email, humanId, protected badge
3// - open/closed issue toggle with BOTH counts always visible
4// - paginated list of that user's bugs (cursor-stack, same approach as BugListPage)
5//
6// The :id param is treated as a humanId prefix and passed directly to the
7// identity(prefix) and allBugs(query:"author:...") GraphQL arguments.
8
9import { useState } from 'react'
10import { useParams, Link } from 'react-router-dom'
11import { formatDistanceToNow } from 'date-fns'
12import {
13 ArrowLeft, MessageSquare, CircleDot, CircleCheck, ShieldCheck,
14 ChevronLeft, ChevronRight,
15} from 'lucide-react'
16import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
17import { Button } from '@/components/ui/button'
18import { Skeleton } from '@/components/ui/skeleton'
19import { LabelBadge } from '@/components/bugs/LabelBadge'
20import { cn } from '@/lib/utils'
21import { Status, useUserProfileQuery } from '@/__generated__/graphql'
22import { useRepo } from '@/lib/repo'
23
24const PAGE_SIZE = 25
25
26export function UserProfilePage() {
27 const { id } = useParams<{ id: string }>()
28 const repo = useRepo()
29 const [statusFilter, setStatusFilter] = useState<'open' | 'closed'>('open')
30
31 // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
32 // Resetting to [undefined] returns to page 1. Shared pattern with BugListPage.
33 const [cursors, setCursors] = useState<(string | undefined)[]>([undefined])
34 const page = cursors.length - 1
35
36 // Three allBugs aliases in one round-trip:
37 // openCount / closedCount — always fetched so both badge numbers are visible
38 // bugs — paginated list for the selected tab
39 const { data, loading, error } = useUserProfileQuery({
40 variables: {
41 ref: repo,
42 prefix: id!,
43 openQuery: `author:${id} status:open`,
44 closedQuery: `author:${id} status:closed`,
45 listQuery: `author:${id} status:${statusFilter}`,
46 after: cursors[page],
47 },
48 })
49
50 function switchStatus(next: 'open' | 'closed') {
51 if (next === statusFilter) return
52 setStatusFilter(next)
53 setCursors([undefined]) // reset to page 1 on tab change
54 }
55
56 if (error) {
57 return (
58 <div className="py-16 text-center text-sm text-destructive">
59 Failed to load profile: {error.message}
60 </div>
61 )
62 }
63
64 if (loading && !data) return <ProfileSkeleton />
65
66 const identity = data?.repository?.identity
67 if (!identity) {
68 return (
69 <div className="py-16 text-center text-sm text-muted-foreground">User not found.</div>
70 )
71 }
72
73 const openCount = data?.repository?.openCount.totalCount ?? 0
74 const closedCount = data?.repository?.closedCount.totalCount ?? 0
75
76 const bugs = data?.repository?.bugs
77 const totalPages = Math.max(1, Math.ceil((bugs?.totalCount ?? 0) / PAGE_SIZE))
78 const hasNext = bugs?.pageInfo.hasNextPage ?? false
79 const hasPrev = page > 0
80
81 function goNext() {
82 const cursor = bugs?.pageInfo.endCursor
83 if (cursor) setCursors((prev) => [...prev, cursor])
84 }
85
86 function goPrev() {
87 setCursors((prev) => prev.slice(0, -1))
88 }
89
90 const issuesHref = repo ? `/${repo}/issues` : '/issues'
91
92 return (
93 <div>
94 <Link
95 to={issuesHref}
96 className="mb-6 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
97 >
98 <ArrowLeft className="size-3.5" />
99 Back to issues
100 </Link>
101
102 {/* ── Profile header ─────────────────────────────────────────────── */}
103 <div className="mb-8 flex items-start gap-5">
104 <Avatar className="size-20">
105 <AvatarImage src={identity.avatarUrl ?? undefined} alt={identity.displayName} />
106 <AvatarFallback className="text-2xl">
107 {identity.displayName.slice(0, 2).toUpperCase()}
108 </AvatarFallback>
109 </Avatar>
110
111 <div className="pt-1">
112 <div className="flex items-center gap-2">
113 <h1 className="text-xl font-semibold">{identity.displayName}</h1>
114 {/* isProtected means this identity has been cryptographically signed */}
115 {identity.isProtected && (
116 <span title="Protected identity">
117 <ShieldCheck className="size-4 text-muted-foreground" />
118 </span>
119 )}
120 </div>
121 <div className="mt-1 space-y-0.5 text-sm text-muted-foreground">
122 {identity.login && <p>@{identity.login}</p>}
123 {identity.email && <p>{identity.email}</p>}
124 <p className="font-mono text-xs">#{identity.humanId}</p>
125 </div>
126
127 {/* Aggregate stats — always visible, independent of selected tab */}
128 <div className="mt-3 flex items-center gap-4 text-sm">
129 <span className="flex items-center gap-1 text-muted-foreground">
130 <CircleDot className="size-3.5 text-green-600 dark:text-green-400" />
131 <span className="font-medium text-foreground">{openCount}</span> open
132 </span>
133 <span className="flex items-center gap-1 text-muted-foreground">
134 <CircleCheck className="size-3.5 text-purple-600 dark:text-purple-400" />
135 <span className="font-medium text-foreground">{closedCount}</span> closed
136 </span>
137 </div>
138 </div>
139 </div>
140
141 {/* ── Issue list ─────────────────────────────────────────────────── */}
142 <div className="rounded-md border border-border">
143 {/* Open / Closed toggle — mirrors BugListPage style */}
144 <div className="flex items-center gap-1 border-b border-border px-4 py-2">
145 <button
146 onClick={() => switchStatus('open')}
147 className={cn(
148 'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
149 statusFilter === 'open'
150 ? 'bg-accent text-accent-foreground'
151 : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
152 )}
153 >
154 <CircleDot className={cn('size-4', statusFilter === 'open' && 'text-green-600 dark:text-green-400')} />
155 Open
156 <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none">
157 {openCount}
158 </span>
159 </button>
160
161 <button
162 onClick={() => switchStatus('closed')}
163 className={cn(
164 'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
165 statusFilter === 'closed'
166 ? 'bg-accent text-accent-foreground'
167 : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
168 )}
169 >
170 <CircleCheck className={cn('size-4', statusFilter === 'closed' && 'text-purple-600 dark:text-purple-400')} />
171 Closed
172 <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none">
173 {closedCount}
174 </span>
175 </button>
176 </div>
177
178 {bugs?.nodes.length === 0 && (
179 <p className="px-4 py-8 text-center text-sm text-muted-foreground">
180 No {statusFilter} issues.
181 </p>
182 )}
183
184 {bugs?.nodes.map((bug) => {
185 const isOpen = bug.status === Status.Open
186 const StatusIcon = isOpen ? CircleDot : CircleCheck
187 return (
188 <div
189 key={bug.id}
190 className="flex items-start gap-3 border-b border-border px-4 py-3 last:border-0"
191 >
192 <StatusIcon
193 className={cn(
194 'mt-0.5 size-4 shrink-0',
195 isOpen
196 ? 'text-green-600 dark:text-green-400'
197 : 'text-purple-600 dark:text-purple-400',
198 )}
199 />
200 <div className="min-w-0 flex-1">
201 <div className="flex flex-wrap items-baseline gap-2">
202 <Link
203 to={repo ? `/${repo}/issues/${bug.humanId}` : `/issues/${bug.humanId}`}
204 className="font-medium text-foreground hover:text-primary hover:underline"
205 >
206 {bug.title}
207 </Link>
208 {bug.labels.map((label) => (
209 <LabelBadge key={label.name} name={label.name} color={label.color} />
210 ))}
211 </div>
212 <p className="mt-0.5 text-xs text-muted-foreground">
213 #{bug.humanId} opened{' '}
214 {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
215 </p>
216 </div>
217 {bug.comments.totalCount > 0 && (
218 <div className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
219 <MessageSquare className="size-3.5" />
220 {bug.comments.totalCount}
221 </div>
222 )}
223 </div>
224 )
225 })}
226
227 {/* Pagination footer — only shown when there is more than one page */}
228 {totalPages > 1 && (
229 <div className="flex items-center justify-center gap-2 border-t border-border px-4 py-2">
230 <Button
231 variant="ghost"
232 size="sm"
233 onClick={goPrev}
234 disabled={!hasPrev || loading}
235 className="gap-1 text-muted-foreground"
236 >
237 <ChevronLeft className="size-4" />
238 Previous
239 </Button>
240 <span className="text-sm text-muted-foreground">
241 Page {page + 1} of {totalPages}
242 </span>
243 <Button
244 variant="ghost"
245 size="sm"
246 onClick={goNext}
247 disabled={!hasNext || loading}
248 className="gap-1 text-muted-foreground"
249 >
250 Next
251 <ChevronRight className="size-4" />
252 </Button>
253 </div>
254 )}
255 </div>
256 </div>
257 )
258}
259
260function ProfileSkeleton() {
261 return (
262 <div className="space-y-6">
263 <div className="flex items-start gap-5">
264 <Skeleton className="size-20 rounded-full" />
265 <div className="space-y-2 pt-1">
266 <Skeleton className="h-6 w-40" />
267 <Skeleton className="h-4 w-24" />
268 <Skeleton className="h-4 w-32" />
269 </div>
270 </div>
271 <div className="space-y-2">
272 {Array.from({ length: 4 }).map((_, i) => (
273 <Skeleton key={i} className="h-14 w-full" />
274 ))}
275 </div>
276 </div>
277 )
278}