diff --git a/.changeset/media-field-multiple.md b/.changeset/media-field-multiple.md new file mode 100644 index 0000000000..97bc1294f7 --- /dev/null +++ b/.changeset/media-field-multiple.md @@ -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. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 806aca20a6..1ea0edb223 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -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"; @@ -1264,6 +1265,30 @@ function FieldRenderer({ ); case "image": { + if (field.validation?.multiple === true) { + return ( + + ); + } // value is either an ImageFieldValue object, a legacy string URL, or undefined const imageValue = value != null && typeof value === "object" ? (value as ImageFieldValue) : undefined; @@ -1290,6 +1315,30 @@ function FieldRenderer({ } case "file": { + if (field.validation?.multiple === true) { + return ( + + ); + } // 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 @@ -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 = () => { diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index 435ea9dddb..052f40b1d4 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -77,6 +77,7 @@ interface FieldFormState { minItems: string; maxItems: string; allowedMimeTypes: string[]; + multiple: boolean; } function getInitialFormState(field?: SchemaField): FieldFormState { @@ -101,6 +102,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState { minItems: (field.validation as Record)?.minItems?.toString() ?? "", maxItems: (field.validation as Record)?.maxItems?.toString() ?? "", allowedMimeTypes: field.validation?.allowedMimeTypes ?? [], + multiple: field.validation?.multiple === true, }; } return { @@ -121,6 +123,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState { minItems: "", maxItems: "", allowedMimeTypes: [], + multiple: false, }; } @@ -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).minItems = parseInt(formState.minItems, 10); + if (formState.maxItems) + (validation as Record).maxItems = parseInt(formState.maxItems, 10); + } + // Only include searchable for text-based fields const isSearchableType = selectedType === "string" || @@ -635,10 +646,46 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie )} {(selectedType === "file" || selectedType === "image") && ( - setField("allowedMimeTypes", next)} - /> +
+ setField("multiple", checked)} + label={ + + {selectedType === "image" + ? t`Allow multiple images` + : t`Allow multiple files`} + + } + /> + {field?.validation?.multiple === true && !formState.multiple && ( +

+ {t`Entries that already hold more than one item will fail validation until they are edited down to a single item.`} +

+ )} + {formState.multiple && ( +
+ setField("minItems", e.target.value)} + placeholder="0" + /> + setField("maxItems", e.target.value)} + placeholder={t`No limit`} + /> +
+ )} + setField("allowedMimeTypes", next)} + /> +
)} )} diff --git a/packages/admin/src/components/ImageFieldRenderer.tsx b/packages/admin/src/components/ImageFieldRenderer.tsx index ac760ea9b7..c83e028b55 100644 --- a/packages/admin/src/components/ImageFieldRenderer.tsx +++ b/packages/admin/src/components/ImageFieldRenderer.tsx @@ -39,6 +39,26 @@ export interface ImageFieldValue { meta?: Record; } +/** 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; @@ -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 = () => { diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index 49f52b59e6..66f4b8bbf4 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -66,6 +66,10 @@ export interface MediaPickerModalProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (item: MediaItem) => void; + /** Allow selecting several items at once; confirms through `onSelectMany`. */ + multiple?: boolean; + /** Called instead of `onSelect` when `multiple` is set. */ + onSelectMany?: (items: MediaItem[]) => void; /** Filter by mime type prefix, e.g. "image/" */ mimeTypeFilter?: string; title?: string; @@ -122,6 +126,8 @@ export function MediaPickerModal({ open, onOpenChange, onSelect, + multiple = false, + onSelectMany, mimeTypeFilter = "image/", mimeTypeFilters, fieldId, @@ -149,6 +155,8 @@ export function MediaPickerModal({ const EmptyStateIcon = isFileKind ? Paperclip : Image; const queryClient = useQueryClient(); const [selectedItem, setSelectedItem] = React.useState(null); + // Multi-select mode keeps items in click order — it becomes the gallery order. + const [selectedItems, setSelectedItems] = React.useState([]); const [activeProvider, setActiveProvider] = React.useState("local"); const [searchQuery, setSearchQuery] = React.useState(""); // Debounced for the local library's server-side filename search. @@ -173,6 +181,7 @@ export function MediaPickerModal({ React.useEffect(() => { if (open) { setSelectedItem(null); + setSelectedItems([]); setActiveProvider("local"); setSearchQuery(""); setImageUrl(""); @@ -257,7 +266,11 @@ export function MediaPickerModal({ mutationFn: (file: File) => uploadMedia(file, { fieldId }), onSuccess: (item) => { void queryClient.invalidateQueries({ queryKey: ["media"] }); - setSelectedItem({ providerId: "local", item }); + if (multiple) { + setSelectedItems((prev) => [...prev, { providerId: "local", item }]); + } else { + setSelectedItem({ providerId: "local", item }); + } setUploadError(null); }, onError: (err: Error) => { @@ -271,7 +284,11 @@ export function MediaPickerModal({ uploadToProvider(providerId, file), onSuccess: (item, { providerId }) => { void queryClient.invalidateQueries({ queryKey: ["provider-media", providerId] }); - setSelectedItem({ providerId, item }); + if (multiple) { + setSelectedItems((prev) => [...prev, { providerId, item }]); + } else { + setSelectedItem({ providerId, item }); + } setUploadError(null); }, onError: (err: Error) => { @@ -356,25 +373,51 @@ export function MediaPickerModal({ } }; + // When providerId is "local", item is always MediaItem; otherwise MediaProviderItem + const toMediaItem = (selected: SelectedMedia): MediaItem => { + if (selected.providerId === "local") { + return selected.item as MediaItem; + } + const providerItem = selected.item as MediaProviderItem; + const dims = providerDimensions[providerItem.id]; + const itemWithDims = dims + ? { + ...providerItem, + width: providerItem.width ?? dims.width, + height: providerItem.height ?? dims.height, + } + : providerItem; + return providerItemToMediaItem(selected.providerId, itemWithDims); + }; + + const isItemSelected = (providerId: string, id: string) => + multiple + ? selectedItems.some((s) => s.providerId === providerId && s.item.id === id) + : selectedItem?.providerId === providerId && selectedItem.item.id === id; + + const handleItemClick = (providerId: string, item: MediaItem | MediaProviderItem) => { + if (multiple) { + setSelectedItems((prev) => + prev.some((s) => s.providerId === providerId && s.item.id === item.id) + ? prev.filter((s) => !(s.providerId === providerId && s.item.id === item.id)) + : [...prev, { providerId, item }], + ); + } else { + setSelectedItem({ providerId, item }); + } + }; + const handleConfirm = () => { + if (multiple) { + if (selectedItems.length === 0) return; + onSelectMany?.(selectedItems.map(toMediaItem)); + onOpenChange(false); + setSelectedItems([]); + setImageUrl(""); + return; + } if (selectedItem) { - if (selectedItem.providerId === "local") { - // When providerId is "local", item is always MediaItem - onSelect(selectedItem.item as MediaItem); - } else { - // When providerId is not "local", item is always MediaProviderItem - const providerItem = selectedItem.item as MediaProviderItem; - const dims = providerDimensions[providerItem.id]; - const itemWithDims = dims - ? { - ...providerItem, - width: providerItem.width ?? dims.width, - height: providerItem.height ?? dims.height, - } - : providerItem; - const mediaItem = providerItemToMediaItem(selectedItem.providerId, itemWithDims); - onSelect(mediaItem); - } + onSelect(toMediaItem(selectedItem)); onOpenChange(false); setSelectedItem(null); setImageUrl(""); @@ -384,6 +427,7 @@ export function MediaPickerModal({ const handleClose = () => { onOpenChange(false); setSelectedItem(null); + setSelectedItems([]); setImageUrl(""); setUrlError(null); }; @@ -431,7 +475,11 @@ export function MediaPickerModal({ createdAt: new Date().toISOString(), }; - onSelect(externalItem); + if (multiple) { + onSelectMany?.([externalItem]); + } else { + onSelect(externalItem); + } onOpenChange(false); setImageUrl(""); } catch { @@ -544,6 +592,7 @@ export function MediaPickerModal({ onClick={() => { setActiveProvider(tab.id); setSelectedItem(null); + setSelectedItems([]); setSearchQuery(""); }} className={cn( @@ -665,11 +714,14 @@ export function MediaPickerModal({ setSelectedItem({ providerId: "local", item })} + selected={isItemSelected("local", item.id)} + onClick={() => handleItemClick("local", item)} onDoubleClick={() => { + // Multi-select: double-click is just a toggle, no instant insert + if (multiple) { + handleItemClick("local", item); + return; + } onSelect(item); onOpenChange(false); }} @@ -680,12 +732,13 @@ export function MediaPickerModal({ setSelectedItem({ providerId: activeProvider, item })} + selected={isItemSelected(activeProvider, item.id)} + onClick={() => handleItemClick(activeProvider, item)} onDoubleClick={() => { + if (multiple) { + handleItemClick(activeProvider, item); + return; + } // Merge loaded dimensions for double-click select const dims = providerDimensions[item.id]; const itemWithDims = dims @@ -728,21 +781,33 @@ export function MediaPickerModal({ {/* Footer */}
- {selectedItem && ( - - {t`Selected:`} {selectedItem.item.filename} - {selectedItem.providerId !== "local" && ( - - {t`(from ${providers?.find((p) => p.id === selectedItem.providerId)?.name})`} + {multiple + ? selectedItems.length > 0 && ( + + {plural(selectedItems.length, { + one: "# item selected", + other: "# items selected", + })} + + ) + : selectedItem && ( + + {t`Selected:`} {selectedItem.item.filename} + {selectedItem.providerId !== "local" && ( + + {t`(from ${providers?.find((p) => p.id === selectedItem.providerId)?.name})`} + + )} )} - - )}
-
diff --git a/packages/admin/src/components/MultiMediaFieldRenderer.tsx b/packages/admin/src/components/MultiMediaFieldRenderer.tsx new file mode 100644 index 0000000000..f59ad386a2 --- /dev/null +++ b/packages/admin/src/components/MultiMediaFieldRenderer.tsx @@ -0,0 +1,281 @@ +/** + * MultiMediaFieldRenderer — ordered list of media values for image/file + * fields with `validation.multiple`. + * + * Items are added through the multi-select media picker and reordered via + * drag-and-drop. The emitted value is a plain array of media values (same + * per-item shape as the single ImageFieldRenderer / FileFieldRenderer). + */ + +import { Button, Label } from "@cloudflare/kumo"; +import { DndContext, closestCenter } from "@dnd-kit/core"; +import type { DragEndEvent } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { plural } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; +import { Plus, Trash, DotsSixVertical, ImageBroken } from "@phosphor-icons/react"; +import * as React from "react"; + +import type { MediaItem } from "../lib/api"; +import { getFileIcon } from "../lib/media-utils"; +import { cn } from "../lib/utils"; +import { mediaItemToImageValue, type ImageFieldValue } from "./ImageFieldRenderer"; +import { MediaPickerModal } from "./MediaPickerModal"; + +/** Superset of the image and file value shapes, keyed by what each kind uses. */ +export interface MultiMediaValue extends ImageFieldValue { + filename?: string; + mimeType?: string; + size?: number; +} + +/** Map a picked MediaItem to the stored file field value. */ +export function mediaItemToFileValue(item: MediaItem): MultiMediaValue { + const isLocalProvider = !item.provider || item.provider === "local"; + return { + 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, + }; +} + +export interface MultiMediaFieldRendererProps { + id?: string; + label: string; + kind: "image" | "file"; + value: unknown; + onChange: (value: MultiMediaValue[]) => void; + required?: boolean; + allowedMimeTypes?: string[]; + fieldId?: string; + minItems?: number; + maxItems?: number; +} + +type KeyedItem = { _key: string; value: MultiMediaValue }; + +function ensureKeys(items: unknown[]): KeyedItem[] { + return items + .filter((item): item is MultiMediaValue => typeof item === "object" && item !== null) + .map((value, i) => ({ _key: `item-${i}-${Date.now()}`, value })); +} + +export function MultiMediaFieldRenderer({ + id, + label, + kind, + value, + onChange, + required, + allowedMimeTypes, + fieldId, + minItems = 0, + maxItems, +}: MultiMediaFieldRendererProps) { + const { t } = useLingui(); + const [pickerOpen, setPickerOpen] = React.useState(false); + // A single stored object (field toggled from single to multiple) is shown + // as a one-item list; the server-side schema does the same wrapping. + const rawItems = Array.isArray(value) ? value : value != null ? [value] : []; + const [items, setItems] = React.useState(() => ensureKeys(rawItems)); + + // Sync from external value changes, preserving keys by position so + // round-trips through onChange don't remount rows. + React.useEffect(() => { + const incoming = Array.isArray(value) ? value : value != null ? [value] : []; + setItems((prev) => + ensureKeys(incoming).map((item, i) => ({ ...item, _key: prev[i]?._key ?? item._key })), + ); + }, [value]); + + const emitChange = (updated: KeyedItem[]) => { + setItems(updated); + onChange(updated.map((item) => item.value)); + }; + + const handleAdd = (selected: MediaItem[]) => { + const mapper = kind === "image" ? mediaItemToImageValue : mediaItemToFileValue; + const added = selected.map((item, i) => ({ + _key: `item-${items.length + i}-${Date.now()}`, + value: mapper(item), + })); + emitChange([...items, ...added]); + }; + + const handleRemove = (key: string) => { + emitChange(items.filter((item) => item._key !== key)); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = items.findIndex((item) => item._key === active.id); + const newIndex = items.findIndex((item) => item._key === over.id); + if (oldIndex === -1 || newIndex === -1) return; + emitChange(arrayMove(items, oldIndex, newIndex)); + }; + + const canAdd = !maxItems || items.length < maxItems; + const canRemove = items.length > minItems; + + return ( +
+
+ + {canAdd && ( + + )} +
+ + {items.length === 0 ? ( + + ) : ( + + item._key)} + strategy={verticalListSortingStrategy} + > +
+ {items.map((item, index) => ( + handleRemove(item._key) : undefined} + /> + ))} +
+
+
+ )} + + {}} + onSelectMany={handleAdd} + mimeTypeFilters={ + allowedMimeTypes && allowedMimeTypes.length > 0 + ? allowedMimeTypes + : kind === "image" + ? ["image/"] + : [] + } + fieldId={fieldId} + hideUrlInput={kind === "file"} + mediaKind={kind} + title={t`Select ${label}`} + /> + {required && items.length === 0 && ( +

{t`This field is required`}

+ )} +
+ ); +} + +interface SortableMediaRowProps { + item: MultiMediaValue; + kind: "image" | "file"; + sortKey: string; + index: number; + onRemove?: () => void; +} + +function SortableMediaRow({ item, kind, sortKey, index, onRemove }: SortableMediaRowProps) { + const { t } = useLingui(); + const [imageBroken, setImageBroken] = React.useState(false); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: sortKey, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const isLocal = !item.provider || item.provider === "local"; + const storageKey = typeof item.meta?.storageKey === "string" ? item.meta.storageKey : undefined; + const displayUrl = + item.previewUrl || + item.src || + (isLocal ? `/_emdash/api/media/file/${encodeURIComponent(storageKey ?? item.id)}` : undefined); + const name = item.filename || item.alt || item.id || t`Untitled`; + + return ( +
+ + {kind === "image" ? ( + imageBroken || !displayUrl ? ( +
+ +
+ ) : ( + {item.alt setImageBroken(true)} + /> + ) + ) : ( + + )} + {name} + {onRemove && ( + + )} +
+ ); +} diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts index 1b991befa4..d074937ed7 100644 --- a/packages/admin/src/lib/api/schema.ts +++ b/packages/admin/src/lib/api/schema.ts @@ -63,6 +63,7 @@ export interface SchemaField { pattern?: string; options?: string[]; allowedMimeTypes?: string[]; + multiple?: boolean; }; widget?: string; options?: Record; @@ -115,6 +116,7 @@ export interface CreateFieldInput { pattern?: string; options?: string[]; allowedMimeTypes?: string[]; + multiple?: boolean; } | null; widget?: string; options?: Record; @@ -134,6 +136,7 @@ export interface UpdateFieldInput { pattern?: string; options?: string[]; allowedMimeTypes?: string[]; + multiple?: boolean; } | null; widget?: string; options?: Record; diff --git a/packages/admin/src/locales/ar/messages.po b/packages/admin/src/locales/ar/messages.po index 35e58cd88c..202a4ba927 100644 --- a/packages/admin/src/locales/ar/messages.po +++ b/packages/admin/src/locales/ar/messages.po @@ -80,7 +80,7 @@ msgstr "(مع منفذ التطوير الخاص بك)." #: packages/admin/src/components/ContentTypeList.tsx:69 #: packages/admin/src/components/RepeaterField.tsx:152 msgid "{0, plural, one {(# item)} other {(# items)}}" -msgstr "{0, plural, few {(# عناصر)} many {(# عنصرًا)}} other {(# عنصر)}}" +msgstr "{0, plural, few {(# عناصر)} many {(# عنصرًا)} other {(# عنصر)}}" #. placeholder {0}: seedInfo.collections #: packages/admin/src/components/SetupWizard.tsx:156 diff --git a/packages/core/src/api/handlers/validate-media-fields.ts b/packages/core/src/api/handlers/validate-media-fields.ts index 354582fd3c..85fe3552f7 100644 --- a/packages/core/src/api/handlers/validate-media-fields.ts +++ b/packages/core/src/api/handlers/validate-media-fields.ts @@ -24,6 +24,17 @@ function asMediaRef(value: unknown): MediaRefValue | null { return value; } +/** A multiple field holds an array of refs; a single field holds one. */ +function asMediaRefList(value: unknown): MediaRefValue[] { + const items = Array.isArray(value) ? value : [value]; + const refs: MediaRefValue[] = []; + for (const item of items) { + const ref = asMediaRef(item); + if (ref) refs.push(ref); + } + return refs; +} + function fail(message: string): ApiResult { return { success: false, error: { code: "INVALID_MIME_FOR_FIELD", message } }; } @@ -65,11 +76,11 @@ export async function validateMediaFields( // Collect local media ids that need a MIME lookup const localIds = new Set(); for (const field of fields) { - const ref = asMediaRef(data[field.slug]); - if (!ref) continue; - const provider = typeof ref.provider === "string" ? ref.provider : "local"; - if (provider === "local" && typeof ref.id === "string") { - localIds.add(ref.id); + for (const ref of asMediaRefList(data[field.slug])) { + const provider = typeof ref.provider === "string" ? ref.provider : "local"; + if (provider === "local" && typeof ref.id === "string") { + localIds.add(ref.id); + } } } @@ -88,36 +99,33 @@ export async function validateMediaFields( } for (const field of fields) { - const value = data[field.slug]; - if (value === null || value === undefined) continue; - const ref = asMediaRef(value); - if (!ref) continue; - - const provider = typeof ref.provider === "string" ? ref.provider : "local"; + for (const ref of asMediaRefList(data[field.slug])) { + const provider = typeof ref.provider === "string" ? ref.provider : "local"; - // External providers carry mimeType in the ref; trust it as-is. - // Local media: look up the stored mimeType by id. - let mime: string | undefined; - if (provider === "local") { - if (typeof ref.id !== "string") { - return fail(`Field '${field.slug}' references media with an invalid id`); + // External providers carry mimeType in the ref; trust it as-is. + // Local media: look up the stored mimeType by id. + let mime: string | undefined; + if (provider === "local") { + if (typeof ref.id !== "string") { + return fail(`Field '${field.slug}' references media with an invalid id`); + } + mime = mimeById.get(ref.id); + if (!mime) { + return fail(`Field '${field.slug}' references media with unknown MIME type`); + } + } else { + if (typeof ref.mimeType !== "string") { + return fail(`Field '${field.slug}' requires a mimeType declaration for non-local media`); + } + // TODO: long-term, consider a server-side HEAD probe or provider-vouched + // MIMEs for non-local refs; for now the constraint is only as strong as + // the client that constructed the ref. + mime = ref.mimeType; } - mime = mimeById.get(ref.id); - if (!mime) { - return fail(`Field '${field.slug}' references media with unknown MIME type`); - } - } else { - if (typeof ref.mimeType !== "string") { - return fail(`Field '${field.slug}' requires a mimeType declaration for non-local media`); - } - // TODO: long-term, consider a server-side HEAD probe or provider-vouched - // MIMEs for non-local refs; for now the constraint is only as strong as - // the client that constructed the ref. - mime = ref.mimeType; - } - if (!matchesMimeAllowlist(mime, field.allowedMimeTypes)) { - return fail(`Field '${field.slug}' does not accept ${mime}`); + if (!matchesMimeAllowlist(mime, field.allowedMimeTypes)) { + return fail(`Field '${field.slug}' does not accept ${mime}`); + } } } diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index f2922289e8..199cec04de 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -61,6 +61,7 @@ const fieldValidation = z subFields: z.array(repeaterSubFieldSchema).min(1).optional(), minItems: z.number().int().min(0).optional(), maxItems: z.number().int().min(1).optional(), + multiple: z.boolean().optional(), allowedMimeTypes: z .array( z diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index ecf6fa0c82..2e0660594e 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -3406,9 +3406,16 @@ export class EmDashRuntime { if (value == null) continue; try { - const normalized = await normalizeMediaValue(value, getProvider); - if (normalized) { - result[field.slug] = normalized; + if (Array.isArray(value)) { + // validation.multiple fields: normalize each item, keep as-is on failure + result[field.slug] = await Promise.all( + value.map(async (item) => (await normalizeMediaValue(item, getProvider)) ?? item), + ); + } else { + const normalized = await normalizeMediaValue(value, getProvider); + if (normalized) { + result[field.slug] = normalized; + } } } catch { // Don't fail the save if normalization fails for a single field diff --git a/packages/core/src/media/usage/extractor.ts b/packages/core/src/media/usage/extractor.ts index fc6747cc63..2cfd8c6a81 100644 --- a/packages/core/src/media/usage/extractor.ts +++ b/packages/core/src/media/usage/extractor.ts @@ -37,24 +37,12 @@ export function extractMediaUsageOccurrences({ const value = data[field.slug]; if (field.type === "image") { - addOccurrence(occurrences, seen, { - fieldSlug: field.slug, - fieldPath: field.slug, - referenceType: "image_field", - value, - fallbackKind: "image", - }); + addMediaFieldOccurrences(occurrences, seen, field.slug, value, "image_field", "image"); continue; } if (field.type === "file") { - addOccurrence(occurrences, seen, { - fieldSlug: field.slug, - fieldPath: field.slug, - referenceType: "file_field", - value, - fallbackKind: null, - }); + addMediaFieldOccurrences(occurrences, seen, field.slug, value, "file_field", null); continue; } @@ -71,6 +59,30 @@ export function extractMediaUsageOccurrences({ return occurrences; } +/** Handles both single values and `validation.multiple` arrays. */ +function addMediaFieldOccurrences( + occurrences: ExtractedMediaUsageOccurrence[], + seen: Set, + fieldSlug: string, + value: unknown, + referenceType: MediaUsageReferenceType, + fallbackKind: MediaKind | null, +): void { + const items = Array.isArray(value) + ? value.map((item, index) => ({ item, fieldPath: `${fieldSlug}[${index}]` })) + : [{ item: value, fieldPath: fieldSlug }]; + + for (const { item, fieldPath } of items) { + addOccurrence(occurrences, seen, { + fieldSlug, + fieldPath, + referenceType, + value: item, + fallbackKind, + }); + } +} + function extractRepeaterOccurrences( occurrences: ExtractedMediaUsageOccurrence[], seen: Set, diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 6d5055e981..8418f74302 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -139,9 +139,15 @@ export interface FieldValidation { pattern?: string; options?: string[]; // For select/multiSelect subFields?: RepeaterSubField[]; // For repeater fields - minItems?: number; // For repeater fields - maxItems?: number; // For repeater fields + minItems?: number; // For repeater and multiple image/file fields + maxItems?: number; // For repeater and multiple image/file fields allowedMimeTypes?: string[]; + /** + * For image/file fields: the value is an ordered array of media values. + * Lives in `validation` (not `options`) so it feeds the generated Zod + * schema and `generateSchemaHash`. + */ + multiple?: boolean; } /** diff --git a/packages/core/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts index cec60b562c..314400d526 100644 --- a/packages/core/src/schema/zod-generator.ts +++ b/packages/core/src/schema/zod-generator.ts @@ -133,32 +133,38 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny { ); case "image": - return z.object({ - id: z.string(), - src: z.string().optional(), - alt: z.string().optional(), - width: z.number().optional(), - height: z.number().optional(), - /** Provider ID (e.g. "local", "cloudflare-images") */ - provider: z.string().optional(), - /** Admin-side preview URL for external providers (not persisted by plugins) */ - previewUrl: z.string().optional(), - /** Provider-specific metadata; for local media this carries storageKey */ - meta: z.record(z.string(), z.unknown()).optional(), - }); + return applyMediaMultiplicity( + z.object({ + id: z.string(), + src: z.string().optional(), + alt: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + /** Provider ID (e.g. "local", "cloudflare-images") */ + provider: z.string().optional(), + /** Admin-side preview URL for external providers (not persisted by plugins) */ + previewUrl: z.string().optional(), + /** Provider-specific metadata; for local media this carries storageKey */ + meta: z.record(z.string(), z.unknown()).optional(), + }), + field, + ); case "file": - return z.object({ - id: z.string(), - src: z.string().optional(), - filename: z.string().optional(), - mimeType: z.string().optional(), - size: z.number().optional(), - /** Provider ID (e.g. "local", "s3") */ - provider: z.string().optional(), - /** Provider-specific metadata; for local media this carries storageKey */ - meta: z.record(z.string(), z.unknown()).optional(), - }); + return applyMediaMultiplicity( + z.object({ + id: z.string(), + src: z.string().optional(), + filename: z.string().optional(), + mimeType: z.string().optional(), + size: z.number().optional(), + /** Provider ID (e.g. "local", "s3") */ + provider: z.string().optional(), + /** Provider-specific metadata; for local media this carries storageKey */ + meta: z.record(z.string(), z.unknown()).optional(), + }), + field, + ); case "reference": return z.string(); // Reference ID @@ -171,6 +177,31 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny { } } +/** + * Wrap an image/file item schema in an array when `validation.multiple` is + * set. Values stored under the other multiplicity must round-trip through + * the editor after a toggle (#867), so a lone object is wrapped and a + * one-element array is unwrapped. + */ +function applyMediaMultiplicity(item: ZodTypeAny, field: Field): ZodTypeAny { + const validation = field.validation; + if (!validation?.multiple) { + return z.preprocess((v) => (Array.isArray(v) && v.length === 1 ? v[0] : v), item); + } + + let arr = z.array(item); + if (validation.minItems !== undefined) { + arr = arr.min(validation.minItems); + } + if (validation.maxItems !== undefined) { + arr = arr.max(validation.maxItems); + } + return z.preprocess( + (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? [v] : v), + arr, + ); +} + /** * Apply validation rules to a schema */ @@ -411,11 +442,17 @@ function fieldTypeToTypeScript(field: Field): string { case "portableText": return "PortableTextBlock[]"; - case "image": - return "{ id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }"; + case "image": { + const imageType = + "{ id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }"; + return field.validation?.multiple ? `Array<${imageType}>` : imageType; + } - case "file": - return "{ id: string; src?: string; filename?: string; mimeType?: string; size?: number; provider?: string; meta?: Record }"; + case "file": { + const fileType = + "{ id: string; src?: string; filename?: string; mimeType?: string; size?: number; provider?: string; meta?: Record }"; + return field.validation?.multiple ? `Array<${fileType}>` : fileType; + } case "reference": // Could be enhanced to include the referenced collection type diff --git a/packages/core/tests/integration/content/media-field-validation.test.ts b/packages/core/tests/integration/content/media-field-validation.test.ts index f8515fee18..493294db66 100644 --- a/packages/core/tests/integration/content/media-field-validation.test.ts +++ b/packages/core/tests/integration/content/media-field-validation.test.ts @@ -215,6 +215,58 @@ describeEachDialect("save-side media-field MIME validation", (dialect) => { expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); }); + it("validates every item of a multiple media field against the allowlist", async () => { + const collection = await ctx.db + .selectFrom("_emdash_collections") + .select("id") + .where("slug", "=", "posts") + .executeTakeFirstOrThrow(); + + await ctx.db + .insertInto("_emdash_fields") + .values({ + id: ulid(), + collection_id: collection.id, + slug: "documents", + label: "Documents", + type: "file", + column_type: "TEXT", + required: 0, + unique: 0, + default_value: null, + validation: JSON.stringify({ multiple: true, allowedMimeTypes: ["application/pdf"] }), + widget: "file", + options: null, + sort_order: 30, + }) + .execute(); + + await ctx.db.schema.alterTable("ec_posts").addColumn("documents", "text").execute(); + + const rejected = await handleContentCreate(ctx.db, "posts", { + slug: "p7", + data: { + title: "p7", + documents: [ + { id: pdfMediaId, provider: "local", filename: "doc.pdf" }, + { id: zipMediaId, provider: "local", filename: "x.zip" }, + ], + }, + }); + expect(rejected.success).toBe(false); + if (rejected.success) return; + expect(rejected.error.code).toBe("INVALID_MIME_FOR_FIELD"); + + const accepted = await handleContentCreate(ctx.db, "posts", { + slug: "p8", + data: { + title: "p8", + documents: [{ id: pdfMediaId, provider: "local", filename: "doc.pdf" }], + }, + }); + expect(accepted.success).toBe(true); + }); + it("file/image field without allowedMimeTypes is not validated", async () => { // Insert a second file field with no MIME restrictions (backwards-compat assertion) const collection = await ctx.db diff --git a/packages/core/tests/unit/media/usage-extractor.test.ts b/packages/core/tests/unit/media/usage-extractor.test.ts index 384c77b251..2fb4c1c713 100644 --- a/packages/core/tests/unit/media/usage-extractor.test.ts +++ b/packages/core/tests/unit/media/usage-extractor.test.ts @@ -56,6 +56,55 @@ describe("extractMediaUsageOccurrences", () => { ]); }); + it("extracts every item of a multiple image/file field with indexed paths", () => { + const occurrences = extractMediaUsageOccurrences({ + fields: [field("gallery", "image"), field("downloads", "file")], + data: { + gallery: [ + { id: "media-a", provider: "local", mimeType: "image/jpeg" }, + { id: "media-b", provider: "local", mimeType: "image/png" }, + ], + downloads: [{ id: "media-doc", provider: "local", mimeType: "application/pdf" }], + }, + }); + + expect(occurrences).toEqual([ + { + fieldSlug: "gallery", + fieldPath: "gallery[0]", + occurrenceIndex: 0, + referenceType: "image_field", + mediaId: "media-a", + provider: "local", + providerAssetId: "media-a", + mediaKind: "image", + mimeType: "image/jpeg", + }, + { + fieldSlug: "gallery", + fieldPath: "gallery[1]", + occurrenceIndex: 0, + referenceType: "image_field", + mediaId: "media-b", + provider: "local", + providerAssetId: "media-b", + mediaKind: "image", + mimeType: "image/png", + }, + { + fieldSlug: "downloads", + fieldPath: "downloads[0]", + occurrenceIndex: 0, + referenceType: "file_field", + mediaId: "media-doc", + provider: "local", + providerAssetId: "media-doc", + mediaKind: "document", + mimeType: "application/pdf", + }, + ]); + }); + it("extracts legacy bare local IDs and skips URLs or internal file routes", () => { const occurrences = extractMediaUsageOccurrences({ fields: [ diff --git a/packages/core/tests/unit/schema/zod-generator.test.ts b/packages/core/tests/unit/schema/zod-generator.test.ts index fc982b98d8..e35fa77243 100644 --- a/packages/core/tests/unit/schema/zod-generator.test.ts +++ b/packages/core/tests/unit/schema/zod-generator.test.ts @@ -281,6 +281,96 @@ describe("Zod Generator", () => { expect(schema.parse(validImage)).toMatchObject(validImage); }); + describe("multiple media fields (validation.multiple)", () => { + function makeImageField(validation?: Field["validation"]): Field { + return { + id: "f1", + collectionId: "c1", + slug: "gallery", + label: "Gallery", + type: "image", + columnType: "TEXT", + required: true, + unique: false, + validation, + sortOrder: 0, + createdAt: new Date().toISOString(), + }; + } + + it("should accept an array of image values when multiple is true", () => { + const schema = generateFieldSchema(makeImageField({ multiple: true })); + const gallery = [ + { id: "img1", alt: "First" }, + { id: "img2", alt: "Second" }, + ]; + expect(schema.parse(gallery)).toMatchObject(gallery); + }); + + it("should reject invalid items inside a multiple image array", () => { + const schema = generateFieldSchema(makeImageField({ multiple: true })); + expect(() => schema.parse([{ id: "img1" }, { alt: "missing id" }])).toThrow(); + }); + + it("should wrap a legacy single object when multiple is turned on", () => { + // A field upgraded from single to multiple still has `{...}` values + // stored from before the toggle. The admin re-sends what it loaded + // on autosave (#867), so the single shape must round-trip. + const schema = generateFieldSchema(makeImageField({ multiple: true })); + expect(schema.parse({ id: "img1", alt: "Old" })).toMatchObject([ + { id: "img1", alt: "Old" }, + ]); + }); + + it("should unwrap a one-element array when multiple is turned off", () => { + // The mirror of the toggle-on case: a field switched back to single + // may hold `[{...}]` values saved while multiple was on. + const schema = generateFieldSchema(makeImageField()); + expect(schema.parse([{ id: "img1" }])).toMatchObject({ id: "img1" }); + // Arrays with more than one item cannot be silently truncated. + expect(() => schema.parse([{ id: "a" }, { id: "b" }])).toThrow(); + }); + + it("should apply minItems/maxItems to multiple media fields", () => { + const schema = generateFieldSchema( + makeImageField({ multiple: true, minItems: 2, maxItems: 3 }), + ); + expect(() => schema.parse([{ id: "a" }])).toThrow(); + expect(schema.parse([{ id: "a" }, { id: "b" }])).toHaveLength(2); + expect(() => schema.parse([{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }])).toThrow(); + }); + + it("should accept an array of file values when multiple is true", () => { + const field: Field = { + id: "f1", + collectionId: "c1", + slug: "downloads", + label: "Downloads", + type: "file", + columnType: "TEXT", + required: true, + unique: false, + validation: { multiple: true }, + sortOrder: 0, + createdAt: new Date().toISOString(), + }; + const schema = generateFieldSchema(field); + const files = [ + { id: "file1", filename: "a.pdf" }, + { id: "file2", filename: "b.pdf" }, + ]; + expect(schema.parse(files)).toMatchObject(files); + }); + + it("should keep nullish round-trips working for non-required multiple fields", () => { + const field = makeImageField({ multiple: true }); + field.required = false; + const schema = generateFieldSchema(field); + expect(schema.parse(undefined)).toBe(undefined); + expect(schema.parse(null)).toBe(null); + }); + }); + it("should make field optional when required is false", () => { const field: Field = { id: "f1", @@ -560,6 +650,48 @@ describe("Zod Generator", () => { expect(ts).toContain("bylines?: ContentBylineCredit[];"); expect(ts).toContain("terms?: Record;"); }); + + it("should emit array types for multiple media fields", () => { + const collection: CollectionWithFields = { + id: "c1", + slug: "events", + label: "Events", + supports: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + fields: [ + { + id: "f1", + collectionId: "c1", + slug: "gallery", + label: "Gallery", + type: "image", + columnType: "TEXT", + required: false, + unique: false, + validation: { multiple: true }, + sortOrder: 0, + createdAt: new Date().toISOString(), + }, + { + id: "f2", + collectionId: "c1", + slug: "cover", + label: "Cover", + type: "image", + columnType: "TEXT", + required: false, + unique: false, + sortOrder: 1, + createdAt: new Date().toISOString(), + }, + ], + }; + + const ts = generateTypeScript(collection); + expect(ts).toContain("gallery?: Array<{ id: string;"); + expect(ts).toContain("cover?: { id: string;"); + }); }); describe("interface names derive from the singularized slug", () => {