@workerdeck/ui 3.4.0 → 3.6.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.
- package/build/index.d.mts +55 -4
- package/build/index.mjs +702 -163
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +131 -0
- package/package.json +4 -4
- package/src/components/agent/AgentAvatar.tsx +117 -0
- package/src/components/agent/SessionBrowser.tsx +246 -43
- package/src/components/agent/SessionGroups.tsx +256 -23
- package/src/components/agent/SessionItem.tsx +200 -68
- package/src/index.ts +11 -0
- package/src/lib/team-drop.ts +65 -0
- package/src/styles/theme.css +22 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import type { DragEvent, HTMLAttributes, ReactNode } from 'react'
|
|
3
|
-
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'
|
|
3
|
+
import { GripVertical, Image as ImageIcon, Pencil, Plus, Trash2 } from 'lucide-react'
|
|
4
4
|
import {
|
|
5
5
|
addCustomGroup,
|
|
6
6
|
moveCustomGroup,
|
|
@@ -8,21 +8,52 @@ import {
|
|
|
8
8
|
newCustomGroupId,
|
|
9
9
|
removeCustomGroup,
|
|
10
10
|
renameCustomGroup,
|
|
11
|
+
sessionKey,
|
|
12
|
+
styleCustomGroup,
|
|
13
|
+
customGroupColor,
|
|
14
|
+
PROJECT_ACCENTS,
|
|
11
15
|
} from '@workerdeck/protocol'
|
|
12
|
-
import type { CustomGroup, SessionGroup } from '@workerdeck/protocol'
|
|
16
|
+
import type { CustomGroup, SessionGroup, SessionRow } from '@workerdeck/protocol'
|
|
17
|
+
import { dropZone, joinDrop, memberDrop, type DropZone, type TeamMove } from '../../lib/team-drop.ts'
|
|
13
18
|
import { Button } from '../ui/Button.tsx'
|
|
14
19
|
import { Input } from '../ui/Input.tsx'
|
|
20
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../ui/Popover.tsx'
|
|
15
21
|
import { cn } from '../../lib/utils.ts'
|
|
16
22
|
|
|
17
23
|
type DragItem = { kind: 'session'; key: string } | { kind: 'group'; id: string }
|
|
18
24
|
|
|
19
25
|
export type GroupDrag = ReturnType<typeof useGroupDrag>
|
|
20
26
|
|
|
27
|
+
// Where a card sits: `lead` is set for a member, the team it is drawn under.
|
|
28
|
+
export type DragPlace = { row: SessionRow; lead?: SessionRow }
|
|
29
|
+
|
|
30
|
+
export type TeamDrag = {
|
|
31
|
+
rowOf: (key: string) => SessionRow | undefined
|
|
32
|
+
// A rejection's message is drawn under the card it was dropped on: a gateway 409 is user-facing copy.
|
|
33
|
+
onMove: (move: TeamMove) => Promise<void> | void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Over = { spot: string; refusal?: string }
|
|
37
|
+
|
|
38
|
+
export type DropCue = { zone?: DropZone | 'refused'; message?: string }
|
|
39
|
+
type Landing = Over & { run?: () => void }
|
|
40
|
+
|
|
41
|
+
const ERROR_MS = 5000
|
|
42
|
+
|
|
21
43
|
// The payload lives in state rather than `dataTransfer`, which is unreadable during `dragover`; `setData` is still
|
|
22
|
-
// written because some engines refuse to start a drag without it.
|
|
23
|
-
|
|
44
|
+
// written because some engines refuse to start a drag without it. A card is split in three: the middle joins its
|
|
45
|
+
// team, the edges reorder (custom groups) or leave a team.
|
|
46
|
+
export function useGroupDrag(
|
|
47
|
+
groups: readonly CustomGroup[],
|
|
48
|
+
onChange: (groups: CustomGroup[]) => void,
|
|
49
|
+
options: { custom: boolean; team?: TeamDrag },
|
|
50
|
+
) {
|
|
51
|
+
const { custom, team } = options
|
|
24
52
|
const [item, setItem] = useState<DragItem>()
|
|
25
|
-
const [over, setOver] = useState<
|
|
53
|
+
const [over, setOver] = useState<Over>()
|
|
54
|
+
const [failure, setFailure] = useState<{ key: string; message: string }>()
|
|
55
|
+
const failureTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
56
|
+
useEffect(() => () => clearTimeout(failureTimer.current), [])
|
|
26
57
|
const end = () => {
|
|
27
58
|
setItem(undefined)
|
|
28
59
|
setOver(undefined)
|
|
@@ -33,49 +64,109 @@ export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups:
|
|
|
33
64
|
e.dataTransfer.setData('text/plain', next.kind === 'session' ? next.key : next.id)
|
|
34
65
|
setItem(next)
|
|
35
66
|
}
|
|
36
|
-
const accept = (e: DragEvent,
|
|
67
|
+
const accept = (e: DragEvent, next: Over) => {
|
|
37
68
|
e.preventDefault()
|
|
38
69
|
e.stopPropagation()
|
|
39
|
-
e.dataTransfer.dropEffect = 'move'
|
|
40
|
-
|
|
70
|
+
e.dataTransfer.dropEffect = next.refusal ? 'none' : 'move'
|
|
71
|
+
if (over?.spot !== next.spot || over.refusal !== next.refusal) {
|
|
72
|
+
setOver(next)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const fail = (key: string, err: unknown) => {
|
|
76
|
+
clearTimeout(failureTimer.current)
|
|
77
|
+
setFailure({ key, message: err instanceof Error ? err.message : String(err) })
|
|
78
|
+
failureTimer.current = setTimeout(() => setFailure(undefined), ERROR_MS)
|
|
79
|
+
}
|
|
80
|
+
const move = (key: string, next: TeamMove) => () => {
|
|
81
|
+
setFailure(undefined)
|
|
82
|
+
void Promise.resolve()
|
|
83
|
+
.then(() => team!.onMove(next))
|
|
84
|
+
.catch((err: unknown) => fail(key, err))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function land(e: DragEvent<HTMLDivElement>, key: string, group: SessionGroup, place?: DragPlace): Landing | undefined {
|
|
88
|
+
if (item?.kind !== 'session' || item.key === key) {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
const reorder = custom
|
|
92
|
+
? { spot: `row:${key}`, run: () => onChange(moveToCustomGroup(groups, item.key, group.custom, group.custom ? key : undefined)) }
|
|
93
|
+
: undefined
|
|
94
|
+
const dragged = team?.rowOf(item.key)
|
|
95
|
+
if (!team || !dragged || !place) {
|
|
96
|
+
return reorder
|
|
97
|
+
}
|
|
98
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
99
|
+
const zone = dropZone(e.clientY - rect.top, rect.height)
|
|
100
|
+
if (place.lead) {
|
|
101
|
+
const result = memberDrop(dragged, place.lead, place.row, zone)
|
|
102
|
+
return typeof result === 'string'
|
|
103
|
+
? { spot: `refused:${key}`, refusal: result }
|
|
104
|
+
: { spot: `${zone === 'before' ? 'before' : 'after'}:${key}`, run: move(key, result) }
|
|
105
|
+
}
|
|
106
|
+
if (zone === 'join') {
|
|
107
|
+
const result = joinDrop(dragged, place.row)
|
|
108
|
+
if (typeof result !== 'string') {
|
|
109
|
+
return { spot: `join:${key}`, run: move(key, result) }
|
|
110
|
+
}
|
|
111
|
+
return reorder ?? { spot: `refused:${key}`, refusal: result }
|
|
112
|
+
}
|
|
113
|
+
if (dragged.info.agent?.lead !== undefined) {
|
|
114
|
+
const leave = move(key, { row: dragged, lead: null })
|
|
115
|
+
return {
|
|
116
|
+
spot: `${zone}:${key}`,
|
|
117
|
+
run: () => {
|
|
118
|
+
leave()
|
|
119
|
+
reorder?.run()
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return reorder ? { ...reorder, spot: `${zone}:${key}` } : undefined
|
|
41
124
|
}
|
|
42
125
|
|
|
43
|
-
function session(key: string, group: SessionGroup): HTMLAttributes<HTMLDivElement> {
|
|
126
|
+
function session(key: string, group: SessionGroup, place?: DragPlace): HTMLAttributes<HTMLDivElement> {
|
|
44
127
|
return {
|
|
45
128
|
draggable: true,
|
|
46
129
|
onDragStart: (e) => start(e, { kind: 'session', key }),
|
|
47
130
|
onDragEnd: end,
|
|
48
131
|
onDragOver: (e) => {
|
|
49
|
-
|
|
50
|
-
|
|
132
|
+
const landing = land(e, key, group, place)
|
|
133
|
+
if (landing) {
|
|
134
|
+
accept(e, landing)
|
|
51
135
|
}
|
|
52
136
|
},
|
|
53
137
|
onDrop: (e) => {
|
|
54
|
-
|
|
138
|
+
const landing = land(e, key, group, place)
|
|
139
|
+
if (!landing) {
|
|
55
140
|
return
|
|
56
141
|
}
|
|
57
142
|
e.preventDefault()
|
|
58
143
|
e.stopPropagation()
|
|
59
|
-
|
|
60
|
-
onChange(moveToCustomGroup(groups, item.key, group.custom, group.custom ? key : undefined))
|
|
61
|
-
}
|
|
144
|
+
landing.run?.()
|
|
62
145
|
end()
|
|
63
146
|
},
|
|
64
147
|
}
|
|
65
148
|
}
|
|
66
149
|
|
|
67
150
|
function container(group: SessionGroup): HTMLAttributes<HTMLDivElement> {
|
|
151
|
+
const leaving = () => {
|
|
152
|
+
const dragged = item?.kind === 'session' ? team?.rowOf(item.key) : undefined
|
|
153
|
+
return dragged?.info.agent?.lead !== undefined ? dragged : undefined
|
|
154
|
+
}
|
|
68
155
|
return {
|
|
69
156
|
onDragOver: (e) => {
|
|
70
|
-
if (item) {
|
|
71
|
-
accept(e, `group:${group.key}`)
|
|
157
|
+
if (item && (custom || leaving())) {
|
|
158
|
+
accept(e, { spot: `group:${group.key}` })
|
|
72
159
|
}
|
|
73
160
|
},
|
|
74
161
|
onDrop: (e) => {
|
|
75
162
|
e.preventDefault()
|
|
76
|
-
|
|
163
|
+
const member = leaving()
|
|
164
|
+
if (member) {
|
|
165
|
+
move(sessionKey(member), { row: member, lead: null })()
|
|
166
|
+
}
|
|
167
|
+
if (custom && item?.kind === 'session') {
|
|
77
168
|
onChange(moveToCustomGroup(groups, item.key, group.custom))
|
|
78
|
-
} else if (item?.kind === 'group') {
|
|
169
|
+
} else if (custom && item?.kind === 'group') {
|
|
79
170
|
onChange(moveCustomGroup(groups, item.id, group.custom))
|
|
80
171
|
}
|
|
81
172
|
end()
|
|
@@ -91,9 +182,32 @@ export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups:
|
|
|
91
182
|
return { draggable: true, onDragStart: (e) => start(e, { kind: 'group', id }), onDragEnd: end }
|
|
92
183
|
}
|
|
93
184
|
|
|
185
|
+
// What to draw on a card while something hovers it, and a rejected drop's message for a while after.
|
|
186
|
+
function cue(key: string): DropCue {
|
|
187
|
+
if (failure?.key === key) {
|
|
188
|
+
return { zone: 'refused', message: failure.message }
|
|
189
|
+
}
|
|
190
|
+
const spot = over?.spot
|
|
191
|
+
if (!spot || spot.slice(spot.indexOf(':') + 1) !== key) {
|
|
192
|
+
return {}
|
|
193
|
+
}
|
|
194
|
+
const kind = spot.slice(0, spot.indexOf(':'))
|
|
195
|
+
if (kind === 'refused') {
|
|
196
|
+
return { zone: 'refused', message: over?.refusal }
|
|
197
|
+
}
|
|
198
|
+
return kind === 'row' || kind === 'before'
|
|
199
|
+
? { zone: 'before' }
|
|
200
|
+
: kind === 'after'
|
|
201
|
+
? { zone: 'after' }
|
|
202
|
+
: kind === 'join'
|
|
203
|
+
? { zone: 'join' }
|
|
204
|
+
: {}
|
|
205
|
+
}
|
|
206
|
+
|
|
94
207
|
return {
|
|
95
208
|
dragging: item !== undefined,
|
|
96
|
-
isOver: (spot: string) => over === spot,
|
|
209
|
+
isOver: (spot: string) => over?.spot === spot,
|
|
210
|
+
cue,
|
|
97
211
|
session,
|
|
98
212
|
container,
|
|
99
213
|
header,
|
|
@@ -103,23 +217,29 @@ export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups:
|
|
|
103
217
|
return id
|
|
104
218
|
},
|
|
105
219
|
rename: (id: string, name: string) => onChange(renameCustomGroup(groups, id, name)),
|
|
220
|
+
style: (id: string, look: { color?: string | null; icon?: string | null }) => onChange(styleCustomGroup(groups, id, look)),
|
|
106
221
|
remove: (id: string) => onChange(removeCustomGroup(groups, id)),
|
|
107
222
|
}
|
|
108
223
|
}
|
|
109
224
|
|
|
110
225
|
export function CustomGroupHeader({
|
|
111
226
|
group,
|
|
227
|
+
look,
|
|
112
228
|
editing,
|
|
113
229
|
onEditingChange,
|
|
114
230
|
onRename,
|
|
115
231
|
onRemove,
|
|
232
|
+
onStyle,
|
|
116
233
|
dragProps,
|
|
117
234
|
}: {
|
|
118
235
|
group: SessionGroup
|
|
236
|
+
// The stored group, for its colour and image; absent for the ungrouped bucket.
|
|
237
|
+
look?: CustomGroup
|
|
119
238
|
editing: boolean
|
|
120
239
|
onEditingChange: (editing: boolean) => void
|
|
121
240
|
onRename: (name: string) => void
|
|
122
241
|
onRemove: () => void
|
|
242
|
+
onStyle?: (look: { color?: string | null; icon?: string | null }) => void
|
|
123
243
|
dragProps: HTMLAttributes<HTMLDivElement>
|
|
124
244
|
}) {
|
|
125
245
|
if (editing) {
|
|
@@ -132,9 +252,17 @@ export function CustomGroupHeader({
|
|
|
132
252
|
{...dragProps}
|
|
133
253
|
label={group.label ?? ''}
|
|
134
254
|
count={group.rows.length}
|
|
255
|
+
caps={!custom}
|
|
135
256
|
className={cn('group/heading', custom && 'cursor-grab')}
|
|
136
257
|
onDoubleClick={custom ? () => onEditingChange(true) : undefined}
|
|
137
|
-
leading={
|
|
258
|
+
leading={
|
|
259
|
+
custom ? (
|
|
260
|
+
<>
|
|
261
|
+
<GripVertical className="-mr-1.5 -ml-1.5 size-3 opacity-0 group-hover/heading:opacity-60" />
|
|
262
|
+
{look ? <GroupBadge group={look} onStyle={onStyle} /> : null}
|
|
263
|
+
</>
|
|
264
|
+
) : undefined
|
|
265
|
+
}
|
|
138
266
|
actions={
|
|
139
267
|
custom ? (
|
|
140
268
|
<>
|
|
@@ -188,6 +316,7 @@ export function GroupHeading({
|
|
|
188
316
|
count,
|
|
189
317
|
leading,
|
|
190
318
|
actions,
|
|
319
|
+
caps = true,
|
|
191
320
|
className,
|
|
192
321
|
...rest
|
|
193
322
|
}: {
|
|
@@ -195,11 +324,13 @@ export function GroupHeading({
|
|
|
195
324
|
count: number
|
|
196
325
|
leading?: ReactNode
|
|
197
326
|
actions?: ReactNode
|
|
327
|
+
// Off for a name the operator typed, which keeps its own case.
|
|
328
|
+
caps?: boolean
|
|
198
329
|
} & HTMLAttributes<HTMLDivElement>) {
|
|
199
330
|
return (
|
|
200
331
|
<div {...rest} className={cn('group/heading flex min-h-5 items-center gap-2 px-2 text-label font-medium text-fg-4', className)}>
|
|
201
332
|
{leading}
|
|
202
|
-
<span className=
|
|
333
|
+
<span className={cn('min-w-0 truncate', caps ? 'uppercase tracking-wide' : 'text-fg-3')}>{label}</span>
|
|
203
334
|
<span className="text-fg-4/70">{count}</span>
|
|
204
335
|
{actions ? (
|
|
205
336
|
<span className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover/heading:opacity-100 focus-within:opacity-100">
|
|
@@ -246,3 +377,105 @@ export function NewGroupButton({ onClick }: { onClick: () => void }) {
|
|
|
246
377
|
</Button>
|
|
247
378
|
)
|
|
248
379
|
}
|
|
380
|
+
|
|
381
|
+
const ICON_PX = 64
|
|
382
|
+
|
|
383
|
+
// A group's badge: its image, else its colour; pressing it picks either.
|
|
384
|
+
export function GroupBadge({
|
|
385
|
+
group,
|
|
386
|
+
onStyle,
|
|
387
|
+
}: {
|
|
388
|
+
group: CustomGroup
|
|
389
|
+
onStyle?: (look: { color?: string | null; icon?: string | null }) => void
|
|
390
|
+
}) {
|
|
391
|
+
const [error, setError] = useState<string>()
|
|
392
|
+
const file = useRef<HTMLInputElement>(null)
|
|
393
|
+
const mark = group.icon ? (
|
|
394
|
+
<img src={group.icon} alt="" className="size-3.5 rounded-[3px] object-cover" draggable={false} />
|
|
395
|
+
) : (
|
|
396
|
+
<span className="size-2 rounded-[2px]" style={{ background: customGroupColor(group) }} />
|
|
397
|
+
)
|
|
398
|
+
if (!onStyle) {
|
|
399
|
+
return <span className="flex size-3.5 shrink-0 items-center justify-center">{mark}</span>
|
|
400
|
+
}
|
|
401
|
+
return (
|
|
402
|
+
<Popover>
|
|
403
|
+
<PopoverTrigger
|
|
404
|
+
render={
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
aria-label={`Colour or image for ${group.name}`}
|
|
408
|
+
title="Colour or image"
|
|
409
|
+
onClick={(e) => e.stopPropagation()}
|
|
410
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
411
|
+
className="flex size-3.5 shrink-0 items-center justify-center rounded-[3px] hover:ring-1 hover:ring-border"
|
|
412
|
+
>
|
|
413
|
+
{mark}
|
|
414
|
+
</button>
|
|
415
|
+
}
|
|
416
|
+
/>
|
|
417
|
+
<PopoverContent align="start" className="flex w-44 flex-col gap-2">
|
|
418
|
+
<div className="grid grid-cols-4 gap-1.5">
|
|
419
|
+
{PROJECT_ACCENTS.map((color) => (
|
|
420
|
+
<button
|
|
421
|
+
key={color}
|
|
422
|
+
type="button"
|
|
423
|
+
aria-label={`Colour ${color}`}
|
|
424
|
+
onClick={() => onStyle({ color, icon: null })}
|
|
425
|
+
className={cn(
|
|
426
|
+
'h-6 rounded-[4px] ring-offset-1 ring-offset-surface',
|
|
427
|
+
!group.icon && customGroupColor(group) === color && 'ring-2 ring-fg-3',
|
|
428
|
+
)}
|
|
429
|
+
style={{ background: color }}
|
|
430
|
+
/>
|
|
431
|
+
))}
|
|
432
|
+
</div>
|
|
433
|
+
<input
|
|
434
|
+
ref={file}
|
|
435
|
+
type="file"
|
|
436
|
+
accept="image/*"
|
|
437
|
+
className="hidden"
|
|
438
|
+
onChange={(e) => {
|
|
439
|
+
const picked = e.target.files?.[0]
|
|
440
|
+
e.target.value = ''
|
|
441
|
+
if (!picked) {
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
setError(undefined)
|
|
445
|
+
void iconFromFile(picked)
|
|
446
|
+
.then((icon) => onStyle({ icon }))
|
|
447
|
+
.catch(() => setError('That image could not be read'))
|
|
448
|
+
}}
|
|
449
|
+
/>
|
|
450
|
+
<Button variant="outline" size="xs" onClick={() => file.current?.click()}>
|
|
451
|
+
<ImageIcon />
|
|
452
|
+
Choose image…
|
|
453
|
+
</Button>
|
|
454
|
+
{group.icon ? (
|
|
455
|
+
<Button variant="ghost" size="xs" onClick={() => onStyle({ icon: null })}>
|
|
456
|
+
Remove image
|
|
457
|
+
</Button>
|
|
458
|
+
) : null}
|
|
459
|
+
{error ? <span className="text-label text-danger">{error}</span> : null}
|
|
460
|
+
</PopoverContent>
|
|
461
|
+
</Popover>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Scaled to cover a 64 px square and re-encoded, so a photo from disk stores as a few kB in the view config.
|
|
466
|
+
async function iconFromFile(file: File): Promise<string> {
|
|
467
|
+
const bitmap = await createImageBitmap(file)
|
|
468
|
+
const canvas = document.createElement('canvas')
|
|
469
|
+
canvas.width = ICON_PX
|
|
470
|
+
canvas.height = ICON_PX
|
|
471
|
+
const context = canvas.getContext('2d')
|
|
472
|
+
if (!context) {
|
|
473
|
+
throw new Error('no canvas')
|
|
474
|
+
}
|
|
475
|
+
const scale = Math.max(ICON_PX / bitmap.width, ICON_PX / bitmap.height)
|
|
476
|
+
const width = bitmap.width * scale
|
|
477
|
+
const height = bitmap.height * scale
|
|
478
|
+
context.drawImage(bitmap, (ICON_PX - width) / 2, (ICON_PX - height) / 2, width, height)
|
|
479
|
+
bitmap.close()
|
|
480
|
+
return canvas.toDataURL('image/png')
|
|
481
|
+
}
|