Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flat-media-folders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds flat media folders to the media API. Media can be filtered or assigned to one folder, and deleting a folder returns its media to the Main library.
78 changes: 6 additions & 72 deletions docs/src/content/docs/guides/media-library.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: Upload and manage images and files in EmDash.
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
import mediaLibraryImg from "../../../assets/screenshots/admin-media-library.png";

EmDash includes a media library for managing images, documents, and other files. This guide covers uploading, organizing, and using media in your content.
EmDash includes a media library for managing images, documents, and other files. This guide covers uploading, finding, and using media in your content.

## Accessing the Media Library

Expand Down Expand Up @@ -151,31 +151,11 @@ This approach keeps large files off your application server and enables direct u
your Worker.
</Aside>

## Organizing Media

### Folders

Create folders to organize your media:

1. Click **New Folder** in the media library

2. Enter a folder name

3. Click **Create**

4. Drag files into folders to organize them

### Search
## Finding Media

Use the search box to find files by name. Search matches partial filenames.

### Filters

Filter media by:

- **Type** - Images, Documents, Video, Audio
- **Date** - Upload date range
- **Folder** - Specific folder
Use the type menu to show all files, images, video, audio, or documents.

## Using Media in Content

Expand Down Expand Up @@ -267,55 +247,9 @@ EmDash installs an image endpoint that produces the resized variants on request.

## Media API

Access media programmatically using the admin API.

### Upload a File

Upload media as multipart form data:

```bash
POST /_emdash/api/media
Content-Type: multipart/form-data
Authorization: Bearer YOUR_API_TOKEN

file=<binary file data>
```

A successful upload returns the stored media item:

```json
{
"success": true,
"data": {
"item": {
"id": "01ABC123",
"filename": "hero-image.jpg",
"mime_type": "image/jpeg",
"storage_key": "media/abc123/hero-image.jpg",
"width": 1200,
"height": 800
}
}
}
```

### List Media

The following request lists media under a prefix:

```bash
GET /_emdash/api/media?prefix=images/&limit=20
Authorization: Bearer YOUR_API_TOKEN
```

### Delete Media

The following request deletes a stored file:

```bash
DELETE /_emdash/api/media/images/hero.jpg
Authorization: Bearer YOUR_API_TOKEN
```
Use the authenticated media endpoints to list, upload, update, and delete local media. The
[REST API reference](/reference/rest-api/#media-endpoints) documents request parameters, response
shapes, permissions, and folder operations.

## Media Providers

Expand Down
84 changes: 66 additions & 18 deletions docs/src/content/docs/reference/rest-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,17 @@ GET /_emdash/api/media?includeUsage=1
| Parameter | Type | Description |
| -------------- | -------- | -------------------------------------------------------------- |
| `cursor` | `string` | Opaque pagination cursor |
| `page` | `number` | Numbered page, starting at 1; cannot be combined with `cursor` |
| `limit` | `number` | Items per page, from 1 to 100 (default: 50) |
| `mimeType` | `string` | Filter by one or more comma-separated MIME types |
| `q` | `string` | Case-insensitive filename search |
| `folderId` | `string` | Folder ID, or `unfiled` for the Main library |
| `includeUsage` | `1` | Include a coverage-aware `usage` summary on every returned item |

Omit `folderId` to list media from the Main library and every folder. Use `folderId=unfiled` to
list only media that is not assigned to a folder. A numbered request returns `totalCount`; cursor
mode returns `nextCursor` when another page is available.

#### Response

```json
Expand All @@ -205,6 +211,7 @@ GET /_emdash/api/media?includeUsage=1
"size": 102400,
"width": 1920,
"height": 1080,
"folderId": null,
"url": "/_emdash/api/media/file/uploads/photo.jpg",
"createdAt": "2025-01-24T12:00:00Z",
"usage": {
Expand Down Expand Up @@ -317,47 +324,88 @@ fields, and Portable Text image blocks managed by EmDash content collections. It
custom code, rendered HTML, settings, menus, widgets, plugin-private data, external sites, or
provider-only assets.

### Create Media
### Upload Media

Upload one file as multipart form data. The endpoint requires `media:upload`; bearer tokens also
require `media:write`.

The following request uploads `photo.jpg`:

```sh
curl -X POST "https://example.com/_emdash/api/media" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "X-EmDash-Request: 1" \
-F "file=@photo.jpg"
```

A new upload returns 201 with the stored media item and `folderId: null`. If the file content
already exists, the endpoint returns 200 with `deduplicated: true`, the existing media item, and
its current folder assignment.

### Update Media

```http
POST /_emdash/api/media
PUT /_emdash/api/media/:id
Content-Type: application/json
```

#### Request Body

```json
{
"filename": "photo.jpg",
"mimeType": "image/jpeg",
"size": 102400,
"width": 1920,
"height": 1080,
"storageKey": "uploads/photo.jpg"
"alt": "Photo description",
"caption": "Photo caption",
"folderId": "01FOLDER..."
}
```

<Aside>
This endpoint records media metadata after upload. Use the upload endpoint or signed URLs to
upload the actual file.
</Aside>
Omit `folderId` to leave the current assignment unchanged. Set it to `null` to return the media
item to the Main library. Authors can update their own media; editors can update any media.

### Update Media
### List Media Folders

```http
PUT /_emdash/api/media/:id
GET /_emdash/api/media/folders?limit=50&cursor=...
```

Returns folders in name order with an optional `nextCursor`. `limit` accepts 1 to 100 and defaults
to 50. The endpoint requires `media:read`.

### Create Media Folder

```http
POST /_emdash/api/media/folders
Content-Type: application/json

{
"name": "Product photos"
}
```

#### Request Body
Folder names contain 1 to 200 UTF-16 code units after trimming. Names conflict when their
NFKC-normalized lowercase forms match. Creating, renaming, and deleting folders requires
`media:edit_any`.

### Rename Media Folder

```http
PUT /_emdash/api/media/folders/:id
Content-Type: application/json

```json
{
"alt": "Photo description",
"caption": "Photo caption"
"name": "Published product photos"
}
```

### Delete Media Folder

```http
DELETE /_emdash/api/media/folders/:id
```

Deleting a folder returns its media to the Main library. It does not delete media, change media
IDs or URLs, or change media usage records.

### Delete Media

```http
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export const ErrorCode = {
MEDIA_CREATE_ERROR: "MEDIA_CREATE_ERROR",
MEDIA_UPDATE_ERROR: "MEDIA_UPDATE_ERROR",
MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR",
MEDIA_FOLDER_LIST_ERROR: "MEDIA_FOLDER_LIST_ERROR",
MEDIA_FOLDER_CREATE_ERROR: "MEDIA_FOLDER_CREATE_ERROR",
MEDIA_FOLDER_UPDATE_ERROR: "MEDIA_FOLDER_UPDATE_ERROR",
MEDIA_FOLDER_DELETE_ERROR: "MEDIA_FOLDER_DELETE_ERROR",
MEDIA_USAGE_READ_ERROR: "MEDIA_USAGE_READ_ERROR",
MEDIA_USAGE_REPAIR_ERROR: "MEDIA_USAGE_REPAIR_ERROR",
MEDIA_USAGE_WORK_LIST_ERROR: "MEDIA_USAGE_WORK_LIST_ERROR",
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export {
type MediaListResponse,
type MediaResponse,
} from "./media.js";
export {
handleMediaFolderList,
handleMediaFolderCreate,
handleMediaFolderUpdate,
handleMediaFolderDelete,
} from "./media-folders.js";

export {
aggregateMediaUsageCoverageStatus,
Expand Down
112 changes: 112 additions & 0 deletions packages/core/src/api/handlers/media-folders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { Kysely } from "kysely";

import {
MediaFolderRepository,
type MediaFolder,
} from "../../database/repositories/media-folders.js";
import { EmDashValidationError, InvalidCursorError } from "../../database/repositories/types.js";
import type { Database } from "../../database/types.js";
import type { ApiResult } from "../types.js";

const UNIQUE_VIOLATION_RE =
/unique constraint failed|duplicate key value violates unique constraint/i;

export async function handleMediaFolderList(
db: Kysely<Database>,
options: { limit?: number; cursor?: string } = {},
): Promise<ApiResult<{ items: MediaFolder[]; nextCursor?: string }>> {
try {
const result = await new MediaFolderRepository(db).findMany(options);
return { success: true, data: result };
} catch (error) {
if (error instanceof InvalidCursorError) {
return { success: false, error: { code: "INVALID_CURSOR", message: error.message } };
}
return {
success: false,
error: { code: "MEDIA_FOLDER_LIST_ERROR", message: "Failed to list media folders" },
};
}
}

export async function handleMediaFolderCreate(
db: Kysely<Database>,
input: { name: string },
): Promise<ApiResult<{ item: MediaFolder }>> {
try {
const item = await new MediaFolderRepository(db).create(input.name);
return { success: true, data: { item } };
} catch (error) {
return mediaFolderWriteError(
error,
"MEDIA_FOLDER_CREATE_ERROR",
"Failed to create media folder",
);
}
}

export async function handleMediaFolderUpdate(
db: Kysely<Database>,
id: string,
input: { name: string },
): Promise<ApiResult<{ item: MediaFolder }>> {
try {
const item = await new MediaFolderRepository(db).update(id, input.name);
if (!item) {
return { success: false, error: { code: "NOT_FOUND", message: "Media folder not found" } };
}
return { success: true, data: { item } };
} catch (error) {
return mediaFolderWriteError(
error,
"MEDIA_FOLDER_UPDATE_ERROR",
"Failed to update media folder",
);
}
}

export async function handleMediaFolderDelete(
db: Kysely<Database>,
id: string,
): Promise<ApiResult<{ deleted: true }>> {
try {
const deleted = await new MediaFolderRepository(db).delete(id);
if (!deleted) {
return { success: false, error: { code: "NOT_FOUND", message: "Media folder not found" } };
}
return { success: true, data: { deleted: true } };
} catch {
return {
success: false,
error: { code: "MEDIA_FOLDER_DELETE_ERROR", message: "Failed to delete media folder" },
};
}
}

function mediaFolderWriteError(
error: unknown,
code: "MEDIA_FOLDER_CREATE_ERROR" | "MEDIA_FOLDER_UPDATE_ERROR",
message: string,
): ApiResult<never> {
if (error instanceof EmDashValidationError) {
return { success: false, error: { code: "VALIDATION_ERROR", message: error.message } };
}
if (isUniqueViolation(error)) {
return {
success: false,
error: { code: "CONFLICT", message: "A media folder with this name already exists" },
};
}
return { success: false, error: { code, message } };
}

function isUniqueViolation(error: unknown): boolean {
if (error && typeof error === "object") {
if ("code" in error && error.code === "23505") return true;
}
const message = error instanceof Error ? error.message : "";
if (UNIQUE_VIOLATION_RE.test(message)) return true;
return Boolean(
error && typeof error === "object" && "cause" in error && isUniqueViolation(error.cause),
);
}
Loading
Loading