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