BugDetailPage.tsx

  1import { useParams, Link } from 'react-router-dom'
  2import { ArrowLeft } from 'lucide-react'
  3import { formatDistanceToNow } from 'date-fns'
  4import { Skeleton } from '@/components/ui/skeleton'
  5import { Separator } from '@/components/ui/separator'
  6import { StatusBadge } from '@/components/bugs/StatusBadge'
  7import { LabelEditor } from '@/components/bugs/LabelEditor'
  8import { TitleEditor } from '@/components/bugs/TitleEditor'
  9import { Timeline } from '@/components/bugs/Timeline'
 10import { CommentBox } from '@/components/bugs/CommentBox'
 11import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 12import { useBugDetailQuery } from '@/__generated__/graphql'
 13import { useRepo } from '@/lib/repo'
 14
 15// Issue detail page (/:repo/issues/:id). Shows title, status, timeline of
 16// comments and events, and a sidebar with labels and participants.
 17export function BugDetailPage() {
 18  const { id } = useParams<{ id: string }>()
 19  const repo = useRepo()
 20  const { data, loading, error } = useBugDetailQuery({
 21    variables: { ref: repo, prefix: id! },
 22  })
 23
 24  if (error) {
 25    return (
 26      <div className="py-16 text-center text-sm text-destructive">
 27        Failed to load issue: {error.message}
 28      </div>
 29    )
 30  }
 31
 32  if (loading && !data) {
 33    return <BugDetailSkeleton />
 34  }
 35
 36  const bug = data?.repository?.bug
 37  if (!bug) {
 38    return (
 39      <div className="py-16 text-center text-sm text-muted-foreground">Issue not found.</div>
 40    )
 41  }
 42
 43  const issuesHref = repo ? `/${repo}/issues` : '/issues'
 44  const authorHref = repo ? `/${repo}/user/${bug.author.humanId}` : `/user/${bug.author.humanId}`
 45
 46  return (
 47    <div>
 48      <Link
 49        to={issuesHref}
 50        className="mb-4 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
 51      >
 52        <ArrowLeft className="size-3.5" />
 53        Back to issues
 54      </Link>
 55
 56      {/* Title row — hover reveals edit button when logged in */}
 57      <div className="mb-3">
 58        <TitleEditor bugPrefix={bug.humanId} title={bug.title} humanId={bug.humanId} ref_={repo} />
 59      </div>
 60
 61      <div className="mb-6 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
 62        <StatusBadge status={bug.status} />
 63        <span>
 64          <Link to={authorHref} className="font-medium text-foreground hover:underline">
 65            {bug.author.displayName}
 66          </Link>{' '}
 67          opened this issue {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
 68        </span>
 69      </div>
 70
 71      <Separator className="mb-6" />
 72
 73      <div className="flex gap-8">
 74        {/* Timeline + comment box */}
 75        <div className="min-w-0 flex-1 space-y-4">
 76          <Timeline bugPrefix={bug.humanId} items={bug.timeline.nodes} />
 77          <CommentBox bugPrefix={bug.humanId} bugStatus={bug.status} ref_={repo} />
 78        </div>
 79
 80        {/* Sidebar */}
 81        <aside className="w-56 shrink-0 space-y-6">
 82          <LabelEditor bugPrefix={bug.humanId} currentLabels={bug.labels} ref_={repo} />
 83
 84          <Separator />
 85
 86          <div>
 87            <h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
 88              Participants
 89            </h3>
 90            <div className="flex flex-wrap gap-1.5">
 91              {bug.participants.nodes.map((p) => {
 92                const participantHref = repo ? `/${repo}/user/${p.humanId}` : `/user/${p.humanId}`
 93                return (
 94                  <Link key={p.id} to={participantHref} title={p.displayName}>
 95                    <Avatar className="size-6">
 96                      <AvatarImage src={p.avatarUrl ?? undefined} alt={p.displayName} />
 97                      <AvatarFallback className="text-[10px]">
 98                        {p.displayName.slice(0, 2).toUpperCase()}
 99                      </AvatarFallback>
100                    </Avatar>
101                  </Link>
102                )
103              })}
104            </div>
105          </div>
106        </aside>
107      </div>
108    </div>
109  )
110}
111
112function BugDetailSkeleton() {
113  return (
114    <div className="space-y-4">
115      <Skeleton className="h-8 w-2/3" />
116      <Skeleton className="h-4 w-1/3" />
117      <Separator />
118      <div className="flex gap-8">
119        <div className="flex-1 space-y-4">
120          {Array.from({ length: 3 }).map((_, i) => (
121            <div key={i} className="rounded-md border border-border p-4">
122              <Skeleton className="mb-3 h-4 w-1/4" />
123              <Skeleton className="h-16 w-full" />
124            </div>
125          ))}
126        </div>
127        <div className="w-56 space-y-3">
128          <Skeleton className="h-4 w-full" />
129          <Skeleton className="h-4 w-3/4" />
130        </div>
131      </div>
132    </div>
133  )
134}