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