@spunto/design-system 0.28.0 → 0.30.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.
@@ -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
+ }