@ilhooq/upload-manager
v0.2.0
Published
A lightweight, framework-agnostic file upload manager widget built with Svelte 5 and Uppy.
Readme
Upload Manager
A lightweight file-uploader widget for the web — a modern, framework-agnostic alternative to Blueimp's jQuery File Upload. Built with Svelte 5 (compiled to a custom element) on top of Uppy, and shipped as ready-to-use ES/UMD bundles.
It provides drag-and-drop uploads, a file table with previews and progress bars, reordering, per-file captions, and a list of files already stored on the server — all driven by a simple HTTP contract you implement on the backend.

Features
- Drag-and-drop or file-picker uploads (single or multiple files).
- Live progress bars and per-file status.
- Image previews (thumbnails) for local and remote files.
- Remote file listing: shows files already stored on the server alongside pending uploads.
- Reordering (move up / down) for both local and remote files, with optional server persistence.
- Editable per-file captions persisted to the server.
- File size restriction.
- Built-in i18n (English default, French included), custom locale packs, and per-key label overrides.
- Event callbacks for every lifecycle step.
- No framework required on the host page — it registers a
<upload-manager>custom element.
Installation
Install from npm:
npm install @ilhooq/upload-managerFor local development in this repository:
npm install
npm run build # produces dist/ (ES + UMD bundles + style.css)The build outputs:
| File | Description |
| --- | --- |
| dist/upload-manager.es.js | ES module bundle (import) |
| dist/upload-manager.umd.js | UMD bundle (require / <script>) |
| dist/upload-manager.css | Stylesheet (import or <link>) |
package.json exposes these through the exports map, so consumers can do:
import { UploaderWidget } from "@ilhooq/upload-manager"
import "@ilhooq/upload-manager/style.css"Quick start
<link rel="stylesheet" href="/dist/upload-manager.css">
<div id="uploader"></div>
<script type="module">
import { UploaderWidget } from "/dist/upload-manager.es.js"
const widget = new UploaderWidget("#uploader", {
endpoint: "/api/files", // POST — upload (multipart)
listEndpoint: "/api/files", // GET — list existing files
updateEndpoint: "/api/files", // PATCH — update caption
orderEndpoint: "/api/files", // POST — persist full ordering ({ order: [ids] })
deleteEndpoint: "/api/files", // DELETE?id=... — remove a file
fieldName: "file",
maxFileSize: 5 * 1024 * 1024,
locale: "en",
onUploadSuccess: ({ file, serverId }) => {
console.log("uploaded", file.name, "->", serverId)
}
})
</script>All endpoints may point to the same URL and dispatch by HTTP method (the upload and the ordering both use POST, distinguished by Content-Type: multipart for upload, application/json for ordering — see the PHP reference in example/app.php).
Running the example
The repository ships with a complete PHP reference backend.
npm run backend # PHP built-in server on localhost:8081 (serves example/app.php)
npm run dev # Vite dev server, opens example/index-dev.html (uses src/ directly)Both processes must run together: the Vite dev server proxies /example/app.php to the PHP server. index-dev.html imports the widget straight from source (no build step needed); index.html uses the built dist/ artifacts.
API
The library exposes two layers:
UploaderWidget— the public entry point. Mounts the UI into a DOM element, wires options to the core, and bridges events to callbacks. This is what most consumers use.UploaderCore— the framework-agnostic engine that owns all state and network calls. Use it directly if you want to build your own UI or integrate with another framework.
Exports
import {
UploaderWidget, // public widget class
UploaderCore, // headless engine
DEFAULT_OPTIONS, // default option values
DEFAULT_LABELS, // default locale's string set
resolveLocale, // (locale, labels) => { strings, pluralize }
formatBytes, // (bytes) => human-readable string
locales, DEFAULT_LOCALE, // locale registry + default key ("en")
fr, en // individual locale packs
} from "@ilhooq/upload-manager"UploaderWidget is also assigned to window.UploaderWidget for non-module usage.
new UploaderWidget(target, options)
Creates and mounts the widget.
target— a CSS selector string or a DOM element. Throws if the target cannot be found.options— see Options below. Callbacks may be passed either at the top level (onReady,onUploadSuccess, …) or nested underoptions.callbacks.
Construction immediately mounts the <upload-manager> element and calls core.init() (which loads remote files if showRemoteFiles is enabled).
Options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| endpoint | string | "./app.php" | URL for the POST upload request. |
| listEndpoint | string | "./app.php" | URL for the GET listing of remote files. |
| updateEndpoint | string | "./app.php" | URL for the PATCH update (caption). Falls back to orderEndpoint if not set. |
| deleteEndpoint | string | "./app.php" | URL for the DELETE request (?id=<id>). |
| orderEndpoint | string | "./app.php" | URL for the POST ordering request (full ordered list of ids). |
| fieldName | string | "file" | Multipart form field name for the uploaded file. |
| maxFileSize | number | 5242880 (5 MB) | Maximum size per file, in bytes. |
| autoProceed | boolean | false | Start uploading as soon as files are added. |
| multiple | boolean | true | Allow more than one file (false limits to 1). |
| showRemoteFiles | boolean | true | Fetch and display files already on the server. |
| persistOrder | boolean | true | Persist remote reordering to the server via POST to orderEndpoint. |
| locale | string | object | "en" | Locale key ("en", "fr"), or a custom { strings, pluralize } pack. |
| labels | object | {} | Per-key string overrides applied on top of the chosen locale. |
Methods
| Method | Description |
| --- | --- |
| getState() | Returns the current state object. |
| addFiles(files) | Adds an array of File/Blob objects to the upload queue. |
| startUpload() | Async. Uploads all pending files. |
| cancelAll() | Cancels all in-progress uploads. |
| reload() | Async. Re-fetches the remote file list. |
| removeLocalById(id) | Async. Removes a local file by its Uppy id (deletes from server too if already uploaded). |
| removeRemoteById(id) | Async. Deletes a remote file by its server id. |
| destroy() | Tears down the widget: unsubscribes events, closes Uppy, revokes preview URLs, empties the target. |
Callbacks
Each callback receives a single object argument that always contains widget (the UploaderWidget instance) and state (the current state), plus event-specific fields.
| Callback | Payload (besides widget, state) | Fired when |
| --- | --- | --- |
| onReady | — | The core has initialized (remote files loaded). |
| onChange | — | Any state change (progress, add, remove, reorder…). |
| onUploadSuccess | file, response, serverId | A file finished uploading. |
| onUploadError | file, error, response | An upload failed. |
| onFileUpdate | id, changes, file | A PATCH caption update succeeded. |
| onFileUpdateError | id, changes, error | A PATCH caption update failed. |
| onReorder | scope ("local"/"remote"), id, direction ("up"/"down"), order, persisted, error? | A file was moved. |
| onDeleteSuccess | id, scope | A file was removed. |
| onDeleteError | id, scope, error | A removal failed. |
| onErrorAddFile | file, error | Adding a file to the queue failed (e.g. rejected by Uppy — size/type restriction). If no callback is registered, the core logs the error to the console instead. |
new UploaderCore(options)
The headless engine. Accepts the same options as the widget (minus callbacks). Use it to drive a custom UI.
const core = new UploaderCore({ endpoint: "/api/files" })
const unsubscribe = core.subscribe((state) => render(state))
await core.init()State subscription vs. event bus
The core exposes two distinct notification channels:
subscribe(listener)— callslistener(state)immediately and on every state change. Returns an unsubscribe function. Use it to render the full UI.on(eventName, listener)— subscribes to a named domain event. Returns an unsubscribe function. Events:ready,change,uploadSuccess,uploadError,fileUpdate,fileUpdateError,reorder,deleteSuccess,deleteError,errorAddFile. (UploaderWidgetbridges these to theon*callbacks above.)on(event, listener)andemit(event, payload)return early when there is no subscriber;emitreturnstruewhen at least one listener ran,falseotherwise — the core uses this to fall back toconsole.errorforerrorAddFilewhen the host registered no handler.
Methods
| Method | Description |
| --- | --- |
| init() | Async. Loads remote files (if enabled), emits ready, notifies subscribers. |
| getState() | Returns the current state object. |
| subscribe(listener) | Subscribe to full-state changes. Returns unsubscribe fn. |
| on(event, listener) | Subscribe to a named event. Returns unsubscribe fn. |
| addFiles(files) | Add files to the Uppy queue. |
| startUpload() | Async. Upload pending files. |
| cancelAll() | Cancel all uploads. |
| moveLocal(id, direction) | Reorder a local file ("up" / "down"). |
| moveRemote(id, direction) | Async. Reorder a remote file; persists the full ordering via POST to orderEndpoint when persistOrder is on. Calls are serialized; on failure the list is resynced from the server. |
| removeLocalById(id) | Async. Remove a local file (and its server copy if uploaded). |
| removeRemoteById(id) | Async. Delete a remote file. |
| saveRemoteCaption(id, caption) | Async. Persist a caption via PATCH (no-op if unchanged). |
| reload() | Async. Re-fetch the remote file list. |
| t(key, vars) | Translate a locale key with %{var} interpolation and pluralization. |
| destroy() | Tear down: close Uppy, revoke previews, clear listeners. |
State object
getState() and every subscriber/callback receive:
{
localFiles: [ /* ordered Uppy file objects (pending or uploading) */ ],
remoteFiles: [ /* ordered remote file descriptors from the server */ ],
counts: { local: Number, remote: Number },
progress: Number // 0–100, overall upload progress of local files
}Local vs. remote files are two independent ordered lists.
localFilesare files in Uppy;remoteFilesare fetched fromlistEndpoint. On upload success, a file is promoted into the remote list (whenshowRemoteFilesis on) so it renders identically to files already on the server.
Helpers
resolveLocale(locale, labels)→{ strings, pluralize }. Mergesdefault pack → chosen pack → labels. Accepts a known key, a full/partial pack object.formatBytes(bytes)→ human-readable size string (e.g."1.5 Mo"). Returns"-"for non-positive/invalid input.
Internationalization (i18n)
UI strings live in locale packs under src/locales/ (en.js, fr.js). Each pack is:
export default {
pluralize: (n) => (n === 1 ? 0 : 1),
strings: { addFiles: "Add files", /* … */ }
}The locale option accepts:
- a known key —
locale: "fr" - a custom pack —
locale: { strings: {...}, pluralize: (n) => ... } - a partial pack (missing keys fall back to the default locale)
The legacy labels option overrides individual keys on top of the resolved locale:
new UploaderWidget("#uploader", {
locale: "fr",
labels: { addFiles: "Choisir des fichiers" }
})Plural strings are objects keyed by pluralize(count); pass count in vars when translating. Strings support %{var} interpolation (e.g. previewAlt: "Preview of %{name}").
Server contract
You implement five endpoints (which may share one URL, dispatching by HTTP method — and, for the two POST routes, by Content-Type). example/app.php is a complete reference implementation that persists ordering and captions in example/uploads/.meta.json and sanitizes/de-duplicates uploaded file names.
GET listEndpoint — list remote files
{
"files": [
{
"id": "photo.jpg",
"name": "photo.jpg",
"size": 12345,
"type": "image/jpeg",
"url": "uploads/photo.jpg",
"caption": "optional",
"order": 1
}
]
}Recognized fields: id, name, size, type, url, caption?, order? (1-based), plus any extra scalar fields (e.g. thumbUrl, updatedAt).
POST endpoint — upload (multipart)
Field name from the fieldName option. Response:
{
"id": "photo.jpg",
"file": { "id": "photo.jpg", "name": "photo.jpg", "url": "uploads/photo.jpg", "...": "..." }
}The server id extracted by the client is response.body.id or response.body.file.id.
PATCH updateEndpoint — update caption
Request body:
{ "id": "photo.jpg", "changes": { "caption": "New caption" } }Response:
{ "id": "photo.jpg", "file": { "...": "..." } }POST orderEndpoint — persist ordering (application/json)
Sent when persistOrder is on. The body carries the complete ordered list of ids (display order); the server rewrites each file's position accordingly. Ids missing from the list keep their relative order after the listed ones.
{ "order": ["photo.jpg", "scan.png", "report.pdf"] }Any 2xx response is accepted; returning the refreshed list ({ "files": [...] }) is optional, as the client already applied the move optimistically.
DELETE deleteEndpoint?id=<id> — remove a file
{ "deleted": "photo.jpg" }Non-2xx responses should return { "error": "message" }, which the client surfaces in the thrown error.
Architecture
Three layers, with strict boundaries:
src/core/uploader-core.js(UploaderCore) — owns all state and side effects (Uppy instance, remote list, ordering, preview URLs, network requests). Framework-agnostic, no DOM.src/ui/UploaderElement.svelte— Svelte 5 component compiled to the<upload-manager>custom element (shadow: "none"). Receives the core via acoreprop and re-renders oncore.subscribe(...).src/adapters/widget.js(UploaderWidget) — public entry point. Wires options to the core, mounts the element, bridges core events to user callbacks, and registers the custom element tag once.
When changing behavior: state/network → core; rendering → Svelte component; option/callback shape → adapter.
Scripts
| Command | Description |
| --- | --- |
| npm run dev | Vite dev server (opens example/index-dev.html, uses src/). |
| npm run backend | PHP built-in server on localhost:8081 serving example/app.php. |
| npm run build | Library build into dist/ (ES + UMD + single CSS). |
| npm run preview | Serve the built dist/ output. |
No test suite, linter, or typecheck is configured.
