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