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