Skip to content

Media Library Plugin #77

@olliethedev

Description

@olliethedev

Overview

Add a Media Library plugin that provides a centralized, self-hosted asset management experience — images, files, and videos in one place. The UI target is a Filestack-style picker: folder tree on the left, grid/list view on the right, drag-to-upload, search, filter by type.

Today every plugin that needs image upload (Blog, CMS, UI Builder) requires consumers to wire up a custom uploadImage override. This plugin replaces that scattered pattern with a first-class, shared asset store.


Core Features

Asset Management

  • Upload files via drag-and-drop or file picker (images, PDFs, videos, any file)
  • Folder tree — create, rename, delete folders
  • Grid view (thumbnails) and list view (name, size, type, uploaded date)
  • Search by filename, filter by MIME type or folder
  • Rename and delete assets
  • Copy public URL to clipboard
  • Bulk select + bulk delete

Image-specific

  • Automatic thumbnail generation at upload time
  • Width × height metadata stored
  • Basic transforms via URL query params: ?w=800&h=600&fit=cover (proxied through a plugin endpoint that calls Sharp or similar)

Picker Component

  • <MediaPicker> modal / drawer — drop-in replacement for every uploadImage override
  • Returns a selected asset URL
  • Can be used outside a plugin route (e.g. embedded in Blog or CMS forms)
  • Supports single or multi-select

Storage Adapters

  • Local filesystem (for dev / self-hosted)
  • S3-compatible (AWS S3, Cloudflare R2, MinIO)
  • Pluggable adapter interface for other providers

Schema

import { createDbPlugin } from "@btst/stack/plugins/api"

export const mediaSchema = createDbPlugin("media", {
  asset: {
    modelName: "asset",
    fields: {
      filename:    { type: "string",  required: true },
      originalName:{ type: "string",  required: true },
      mimeType:    { type: "string",  required: true },
      size:        { type: "number",  required: true },       // bytes
      url:         { type: "string",  required: true },       // public URL
      thumbnailUrl:{ type: "string",  required: false },
      width:       { type: "number",  required: false },
      height:      { type: "number",  required: false },
      folderId:    { type: "string",  required: false },
      alt:         { type: "string",  required: false },
      createdAt:   { type: "date",    defaultValue: () => new Date() },
    },
  },
  folder: {
    modelName: "folder",
    fields: {
      name:        { type: "string",  required: true },
      parentId:    { type: "string",  required: false },
      createdAt:   { type: "date",    defaultValue: () => new Date() },
    },
  },
})

Plugin Structure

src/plugins/media/
├── db.ts
├── types.ts
├── schemas.ts
├── query-keys.ts
├── client.css
├── style.css
├── api/
│   ├── plugin.ts               # defineBackendPlugin — upload, list, delete endpoints
│   ├── getters.ts              # listAssets, getAssetById, listFolders
│   ├── mutations.ts            # createAsset, deleteAsset, createFolder
│   ├── query-key-defs.ts
│   ├── serializers.ts
│   ├── storage-adapter.ts      # StorageAdapter interface
│   ├── adapters/
│   │   ├── local.ts            # LocalStorageAdapter (writes to /public/uploads)
│   │   └── s3.ts               # S3StorageAdapter (AWS / R2 / MinIO)
│   └── index.ts
└── client/
    ├── plugin.tsx              # defineClientPlugin — library route
    ├── overrides.ts            # MediaPluginOverrides
    ├── index.ts
    ├── hooks/
    │   ├── use-media.tsx       # useAssets, useAsset, useFolders
    │   └── index.tsx
    └── components/
        ├── media-picker.tsx            # <MediaPicker> modal — embeddable in any plugin
        └── pages/
            ├── library-page.tsx / .internal.tsx        # Main media library UI
            └── upload-zone.tsx                         # Drag-and-drop uploader

Routes

Route Path Description
library /media Full media library UI

Storage Adapter Interface

export interface StorageAdapter {
  upload(file: File | Buffer, options: {
    filename: string
    mimeType: string
    folder?: string
  }): Promise<{ url: string; thumbnailUrl?: string; width?: number; height?: number }>

  delete(url: string): Promise<void>
}

// Built-in adapters:
export function localAdapter(options?: { uploadDir?: string; publicPath?: string }): StorageAdapter
export function s3Adapter(options: {
  bucket: string
  region: string
  accessKeyId: string
  secretAccessKey: string
  endpoint?: string   // for R2 / MinIO
  publicBaseUrl?: string
}): StorageAdapter

MediaPicker Component

The key consumer-facing component — a modal that wraps the full library UI for inline asset selection:

import { MediaPicker } from "@btst/stack/plugins/media/client"

// Drop into any form or editor
<MediaPicker
  apiBaseURL="https://example.com"
  apiBasePath="/api/data"
  accept={["image/*"]}              // optional MIME filter
  multiple={false}
  onSelect={(assets) => {
    form.setValue("coverImage", assets[0].url)
  }}
  trigger={<Button>Choose image</Button>}
/>

Integration with Other Plugins

Replace the scattered uploadImage override across Blog, CMS, and UI Builder:

// Before: each plugin required its own uploadImage override
blog: { uploadImage: async (file) => myCustomUpload(file) }
cms:  { uploadImage: async (file) => myCustomUpload(file) }

// After: configure once, used everywhere
media: mediaClientPlugin({ apiBaseURL, apiBasePath, queryClient })
// blog/cms/ui-builder pick up MediaPicker automatically when media plugin is registered

Backend API Surface

const assets  = await myStack.api.media.listAssets({ folderId: "folder-id", mimeType: "image/" })
const asset   = await myStack.api.media.getAssetById("asset-id")
const folders = await myStack.api.media.listFolders()

Hooks

mediaBackendPlugin({
  storageAdapter: s3Adapter({ ... }),
  onBeforeUpload?: (file, ctx) => Promise<void>  // throw to reject (e.g. size/type validation)
  onAfterUpload?:  (asset, ctx) => Promise<void>
  onBeforeDelete?: (asset, ctx) => Promise<void>
  maxFileSizeBytes?: number   // default: 10 MB
  allowedMimeTypes?: string[] // default: all
})

SSG Support

Media library is auth-gated — prefetchForRoute is not applicable. dynamic = "force-dynamic" on the library page.


Non-Goals (v1)

  • Video transcoding / streaming
  • Image CDN / edge transforms (URL param transforms are server-proxied only)
  • Advanced DAM features (version history, approval workflows)
  • External integrations (Unsplash, Getty)
  • Per-asset access control

Plugin Configuration Options

Option Type Description
storageAdapter StorageAdapter Where files are stored (local, S3, R2)
maxFileSizeBytes number Upload size limit (default: 10 MB)
allowedMimeTypes string[] MIME type allowlist (default: all)
hooks MediaPluginHooks onBeforeUpload, onAfterUpload, onBeforeDelete

Documentation

Add docs/content/docs/plugins/media.mdx covering:

  • Overview — centralized asset management, replaces scattered uploadImage overrides
  • SetupmediaBackendPlugin with storage adapter, mediaClientPlugin
  • Storage adapters — local (dev) and S3/R2 examples; custom adapter interface
  • <MediaPicker> component — embedding in forms, Blog, CMS, UI Builder
  • Integration with other plugins — how to wire up shared image picking
  • Schema referenceAutoTypeTable for config + hooks
  • Image transforms — URL param reference (?w, ?h, ?fit)

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions