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