IssueDetail.tsx
Raw
1import type {
2 IssueCommentRow,
3 IssueRow,
4 LabelRow,
5 RepositoryRow,
6} from "../../db/index.ts";
7import { labelTextColor } from "../../lib/labelColor.ts";
8import { displayName } from "../../lib/users.ts";
9import type { SessionUser } from "../../middleware/session.ts";
10import { Avatar } from "../Avatar.tsx";
11import { DateWithEdited } from "../DateWithEdited.tsx";
12import { Layout } from "../layout.tsx";
13import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
14import { RepoHeader } from "../repos/RepoHeader.tsx";
15import { RepoNav } from "../repos/RepoNav.tsx";
16
17interface IssueDetailProps {
18 user: SessionUser | null;
19 repo: RepositoryRow;
20 issue: IssueRow & {
21 author_username: string;
22 author_avatar_version: number | null;
23 };
24 bodyHtml: string;
25 comments: (IssueCommentRow & {
26 author_username: string;
27 author_avatar_version: number | null;
28 bodyHtml: string;
29 })[];
30 reactions: ReactionCount[];
31 commentReactions: Map<number, ReactionCount[]>;
32 issueLabels: LabelRow[];
33 repoLabels: LabelRow[];
34}
35
36export function IssueDetail({
37 user,
38 repo,
39 issue,
40 bodyHtml,
41 comments,
42 reactions,
43 commentReactions,
44 issueLabels,
45 repoLabels,
46}: IssueDetailProps) {
47 const canEditIssue =
48 user != null &&
49 (user.isAdmin ||
50 (user.id === issue.author_id && issue.status === "open"));
51 const canEditComment = (c: IssueCommentRow) =>
52 user != null &&
53 (user.isAdmin || (user.id === c.author_id && issue.status === "open"));
54 return (
55 <Layout user={user} title={`${issue.title} — ${repo.name}`}>
56 <div class="container">
57 <RepoHeader repo={repo} />
58 <RepoNav repo={repo} active="issues" user={user} />
59 <div class="issue-detail">
60 <div class="issue-detail-header">
61 <span class="issue-number">#{issue.number}</span>
62 <div class="title-with-edit">
63 <h2 class="issue-detail-title">{issue.title}</h2>
64 {canEditIssue && (
65 <>
66 <div class="title-edit-form-area">
67 <form
68 method="POST"
69 action={`/${repo.name}/issues/${issue.number}/edit`}
70 class="title-edit-form"
71 >
72 <input
73 class="form-input"
74 name="title"
75 value={issue.title}
76 required
77 />
78 <input
79 type="hidden"
80 name="edit_body"
81 value={issue.body}
82 />
83 <div class="title-edit-actions">
84 <button
85 type="submit"
86 class="btn btn-sm btn-primary"
87 >
88 Save
89 </button>
90 <button
91 type="button"
92 class="btn btn-sm"
93 onclick="this.closest('.title-with-edit').querySelector('details').removeAttribute('open')"
94 >
95 Cancel
96 </button>
97 </div>
98 </form>
99 </div>
100 <details class="title-edit-details">
101 <summary class="btn btn-xs">
102 Edit title
103 </summary>
104 </details>
105 </>
106 )}
107 </div>
108 <span class={`issue-badge ${issue.status}`}>
109 {issue.status}
110 </span>
111 {canEditIssue && (
112 <div class="issue-detail-meta-actions">
113 {user?.isAdmin && issue.status === "open" && (
114 <form
115 method="POST"
116 action={`/${repo.name}/issues/${issue.number}/complete`}
117 class="inline-form"
118 >
119 <button
120 type="submit"
121 class="btn btn-sm btn-primary"
122 >
123 Completed
124 </button>
125 </form>
126 )}
127 {user?.isAdmin && (
128 <form
129 method="POST"
130 action={`/${repo.name}/issues/${issue.number}/close`}
131 class="inline-form"
132 >
133 <button
134 type="submit"
135 class="btn btn-sm"
136 >
137 {issue.status === "open"
138 ? "Close issue"
139 : "Reopen issue"}
140 </button>
141 </form>
142 )}
143 <details class="confirm-details">
144 <summary class="btn btn-sm btn-danger">
145 Delete
146 </summary>
147 <div class="confirm-popup">
148 Permanently delete this issue?
149 <form
150 method="POST"
151 action={`/${repo.name}/issues/${issue.number}/delete`}
152 class="inline-form"
153 >
154 <button
155 type="submit"
156 class="btn btn-sm btn-danger"
157 >
158 Yes, delete
159 </button>
160 </form>
161 </div>
162 </details>
163 </div>
164 )}
165 </div>
166
167 {(issueLabels.length > 0 || user?.isAdmin) && (
168 <div class="issue-labels-row">
169 {issueLabels.map((label) => (
170 <span class="label-badge-wrap">
171 <span
172 class="label-badge"
173 style={`background:${label.color};color:${labelTextColor(label.color)}`}
174 >
175 {label.name}
176 </span>
177 {user?.isAdmin && (
178 <form
179 method="POST"
180 action={`/${repo.name}/issues/${issue.number}/labels/remove`}
181 class="label-remove-form"
182 >
183 <input
184 type="hidden"
185 name="label_id"
186 value={String(label.id)}
187 />
188 <button
189 type="submit"
190 class="label-remove-btn"
191 title="Remove label"
192 >
193 ×
194 </button>
195 </form>
196 )}
197 </span>
198 ))}
199 {user?.isAdmin &&
200 repoLabels.filter(
201 (l) =>
202 !issueLabels.some((il) => il.id === l.id),
203 ).length > 0 && (
204 <form
205 method="POST"
206 action={`/${repo.name}/issues/${issue.number}/labels/add`}
207 class="label-add-inline-form"
208 >
209 <select name="label_id" class="label-select">
210 {repoLabels
211 .filter(
212 (l) =>
213 !issueLabels.some(
214 (il) => il.id === l.id,
215 ),
216 )
217 .map((label) => (
218 <option
219 value={String(label.id)}
220 >
221 {label.name}
222 </option>
223 ))}
224 </select>
225 <button
226 type="submit"
227 class="btn btn-sm btn-secondary"
228 >
229 Add label
230 </button>
231 </form>
232 )}
233 </div>
234 )}
235
236 <div class="timeline-item">
237 <div class="timeline-author">
238 <Avatar
239 userId={issue.author_id}
240 version={issue.author_avatar_version}
241 size={24}
242 />
243 <strong>
244 {displayName(issue.author_username)}
245 </strong>
246 <div class="timeline-author-right">
247 {canEditIssue && (
248 <details class="inline-edit-details">
249 <summary class="btn btn-xs">
250 <span class="when-closed">
251 Edit
252 </span>
253 <span class="when-open">
254 Stop editing
255 </span>
256 </summary>
257 </details>
258 )}
259 <DateWithEdited
260 date={issue.created_at}
261 editedAt={issue.edited_at}
262 />
263 </div>
264 </div>
265 {canEditIssue && (
266 <div class="inline-edit-form-area">
267 <form
268 method="POST"
269 action={`/${repo.name}/issues/${issue.number}/edit`}
270 class="inline-edit-form"
271 >
272 <input
273 type="hidden"
274 name="title"
275 value={issue.title}
276 />
277 <div class="form-group">
278 <textarea
279 class="form-input"
280 id="edit-issue-body"
281 name="edit_body"
282 rows="6"
283 >
284 {issue.body}
285 </textarea>
286 </div>
287 <div class="form-actions">
288 <button
289 type="submit"
290 class="btn btn-sm btn-primary"
291 >
292 Save
293 </button>
294 </div>
295 </form>
296 </div>
297 )}
298 <div class="timeline-body markdown-body">
299 {bodyHtml || (
300 <em class="text-muted">
301 No description provided.
302 </em>
303 )}
304 </div>
305 <ReactionBar
306 reactions={reactions}
307 postUrl={`/${repo.name}/issues/${issue.number}/react`}
308 user={user}
309 />
310 </div>
311
312 {comments.map((comment) => (
313 <div class="timeline-item">
314 <div class="timeline-author">
315 <Avatar
316 userId={comment.author_id}
317 version={comment.author_avatar_version}
318 size={24}
319 />
320 <strong>
321 {displayName(comment.author_username)}
322 </strong>
323 <div class="timeline-author-right">
324 {canEditComment(comment) && (
325 <details class="inline-edit-details">
326 <summary class="btn btn-xs">
327 <span class="when-closed">
328 Edit
329 </span>
330 <span class="when-open">
331 Stop editing
332 </span>
333 </summary>
334 </details>
335 )}
336 <DateWithEdited
337 date={comment.created_at}
338 editedAt={comment.edited_at}
339 />
340 </div>
341 </div>
342 {canEditComment(comment) && (
343 <div class="inline-edit-form-area">
344 <form
345 method="POST"
346 action={`/${repo.name}/issues/${issue.number}/comments/${comment.id}/edit`}
347 class="inline-edit-form"
348 >
349 <div class="form-group">
350 <textarea
351 class="form-input"
352 name="edit_body"
353 rows="6"
354 >
355 {comment.body}
356 </textarea>
357 </div>
358 <div class="form-actions">
359 <button
360 type="submit"
361 class="btn btn-sm btn-primary"
362 >
363 Save
364 </button>
365 </div>
366 </form>
367 </div>
368 )}
369 <div class="timeline-body markdown-body">
370 {comment.bodyHtml}
371 </div>
372 <ReactionBar
373 reactions={
374 commentReactions.get(comment.id) ?? []
375 }
376 postUrl={`/${repo.name}/issues/${issue.number}/react`}
377 commentId={comment.id}
378 user={user}
379 />
380 </div>
381 ))}
382
383 {user && (
384 <div class="timeline-item timeline-item-new">
385 <h3 class="section-title">Add a comment</h3>
386 <form
387 method="POST"
388 action={`/${repo.name}/issues/${issue.number}/comments`}
389 >
390 <div class="form-group">
391 <textarea
392 name="body"
393 rows="6"
394 placeholder="Leave a comment (Markdown supported)"
395 required
396 />
397 </div>
398 <div class="form-actions">
399 <button
400 type="submit"
401 class="btn btn-primary"
402 >
403 Comment
404 </button>
405 </div>
406 </form>
407 </div>
408 )}
409 {!user && (
410 <p class="text-muted">
411 <a href="/login">Sign in</a> to leave a comment.
412 </p>
413 )}
414 </div>
415 </div>
416 </Layout>
417 );
418}
419