UserProfilePage.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 { 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}