Component

Media browser

<DomMediaBrowser>

A folder-aware media library surface for server-backed files, optimistic Pinia stores, custom file renderers, drag and drop, upload progress, and picker workflows.

Demo

Server-backed media library

A Pinia setup store drives folder loading, optimistic folder creation, rename, move, metadata edits, route-aware navigation, picker selection, and emulated chunk uploads.

Pinia-driven media library

/

Route-aware folder path: /data/media-browser

0 visible rows
Detached selection controlNothing selected

Recent REST calls

Brand media

No items selected

No media yet

Drop files here or create a folder to start this library.

Last request body

{}

Last response

{}

Forms

File picker field

A form-style control opens DomMediaBrowser in a dialog, allows files to be uploaded or selected, then writes either a single media row id or an array of ids back into the form value.

No file selected

No media id

The form stores the selected media row id while the browser stays responsible for navigation and uploads.

Select media file

Choose an existing file or drop files into the browser to upload them.

File picker media

No items selected

No media yet

Drop files here or navigate to another folder.

Select a file to update the form value.

No files selected

No media ids

The form model receives an array of selected media row ids, with shift-click, command-click, and rubber-band selection available in the dialog.

Select media files

Choose an existing file or drop files into the browser to upload them.

File picker media

No items selected

No media yet

Drop files here or navigate to another folder.

Select one or more files to update the form value.

Architecture

Data model

The media row is the folder structure. The file row is the stored object. Keeping them separate lets one uploaded file appear in multiple roots or folders while the binary, MIME type, size, and metadata stay canonical.

This follows the data grid and month calendar pattern: the component renders current rows and emits intent, while the Pinia store owns fetching, optimistic updates, stale-response guards, cached folder data, and server mutation policy.

media
	ulid varchar(26) primary key
	root_id varchar(26) not null
	type enum('root', 'dir', 'file') not null
	path varchar(1024) not null
	parent_id varchar(26) null references media(ulid)
	file_uuid varchar(26) null references files(ulid)
	created_at datetime not null
	updated_at datetime not null

files
	ulid varchar(26) primary key
	name varchar(255) not null
	mime_type varchar(255) not null
	disk_path varchar(1024) not null
	size bigint unsigned not null
	public_url varchar(1024) null
	thumbnail_url varchar(1024) null
	meta json null
	created_at datetime not null
	updated_at datetime not null

unique index media_root_path_unique on media(root_id, path)
index media_parent_index on media(parent_id)
index media_file_index on media(file_uuid)

REST

Folder read endpoint

Folder navigation reads one parent at a time. The store updates the route path and derived breadcrumbs immediately, keeps any cached rows visible, then merges the server response when it returns. The response includes the current folder, hydrated children, canonical breadcrumbs, and request metadata.

GET /api/media/roots/:rootId/folders?path=/Images

// Response
{
	folder: {
		ulid: 'media_images',
		type: 'dir',
		path: '/Images',
		parent_id: 'media_root_brand',
		file_uuid: null,
		name: 'Images',
	},
	data: [
		{
			ulid: 'media_hero',
			type: 'file',
			path: '/Images/hero-image.jpg',
			parent_id: 'media_images',
			file_uuid: 'file_hero',
			name: 'hero-image.jpg',
			file: {
				ulid: 'file_hero',
				name: 'hero-image.jpg',
				mimeType: 'image/jpeg',
				diskPath: 'brand/file_hero/hero-image.jpg',
				size: 842120,
				publicUrl: '/media/file_hero/hero-image.jpg',
				thumbnailUrl: '/media/file_hero/thumb.jpg',
				meta: { alt: 'Dashboard hero card', width: 1600, height: 900 },
			},
		},
	],
	breadcrumbs: [
		{ label: 'Root', path: '/', ulid: 'media_root_brand' },
		{ label: 'Images', path: '/Images', ulid: 'media_images' },
	],
	meta: {
		rootId: 'media_root_brand',
		path: '/Images',
		total: 1,
		requestedAt: '2026-07-01T10:00:00.000Z',
	},
}

Store

Configurable Pinia store

The demo store exports a configured demo instance and a reusable store factory. Projects can provide REST endpoint roots or a custom API adapter with the same method names. Rows and files are stored in reactive object maps, so external inspector and detail components can observe file metadata updates without replacing the whole collection.

import { defineMediaBrowserStore } from '@getdom/studio/vue';

export const useProjectMediaStore = defineMediaBrowserStore('project-media', {
	rootId: 'media_root_brand',
	baseUrl: '/api/media',
	filesBaseUrl: '/api/files',
	mode: 'grid',
});

// The store updates currentPath and derived breadcrumbs immediately.
// Cached children stay visible while the server refresh request runs.
await store.loadFolder('/Images/Campaign');

REST

Optimistic mutations

The store updates visible rows first, marks the row as saving, sends the REST request, then replaces the optimistic row with the canonical server response. Rename and move stay server-defined, so descendant paths and duplicate names are resolved by the API.

POST /api/media/folders
{
	rootId: 'media_root_brand',
	parentId: 'media_images',
	path: '/Images',
	name: 'Campaign',
	tempUlid: 'temp_folder_lkx',
}

PATCH /api/media/:ulid
{
	name: 'Brand hero.jpg'
}

PATCH /api/media/move
{
	ulids: ['media_hero', 'media_product'],
	targetParentId: 'media_campaign',
}

PATCH /api/files/:fileUuid/meta
{
	meta: {
		alt: 'Product interface shown on a laptop',
		publicUrl: '/media/file_product/product-shot.png',
	}
}

Upload

Chunk upload contract

Drops are folder-aware where the browser exposes relative paths. The component emits dropped file records; the store creates temporary media rows with upload status fields and image object previews; the upload service reports per-file and overall progress; completion returns canonical rows.

POST /api/media/uploads
{
	rootId: 'media_root_brand',
	parentId: 'media_images',
	path: '/Images',
	chunkSize: 5242880,
	files: [
		{
			tempUlid: 'temp_upload_8',
			name: 'homepage.png',
			relativePath: 'homepage.png',
			pathSegments: ['homepage.png'],
			size: 628120,
			mimeType: 'image/png',
		},
		{
			tempUlid: 'temp_upload_9',
			name: 'detail.png',
			relativePath: 'Campaign/detail.png',
			pathSegments: ['Campaign', 'detail.png'],
			size: 450120,
			mimeType: 'image/png',
		},
	],
}

// Progress event emitted by the uploader service
{
	overall: 64,
	files: [
		{ tempUlid: 'temp_upload_8', progress: 68 },
		{ tempUlid: 'temp_upload_9', progress: 61 },
	],
}

// Completion response
{
	data: [
		/* created media rows, using tempUlid when the server accepts it */
	],
	visible: [
		/* canonical children for the current folder after upload */
	],
	meta: {
		action: 'upload-batch',
		total: 2,
		requestedAt: '2026-07-01T10:01:00.000Z',
	},
}

Routing

Folder paths in the URL

Use a catch-all child route when a browser route should deep-link into folders. The page converts route params to the media path, loads the folder through the store, and pushes breadcrumb or folder navigation back into the URL.

// src/routes.js
{
	path: '/data/media-browser/:folderPath(.*)*',
	component: MediaBrowserPage,
	meta: routeMeta('/data/media-browser'),
}

// Inside a page that wraps DomMediaBrowser
function routePathForFolder(path) {
	if (path === '/') return '/data/media-browser';
	const encoded = path
		.split('/')
		.filter(Boolean)
		.map((segment) => encodeURIComponent(segment))
		.join('/');
	return `/data/media-browser/${encoded}`;
}

function pathFromRouteParam(folderPath) {
	const parts = Array.isArray(folderPath) ? folderPath : [folderPath].filter(Boolean);
	return parts.length ? `/${parts.map(decodeURIComponent).join('/')}` : '/';
}

Rendering

Custom file components

The built-in renderer covers folder, image, pdf, and default file rows. Product teams can register components per kind, or replace the item, inspector, and detail slots entirely when a file type needs a bespoke preview, metadata editor, or full detail view. Detail components can open in a dialog or inline inside the browser; folders keep using the navigation event.

<DomMediaBrowser
	:items="store.items"
	:item-components="{
		image: ImageMediaTile,
		pdf: PdfMediaTile,
		default: FileMediaTile,
	}"
	:inspector-components="{
		image: ImageMetadataInspector,
		default: FileMetadataInspector,
	}"
	:detail-components="{
		image: { component: ImageMediaDetail, mode: 'dialog' },
		pdf: { component: PdfMediaDetail, mode: 'inline' },
	}"
/>

<!-- Or replace the whole visible row. -->
<template #item="{ item, kind, selected, rename }">
	<MyMediaRow
		:item="item"
		:kind="kind"
		:selected="selected"
		@rename="rename"
	/>
</template>

Source

Store and mock API

useMediaBrowserDemoStore.js

useMediaBrowserDemoStore.jsjs

mediaBrowserMockApi.js

mediaBrowserMockApi.jsjs

Reference

Props

Control props

NameTypeTSDefaultDescription
itemsarrayArray<unknown>[]Media rows for the current folder. Rows usually come from the Pinia store after a folder API request.
breadcrumbsarrayArray<unknown>[]Clickable folder path segments. When omitted, the component derives labels from currentPath.
currentPathstringstring'/'Slash-prefixed folder path currently being rendered.
selectedKeysarrayArray<unknown>[]Controlled media row ids selected in the browser.
mode'grid' | 'list'string'grid'Visual display mode.
loadingbooleanbooleanfalseShow the current folder as loading while the store waits for the API.
toolbarbooleanbooleantrueRender the built-in breadcrumb and action toolbar.
titlestringstring'Media browser'Accessible title for the browser surface.
emptyTextstringstring'Drop files here or create a folder to start this library.'Message shown when the current folder has no media rows.
inspectorbooleanbooleantrueShow an inspector panel for the active selection.
canUploadbooleanbooleantrueAllow external files and folders to be dropped onto the browser.
canCreateFolderbooleanbooleantrueShow create-folder actions and emit create-folder events.
canRenamebooleanbooleantrueAllow selected rows to enter inline rename mode.
canMovebooleanbooleantrueAllow internal rows to be dragged onto folders.
rubberBandbooleanbooleantrueAllow click-drag rectangle selection across visible rows.
selectionMode'single' | 'multiple'string'multiple'Selection behavior for picker and library workflows.
selectableTypesarrayArray<unknown>["dir","file"]Media row types that can be selected. Use ["file"] with accept="image/*" for an image picker.
acceptstring | arrayArray<unknown>''Accepted file MIME patterns or extensions for file selection, such as image/* or .pdf.
itemComponentsobjectRecord<string, unknown>{}Component map keyed by folder, image, pdf, or default for custom item renderers.
inspectorComponentsobjectRecord<string, unknown>{}Component map keyed by folder, image, pdf, or default for custom inspector renderers.
detailComponentsobjectRecord<string, unknown>{}Component map keyed by folder, image, pdf, or default. Values may be a component or { component, mode }.
detailMode'dialog' | 'inline'string'dialog'Default placement when a detail component does not define its own mode.
renameDelaynumbernumber260Delay in milliseconds before a selected filename click enters rename mode, leaving double-click open intact.
uploadProgressnumbernumberOverall upload progress. When omitted, progress is inferred from uploading rows.

Auto-generated from Media browser.props and inline _edit hints.

Events

NamePayloadDescription
@update:selectedKeysArray<string>Fired when the user changes the selected media rows.
@update:mode'grid' | 'list'Fired when the user switches between grid and list layout.
@selection-changeMediaBrowserSelectionPayloadFired with selected keys and resolved rows.
@navigate({ path, item, breadcrumb, event })Fired when the user opens a folder or breadcrumb.
@create-folder({ path })Fired when the built-in new-folder action is activated.
@rename({ item, name })Fired after inline rename is committed.
@moveMediaBrowserMovePayloadFired when one or more selected rows are dropped onto a folder.
@uploadMediaBrowserUploadPayloadFired when files or folders are dropped onto the current browser or a folder.
@metadata-update({ item, meta })Fired when the default inspector commits editable metadata such as image alt text.
@make-folder-with-items({ items, path })Fired from the context menu when the selection should be grouped into a new folder.
@context-action({ action, items, path })Fired for context menu actions after built-in handling.
@detail-openMediaBrowserDetailPayloadFired when a row with a detail component opens in dialog or inline mode.
@detail-closeMediaBrowserDetailPayloadFired when the current detail component closes.
@openMediaBrowserItemPayloadFired when a non-folder row is opened.
@item-clickMediaBrowserItemPayloadFired before selection handling when a visible row is clicked.

Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.

Slots

NameScopeDescription
#toolbar{ selectedItems, selectedKeys, mode, currentPath, setMode, viewModeOptions }Replace or extend the toolbar for detached picker controls.
#item{ item, kind, selected, disabled, mode, rename }Replace the full item body.
#inspector{ item, kind, file, selectedItems }Replace the inspector panel body.
#detail{ item, kind, file, mode, close }Replace a detail component body for dialog or inline rendering.
#emptyCustom empty-folder state.

Keyboard

  • Enter on folderNavigate into the folder.
  • Enter / Space on fileSelect the file.
  • F2 on selected rowStart inline rename.
  • Enter in rename inputCommit the rename.
  • Esc in rename inputCancel the rename.