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