@blitheforge/media-library
v1.1.1
Published
Self-contained React media library with pre-built CSS. Works with or without Tailwind in your app.
Maintainers
Readme
@blitheforge/media-library
Production-ready React media library with pre-built, scoped CSS — no Tailwind required in your app. Works in Next.js, Vite, plain React, or vanilla JavaScript (headless API).
Features
- Self-contained CSS — import one file; styles are scoped to
.bfml-rootand won't break your app layout - Nested folder management — browse, create, and delete folders
- File upload — click to upload or drag-and-drop; files upload one-by-one with live preview cards in the grid
- 5 MB client-side limit — oversized files show a warning toast and are skipped (configurable via
MAX_MEDIA_UPLOAD_BYTES) - Search — filter files in the current folder
- RBAC / capabilities — upload, create-folder, and delete UI is driven by your API
capabilitiesresponse - Delete confirmation — custom confirm dialog (not native
confirm()) - Toast notifications — success, error, and warning toasts (top-right inside the panel/modal)
- Responsive — mobile folder drawer, full-screen modal on small screens
- Theme sync — inherits host light/dark mode via CSS variables (
theme="sync") - Three display modes — form pickers (
MediaPicker), modal (MediaLibraryModal), embedded widget (MediaLibraryWidget) - Type-safe headless client —
createMediaLibraryClientfor custom integrations
Install
npm install @blitheforge/media-libraryPeer dependencies:
npm install react react-domSetup
1. Import the bundled CSS once
The package ships a complete CSS file built from Tailwind at publish time. Your app does not need Tailwind.
// React — app/layout.tsx, main.tsx, etc.
import "@blitheforge/media-library/styles.css";<!-- Vanilla HTML / any framework -->
<link rel="stylesheet" href="/node_modules/@blitheforge/media-library/dist/style.css" />Styles are scoped under .bfml-root, so they will not override your app's hidden, flex, lg:block, etc.
If your app also uses Tailwind, import the library CSS before or after your globals — both work with scoped styles. You do not need @source for this package.
2. React (Next.js App Router)
Add the package to transpilePackages in next.config.ts:
const nextConfig = {
transpilePackages: ["@blitheforge/media-library"]
};
export default nextConfig;3. Vanilla JavaScript (no React)
Use the headless client for upload/list/delete from any JS project:
<link rel="stylesheet" href="https://unpkg.com/@blitheforge/media-library/dist/style.css" />
<script type="module">
import { createMediaLibraryClient } from "https://unpkg.com/@blitheforge/media-library/dist/client.js";
const client = createMediaLibraryClient({
listUrl: "/api/media",
uploadUrl: "/api/media/upload",
createFolderUrl: "/api/media/folders",
deleteUrl: "/api/media"
});
const listing = await client.list("");
console.log(listing.files);
// Upload from a file input
document.querySelector("#files").addEventListener("change", async (event) => {
const files = [...event.target.files];
const uploaded = await client.upload("", files);
console.log(uploaded);
});
</script>Node / bundler:
import { createMediaLibraryClient } from "@blitheforge/media-library/client";React UI components (MediaPicker, MediaLibraryModal, etc.) still require React. The /client export has no React dependency.
4. Monorepo / workspace
Link the local package via pnpm workspace:
# pnpm-workspace.yaml
packages:
- "package"
- "."// package.json
{
"dependencies": {
"@blitheforge/media-library": "workspace:*"
}
}Rebuild after package changes:
cd package && pnpm buildPublishing
Pushes to main run .github/workflows/publish.yml. The workflow
typechecks and builds the package, then publishes it only when the version in
package.json is not already present on npm.
Before the first automated publish:
- Create an npm granular access token with Bypass 2FA enabled and
Read and write package permission for the
@blitheforgescope. - In the GitHub repository, open Settings > Secrets and variables > Actions.
- Add the token as a repository secret named
NPM_TOKEN.
For each release, bump the package version and push to main:
npm version patch
git push --follow-tagsTheming
Default is theme="sync" — the library reads your app's CSS variables:
| Host variable | Used for |
|---------------|----------|
| --background | Page background |
| --foreground | Text |
| --surface | Panel/card background |
| --border | Borders |
| --primary | Buttons, active states |
| --destructive | Delete actions |
| --accent | Hover/secondary surfaces |
| --warning | Warning toasts |
When your site toggles dark mode (e.g. [data-theme="dark"] on <html>), the media library follows automatically.
Standalone (no shared design tokens):
<MediaPicker name="image" theme="light" />
<MediaPicker name="image" theme="dark" />Or via config:
<MediaPicker name="image" config={{ theme: "sync" }} />Theme modes: "sync" | "light" | "dark"
Components
| Export | Use case |
|--------|----------|
| MediaPicker | Single file field for forms — opens modal, shows live preview |
| MediaPickerMulti | Multiple attachments with preview cards |
| MediaLibraryModal | Full-screen/modal picker with Done button |
| MediaLibraryWidget | Embedded admin panel — fixed width/height, no overlay |
| MediaLibraryPanel | Low-level panel (variant="modal" or "embedded") |
| MediaPreview | Standalone image/PDF preview block |
| createMediaLibraryClient | Headless API client |
MediaPicker — single file (forms)
Best for donation proofs, expense vouchers, profile photos, etc.
import { MediaPicker } from "@blitheforge/media-library";
export function DonationForm() {
return (
<form>
<MediaPicker
name="proofUrl"
label="Donation proof"
accept={["image", "pdf"]}
onChange={(path) => console.log(path)}
/>
</form>
);
}Props: name, label, value, defaultValue, onChange, accept, config, theme, title, description, className
MediaPickerMulti — multiple attachments
import { MediaPickerMulti } from "@blitheforge/media-library";
<MediaPickerMulti
name="attachments"
label="Attachments"
max={5}
accept={["image", "pdf"]}
onChange={(paths) => console.log(paths)}
/>MediaLibraryModal — picker modal
Use when you need a custom trigger but still want the picker flow (select file → Done).
import { useState } from "react";
import { MediaLibraryModal } from "@blitheforge/media-library";
function CustomTrigger() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>Browse media</button>
<MediaLibraryModal
open={open}
onClose={() => setOpen(false)}
onSelect={(file) => {
console.log(file.url);
setOpen(false);
}}
accept={["image", "pdf"]}
/>
</>
);
}Modal behavior:
- Portal overlay with backdrop blur
- Escape key closes (unless delete confirm is open)
- Mobile: full viewport height; desktop: centered dialog
- Footer with Selected label and Done button
MediaLibraryWidget — embedded admin panel
Use for a dedicated Media Library admin page — no modal, no overlay. Folders and files are always visible.
import { MediaLibraryWidget } from "@blitheforge/media-library";
export function MediaAdminPage() {
return (
<MediaLibraryWidget
width="100%"
height="calc(100vh - 200px)"
title="Media Library"
description="Create folders, upload files, and manage media."
accept={["image", "pdf"]}
/>
);
}Width & height props
| Prop | Type | Default | Examples |
|------|------|---------|----------|
| width | string \| number | "100%" | 800, "70vw", "100%" |
| height | string \| number | 640 | 720, "70vh", "calc(100vh - 200px)" |
- Numbers → pixels (
720→720px) - Strings → passed as CSS values (
"100%","70vh") calc()— use proper CSS syntax with spaces:"calc(100vh - 200px)"- Shorthand —
"100vh-200px"is auto-converted tocalc(100vh - 200px)
Widget vs modal
| | MediaLibraryWidget | MediaLibraryModal |
|--|----------------------|---------------------|
| Display | Inline on page | Overlay / portal |
| Close button | No | Yes |
| Done footer | Hidden by default | Always shown |
| Loads | On mount | When open={true} |
| Best for | Admin manage page | Form file picking |
Selectable widget (picker in embedded mode)
<MediaLibraryWidget
width={900}
height={600}
selectable
onSelect={(file) => console.log(file.path)}
/>MediaLibraryPanel — low-level
Build custom layouts by composing the panel directly.
import { MediaLibraryPanel } from "@blitheforge/media-library";
<MediaLibraryPanel
active
variant="embedded"
selectable={false}
title="Files"
accept={["image"]}
className="h-full"
/>Props: active, variant ("modal" | "embedded"), selectable, onClose, onSelect, config, theme, title, description, accept, className
MediaPreview
Standalone preview for a stored path/URL:
import { MediaPreview } from "@blitheforge/media-library";
<MediaPreview path="/uploads/media/photo.png" alt="Donation proof" />Configure API URLs
All components accept an optional config prop:
<MediaPicker
name="imageUrl"
config={{
listUrl: "/api/media",
uploadUrl: "/api/media/upload",
createFolderUrl: "/api/media/folders",
updateUrl: "/api/media",
deleteUrl: "/api/media",
rootLabel: "Root",
theme: "sync"
}}
/>Default URLs (relative to your app):
| Key | Default |
|-----|---------|
| listUrl | /api/media |
| uploadUrl | /api/media/upload |
| createFolderUrl | /api/media/folders |
| updateUrl | /api/media |
| deleteUrl | /api/media |
| rootLabel | "Root" |
Backend API contract
All responses must follow:
{ "success": true, "data": ... }Errors:
{
"success": false,
"error": { "code": "VALIDATION_ERROR", "message": "Human readable message" }
}List — GET {listUrl}?path=&q=
Query params:
path— current folder path (empty = root)q— search query (optional)
Response:
{
"success": true,
"data": {
"path": "donations",
"folders": [
{ "name": "2026", "path": "donations/2026" }
],
"files": [
{
"name": "receipt.png",
"path": "donations/receipt.png",
"url": "/uploads/media/donations/receipt.png",
"size": 12345,
"mimeType": "image/png",
"updatedAt": "2026-06-06T00:00:00.000Z"
}
],
"capabilities": {
"view": true,
"upload": true,
"createFolder": true,
"delete": true,
"rename": true,
"select": true
}
}
}Capabilities (RBAC)
The UI reads capabilities from every list response to show or hide actions:
| Capability | Controls |
|------------|----------|
| view | Access to browse (required) |
| upload | Upload button + drag-and-drop |
| createFolder | Folder create form in sidebar |
| delete | Delete buttons on files/folders |
| rename | Reserved for future rename UI |
| select | File selection (picker flows) |
If capabilities is omitted, all actions default to enabled.
Upload — POST {uploadUrl}
multipart/form-data:
path— target folder path (empty string = root)files— one or more files
The UI uploads one file at a time sequentially and shows a preview card per file in the grid.
Recommended server limit: 5 MB per file (matches client-side check).
Create folder — POST {createFolderUrl}
{
"path": "donations",
"name": "2026",
"nested": true
}nested: true— create insidepathnested: false— create at root level
Rename — PATCH {updateUrl}
{
"path": "donations/old.png",
"newName": "new.png",
"type": "file"
}type: "file" | "folder"
Delete — DELETE {deleteUrl}
{
"path": "donations/old.png",
"type": "file"
}Deleting a folder should remove all nested content on the server.
Headless client
Use without UI for scripts, tests, or custom interfaces:
import { createMediaLibraryClient } from "@blitheforge/media-library";
const client = createMediaLibraryClient({
listUrl: "/api/media",
uploadUrl: "/api/media/upload",
createFolderUrl: "/api/media/folders",
updateUrl: "/api/media",
deleteUrl: "/api/media"
});
const listing = await client.list("donations", "receipt");
await client.createFolder("", "archive", true);
await client.uploadOne("donations", file);
await client.rename("donations/old.png", "new.png", "file");
await client.remove("donations/old.png", "file");Utility exports
import {
MAX_MEDIA_UPLOAD_BYTES, // 5 * 1024 * 1024
isFileWithinUploadSizeLimit,
formatUploadSizeLimit, // "5 MB"
fileMatchesAccept,
fileMatchesAcceptForUpload,
fileNameFromPath,
isImagePath,
bfmlRootProps,
resolveThemeMode
} from "@blitheforge/media-library";Upload behavior
- User selects files (click or drag-and-drop)
- Client validates type (
acceptprop) and size (5 MB default) - Oversized files → warning toast, skipped
- Invalid type → error toast, skipped
- Valid files → preview cards appear in the file grid
- Each file uploads sequentially; card removed on success
- Folder list refreshes silently after each successful upload
- Success toast when batch completes
File type filtering
Pass accept to restrict allowed types:
accept={["image"]} // images only
accept={["pdf"]} // PDF only
accept={["image", "pdf"]} // both (default for admin widget)Omit accept to allow all types returned by the API.
TypeScript types
import type {
MediaFile,
MediaFolder,
MediaListing,
MediaCapabilities,
MediaLibraryConfig,
MediaLibraryModalProps,
MediaLibraryWidgetProps,
MediaLibraryPanelProps,
MediaPickerProps,
MediaPickerMultiProps,
MediaLibraryThemeMode
} from "@blitheforge/media-library";