Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/shiny-seals-make.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@emdash-cms/admin": patch
"emdash": patch
---

Fixes the `file` field type rendering as a plain text input in the content editor. Adds a `FileFieldRenderer` that opens the media picker (with mime filter disabled) so any file type can be attached. Also adds a `hideUrlInput` prop to `MediaPickerModal` so non-image pickers can hide the image-specific "Insert from URL" input.

Aligns the Zod schema and generated TypeScript types for `image` and `file` fields with the shape the admin actually stores: `provider?`, `meta?` (for both), and `previewUrl?` (for image). Previously these fields were stripped on validation and missing from generated types, so site code could not reliably resolve local media URLs from `meta.storageKey`.
184 changes: 183 additions & 1 deletion packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Eye,
Image as ImageIcon,
MagnifyingGlass,
Paperclip,
X,
Trash,
ArrowsInSimple,
Expand All @@ -37,8 +38,9 @@ import type {
} from "../lib/api";
import { getPreviewUrl, getDraftStatus } from "../lib/api";
import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js";
import { formatFileSize, getFileIcon } from "../lib/media-utils";
import { usePluginAdmins } from "../lib/plugin-context.js";
import { contentUrl } from "../lib/url.js";
import { contentUrl, isSafeUrl } from "../lib/url.js";
import { cn, slugify } from "../lib/utils";
import { ArrowPrev } from "./ArrowIcons.js";
import { BlockKitFieldWidget } from "./BlockKitFieldWidget.js";
Expand Down Expand Up @@ -1342,6 +1344,24 @@ function FieldRenderer({
);
}

case "file": {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs fixing: The comment says value may be "a legacy string URL", but the very next line casts any string to undefined before passing it to FileFieldRenderer. This makes existing string data invisible in the UI — a regression from the previous behavior where it at least rendered in a text input. For parity with ImageFieldRenderer (which accepts string | undefined and renders legacy URLs), consider passing raw value to FileFieldRenderer and handling strings there (e.g., showing them as a plain link with a clear button), or update the comment to remove the legacy-string claim if dropping support is intentional.

// 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
// "legacy string" data to preserve here.
const fileValue =
value != null && typeof value === "object" ? (value as FileFieldValue) : undefined;
return (
<FileFieldRenderer
id={id}
label={label}
value={fileValue}
onChange={handleChange}
required={field.required}
Comment on lines +1354 to +1360
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new file case claims value may be a legacy string URL, but string values are treated as undefined and the existing value won’t render (and required will show an error even though data exists). Either handle legacy string values (e.g., render as a link / allow clearing) or update the comment and ensure required-state logic doesn’t misfire for existing string data.

Suggested change
return (
<FileFieldRenderer
id={id}
label={label}
value={fileValue}
onChange={handleChange}
required={field.required}
const legacyFileUrl = typeof value === "string" ? value : undefined;
const resolvedLegacyFileUrl = legacyFileUrl
? isSafeUrl(legacyFileUrl)
? legacyFileUrl
: contentUrl(legacyFileUrl)
: undefined;
const legacyFileName =
legacyFileUrl?.split("/").filter(Boolean).pop() ?? legacyFileUrl ?? "";
if (resolvedLegacyFileUrl) {
return (
<fieldset>
<Label className={labelClass}>{label}</Label>
<div className="mt-2 flex items-center justify-between gap-3 rounded-md border p-3">
<div className="flex min-w-0 items-center gap-2">
<Paperclip className="size-4 shrink-0 text-muted-foreground" />
<a
href={resolvedLegacyFileUrl}
target="_blank"
rel="noreferrer"
className="flex min-w-0 items-center gap-1 truncate text-sm text-blue-600 hover:underline"
>
<span className="truncate">{legacyFileName}</span>
<ArrowSquareOut className="size-4 shrink-0" />
</a>
</div>
<Button type="button" onClick={() => handleChange(undefined)}>
<X className="size-4" />
</Button>
</div>
</fieldset>
);
}
return (
<FileFieldRenderer
id={id}
label={label}
value={fileValue}
onChange={handleChange}
required={field.required && !legacyFileUrl}

Copilot uses AI. Check for mistakes.
/>
);
}

case "repeater": {
const validation = field.validation;
const subFields = (validation?.subFields ?? []) as Array<{
Expand Down Expand Up @@ -1666,6 +1686,168 @@ function ImageFieldRenderer({
);
}

/**
* File field value — matches the "file" shape validated by the Zod generator:
* { id, provider?, src?, filename?, mimeType?, size?, meta? }
*/
interface FileFieldValue {
id: string;
/** Provider ID (e.g., "local", "s3") */
provider?: string;
/** Direct URL for non-local media */
src?: string;
filename?: string;
mimeType?: string;
size?: number;
/** Provider-specific metadata */
meta?: Record<string, unknown>;
}

interface FileFieldRendererProps {
id?: string;
label: string;
value: FileFieldValue | undefined;
onChange: (value: FileFieldValue | null) => void;
required?: boolean;
}

/**
* File field with media picker
*
* Like ImageFieldRenderer but for arbitrary file types. Shows a mime-type-appropriate
* icon, filename, and size instead of an image preview.
*/
function FileFieldRenderer({ id, label, value, onChange, required }: FileFieldRendererProps) {
const { t } = useLingui();
const [pickerOpen, setPickerOpen] = React.useState(false);

// Normalize value to derive display info.
// For local files, prefer meta.storageKey; fall back to value.src when it's an
// internal media path; finally fall back to value.id so local files remain
// clickable even when metadata is sparse. For external providers, use value.src
// but only when it's an http(s) URL — a hostile provider plugin could otherwise
// return a data: or javascript: URL that gets rendered as a clickable link.
const normalized = React.useMemo(() => {
if (!value) return null;
const isLocal = !value.provider || value.provider === "local";
const storageKey =
typeof value.meta?.storageKey === "string" ? value.meta.storageKey : undefined;
const localSrc =
typeof value.src === "string" && value.src.startsWith("/_emdash/") ? value.src : undefined;
// Storage keys come from server-controlled paths today, but the Zod schema
// now lets clients write arbitrary `meta.storageKey` strings via the content
// API. Encode before interpolating so attacker-shaped values can't escape
// the path with `?` or `#`.
const localUrl = isLocal
? storageKey
? `/_emdash/api/media/file/${encodeURIComponent(storageKey)}`
: (localSrc ?? `/_emdash/api/media/file/${encodeURIComponent(value.id)}`)
: undefined;
const externalUrl = !isLocal && value.src && isSafeUrl(value.src) ? value.src : undefined;
return {
Comment on lines +1731 to +1747
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FileFieldRenderer only builds a link for local files when meta.storageKey is present, and ignores value.src entirely for local providers. Since the file schema/type allows src? and meta?, a valid local file value without meta.storageKey (or one that only has a local src like /_emdash/api/media/file/...) will render without a download link. Consider falling back to value.src when it’s an internal relative media URL, and/or falling back to value.id (matching the existing ImageFieldRenderer behavior) so local files remain clickable even when meta.storageKey is missing.

Copilot uses AI. Check for mistakes.
displayUrl: localUrl ?? externalUrl,
filename: value.filename || t`Untitled file`,
mimeType: value.mimeType || "",
size: value.size,
};
}, [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,
});
};

const handleRemove = () => {
onChange(null);
};

const hasMime = !!normalized?.mimeType;
const size = typeof normalized?.size === "number" ? normalized.size : undefined;
const hasSize = size !== undefined;

return (
<div id={id}>
<Label>{label}</Label>
{normalized ? (
<div className="mt-2 flex items-center gap-3 rounded-lg border p-3">
<span className="text-3xl" aria-hidden="true">
{getFileIcon(normalized.mimeType)}
</span>
<div className="flex-1 min-w-0">
{normalized.displayUrl ? (
<a
href={normalized.displayUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium truncate block hover:underline"
>
{normalized.filename}
</a>
) : (
<p className="text-sm font-medium truncate">{normalized.filename}</p>
)}
{(hasMime || hasSize) && (
<p className="text-xs text-kumo-subtle">
{hasMime ? normalized.mimeType : null}
{hasMime && hasSize ? " • " : null}
{hasSize ? formatFileSize(size) : null}
</p>
)}
</div>
<div className="flex gap-1">
<Button type="button" size="sm" variant="secondary" onClick={() => setPickerOpen(true)}>
{t`Change`}
</Button>
<Button
type="button"
shape="square"
variant="destructive"
className="h-8 w-8"
onClick={handleRemove}
aria-label={t`Remove ${label}`}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
className="mt-2 w-full h-32 border-dashed"
onClick={() => setPickerOpen(true)}
aria-label={t`Select ${label}`}
>
<div className="flex flex-col items-center gap-2 text-kumo-subtle">
<Paperclip className="h-8 w-8" />
<span>{t`Select file`}</span>
</div>
</Button>
)}
<MediaPickerModal
open={pickerOpen}
onOpenChange={setPickerOpen}
onSelect={handleSelect}
mimeTypeFilter=""
hideUrlInput
mediaKind="file"
title={t`Select ${label}`}
/>
{required && !normalized && (
<p className="text-sm text-kumo-danger mt-1">{t`This field is required`}</p>
)}
</div>
);
}

/**
* Author selector component for editors and above
*/
Expand Down
100 changes: 62 additions & 38 deletions packages/admin/src/components/MediaPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { Button, Dialog, Input, Label, Loader } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { Upload, Image, Check, Globe, MagnifyingGlass } from "@phosphor-icons/react";
import { Upload, Image, Check, Globe, MagnifyingGlass, Paperclip } from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
Expand Down Expand Up @@ -42,6 +42,18 @@ export interface MediaPickerModalProps {
/** Filter by mime type prefix, e.g. "image/" */
mimeTypeFilter?: string;
title?: string;
/**
* Hide the "Insert from URL" input. Defaults to false.
* The URL input probes image dimensions and is only meaningful for image pickers,
* so non-image pickers (e.g. generic file pickers) should hide it.
*/
hideUrlInput?: boolean;
Comment on lines +45 to +50
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hideUrlInput enables using this modal for non-image pickers, but several user-visible strings inside the modal are still image-specific (e.g. default title Select Image, empty-state copy/button text like “Upload an image…” / “Upload Image”). Consider parameterizing those strings based on mimeTypeFilter (or adding a dedicated prop) so the file picker UX doesn’t talk about images.

Copilot uses AI. Check for mistakes.
/**
* What kind of media this picker is for. Drives user-facing copy
* (default title, empty-state message, upload button label, empty-state icon).
* Defaults to "image" — set to "file" for generic file pickers.
*/
mediaKind?: "image" | "file";
}

/**
Expand All @@ -66,9 +78,17 @@ export function MediaPickerModal({
onSelect,
mimeTypeFilter = "image/",
title: providedTitle,
hideUrlInput = false,
mediaKind = "image",
}: MediaPickerModalProps) {
const { t } = useLingui();
const title = providedTitle ?? t`Select Image`;
const isFileKind = mediaKind === "file";
const title = providedTitle ?? (isFileKind ? t`Select File` : t`Select Image`);
const emptyStateUploadHint = isFileKind
? t`Upload a file to get started`
: t`Upload an image to get started`;
const emptyStateUploadCta = isFileKind ? t`Upload File` : t`Upload Image`;
const EmptyStateIcon = isFileKind ? Paperclip : Image;
const queryClient = useQueryClient();
const [selectedItem, setSelectedItem] = React.useState<SelectedMedia | null>(null);
const [activeProvider, setActiveProvider] = React.useState<string>("local");
Expand Down Expand Up @@ -362,41 +382,45 @@ export function MediaPickerModal({
/>
</div>

{/* URL Input */}
<div className="border-b pb-4">
<Label>{t`Insert from URL`}</Label>
<div className="flex gap-2 mt-1.5">
<div className="flex-1 relative">
<Globe className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
type="url"
placeholder="https://example.com/image.jpg"
aria-label={t`Image URL`}
value={imageUrl}
onChange={(e) => {
setImageUrl(e.target.value);
setUrlError(null);
}}
onKeyDown={handleUrlKeyDown}
className="ps-9"
/>
{/* URL Input (image pickers only — probes image dimensions) */}
{!hideUrlInput && (
<>
<div className="border-b pb-4">
<Label>{t`Insert from URL`}</Label>
<div className="flex gap-2 mt-1.5">
<div className="flex-1 relative">
<Globe className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
type="url"
placeholder="https://example.com/image.jpg"
aria-label={t`Image URL`}
value={imageUrl}
onChange={(e) => {
setImageUrl(e.target.value);
setUrlError(null);
}}
onKeyDown={handleUrlKeyDown}
className="ps-9"
/>
</div>
<Button onClick={handleUrlSubmit} disabled={!imageUrl.trim() || isProbing}>
{isProbing ? <Loader size="sm" /> : t`Insert`}
</Button>
</div>
{urlError && <p className="text-sm text-kumo-danger mt-1">{urlError}</p>}
</div>
<Button onClick={handleUrlSubmit} disabled={!imageUrl.trim() || isProbing}>
{isProbing ? <Loader size="sm" /> : t`Insert`}
</Button>
</div>
{urlError && <p className="text-sm text-kumo-danger mt-1">{urlError}</p>}
</div>

{/* Divider with "or" */}
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-kumo-base px-2 text-kumo-subtle">{t`or choose from library`}</span>
</div>
</div>
{/* Divider with "or" */}
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-kumo-base px-2 text-kumo-subtle">{t`or choose from library`}</span>
</div>
</div>
</>
)}

{/* Provider Tabs */}
{providerTabs.length > 1 && (
Expand Down Expand Up @@ -487,13 +511,13 @@ export function MediaPickerModal({
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<Image className="h-12 w-12 text-kumo-subtle mb-4" aria-hidden="true" />
<EmptyStateIcon className="h-12 w-12 text-kumo-subtle mb-4" aria-hidden="true" />
<h3 className="text-lg font-medium">{t`No media found`}</h3>
<p className="text-sm text-kumo-subtle mt-1">
{canSearch && searchQuery
? t`Try a different search term`
: canUpload
? t`Upload an image to get started`
? emptyStateUploadHint
: t`No media available from this provider`}
</p>
{canUpload && !searchQuery && (
Expand All @@ -502,7 +526,7 @@ export function MediaPickerModal({
icon={<Upload />}
onClick={() => fileInputRef.current?.click()}
>
{t`Upload Image`}
{emptyStateUploadCta}
</Button>
)}
</div>
Expand Down
Loading
Loading