$id.tsx

  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}