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