Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/media-field-multiple.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds a "multiple" option to image and file fields for ordered galleries and download lists. Enable "Allow multiple" on an image or file field to store an ordered array of media values; the admin renders a multi-select media picker with drag-and-drop reordering. Existing single-value fields are unchanged, and a field can be switched to multiple in place — previously saved single values keep validating and become one-item arrays on the next save.
60 changes: 50 additions & 10 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
SettingsActionBar,
} from "./ContentSettingsPanel.js";
import { ImageFieldRenderer, type ImageFieldValue } from "./ImageFieldRenderer.js";
import { MultiMediaFieldRenderer, mediaItemToFileValue } from "./MultiMediaFieldRenderer.js";
import { PluginFieldErrorBoundary } from "./PluginFieldErrorBoundary.js";
import { RepeaterField } from "./RepeaterField.js";
import { RouterLinkButton } from "./RouterLinkButton.js";
Expand Down Expand Up @@ -1264,6 +1265,30 @@ function FieldRenderer({
);

case "image": {
if (field.validation?.multiple === true) {
return (
<MultiMediaFieldRenderer
id={id}
label={label}
kind="image"
value={value}
onChange={handleChange}
required={field.required}
allowedMimeTypes={
Array.isArray(field.validation?.allowedMimeTypes)
? (field.validation.allowedMimeTypes as string[])
: undefined
}
fieldId={field.id}
minItems={
typeof field.validation?.minItems === "number" ? field.validation.minItems : undefined
}
maxItems={
typeof field.validation?.maxItems === "number" ? field.validation.maxItems : undefined
}
/>
);
}
// value is either an ImageFieldValue object, a legacy string URL, or undefined
const imageValue =
value != null && typeof value === "object" ? (value as ImageFieldValue) : undefined;
Expand All @@ -1290,6 +1315,30 @@ function FieldRenderer({
}

case "file": {
if (field.validation?.multiple === true) {
return (
<MultiMediaFieldRenderer
id={id}
label={label}
kind="file"
value={value}
onChange={handleChange}
required={field.required}
allowedMimeTypes={
Array.isArray(field.validation?.allowedMimeTypes)
? (field.validation.allowedMimeTypes as string[])
: undefined
}
fieldId={field.id}
minItems={
typeof field.validation?.minItems === "number" ? field.validation.minItems : undefined
}
maxItems={
typeof field.validation?.maxItems === "number" ? field.validation.maxItems : undefined
}
/>
);
}
// value is either a FileFieldValue object or undefined.
// The file field type was unusable before this PR (rendered as a text input
// that produced raw strings nobody could meaningfully save), so there is no
Expand Down Expand Up @@ -1593,16 +1642,7 @@ function FileFieldRenderer({
}, [value, t]);

const handleSelect = (item: MediaItem) => {
const isLocalProvider = !item.provider || item.provider === "local";
onChange({
id: item.id,
provider: item.provider || "local",
src: isLocalProvider ? undefined : item.url,
filename: item.filename,
mimeType: item.mimeType,
size: item.size,
meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta,
});
onChange(mediaItemToFileValue(item));
};

const handleRemove = () => {
Expand Down
55 changes: 51 additions & 4 deletions packages/admin/src/components/FieldEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ interface FieldFormState {
minItems: string;
maxItems: string;
allowedMimeTypes: string[];
multiple: boolean;
}

function getInitialFormState(field?: SchemaField): FieldFormState {
Expand All @@ -101,6 +102,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState {
minItems: (field.validation as Record<string, unknown>)?.minItems?.toString() ?? "",
maxItems: (field.validation as Record<string, unknown>)?.maxItems?.toString() ?? "",
allowedMimeTypes: field.validation?.allowedMimeTypes ?? [],
multiple: field.validation?.multiple === true,
};
}
return {
Expand All @@ -121,6 +123,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState {
minItems: "",
maxItems: "",
allowedMimeTypes: [],
multiple: false,
};
}

Expand Down Expand Up @@ -311,6 +314,14 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
validation.allowedMimeTypes = formState.allowedMimeTypes;
}

if ((selectedType === "file" || selectedType === "image") && formState.multiple) {
validation.multiple = true;
if (formState.minItems)
(validation as Record<string, unknown>).minItems = parseInt(formState.minItems, 10);
if (formState.maxItems)
(validation as Record<string, unknown>).maxItems = parseInt(formState.maxItems, 10);
}

// Only include searchable for text-based fields
const isSearchableType =
selectedType === "string" ||
Expand Down Expand Up @@ -635,10 +646,46 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
)}

{(selectedType === "file" || selectedType === "image") && (
<AllowedTypesEditor
value={formState.allowedMimeTypes}
onChange={(next) => setField("allowedMimeTypes", next)}
/>
<div className="space-y-4">
<Switch
checked={formState.multiple}
onCheckedChange={(checked) => setField("multiple", checked)}
label={
<span className="text-sm">
{selectedType === "image"
? t`Allow multiple images`
: t`Allow multiple files`}
</span>
}
/>
{field?.validation?.multiple === true && !formState.multiple && (
<p className="text-sm text-kumo-danger">
{t`Entries that already hold more than one item will fail validation until they are edited down to a single item.`}
</p>
)}
{formState.multiple && (
<div className="grid grid-cols-2 gap-4">
<Input
label={t`Min Items`}
type="number"
value={formState.minItems}
onChange={(e) => setField("minItems", e.target.value)}
placeholder="0"
/>
<Input
label={t`Max Items`}
type="number"
value={formState.maxItems}
onChange={(e) => setField("maxItems", e.target.value)}
placeholder={t`No limit`}
/>
</div>
)}
<AllowedTypesEditor
value={formState.allowedMimeTypes}
onChange={(next) => setField("allowedMimeTypes", next)}
/>
</div>
)}
</div>
)}
Expand Down
38 changes: 21 additions & 17 deletions packages/admin/src/components/ImageFieldRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ export interface ImageFieldValue {
meta?: Record<string, unknown>;
}

/** Map a picked MediaItem to the stored image field value. */
export function mediaItemToImageValue(item: MediaItem): ImageFieldValue {
const isLocalProvider = !item.provider || item.provider === "local";
return {
id: item.id,
provider: item.provider || "local",
// Local media derives URLs from meta.storageKey at display time — no src needed
// External providers cache a preview URL for admin display
previewUrl: isLocalProvider ? undefined : item.url,
alt: item.alt || "",
width: item.width,
height: item.height,
// Cache LQIP alongside dimensions so embeds render a placeholder without a
// runtime lookup. Fall back to `meta` for providers that stash it there.
blurhash: item.blurhash ?? metaString(item.meta, "blurhash"),
dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"),
meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta,
};
}

export interface ImageFieldRendererProps {
id?: string;
label: string;
Expand Down Expand Up @@ -79,23 +99,7 @@ export function ImageFieldRenderer({
}, [displayUrl]);

const handleSelect = (item: MediaItem) => {
const isLocalProvider = !item.provider || item.provider === "local";

onChange({
id: item.id,
provider: item.provider || "local",
// Local media derives URLs from meta.storageKey at display time — no src needed
// External providers cache a preview URL for admin display
previewUrl: isLocalProvider ? undefined : item.url,
alt: item.alt || "",
width: item.width,
height: item.height,
// Cache LQIP alongside dimensions so embeds render a placeholder without a
// runtime lookup. Fall back to `meta` for providers that stash it there.
blurhash: item.blurhash ?? metaString(item.meta, "blurhash"),
dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"),
meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta,
});
onChange(mediaItemToImageValue(item));
};

const handleRemove = () => {
Expand Down
Loading
Loading