@alphinex/files
v1.0.1
Published
File manager UI, upload queue with progress, gallery lightbox and image cropper. Upload transport is injected by the app — no @alphinex/api dependency. See documentation/PRODUCT-RESEARCH-MASTER-PLAN.md §10.3 (Sprint 38).
Readme
@alphinex/files
File management UI for React: an upload queue with real progress, a file browser, a gallery lightbox, and an image cropper.
The backend is injected, never imported. You supply a FileTransport; this package never
touches @alphinex/api or any HTTP client, so it works the same against REST, a signed S3 URL, or
a fake in tests.
npm install @alphinex/filesPeer dependencies: react and react-dom 19+. Requires a ThemeProvider from
@alphinex/theme somewhere above it, like the rest of the platform.
The transport
One required method. remove and rename are optional — their affordances simply don't render
when you don't supply them.
import type { FileTransport } from "@alphinex/files";
const transport: FileTransport = {
async upload(file, { signal, onProgress }) {
const body = new FormData();
body.append("file", file);
const response = await fetch("/api/files", { method: "POST", body, signal });
if (!response.ok) throw new Error("Upload failed");
const saved = await response.json();
return { id: saved.id, name: file.name, size: file.size, url: saved.url };
},
};fetch cannot report upload progress in any shipping browser. If you want a progress bar, use
uploadFile from @alphinex/api, which is XMLHttpRequest-based for exactly this reason:
import { uploadFile } from "@alphinex/api";
const transport: FileTransport = {
upload: (file, { signal, onProgress }) =>
uploadFile({ url: "/api/files", file, signal, onProgress }),
};Uploading
useUploadQueue runs the transport with a concurrency limit, and UploadList renders it.
import { FileUpload } from "@alphinex/ui";
import { UploadList, useUploadQueue } from "@alphinex/files";
function Attachments() {
const queue = useUploadQueue({
transport,
concurrency: 3,
onComplete: (uploaded) => console.log(`${uploaded.length} files stored`),
});
return (
<>
<FileUpload
aria-label="Attachments"
multiple
accept=".pdf,.png"
maxSize={5_000_000}
showFileList={false}
onChange={queue.add}
/>
<UploadList
tasks={queue.tasks}
onCancel={queue.cancel}
onRetry={queue.retry}
onRemove={queue.remove}
/>
</>
);
}Cancelling aborts the request through an AbortSignal and frees the slot for the next queued
file. A cancelled upload cannot be resurrected by a late-arriving response, and a finished one
cannot be cancelled — the bytes are already on the server.
queue.summary gives you { total, queued, uploading, success, error, canceled, overallPercent,
isUploading } for a combined progress bar.
Browsing
import { FileManager } from "@alphinex/files";
<FileManager
files={files}
onOpen={(file) => setPreview(file)}
onDelete={(selected) => remove(selected.map((f) => f.id))}
onDownload={(file) => window.open(file.url)}
/>;Search, sorting (name, size, modified, type) and a grid/list toggle are built in. Selection is by checkbox and opening is by clicking the tile — two distinct actions, deliberately not overloaded onto one click.
Name sorting is numeric-aware, so file9 comes before file10.
Gallery
import { FileGallery } from "@alphinex/files";
<FileGallery files={files} isOpen={isOpen} onClose={close} onDownload={download} />;Arrow keys page, Home/End jump to the ends, Escape closes. Paging clamps at each end rather
than wrapping, so you always know where you are in a long set. Non-image files get a
"no preview available" placeholder rather than a broken <img>.
Cropping
import { ImageCropper } from "@alphinex/files";
<ImageCropper
src={objectUrl}
aspect={1}
onApply={(blob) => upload(new File([blob], "avatar.png"))}
/>;The crop box is fully keyboard-operable — arrows nudge, Shift+arrows resize, Alt makes either
one-pixel-precise. A crop tool that only responds to dragging is unusable without a mouse.
onApply and onCropChange report in natural image pixels, not display pixels. Handing
display coordinates to a canvas is the classic cropper bug, and toNaturalRect is the single
place the two coordinate spaces meet.
With @alphinex/forms
import { FileUploadField } from "@alphinex/forms";
<FileUploadField
name="attachments"
label="Attachments"
multiple
accept=".pdf"
maxSize={5_000_000}
/>;The stored value is always a File[], even in single-file mode, so z.array(z.instanceof(File))
validates both cases without branching.
Exported helpers
Everything below is a pure function, usable without rendering anything:
| Function | Purpose |
| --------------------------------------------------------- | --------------------------------------------------------------- |
| fileKindFrom(name, mime?) | Classifies a file as image/video/audio/pdf/archive/text/other |
| previewUrlOf(file) | Thumbnail URL, or undefined when there's nothing safe to show |
| sortFiles, filterFiles | The manager's sorting and search |
| toggleSelection, selectRange, selectAllState | Selection maths, including the tri-state header |
| uploadQueueReducer, nextQueued, summarize | The queue state machine |
| centeredRect, resizeRect, moveRect, toNaturalRect | Crop geometry |
Deliberately not included
- Folder hierarchy and breadcrumbs.
ManagedFilecarries afolderId, but navigation is the app's concern; no folder tree ships here. - Chunked or resumable uploads. A transport can implement them; the queue doesn't assume them.
- Server-side thumbnail generation. Supply
thumbnailUrland it's used.
See documentation/adr/0025-*.md for the reasoning behind the injected transport.
