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}