$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        className="text-muted-foreground hover:text-foreground mb-4 flex items-center gap-1.5 text-sm"
 46      >
 47        <ArrowLeft className="size-3.5" />
 48        Back to issues
 49      </Link>
 50
 51      {/* Title row — hover reveals edit button when logged in */}
 52      <div className="mb-3">
 53        <TitleEditor bugPrefix={bug.humanId} title={bug.title} humanId={bug.humanId} ref_={repo} />
 54      </div>
 55
 56      <div className="text-muted-foreground mb-6 flex flex-wrap items-center gap-3 text-sm">
 57        <StatusBadge status={bug.status} />
 58        <span>
 59          <Link
 60            to="/$repo/user/$id"
 61            params={{ repo: repo!, id: bug.author.humanId }}
 62            className="text-foreground font-medium hover:underline"
 63          >
 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="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
 87              Participants
 88            </h3>
 89            <div className="flex flex-wrap gap-1.5">
 90              {bug.participants.nodes.map((p) => {
 91                return (
 92                  <Link
 93                    key={p.id}
 94                    to="/$repo/user/$id"
 95                    params={{ repo: repo!, id: p.humanId }}
 96                    title={p.displayName}
 97                  >
 98                    <Avatar className="size-6">
 99                      <AvatarImage src={p.avatarUrl ?? undefined} alt={p.displayName} />
100                      <AvatarFallback className="text-[10px]">
101                        {p.displayName.slice(0, 2).toUpperCase()}
102                      </AvatarFallback>
103                    </Avatar>
104                  </Link>
105                );
106              })}
107            </div>
108          </div>
109        </aside>
110      </div>
111    </div>
112  );
113}
114
115function BugDetailSkeleton() {
116  return (
117    <div className="space-y-4">
118      <Skeleton className="h-8 w-2/3" />
119      <Skeleton className="h-4 w-1/3" />
120      <Separator />
121      <div className="flex gap-8">
122        <div className="flex-1 space-y-4">
123          {Array.from({ length: 3 }).map((_, i) => (
124            <div key={i} className="border-border rounded-md border p-4">
125              <Skeleton className="mb-3 h-4 w-1/4" />
126              <Skeleton className="h-16 w-full" />
127            </div>
128          ))}
129        </div>
130        <div className="w-56 space-y-3">
131          <Skeleton className="h-4 w-full" />
132          <Skeleton className="h-4 w-3/4" />
133        </div>
134      </div>
135    </div>
136  );
137}