BugDetailPage.tsx

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