| 1 | import { |
| 2 | useCallback, |
| 3 | useEffect, |
| 4 | useMemo, |
| 5 | useRef, |
| 6 | useState, |
| 7 | type KeyboardEvent as ReactKeyboardEvent, |
| 8 | } from "react"; |
| 9 | import { ArrowUp, ImageIcon, Loader2, X } from "lucide-react"; |
| 10 | import { useTranslation } from "react-i18next"; |
| 11 | |
| 12 | import { Button } from "@/components/ui/button"; |
| 13 | import { CountableTextarea } from "@/components/thread/CountableTextarea"; |
| 14 | import { |
| 15 | DEFAULT_DURATION, |
| 16 | DurationPicker, |
| 17 | } from "@/components/thread/DurationPicker"; |
| 18 | import { |
| 19 | AspectRatioPicker, |
| 20 | DEFAULT_VIDEO_SIZE, |
| 21 | type VideoSize, |
| 22 | } from "@/components/thread/AspectRatioPicker"; |
| 23 | import { FirstFrameUploader } from "@/components/thread/FirstFrameUploader"; |
| 24 | import { |
| 25 | DEFAULT_STORY_LANGUAGE, |
| 26 | LanguagePicker, |
| 27 | type StoryLanguage, |
| 28 | } from "@/components/thread/LanguagePicker"; |
| 29 | import { FIRST_FRAME_UPLOAD_ENABLED } from "@/config/features"; |
| 30 | import { |
| 31 | useAttachedImages, |
| 32 | type AttachedImage, |
| 33 | type AttachmentError, |
| 34 | MAX_IMAGES_PER_MESSAGE, |
| 35 | } from "@/hooks/useAttachedImages"; |
| 36 | import type { FirstFrameData } from "@/hooks/useFirstFrameImage"; |
| 37 | import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; |
| 38 | import type { SendImage } from "@/hooks/useNanobotStream"; |
| 39 | import { cn } from "@/lib/utils"; |
| 40 | |
| 41 | function formatBytes(n: number): string { |
| 42 | if (n < 1024) return `${n} B`; |
| 43 | if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; |
| 44 | return `${(n / (1024 * 1024)).toFixed(1)} MB`; |
| 45 | } |
| 46 | |
| 47 | interface ThreadComposerProps { |
| 48 | /** Return false (or reject) to keep the draft and attachments for retry. */ |
| 49 | onSend: ( |
| 50 | content: string, |
| 51 | images?: SendImage[], |
| 52 | ) => void | boolean | Promise<void | boolean>; |
| 53 | disabled?: boolean; |
| 54 | /** Blocks send (ArrowUp) only; textarea stays editable when false alongside disabled=false. */ |
| 55 | sendDisabled?: boolean; |
| 56 | placeholder?: string; |
| 57 | modelLabel?: string | null; |
| 58 | variant?: "thread" | "hero"; |
| 59 | durationSec?: number; |
| 60 | onDurationChange?: (sec: number) => void; |
| 61 | videoSize?: VideoSize; |
| 62 | onVideoSizeChange?: (size: VideoSize) => void; |
| 63 | videoSizeLocked?: boolean; |
| 64 | storyLanguage?: StoryLanguage; |
| 65 | onStoryLanguageChange?: (language: StoryLanguage) => void; |
| 66 | /** Generate the remaining shots automatically after story confirmation. */ |
| 67 | autoGenerate?: boolean; |
| 68 | onAutoGenerateChange?: (enabled: boolean) => void; |
| 69 | showAutoGenerateToggle?: boolean; |
| 70 | durationOptions?: readonly number[]; |
| 71 | /** Short-video first-frame uploader (inside composer card, left of textarea). */ |
| 72 | showFirstFrame?: boolean; |
| 73 | firstFrame?: FirstFrameData | null; |
| 74 | firstFrameCropping?: boolean; |
| 75 | firstFrameLocked?: boolean; |
| 76 | onFirstFramePick?: (file: File) => void; |
| 77 | onFirstFrameClear?: () => void; |
| 78 | onFirstFrameLockedAttempt?: () => void; |
| 79 | } |
| 80 | |
| 81 | export function ThreadComposer({ |
| 82 | onSend, |
| 83 | disabled, |
| 84 | sendDisabled, |
| 85 | placeholder, |
| 86 | modelLabel = null, |
| 87 | variant = "thread", |
| 88 | durationSec, |
| 89 | onDurationChange, |
| 90 | videoSize = DEFAULT_VIDEO_SIZE, |
| 91 | onVideoSizeChange, |
| 92 | videoSizeLocked = false, |
| 93 | storyLanguage = DEFAULT_STORY_LANGUAGE, |
| 94 | onStoryLanguageChange, |
| 95 | autoGenerate = false, |
| 96 | onAutoGenerateChange, |
| 97 | showAutoGenerateToggle = true, |
| 98 | durationOptions, |
| 99 | showFirstFrame = false, |
| 100 | firstFrame = null, |
| 101 | firstFrameCropping = false, |
| 102 | firstFrameLocked = false, |
| 103 | onFirstFramePick, |
| 104 | onFirstFrameClear, |
| 105 | onFirstFrameLockedAttempt, |
| 106 | }: ThreadComposerProps) { |
| 107 | const { t } = useTranslation(); |
| 108 | const [value, setValue] = useState(""); |
| 109 | const [internalDuration, setInternalDuration] = useState(DEFAULT_DURATION); |
| 110 | const [inlineError, setInlineError] = useState<string | null>(null); |
| 111 | const [submitting, setSubmitting] = useState(false); |
| 112 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 113 | const chipRefs = useRef(new Map<string, HTMLButtonElement>()); |
| 114 | const isHero = variant === "hero"; |
| 115 | const resolvedDuration = durationSec ?? internalDuration; |
| 116 | const resolvedPlaceholder = |
| 117 | placeholder ?? t("thread.composer.placeholderThread"); |
| 118 | |
| 119 | const { images, enqueue, remove, clear, encoding } = useAttachedImages(); |
| 120 | |
| 121 | const formatRejection = useCallback( |
| 122 | (reason: AttachmentError): string => { |
| 123 | const key = `thread.composer.imageRejected.${reason}`; |
| 124 | return t(key, { max: MAX_IMAGES_PER_MESSAGE }); |
| 125 | }, |
| 126 | [t], |
| 127 | ); |
| 128 | |
| 129 | const addFiles = useCallback( |
| 130 | (files: File[]) => { |
| 131 | if (files.length === 0) return; |
| 132 | // First-frame mode: drop/paste replaces the single reference image. |
| 133 | if (FIRST_FRAME_UPLOAD_ENABLED && showFirstFrame && onFirstFramePick) { |
| 134 | onFirstFramePick(files[0]!); |
| 135 | setInlineError(null); |
| 136 | return; |
| 137 | } |
| 138 | if (showFirstFrame && !FIRST_FRAME_UPLOAD_ENABLED) { |
| 139 | return; |
| 140 | } |
| 141 | const { rejected } = enqueue(files); |
| 142 | if (rejected.length > 0) { |
| 143 | setInlineError(formatRejection(rejected[0].reason)); |
| 144 | } else { |
| 145 | setInlineError(null); |
| 146 | } |
| 147 | }, |
| 148 | [enqueue, formatRejection, showFirstFrame, onFirstFramePick], |
| 149 | ); |
| 150 | |
| 151 | const { isDragging, onPaste, onDragEnter, onDragOver, onDragLeave, onDrop } = |
| 152 | useClipboardAndDrop(addFiles); |
| 153 | |
| 154 | useEffect(() => { |
| 155 | if (disabled) return; |
| 156 | const el = textareaRef.current; |
| 157 | if (!el) return; |
| 158 | const id = requestAnimationFrame(() => el.focus()); |
| 159 | return () => cancelAnimationFrame(id); |
| 160 | }, [disabled]); |
| 161 | |
| 162 | const readyImages = useMemo( |
| 163 | () => |
| 164 | images.filter( |
| 165 | (img): img is AttachedImage & { dataUrl: string } => |
| 166 | img.status === "ready" && typeof img.dataUrl === "string", |
| 167 | ), |
| 168 | [images], |
| 169 | ); |
| 170 | const hasErrors = images.some((img) => img.status === "error"); |
| 171 | |
| 172 | const canSend = |
| 173 | !disabled && |
| 174 | !sendDisabled && |
| 175 | !submitting && |
| 176 | !encoding && |
| 177 | !hasErrors && |
| 178 | (value.trim().length > 0 || readyImages.length > 0); |
| 179 | |
| 180 | const handleDurationChange = useCallback( |
| 181 | (sec: number) => { |
| 182 | if (durationSec === undefined) { |
| 183 | setInternalDuration(sec); |
| 184 | } |
| 185 | onDurationChange?.(sec); |
| 186 | }, |
| 187 | [durationSec, onDurationChange], |
| 188 | ); |
| 189 | |
| 190 | const submit = useCallback(async () => { |
| 191 | if (!canSend) return; |
| 192 | const trimmed = value.trim(); |
| 193 | // Share the same normalized ``data:`` URL with both the wire payload and |
| 194 | // the optimistic bubble preview: data URLs are self-contained (no blob |
| 195 | // lifetime, safe under React StrictMode double-mount) and keep the |
| 196 | // bubble in sync with whatever the backend actually sees. |
| 197 | const payload: SendImage[] | undefined = |
| 198 | readyImages.length > 0 |
| 199 | ? readyImages.map((img) => ({ |
| 200 | media: { |
| 201 | data_url: img.dataUrl, |
| 202 | name: img.file.name, |
| 203 | }, |
| 204 | preview: { url: img.dataUrl, name: img.file.name }, |
| 205 | })) |
| 206 | : undefined; |
| 207 | setSubmitting(true); |
| 208 | try { |
| 209 | const accepted = await onSend(trimmed, payload); |
| 210 | if (accepted === false) return; |
| 211 | setValue(""); |
| 212 | setInlineError(null); |
| 213 | // Bubble owns the data URL copy; safe to revoke every staged blob |
| 214 | // preview here without affecting the rendered message. |
| 215 | clear(); |
| 216 | requestAnimationFrame(() => { |
| 217 | const el = textareaRef.current; |
| 218 | if (el) { |
| 219 | el.style.height = "auto"; |
| 220 | el.focus(); |
| 221 | } |
| 222 | }); |
| 223 | } catch { |
| 224 | // Keep the complete draft intact so the user can retry. |
| 225 | } finally { |
| 226 | setSubmitting(false); |
| 227 | } |
| 228 | }, [canSend, clear, onSend, readyImages, value]); |
| 229 | |
| 230 | const onKeyDown = (_e: ReactKeyboardEvent<HTMLTextAreaElement>) => { |
| 231 | // Enter inserts newline; send via submit button only. |
| 232 | }; |
| 233 | |
| 234 | const removeChip = useCallback( |
| 235 | (id: string) => { |
| 236 | const { nextFocusId } = remove(id); |
| 237 | setInlineError(null); |
| 238 | requestAnimationFrame(() => { |
| 239 | const el = nextFocusId ? chipRefs.current.get(nextFocusId) : null; |
| 240 | if (el) { |
| 241 | el.focus(); |
| 242 | } else { |
| 243 | textareaRef.current?.focus(); |
| 244 | } |
| 245 | }); |
| 246 | }, |
| 247 | [remove], |
| 248 | ); |
| 249 | |
| 250 | const onChipKey = useCallback( |
| 251 | (id: string) => (e: ReactKeyboardEvent<HTMLButtonElement>) => { |
| 252 | if ( |
| 253 | e.key === "Delete" || |
| 254 | e.key === "Backspace" || |
| 255 | e.key === "Enter" || |
| 256 | e.key === " " |
| 257 | ) { |
| 258 | e.preventDefault(); |
| 259 | removeChip(id); |
| 260 | } |
| 261 | }, |
| 262 | [removeChip], |
| 263 | ); |
| 264 | |
| 265 | const textarea = ( |
| 266 | <CountableTextarea |
| 267 | ref={textareaRef} |
| 268 | value={value} |
| 269 | onChange={setValue} |
| 270 | onKeyDown={onKeyDown} |
| 271 | onPaste={onPaste} |
| 272 | rows={1} |
| 273 | placeholder={resolvedPlaceholder} |
| 274 | disabled={disabled} |
| 275 | aria-label={t("thread.composer.inputAria")} |
| 276 | showCount={false} |
| 277 | countPosition="bottom-right" |
| 278 | formatCount={(count) => t("thread.composer.charCount", { count })} |
| 279 | className={cn( |
| 280 | "w-full resize-none bg-transparent", |
| 281 | showFirstFrame |
| 282 | ? "min-h-[72px] px-1 pb-1 pt-1 text-[15px] leading-6" |
| 283 | : isHero |
| 284 | ? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6" |
| 285 | : "min-h-[50px] px-4 pb-1.5 pt-3 text-[15px]", |
| 286 | "placeholder:text-muted-foreground", |
| 287 | "focus:outline-none focus-visible:outline-none", |
| 288 | "disabled:cursor-not-allowed", |
| 289 | )} |
| 290 | /> |
| 291 | ); |
| 292 | |
| 293 | return ( |
| 294 | <form |
| 295 | onSubmit={(e) => { |
| 296 | e.preventDefault(); |
| 297 | void submit(); |
| 298 | }} |
| 299 | onDragEnter={onDragEnter} |
| 300 | onDragOver={onDragOver} |
| 301 | onDragLeave={onDragLeave} |
| 302 | onDrop={onDrop} |
| 303 | className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} |
| 304 | > |
| 305 | <div |
| 306 | className={cn( |
| 307 | "relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200", |
| 308 | isHero |
| 309 | ? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card shadow-[0_10px_30px_rgba(0,0,0,0.10)]" |
| 310 | : "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card", |
| 311 | "focus-within:ring-1 focus-within:ring-foreground/8", |
| 312 | disabled && "opacity-60", |
| 313 | isDragging && |
| 314 | "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary", |
| 315 | )} |
| 316 | > |
| 317 | {images.length > 0 && !showFirstFrame ? ( |
| 318 | <div |
| 319 | className="flex flex-wrap gap-2 px-3 pt-3" |
| 320 | aria-label={t("thread.composer.attachImage")} |
| 321 | > |
| 322 | {images.map((img) => ( |
| 323 | <AttachmentChip |
| 324 | key={img.id} |
| 325 | image={img} |
| 326 | labelRemove={t("thread.composer.remove")} |
| 327 | labelEncoding={t("thread.composer.encoding")} |
| 328 | normalizedHint={(orig, current) => |
| 329 | t("thread.composer.normalizedSizeHint", { |
| 330 | orig: formatBytes(orig), |
| 331 | current: formatBytes(current), |
| 332 | }) |
| 333 | } |
| 334 | formatError={formatRejection} |
| 335 | onRemove={() => removeChip(img.id)} |
| 336 | onKeyDown={onChipKey(img.id)} |
| 337 | registerRef={(el) => { |
| 338 | if (el) chipRefs.current.set(img.id, el); |
| 339 | else chipRefs.current.delete(img.id); |
| 340 | }} |
| 341 | /> |
| 342 | ))} |
| 343 | </div> |
| 344 | ) : null} |
| 345 | |
| 346 | {showFirstFrame ? ( |
| 347 | <div |
| 348 | className={cn( |
| 349 | "flex items-start gap-3 px-3.5", |
| 350 | "pt-2", |
| 351 | )} |
| 352 | > |
| 353 | <FirstFrameUploader |
| 354 | value={firstFrame} |
| 355 | cropping={firstFrameCropping} |
| 356 | disabled={disabled || !FIRST_FRAME_UPLOAD_ENABLED} |
| 357 | clearable={!firstFrameLocked} |
| 358 | locked={firstFrameLocked} |
| 359 | onLockedAttempt={onFirstFrameLockedAttempt} |
| 360 | onPickFile={onFirstFramePick ?? (() => {})} |
| 361 | onClear={onFirstFrameClear ?? (() => {})} |
| 362 | /> |
| 363 | <div className="min-w-0 flex-1">{textarea}</div> |
| 364 | </div> |
| 365 | ) : ( |
| 366 | textarea |
| 367 | )} |
| 368 | {inlineError ? ( |
| 369 | <div |
| 370 | role="alert" |
| 371 | className={cn( |
| 372 | "mx-3 mb-1 rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1", |
| 373 | "text-[11.5px] font-medium text-destructive", |
| 374 | )} |
| 375 | > |
| 376 | {inlineError} |
| 377 | </div> |
| 378 | ) : null} |
| 379 | <div |
| 380 | className={cn( |
| 381 | "flex items-center justify-between gap-2", |
| 382 | isHero ? "px-3.5 pb-3.5" : "px-3 pb-2", |
| 383 | )} |
| 384 | > |
| 385 | <div className="flex min-w-0 items-center gap-2"> |
| 386 | {modelLabel ? ( |
| 387 | <span |
| 388 | title={modelLabel} |
| 389 | className={cn( |
| 390 | "inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1", |
| 391 | "border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80", |
| 392 | isHero ? "text-[11px]" : "text-[12px]", |
| 393 | )} |
| 394 | > |
| 395 | <span className="truncate">Echo Director</span> |
| 396 | </span> |
| 397 | ) : null} |
| 398 | <> |
| 399 | <AspectRatioPicker |
| 400 | value={videoSize} |
| 401 | onChange={onVideoSizeChange ?? (() => {})} |
| 402 | disabled={disabled || videoSizeLocked} |
| 403 | size={isHero ? "hero" : "thread"} |
| 404 | /> |
| 405 | {videoSizeLocked ? ( |
| 406 | <span className="text-[10px] text-muted-foreground/50"> |
| 407 | Size locked |
| 408 | </span> |
| 409 | ) : null} |
| 410 | <LanguagePicker |
| 411 | value={storyLanguage} |
| 412 | onChange={onStoryLanguageChange ?? (() => {})} |
| 413 | disabled={disabled} |
| 414 | size={isHero ? "hero" : "thread"} |
| 415 | /> |
| 416 | {autoGenerate ? ( |
| 417 | <DurationPicker |
| 418 | value={resolvedDuration} |
| 419 | onChange={handleDurationChange} |
| 420 | disabled={disabled} |
| 421 | size={isHero ? "hero" : "thread"} |
| 422 | options={durationOptions} |
| 423 | /> |
| 424 | ) : null} |
| 425 | {showAutoGenerateToggle ? ( |
| 426 | <div className="flex justify-end"> |
| 427 | <button |
| 428 | type="button" |
| 429 | role="switch" |
| 430 | aria-checked={autoGenerate} |
| 431 | disabled={disabled} |
| 432 | onClick={() => onAutoGenerateChange?.(!autoGenerate)} |
| 433 | className="inline-flex select-none items-center gap-1.5 rounded-full border border-foreground/10 bg-foreground/[0.035] px-2.5 py-1 text-[11px] text-foreground/75 disabled:cursor-not-allowed disabled:opacity-50" |
| 434 | > |
| 435 | <span |
| 436 | className={cn( |
| 437 | "relative inline-block h-3.5 w-6 shrink-0 overflow-hidden rounded-full transition-colors", |
| 438 | autoGenerate |
| 439 | ? "bg-foreground/70" |
| 440 | : "bg-foreground/15", |
| 441 | "break-keep", |
| 442 | )} |
| 443 | > |
| 444 | <span |
| 445 | className={cn( |
| 446 | "absolute left-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-background shadow-sm transition-transform", |
| 447 | autoGenerate ? "translate-x-2.5" : "translate-x-0", |
| 448 | )} |
| 449 | /> |
| 450 | </span> |
| 451 | <span className="break-keep"> |
| 452 | {t("thread.composer.autoGenerate")} |
| 453 | </span> |
| 454 | </button> |
| 455 | </div> |
| 456 | ) : null} |
| 457 | </> |
| 458 | <span className="hidden select-none text-[12px] text-muted-foreground/60 sm:inline"> |
| 459 | {t("thread.composer.sendHint")} |
| 460 | </span> |
| 461 | </div> |
| 462 | <span className="sm:hidden" aria-hidden /> |
| 463 | <Button |
| 464 | type="submit" |
| 465 | size="icon" |
| 466 | disabled={!canSend} |
| 467 | aria-label={t("thread.composer.send")} |
| 468 | className={cn( |
| 469 | "rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent", |
| 470 | isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5", |
| 471 | canSend && "hover:scale-[1.03] active:scale-95", |
| 472 | )} |
| 473 | > |
| 474 | {submitting ? ( |
| 475 | <Loader2 |
| 476 | className={cn( |
| 477 | "animate-spin motion-reduce:animate-none", |
| 478 | isHero ? "h-4.5 w-4.5" : "h-4 w-4", |
| 479 | )} |
| 480 | /> |
| 481 | ) : ( |
| 482 | <ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} /> |
| 483 | )} |
| 484 | </Button> |
| 485 | </div> |
| 486 | </div> |
| 487 | </form> |
| 488 | ); |
| 489 | } |
| 490 | |
| 491 | interface AttachmentChipProps { |
| 492 | image: AttachedImage; |
| 493 | labelRemove: string; |
| 494 | labelEncoding: string; |
| 495 | normalizedHint: (origBytes: number, currentBytes: number) => string; |
| 496 | formatError: (reason: AttachmentError) => string; |
| 497 | onRemove: () => void; |
| 498 | onKeyDown: (e: ReactKeyboardEvent<HTMLButtonElement>) => void; |
| 499 | registerRef: (el: HTMLButtonElement | null) => void; |
| 500 | } |
| 501 | |
| 502 | function AttachmentChip({ |
| 503 | image, |
| 504 | labelRemove, |
| 505 | labelEncoding, |
| 506 | normalizedHint, |
| 507 | formatError, |
| 508 | onRemove, |
| 509 | onKeyDown, |
| 510 | registerRef, |
| 511 | }: AttachmentChipProps) { |
| 512 | const sizeLabel = |
| 513 | image.status === "ready" && image.normalized && image.encodedBytes |
| 514 | ? normalizedHint(image.file.size, image.encodedBytes) |
| 515 | : formatBytes(image.file.size); |
| 516 | const tone = |
| 517 | image.status === "error" |
| 518 | ? "border-destructive/40 bg-destructive/5 text-destructive" |
| 519 | : "border-border/70 bg-muted/60"; |
| 520 | |
| 521 | return ( |
| 522 | <div |
| 523 | className={cn( |
| 524 | "group relative flex items-center gap-2 rounded-[12px] border px-2 py-1.5", |
| 525 | "transition-colors motion-reduce:transition-none", |
| 526 | tone, |
| 527 | )} |
| 528 | data-testid="composer-chip" |
| 529 | > |
| 530 | <div className="relative h-10 w-10 overflow-hidden rounded-md bg-background"> |
| 531 | {image.previewUrl ? ( |
| 532 | <img |
| 533 | src={image.previewUrl} |
| 534 | alt="" |
| 535 | aria-hidden |
| 536 | loading="eager" |
| 537 | draggable={false} |
| 538 | className="h-full w-full object-cover" |
| 539 | /> |
| 540 | ) : ( |
| 541 | <div className="flex h-full w-full items-center justify-center"> |
| 542 | <ImageIcon className="h-4 w-4 text-muted-foreground" aria-hidden /> |
| 543 | </div> |
| 544 | )} |
| 545 | {image.status === "encoding" ? ( |
| 546 | <div |
| 547 | className="absolute inset-0 flex items-center justify-center bg-background/60" |
| 548 | aria-label={labelEncoding} |
| 549 | > |
| 550 | <Loader2 |
| 551 | className="h-4 w-4 animate-spin motion-reduce:animate-none" |
| 552 | aria-hidden |
| 553 | /> |
| 554 | </div> |
| 555 | ) : null} |
| 556 | </div> |
| 557 | <div className="flex min-w-0 flex-col text-[11.5px] leading-4"> |
| 558 | <span |
| 559 | className="truncate max-w-[14rem] font-medium" |
| 560 | title={image.file.name} |
| 561 | > |
| 562 | {image.file.name} |
| 563 | </span> |
| 564 | <span className="truncate text-muted-foreground"> |
| 565 | {image.status === "error" && image.error |
| 566 | ? formatError(image.error) |
| 567 | : sizeLabel} |
| 568 | </span> |
| 569 | </div> |
| 570 | <button |
| 571 | type="button" |
| 572 | ref={registerRef} |
| 573 | onClick={onRemove} |
| 574 | onKeyDown={onKeyDown} |
| 575 | aria-label={labelRemove} |
| 576 | className={cn( |
| 577 | "ml-1 grid h-5 w-5 flex-none place-items-center rounded-full", |
| 578 | "text-muted-foreground/80 hover:bg-foreground/8 hover:text-foreground", |
| 579 | "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/30", |
| 580 | )} |
| 581 | > |
| 582 | <X className="h-3.5 w-3.5" aria-hidden /> |
| 583 | </button> |
| 584 | </div> |
| 585 | ); |
| 586 | } |
| 587 |