@mdimranh/filemanager
v0.1.0
Published
A React file manager — folders, uploads with progress, thumbnails, a picker — on top of the onestorage object store.
Maintainers
Readme
@mdimranh/filemanager
A file browser for React, on top of onestorage.
Folders, uploads with real progress, thumbnails, search, sorting, a picker modal — and a server that stores the files in whichever object store you already have. Local disk, Amazon S3, Cloudflare R2, Backblaze B2, MinIO, Azure Blob, Google Cloud Storage, Wasabi: one configuration object, no call site changes.
- No database required. Folder names, who uploaded what, what a file is called and how often
it has been opened are kept as one JSON document beside the objects — in the same bucket — which
is fine for one server. A deployment with several servers or a serverless platform with more
than one instance wires the same eight-method
LibraryRepositoryinterface against its own database (Postgres, SQLite, MongoDB, anything) and the rest of the library does not change. - Bytes never pass through your server when the backend can avoid it. An upload is authorised on
the server, the browser is handed a presigned
PUT, and the row only becomes real once the store confirms an object of the expected size arrived. A 400 MB file does not appear in a request log. - Tailwind in, Tailwind out. The components are Tailwind utilities over a small
fl-*token vocabulary, so they inherit the palette, radii and dark mode of the project they are dropped into — not a theme of their own. No runtime dependency: the only build-time requirement is the Tailwind you already have. Icons are drawn, dates come fromtoLocaleString. - The rules are the server's.
canin every listing is computed by running the same rule the write path enforces, so a button is never the only thing standing between a person and an act.
npm install @mdimranh/filemanagerimport { FileLibrary, FileLibraryProvider } from '@mdimranh/filemanager/react';
<FileLibraryProvider baseUrl="/api/files">
<FileLibrary />
</FileLibraryProvider>/* your Tailwind entry point — see “Styling” below */
@import "tailwindcss";
@import "@mdimranh/filemanager/styles.css";
@source "../node_modules/@mdimranh/filemanager/dist/react";There is a running demo in this repository — real objects on disk, the real handler, React bundled by Bun:
bun examples/demo.ts # → http://localhost:4173The three entry points
| Import | What it is | Runs where |
| --- | --- | --- |
| @mdimranh/filemanager | the shared vocabulary: types, file rules, size and date labels | anywhere |
| @mdimranh/filemanager/server | the service and its HTTP handler, on onestorage | Node, Bun, Workers |
| @mdimranh/filemanager/react | the browser, the picker and the upload dock | the browser |
An application imports two of them and its own framework glue in between:
browser ── FileLibraryProvider ──▶ /api/files ──▶ createFileLibraryHandler ──▶ onestorage ──▶ the store
(queue, progress) (HTTP) (rules, folders, index)Server
1. Mount the handler
It is one Request → Response function. Next.js, Bun, Deno, Cloudflare Workers and Hono take it
directly; Express and Fastify are a three-line adapter away.
// app/api/files/[[...route]]/route.ts
import { createStorage } from 'onestorage';
import { createFileLibraryHandler } from '@mdimranh/filemanager/server';
const storage = createStorage({ driver: 'fs', root: './storage' });
const handler = createFileLibraryHandler({
storage,
basePath: '/api/files',
resolveActor: async (request) => {
const user = await readSession(request);
if (!user) return null;
return {
id: user.id,
name: user.name,
ownerKey: (scope) => (scope === 'mine' ? user.id : user.organizationId),
ownerLabel: user.organizationName,
scopes: { shared: true, mine: { read: true, upload: true, update: true, delete: true, share: true } },
};
},
});
export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE };basePath must match where the file is mounted: every URL the library mints — a thumbnail, a
presigned upload, a download — is built from it, and a wrong one is a broken image on every row.
2. Say who is asking
resolveActor is the whole integration. It returns:
scopes— which trees this caller can reach, and what they may do in each.trueis shorthand for every capability.ownerKey— which tree inside a scope. A string, or a function of the scope name. This is where an organisation id and a person's own id are told apart. It defaults to the caller's id, because a default that leaks across tenants is the one mistake this package must not be able to make: a shared tree is something you opt into by naming one key for everybody.ownerLabel— what the toolbar says, so nobody has to guess whose files these are.
Returning null answers every route with 401.
3. Choose the backend
// Local disk. Atomic writes, and nothing ever leaves the machine.
createStorage({ driver: 'fs', root: '/var/lib/files', metadataSidecar: true });
// Cloudflare R2. Uploads go from the browser straight here; the library only signs them.
createStorage({
driver: 's3',
provider: 'r2',
accountId: process.env.R2_ACCOUNT_ID!,
bucket: 'documents',
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});Everything in the onestorage documentation
applies — including one client with several backends, so uploads/ can be hot on R2 while
archive/ is cold on disk:
const storage = createStorage({
default: 'disk',
drivers: {
disk: { driver: 'fs', root: '/var/lib/files' },
hot: { driver: 's3', provider: 'r2', accountId: '…', bucket: 'hot', credentials: r2Credentials },
},
mounts: { 'files/hot/': 'hot' },
});The library decides per key whether a URL can be presigned, so a mounted layout gets the right answer for each object rather than one answer for the process.
React
The browser
import { FileLibrary, FileLibraryProvider } from '@mdimranh/filemanager/react';
export function FilesPage() {
return (
<FileLibraryProvider baseUrl="/api/files">
<FileLibrary
title="Files"
ownerNote="Devspire buying house"
// Mirror every move into the URL, if you want a folder to be a link somebody can send:
onNavigate={(state) => history.replaceState(null, '', toQuery(state))}
/>
</FileLibraryProvider>
);
}What the screen does: a toolbar (search, kind filter, sort, list/grid, new folder, upload), a breadcrumb trail, folder cards with rolled-up totals, file rows or thumbnail tiles, a pager, a per-file menu (download, preview, copy a direct link, rename, move, description and tags, who can see it, remove) and drag-and-drop upload anywhere on the surface.
Every control is rendered from what the server reported it may do, so a read-only caller sees no upload button and a missing button is never the only thing preventing an act.
The picker
For the places that need a file rather than a list of them — a profile picture, a purchase order attachment:
<FilePicker
open={open}
onOpenChange={setOpen}
title="Choose a profile picture"
kind="IMAGE" // a picture picker should not offer a spreadsheet
onPick={(entry) => { save(entry.id); setOpen(false); }}
/>It is the same listing, the same thumbnails, the same rights, and the same upload queue — so it cannot offer a file the full screen would refuse to open. A file uploaded from inside the picker is a document in the library, not a hidden second copy.
For places that need a set of files — a shipment with several invoices, a profile with a few
pictures — pass multiple: true and use onPickMultiple instead of onPick:
<FilePicker
open={open}
onOpenChange={setOpen}
title="Choose the invoices for this shipment"
multiple
onPickMultiple={(entries) => { save(entries.map((e) => e.id)); setOpen(false); }}
/>In multi-select mode each tile toggles in pick order, and a freshly uploaded file joins the selection automatically. The two callbacks are mutually exclusive at the type level — TypeScript catches the mismatch at the call site, so a caller cannot wire one up and accidentally call the other.
Loading states
A spinner tells the user the screen is busy. A skeleton tells them what is coming. The library ships two skeletons that mirror the real layout's grid:
import { LibrarySkeleton, PickerSkeleton } from '@mdimranh/filemanager/react';
// While the first view is in flight, drop in the skeleton the same way the component itself does.
{!view && viewer.loading ? <LibrarySkeleton mode={mode} /> : null}
// The picker has its own — a chooser tile grid instead of the browser's folder-and-row layout.
{!view && viewer.loading ? <PickerSkeleton /> : null}In a Next.js App Router page the route-level loading.tsx is the place for a server-rendered
skeleton so the page that arrives looks like the page that was promised — no client JS, and no
flash of an empty structure while the segment prepares:
// app/files/loading.tsx — a server component
export default function FilesLoading() {
return (
<main className="mx-auto max-w-6xl p-6">
{/* plain Tailwind, no client JS; matches the in-component LibrarySkeleton */}
</main>
);
}Uploads outlive the screen
The queue lives in the provider, so starting a twelve-file upload and then opening the record those files belong to does not orphan it. The dock sits bottom-right, above dialogs (the picker uploads too), and a reload while anything is outstanding is warned about rather than silently lost.
const uploads = useUploads();
uploads.add(files, { scope: 'shared', folderId, onUploaded: (entries) => console.log(entries) });Anything the library refuses — an .exe, an empty file, an oversized one — is refused before a
byte is sent, and appears in the dock with the reason.
Acting on many files at once
A Select toggle in the toolbar enters select-mode: every row and tile grows a checkbox, clicking
the body toggles selection, and a sticky bar above the listing counts the selection and offers the
four bulk operations — delete, move, change who can see it, and edit description & tags. The
operations the actor is not allowed to perform (move without can.move, share without can.share)
are hidden from the menu, not just disabled.
Each bulk action goes through the same per-entry server methods as the single-file actions, so the
rules — capability, visibility, duplicate-name in the destination folder — apply per row. A bulk
update that targets two files in the same destination folder with a colliding name gets one
ok: false row and one ok: true row, with a notice that says which one failed and why.
For custom UIs, the same four operations are available as client methods:
await library.bulkRemoveEntries({ entryIds: ['a', 'b', 'c'] });
await library.bulkMoveEntries({ entryIds: ['a', 'b'], folderId: 'archive' });
await library.bulkSetEntriesVisibility({ entryIds: ['a'], visibility: 'PRIVATE' });
await library.bulkUpdateEntries({ entryIds: ['a'], patch: { tags: ['reviewed', 'q3'] } });Each returns { results: BulkEntryResultRow[] }. A selection does not survive a folder hop — the
toolbar toggle and the selection clear when the user navigates into another folder.
Styling
The components are Tailwind utilities, so there is no stylesheet of theirs to fight. Tailwind needs two things from you, and both are one line.
@import "tailwindcss";
/* the package's tokens and the `.fl` scope. Import it *after* tailwind. */
@import "@mdimranh/filemanager/styles.css";
/* where the package's class names live. `node_modules` is skipped by default, so without
this every utility compiles to nothing and the browser renders as unstyled HTML. */
@source "../node_modules/@mdimranh/filemanager/dist/react";The stylesheet above is not components — it is a vocabulary. It declares --color-fl-bg,
--color-fl-surface, --color-fl-border, --color-fl-fg, --color-fl-accent and their
intention-named siblings, plus --radius-fl-sm|fl|fl-lg and --shadow-fl, and the handful of base
rules a component cannot inherit safely.
So to theme it, restate those tokens in your own @theme — nothing is overridden and nothing
is wrapped:
@theme {
--color-fl-accent: light-dark(var(--color-brand-600), var(--color-brand-400));
--color-fl-border: light-dark(var(--color-ink-200), var(--color-ink-700));
--radius-fl: 10px;
}Order matters in one direction only: Tailwind emits @theme in source order, so this block belongs
below the package's import. A host that says nothing at all gets the package's neutral palette,
which is deliberately one a file browser can be dropped into.
Dark mode is color-scheme, and nothing else. Every colour token — the package's, and the ones
you restate — is a light-dark() pair, and a pair inside a custom property resolves against the
color-scheme of the element using it, not the one it was declared on. color-scheme is inherited,
so the switch is one line, in the place you already do it:
:root { color-scheme: light dark; } /* follow the system's preference */
.dark { color-scheme: dark; } /* or a class you already toggle */Inheritance is the reason it is not an attribute or a class of the package's own: the dialog and the
upload dock are portalled to <body>, where a selector scoped to the element that wraps the browser
cannot reach them and an inherited property can. Nothing to pass, nothing to render, nothing that
can drift out of sync with the rest of your app.
The consequence worth knowing: restating a colour replaces the package's pair, so a restated token needs both halves. Forget the dark one and dark mode renders a white browser on a dark page — a failure the pair shape makes visible in the diff. Radius needs no pair; a shape does not change when the lights go out.
Your own components. Nothing in the package is a class you have to know: a button next to it is
your button, styled with your utilities, because the accent it must match is a token and not a
selector. The exported renderers — FileLibrary, FilePicker, UploadDock and the dialogs — take
className, and the small ones (FileThumb, KindBadge, Badge, FolderGlyph) are there if you
would rather build a row of your own.
The model
Folders are metadata, not paths
A folder is a row with a name and a parent. A file's folder is one column on the file, so moving a file between folders rewrites nothing in the store — the object keeps its key, its URL keeps working, and the download count, history and links survive. Renaming changes a label, not an address.
An object's key is <keyPrefix>/<scope>/<ownerKey>/<id>.<ext>: the id carries the identity and the
extension carries the content type, and the name is not in the key at all, which is what makes a
rename free.
Private means private
A file is either the library's or its uploader's. A private file is readable by the person who uploaded it and by nobody else — not by an administrator of that library, and not by a cross-tenant probe, which gets a 404 rather than a 403 so it cannot tell a hidden file from a missing one. There is no per-person sharing list: roles and trees answer that question, and a share list is where a file library turns into a permissions system nobody can read.
Unfinished uploads are visible
An upload is authorised with the row in PENDING, and the row only becomes ACTIVE once the store
confirms an object of the expected size is there. So PENDING is a state a person can see — an
upload that was abandoned — rather than a claim about a zero-byte file that never existed. It is
removable from the same menu as anything else.
Refusals happen before a byte moves
Extensions that can run code (.exe, .js, .html, .svg, .py, …) are refused at the point of
authorisation, not scanned for afterwards. Empty files are refused. Oversized ones are refused
against maxFileBytes (250 MB by default). Nothing is ever cleaned up because nothing was written.
What is deliberately not here
No databases in the package itself (you bring your own — see Using a database
below), no virus scanning, no image resizing (a thumbnail is the original served with render=1,
which the browser scales), no version history, no per-person sharing, no permissions model. Those
are either somebody else's product or a decision your application should be making.
Configuration
createFileLibrary({
storage, // required: an onestorage client
resolveActor, // required: who is asking
scopes: [{ name: 'shared', label: 'Team files' }],
keyPrefix: 'files', // where objects are written
repository, // bring your own (see below); defaults to a JSON document
maxFileBytes: 250 * 1024 * 1024,
maxFolderDepth: 6,
pageSize: 50,
upload: 'auto', // 'auto' | 'signed' | 'proxy'
signedUploadExpiresIn: 900,
signedUrlExpiresIn: 3600,
validateUpload: ({ name, sizeBytes, mimeType }) => (mimeType === 'image/heic' ? 'Convert that first.' : null),
onOrphanedObject: ({ entry, error }) => queueForCleanup(entry),
});upload
auto(default) — presign when the backend can be reached by a browser, and stream through the handler when it cannot. That includes the local-disk and in-memory drivers, whose "signed" URLs are for your server to verify rather than for a browser to send.signed— always presign. Use it when the bucket is reachable but the handler is not, and the bytes must not go through your process.proxy— always stream through the handler. Use it behind a private network, or when you want every byte to pass through your own logging.
The client does not care which it got.
Where the metadata lives
By default the metadata is one JSON document in the same store, written through a queue so a single
process is always consistent. That is deliberately the only thing that is not multi-writer: a
deployment with several servers, or a serverless platform with more than one instance, supplies its
own LibraryRepository. See Using a database below.
Using a database
The library never imports an ORM, a driver, or a query builder. Folder and entry metadata lives
behind a LibraryRepository — eight typed methods, two tables, fixed input and output shapes.
Implement it against your database and pass it through repository:
import {
createFileLibraryHandler,
type LibraryRepository,
type ListTreeInput,
type IdInput,
type EntryRecord,
type FolderRecord,
} from '@mdimranh/filemanager/server';
const repository: LibraryRepository = {
async listFolders({ scope, ownerKey }: ListTreeInput): Promise<FolderRecord[]> {
return db
.select()
.from(folders)
.where(and(eq(folders.scope, scope), eq(folders.ownerKey, ownerKey)));
},
async getFolder({ id }: IdInput): Promise<FolderRecord | null> {
const row = await db.select().from(folders).where(eq(folders.id, id)).limit(1);
return row[0] ?? null;
},
async saveFolder(folder: FolderRecord): Promise<void> {
await db.insert(folders).values(folder).onConflictDoUpdate({ target: folders.id, set: folder });
},
async removeFolder({ id }: IdInput): Promise<void> {
await db.delete(folders).where(eq(folders.id, id));
},
async listEntries({ scope, ownerKey }: ListTreeInput): Promise<EntryRecord[]> {
return db
.select()
.from(entries)
.where(and(eq(entries.scope, scope), eq(entries.ownerKey, ownerKey)));
},
async getEntry({ id }: IdInput): Promise<EntryRecord | null> {
const row = await db.select().from(entries).where(eq(entries.id, id)).limit(1);
return row[0] ?? null;
},
async saveEntry(entry: EntryRecord): Promise<void> {
await db.insert(entries).values(entry).onConflictDoUpdate({ target: entries.id, set: entry });
},
async removeEntry({ id }: IdInput): Promise<void> {
await db.delete(entries).where(eq(entries.id, id));
},
};
const handler = createFileLibraryHandler({ storage, resolveActor, repository });Contract
listFolders/listEntriesreturn every folder / entry in one tree (scope+ownerKey). The library does search, sort, visibility filter, and folder totals in memory over what you return — so returning rows from aSELECT *works the same as a denormalised view.getFolder/getEntryreturnnullfor an absent row, never throw.saveFolder/saveEntryare upserts — the library always supplies anid, so the same call handles create and update.removeFolder/removeEntryare idempotent — removing an absent row is not an error.- A row's
scopeandownerKeyare the tenant boundary. Rows for one tree never appear in another tree's listing.
Reference implementations shipped with the package
createJsonObjectRepository(storage, { key, cacheTtlMs })— the default. A single JSON document in the same object store as the files, with a short read cache so a screen reads it once rather than three or four times. Suitable for one-server deployments.createInMemoryRepository()— aMap-backed reference. No persistence. Used by the test suite and as a worked example of the contract.
A worked SQLite implementation is in examples/db-sqlite/ — bun add
better-sqlite3 && bun examples/db-sqlite runs it end to end against the handler. The same shape
translates to Postgres, MySQL, MongoDB, or any KV store.
Notes for real databases
- The library serialises its own writes per process (
createWriteQueue), so a consumer database does not need a per-process lock. Cross-process concurrency is the database's problem — transactions, optimistic locks, unique constraints. - A
UNIQUE(scope, owner_key, folder_id, name)constraint onfoldersand a matching one onentries(when the library grows to enforce them) are the right place for the duplicate-name check the library already does in memory. - An index on
(scope, owner_key)is the only one the bridge'slist*calls need; everything else is up to you.
The HTTP API
| Method | Path | What it does |
| --- | --- | --- |
| GET | /entries | one screen of one folder (scope, folderId, q, kind, sort, dir, page, pageSize) |
| POST | /folders | create a folder |
| PATCH | /folders/:id | rename a folder |
| DELETE | /folders/:id | delete an empty folder |
| POST | /uploads | authorise an upload and say where the bytes go |
| PUT | /uploads/:id/content | the bytes, when the store cannot be presigned |
| POST | /uploads/:id/complete | confirm the object arrived |
| GET | /entries/:id | one entry |
| PATCH | /entries/:id | rename, move, describe, tag, or change who can see it |
| DELETE | /entries/:id | remove an entry and its object |
| GET | /entries/:id/content | the bytes — inline=1 to draw, render=1 to draw without counting a download |
| GET | /entries/:id/url | a time-limited URL that points straight at the store |
| POST | /entries/bulk-remove | delete many entries; body { entryIds: string[] } |
| POST | /entries/bulk-move | move many entries; body { entryIds: string[], folderId: string \| null } |
| POST | /entries/bulk-visibility | share/private many entries; body { entryIds: string[], visibility: "SCOPE" \| "PRIVATE" } |
| POST | /entries/bulk-update | describe/tag many entries; body { entryIds: string[], patch: { description?: string \| null, tags?: string[] } } |
The four bulk endpoints return { results: BulkEntryResultRow[] } — one row per id, each
either { id, ok: true } or { id, ok: false, code, message }. The server runs each row
through the existing per-entry method, so every check that applies to a single operation
(capability, visibility, duplicate-name, folder-exists) applies to each row of the batch.
A row that fails leaves the others untouched.
Errors are { error: { code, message } } with a real status: 400 for a refusal, 403 for a
missing capability, 404 for something that is not there or not yours, 409 for a folder that is
not empty, 413 for a size. createLibraryClient turns all of that into a thrown
LibraryRequestError with a code.
Testing
bun test # 73 tests, no mocks: real objects, real handler, real clientThe suite runs the library against onestorage's filesystem driver over HTTP, the client against the
handler in-process, and the components through renderToStaticMarkup — so a URL the client builds
that no route answers, or a class the stylesheet does not define, fails the build rather than a
user's page.
Development
bun link onestorage # first: `onestorage` is a peer, and bun resolves peers on install
bun install # tailwindcss is a dev dependency — the demo compiles examples/demo.css
bun test
bun run typecheck
bun run build # dist/ plus styles.css
bun examples/demo.ts # the running demo, styled by examples/demo.cssThe demo is also where the styling contract is exercised. examples/demo.css is written the way a
host writes one, and it is compiled at startup by the same Tailwind plugin a host would use — so a
utility the components use and the stylesheet does not generate shows up on reload as an unstyled
screen, rather than as a bug report from somebody whose @source line is missing.
Licence
MIT
