@kernhq/module-tracker 0.10.1 → 0.11.1

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.
Files changed (83) hide show
  1. package/package.json +19 -6
  2. package/src/client/api-instance.ts +34 -0
  3. package/src/client/components/BoardCard.svelte +182 -0
  4. package/src/client/components/BoardView.svelte +248 -0
  5. package/src/client/components/CommentComposer.svelte +219 -0
  6. package/src/client/components/CommentThread.svelte +389 -0
  7. package/src/client/components/CustomField.svelte +333 -0
  8. package/src/client/components/DueDate.svelte +44 -0
  9. package/src/client/components/FilterMenu.svelte +91 -0
  10. package/src/client/components/GroupHeader.svelte +137 -0
  11. package/src/client/components/HomeLinks.svelte +38 -0
  12. package/src/client/components/IssueApprovals.svelte +217 -0
  13. package/src/client/components/IssueConnections.svelte +424 -0
  14. package/src/client/components/IssueDetailPanel.svelte +1337 -0
  15. package/src/client/components/IssueInline.svelte +75 -0
  16. package/src/client/components/IssueListView.svelte +153 -0
  17. package/src/client/components/IssuePicker.svelte +180 -0
  18. package/src/client/components/IssueRow.svelte +171 -0
  19. package/src/client/components/IssueTime.svelte +306 -0
  20. package/src/client/components/KqlInput.svelte +295 -0
  21. package/src/client/components/NewIssueDialog.svelte +210 -0
  22. package/src/client/components/NewProjectDialog.svelte +235 -0
  23. package/src/client/components/PriorityGlyph.svelte +52 -0
  24. package/src/client/components/SaveViewDialog.svelte +153 -0
  25. package/src/client/components/SidebarProjects.svelte +263 -0
  26. package/src/client/components/SidebarViews.svelte +336 -0
  27. package/src/client/components/StatusIcon.svelte +50 -0
  28. package/src/client/components/TrackerControls.svelte +109 -0
  29. package/src/client/components/TrackerSidebar.svelte +96 -0
  30. package/src/client/context.svelte.ts +105 -0
  31. package/src/client/core-api.ts +35 -0
  32. package/src/client/csv.test.ts +50 -0
  33. package/src/client/csv.ts +60 -0
  34. package/src/client/filters.ts +94 -0
  35. package/src/client/i18n.ts +3260 -0
  36. package/src/client/index.ts +12 -0
  37. package/src/client/labels.ts +200 -0
  38. package/src/client/mock.ts +2729 -0
  39. package/src/client/module.ts +491 -0
  40. package/src/client/nav.test.ts +59 -0
  41. package/src/client/nav.ts +84 -0
  42. package/src/client/pages/IntakePage.svelte +287 -0
  43. package/src/client/pages/IssuesPage.svelte +701 -0
  44. package/src/client/pages/ProjectPage.svelte +29 -0
  45. package/src/client/pages/ReportsPage.svelte +365 -0
  46. package/src/client/permissions.ts +33 -0
  47. package/src/client/planning/PlanningSections.svelte +259 -0
  48. package/src/client/project/ComponentsPage.svelte +423 -0
  49. package/src/client/project/CyclesPage.svelte +488 -0
  50. package/src/client/project/MilestonesPage.svelte +342 -0
  51. package/src/client/project/PlanCard.svelte +191 -0
  52. package/src/client/project/ProjectShell.svelte +93 -0
  53. package/src/client/project/TemplatesPage.svelte +321 -0
  54. package/src/client/project/context.svelte.ts +58 -0
  55. package/src/client/query.ts +50 -0
  56. package/src/client/recurrence.test.ts +37 -0
  57. package/src/client/recurrence.ts +89 -0
  58. package/src/client/richtext.ts +155 -0
  59. package/src/client/settings/CycleList.svelte +422 -0
  60. package/src/client/settings/FieldEditor.svelte +324 -0
  61. package/src/client/settings/FieldsSettings.svelte +273 -0
  62. package/src/client/settings/ImportSettings.svelte +410 -0
  63. package/src/client/settings/LayoutEditor.svelte +263 -0
  64. package/src/client/settings/PlanningList.svelte +249 -0
  65. package/src/client/settings/PlanningSettings.svelte +179 -0
  66. package/src/client/settings/ProjectsSettings.svelte +462 -0
  67. package/src/client/settings/RepeatingSettings.svelte +293 -0
  68. package/src/client/settings/TypeEditor.svelte +214 -0
  69. package/src/client/settings/TypesSettings.svelte +384 -0
  70. package/src/client/settings/WorkflowsSettings.svelte +645 -0
  71. package/src/client/settings/mutations.ts +19 -0
  72. package/src/client/time.test.ts +39 -0
  73. package/src/client/time.ts +34 -0
  74. package/src/client/tracker.test.ts +399 -0
  75. package/src/client/views.ts +27 -0
  76. package/src/client/widgets/AssignedCountWidget.svelte +14 -0
  77. package/src/client/widgets/CountWidget.svelte +56 -0
  78. package/src/client/widgets/CycleWidget.svelte +85 -0
  79. package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
  80. package/src/client/widgets/IssuesWidget.svelte +222 -0
  81. package/src/client/widgets/ThroughputWidget.svelte +68 -0
  82. package/src/client/widgets/TimerWidget.svelte +116 -0
  83. package/src/client/widgets/VelocityWidget.svelte +55 -0
@@ -0,0 +1,217 @@
1
+ <script lang="ts">
2
+ import { Button, Icon, relativeTime, toast } from '@kernhq/ui'
3
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import { getTrackerApi } from '../api-instance.js'
5
+ import { getTrackerCatalogue } from '../context.svelte.js'
6
+ import { t } from '../i18n.js'
7
+ import type { IssueApproval } from '../index.js'
8
+ import { trackerKeys } from '../query.js'
9
+
10
+ /**
11
+ * Approvals waiting on somebody.
12
+ *
13
+ * `approvals.list` and `approvals.decide` have existed since transitions did, and nobody could see
14
+ * an approval, let alone decide one — so a transition that needed approval simply stopped.
15
+ *
16
+ * Whether *you* may decide is a question only the server can answer: approvers are subjects
17
+ * (a user, a group, a role, the project lead), and expanding them here would duplicate the rule
18
+ * and eventually disagree with it. So the controls are offered and a refusal is reported plainly.
19
+ */
20
+ interface Props {
21
+ workspaceId: string
22
+ issueId: string
23
+ /** invalidated when a decision applies the transition and moves the issue */
24
+ oninvalidate: () => void
25
+ }
26
+ let { workspaceId, issueId, oninvalidate }: Props = $props()
27
+
28
+ const api = getTrackerApi()
29
+ const cat = getTrackerCatalogue()
30
+ const queryClient = useQueryClient()
31
+
32
+ let comment = $state('')
33
+ let deciding = $state<string | null>(null)
34
+
35
+ const approvalsQuery = createQuery(() => ({
36
+ queryKey: trackerKeys.approvals(workspaceId, issueId),
37
+ queryFn: () => api.issues.approvals.list({ workspaceId, issueId }),
38
+ }))
39
+
40
+ /** Only pending ones need a decision; a settled approval is history, and history has its own feed. */
41
+ const pending = $derived((approvalsQuery.data ?? []).filter((a) => a.state.status === 'pending'))
42
+
43
+ const decide = createMutation(() => ({
44
+ mutationFn: (input: { transitionId: string; decision: 'approve' | 'reject' }) =>
45
+ api.issues.approvals.decide({
46
+ workspaceId,
47
+ issueId,
48
+ transitionId: input.transitionId,
49
+ decision: input.decision,
50
+ ...(comment.trim() ? { comment: comment.trim() } : {}),
51
+ }),
52
+ onSuccess: () => {
53
+ comment = ''
54
+ deciding = null
55
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.approvals(workspaceId, issueId) })
56
+ oninvalidate()
57
+ },
58
+ onError: (error: Error) => toast.error(error.message),
59
+ }))
60
+
61
+ const approvedCount = (approval: IssueApproval) =>
62
+ approval.state.decisions.filter((d) => d.decision === 'approve').length
63
+ const name = (userId: string | null) => (userId ? (cat.person(userId)?.name ?? userId) : '')
64
+ </script>
65
+
66
+ {#if pending.length}
67
+ <section class="approvals" data-testid="approvals">
68
+ <span class="kern-sublabel">{t('approvals')}</span>
69
+ {#each pending as approval (approval.id)}
70
+ <div class="card">
71
+ <div class="chead">
72
+ <Icon name="shield-check" size={14} strokeWidth={1.8} />
73
+ <span class="what">{t('approval_needed', { transition: approval.state.transitionId })}</span>
74
+ </div>
75
+ <p class="meta">
76
+ {t('approval_progress', {
77
+ approved: approvedCount(approval),
78
+ needed: approval.state.spec.minApprovals,
79
+ })}
80
+ ·
81
+ {t('approval_requested', {
82
+ who: name(approval.state.requestedBy),
83
+ when: relativeTime(approval.state.requestedAt),
84
+ })}
85
+ </p>
86
+
87
+ {#if approval.state.decisions.length}
88
+ <ul class="decisions">
89
+ {#each approval.state.decisions as decision (decision.userId + decision.at)}
90
+ <li>
91
+ <Icon
92
+ name={decision.decision === 'approve' ? 'check' : 'x'}
93
+ size={12}
94
+ strokeWidth={2}
95
+ />
96
+ <span>{name(decision.userId)}</span>
97
+ {#if decision.comment}<span class="dcomment">{decision.comment}</span>{/if}
98
+ <time datetime={decision.at}>{relativeTime(decision.at)}</time>
99
+ </li>
100
+ {/each}
101
+ </ul>
102
+ {/if}
103
+
104
+ {#if deciding === approval.id}
105
+ <textarea
106
+ bind:value={comment}
107
+ rows="2"
108
+ placeholder={t('approval_comment')}
109
+ aria-label={t('approval_comment')}
110
+ ></textarea>
111
+ {/if}
112
+
113
+ <div class="row">
114
+ <Button
115
+ size="sm"
116
+ disabled={decide.isPending}
117
+ onclick={() => decide.mutate({ transitionId: approval.state.transitionId, decision: 'approve' })}
118
+ >
119
+ {t('approval_approve')}
120
+ </Button>
121
+ <Button
122
+ size="sm"
123
+ variant="ghost"
124
+ disabled={decide.isPending}
125
+ onclick={() => decide.mutate({ transitionId: approval.state.transitionId, decision: 'reject' })}
126
+ >
127
+ {t('approval_reject')}
128
+ </Button>
129
+ {#if deciding !== approval.id}
130
+ <button type="button" class="addnote" onclick={() => (deciding = approval.id)}>
131
+ {t('approval_add_comment')}
132
+ </button>
133
+ {/if}
134
+ </div>
135
+ <p class="who">{t('approval_who')}</p>
136
+ </div>
137
+ {/each}
138
+ </section>
139
+ {/if}
140
+
141
+ <style>
142
+ .approvals {
143
+ display: block;
144
+ margin-top: 16px;
145
+ }
146
+ .card {
147
+ margin-top: 6px;
148
+ padding: 10px;
149
+ border: 1px solid var(--kern-border);
150
+ border-radius: var(--kern-r-sm);
151
+ background: var(--kern-shell);
152
+ }
153
+ .chead {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 6px;
157
+ font-size: 13px;
158
+ font-weight: 600;
159
+ }
160
+ .meta,
161
+ .who {
162
+ margin: 4px 0 0;
163
+ font-size: 12px;
164
+ color: var(--kern-ink-350);
165
+ }
166
+ .decisions {
167
+ list-style: none;
168
+ margin: 6px 0 0;
169
+ padding: 0;
170
+ display: flex;
171
+ flex-direction: column;
172
+ gap: 3px;
173
+ }
174
+ .decisions li {
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 5px;
178
+ font-size: 12px;
179
+ }
180
+ .dcomment {
181
+ color: var(--kern-ink-350);
182
+ }
183
+ textarea {
184
+ width: 100%;
185
+ margin-top: 6px;
186
+ padding: 6px 8px;
187
+ border: 1px solid var(--kern-border);
188
+ border-radius: var(--kern-r-sm);
189
+ background: var(--kern-surface);
190
+ color: inherit;
191
+ font: inherit;
192
+ font-size: 13px;
193
+ resize: vertical;
194
+ }
195
+ textarea:focus-visible {
196
+ outline: none;
197
+ border-color: var(--kern-accent);
198
+ box-shadow: none;
199
+ }
200
+ .row {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 6px;
204
+ margin-top: 8px;
205
+ }
206
+ .addnote {
207
+ border: 0;
208
+ background: none;
209
+ color: var(--kern-ink-350);
210
+ font: inherit;
211
+ font-size: 12px;
212
+ cursor: pointer;
213
+ }
214
+ .addnote:hover {
215
+ color: var(--kern-ink-900);
216
+ }
217
+ </style>
@@ -0,0 +1,424 @@
1
+ <script lang="ts">
2
+ import { DropdownMenu, Icon, IconButton, type MenuItem, toast } from '@kernhq/ui'
3
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import { getTrackerApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import type { Issue, RelationType, StatusCategory } from '../index.js'
7
+ import { trackerKeys } from '../query.js'
8
+ import IssuePicker from './IssuePicker.svelte'
9
+ import StatusIcon from './StatusIcon.svelte'
10
+
11
+ /**
12
+ * What an issue is connected to: its children, other issues, and pages elsewhere.
13
+ *
14
+ * Three servers with no interface until now. They share one shape — list, add, remove — so they
15
+ * share one component rather than three that drift apart.
16
+ */
17
+ interface Props {
18
+ workspaceId: string
19
+ issue: Issue
20
+ canEdit: boolean
21
+ onopen: (key: string) => void
22
+ }
23
+ let { workspaceId, issue, canEdit, onopen }: Props = $props()
24
+
25
+ const api = getTrackerApi()
26
+ const queryClient = useQueryClient()
27
+
28
+ type Adding = 'child' | 'relation' | 'link' | null
29
+ let adding = $state<Adding>(null)
30
+ let relationType = $state<RelationType>('blocks')
31
+ let linkUrl = $state('')
32
+ let linkTitle = $state('')
33
+
34
+ const RELATION_LABELS: Record<RelationType, () => string> = {
35
+ blocks: () => t('relation_blocks'),
36
+ blocked_by: () => t('relation_blocked_by'),
37
+ relates: () => t('relation_relates'),
38
+ duplicates: () => t('relation_duplicates'),
39
+ duplicated_by: () => t('relation_duplicated_by'),
40
+ clones: () => t('relation_clones'),
41
+ cloned_by: () => t('relation_cloned_by'),
42
+ }
43
+
44
+ const relationsQuery = createQuery(() => ({
45
+ queryKey: trackerKeys.relations(workspaceId, issue.id),
46
+ queryFn: () => api.issues.relations.list({ workspaceId, issueId: issue.id }),
47
+ }))
48
+ const linksQuery = createQuery(() => ({
49
+ queryKey: trackerKeys.links(workspaceId, issue.id),
50
+ queryFn: () => api.issues.links.list({ workspaceId, issueId: issue.id }),
51
+ }))
52
+ const childrenQuery = createQuery(() => ({
53
+ queryKey: trackerKeys.children(workspaceId, issue.id),
54
+ queryFn: () =>
55
+ api.issues.query({
56
+ workspaceId,
57
+ kql: `parent = "${issue.key}"`,
58
+ limit: 100,
59
+ includeArchived: false,
60
+ include: { total: false, groupCounts: false, full: false },
61
+ }),
62
+ }))
63
+
64
+ const relations = $derived(relationsQuery.data ?? [])
65
+ const links = $derived(linksQuery.data ?? [])
66
+ const children = $derived(childrenQuery.data?.items ?? [])
67
+ /** Never offer an issue that is already connected, nor the issue itself. */
68
+ const connectedIds = $derived([
69
+ issue.id,
70
+ ...(issue.parentId ? [issue.parentId] : []),
71
+ ...relations.map((r) => r.issue.id),
72
+ ...children.map((c) => c.id),
73
+ ])
74
+
75
+ const invalidate = (keys: readonly (readonly unknown[])[]) => {
76
+ for (const queryKey of keys) void queryClient.invalidateQueries({ queryKey })
77
+ }
78
+ const fail = (error: Error) => toast.error(error.message)
79
+
80
+ const addRelation = createMutation(() => ({
81
+ mutationFn: (targetIssueId: string) =>
82
+ api.issues.relations.create({ workspaceId, issueId: issue.id, type: relationType, targetIssueId }),
83
+ onSuccess: () => {
84
+ adding = null
85
+ invalidate([trackerKeys.relations(workspaceId, issue.id), trackerKeys.issue(workspaceId, issue.id)])
86
+ },
87
+ onError: fail,
88
+ }))
89
+
90
+ const removeRelation = createMutation(() => ({
91
+ mutationFn: (relationId: string) => api.issues.relations.delete({ workspaceId, relationId }),
92
+ onSuccess: () =>
93
+ invalidate([trackerKeys.relations(workspaceId, issue.id), trackerKeys.issue(workspaceId, issue.id)]),
94
+ onError: fail,
95
+ }))
96
+
97
+ const addChild = createMutation(() => ({
98
+ mutationFn: (childId: string) =>
99
+ api.issues.update({ workspaceId, issueId: childId, patch: { parentId: issue.id } }),
100
+ onSuccess: () => {
101
+ adding = null
102
+ invalidate([trackerKeys.children(workspaceId, issue.id)])
103
+ },
104
+ onError: fail,
105
+ }))
106
+
107
+ const removeChild = createMutation(() => ({
108
+ mutationFn: (childId: string) =>
109
+ api.issues.update({ workspaceId, issueId: childId, patch: { parentId: null } }),
110
+ onSuccess: () => invalidate([trackerKeys.children(workspaceId, issue.id)]),
111
+ onError: fail,
112
+ }))
113
+
114
+ const addLink = createMutation(() => ({
115
+ mutationFn: () =>
116
+ api.issues.links.add({
117
+ workspaceId,
118
+ issueId: issue.id,
119
+ url: linkUrl.trim(),
120
+ ...(linkTitle.trim() ? { title: linkTitle.trim() } : {}),
121
+ kind: 'generic',
122
+ }),
123
+ onSuccess: () => {
124
+ adding = null
125
+ linkUrl = ''
126
+ linkTitle = ''
127
+ invalidate([trackerKeys.links(workspaceId, issue.id)])
128
+ },
129
+ onError: fail,
130
+ }))
131
+
132
+ const removeLink = createMutation(() => ({
133
+ mutationFn: (linkId: string) => api.issues.links.remove({ workspaceId, linkId }),
134
+ onSuccess: () => invalidate([trackerKeys.links(workspaceId, issue.id)]),
135
+ onError: fail,
136
+ }))
137
+
138
+ const relationMenu = $derived<MenuItem[]>(
139
+ (Object.keys(RELATION_LABELS) as RelationType[]).map((type) => ({
140
+ type: 'item' as const,
141
+ id: type,
142
+ label: RELATION_LABELS[type](),
143
+ onSelect: () => {
144
+ relationType = type
145
+ adding = 'relation'
146
+ },
147
+ })),
148
+ )
149
+
150
+ /** Relations read better grouped by kind than as one undifferentiated list. */
151
+ const relationGroups = $derived.by(() => {
152
+ const groups = new Map<RelationType, typeof relations>()
153
+ for (const relation of relations)
154
+ groups.set(relation.type, [...(groups.get(relation.type) ?? []), relation])
155
+ return [...groups.entries()]
156
+ })
157
+
158
+ const isHttp = $derived(/^https?:\/\/\S+$/i.test(linkUrl.trim()))
159
+ </script>
160
+
161
+ {#snippet issueLine(
162
+ key: string,
163
+ title: string,
164
+ statusId: string,
165
+ category: StatusCategory,
166
+ remove?: () => void,
167
+ )}
168
+ <li>
169
+ <button type="button" class="line" onclick={() => onopen(key)}>
170
+ <StatusIcon {category} {statusId} size={13} />
171
+ <span class="ikey">{key}</span>
172
+ <span class="ititle">{title}</span>
173
+ </button>
174
+ {#if remove && canEdit}
175
+ <IconButton icon="x" size={22} label={t('connection_remove')} onclick={remove} />
176
+ {/if}
177
+ </li>
178
+ {/snippet}
179
+
180
+ <section class="connections">
181
+ <!-- sub-issues -->
182
+ <div class="block">
183
+ <div class="bhead">
184
+ <span class="kern-sublabel">{t('subissues')}</span>
185
+ {#if canEdit}
186
+ <IconButton
187
+ icon="plus"
188
+ size={22}
189
+ label={t('subissue_add')}
190
+ onclick={() => (adding = adding === 'child' ? null : 'child')}
191
+ />
192
+ {/if}
193
+ </div>
194
+ {#if adding === 'child'}
195
+ <IssuePicker
196
+ {workspaceId}
197
+ projectId={issue.projectId}
198
+ exclude={connectedIds}
199
+ placeholder={t('subissue_add')}
200
+ onpick={(picked) => addChild.mutate(picked.id)}
201
+ oncancel={() => (adding = null)}
202
+ />
203
+ {/if}
204
+ {#if children.length}
205
+ <ul>
206
+ {#each children as child (child.id)}
207
+ {@render issueLine(child.key, child.title, child.statusId, child.statusCategory, () =>
208
+ removeChild.mutate(child.id),
209
+ )}
210
+ {/each}
211
+ </ul>
212
+ {:else if adding !== 'child'}
213
+ <p class="empty">{t('subissues_empty')}</p>
214
+ {/if}
215
+ </div>
216
+
217
+ <!-- relations -->
218
+ <div class="block">
219
+ <div class="bhead">
220
+ <span class="kern-sublabel">{t('relations')}</span>
221
+ {#if canEdit}
222
+ <DropdownMenu items={relationMenu} align="end">
223
+ {#snippet trigger(props)}
224
+ <IconButton {...props} icon="plus" size={22} label={t('relation_add')} />
225
+ {/snippet}
226
+ </DropdownMenu>
227
+ {/if}
228
+ </div>
229
+ {#if adding === 'relation'}
230
+ <p class="picking">{RELATION_LABELS[relationType]()}</p>
231
+ <IssuePicker
232
+ {workspaceId}
233
+ exclude={connectedIds}
234
+ placeholder={t('relation_add')}
235
+ onpick={(picked) => addRelation.mutate(picked.id)}
236
+ oncancel={() => (adding = null)}
237
+ />
238
+ {/if}
239
+ {#if relationGroups.length}
240
+ {#each relationGroups as [type, group] (type)}
241
+ <p class="gname">{RELATION_LABELS[type]()}</p>
242
+ <ul>
243
+ {#each group as relation (relation.id)}
244
+ {@render issueLine(
245
+ relation.issue.key,
246
+ relation.issue.title,
247
+ relation.issue.statusId,
248
+ relation.issue.statusCategory,
249
+ () => removeRelation.mutate(relation.id),
250
+ )}
251
+ {/each}
252
+ </ul>
253
+ {/each}
254
+ {:else if adding !== 'relation'}
255
+ <p class="empty">{t('relations_empty')}</p>
256
+ {/if}
257
+ </div>
258
+
259
+ <!-- external links -->
260
+ <div class="block">
261
+ <div class="bhead">
262
+ <span class="kern-sublabel">{t('links')}</span>
263
+ {#if canEdit}
264
+ <IconButton
265
+ icon="link"
266
+ size={22}
267
+ label={t('link_add')}
268
+ onclick={() => (adding = adding === 'link' ? null : 'link')}
269
+ />
270
+ {/if}
271
+ </div>
272
+ {#if adding === 'link'}
273
+ <form
274
+ class="linkform"
275
+ onsubmit={(e) => {
276
+ e.preventDefault()
277
+ if (isHttp) addLink.mutate()
278
+ }}
279
+ >
280
+ <input
281
+ bind:value={linkUrl}
282
+ type="url"
283
+ placeholder="https://"
284
+ aria-label={t('link_url')}
285
+ data-testid="link-url"
286
+ />
287
+ <input bind:value={linkTitle} type="text" placeholder={t('link_title')} aria-label={t('link_title')} />
288
+ <div class="row">
289
+ <button type="submit" disabled={!isHttp}>{t('common.add')}</button>
290
+ <button type="button" onclick={() => (adding = null)}>{t('common.cancel')}</button>
291
+ </div>
292
+ </form>
293
+ {/if}
294
+ {#if links.length}
295
+ <ul>
296
+ {#each links as link (link.id)}
297
+ <li>
298
+ <a class="line" href={link.url} target="_blank" rel="noreferrer noopener">
299
+ <Icon name="external-link" size={13} strokeWidth={1.8} />
300
+ <span class="ititle">{link.title || link.url}</span>
301
+ </a>
302
+ {#if canEdit}
303
+ <IconButton
304
+ icon="x"
305
+ size={22}
306
+ label={t('connection_remove')}
307
+ onclick={() => removeLink.mutate(link.id)}
308
+ />
309
+ {/if}
310
+ </li>
311
+ {/each}
312
+ </ul>
313
+ {:else if adding !== 'link'}
314
+ <p class="empty">{t('links_empty')}</p>
315
+ {/if}
316
+ </div>
317
+ </section>
318
+
319
+ <style>
320
+ .connections {
321
+ display: flex;
322
+ flex-direction: column;
323
+ gap: 14px;
324
+ margin-top: 16px;
325
+ }
326
+ .bhead {
327
+ display: flex;
328
+ align-items: center;
329
+ justify-content: space-between;
330
+ }
331
+ ul {
332
+ list-style: none;
333
+ margin: 4px 0 0;
334
+ padding: 0;
335
+ display: flex;
336
+ flex-direction: column;
337
+ gap: 2px;
338
+ }
339
+ li {
340
+ display: flex;
341
+ align-items: center;
342
+ gap: 4px;
343
+ }
344
+ .line {
345
+ display: flex;
346
+ align-items: center;
347
+ gap: 6px;
348
+ flex: 1;
349
+ min-width: 0;
350
+ padding: 3px 6px;
351
+ margin-inline-start: -6px;
352
+ border: 0;
353
+ border-radius: var(--kern-r-sm);
354
+ background: none;
355
+ color: inherit;
356
+ font: inherit;
357
+ font-size: 13px;
358
+ text-align: start;
359
+ text-decoration: none;
360
+ cursor: pointer;
361
+ }
362
+ .line:hover {
363
+ background: var(--kern-surface-active);
364
+ }
365
+ .ikey {
366
+ font-family: var(--kern-font-mono);
367
+ font-size: 12px;
368
+ color: var(--kern-ink-350);
369
+ }
370
+ .ititle {
371
+ overflow: hidden;
372
+ text-overflow: ellipsis;
373
+ white-space: nowrap;
374
+ }
375
+ .gname,
376
+ .picking {
377
+ margin: 6px 0 0;
378
+ font-size: 12px;
379
+ color: var(--kern-ink-350);
380
+ }
381
+ .empty {
382
+ margin: 4px 0 0;
383
+ font-size: 13px;
384
+ color: var(--kern-ink-350);
385
+ }
386
+ .linkform {
387
+ display: flex;
388
+ flex-direction: column;
389
+ gap: 6px;
390
+ margin-top: 6px;
391
+ }
392
+ .linkform input {
393
+ padding: 5px 8px;
394
+ border: 1px solid var(--kern-border);
395
+ border-radius: var(--kern-r-sm);
396
+ background: var(--kern-surface);
397
+ color: inherit;
398
+ font: inherit;
399
+ font-size: 13px;
400
+ }
401
+ .linkform input:focus-visible {
402
+ outline: none;
403
+ border-color: var(--kern-accent);
404
+ box-shadow: none;
405
+ }
406
+ .row {
407
+ display: flex;
408
+ gap: 6px;
409
+ }
410
+ .row button {
411
+ padding: 3px 10px;
412
+ border: 1px solid var(--kern-border);
413
+ border-radius: var(--kern-r-sm);
414
+ background: var(--kern-surface);
415
+ color: inherit;
416
+ font: inherit;
417
+ font-size: 12px;
418
+ cursor: pointer;
419
+ }
420
+ .row button:disabled {
421
+ opacity: 0.5;
422
+ cursor: default;
423
+ }
424
+ </style>