@spunto/design-system 0.28.0 → 0.30.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/README.md +35 -1
- package/package.json +6 -1
- package/src/components/sheet.tsx +7 -1
- package/src/components/tasks/agent-markdown.tsx +322 -0
- package/src/components/tasks/attachments.ts +152 -0
- package/src/components/tasks/command-log.tsx +102 -0
- package/src/components/tasks/composer.tsx +252 -0
- package/src/components/tasks/conversation-list.tsx +238 -0
- package/src/components/tasks/diff-panel.tsx +534 -0
- package/src/components/tasks/event-stream.tsx +860 -0
- package/src/components/tasks/index.ts +111 -0
- package/src/components/tasks/machine.tsx +455 -0
- package/src/components/tasks/new-task-dialog.tsx +301 -0
- package/src/components/tasks/pull-request.tsx +179 -0
- package/src/components/tasks/session-usage.tsx +165 -0
- package/src/components/tasks/task-attachments.tsx +475 -0
- package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
- package/src/components/tasks/task-cockpit.tsx +229 -0
- package/src/components/tasks/task-details.tsx +239 -0
- package/src/components/tasks/task-list.tsx +272 -0
- package/src/components/tasks/task-state.tsx +180 -0
- package/src/components/tasks/tasks.test.tsx +205 -0
- package/src/components/tasks/tool-stack.tsx +692 -0
- package/src/components/tasks/types.ts +220 -0
- package/src/components/tasks/worker-link.tsx +126 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, type ClipboardEvent, type DragEvent } from "react"
|
|
4
|
+
import { createPortal } from "react-dom"
|
|
5
|
+
import { DownloadIcon, Loader2Icon, PaperclipIcon, XIcon } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "../../utils"
|
|
8
|
+
import { useOverlayContainer } from "../spunto-provider"
|
|
9
|
+
import { MAX_FILES_PER_TURN, MAX_TURN_BYTES, fileKind, filesFrom, formatBytes, isPreviewable, prepareFile } from "./attachments"
|
|
10
|
+
import type { AttachmentUrl, DraftFile, TaskFileRef } from "./types"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Files in a delegated conversation (RFC 0022) — both directions, in one file, because they are
|
|
14
|
+
* the same object seen from two sides: what you are about to send, and what is already there.
|
|
15
|
+
*
|
|
16
|
+
* Two rules run through all of it:
|
|
17
|
+
*
|
|
18
|
+
* 1. **An attachment is never bigger than the thing it explains.** On the timeline a screenshot
|
|
19
|
+
* is a thumbnail on the rail, the size of a paragraph, and clicking it opens it full — a
|
|
20
|
+
* transcript where every tool answer is a full-bleed picture stops being a transcript.
|
|
21
|
+
* 2. **Only images are pictures.** Everything else is a row with a name, a size and a download,
|
|
22
|
+
* because that is all a browser should be asked to do with bytes it was handed (the API
|
|
23
|
+
* refuses to serve anything but four raster formats inline, for the same reason).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ── Sending ───────────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The files a composer is holding, and the three ways they arrive: the button, a paste, a drop.
|
|
30
|
+
*
|
|
31
|
+
* Paste is the one that carries the feature — a screenshot lives on the clipboard, and any path
|
|
32
|
+
* that makes someone save it to a file first is a path they will not take.
|
|
33
|
+
*
|
|
34
|
+
* Preparation (shrink, encode) is async and shows as a count, because a 4K paste takes a beat and
|
|
35
|
+
* a composer that looks like nothing happened invites a second paste.
|
|
36
|
+
*/
|
|
37
|
+
export function useAttachmentDraft() {
|
|
38
|
+
const [files, setFiles] = useState<DraftFile[]>([])
|
|
39
|
+
const [preparing, setPreparing] = useState(0)
|
|
40
|
+
const [error, setError] = useState<string | null>(null)
|
|
41
|
+
const [draggingOver, setDraggingOver] = useState(false)
|
|
42
|
+
// `dragenter`/`dragleave` fire for every child the pointer crosses, so a boolean flickers off
|
|
43
|
+
// the moment the cursor passes over the textarea inside the zone. Counting entries and exits is
|
|
44
|
+
// the standard fix: the zone is "entered" until as many leaves as enters have come back.
|
|
45
|
+
const depth = useRef(0)
|
|
46
|
+
|
|
47
|
+
// Object URLs outlive the render that made them, so they are revoked when the draft lets go of
|
|
48
|
+
// them and once more when the component unmounts with files still in hand.
|
|
49
|
+
const live = useRef<DraftFile[]>([])
|
|
50
|
+
live.current = files
|
|
51
|
+
useEffect(() => () => live.current.forEach(revoke), [])
|
|
52
|
+
|
|
53
|
+
const add = useCallback(async (incoming: File[]) => {
|
|
54
|
+
if (incoming.length === 0) return
|
|
55
|
+
setError(null)
|
|
56
|
+
setPreparing((n) => n + incoming.length)
|
|
57
|
+
try {
|
|
58
|
+
for (const file of incoming) {
|
|
59
|
+
const prepared = await prepareFile(file)
|
|
60
|
+
if (!prepared) {
|
|
61
|
+
setError(`${file.name || "That file"} could not be attached — it is empty, or over 10 MB.`)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
let refused: string | null = null
|
|
65
|
+
setFiles((prev) => {
|
|
66
|
+
if (prev.length >= MAX_FILES_PER_TURN) {
|
|
67
|
+
refused = `Up to ${MAX_FILES_PER_TURN} files per message.`
|
|
68
|
+
return prev
|
|
69
|
+
}
|
|
70
|
+
// The per-turn budget, so ten files at the ceiling is not a 100 MB request — the API
|
|
71
|
+
// enforces the same thing, this just says so before the round trip.
|
|
72
|
+
const total = prev.reduce((n, f) => n + f.bytes, 0)
|
|
73
|
+
if (total + prepared.bytes > MAX_TURN_BYTES) {
|
|
74
|
+
refused = `That would take this message over ${formatBytes(MAX_TURN_BYTES)} in total.`
|
|
75
|
+
return prev
|
|
76
|
+
}
|
|
77
|
+
return [...prev, prepared]
|
|
78
|
+
})
|
|
79
|
+
if (refused) {
|
|
80
|
+
revoke(prepared)
|
|
81
|
+
setError(refused)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
setPreparing((n) => Math.max(0, n - incoming.length))
|
|
86
|
+
}
|
|
87
|
+
}, [])
|
|
88
|
+
|
|
89
|
+
const remove = useCallback((key: string) => {
|
|
90
|
+
setFiles((prev) => {
|
|
91
|
+
const gone = prev.find((f) => f.key === key)
|
|
92
|
+
if (gone) revoke(gone)
|
|
93
|
+
return prev.filter((f) => f.key !== key)
|
|
94
|
+
})
|
|
95
|
+
}, [])
|
|
96
|
+
|
|
97
|
+
/** Drop the draft and free its thumbnails — abandoning it, not sending it. */
|
|
98
|
+
const clear = useCallback(() => {
|
|
99
|
+
setFiles((prev) => {
|
|
100
|
+
prev.forEach(revoke)
|
|
101
|
+
return []
|
|
102
|
+
})
|
|
103
|
+
setError(null)
|
|
104
|
+
}, [])
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Empty the draft while **handing the object URLs on** to whoever takes the turn from here.
|
|
108
|
+
*
|
|
109
|
+
* The difference with `clear` is the whole point: a sent message is drawn optimistically from
|
|
110
|
+
* these very URLs until its own event comes back, so revoking them on submit blanks the picture
|
|
111
|
+
* in the bubble the instant it appears — which reads as the send having failed.
|
|
112
|
+
*/
|
|
113
|
+
const release = useCallback(() => {
|
|
114
|
+
setFiles([])
|
|
115
|
+
setError(null)
|
|
116
|
+
}, [])
|
|
117
|
+
|
|
118
|
+
/** What goes in the request body — the local-only fields stay local. */
|
|
119
|
+
const payload = useCallback(
|
|
120
|
+
() => files.map(({ name, mediaType, data }) => ({ filename: name, mediaType, data })),
|
|
121
|
+
[files],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
files,
|
|
126
|
+
preparing,
|
|
127
|
+
error,
|
|
128
|
+
add,
|
|
129
|
+
remove,
|
|
130
|
+
clear,
|
|
131
|
+
release,
|
|
132
|
+
payload,
|
|
133
|
+
draggingOver,
|
|
134
|
+
/** Wire onto a textarea: a screenshot on the clipboard is the common case, not a fallback. */
|
|
135
|
+
onPaste: (e: ClipboardEvent) => {
|
|
136
|
+
const dropped = filesFrom(e.clipboardData?.items ?? null)
|
|
137
|
+
if (dropped.length === 0) return
|
|
138
|
+
// Only once there *is* a file: a plain text paste must go through untouched.
|
|
139
|
+
e.preventDefault()
|
|
140
|
+
void add(dropped)
|
|
141
|
+
},
|
|
142
|
+
/**
|
|
143
|
+
* Spread onto whatever should accept a drop — in practice the whole composer, not just the
|
|
144
|
+
* textarea. Dropping a file onto a reply box is something people try before they look for a
|
|
145
|
+
* button, so the target has to be the box they can see, and it has to light up.
|
|
146
|
+
*/
|
|
147
|
+
dropZone: {
|
|
148
|
+
onDragEnter: (e: DragEvent) => {
|
|
149
|
+
if (!e.dataTransfer?.types.includes("Files")) return
|
|
150
|
+
depth.current += 1
|
|
151
|
+
setDraggingOver(true)
|
|
152
|
+
},
|
|
153
|
+
onDragLeave: () => {
|
|
154
|
+
depth.current = Math.max(0, depth.current - 1)
|
|
155
|
+
if (depth.current === 0) setDraggingOver(false)
|
|
156
|
+
},
|
|
157
|
+
// Without this the browser navigates to the dropped file instead of handing it over.
|
|
158
|
+
onDragOver: (e: DragEvent) => {
|
|
159
|
+
if (e.dataTransfer?.types.includes("Files")) e.preventDefault()
|
|
160
|
+
},
|
|
161
|
+
onDrop: (e: DragEvent) => {
|
|
162
|
+
depth.current = 0
|
|
163
|
+
setDraggingOver(false)
|
|
164
|
+
const dropped = filesFrom(e.dataTransfer?.files ?? null)
|
|
165
|
+
if (dropped.length === 0) return
|
|
166
|
+
e.preventDefault()
|
|
167
|
+
void add(dropped)
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function revoke(file: DraftFile): void {
|
|
174
|
+
if (file.previewUrl) URL.revokeObjectURL(file.previewUrl)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type AttachmentDraft = ReturnType<typeof useAttachmentDraft>
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The attach control — a hidden file input behind a button you can actually find.
|
|
181
|
+
*
|
|
182
|
+
* A **paperclip**, not a picture frame: the glyph every messaging and mail client uses for "put
|
|
183
|
+
* something in this message", and now literally true, since it takes any file.
|
|
184
|
+
*
|
|
185
|
+
* Sized and placed like one too. The first cut of this was a 28px borderless icon tucked into the
|
|
186
|
+
* composer's 10px hint bar, next to the text — technically present, and missed entirely by the
|
|
187
|
+
* first person who went looking for it. Attaching is not a footnote about the box, it is one of
|
|
188
|
+
* the two things you do with it, so it sits **at the edge of the writing area**, at a size a
|
|
189
|
+
* pointer aims at without care.
|
|
190
|
+
*/
|
|
191
|
+
export function AttachFileButton({
|
|
192
|
+
onFiles,
|
|
193
|
+
disabled,
|
|
194
|
+
className,
|
|
195
|
+
size = "md",
|
|
196
|
+
label,
|
|
197
|
+
}: {
|
|
198
|
+
onFiles: (files: File[]) => void
|
|
199
|
+
disabled?: boolean
|
|
200
|
+
className?: string
|
|
201
|
+
size?: "md" | "lg"
|
|
202
|
+
/** Spelled out instead of icon-only — for a form, which has the room a composer doesn't. */
|
|
203
|
+
label?: string
|
|
204
|
+
}) {
|
|
205
|
+
const input = useRef<HTMLInputElement>(null)
|
|
206
|
+
return (
|
|
207
|
+
<>
|
|
208
|
+
{/* No `accept`: the platform stores bytes and hands the agent a path, so narrowing the
|
|
209
|
+
picker would only hide files it is perfectly able to carry. */}
|
|
210
|
+
<input
|
|
211
|
+
ref={input}
|
|
212
|
+
type="file"
|
|
213
|
+
multiple
|
|
214
|
+
className="hidden"
|
|
215
|
+
onChange={(e) => {
|
|
216
|
+
onFiles(filesFrom(e.target.files))
|
|
217
|
+
// Cleared so picking the same file twice in a row still fires a change.
|
|
218
|
+
e.target.value = ""
|
|
219
|
+
}}
|
|
220
|
+
/>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
disabled={disabled}
|
|
224
|
+
onClick={() => input.current?.click()}
|
|
225
|
+
aria-label={label ?? "Attach files"}
|
|
226
|
+
title="Attach files — or paste, or drop them in"
|
|
227
|
+
className={cn(
|
|
228
|
+
"flex shrink-0 touch-manipulation items-center gap-1.5 rounded-lg border border-border/80 bg-background text-muted-foreground transition-colors hover:border-primary/40 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40",
|
|
229
|
+
label ? "px-2.5 py-1.5 text-xs" : size === "lg" ? "h-10 w-10 justify-center rounded-full" : "h-8 w-8 justify-center",
|
|
230
|
+
className,
|
|
231
|
+
)}
|
|
232
|
+
>
|
|
233
|
+
<PaperclipIcon className={size === "lg" && !label ? "h-[18px] w-[18px]" : "h-4 w-4"} />
|
|
234
|
+
{label}
|
|
235
|
+
</button>
|
|
236
|
+
</>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* What a drag over the composer looks like. Drawn over the whole box rather than as a border
|
|
242
|
+
* tint: the question in a reader's head at that moment is "will this land?", and only something
|
|
243
|
+
* unambiguous answers it.
|
|
244
|
+
*/
|
|
245
|
+
export function FileDropOverlay() {
|
|
246
|
+
return (
|
|
247
|
+
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-primary/60 bg-primary/[0.08] backdrop-blur-[1px]">
|
|
248
|
+
<span className="flex items-center gap-2 text-xs font-medium text-primary">
|
|
249
|
+
<PaperclipIcon className="h-4 w-4" />
|
|
250
|
+
Drop files to attach them
|
|
251
|
+
</span>
|
|
252
|
+
</div>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** What is about to be sent. Tiles, not a gallery: the words are still the message. */
|
|
257
|
+
export function AttachmentDraftStrip({ draft }: { draft: AttachmentDraft }) {
|
|
258
|
+
if (draft.files.length === 0 && draft.preparing === 0 && !draft.error) return null
|
|
259
|
+
return (
|
|
260
|
+
<div className="px-2 pt-2">
|
|
261
|
+
{draft.error && <p className="mb-1.5 text-[11px] text-destructive">{draft.error}</p>}
|
|
262
|
+
<div className="flex flex-wrap gap-1.5">
|
|
263
|
+
{draft.files.map((file) => (
|
|
264
|
+
<div
|
|
265
|
+
key={file.key}
|
|
266
|
+
className="group/att relative flex h-14 items-center overflow-hidden rounded-md border bg-muted"
|
|
267
|
+
title={`${file.name} · ${formatBytes(file.bytes)}`}
|
|
268
|
+
>
|
|
269
|
+
{file.previewUrl ? (
|
|
270
|
+
<img src={file.previewUrl} alt={file.name} className="h-14 w-14 object-cover" />
|
|
271
|
+
) : (
|
|
272
|
+
// A file with no picture still has to be identifiable at a glance, so it shows the
|
|
273
|
+
// two things that identify it: what kind it is, and what it is called.
|
|
274
|
+
<div className="flex h-full max-w-48 items-center gap-2 bg-background px-2.5">
|
|
275
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted text-[9px] font-semibold text-muted-foreground">
|
|
276
|
+
{fileKind(file.name, file.mediaType)}
|
|
277
|
+
</span>
|
|
278
|
+
<span className="min-w-0">
|
|
279
|
+
<span className="block truncate text-[11px] font-medium">{file.name}</span>
|
|
280
|
+
<span className="block text-[10px] text-muted-foreground">{formatBytes(file.bytes)}</span>
|
|
281
|
+
</span>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={() => draft.remove(file.key)}
|
|
287
|
+
aria-label={`Remove ${file.name}`}
|
|
288
|
+
className="absolute right-0.5 top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-background/90 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/att:opacity-100 focus-visible:opacity-100"
|
|
289
|
+
>
|
|
290
|
+
<XIcon className="h-3 w-3" />
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
))}
|
|
294
|
+
{draft.preparing > 0 && (
|
|
295
|
+
<div className="flex h-14 w-14 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
|
296
|
+
<Loader2Icon className="h-4 w-4 animate-spin" />
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Reading ───────────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
export function fileRefs(value: unknown): TaskFileRef[] {
|
|
307
|
+
if (!Array.isArray(value)) return []
|
|
308
|
+
return value.filter(
|
|
309
|
+
(v): v is TaskFileRef => !!v && typeof v === "object" && typeof (v as TaskFileRef).id === "string",
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The attachments of one event, on the timeline.
|
|
315
|
+
*
|
|
316
|
+
* Fetched by id from a route whose answer never changes, so a stream polling every two seconds
|
|
317
|
+
* asks for each one exactly once however many times the event is re-rendered — which is the whole
|
|
318
|
+
* reason the bytes are not in the payload. `loading="lazy"` because a long session scrolled to its
|
|
319
|
+
* end has no business downloading the screenshots of an hour ago.
|
|
320
|
+
*
|
|
321
|
+
* Images get a thumbnail and a lightbox; everything else gets a row that downloads. That split is
|
|
322
|
+
* not a rendering preference — the API will not serve anything else with its own content type, so
|
|
323
|
+
* there is nothing else to draw.
|
|
324
|
+
*/
|
|
325
|
+
export function TaskFiles({
|
|
326
|
+
files,
|
|
327
|
+
attachmentUrl,
|
|
328
|
+
className,
|
|
329
|
+
}: {
|
|
330
|
+
files: TaskFileRef[]
|
|
331
|
+
attachmentUrl: AttachmentUrl
|
|
332
|
+
className?: string
|
|
333
|
+
}) {
|
|
334
|
+
const [open, setOpen] = useState<TaskFileRef | null>(null)
|
|
335
|
+
if (files.length === 0) return null
|
|
336
|
+
const images = files.filter((f) => isPreviewable(f.mediaType))
|
|
337
|
+
const others = files.filter((f) => !isPreviewable(f.mediaType))
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<>
|
|
341
|
+
{images.length > 0 && (
|
|
342
|
+
<div className={cn("mt-2 flex flex-wrap gap-2", className)}>
|
|
343
|
+
{images.map((file) => (
|
|
344
|
+
<button
|
|
345
|
+
key={file.id}
|
|
346
|
+
type="button"
|
|
347
|
+
onClick={() => setOpen(file)}
|
|
348
|
+
className="group/img block max-w-full overflow-hidden rounded-lg border bg-muted/40 transition-colors hover:border-primary/40"
|
|
349
|
+
>
|
|
350
|
+
<img
|
|
351
|
+
src={attachmentUrl(file.id)}
|
|
352
|
+
alt={file.filename ?? "Attached image"}
|
|
353
|
+
loading="lazy"
|
|
354
|
+
// Capped rather than full-bleed: on the rail a picture is a paragraph, not a page.
|
|
355
|
+
className="max-h-56 w-auto max-w-full object-contain"
|
|
356
|
+
/>
|
|
357
|
+
</button>
|
|
358
|
+
))}
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
|
|
362
|
+
{others.length > 0 && (
|
|
363
|
+
<div className={cn("mt-2 flex flex-col gap-1.5", className)}>
|
|
364
|
+
{others.map((file) => (
|
|
365
|
+
<a
|
|
366
|
+
key={file.id}
|
|
367
|
+
href={attachmentUrl(file.id)}
|
|
368
|
+
// The API answers these with `Content-Disposition: attachment`, so this saves rather
|
|
369
|
+
// than navigates; the attribute is what gives the browser the name to save under
|
|
370
|
+
// before it has seen a single header.
|
|
371
|
+
download={file.filename ?? undefined}
|
|
372
|
+
className="group/file flex max-w-sm items-center gap-2.5 rounded-lg border bg-card px-2.5 py-2 transition-colors hover:border-primary/40 hover:bg-muted/50"
|
|
373
|
+
>
|
|
374
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted text-[9px] font-semibold text-muted-foreground">
|
|
375
|
+
{fileKind(file.filename ?? "", file.mediaType)}
|
|
376
|
+
</span>
|
|
377
|
+
<span className="min-w-0 flex-1">
|
|
378
|
+
<span className="block truncate text-xs font-medium">{file.filename ?? "Attachment"}</span>
|
|
379
|
+
<span className="block text-[10px] text-muted-foreground">{formatBytes(file.bytes)}</span>
|
|
380
|
+
</span>
|
|
381
|
+
<DownloadIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-colors group-hover/file:text-foreground" />
|
|
382
|
+
</a>
|
|
383
|
+
))}
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
{open && (
|
|
388
|
+
<FileLightbox
|
|
389
|
+
src={attachmentUrl(open.id)}
|
|
390
|
+
name={open.filename}
|
|
391
|
+
bytes={open.bytes}
|
|
392
|
+
onClose={() => setOpen(null)}
|
|
393
|
+
/>
|
|
394
|
+
)}
|
|
395
|
+
</>
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Full size, over everything.
|
|
401
|
+
*
|
|
402
|
+
* Deliberately not the `Dialog` primitive: this has no title, no description and no actions — it
|
|
403
|
+
* is one picture, and everything a dialog would frame it with is chrome between the reader and
|
|
404
|
+
* the thing they clicked. Escape and a click anywhere close it, which is what every viewer has
|
|
405
|
+
* trained for.
|
|
406
|
+
*
|
|
407
|
+
* **Portalled to `body`, and that is not a detail.** The timeline is virtualized, so every row
|
|
408
|
+
* sits under a `transform: translateY(…)` — and a transformed ancestor becomes the containing
|
|
409
|
+
* block of its `position: fixed` descendants. Rendered in place, "over everything" turned out to
|
|
410
|
+
* mean "over that one row", which is exactly what it looked like: a postage stamp inside the
|
|
411
|
+
* transcript. Mounted on the body there is no ancestor left to be trapped by.
|
|
412
|
+
*/
|
|
413
|
+
function FileLightbox({
|
|
414
|
+
src,
|
|
415
|
+
name,
|
|
416
|
+
bytes,
|
|
417
|
+
onClose,
|
|
418
|
+
}: {
|
|
419
|
+
src: string
|
|
420
|
+
name: string | null
|
|
421
|
+
bytes: number
|
|
422
|
+
onClose: () => void
|
|
423
|
+
}) {
|
|
424
|
+
const container = useOverlayContainer()
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose()
|
|
427
|
+
window.addEventListener("keydown", onKey)
|
|
428
|
+
return () => window.removeEventListener("keydown", onKey)
|
|
429
|
+
}, [onClose])
|
|
430
|
+
|
|
431
|
+
// Into the SpuntoProvider's overlay container when there is one (themed tree), `body` otherwise.
|
|
432
|
+
// Above `z-[120]`: the phone cockpit is itself a layer at that height.
|
|
433
|
+
//
|
|
434
|
+
// No "is this the client yet?" guard: nothing renders this until someone clicks a thumbnail, so
|
|
435
|
+
// it never exists during SSR or on the hydrating render, and `document` is always there by then.
|
|
436
|
+
return createPortal(
|
|
437
|
+
<div
|
|
438
|
+
role="dialog"
|
|
439
|
+
aria-modal="true"
|
|
440
|
+
aria-label={name ?? "Attached image"}
|
|
441
|
+
onClick={onClose}
|
|
442
|
+
className="fixed inset-0 z-[140] flex items-center justify-center bg-background/90 p-4 backdrop-blur-sm"
|
|
443
|
+
>
|
|
444
|
+
<img
|
|
445
|
+
src={src}
|
|
446
|
+
alt={name ?? "Attached image"}
|
|
447
|
+
onClick={(e) => e.stopPropagation()}
|
|
448
|
+
className="max-h-full max-w-full rounded-lg object-contain shadow-lg"
|
|
449
|
+
/>
|
|
450
|
+
<div className="absolute right-3 top-3 flex items-center gap-2">
|
|
451
|
+
<span className="max-w-xs truncate rounded-full bg-background/80 px-2 py-1 text-[11px] text-muted-foreground">
|
|
452
|
+
{name ? `${name} · ${formatBytes(bytes)}` : formatBytes(bytes)}
|
|
453
|
+
</span>
|
|
454
|
+
<a
|
|
455
|
+
href={src}
|
|
456
|
+
target="_blank"
|
|
457
|
+
rel="noreferrer"
|
|
458
|
+
onClick={(e) => e.stopPropagation()}
|
|
459
|
+
className="rounded-full bg-background/80 px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground"
|
|
460
|
+
>
|
|
461
|
+
Open
|
|
462
|
+
</a>
|
|
463
|
+
<button
|
|
464
|
+
type="button"
|
|
465
|
+
onClick={onClose}
|
|
466
|
+
aria-label="Close"
|
|
467
|
+
className="flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-muted-foreground hover:text-foreground"
|
|
468
|
+
>
|
|
469
|
+
<XIcon className="h-4 w-4" />
|
|
470
|
+
</button>
|
|
471
|
+
</div>
|
|
472
|
+
</div>,
|
|
473
|
+
container ?? document.body,
|
|
474
|
+
)
|
|
475
|
+
}
|