@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,219 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Icon, IconButton, rankCandidates, Spinner, toast, uploadFile } from '@kernhq/ui'
|
|
3
|
+
import { type MentionCandidate, RichTextEditor } from '@kernhq/ui/editor'
|
|
4
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import type { RichDoc } from '../index.js'
|
|
7
|
+
import { isEmptyDoc } from '../richtext.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Writing a comment: text, `@` mentions, and files.
|
|
11
|
+
*
|
|
12
|
+
* The same `@` machinery chat uses (`$lib/mentions`), so a mention here is a real mention node the
|
|
13
|
+
* server turns into a notification, not the characters `@Ada` sitting in a sentence.
|
|
14
|
+
*
|
|
15
|
+
* Files upload as you pick them and are attached to the comment once it exists — a comment id has
|
|
16
|
+
* to exist before an attachment can point at it, so the parent posts the comment and then hands
|
|
17
|
+
* the ids over.
|
|
18
|
+
*/
|
|
19
|
+
interface Props {
|
|
20
|
+
initial?: RichDoc | null
|
|
21
|
+
placeholder?: string
|
|
22
|
+
submitLabel?: string
|
|
23
|
+
/** editing an existing comment cannot add files: they attach at creation */
|
|
24
|
+
allowFiles?: boolean
|
|
25
|
+
busy?: boolean
|
|
26
|
+
/** the workspace the files belong to */
|
|
27
|
+
workspaceId: string
|
|
28
|
+
oncancel?: () => void
|
|
29
|
+
onsubmit: (body: RichDoc, fileIds: string[], internal: boolean) => void
|
|
30
|
+
}
|
|
31
|
+
let {
|
|
32
|
+
initial = null,
|
|
33
|
+
placeholder = t('comment_placeholder'),
|
|
34
|
+
submitLabel = t('comment_send'),
|
|
35
|
+
allowFiles = true,
|
|
36
|
+
busy = false,
|
|
37
|
+
workspaceId,
|
|
38
|
+
oncancel,
|
|
39
|
+
onsubmit,
|
|
40
|
+
}: Props = $props()
|
|
41
|
+
|
|
42
|
+
const cat = getTrackerCatalogue()
|
|
43
|
+
|
|
44
|
+
// The starting draft only: later prop changes must not overwrite what is being typed.
|
|
45
|
+
let body = $state<RichDoc | null>(initial ?? null)
|
|
46
|
+
let internal = $state(false)
|
|
47
|
+
let fileInput = $state<HTMLInputElement | null>(null)
|
|
48
|
+
|
|
49
|
+
let uploads = $state<Array<{ id: string; name: string; ratio: number | null; fileId: string | null }>>([])
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
* Mentions are nodes in the document now, not a list kept beside a string. The editor inserts a
|
|
53
|
+
* real `mention` node when someone is picked, so there is nothing left to reconcile on save and
|
|
54
|
+
* no way for a hand-typed "@Ada" to be mistaken for one.
|
|
55
|
+
*/
|
|
56
|
+
const people = $derived(cat.people.map((p) => ({ id: p.id, name: p.name, avatarUrl: p.avatarUrl ?? null })))
|
|
57
|
+
/* `rankCandidates` is the ranking chat uses, and it wants a lowercased query and a `name`; the
|
|
58
|
+
editor speaks `label`. Bridge here rather than forking either. */
|
|
59
|
+
const mentionSource = (query: string): MentionCandidate[] =>
|
|
60
|
+
rankCandidates(people, query.toLowerCase()).map((p) => ({
|
|
61
|
+
id: p.id,
|
|
62
|
+
label: p.name,
|
|
63
|
+
avatarUrl: p.avatarUrl ?? null,
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
const ready = $derived(uploads.every((u) => u.fileId !== null))
|
|
67
|
+
const empty = $derived(isEmptyDoc(body))
|
|
68
|
+
|
|
69
|
+
async function addFiles(list: FileList | null) {
|
|
70
|
+
if (!list?.length) return
|
|
71
|
+
for (const file of Array.from(list)) {
|
|
72
|
+
const id = crypto.randomUUID()
|
|
73
|
+
uploads = [...uploads, { id, name: file.name, ratio: 0, fileId: null }]
|
|
74
|
+
try {
|
|
75
|
+
const uploaded = await uploadFile({
|
|
76
|
+
workspaceId,
|
|
77
|
+
file,
|
|
78
|
+
name: file.name,
|
|
79
|
+
mimeType: file.type || undefined,
|
|
80
|
+
onProgress: ({ ratio }) => {
|
|
81
|
+
uploads = uploads.map((u) => (u.id === id ? { ...u, ratio } : u))
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
uploads = uploads.map((u) => (u.id === id ? { ...u, ratio: 1, fileId: uploaded.id } : u))
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Drop the row rather than leave a file that looks attached and is not.
|
|
87
|
+
uploads = uploads.filter((u) => u.id !== id)
|
|
88
|
+
toast.error(err instanceof Error ? err.message : t('attachment_failed'))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function submit() {
|
|
94
|
+
if (empty || busy || !ready) return
|
|
95
|
+
onsubmit(
|
|
96
|
+
// A state proxy cannot survive `structuredClone` on the way to the API — snapshot it first.
|
|
97
|
+
$state.snapshot(body) as RichDoc,
|
|
98
|
+
uploads.map((u) => u.fileId).filter((id): id is string => Boolean(id)),
|
|
99
|
+
internal,
|
|
100
|
+
)
|
|
101
|
+
body = null
|
|
102
|
+
uploads = []
|
|
103
|
+
internal = false
|
|
104
|
+
}
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<div class="composer">
|
|
108
|
+
<RichTextEditor
|
|
109
|
+
bind:value={body}
|
|
110
|
+
minRows={2}
|
|
111
|
+
{placeholder}
|
|
112
|
+
label={placeholder}
|
|
113
|
+
{mentionSource}
|
|
114
|
+
data-testid="comment-input"
|
|
115
|
+
onsubmit={submit}
|
|
116
|
+
onescape={oncancel}
|
|
117
|
+
/>
|
|
118
|
+
|
|
119
|
+
{#if uploads.length}
|
|
120
|
+
<ul class="uploads">
|
|
121
|
+
{#each uploads as file (file.id)}
|
|
122
|
+
<li>
|
|
123
|
+
{#if file.fileId}
|
|
124
|
+
<Icon name="paperclip" size={12} strokeWidth={1.8} />
|
|
125
|
+
{:else}
|
|
126
|
+
<Spinner size={12} />
|
|
127
|
+
{/if}
|
|
128
|
+
<span class="uname">{file.name}</span>
|
|
129
|
+
<IconButton
|
|
130
|
+
icon="x"
|
|
131
|
+
size={22}
|
|
132
|
+
label={t('attachment_remove')}
|
|
133
|
+
onclick={() => (uploads = uploads.filter((u) => u.id !== file.id))}
|
|
134
|
+
/>
|
|
135
|
+
</li>
|
|
136
|
+
{/each}
|
|
137
|
+
</ul>
|
|
138
|
+
{/if}
|
|
139
|
+
|
|
140
|
+
<div class="actions">
|
|
141
|
+
{#if allowFiles}
|
|
142
|
+
<input
|
|
143
|
+
bind:this={fileInput}
|
|
144
|
+
type="file"
|
|
145
|
+
multiple
|
|
146
|
+
hidden
|
|
147
|
+
onchange={(e) => {
|
|
148
|
+
void addFiles(e.currentTarget.files)
|
|
149
|
+
e.currentTarget.value = ''
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
<IconButton
|
|
153
|
+
icon="paperclip"
|
|
154
|
+
size={26}
|
|
155
|
+
label={t('attach_file')}
|
|
156
|
+
onclick={() => fileInput?.click()}
|
|
157
|
+
/>
|
|
158
|
+
<label class="internal">
|
|
159
|
+
<input type="checkbox" bind:checked={internal} />
|
|
160
|
+
<span title={t('comment_internal_hint')}>{t('comment_internal')}</span>
|
|
161
|
+
</label>
|
|
162
|
+
{/if}
|
|
163
|
+
<span class="hint">{t('comment_hint')}</span>
|
|
164
|
+
{#if oncancel}
|
|
165
|
+
<Button size="sm" variant="ghost" onclick={oncancel}>{t('common.cancel')}</Button>
|
|
166
|
+
{/if}
|
|
167
|
+
<Button size="sm" disabled={empty || busy || !ready} onclick={submit}>{submitLabel}</Button>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<style>
|
|
172
|
+
.composer {
|
|
173
|
+
position: relative;
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-direction: column;
|
|
176
|
+
gap: 6px;
|
|
177
|
+
}
|
|
178
|
+
.uploads {
|
|
179
|
+
display: flex;
|
|
180
|
+
flex-wrap: wrap;
|
|
181
|
+
gap: 6px;
|
|
182
|
+
margin: 0;
|
|
183
|
+
padding: 0;
|
|
184
|
+
list-style: none;
|
|
185
|
+
}
|
|
186
|
+
.uploads li {
|
|
187
|
+
display: inline-flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
gap: 4px;
|
|
190
|
+
padding: 2px 6px;
|
|
191
|
+
border-radius: var(--kern-r-sm);
|
|
192
|
+
background: var(--kern-surface-active);
|
|
193
|
+
font-size: 12px;
|
|
194
|
+
}
|
|
195
|
+
.uname {
|
|
196
|
+
max-width: 160px;
|
|
197
|
+
overflow: hidden;
|
|
198
|
+
text-overflow: ellipsis;
|
|
199
|
+
white-space: nowrap;
|
|
200
|
+
}
|
|
201
|
+
.actions {
|
|
202
|
+
display: flex;
|
|
203
|
+
align-items: center;
|
|
204
|
+
gap: 8px;
|
|
205
|
+
}
|
|
206
|
+
.internal {
|
|
207
|
+
display: inline-flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
gap: 4px;
|
|
210
|
+
font-size: 12px;
|
|
211
|
+
color: var(--kern-ink-350);
|
|
212
|
+
cursor: pointer;
|
|
213
|
+
}
|
|
214
|
+
.hint {
|
|
215
|
+
flex: 1;
|
|
216
|
+
font-size: 11px;
|
|
217
|
+
color: var(--kern-ink-350);
|
|
218
|
+
}
|
|
219
|
+
</style>
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
EmojiPicker,
|
|
6
|
+
Icon,
|
|
7
|
+
IconButton,
|
|
8
|
+
type MenuItem,
|
|
9
|
+
QUICK_REACTIONS,
|
|
10
|
+
relativeTime,
|
|
11
|
+
session,
|
|
12
|
+
} from '@kernhq/ui'
|
|
13
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
14
|
+
import { t } from '../i18n.js'
|
|
15
|
+
import type { Attachment, Comment as IssueComment } from '../index.js'
|
|
16
|
+
import { renderDoc } from '../richtext.js'
|
|
17
|
+
import CommentComposer from './CommentComposer.svelte'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One comment and its replies.
|
|
21
|
+
*
|
|
22
|
+
* The server has carried threading, mentions, an internal flag, edit and delete since the model
|
|
23
|
+
* existed; the panel used three of the five procedures. Everything a comment can do is reachable
|
|
24
|
+
* here.
|
|
25
|
+
*/
|
|
26
|
+
interface Props {
|
|
27
|
+
workspaceId: string
|
|
28
|
+
comment: IssueComment
|
|
29
|
+
replies: IssueComment[]
|
|
30
|
+
attachments: Attachment[]
|
|
31
|
+
canComment: boolean
|
|
32
|
+
onreact: (commentId: string, emoji: string) => void
|
|
33
|
+
onedit: (commentId: string, body: unknown) => void
|
|
34
|
+
ondelete: (commentId: string) => void
|
|
35
|
+
onreply: (parentId: string, body: unknown, fileIds: string[]) => void
|
|
36
|
+
onremoveAttachment: (attachmentId: string) => void
|
|
37
|
+
}
|
|
38
|
+
let {
|
|
39
|
+
workspaceId,
|
|
40
|
+
comment,
|
|
41
|
+
replies,
|
|
42
|
+
attachments,
|
|
43
|
+
canComment,
|
|
44
|
+
onreact,
|
|
45
|
+
onedit,
|
|
46
|
+
ondelete,
|
|
47
|
+
onreply,
|
|
48
|
+
onremoveAttachment,
|
|
49
|
+
}: Props = $props()
|
|
50
|
+
|
|
51
|
+
const cat = getTrackerCatalogue()
|
|
52
|
+
|
|
53
|
+
let editing = $state(false)
|
|
54
|
+
let replying = $state(false)
|
|
55
|
+
let confirmingDelete = $state(false)
|
|
56
|
+
let pickerOpen = $state(false)
|
|
57
|
+
let pickerTrigger = $state<HTMLElement | null>(null)
|
|
58
|
+
|
|
59
|
+
const person = $derived(cat.person(comment.authorId))
|
|
60
|
+
/** Only the author edits or deletes their own comment; anything else is somebody else's words. */
|
|
61
|
+
const mine = $derived(Boolean(session.user && comment.authorId === session.user.id))
|
|
62
|
+
|
|
63
|
+
const menu = $derived<MenuItem[]>([
|
|
64
|
+
{ type: 'item', id: 'edit', label: t('common.edit'), icon: 'pencil', onSelect: () => (editing = true) },
|
|
65
|
+
{
|
|
66
|
+
type: 'item',
|
|
67
|
+
id: 'delete',
|
|
68
|
+
label: t('common.delete'),
|
|
69
|
+
icon: 'trash-2',
|
|
70
|
+
danger: true,
|
|
71
|
+
onSelect: () => (confirmingDelete = true),
|
|
72
|
+
},
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/** Whether I am one of the people behind this reaction, so it can render as mine. */
|
|
76
|
+
const reacted = (userIds: readonly string[]) => (session.user ? userIds.includes(session.user.id) : false)
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<article class="thread" class:internal={comment.internal}>
|
|
80
|
+
<Avatar name={person?.name} src={person?.avatarUrl} id={person?.id} size={28} />
|
|
81
|
+
<div class="tbody">
|
|
82
|
+
<div class="thead">
|
|
83
|
+
<span class="author">{person?.name ?? ''}</span>
|
|
84
|
+
<time datetime={comment.createdAt}>{relativeTime(comment.createdAt)}</time>
|
|
85
|
+
{#if comment.editedAt}
|
|
86
|
+
<span class="tag">{t('comment_edited')}</span>
|
|
87
|
+
{/if}
|
|
88
|
+
{#if comment.internal}
|
|
89
|
+
<span class="tag internal-tag" title={t('comment_internal_hint')}>
|
|
90
|
+
<Icon name="lock" size={11} strokeWidth={1.8} />
|
|
91
|
+
{t('comment_internal')}
|
|
92
|
+
</span>
|
|
93
|
+
{/if}
|
|
94
|
+
{#if mine}
|
|
95
|
+
<span class="spacer"></span>
|
|
96
|
+
<DropdownMenu items={menu} align="end">
|
|
97
|
+
{#snippet trigger(props)}
|
|
98
|
+
<IconButton {...props} icon="ellipsis" size={26} label={t('comment_actions')} />
|
|
99
|
+
{/snippet}
|
|
100
|
+
</DropdownMenu>
|
|
101
|
+
{/if}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{#if editing}
|
|
105
|
+
<CommentComposer
|
|
106
|
+
{workspaceId}
|
|
107
|
+
initial={comment.body}
|
|
108
|
+
placeholder={t('comment_edit_placeholder')}
|
|
109
|
+
submitLabel={t('common.save')}
|
|
110
|
+
allowFiles={false}
|
|
111
|
+
oncancel={() => (editing = false)}
|
|
112
|
+
onsubmit={(body) => {
|
|
113
|
+
onedit(comment.id, body)
|
|
114
|
+
editing = false
|
|
115
|
+
}}
|
|
116
|
+
/>
|
|
117
|
+
{:else if confirmingDelete}
|
|
118
|
+
<!-- Inline rather than window.confirm: it says what happens and can be dismissed with Escape. -->
|
|
119
|
+
<div class="confirm" role="alertdialog" aria-label={t('comment_delete_title')}>
|
|
120
|
+
<p>{t('comment_delete_body')}</p>
|
|
121
|
+
<div class="row">
|
|
122
|
+
<button type="button" class="danger" onclick={() => ondelete(comment.id)}>
|
|
123
|
+
{t('common.delete')}
|
|
124
|
+
</button>
|
|
125
|
+
<button type="button" onclick={() => (confirmingDelete = false)}>{t('common.cancel')}</button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
{:else}
|
|
129
|
+
<div class="kern-prose tprose">{@html renderDoc(comment.body)}</div>
|
|
130
|
+
{/if}
|
|
131
|
+
|
|
132
|
+
{#if attachments.length}
|
|
133
|
+
<ul class="files">
|
|
134
|
+
{#each attachments as file (file.id)}
|
|
135
|
+
<li>
|
|
136
|
+
<Icon name="paperclip" size={12} strokeWidth={1.8} />
|
|
137
|
+
<span class="fname">{file.name}</span>
|
|
138
|
+
{#if mine}
|
|
139
|
+
<IconButton
|
|
140
|
+
icon="x"
|
|
141
|
+
size={22}
|
|
142
|
+
label={t('attachment_remove')}
|
|
143
|
+
onclick={() => onremoveAttachment(file.id)}
|
|
144
|
+
/>
|
|
145
|
+
{/if}
|
|
146
|
+
</li>
|
|
147
|
+
{/each}
|
|
148
|
+
</ul>
|
|
149
|
+
{/if}
|
|
150
|
+
|
|
151
|
+
<div class="reactions">
|
|
152
|
+
{#each comment.reactions as reaction (reaction.emoji)}
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
class="reaction"
|
|
156
|
+
class:mine={reacted(reaction.userIds)}
|
|
157
|
+
onclick={() => onreact(comment.id, reaction.emoji)}
|
|
158
|
+
>
|
|
159
|
+
{reaction.emoji}
|
|
160
|
+
{reaction.count}
|
|
161
|
+
</button>
|
|
162
|
+
{/each}
|
|
163
|
+
<div class="picker-anchor">
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
class="reaction add"
|
|
167
|
+
aria-label={t('add_reaction')}
|
|
168
|
+
bind:this={pickerTrigger}
|
|
169
|
+
onclick={() => (pickerOpen = !pickerOpen)}
|
|
170
|
+
>
|
|
171
|
+
<Icon name="smile" size={13} strokeWidth={1.6} />
|
|
172
|
+
</button>
|
|
173
|
+
{#if pickerOpen}
|
|
174
|
+
<EmojiPicker
|
|
175
|
+
trigger={pickerTrigger}
|
|
176
|
+
onclose={() => (pickerOpen = false)}
|
|
177
|
+
onpick={(emoji) => {
|
|
178
|
+
onreact(comment.id, emoji)
|
|
179
|
+
pickerOpen = false
|
|
180
|
+
}}
|
|
181
|
+
/>
|
|
182
|
+
{/if}
|
|
183
|
+
</div>
|
|
184
|
+
{#each QUICK_REACTIONS.slice(0, 3) as emoji (emoji)}
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
class="reaction quick"
|
|
188
|
+
onclick={() => onreact(comment.id, emoji)}
|
|
189
|
+
aria-label={emoji}
|
|
190
|
+
>
|
|
191
|
+
{emoji}
|
|
192
|
+
</button>
|
|
193
|
+
{/each}
|
|
194
|
+
{#if canComment}
|
|
195
|
+
<button type="button" class="reply-btn" onclick={() => (replying = !replying)}>
|
|
196
|
+
{t('comment_reply')}
|
|
197
|
+
</button>
|
|
198
|
+
{/if}
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{#if replies.length}
|
|
202
|
+
<ul class="replies">
|
|
203
|
+
{#each replies as reply (reply.id)}
|
|
204
|
+
{@const author = cat.person(reply.authorId)}
|
|
205
|
+
<li>
|
|
206
|
+
<Avatar name={author?.name} src={author?.avatarUrl} id={author?.id} size={22} />
|
|
207
|
+
<div>
|
|
208
|
+
<div class="thead">
|
|
209
|
+
<span class="author">{author?.name ?? ''}</span>
|
|
210
|
+
<time datetime={reply.createdAt}>{relativeTime(reply.createdAt)}</time>
|
|
211
|
+
</div>
|
|
212
|
+
<div class="kern-prose tprose">{@html renderDoc(reply.body)}</div>
|
|
213
|
+
</div>
|
|
214
|
+
</li>
|
|
215
|
+
{/each}
|
|
216
|
+
</ul>
|
|
217
|
+
{/if}
|
|
218
|
+
|
|
219
|
+
{#if replying}
|
|
220
|
+
<CommentComposer
|
|
221
|
+
{workspaceId}
|
|
222
|
+
placeholder={t('comment_reply_placeholder')}
|
|
223
|
+
submitLabel={t('comment_reply')}
|
|
224
|
+
oncancel={() => (replying = false)}
|
|
225
|
+
onsubmit={(body, fileIds) => {
|
|
226
|
+
onreply(comment.id, body, fileIds)
|
|
227
|
+
replying = false
|
|
228
|
+
}}
|
|
229
|
+
/>
|
|
230
|
+
{/if}
|
|
231
|
+
</div>
|
|
232
|
+
</article>
|
|
233
|
+
|
|
234
|
+
<style>
|
|
235
|
+
.thread {
|
|
236
|
+
display: grid;
|
|
237
|
+
grid-template-columns: 28px 1fr;
|
|
238
|
+
gap: 10px;
|
|
239
|
+
padding: 10px 0;
|
|
240
|
+
}
|
|
241
|
+
.thread.internal {
|
|
242
|
+
border-inline-start: 2px solid var(--kern-warning);
|
|
243
|
+
padding-inline-start: 10px;
|
|
244
|
+
margin-inline-start: -12px;
|
|
245
|
+
}
|
|
246
|
+
.thead {
|
|
247
|
+
display: flex;
|
|
248
|
+
align-items: center;
|
|
249
|
+
gap: 8px;
|
|
250
|
+
font-size: 12px;
|
|
251
|
+
color: var(--kern-ink-350);
|
|
252
|
+
}
|
|
253
|
+
.author {
|
|
254
|
+
font-weight: 600;
|
|
255
|
+
color: var(--kern-ink-900);
|
|
256
|
+
}
|
|
257
|
+
.spacer {
|
|
258
|
+
flex: 1;
|
|
259
|
+
}
|
|
260
|
+
.tag {
|
|
261
|
+
display: inline-flex;
|
|
262
|
+
align-items: center;
|
|
263
|
+
gap: 3px;
|
|
264
|
+
font-size: 11px;
|
|
265
|
+
}
|
|
266
|
+
.internal-tag {
|
|
267
|
+
color: var(--kern-warning);
|
|
268
|
+
}
|
|
269
|
+
/* Size only — everything else now comes from `@kernhq/ui/styles/prose.css`, the same sheet the
|
|
270
|
+
editor's writing surface uses, so a comment reads exactly as it was written. */
|
|
271
|
+
.tprose {
|
|
272
|
+
font-size: 13px;
|
|
273
|
+
margin-top: 3px;
|
|
274
|
+
}
|
|
275
|
+
.files {
|
|
276
|
+
display: flex;
|
|
277
|
+
flex-wrap: wrap;
|
|
278
|
+
gap: 6px;
|
|
279
|
+
margin: 6px 0 0;
|
|
280
|
+
padding: 0;
|
|
281
|
+
list-style: none;
|
|
282
|
+
}
|
|
283
|
+
.files li {
|
|
284
|
+
display: inline-flex;
|
|
285
|
+
align-items: center;
|
|
286
|
+
gap: 4px;
|
|
287
|
+
padding: 2px 6px;
|
|
288
|
+
border-radius: var(--kern-r-sm);
|
|
289
|
+
background: var(--kern-surface-active);
|
|
290
|
+
font-size: 12px;
|
|
291
|
+
}
|
|
292
|
+
.fname {
|
|
293
|
+
max-width: 180px;
|
|
294
|
+
overflow: hidden;
|
|
295
|
+
text-overflow: ellipsis;
|
|
296
|
+
white-space: nowrap;
|
|
297
|
+
}
|
|
298
|
+
.reactions {
|
|
299
|
+
display: flex;
|
|
300
|
+
align-items: center;
|
|
301
|
+
flex-wrap: wrap;
|
|
302
|
+
gap: 4px;
|
|
303
|
+
margin-top: 6px;
|
|
304
|
+
}
|
|
305
|
+
.picker-anchor {
|
|
306
|
+
position: relative;
|
|
307
|
+
}
|
|
308
|
+
.reaction {
|
|
309
|
+
display: inline-flex;
|
|
310
|
+
align-items: center;
|
|
311
|
+
gap: 4px;
|
|
312
|
+
padding: 1px 6px;
|
|
313
|
+
border: 1px solid var(--kern-border);
|
|
314
|
+
border-radius: 999px;
|
|
315
|
+
background: none;
|
|
316
|
+
color: inherit;
|
|
317
|
+
font: inherit;
|
|
318
|
+
font-size: 12px;
|
|
319
|
+
cursor: pointer;
|
|
320
|
+
}
|
|
321
|
+
.reaction.mine {
|
|
322
|
+
border-color: var(--kern-accent);
|
|
323
|
+
background: var(--kern-info-tint);
|
|
324
|
+
}
|
|
325
|
+
.reaction.quick,
|
|
326
|
+
.reaction.add {
|
|
327
|
+
border-color: transparent;
|
|
328
|
+
opacity: 0;
|
|
329
|
+
}
|
|
330
|
+
.thread:hover .reaction.quick,
|
|
331
|
+
.thread:hover .reaction.add,
|
|
332
|
+
.reaction.add:focus-visible,
|
|
333
|
+
.reaction.quick:focus-visible {
|
|
334
|
+
opacity: 1;
|
|
335
|
+
}
|
|
336
|
+
.reply-btn {
|
|
337
|
+
border: 0;
|
|
338
|
+
background: none;
|
|
339
|
+
color: var(--kern-ink-350);
|
|
340
|
+
font: inherit;
|
|
341
|
+
font-size: 12px;
|
|
342
|
+
cursor: pointer;
|
|
343
|
+
padding: 1px 4px;
|
|
344
|
+
}
|
|
345
|
+
.reply-btn:hover {
|
|
346
|
+
color: var(--kern-ink-900);
|
|
347
|
+
}
|
|
348
|
+
.replies {
|
|
349
|
+
list-style: none;
|
|
350
|
+
margin: 8px 0 0;
|
|
351
|
+
padding: 0 0 0 4px;
|
|
352
|
+
border-inline-start: 1px solid var(--kern-border);
|
|
353
|
+
}
|
|
354
|
+
.replies li {
|
|
355
|
+
display: grid;
|
|
356
|
+
grid-template-columns: 22px 1fr;
|
|
357
|
+
gap: 8px;
|
|
358
|
+
padding: 6px 0 6px 8px;
|
|
359
|
+
}
|
|
360
|
+
.confirm {
|
|
361
|
+
margin-top: 6px;
|
|
362
|
+
padding: 8px 10px;
|
|
363
|
+
border: 1px solid var(--kern-border);
|
|
364
|
+
border-radius: var(--kern-r-sm);
|
|
365
|
+
background: var(--kern-shell);
|
|
366
|
+
font-size: 13px;
|
|
367
|
+
}
|
|
368
|
+
.confirm p {
|
|
369
|
+
margin: 0 0 8px;
|
|
370
|
+
}
|
|
371
|
+
.confirm .row {
|
|
372
|
+
display: flex;
|
|
373
|
+
gap: 8px;
|
|
374
|
+
}
|
|
375
|
+
.confirm button {
|
|
376
|
+
padding: 3px 10px;
|
|
377
|
+
border: 1px solid var(--kern-border);
|
|
378
|
+
border-radius: var(--kern-r-sm);
|
|
379
|
+
background: var(--kern-surface);
|
|
380
|
+
color: inherit;
|
|
381
|
+
font: inherit;
|
|
382
|
+
font-size: 12px;
|
|
383
|
+
cursor: pointer;
|
|
384
|
+
}
|
|
385
|
+
.confirm .danger {
|
|
386
|
+
border-color: var(--kern-danger);
|
|
387
|
+
color: var(--kern-danger);
|
|
388
|
+
}
|
|
389
|
+
</style>
|