$id.tsx

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