Skip to content

Media ​

The editor owns the picker: Browse on image fields, video thumbnails and custom-block image fields, drag-and-drop upload, crop, folders, search. You own storage.

onRequestMedia is a separate seam — a UI override for a host widget (Bynder, Cloudinary, a modal of your own). It is not this store. When both are set, the callback wins and the built-in modal never opens. See Images.

Quick start ​

The bundled browser-local provider needs no backend:

js
import { init, createLocalStorageMediaProvider } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  media: createLocalStorageMediaProvider(),
});

Entries go into localStorage under templatical:media. create stores the file as a data URL — a demo-sized model. localStorage quotas are typically around 5 MB, and a few large images will fill it. Folders, replace, import, usage, frequently-used and quota are false. Supply a provider for anything beyond demos, prototypes and a single device.

Installation ​

Only needed standalone

Do not install @templatical/media-library next to the editor. init({ media }) and Cloud's store already ship Browse as a lazy chunk; Vue is not installed. This package is the standalone SDK — init(), useMediaLibrary, or MediaLibraryModal in your own Vue app.

bash
npm install @templatical/media-library
bash
pnpm add @templatical/media-library
bash
yarn add @templatical/media-library
bash
bun add @templatical/media-library

That package takes vue as a peer. Skip it when:

  • you pass onRequestMedia and no media key — the host widget never mounts the modal
  • you omit media entirely — image fields stay URL-only
  • you call provider methods yourself — they are ordinary functions

Loading the editor from the CDN?

There is nothing to install. The CDN editor inlines the same chunk that the npm editor ships.

The contract ​

media takes any object implementing MediaProvider. list is a method; every other member is either a function or false:

ts
interface MediaProvider {
  list(params?: MediaListParams): Promise<MediaListPage>;

  create: false | ((input: MediaCreateInput) => Promise<MediaAsset>);
  update: false | ((id: string, patch: MediaAssetPatch) => Promise<MediaAsset>);
  delete: false | ((ids: string[]) => Promise<void>);
  folders: false | MediaFoldersProvider;
  replace: false | ((id: string, file: File) => Promise<MediaAsset>);
  importFromUrl:
    | false
    | ((url: string, folderId?: string | null, templateId?: string) => Promise<MediaAsset>);
  checkUsage: false | ((ids: string[]) => Promise<Record<string, MediaUsageInfo>>);
  frequentlyUsed: false | (() => Promise<MediaAsset[]>);
  storage: false | (() => Promise<MediaStorageInfo | null>);
}

interface MediaFoldersProvider {
  list(): Promise<MediaFolder[]>; // FLAT; the UI trees via parentId
  create: false | ((input: MediaFolderInput) => Promise<MediaFolder>);
  update: false | ((id: string, patch: { name: string }) => Promise<MediaFolder>);
  delete: false | ((id: string) => Promise<void>);
  move: false | ((ids: string[], folderId: string | null) => Promise<MediaAsset[]>);
}

false means the current user may not perform that action, and the editor hides the affordance.

list cannot be false: without it the picker has nothing to show. Search, folder, category and cursor are sent on every listing — galleries outgrow one response. A provider that returns everything at once omits nextCursor.

delete and checkUsage are bulk: the grid is multi-select. folders is nested so a gallery without folders writes folders: false once; folders.list() returns a flat array and the UI trees it via parentId.

Cropping is client-side. The Edit dialog crops and resizes JPEG, PNG and WebP images in the browser and saves the result with replace(id, file), then update(id, patch). It calls replace only when the crop or a max width or height changed the image; an alt-text or filename edit calls update alone. If replace rejects, update is not called and the dialog stays open with an error. With replace: false the dialog offers no crop. GIFs get no crop either, since the exported file would hold one frame.

There is no crop method on the provider, and no upload-progress callback: create takes a File and resolves with the asset.

Confirm always inserts asset.url. The grid uses thumbnailUrl, falling back to url. There is no conversion set (small / medium / large) on MediaAsset — if a derivative belongs in the email, put that URL on url.

The data shape ​

ts
interface MediaAsset {
  id: string;              // assigned by the provider, returned from create()
  url: string;             // lands on the block
  alt?: string;
  filename?: string;
  mimeType?: string;
  width?: number;
  height?: number;
  size?: number;           // bytes
  thumbnailUrl?: string;   // grid; falls back to url
  folderId?: string | null;
  canUpdate?: boolean;     // optional — absent means allowed; set false to forbid
  canDelete?: boolean;
  createdAt?: string;      // optional — display only, never affects ordering
  updatedAt?: string;
}

interface MediaCreateInput {
  file: File;
  folderId?: string | null;
  alt?: string;
  filename?: string;
  templateId?: string;
}

interface MediaAssetPatch {
  alt?: string;
  filename?: string;
}

interface MediaListParams {
  search?: string;
  cursor?: string;
  folderId?: string | null;
  category?: MediaCategory;   // "images" | "documents" | "videos" | "audio"
  templateId?: string;        // when a template is loaded; Cloud ignores
}

interface MediaListPage {
  items: MediaAsset[];
  nextCursor?: string;
}
  • id comes from the provider. The editor never generates one; it uses whatever create() returns.
  • templateId is opportunistic. Passed on list / create / importFromUrl when a template is loaded; omitted on a blank canvas. Media is not gated on it. Cloud ignores it; a CMS that scopes a gallery per template reads it here.
  • Ordering comes from the provider. The editor renders list()'s order and never re-sorts.
  • Timestamps are display only. Omit both and no label renders.
  • maxFileSize / mimeTypes on the provider are a client pre-check, not a security boundary — the backend must enforce too. The upload zone's hint is built from them, and files they reject are named under it.
ts
interface MediaOptions {
  maxFileSize?: number; // bytes
  mimeTypes?: Partial<Record<MediaCategory, string[]>>;
  onCreated?: (asset: MediaAsset) => void;
  onUpdated?: (asset: MediaAsset) => void;
  onDeleted?: (asset: MediaAsset) => void;
}

MediaProvider extends MediaOptions, so one object carries storage and events.

Controlling permissions ​

Withhold a whole capability by passing false instead of a function. The editor hides what it can't do: no upload zone and no drop when create is off, no edit when update is off, no delete when delete is off, no folder tree when folders is off.

ts
const media: MediaProvider = {
  list: ({ search, cursor, templateId }) =>
    myCms.page({ search, cursor, templateId }),
  create: ({ file }) => myCms.upload(file),
  update: false,
  delete: false,
  folders: false,
  replace: false,
  importFromUrl: false,
  checkUsage: false,
  frequentlyUsed: false,
  storage: false,
};

Withhold a single entry with canUpdate / canDelete on it. Absent means allowed, so set them only on the exceptions. The two levers compose in one direction: canUpdate: true cannot re-enable an update the provider passed as false.

A read-only library ​

Every mutation false gives a curated gallery users browse, search and pick from but never modify:

ts
const media: MediaProvider = {
  list: async () => {
    const res = await fetch('/api/media');
    return json(res);
  },
  create: false,
  update: false,
  delete: false,
  folders: false,
  replace: false,
  importFromUrl: false,
  checkUsage: false,
  frequentlyUsed: false,
  storage: false,
};

Picking still works — it copies { url, alt } onto the canvas, and nothing reaches the provider. Drop does not: drop needs create. list is the one member that can't be disabled.

Not a security boundary

Hiding a control stops the editor offering an action; it doesn't stop a determined caller. Provider methods run in the user's browser, so enforce permissions server-side too — including who may read, change or delete an asset in a library shared across a team.

Error handling ​

Any method may reject. The editor reports the failure through onError and leaves its in-memory list untouched, so a failed delete doesn't make an asset vanish from the UI. The modal stays open. Provider messages are user-presentable: they reach the UI verbatim.

Failed create does not prepend. importFromUrl keeps an inline field error.

In the editor ​

The surface is a picker. There is no sidebar rail and no "manage media" chrome.

  • Browse — an image field (and the video thumbnail, and a custom-block image field) shows a browse button when media or onRequestMedia is configured. With a media provider and no callback, the click opens the library modal. With onRequestMedia, the callback runs instead — the modal never mounts. Image fields and the video thumbnail pass accept: ["images"]; custom blocks may pass other categories. The modal forces list({ category }) to that set and hides other tabs.

  • Confirm — Confirm or double-click commits one previewed asset that matches accept, as { url: asset.url, alt: asset.alt }. Multi-select is for bulk delete and move only. Close, Escape or backdrop returns null.

  • Drop — dragging an image file onto an image block or field:

    ConfigDrop
    onRequestMediayes — context.files
    provider, create is a functionyes — provider.create({ file, templateId? })
    provider, create: falseno
    neitherno

    The drop zone pre-filters to image/ MIME types. Size and type after that are MediaOptions, then the server. Don't return a blob: URL: URL.createObjectURL(file) is session-local and breaks export.

  • Lazy load — omit media and none of the library UI is downloaded. onRequestMedia alone never mounts the modal.

Off by default ​

Omit media and image fields stay URL-only, unless you passed onRequestMedia.

Events ​

ts
media: {
  // ...list, create, update, delete, …
  onCreated: (asset) => {},
  onUpdated: (asset) => {},
  onDeleted: (asset) => {},
}

Each fires once the matching mutation resolves, with the stored asset:

  • onCreated after create or importFromUrl. A drop calls create and fires this without opening the modal and without prepending a listing row — the URL lands on the block.
  • onUpdated after update or replace.
  • onDeleted after delete.

There is no editor-level media list. The modal's own listing (when it is open) prepends, replaces and filters on success; a drop never touches that listing.

onDeleted carries the asset, not an id

delete resolves to nothing, so the handler receives the entry captured from the modal's loaded listing immediately before removing it.

A delete outside the loaded listing fires no event

The captured entry has to already be in the modal's listing. Deleting an id that listing never held still deletes successfully; there is nothing to pass to onDeleted, so it does not fire.

A handler that throws is caught and reported to onError — it never fails the create, update or delete that triggered it.

Headless use ​

There is no useMedia in @templatical/core. Media state is modal-scoped; the provider methods are the headless API. Call them from your own UI, or drive the bundled modal's state machine:

ts
import { useMediaLibrary } from '@templatical/media-library';

const {
  items,          // Ref<MediaAsset[]>
  isLoading,      // Ref<boolean>
  hasMore,        // Ref<boolean>
  loadItems,      // () => Promise<void>
  loadMore,       // () => Promise<void>
  uploadFile,     // (file: File) => Promise<MediaAsset | null>
  confirmDelete,  // () => Promise<void>
} = useMediaLibrary({
  provider,
  onError: (error) => {
    /* handle */
  },
});

It keeps the list in sync after each successful call — prepending on create, replacing on update, removing on delete — and reports to onError without mutating the list on failure. A stale list / loadMore response is discarded.

Bringing your own ​

A minimal REST implementation:

ts
import { init } from '@templatical/editor';
import type { MediaProvider } from '@templatical/editor';

const json = async (res: Response) => {
  if (!res.ok) throw new Error(`Media request failed: ${res.status}`);
  return res.json();
};

const media: MediaProvider = {
  list: async (params) => {
    const query = new URLSearchParams();
    if (params?.search) query.set('search', params.search);
    if (params?.cursor) query.set('cursor', params.cursor);
    if (params?.folderId) query.set('folderId', params.folderId);
    if (params?.category) query.set('category', params.category);
    if (params?.templateId) query.set('templateId', params.templateId);
    const res = await fetch(`/api/media?${query}`);
    return json(res);
  },

  create: async (input) => {
    const body = new FormData();
    body.append('file', input.file);
    if (input.folderId) body.append('folderId', input.folderId);
    if (input.templateId) body.append('templateId', input.templateId);
    const res = await fetch('/api/media', { method: 'POST', body });
    return json(res);
  },

  update: async (id, patch) => {
    const res = await fetch(`/api/media/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(patch),
    });
    return json(res);
  },

  delete: async (ids) => {
    const res = await fetch('/api/media', {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ids }),
    });
    if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
  },

  folders: false,
  replace: false,
  importFromUrl: false,
  checkUsage: false,
  frequentlyUsed: false,
  storage: false,
};

await init({ container: '#editor', media });

A CMS gallery that lists and uploads, and nothing else, is the same shape as the permissions example above. Shared and per-template galleries merge inside list({ templateId }).

In the playground ​

The Media setup passes a media provider with a seeded gallery in this browser's storage: browse, drop a file to upload, pick an image.

Open in playground