$id.tsx

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