@kernhq/module-tracker 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -3
- package/src/client/api-instance.ts +34 -0
- package/src/client/components/BoardCard.svelte +182 -0
- package/src/client/components/BoardView.svelte +248 -0
- package/src/client/components/CommentComposer.svelte +219 -0
- package/src/client/components/CommentThread.svelte +389 -0
- package/src/client/components/CustomField.svelte +333 -0
- package/src/client/components/DueDate.svelte +44 -0
- package/src/client/components/FilterMenu.svelte +91 -0
- package/src/client/components/GroupHeader.svelte +137 -0
- package/src/client/components/HomeLinks.svelte +38 -0
- package/src/client/components/IssueApprovals.svelte +217 -0
- package/src/client/components/IssueConnections.svelte +424 -0
- package/src/client/components/IssueDetailPanel.svelte +1337 -0
- package/src/client/components/IssueInline.svelte +75 -0
- package/src/client/components/IssueListView.svelte +153 -0
- package/src/client/components/IssuePicker.svelte +180 -0
- package/src/client/components/IssueRow.svelte +171 -0
- package/src/client/components/IssueTime.svelte +306 -0
- package/src/client/components/KqlInput.svelte +295 -0
- package/src/client/components/NewIssueDialog.svelte +210 -0
- package/src/client/components/NewProjectDialog.svelte +235 -0
- package/src/client/components/PriorityGlyph.svelte +52 -0
- package/src/client/components/SaveViewDialog.svelte +153 -0
- package/src/client/components/SidebarProjects.svelte +263 -0
- package/src/client/components/SidebarViews.svelte +336 -0
- package/src/client/components/StatusIcon.svelte +50 -0
- package/src/client/components/TrackerControls.svelte +109 -0
- package/src/client/components/TrackerSidebar.svelte +96 -0
- package/src/client/context.svelte.ts +105 -0
- package/src/client/core-api.ts +35 -0
- package/src/client/csv.test.ts +50 -0
- package/src/client/csv.ts +60 -0
- package/src/client/filters.ts +94 -0
- package/src/client/i18n.ts +3260 -0
- package/src/client/index.ts +12 -0
- package/src/client/labels.ts +200 -0
- package/src/client/mock.ts +2729 -0
- package/src/client/module.ts +491 -0
- package/src/client/nav.test.ts +59 -0
- package/src/client/nav.ts +84 -0
- package/src/client/pages/IntakePage.svelte +287 -0
- package/src/client/pages/IssuesPage.svelte +701 -0
- package/src/client/pages/ProjectPage.svelte +29 -0
- package/src/client/pages/ReportsPage.svelte +365 -0
- package/src/client/permissions.ts +33 -0
- package/src/client/planning/PlanningSections.svelte +259 -0
- package/src/client/project/ComponentsPage.svelte +423 -0
- package/src/client/project/CyclesPage.svelte +488 -0
- package/src/client/project/MilestonesPage.svelte +342 -0
- package/src/client/project/PlanCard.svelte +191 -0
- package/src/client/project/ProjectShell.svelte +93 -0
- package/src/client/project/TemplatesPage.svelte +321 -0
- package/src/client/project/context.svelte.ts +58 -0
- package/src/client/query.ts +50 -0
- package/src/client/recurrence.test.ts +37 -0
- package/src/client/recurrence.ts +89 -0
- package/src/client/richtext.ts +155 -0
- package/src/client/settings/CycleList.svelte +422 -0
- package/src/client/settings/FieldEditor.svelte +324 -0
- package/src/client/settings/FieldsSettings.svelte +273 -0
- package/src/client/settings/ImportSettings.svelte +410 -0
- package/src/client/settings/LayoutEditor.svelte +263 -0
- package/src/client/settings/PlanningList.svelte +249 -0
- package/src/client/settings/PlanningSettings.svelte +179 -0
- package/src/client/settings/ProjectsSettings.svelte +462 -0
- package/src/client/settings/RepeatingSettings.svelte +293 -0
- package/src/client/settings/TypeEditor.svelte +214 -0
- package/src/client/settings/TypesSettings.svelte +384 -0
- package/src/client/settings/WorkflowsSettings.svelte +645 -0
- package/src/client/settings/mutations.ts +19 -0
- package/src/client/time.test.ts +39 -0
- package/src/client/time.ts +34 -0
- package/src/client/tracker.test.ts +399 -0
- package/src/client/views.ts +27 -0
- package/src/client/widgets/AssignedCountWidget.svelte +14 -0
- package/src/client/widgets/CountWidget.svelte +56 -0
- package/src/client/widgets/CycleWidget.svelte +85 -0
- package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
- package/src/client/widgets/IssuesWidget.svelte +222 -0
- package/src/client/widgets/ThroughputWidget.svelte +68 -0
- package/src/client/widgets/TimerWidget.svelte +116 -0
- package/src/client/widgets/VelocityWidget.svelte +55 -0
|
@@ -0,0 +1,1337 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
Button,
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
formatBytes,
|
|
7
|
+
Icon,
|
|
8
|
+
IconButton,
|
|
9
|
+
type MenuItem,
|
|
10
|
+
relativeTime,
|
|
11
|
+
Sheet,
|
|
12
|
+
Spinner,
|
|
13
|
+
session,
|
|
14
|
+
toast,
|
|
15
|
+
uploadFile,
|
|
16
|
+
} from '@kernhq/ui'
|
|
17
|
+
import { RichTextEditor } from '@kernhq/ui/editor'
|
|
18
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
19
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
20
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
21
|
+
import { t } from '../i18n.js'
|
|
22
|
+
import type {
|
|
23
|
+
AvailableTransition,
|
|
24
|
+
Issue,
|
|
25
|
+
Comment as IssueComment,
|
|
26
|
+
Priority,
|
|
27
|
+
ResolvedField,
|
|
28
|
+
RichDoc,
|
|
29
|
+
} from '../index.js'
|
|
30
|
+
import { PRIORITY_GROUP_ORDER } from '../index.js'
|
|
31
|
+
import { estimateLabel, estimateUnitOf, priorityLabel } from '../labels.js'
|
|
32
|
+
import { canTracker } from '../permissions.js'
|
|
33
|
+
import { trackerKeys } from '../query.js'
|
|
34
|
+
import { renderDoc } from '../richtext.js'
|
|
35
|
+
import CommentComposer from './CommentComposer.svelte'
|
|
36
|
+
import CommentThread from './CommentThread.svelte'
|
|
37
|
+
import CustomField from './CustomField.svelte'
|
|
38
|
+
import DueDate from './DueDate.svelte'
|
|
39
|
+
import IssueApprovals from './IssueApprovals.svelte'
|
|
40
|
+
import IssueConnections from './IssueConnections.svelte'
|
|
41
|
+
import IssueTime from './IssueTime.svelte'
|
|
42
|
+
import PriorityGlyph from './PriorityGlyph.svelte'
|
|
43
|
+
import StatusIcon from './StatusIcon.svelte'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The issue detail panel (DESIGN.md 3.13).
|
|
47
|
+
*
|
|
48
|
+
* A 440px panel over the content column rather than a page of its own: you keep your place in the
|
|
49
|
+
* list, and closing it puts you back exactly where you were. Every property is editable in place,
|
|
50
|
+
* and each edit is its own small mutation, so a failed request only rolls back the field it touched.
|
|
51
|
+
*/
|
|
52
|
+
interface Props {
|
|
53
|
+
workspaceId: string
|
|
54
|
+
/** the issue key from the URL, so a panel can be linked to and reopened after a reload */
|
|
55
|
+
issueKey: string | null
|
|
56
|
+
onclose: () => void
|
|
57
|
+
/** follow a connection to another issue — the page owns the URL, not this panel */
|
|
58
|
+
onopenissue: (key: string) => void
|
|
59
|
+
}
|
|
60
|
+
let { workspaceId, issueKey, onclose, onopenissue }: Props = $props()
|
|
61
|
+
|
|
62
|
+
/** The built-in properties, in the order the panel showed them before layouts existed. */
|
|
63
|
+
const FALLBACK_SIDEBAR: ResolvedField[] = [
|
|
64
|
+
'status',
|
|
65
|
+
'assignees',
|
|
66
|
+
'priority',
|
|
67
|
+
'dueDate',
|
|
68
|
+
'labels',
|
|
69
|
+
'cycle',
|
|
70
|
+
'estimate',
|
|
71
|
+
'project',
|
|
72
|
+
].map((fieldId, order) => ({
|
|
73
|
+
fieldId,
|
|
74
|
+
kind: 'system' as const,
|
|
75
|
+
label: fieldId,
|
|
76
|
+
section: 'sidebar' as const,
|
|
77
|
+
order,
|
|
78
|
+
required: false,
|
|
79
|
+
pinned: fieldId === 'status',
|
|
80
|
+
showInCards: false,
|
|
81
|
+
field: null,
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
const api = getTrackerApi()
|
|
85
|
+
const cat = getTrackerCatalogue()
|
|
86
|
+
|
|
87
|
+
let issueFileInput = $state<HTMLInputElement | null>(null)
|
|
88
|
+
|
|
89
|
+
/** Uploads first, then attaches: an attachment points at a file that already exists. */
|
|
90
|
+
async function uploadIssueFiles(list: FileList | null) {
|
|
91
|
+
if (!list?.length) return
|
|
92
|
+
const ids: string[] = []
|
|
93
|
+
for (const file of Array.from(list)) {
|
|
94
|
+
try {
|
|
95
|
+
const uploaded = await uploadFile({
|
|
96
|
+
workspaceId,
|
|
97
|
+
file,
|
|
98
|
+
name: file.name,
|
|
99
|
+
mimeType: file.type || undefined,
|
|
100
|
+
attachedTo: { module: 'tracker', type: 'issue', id: issue?.id as string },
|
|
101
|
+
})
|
|
102
|
+
ids.push(uploaded.id)
|
|
103
|
+
} catch (err) {
|
|
104
|
+
toast.error(err instanceof Error ? err.message : t('attachment_failed'))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (ids.length) addIssueFiles.mutate(ids)
|
|
108
|
+
}
|
|
109
|
+
const queryClient = useQueryClient()
|
|
110
|
+
|
|
111
|
+
let editingDescription = $state(false)
|
|
112
|
+
/** Named on screen before it happens, never behind `window.confirm`. */
|
|
113
|
+
let confirmingDelete = $state(false)
|
|
114
|
+
let descriptionDraft = $state<RichDoc | null>(null)
|
|
115
|
+
|
|
116
|
+
const issueQuery = createQuery(() => ({
|
|
117
|
+
queryKey: trackerKeys.issue(workspaceId, issueKey ?? ''),
|
|
118
|
+
queryFn: () => api.issues.getByKey({ workspaceId, key: issueKey as string }),
|
|
119
|
+
enabled: Boolean(issueKey),
|
|
120
|
+
}))
|
|
121
|
+
const issue = $derived(issueQuery.data ?? null)
|
|
122
|
+
const issueId = $derived(issue?.id ?? null)
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What this issue's type says to show, in what order. The panel used to render eight fixed
|
|
126
|
+
* properties; it now renders whatever the type's layout resolves to, with the built-in controls
|
|
127
|
+
* keyed by field id so nothing about them changed.
|
|
128
|
+
*/
|
|
129
|
+
const layoutQuery = createQuery(() => ({
|
|
130
|
+
queryKey: trackerKeys.layout(workspaceId, issue?.typeId ?? '', issue?.projectId ?? null),
|
|
131
|
+
queryFn: () =>
|
|
132
|
+
api.types.layout({
|
|
133
|
+
workspaceId,
|
|
134
|
+
id: issue?.typeId as string,
|
|
135
|
+
projectId: issue?.projectId ?? null,
|
|
136
|
+
}),
|
|
137
|
+
enabled: Boolean(issue?.typeId),
|
|
138
|
+
}))
|
|
139
|
+
/**
|
|
140
|
+
* Until the layout arrives, show the built-in properties in their default order rather than an
|
|
141
|
+
* empty panel — the fields are already in `issue`, so a spinner here would be a blank column over
|
|
142
|
+
* data we have.
|
|
143
|
+
*/
|
|
144
|
+
const sidebarFields = $derived(layoutQuery.data?.sidebar ?? FALLBACK_SIDEBAR)
|
|
145
|
+
/**
|
|
146
|
+
* Custom fields the type puts in the main column. `title` and `description` are system fields with
|
|
147
|
+
* their own places, so only custom ones need rendering here — without this, moving a field to Main
|
|
148
|
+
* in settings would make it disappear from the issue entirely.
|
|
149
|
+
*/
|
|
150
|
+
const mainFields = $derived((layoutQuery.data?.main ?? []).filter((f) => f.kind === 'custom' && f.field))
|
|
151
|
+
|
|
152
|
+
const commentsQuery = createQuery(() => ({
|
|
153
|
+
queryKey: trackerKeys.comments(workspaceId, issueId ?? ''),
|
|
154
|
+
queryFn: () => api.issues.comments.list({ workspaceId, issueId: issueId as string, limit: 50 }),
|
|
155
|
+
enabled: Boolean(issueId),
|
|
156
|
+
}))
|
|
157
|
+
|
|
158
|
+
const historyQuery = createQuery(() => ({
|
|
159
|
+
queryKey: trackerKeys.history(workspaceId, issueId ?? ''),
|
|
160
|
+
queryFn: () => api.issues.history({ workspaceId, issueId: issueId as string, limit: 50 }),
|
|
161
|
+
enabled: Boolean(issueId),
|
|
162
|
+
}))
|
|
163
|
+
|
|
164
|
+
const transitionsQuery = createQuery(() => ({
|
|
165
|
+
queryKey: trackerKeys.transitions(workspaceId, issueId ?? ''),
|
|
166
|
+
queryFn: () => api.issues.transitions.available({ workspaceId, issueId: issueId as string }),
|
|
167
|
+
enabled: Boolean(issueId),
|
|
168
|
+
}))
|
|
169
|
+
|
|
170
|
+
const project = $derived(cat.project(issue?.projectId))
|
|
171
|
+
const status = $derived(cat.status(issue?.statusId))
|
|
172
|
+
const cycle = $derived(cat.cycle(issue?.cycleId))
|
|
173
|
+
const watching = $derived(Boolean(issue && session.user && issue.watcherIds.includes(session.user.id)))
|
|
174
|
+
|
|
175
|
+
// the server enforces these too; here they decide whether a control is offered at all
|
|
176
|
+
const canTriage = $derived(canTracker('triageManage'))
|
|
177
|
+
const canEdit = $derived(canTracker('edit'))
|
|
178
|
+
const canTransition = $derived(canTracker('transition'))
|
|
179
|
+
const canComment = $derived(canTracker('comment'))
|
|
180
|
+
|
|
181
|
+
function invalidate() {
|
|
182
|
+
void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
|
|
183
|
+
void queryClient.invalidateQueries({ queryKey: ['tracker', 'transition'] })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const patch = createMutation(() => ({
|
|
187
|
+
mutationFn: (input: Record<string, unknown>) =>
|
|
188
|
+
api.issues.update({ workspaceId, issueId: issue?.id as string, patch: input as never }),
|
|
189
|
+
onSuccess: invalidate,
|
|
190
|
+
onError: (error: Error) => toast.error(error.message),
|
|
191
|
+
}))
|
|
192
|
+
|
|
193
|
+
const transition = createMutation(() => ({
|
|
194
|
+
mutationFn: (transitionId: string) =>
|
|
195
|
+
api.issues.transitions.apply({ workspaceId, issueId: issue?.id as string, transitionId }),
|
|
196
|
+
onSuccess: (result) => {
|
|
197
|
+
invalidate()
|
|
198
|
+
void queryClient.invalidateQueries({ queryKey: trackerKeys.history(workspaceId, issue?.id ?? '') })
|
|
199
|
+
// A transition that needs sign-off does not move the issue; it returns the approval it is
|
|
200
|
+
// waiting on. Without refetching approvals — and saying so — the click looks like it did
|
|
201
|
+
// nothing at all.
|
|
202
|
+
if (result.approval) {
|
|
203
|
+
void queryClient.invalidateQueries({
|
|
204
|
+
queryKey: trackerKeys.approvals(workspaceId, issue?.id ?? ''),
|
|
205
|
+
})
|
|
206
|
+
toast.info(t('approval_requested_toast'))
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
onError: (error: Error) => toast.error(error.message),
|
|
210
|
+
}))
|
|
211
|
+
|
|
212
|
+
const comment = createMutation(() => ({
|
|
213
|
+
mutationFn: async (input: { body: RichDoc; fileIds: string[]; internal: boolean; parentId?: string }) => {
|
|
214
|
+
const created = await api.issues.comments.create({
|
|
215
|
+
workspaceId,
|
|
216
|
+
issueId: issue?.id as string,
|
|
217
|
+
body: input.body,
|
|
218
|
+
internal: input.internal,
|
|
219
|
+
...(input.parentId ? { parentId: input.parentId } : {}),
|
|
220
|
+
})
|
|
221
|
+
// The comment has to exist before a file can point at it, so the upload happens in the
|
|
222
|
+
// composer and the attachment is claimed here.
|
|
223
|
+
if (input.fileIds.length)
|
|
224
|
+
await api.issues.attachments.add({
|
|
225
|
+
workspaceId,
|
|
226
|
+
issueId: issue?.id as string,
|
|
227
|
+
fileIds: input.fileIds,
|
|
228
|
+
commentId: created.id,
|
|
229
|
+
})
|
|
230
|
+
return created
|
|
231
|
+
},
|
|
232
|
+
onSuccess: () => {
|
|
233
|
+
refreshComments()
|
|
234
|
+
invalidate()
|
|
235
|
+
},
|
|
236
|
+
onError: (error: Error) => toast.error(error.message),
|
|
237
|
+
}))
|
|
238
|
+
|
|
239
|
+
const attachmentsQuery = createQuery(() => ({
|
|
240
|
+
queryKey: trackerKeys.attachments(workspaceId, issueId ?? ''),
|
|
241
|
+
queryFn: () => api.issues.attachments.list({ workspaceId, issueId: issueId as string }),
|
|
242
|
+
enabled: Boolean(issueId),
|
|
243
|
+
}))
|
|
244
|
+
const attachments = $derived(attachmentsQuery.data ?? [])
|
|
245
|
+
/** Files that arrived with a comment, so each comment can show its own. */
|
|
246
|
+
const attachmentsByComment = $derived.by(() => {
|
|
247
|
+
const byComment = new Map<string, typeof attachments>()
|
|
248
|
+
for (const file of attachments) {
|
|
249
|
+
if (!file.commentId) continue
|
|
250
|
+
byComment.set(file.commentId, [...(byComment.get(file.commentId) ?? []), file])
|
|
251
|
+
}
|
|
252
|
+
return byComment
|
|
253
|
+
})
|
|
254
|
+
const issueAttachments = $derived(attachments.filter((f) => !f.commentId))
|
|
255
|
+
|
|
256
|
+
const refreshComments = () => {
|
|
257
|
+
void queryClient.invalidateQueries({ queryKey: trackerKeys.comments(workspaceId, issue?.id ?? '') })
|
|
258
|
+
void queryClient.invalidateQueries({
|
|
259
|
+
queryKey: trackerKeys.attachments(workspaceId, issue?.id ?? ''),
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const editComment = createMutation(() => ({
|
|
264
|
+
mutationFn: (input: { commentId: string; body: RichDoc }) =>
|
|
265
|
+
api.issues.comments.update({ workspaceId, ...input }),
|
|
266
|
+
onSuccess: refreshComments,
|
|
267
|
+
onError: (error: Error) => toast.error(error.message),
|
|
268
|
+
}))
|
|
269
|
+
|
|
270
|
+
const deleteComment = createMutation(() => ({
|
|
271
|
+
mutationFn: (commentId: string) => api.issues.comments.delete({ workspaceId, commentId }),
|
|
272
|
+
onSuccess: refreshComments,
|
|
273
|
+
onError: (error: Error) => toast.error(error.message),
|
|
274
|
+
}))
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Triage: what to do with something that arrived from outside.
|
|
278
|
+
*
|
|
279
|
+
* The three actions have existed on the server since intake did and nothing offered them, so an
|
|
280
|
+
* issue raised by email or through the public form sat in triage with no way out but editing its
|
|
281
|
+
* status by hand — which skips the workflow's initial status and the decline comment entirely.
|
|
282
|
+
*/
|
|
283
|
+
const triage = createMutation(() => ({
|
|
284
|
+
mutationFn: (input: { action: 'accept' | 'decline' | 'snooze'; until?: string }) => {
|
|
285
|
+
const issueId = issue?.id as string
|
|
286
|
+
if (input.action === 'accept') return api.triage.accept({ workspaceId, issueId })
|
|
287
|
+
if (input.action === 'decline') return api.triage.decline({ workspaceId, issueId })
|
|
288
|
+
return api.triage.snooze({ workspaceId, issueId, until: input.until as string })
|
|
289
|
+
},
|
|
290
|
+
onSuccess: () => {
|
|
291
|
+
invalidate()
|
|
292
|
+
void queryClient.invalidateQueries({ queryKey: trackerKeys.history(workspaceId, issue?.id ?? '') })
|
|
293
|
+
},
|
|
294
|
+
onError: (error: Error) => toast.error(error.message),
|
|
295
|
+
}))
|
|
296
|
+
|
|
297
|
+
/** A week is the interval somebody means by "not now"; anything finer belongs on a date picker. */
|
|
298
|
+
const nextWeek = () => new Date(Date.now() + 7 * 86_400_000).toISOString()
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* What happens to an issue as a whole: move it, archive it, delete it.
|
|
302
|
+
*
|
|
303
|
+
* All three had a server and no control, so an issue raised in the wrong project stayed there and
|
|
304
|
+
* a mistake could never be taken back. Archiving is the ordinary one — it leaves the issue and its
|
|
305
|
+
* history intact and off every list — so it is the one offered first; deleting is behind a
|
|
306
|
+
* confirmation that says what goes with it.
|
|
307
|
+
*/
|
|
308
|
+
const moveIssue = createMutation(() => ({
|
|
309
|
+
mutationFn: (projectId: string) =>
|
|
310
|
+
api.issues.move({ workspaceId, issueId: issue?.id as string, projectId }),
|
|
311
|
+
onSuccess: (moved: Issue) => {
|
|
312
|
+
invalidate()
|
|
313
|
+
// The key changed with the project, so the panel is now pointed at a key that no longer
|
|
314
|
+
// exists. Following it is the only thing that leaves the screen showing the same issue.
|
|
315
|
+
toast.success(t('issue_moved', { key: moved.key }))
|
|
316
|
+
onopenissue(moved.key)
|
|
317
|
+
},
|
|
318
|
+
onError: (error: Error) => toast.error(error.message),
|
|
319
|
+
}))
|
|
320
|
+
|
|
321
|
+
const archiveIssue = createMutation(() => ({
|
|
322
|
+
mutationFn: (archived: boolean) =>
|
|
323
|
+
api.issues.archive({ workspaceId, issueId: issue?.id as string, archived }),
|
|
324
|
+
onSuccess: () => {
|
|
325
|
+
invalidate()
|
|
326
|
+
void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue', workspaceId] })
|
|
327
|
+
},
|
|
328
|
+
onError: (error: Error) => toast.error(error.message),
|
|
329
|
+
}))
|
|
330
|
+
|
|
331
|
+
const deleteIssue = createMutation(() => ({
|
|
332
|
+
mutationFn: () => api.issues.delete({ workspaceId, issueId: issue?.id as string }),
|
|
333
|
+
onSuccess: () => {
|
|
334
|
+
confirmingDelete = false
|
|
335
|
+
void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue', workspaceId] })
|
|
336
|
+
toast.success(t('issue_deleted'))
|
|
337
|
+
onclose()
|
|
338
|
+
},
|
|
339
|
+
onError: (error: Error) => toast.error(error.message),
|
|
340
|
+
}))
|
|
341
|
+
|
|
342
|
+
const issueMenu = $derived<MenuItem[]>([
|
|
343
|
+
{
|
|
344
|
+
id: 'move',
|
|
345
|
+
label: t('issue_move'),
|
|
346
|
+
icon: 'arrow-right',
|
|
347
|
+
disabled: !canEdit,
|
|
348
|
+
children: cat.projects
|
|
349
|
+
.filter((p) => p.id !== issue?.projectId)
|
|
350
|
+
.map((p) => ({
|
|
351
|
+
id: p.id,
|
|
352
|
+
label: `${p.key} · ${p.name}`,
|
|
353
|
+
onSelect: () => moveIssue.mutate(p.id),
|
|
354
|
+
})),
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: 'archive',
|
|
358
|
+
label: issue?.archivedAt ? t('issue_unarchive') : t('common.archive'),
|
|
359
|
+
icon: 'archive',
|
|
360
|
+
disabled: !canEdit,
|
|
361
|
+
onSelect: () => archiveIssue.mutate(!issue?.archivedAt),
|
|
362
|
+
},
|
|
363
|
+
{ type: 'separator' },
|
|
364
|
+
{
|
|
365
|
+
id: 'delete',
|
|
366
|
+
label: t('common.delete'),
|
|
367
|
+
icon: 'trash-2',
|
|
368
|
+
danger: true,
|
|
369
|
+
disabled: !canEdit,
|
|
370
|
+
onSelect: () => (confirmingDelete = true),
|
|
371
|
+
},
|
|
372
|
+
])
|
|
373
|
+
|
|
374
|
+
const removeAttachment = createMutation(() => ({
|
|
375
|
+
mutationFn: (attachmentId: string) => api.issues.attachments.remove({ workspaceId, attachmentId }),
|
|
376
|
+
onSuccess: refreshComments,
|
|
377
|
+
onError: (error: Error) => toast.error(error.message),
|
|
378
|
+
}))
|
|
379
|
+
|
|
380
|
+
const addIssueFiles = createMutation(() => ({
|
|
381
|
+
mutationFn: (fileIds: string[]) =>
|
|
382
|
+
api.issues.attachments.add({ workspaceId, issueId: issue?.id as string, fileIds }),
|
|
383
|
+
onSuccess: refreshComments,
|
|
384
|
+
onError: (error: Error) => toast.error(error.message),
|
|
385
|
+
}))
|
|
386
|
+
|
|
387
|
+
const react = createMutation(() => ({
|
|
388
|
+
mutationFn: (input: { commentId: string; emoji: string }) =>
|
|
389
|
+
api.issues.comments.react({ workspaceId, ...input }),
|
|
390
|
+
onSuccess: () =>
|
|
391
|
+
queryClient.invalidateQueries({ queryKey: trackerKeys.comments(workspaceId, issue?.id ?? '') }),
|
|
392
|
+
}))
|
|
393
|
+
|
|
394
|
+
const watch = createMutation(() => ({
|
|
395
|
+
mutationFn: (next: boolean) =>
|
|
396
|
+
next
|
|
397
|
+
? api.issues.watchers.add({ workspaceId, issueId: issue?.id as string })
|
|
398
|
+
: api.issues.watchers.remove({
|
|
399
|
+
workspaceId,
|
|
400
|
+
issueId: issue?.id as string,
|
|
401
|
+
userId: session.user?.id as string,
|
|
402
|
+
}),
|
|
403
|
+
onSuccess: invalidate,
|
|
404
|
+
}))
|
|
405
|
+
|
|
406
|
+
const statusMenu = $derived<MenuItem[]>(
|
|
407
|
+
(transitionsQuery.data ?? [])
|
|
408
|
+
.filter((tr: AvailableTransition) => !tr.hidden)
|
|
409
|
+
.map((tr: AvailableTransition) => ({
|
|
410
|
+
id: tr.id,
|
|
411
|
+
label: tr.toStatus.name,
|
|
412
|
+
disabled: !tr.allowed,
|
|
413
|
+
hint: tr.allowed ? undefined : (tr.reasons[0]?.message ?? undefined),
|
|
414
|
+
onSelect: () => transition.mutate(tr.id),
|
|
415
|
+
})),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
const priorityMenu = $derived<MenuItem[]>(
|
|
419
|
+
PRIORITY_GROUP_ORDER.map((p: Priority) => ({
|
|
420
|
+
id: p,
|
|
421
|
+
label: priorityLabel(p),
|
|
422
|
+
icon: issue?.priority === p ? 'check' : undefined,
|
|
423
|
+
onSelect: () => patch.mutate({ priority: p }),
|
|
424
|
+
})),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
const assigneeMenu = $derived<MenuItem[]>(
|
|
428
|
+
cat.people.map((person) => ({
|
|
429
|
+
type: 'checkbox' as const,
|
|
430
|
+
id: person.id,
|
|
431
|
+
label: person.name,
|
|
432
|
+
// a face is recognised faster than a name, and this menu is how work is handed over
|
|
433
|
+
avatar: { id: person.id, name: person.name, src: person.avatarUrl },
|
|
434
|
+
checked: issue?.assigneeIds.some((id) => id === person.id) ?? false,
|
|
435
|
+
onCheckedChange: (on: boolean) =>
|
|
436
|
+
patch.mutate(on ? { assigneeAdd: [person.id] } : { assigneeRemove: [person.id] }),
|
|
437
|
+
})),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
const labelMenu = $derived<MenuItem[]>(
|
|
441
|
+
cat.labels.map((label) => ({
|
|
442
|
+
type: 'checkbox' as const,
|
|
443
|
+
id: label.id,
|
|
444
|
+
label: label.name,
|
|
445
|
+
checked: issue?.labelIds.includes(label.id) ?? false,
|
|
446
|
+
onCheckedChange: (on: boolean) =>
|
|
447
|
+
patch.mutate(on ? { labelAdd: [label.id] } : { labelRemove: [label.id] }),
|
|
448
|
+
})),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
const cycleMenu = $derived<MenuItem[]>([
|
|
452
|
+
{ id: 'none', label: t('no_cycle'), onSelect: () => patch.mutate({ cycleId: null }) },
|
|
453
|
+
{ type: 'separator' as const },
|
|
454
|
+
...cat.cycles
|
|
455
|
+
.filter((c) => c.status !== 'completed')
|
|
456
|
+
.map((c) => ({
|
|
457
|
+
id: c.id,
|
|
458
|
+
label: c.name,
|
|
459
|
+
icon: issue?.cycleId === c.id ? 'check' : undefined,
|
|
460
|
+
onSelect: () => patch.mutate({ cycleId: c.id }),
|
|
461
|
+
})),
|
|
462
|
+
])
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* What this project estimates in.
|
|
466
|
+
*
|
|
467
|
+
* A project chooses points or hours — or neither — and the issue keeps the unit it was raised
|
|
468
|
+
* under. The panel said "pts" whatever the project had chosen, and offered to estimate work in a
|
|
469
|
+
* project whose setting is "do not estimate".
|
|
470
|
+
*/
|
|
471
|
+
const estimateUnit = $derived(estimateUnitOf(project ?? null))
|
|
472
|
+
|
|
473
|
+
const estimateMenu = $derived<MenuItem[]>([
|
|
474
|
+
{ id: 'none', label: t('no_estimate'), onSelect: () => patch.mutate({ estimate: null }) },
|
|
475
|
+
{ type: 'separator' as const },
|
|
476
|
+
...(project?.settings.estimateScale ?? [1, 2, 3, 5, 8]).map((value) => ({
|
|
477
|
+
id: String(value),
|
|
478
|
+
label: estimateLabel(value, estimateUnit),
|
|
479
|
+
icon: issue?.estimate === value ? 'check' : undefined,
|
|
480
|
+
onSelect: () => patch.mutate({ estimate: value }),
|
|
481
|
+
})),
|
|
482
|
+
])
|
|
483
|
+
|
|
484
|
+
/** Comments and history interleaved, oldest first, the way the design shows the activity feed. */
|
|
485
|
+
/** Replies, grouped under the comment they answer — the feed shows only top-level comments. */
|
|
486
|
+
const repliesOf = $derived.by(() => {
|
|
487
|
+
const byParent = new Map<string, IssueComment[]>()
|
|
488
|
+
for (const c of commentsQuery.data?.items ?? []) {
|
|
489
|
+
if (!c.parentId) continue
|
|
490
|
+
byParent.set(c.parentId, [...(byParent.get(c.parentId) ?? []), c])
|
|
491
|
+
}
|
|
492
|
+
return byParent
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
const activity = $derived.by(() => {
|
|
496
|
+
const all = commentsQuery.data?.items ?? []
|
|
497
|
+
const comments = all
|
|
498
|
+
.filter((c) => !c.parentId)
|
|
499
|
+
.map((c) => ({ kind: 'comment' as const, at: c.createdAt, comment: c }))
|
|
500
|
+
const events = (historyQuery.data?.items ?? []).map((h) => ({
|
|
501
|
+
kind: 'event' as const,
|
|
502
|
+
at: h.occurredAt,
|
|
503
|
+
event: h,
|
|
504
|
+
}))
|
|
505
|
+
return [...comments, ...events].sort((a, b) => a.at.localeCompare(b.at))
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
function startEditingDescription(current: Issue) {
|
|
509
|
+
descriptionDraft = current.description ?? null
|
|
510
|
+
editingDescription = true
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function saveDescription() {
|
|
514
|
+
/*
|
|
515
|
+
* `$state.snapshot` because the editor's document is a Svelte state proxy, and a proxy cannot be
|
|
516
|
+
* passed to `structuredClone` — which is what the API layer does on the way out. Without it the
|
|
517
|
+
* save threw "could not be cloned" and silently dropped the edit.
|
|
518
|
+
*/
|
|
519
|
+
patch.mutate({ description: $state.snapshot(descriptionDraft) as RichDoc | null })
|
|
520
|
+
editingDescription = false
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function describeEvent(action: string, changes: Array<{ field: string; to: unknown }>): string {
|
|
524
|
+
if (action === 'created') return t('event_created')
|
|
525
|
+
const change = changes[0]
|
|
526
|
+
if (action === 'status_changed' && change) {
|
|
527
|
+
return t('event_status', { status: cat.status(String(change.to))?.name ?? String(change.to) })
|
|
528
|
+
}
|
|
529
|
+
return t('event_updated', { field: change?.field ?? '' })
|
|
530
|
+
}
|
|
531
|
+
</script>
|
|
532
|
+
|
|
533
|
+
<Sheet
|
|
534
|
+
open={issueKey !== null}
|
|
535
|
+
inline
|
|
536
|
+
width={440}
|
|
537
|
+
onOpenChange={(open) => {
|
|
538
|
+
if (!open) onclose()
|
|
539
|
+
}}
|
|
540
|
+
>
|
|
541
|
+
{#snippet header()}
|
|
542
|
+
{#if issue}
|
|
543
|
+
<StatusIcon
|
|
544
|
+
category={issue.statusCategory}
|
|
545
|
+
statusId={issue.statusId}
|
|
546
|
+
name={status?.name}
|
|
547
|
+
size={16}
|
|
548
|
+
/>
|
|
549
|
+
<span class="hkey">{issue.key}</span>
|
|
550
|
+
<span class="hstatus">{status?.name ?? ''}</span>
|
|
551
|
+
{/if}
|
|
552
|
+
{/snippet}
|
|
553
|
+
|
|
554
|
+
<!-- The menu belongs beside the close button, not beside the title: a spacer of our own here
|
|
555
|
+
and `Sheet`'s own one split the row and stranded it mid-header. -->
|
|
556
|
+
{#snippet actions()}
|
|
557
|
+
{#if issue}
|
|
558
|
+
<DropdownMenu items={issueMenu} align="end">
|
|
559
|
+
{#snippet trigger(props)}
|
|
560
|
+
<button
|
|
561
|
+
{...props}
|
|
562
|
+
type="button"
|
|
563
|
+
class="hmore"
|
|
564
|
+
aria-label={t('issue_actions')}
|
|
565
|
+
data-testid="issue-actions"
|
|
566
|
+
>
|
|
567
|
+
<Icon name="ellipsis" size={15} strokeWidth={1.8} />
|
|
568
|
+
</button>
|
|
569
|
+
{/snippet}
|
|
570
|
+
</DropdownMenu>
|
|
571
|
+
{/if}
|
|
572
|
+
{/snippet}
|
|
573
|
+
|
|
574
|
+
{#if !issue}
|
|
575
|
+
<div class="loading"><Spinner /></div>
|
|
576
|
+
{:else}
|
|
577
|
+
{#if confirmingDelete}
|
|
578
|
+
<div class="danger" role="alertdialog" aria-label={t('issue_delete_body')}>
|
|
579
|
+
<p>{t('issue_delete_body')}</p>
|
|
580
|
+
<div class="drow">
|
|
581
|
+
<Button
|
|
582
|
+
size="sm"
|
|
583
|
+
variant="danger"
|
|
584
|
+
loading={deleteIssue.isPending}
|
|
585
|
+
onclick={() => deleteIssue.mutate()}
|
|
586
|
+
data-testid="issue-delete-confirm"
|
|
587
|
+
>
|
|
588
|
+
{t('common.delete')}
|
|
589
|
+
</Button>
|
|
590
|
+
<Button size="sm" variant="ghost" onclick={() => (confirmingDelete = false)}>
|
|
591
|
+
{t('common.cancel')}
|
|
592
|
+
</Button>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
{/if}
|
|
596
|
+
|
|
597
|
+
<h1 class="title">{issue.title}</h1>
|
|
598
|
+
|
|
599
|
+
{#snippet row_status()}
|
|
600
|
+
<dt>{t('detail_status')}</dt>
|
|
601
|
+
<dd>
|
|
602
|
+
<DropdownMenu items={canTransition ? statusMenu : []} align="start">
|
|
603
|
+
{#snippet trigger(props)}
|
|
604
|
+
<button
|
|
605
|
+
{...props}
|
|
606
|
+
type="button"
|
|
607
|
+
class="val"
|
|
608
|
+
class:static={!canTransition}
|
|
609
|
+
disabled={!canTransition}
|
|
610
|
+
title={canTransition ? undefined : t('no_permission')}
|
|
611
|
+
data-testid="status-picker"
|
|
612
|
+
>
|
|
613
|
+
<StatusIcon
|
|
614
|
+
category={issue.statusCategory}
|
|
615
|
+
statusId={issue.statusId}
|
|
616
|
+
name={status?.name}
|
|
617
|
+
size={15}
|
|
618
|
+
/>
|
|
619
|
+
{status?.name ?? issue.statusId}
|
|
620
|
+
</button>
|
|
621
|
+
{/snippet}
|
|
622
|
+
</DropdownMenu>
|
|
623
|
+
</dd>
|
|
624
|
+
|
|
625
|
+
{/snippet}
|
|
626
|
+
|
|
627
|
+
{#snippet row_assignees()}
|
|
628
|
+
<dt>{t('detail_assignee')}</dt>
|
|
629
|
+
<dd>
|
|
630
|
+
<DropdownMenu items={canEdit ? assigneeMenu : []} align="start">
|
|
631
|
+
{#snippet trigger(props)}
|
|
632
|
+
<button
|
|
633
|
+
{...props}
|
|
634
|
+
type="button"
|
|
635
|
+
class="val"
|
|
636
|
+
class:static={!canEdit}
|
|
637
|
+
disabled={!canEdit}
|
|
638
|
+
title={canEdit ? undefined : t('no_permission')}>
|
|
639
|
+
{#if issue.assigneeIds.length === 0}
|
|
640
|
+
<span class="muted">{t('unassigned')}</span>
|
|
641
|
+
{:else}
|
|
642
|
+
{#each issue.assigneeIds as id (id)}
|
|
643
|
+
{@const person = cat.person(id)}
|
|
644
|
+
<Avatar name={person?.name} src={person?.avatarUrl} {id} size={22} />
|
|
645
|
+
{/each}
|
|
646
|
+
<span>{cat.person(issue.assigneeIds[0])?.name ?? ''}</span>
|
|
647
|
+
{#if issue.assigneeIds.length > 1}
|
|
648
|
+
<span class="muted">+{issue.assigneeIds.length - 1}</span>
|
|
649
|
+
{/if}
|
|
650
|
+
{/if}
|
|
651
|
+
</button>
|
|
652
|
+
{/snippet}
|
|
653
|
+
</DropdownMenu>
|
|
654
|
+
</dd>
|
|
655
|
+
|
|
656
|
+
{/snippet}
|
|
657
|
+
|
|
658
|
+
{#snippet row_priority()}
|
|
659
|
+
<dt>{t('detail_priority')}</dt>
|
|
660
|
+
<dd>
|
|
661
|
+
<DropdownMenu items={canEdit ? priorityMenu : []} align="start">
|
|
662
|
+
{#snippet trigger(props)}
|
|
663
|
+
<button
|
|
664
|
+
{...props}
|
|
665
|
+
type="button"
|
|
666
|
+
class="val"
|
|
667
|
+
class:static={!canEdit}
|
|
668
|
+
disabled={!canEdit}
|
|
669
|
+
title={canEdit ? undefined : t('no_permission')}>
|
|
670
|
+
<PriorityGlyph priority={issue.priority} size={15} />
|
|
671
|
+
{priorityLabel(issue.priority)}
|
|
672
|
+
</button>
|
|
673
|
+
{/snippet}
|
|
674
|
+
</DropdownMenu>
|
|
675
|
+
</dd>
|
|
676
|
+
|
|
677
|
+
{/snippet}
|
|
678
|
+
|
|
679
|
+
{#snippet row_dueDate()}
|
|
680
|
+
<dt>{t('detail_due')}</dt>
|
|
681
|
+
<dd>
|
|
682
|
+
<label class="val date" class:static={!canEdit}>
|
|
683
|
+
{#if issue.dueDate}
|
|
684
|
+
<DueDate date={issue.dueDate} />
|
|
685
|
+
{:else}
|
|
686
|
+
<span class="muted">{t('no_due')}</span>
|
|
687
|
+
{/if}
|
|
688
|
+
<input
|
|
689
|
+
type="date"
|
|
690
|
+
value={issue.dueDate ?? ''}
|
|
691
|
+
aria-label={t('detail_due')}
|
|
692
|
+
disabled={!canEdit}
|
|
693
|
+
onchange={(e) => patch.mutate({ dueDate: e.currentTarget.value || null })}
|
|
694
|
+
/>
|
|
695
|
+
</label>
|
|
696
|
+
</dd>
|
|
697
|
+
|
|
698
|
+
{/snippet}
|
|
699
|
+
|
|
700
|
+
{#snippet row_labels()}
|
|
701
|
+
<dt>{t('detail_labels')}</dt>
|
|
702
|
+
<dd>
|
|
703
|
+
<DropdownMenu items={labelMenu} align="start">
|
|
704
|
+
{#snippet trigger(props)}
|
|
705
|
+
<button {...props} type="button" class="val wrap">
|
|
706
|
+
{#if issue.labelIds.length === 0}
|
|
707
|
+
<span class="muted">{t('no_label')}</span>
|
|
708
|
+
{:else}
|
|
709
|
+
{#each issue.labelIds as id (id)}
|
|
710
|
+
{@const label = cat.label(id)}
|
|
711
|
+
<span class="lbl">
|
|
712
|
+
<span class="swatch" style:background={label?.color ?? 'var(--kern-ink-330)'}></span>
|
|
713
|
+
{label?.name ?? ''}
|
|
714
|
+
</span>
|
|
715
|
+
{/each}
|
|
716
|
+
{/if}
|
|
717
|
+
</button>
|
|
718
|
+
{/snippet}
|
|
719
|
+
</DropdownMenu>
|
|
720
|
+
</dd>
|
|
721
|
+
|
|
722
|
+
{/snippet}
|
|
723
|
+
|
|
724
|
+
{#snippet row_cycle()}
|
|
725
|
+
<dt>{t('detail_cycle')}</dt>
|
|
726
|
+
<dd>
|
|
727
|
+
<DropdownMenu items={canEdit ? cycleMenu : []} align="start">
|
|
728
|
+
{#snippet trigger(props)}
|
|
729
|
+
<button
|
|
730
|
+
{...props}
|
|
731
|
+
type="button"
|
|
732
|
+
class="val"
|
|
733
|
+
class:static={!canEdit}
|
|
734
|
+
disabled={!canEdit}
|
|
735
|
+
title={canEdit ? undefined : t('no_permission')}>
|
|
736
|
+
{#if cycle}{cycle.name}{:else}<span class="muted">{t('no_cycle')}</span>{/if}
|
|
737
|
+
</button>
|
|
738
|
+
{/snippet}
|
|
739
|
+
</DropdownMenu>
|
|
740
|
+
</dd>
|
|
741
|
+
|
|
742
|
+
{/snippet}
|
|
743
|
+
|
|
744
|
+
{#snippet row_estimate()}
|
|
745
|
+
<dt>{t('detail_estimate')}</dt>
|
|
746
|
+
<dd>
|
|
747
|
+
<DropdownMenu items={canEdit ? estimateMenu : []} align="start">
|
|
748
|
+
{#snippet trigger(props)}
|
|
749
|
+
<button
|
|
750
|
+
{...props}
|
|
751
|
+
type="button"
|
|
752
|
+
class="val"
|
|
753
|
+
class:static={!canEdit}
|
|
754
|
+
disabled={!canEdit}
|
|
755
|
+
title={canEdit ? undefined : t('no_permission')}>
|
|
756
|
+
{#if issue.estimate !== null}
|
|
757
|
+
{estimateLabel(issue.estimate, issue.estimateUnit)}
|
|
758
|
+
{:else}
|
|
759
|
+
<span class="muted">{t('no_estimate')}</span>
|
|
760
|
+
{/if}
|
|
761
|
+
</button>
|
|
762
|
+
{/snippet}
|
|
763
|
+
</DropdownMenu>
|
|
764
|
+
</dd>
|
|
765
|
+
|
|
766
|
+
{/snippet}
|
|
767
|
+
|
|
768
|
+
{#snippet row_project()}
|
|
769
|
+
<dt>{t('detail_project')}</dt>
|
|
770
|
+
<dd><span class="val static">{project?.name ?? ''}</span></dd>
|
|
771
|
+
{/snippet}
|
|
772
|
+
|
|
773
|
+
<dl class="props">
|
|
774
|
+
<!-- Dispatched by field id: the layout decides which rows appear and in what order, and each
|
|
775
|
+
built-in control is the same one the panel always had. -->
|
|
776
|
+
{#each sidebarFields as f (f.fieldId)}
|
|
777
|
+
{#if f.fieldId === 'status'}
|
|
778
|
+
{@render row_status()}
|
|
779
|
+
{:else if f.fieldId === 'assignees'}
|
|
780
|
+
{@render row_assignees()}
|
|
781
|
+
{:else if f.fieldId === 'priority'}
|
|
782
|
+
{@render row_priority()}
|
|
783
|
+
{:else if f.fieldId === 'dueDate'}
|
|
784
|
+
{@render row_dueDate()}
|
|
785
|
+
{:else if f.fieldId === 'labels'}
|
|
786
|
+
{@render row_labels()}
|
|
787
|
+
{:else if f.fieldId === 'cycle'}
|
|
788
|
+
{@render row_cycle()}
|
|
789
|
+
{:else if f.fieldId === 'estimate'}
|
|
790
|
+
<!-- A project that estimates in nothing is not offered an estimate to set. -->
|
|
791
|
+
{#if estimateUnit !== 'none' || issue.estimate !== null}
|
|
792
|
+
{@render row_estimate()}
|
|
793
|
+
{/if}
|
|
794
|
+
{:else if f.fieldId === 'project'}
|
|
795
|
+
{@render row_project()}
|
|
796
|
+
{:else if f.kind === 'custom' && f.field}
|
|
797
|
+
<dt>{f.label}</dt>
|
|
798
|
+
<dd>
|
|
799
|
+
<CustomField
|
|
800
|
+
field={f.field}
|
|
801
|
+
value={issue.custom?.[f.field.key] ?? null}
|
|
802
|
+
editable={canEdit}
|
|
803
|
+
required={f.required}
|
|
804
|
+
{workspaceId}
|
|
805
|
+
issueId={issue.id}
|
|
806
|
+
onchange={(value) => patch.mutate({ custom: { [f.field!.key]: value } })}
|
|
807
|
+
/>
|
|
808
|
+
</dd>
|
|
809
|
+
{/if}
|
|
810
|
+
{/each}
|
|
811
|
+
</dl>
|
|
812
|
+
|
|
813
|
+
<IssueTime {workspaceId} issueId={issue.id} canLog={canEdit} />
|
|
814
|
+
|
|
815
|
+
<IssueApprovals
|
|
816
|
+
{workspaceId}
|
|
817
|
+
issueId={issue.id}
|
|
818
|
+
oninvalidate={() => {
|
|
819
|
+
invalidate()
|
|
820
|
+
void queryClient.invalidateQueries({
|
|
821
|
+
queryKey: trackerKeys.transitions(workspaceId, issue.id),
|
|
822
|
+
})
|
|
823
|
+
}}
|
|
824
|
+
/>
|
|
825
|
+
|
|
826
|
+
<IssueConnections
|
|
827
|
+
{workspaceId}
|
|
828
|
+
{issue}
|
|
829
|
+
{canEdit}
|
|
830
|
+
onopen={onopenissue}
|
|
831
|
+
/>
|
|
832
|
+
|
|
833
|
+
{#if issueAttachments.length || canEdit}
|
|
834
|
+
<section class="attachments">
|
|
835
|
+
<div class="ahead">
|
|
836
|
+
<span class="kern-sublabel">{t('attachments')}</span>
|
|
837
|
+
{#if canEdit}
|
|
838
|
+
<input
|
|
839
|
+
bind:this={issueFileInput}
|
|
840
|
+
type="file"
|
|
841
|
+
multiple
|
|
842
|
+
hidden
|
|
843
|
+
onchange={(e) => {
|
|
844
|
+
void uploadIssueFiles(e.currentTarget.files)
|
|
845
|
+
e.currentTarget.value = ''
|
|
846
|
+
}}
|
|
847
|
+
/>
|
|
848
|
+
<IconButton
|
|
849
|
+
icon="paperclip"
|
|
850
|
+
size={26}
|
|
851
|
+
label={t('attach_file')}
|
|
852
|
+
onclick={() => issueFileInput?.click()}
|
|
853
|
+
/>
|
|
854
|
+
{/if}
|
|
855
|
+
</div>
|
|
856
|
+
{#if issueAttachments.length}
|
|
857
|
+
<ul class="afiles">
|
|
858
|
+
{#each issueAttachments as file (file.id)}
|
|
859
|
+
<li>
|
|
860
|
+
<Icon name="paperclip" size={13} strokeWidth={1.8} />
|
|
861
|
+
<span class="aname" title={file.name}>{file.name}</span>
|
|
862
|
+
<span class="asize">{formatBytes(file.size)}</span>
|
|
863
|
+
{#if canEdit}
|
|
864
|
+
<IconButton
|
|
865
|
+
icon="x"
|
|
866
|
+
size={22}
|
|
867
|
+
label={t('attachment_remove')}
|
|
868
|
+
onclick={() => removeAttachment.mutate(file.id)}
|
|
869
|
+
/>
|
|
870
|
+
{/if}
|
|
871
|
+
</li>
|
|
872
|
+
{/each}
|
|
873
|
+
</ul>
|
|
874
|
+
{:else}
|
|
875
|
+
<p class="empty">{t('attachments_empty')}</p>
|
|
876
|
+
{/if}
|
|
877
|
+
</section>
|
|
878
|
+
{/if}
|
|
879
|
+
|
|
880
|
+
{#if issue.triage && canTriage}
|
|
881
|
+
<section class="triage" data-testid="triage">
|
|
882
|
+
<div class="thead">
|
|
883
|
+
<Icon name="inbox" size={14} strokeWidth={1.8} />
|
|
884
|
+
<span>{t('triage_waiting')}</span>
|
|
885
|
+
</div>
|
|
886
|
+
<p class="tnote">{t('triage_hint')}</p>
|
|
887
|
+
<div class="row">
|
|
888
|
+
<Button
|
|
889
|
+
size="sm"
|
|
890
|
+
disabled={triage.isPending}
|
|
891
|
+
onclick={() => triage.mutate({ action: 'accept' })}
|
|
892
|
+
data-testid="triage-accept"
|
|
893
|
+
>
|
|
894
|
+
{t('triage_accept')}
|
|
895
|
+
</Button>
|
|
896
|
+
<Button
|
|
897
|
+
size="sm"
|
|
898
|
+
variant="ghost"
|
|
899
|
+
disabled={triage.isPending}
|
|
900
|
+
onclick={() => triage.mutate({ action: 'snooze', until: nextWeek() })}
|
|
901
|
+
>
|
|
902
|
+
{t('triage_snooze')}
|
|
903
|
+
</Button>
|
|
904
|
+
<Button
|
|
905
|
+
size="sm"
|
|
906
|
+
variant="ghost"
|
|
907
|
+
disabled={triage.isPending}
|
|
908
|
+
onclick={() => triage.mutate({ action: 'decline' })}
|
|
909
|
+
data-testid="triage-decline"
|
|
910
|
+
>
|
|
911
|
+
{t('triage_decline')}
|
|
912
|
+
</Button>
|
|
913
|
+
</div>
|
|
914
|
+
</section>
|
|
915
|
+
{/if}
|
|
916
|
+
|
|
917
|
+
{#if mainFields.length}
|
|
918
|
+
<dl class="props main" data-testid="main-fields">
|
|
919
|
+
{#each mainFields as f (f.fieldId)}
|
|
920
|
+
<dt>{f.label}</dt>
|
|
921
|
+
<dd>
|
|
922
|
+
<CustomField
|
|
923
|
+
field={f.field!}
|
|
924
|
+
value={issue.custom?.[f.field!.key] ?? null}
|
|
925
|
+
editable={canEdit}
|
|
926
|
+
required={f.required}
|
|
927
|
+
{workspaceId}
|
|
928
|
+
issueId={issue.id}
|
|
929
|
+
onchange={(value) => patch.mutate({ custom: { [f.field!.key]: value } })}
|
|
930
|
+
/>
|
|
931
|
+
</dd>
|
|
932
|
+
{/each}
|
|
933
|
+
</dl>
|
|
934
|
+
{/if}
|
|
935
|
+
|
|
936
|
+
<section class="desc">
|
|
937
|
+
{#if editingDescription}
|
|
938
|
+
<RichTextEditor
|
|
939
|
+
bind:value={descriptionDraft}
|
|
940
|
+
minRows={6}
|
|
941
|
+
autofocus
|
|
942
|
+
label={t('detail_description')}
|
|
943
|
+
placeholder={t('no_description')}
|
|
944
|
+
onsubmit={saveDescription}
|
|
945
|
+
onescape={() => (editingDescription = false)}
|
|
946
|
+
/>
|
|
947
|
+
<div class="row">
|
|
948
|
+
<Button size="sm" onclick={saveDescription}>{t('common.save')}</Button>
|
|
949
|
+
<Button size="sm" variant="ghost" onclick={() => (editingDescription = false)}>
|
|
950
|
+
{t('common.cancel')}
|
|
951
|
+
</Button>
|
|
952
|
+
</div>
|
|
953
|
+
{:else}
|
|
954
|
+
<button
|
|
955
|
+
type="button"
|
|
956
|
+
class="kern-prose prose"
|
|
957
|
+
disabled={!canEdit}
|
|
958
|
+
onclick={() => startEditingDescription(issue)}
|
|
959
|
+
>
|
|
960
|
+
{#if issue.description}
|
|
961
|
+
<!-- rendered from stored ProseMirror JSON; see richtext.ts for the allowed nodes -->
|
|
962
|
+
{@html renderDoc(issue.description)}
|
|
963
|
+
{:else}
|
|
964
|
+
<span class="muted">{t('no_description')}</span>
|
|
965
|
+
{/if}
|
|
966
|
+
</button>
|
|
967
|
+
{/if}
|
|
968
|
+
</section>
|
|
969
|
+
|
|
970
|
+
<section class="activity">
|
|
971
|
+
<div class="arow">
|
|
972
|
+
<span class="kern-sublabel">{t('activity')}</span>
|
|
973
|
+
<span class="sp"></span>
|
|
974
|
+
<button type="button" class="watch" onclick={() => watch.mutate(!watching)}>
|
|
975
|
+
<Icon name={watching ? 'eye' : 'eye-off'} size={13} strokeWidth={1.6} />
|
|
976
|
+
{watching ? t('watching', { count: issue.watcherIds.length }) : t('watch')}
|
|
977
|
+
</button>
|
|
978
|
+
</div>
|
|
979
|
+
|
|
980
|
+
{#each activity as entry (entry.kind === 'comment' ? entry.comment.id : entry.event.id)}
|
|
981
|
+
{#if entry.kind === 'comment'}
|
|
982
|
+
<CommentThread
|
|
983
|
+
{workspaceId}
|
|
984
|
+
comment={entry.comment}
|
|
985
|
+
replies={repliesOf.get(entry.comment.id) ?? []}
|
|
986
|
+
attachments={attachmentsByComment.get(entry.comment.id) ?? []}
|
|
987
|
+
{canComment}
|
|
988
|
+
onreact={(commentId, emoji) => react.mutate({ commentId, emoji })}
|
|
989
|
+
onedit={(commentId, body) => editComment.mutate({ commentId, body: body as RichDoc })}
|
|
990
|
+
ondelete={(commentId) => deleteComment.mutate(commentId)}
|
|
991
|
+
onreply={(parentId, body, fileIds) =>
|
|
992
|
+
comment.mutate({ body: body as RichDoc, fileIds, internal: false, parentId })}
|
|
993
|
+
onremoveAttachment={(attachmentId) => removeAttachment.mutate(attachmentId)}
|
|
994
|
+
/>
|
|
995
|
+
{:else}
|
|
996
|
+
{@const person = cat.person(entry.event.actorId)}
|
|
997
|
+
<div class="event">
|
|
998
|
+
<span class="ename">{person?.name ?? t('common.system')}</span>
|
|
999
|
+
{describeEvent(entry.event.action, entry.event.changes)}
|
|
1000
|
+
<time datetime={entry.event.occurredAt}>{relativeTime(entry.event.occurredAt)}</time>
|
|
1001
|
+
</div>
|
|
1002
|
+
{/if}
|
|
1003
|
+
{/each}
|
|
1004
|
+
</section>
|
|
1005
|
+
{/if}
|
|
1006
|
+
|
|
1007
|
+
{#snippet footer()}
|
|
1008
|
+
{#if canComment}
|
|
1009
|
+
<CommentComposer
|
|
1010
|
+
{workspaceId}
|
|
1011
|
+
busy={comment.isPending}
|
|
1012
|
+
onsubmit={(body, fileIds, internal) => comment.mutate({ body, fileIds, internal })}
|
|
1013
|
+
/>
|
|
1014
|
+
{:else}
|
|
1015
|
+
<p class="no-comment">{t('no_permission')}</p>
|
|
1016
|
+
{/if}
|
|
1017
|
+
{/snippet}
|
|
1018
|
+
</Sheet>
|
|
1019
|
+
|
|
1020
|
+
<style>
|
|
1021
|
+
.triage {
|
|
1022
|
+
margin-top: 14px;
|
|
1023
|
+
padding: 10px 12px;
|
|
1024
|
+
border: 1px solid var(--kern-warning);
|
|
1025
|
+
border-radius: var(--kern-r-sm);
|
|
1026
|
+
background: var(--kern-warning-tint, var(--kern-shell));
|
|
1027
|
+
}
|
|
1028
|
+
.thead {
|
|
1029
|
+
display: flex;
|
|
1030
|
+
align-items: center;
|
|
1031
|
+
gap: 6px;
|
|
1032
|
+
font-size: 13px;
|
|
1033
|
+
font-weight: 600;
|
|
1034
|
+
}
|
|
1035
|
+
.tnote {
|
|
1036
|
+
margin: 3px 0 8px;
|
|
1037
|
+
font-size: 12px;
|
|
1038
|
+
color: var(--kern-ink-450, var(--kern-ink-400));
|
|
1039
|
+
}
|
|
1040
|
+
.triage .row {
|
|
1041
|
+
display: flex;
|
|
1042
|
+
gap: 6px;
|
|
1043
|
+
}
|
|
1044
|
+
.attachments {
|
|
1045
|
+
margin-top: 16px;
|
|
1046
|
+
}
|
|
1047
|
+
.ahead {
|
|
1048
|
+
display: flex;
|
|
1049
|
+
align-items: center;
|
|
1050
|
+
justify-content: space-between;
|
|
1051
|
+
}
|
|
1052
|
+
.afiles {
|
|
1053
|
+
list-style: none;
|
|
1054
|
+
margin: 6px 0 0;
|
|
1055
|
+
padding: 0;
|
|
1056
|
+
display: flex;
|
|
1057
|
+
flex-direction: column;
|
|
1058
|
+
gap: 4px;
|
|
1059
|
+
}
|
|
1060
|
+
.afiles li {
|
|
1061
|
+
display: flex;
|
|
1062
|
+
align-items: center;
|
|
1063
|
+
gap: 6px;
|
|
1064
|
+
font-size: 13px;
|
|
1065
|
+
}
|
|
1066
|
+
.aname {
|
|
1067
|
+
flex: 1;
|
|
1068
|
+
overflow: hidden;
|
|
1069
|
+
text-overflow: ellipsis;
|
|
1070
|
+
white-space: nowrap;
|
|
1071
|
+
}
|
|
1072
|
+
.asize {
|
|
1073
|
+
color: var(--kern-ink-350);
|
|
1074
|
+
font-size: 12px;
|
|
1075
|
+
}
|
|
1076
|
+
.empty,
|
|
1077
|
+
.no-comment {
|
|
1078
|
+
margin: 6px 0 0;
|
|
1079
|
+
font-size: 13px;
|
|
1080
|
+
color: var(--kern-ink-350);
|
|
1081
|
+
}
|
|
1082
|
+
.loading {
|
|
1083
|
+
display: grid;
|
|
1084
|
+
place-items: center;
|
|
1085
|
+
padding: 40px;
|
|
1086
|
+
}
|
|
1087
|
+
.hkey {
|
|
1088
|
+
font-family: var(--kern-font-mono);
|
|
1089
|
+
font-size: 12px;
|
|
1090
|
+
letter-spacing: -0.01em;
|
|
1091
|
+
color: var(--kern-ink-300);
|
|
1092
|
+
}
|
|
1093
|
+
.hstatus {
|
|
1094
|
+
font-size: 13px;
|
|
1095
|
+
color: var(--kern-ink-550);
|
|
1096
|
+
}
|
|
1097
|
+
.hmore {
|
|
1098
|
+
display: grid;
|
|
1099
|
+
place-items: center;
|
|
1100
|
+
width: 24px;
|
|
1101
|
+
height: 24px;
|
|
1102
|
+
border: 0;
|
|
1103
|
+
border-radius: var(--kern-r-sm);
|
|
1104
|
+
background: none;
|
|
1105
|
+
color: var(--kern-ink-500);
|
|
1106
|
+
cursor: pointer;
|
|
1107
|
+
}
|
|
1108
|
+
.hmore:hover {
|
|
1109
|
+
background: var(--kern-surface-hover);
|
|
1110
|
+
}
|
|
1111
|
+
.danger {
|
|
1112
|
+
margin: 0 0 10px;
|
|
1113
|
+
padding: 10px 12px;
|
|
1114
|
+
border: 1px solid var(--kern-danger);
|
|
1115
|
+
border-radius: var(--kern-r-sm);
|
|
1116
|
+
font-size: 12.5px;
|
|
1117
|
+
}
|
|
1118
|
+
.danger p {
|
|
1119
|
+
margin: 0 0 8px;
|
|
1120
|
+
}
|
|
1121
|
+
.drow {
|
|
1122
|
+
display: flex;
|
|
1123
|
+
gap: 8px;
|
|
1124
|
+
}
|
|
1125
|
+
.title {
|
|
1126
|
+
margin: 0;
|
|
1127
|
+
font-size: 19px;
|
|
1128
|
+
font-weight: 600;
|
|
1129
|
+
line-height: 1.32;
|
|
1130
|
+
letter-spacing: -0.02em;
|
|
1131
|
+
color: var(--kern-ink-900);
|
|
1132
|
+
text-wrap: pretty;
|
|
1133
|
+
}
|
|
1134
|
+
.props.main {
|
|
1135
|
+
margin-top: 14px;
|
|
1136
|
+
}
|
|
1137
|
+
.props {
|
|
1138
|
+
display: grid;
|
|
1139
|
+
grid-template-columns: 84px 1fr;
|
|
1140
|
+
align-items: center;
|
|
1141
|
+
gap: 10px 12px;
|
|
1142
|
+
margin: 18px 0 0;
|
|
1143
|
+
font-size: 13px;
|
|
1144
|
+
}
|
|
1145
|
+
.props dt {
|
|
1146
|
+
color: var(--kern-ink-350);
|
|
1147
|
+
}
|
|
1148
|
+
.props dd {
|
|
1149
|
+
margin: 0;
|
|
1150
|
+
min-width: 0;
|
|
1151
|
+
}
|
|
1152
|
+
.val {
|
|
1153
|
+
display: inline-flex;
|
|
1154
|
+
align-items: center;
|
|
1155
|
+
gap: 8px;
|
|
1156
|
+
min-height: 26px;
|
|
1157
|
+
max-width: 100%;
|
|
1158
|
+
padding: 2px 6px;
|
|
1159
|
+
margin-inline-start: -6px;
|
|
1160
|
+
border-radius: var(--kern-r-md);
|
|
1161
|
+
color: var(--kern-ink-700);
|
|
1162
|
+
text-align: start;
|
|
1163
|
+
}
|
|
1164
|
+
.val:not(.static):hover {
|
|
1165
|
+
background: var(--kern-surface-hover);
|
|
1166
|
+
}
|
|
1167
|
+
.val.static,
|
|
1168
|
+
.val:disabled {
|
|
1169
|
+
cursor: default;
|
|
1170
|
+
}
|
|
1171
|
+
.prose:disabled {
|
|
1172
|
+
cursor: default;
|
|
1173
|
+
}
|
|
1174
|
+
.prose:disabled:hover {
|
|
1175
|
+
background: transparent;
|
|
1176
|
+
}
|
|
1177
|
+
.val.wrap {
|
|
1178
|
+
flex-wrap: wrap;
|
|
1179
|
+
}
|
|
1180
|
+
.val.date {
|
|
1181
|
+
position: relative;
|
|
1182
|
+
cursor: pointer;
|
|
1183
|
+
}
|
|
1184
|
+
.val.date input {
|
|
1185
|
+
position: absolute;
|
|
1186
|
+
inset: 0;
|
|
1187
|
+
opacity: 0;
|
|
1188
|
+
cursor: pointer;
|
|
1189
|
+
width: 100%;
|
|
1190
|
+
}
|
|
1191
|
+
.muted {
|
|
1192
|
+
color: var(--kern-ink-350);
|
|
1193
|
+
}
|
|
1194
|
+
.lbl {
|
|
1195
|
+
display: inline-flex;
|
|
1196
|
+
align-items: center;
|
|
1197
|
+
gap: 5px;
|
|
1198
|
+
font-size: 12px;
|
|
1199
|
+
padding: 3px 9px;
|
|
1200
|
+
border-radius: var(--kern-r-md);
|
|
1201
|
+
background: var(--kern-surface-chip);
|
|
1202
|
+
color: var(--kern-ink-520);
|
|
1203
|
+
}
|
|
1204
|
+
.swatch {
|
|
1205
|
+
width: 8px;
|
|
1206
|
+
height: 8px;
|
|
1207
|
+
border-radius: var(--kern-r-full);
|
|
1208
|
+
}
|
|
1209
|
+
.desc {
|
|
1210
|
+
margin-top: 18px;
|
|
1211
|
+
padding-top: 18px;
|
|
1212
|
+
border-top: 1px solid var(--kern-border-hairline);
|
|
1213
|
+
}
|
|
1214
|
+
/* The read view of the description. Everything typographic now comes from
|
|
1215
|
+
`@kernhq/ui/styles/prose.css` — the same sheet the editor wears — so switching between reading
|
|
1216
|
+
and editing does not move a single line. What is left here is the button it happens to be. */
|
|
1217
|
+
.prose {
|
|
1218
|
+
display: block;
|
|
1219
|
+
width: 100%;
|
|
1220
|
+
text-align: start;
|
|
1221
|
+
font-size: 14px;
|
|
1222
|
+
border-radius: var(--kern-r-lg);
|
|
1223
|
+
}
|
|
1224
|
+
.prose:hover {
|
|
1225
|
+
background: var(--kern-surface-hover);
|
|
1226
|
+
}
|
|
1227
|
+
.row {
|
|
1228
|
+
display: flex;
|
|
1229
|
+
gap: 8px;
|
|
1230
|
+
margin-top: 8px;
|
|
1231
|
+
}
|
|
1232
|
+
.activity {
|
|
1233
|
+
margin-top: 22px;
|
|
1234
|
+
padding-top: 16px;
|
|
1235
|
+
border-top: 1px solid var(--kern-border-hairline);
|
|
1236
|
+
}
|
|
1237
|
+
.arow {
|
|
1238
|
+
display: flex;
|
|
1239
|
+
align-items: center;
|
|
1240
|
+
gap: 10px;
|
|
1241
|
+
margin-bottom: 14px;
|
|
1242
|
+
}
|
|
1243
|
+
.sp {
|
|
1244
|
+
flex: 1;
|
|
1245
|
+
}
|
|
1246
|
+
.watch {
|
|
1247
|
+
display: inline-flex;
|
|
1248
|
+
align-items: center;
|
|
1249
|
+
gap: 6px;
|
|
1250
|
+
font-size: 12.5px;
|
|
1251
|
+
color: var(--kern-ink-400);
|
|
1252
|
+
padding: 3px 7px;
|
|
1253
|
+
border-radius: var(--kern-r-md);
|
|
1254
|
+
}
|
|
1255
|
+
.watch:hover {
|
|
1256
|
+
background: var(--kern-surface-hover);
|
|
1257
|
+
color: var(--kern-ink-700);
|
|
1258
|
+
}
|
|
1259
|
+
.thread {
|
|
1260
|
+
display: grid;
|
|
1261
|
+
grid-template-columns: 28px 1fr;
|
|
1262
|
+
gap: 10px;
|
|
1263
|
+
margin-bottom: 16px;
|
|
1264
|
+
}
|
|
1265
|
+
.thead {
|
|
1266
|
+
display: flex;
|
|
1267
|
+
align-items: baseline;
|
|
1268
|
+
gap: 9px;
|
|
1269
|
+
}
|
|
1270
|
+
.author {
|
|
1271
|
+
font-size: 13.5px;
|
|
1272
|
+
font-weight: 500;
|
|
1273
|
+
color: var(--kern-ink-900);
|
|
1274
|
+
}
|
|
1275
|
+
.thead time,
|
|
1276
|
+
.event time {
|
|
1277
|
+
font-size: 12px;
|
|
1278
|
+
color: var(--kern-ink-280);
|
|
1279
|
+
}
|
|
1280
|
+
.tprose {
|
|
1281
|
+
font-size: 13.5px;
|
|
1282
|
+
margin-top: 3px;
|
|
1283
|
+
}
|
|
1284
|
+
.reactions {
|
|
1285
|
+
display: flex;
|
|
1286
|
+
gap: 4px;
|
|
1287
|
+
margin-top: 6px;
|
|
1288
|
+
}
|
|
1289
|
+
.reaction {
|
|
1290
|
+
display: inline-flex;
|
|
1291
|
+
align-items: center;
|
|
1292
|
+
gap: 4px;
|
|
1293
|
+
height: 22px;
|
|
1294
|
+
padding: 0 7px;
|
|
1295
|
+
border-radius: var(--kern-r-md);
|
|
1296
|
+
background: var(--kern-surface-chip);
|
|
1297
|
+
color: var(--kern-ink-520);
|
|
1298
|
+
font-size: 12px;
|
|
1299
|
+
}
|
|
1300
|
+
.reaction.mine {
|
|
1301
|
+
background: var(--kern-accent-tint);
|
|
1302
|
+
color: var(--kern-accent-deep);
|
|
1303
|
+
}
|
|
1304
|
+
.reaction.add {
|
|
1305
|
+
background: transparent;
|
|
1306
|
+
color: var(--kern-ink-350);
|
|
1307
|
+
}
|
|
1308
|
+
.reaction.add:hover {
|
|
1309
|
+
background: var(--kern-surface-hover);
|
|
1310
|
+
}
|
|
1311
|
+
.event {
|
|
1312
|
+
display: flex;
|
|
1313
|
+
align-items: baseline;
|
|
1314
|
+
flex-wrap: wrap;
|
|
1315
|
+
gap: 6px;
|
|
1316
|
+
margin: 0 0 14px 38px;
|
|
1317
|
+
font-size: 12.5px;
|
|
1318
|
+
color: var(--kern-ink-450);
|
|
1319
|
+
}
|
|
1320
|
+
:global([dir='rtl']) .event {
|
|
1321
|
+
margin: 0 38px 14px 0;
|
|
1322
|
+
}
|
|
1323
|
+
.ename {
|
|
1324
|
+
font-weight: 500;
|
|
1325
|
+
color: var(--kern-ink-700);
|
|
1326
|
+
}
|
|
1327
|
+
.reply input {
|
|
1328
|
+
width: 100%;
|
|
1329
|
+
height: 36px;
|
|
1330
|
+
border: 1px solid var(--kern-border-strong);
|
|
1331
|
+
border-radius: var(--kern-r-lg);
|
|
1332
|
+
background: var(--kern-surface-raised);
|
|
1333
|
+
padding: 0 12px;
|
|
1334
|
+
font-size: 13.5px;
|
|
1335
|
+
color: var(--kern-ink-800);
|
|
1336
|
+
}
|
|
1337
|
+
</style>
|