'use client'; import * as React from 'react'; import { UploadCloudIcon } from '@/icons'; import { cva, type VariantProps } from '@/lib/cva'; import { cn } from '@/lib/utils'; import { buttonVariants } from '@/components/button/button'; /* * CBAR's FileUpload has five `type` values, but only two of them are a distinct * surface: the dashed drop target and a plain trigger button. Its `input`, * `clearable` and `paste` types are its Input component with a file trigger * attached, which this kit composes rather than ships as variants — see §9a of * the README. */ const uploadVariants = cva( cn( 'group/upload relative cursor-pointer', 'transition-colors duration-(--ui-duration-fast) ease-(--ui-ease-standard)' ), { variants: { variant: { dropzone: cn( /* CBAR draws the dashed rule at 2px and sizes the box to ~224px tall; the padding alone would collapse it to the height of its label. */ 'flex min-h-56 flex-col items-center justify-center gap-4 rounded-sm border-2 border-dashed border-input bg-background px-3 py-2 text-center', 'hover:border-ring/60 hover:bg-accent/40', 'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50', 'data-dragging:border-ring data-dragging:bg-accent/60' ), button: cn( buttonVariants({ variant: 'outline', size: 'md' }), 'focus-within:ring-[3px] focus-within:ring-ring/50' ), }, }, defaultVariants: { variant: 'dropzone', }, } ); /** A picked file, tagged so a caller can track it across progress callbacks. */ export interface UploadFile extends File { uid: string; } export type UploadRequestMethod = 'POST' | 'PUT' | 'PATCH'; export interface UploadProgressEvent extends Partial { /** 0–100. Absent when the browser reports an indeterminate length. */ percent?: number; } export interface UploadRequestError extends Error { status?: number; method?: UploadRequestMethod; url?: string; } /** The description of one transfer, handed to `customRequest`. */ export interface UploadRequestOption { action: string; method: UploadRequestMethod; /** Form field name the file is sent under. */ filename: string; file: File | Blob; data?: Record; headers?: Record; withCredentials?: boolean; onProgress?: (event: UploadProgressEvent) => void; onSuccess?: (body: T, xhr?: XMLHttpRequest) => void; onError?: (error: UploadRequestError | ProgressEvent, body?: T) => void; } export interface UploadRequestHandle { abort: () => void; } /** * `false` cancels the upload, a `File`/`Blob` replaces what gets sent (for * client-side resizing or encryption), and anything else lets it proceed. */ export type BeforeUploadResult = File | Blob | boolean | void; export interface UploadProps extends VariantProps { /** Where to send the file. A function is resolved per file, for signed URLs. */ action?: string | ((file: UploadFile) => string | PromiseLike); method?: UploadRequestMethod; /** Form field name the file is sent under. */ name?: string; /** Extra form fields. A function is resolved per file. */ data?: | Record | ((file: UploadFile) => Record | PromiseLike>); headers?: Record; withCredentials?: boolean; accept?: string; multiple?: boolean; /** Pick a whole folder rather than individual files. */ directory?: boolean; disabled?: boolean; /** Set `false` to open the picker yourself, e.g. from a button elsewhere. */ openFileDialogOnClick?: boolean; beforeUpload?: ( file: UploadFile, fileList: UploadFile[] ) => BeforeUploadResult | Promise; /** Take over the transfer entirely — resumable uploads, SDK clients, S3. */ customRequest?: ( option: UploadRequestOption, info: { defaultRequest: (option: UploadRequestOption) => UploadRequestHandle } ) => UploadRequestHandle | void; onStart?: (file: UploadFile) => void; onProgress?: (event: UploadProgressEvent, file: UploadFile) => void; onSuccess?: (response: unknown, file: UploadFile, xhr?: XMLHttpRequest) => void; onError?: (error: UploadRequestError | ProgressEvent, body: unknown, file: UploadFile) => void; id?: string; className?: string; classNames?: { input?: string }; children?: React.ReactNode; 'aria-label'?: string; } let uidSeed = 0; /** Tags a File in place — its object identity is what callers key progress on. */ const withUid = (file: File): UploadFile => { const tagged = file as UploadFile; uidSeed += 1; tagged.uid = `upload-${uidSeed}`; return tagged; }; const parseBody = (xhr: XMLHttpRequest) => { const text = xhr.responseText || xhr.response; if (!text) return text; try { return JSON.parse(text); } catch { /* Not every endpoint answers in JSON; hand back what actually arrived. */ return text; } }; /** * The transfer performed when `customRequest` is not given. * * `XMLHttpRequest` rather than `fetch`, because upload progress events and a * cheap abort are the two things `fetch` still cannot do. */ function xhrRequest(option: UploadRequestOption): UploadRequestHandle { const xhr = new XMLHttpRequest(); if (option.onProgress && xhr.upload) { xhr.upload.onprogress = (event: ProgressEvent) => { option.onProgress?.({ ...event, percent: event.total > 0 ? (event.loaded / event.total) * 100 : undefined, }); }; } const form = new FormData(); for (const [key, value] of Object.entries(option.data ?? {})) { form.append(key, value as string); } form.append(option.filename, option.file); xhr.onerror = (event) => option.onError?.(event); xhr.onload = () => { const body = parseBody(xhr); if (xhr.status < 200 || xhr.status >= 300) { const error: UploadRequestError = Object.assign( new Error(`Upload failed with status ${xhr.status}`), { status: xhr.status, method: option.method, url: option.action } ); option.onError?.(error, body); return; } option.onSuccess?.(body, xhr); }; xhr.open(option.method, option.action, true); if (option.withCredentials) xhr.withCredentials = true; for (const [header, value] of Object.entries(option.headers ?? {})) { xhr.setRequestHeader(header, value); } xhr.send(form); return { abort: () => xhr.abort() }; } /** * File picker with drag and drop. * * The visible surface is entirely yours — pass `children` to replace the * default dropzone. What this handles is the awkward part: a hidden but still * *focusable* file input wrapped in a `