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}