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
14 changes: 14 additions & 0 deletions .changeset/site-default-og-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds support for a site-wide default Open Graph image. The setting is exposed in the admin SEO settings page (Settings -> SEO -> Default Social Image), resolved to a URL on read by `getSiteSettings()`, and automatically emitted as `og:image` / `twitter:image` (and BlogPosting JSON-LD `image`) by `EmDashHead.astro` whenever a page has no image of its own. Per-page images still take precedence.

This wires up an existing data model that was previously defined in the schema and MCP tools but never used: stored values were not resolved and no template path read the setting.

Emitted URLs are absolutized using `SiteSettings.url`, the page's `siteUrl`, or the request origin so crawlers and JSON-LD consumers that reject relative URLs work correctly.

Also adds a `localOnly` prop to `MediaPickerModal` that suppresses the "Insert from URL" input and external provider tabs. Used by SEO settings to ensure the picker only returns locally-stored media (since the setting only persists a local `mediaId`).

Media metadata updates and deletes now invalidate the worker-scoped site-settings cache, so resolved logo/favicon/default-social-image URLs and dimensions stay in sync with the underlying media row.
45 changes: 35 additions & 10 deletions packages/admin/src/components/MediaPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ export interface MediaPickerModalProps {
mimeTypeFilters?: string[];
/** `_emdash_fields` row id for server-side MIME widening. */
fieldId?: string;
/**
* Restrict the picker to the local Library only — hides the "Insert from URL"
* input and suppresses external provider tabs.
*
* Use this for fields whose storage model only persists a local `mediaId`.
* Selecting an external URL or provider item would return an item the
* server cannot later resolve back to a URL (the `id` is either empty
* for "Insert from URL" or a provider-namespaced string that won't match
* a row in the `media` table). Site settings (logo, favicon,
* `seo.defaultOgImage`) are the canonical callers.
*/
localOnly?: boolean;
}

/**
Expand Down Expand Up @@ -110,6 +122,7 @@ export function MediaPickerModal({
title: providedTitle,
hideUrlInput = false,
mediaKind = "image",
localOnly = false,
}: MediaPickerModalProps) {
const { t } = useLingui();
const isFileKind = mediaKind === "file";
Expand Down Expand Up @@ -144,7 +157,11 @@ export function MediaPickerModal({
Record<string, { width: number; height: number }>
>({});

// Reset state when modal opens
// Reset state when modal opens, or when `localOnly` flips on while it's
// already open. Without the `localOnly` dependency a parent that toggles
// the prop mid-session could leave `activeProvider` on a non-local tab
// (the tab UI is suppressed, but the selection state and provider-media
// query would still target the external provider).
React.useEffect(() => {
if (open) {
setSelectedItem(null);
Expand All @@ -155,13 +172,16 @@ export function MediaPickerModal({
setUploadError(null);
setProviderDimensions({});
}
}, [open]);
}, [open, localOnly]);

// Fetch available providers
// Fetch available providers — skipped when `localOnly` is set since the
// list isn't used (provider tabs are suppressed and the active provider
// stays "local"). Avoids a request to /providers on every modal open
// when we'll just throw the result away.
const { data: providers } = useQuery({
queryKey: ["media-providers"],
queryFn: fetchMediaProviders,
enabled: open,
enabled: open && !localOnly,
// Default to just local if fetch fails
placeholderData: [],
});
Expand Down Expand Up @@ -190,7 +210,10 @@ export function MediaPickerModal({
enabled: open && activeProvider === "local",
});

// Fetch provider media list
// Fetch provider media list. Belt-and-suspenders: the reset effect
// forces `activeProvider` back to "local" when `localOnly` is true, but
// also gate this query directly so a stale render can't fire an
// external request between state updates.
const { data: providerData, isLoading: providerLoading } = useQuery({
queryKey: ["provider-media", activeProvider, filters?.join(",") ?? "", searchQuery],
queryFn: () =>
Expand All @@ -199,7 +222,7 @@ export function MediaPickerModal({
limit: 50,
query: searchQuery || undefined,
}),
enabled: open && activeProvider !== "local",
enabled: open && !localOnly && activeProvider !== "local",
});

const isLoading = activeProvider === "local" ? localLoading : providerLoading;
Expand Down Expand Up @@ -397,20 +420,22 @@ export function MediaPickerModal({
const canSearch = activeProviderInfo?.capabilities.search ?? false;

// Build provider tabs - always show local first, then add external providers
// Filter out "local" from API response since we add it manually
// Filter out "local" from API response since we add it manually.
// When `localOnly` is set, suppress external providers entirely so the
// picker can only return locally-stored media (see prop docs).
const providerTabs = React.useMemo(() => {
const tabs: Array<{ id: string; name: string; icon?: string }> = [
{ id: "local", name: "Library", icon: undefined },
];
if (providers) {
if (providers && !localOnly) {
for (const p of providers) {
if (p.id !== "local") {
tabs.push({ id: p.id, name: p.name, icon: p.icon });
}
}
}
return tabs;
}, [providers]);
}, [providers, localOnly]);

return (
<Dialog.Root open={open} onOpenChange={handleClose}>
Expand All @@ -437,7 +462,7 @@ export function MediaPickerModal({
</div>

{/* URL Input (image pickers only — probes image dimensions) */}
{!hideUrlInput && (
{!hideUrlInput && !localOnly && (
<>
<div className="border-b pb-4">
<Label>{t`Insert from URL`}</Label>
Expand Down
60 changes: 45 additions & 15 deletions packages/admin/src/components/settings/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,16 +172,31 @@ export function GeneralSettings() {
description={t`The public URL of your site (used for canonical links and sitemaps)`}
/>

{/* Logo Picker */}
{/* Logo Picker --
"configured" gates on `mediaId`, not `url`, so an orphaned
reference (media row deleted, or a stale provider id stored
pre-localOnly fix) still renders Remove. Otherwise the user
would see "Select Logo" and silently re-save the dangling
`mediaId` on any unrelated change. */}
<div>
<Label>{t`Logo`}</Label>
{formData.logo?.url ? (
{formData.logo?.mediaId ? (
<div className="mt-2 space-y-2">
<img
src={formData.logo.url}
alt={formData.logo.alt || t`Logo`}
className="h-16 rounded border bg-kumo-tint object-contain p-2"
/>
{formData.logo.url ? (
<img
src={formData.logo.url}
alt={formData.logo.alt || t`Logo`}
className="h-16 rounded border bg-kumo-tint object-contain p-2"
/>
) : (
<div
className="flex min-h-16 items-center gap-2 rounded border border-dashed bg-kumo-tint px-3 py-2 text-sm text-kumo-subtle"
role="status"
>
<WarningCircle className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span>{t`The referenced logo is no longer available. Pick a new one or remove the reference.`}</span>
</div>
)}
<div className="flex gap-2">
<Button
type="button"
Expand Down Expand Up @@ -216,16 +231,26 @@ export function GeneralSettings() {
)}
</div>

{/* Favicon Picker */}
{/* Favicon Picker — see Logo Picker for the orphan-state rationale. */}
<div>
<Label>{t`Favicon`}</Label>
{formData.favicon?.url ? (
{formData.favicon?.mediaId ? (
<div className="mt-2 space-y-2">
<img
src={formData.favicon.url}
alt={t`Favicon`}
className="h-8 w-8 rounded border bg-kumo-tint object-contain p-1"
/>
{formData.favicon.url ? (
<img
src={formData.favicon.url}
alt={t`Favicon`}
className="h-8 w-8 rounded border bg-kumo-tint object-contain p-1"
/>
) : (
<div
className="flex min-h-8 items-center gap-2 rounded border border-dashed bg-kumo-tint px-2 py-1 text-xs text-kumo-subtle"
role="status"
>
<WarningCircle className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
<span>{t`Referenced favicon unavailable.`}</span>
</div>
)}
<div className="flex gap-2">
<Button
type="button"
Expand Down Expand Up @@ -298,19 +323,24 @@ export function GeneralSettings() {
</div>
</form>

{/* Media Picker Modals */}
{/* Media Picker Modals --
localOnly: site settings only persist a local `mediaId`. URL/provider
selections would be stripped on save, leaving an unresolvable reference.
See MediaPickerModalProps.localOnly. */}
<MediaPickerModal
open={logoPickerOpen}
onOpenChange={setLogoPickerOpen}
onSelect={handleLogoSelect}
mimeTypeFilter="image/"
localOnly
title={t`Select Logo`}
/>
<MediaPickerModal
open={faviconPickerOpen}
onOpenChange={setFaviconPickerOpen}
onSelect={handleFaviconSelect}
mimeTypeFilter="image/"
localOnly
title={t`Select Favicon`}
/>
</div>
Expand Down
110 changes: 107 additions & 3 deletions packages/admin/src/components/settings/SeoSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,22 @@
* Title separator, search engine verification codes, and robots.txt.
*/

import { Button, Input, InputArea } from "@cloudflare/kumo";
import { Button, Input, InputArea, Label } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { FloppyDisk, CheckCircle, WarningCircle, MagnifyingGlass } from "@phosphor-icons/react";
import {
FloppyDisk,
CheckCircle,
WarningCircle,
MagnifyingGlass,
Upload,
X,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as React from "react";

import { fetchSettings, updateSettings, type SiteSettings } from "../../lib/api";
import { fetchSettings, updateSettings, type SiteSettings, type MediaItem } from "../../lib/api";
import { EditorHeader } from "../EditorHeader";
import { MediaPickerModal } from "../MediaPickerModal";
import { BackToSettingsLink } from "./BackToSettingsLink.js";

export function SeoSettings() {
Expand All @@ -29,6 +37,7 @@ export function SeoSettings() {
type: "success" | "error";
message: string;
} | null>(null);
const [ogImagePickerOpen, setOgImagePickerOpen] = React.useState(false);

React.useEffect(() => {
if (settings) setFormData(settings);
Expand Down Expand Up @@ -70,6 +79,24 @@ export function SeoSettings() {
}));
};

const handleDefaultOgImageSelect = (media: MediaItem) => {
setFormData((prev) => ({
...prev,
seo: {
...prev.seo,
defaultOgImage: { mediaId: media.id, alt: media.alt || "", url: media.url },
},
}));
setOgImagePickerOpen(false);
};

const handleDefaultOgImageRemove = () => {
setFormData((prev) => ({
...prev,
seo: { ...prev.seo, defaultOgImage: undefined },
}));
};

if (isLoading) {
return (
<div className="space-y-6">
Expand Down Expand Up @@ -134,6 +161,68 @@ export function SeoSettings() {
onChange={(e) => handleSeoChange("titleSeparator", e.target.value)}
description={t`Character between page title and site name (e.g., "My Post | My Site")`}
/>

{/* Default OG Image Picker --
"configured" is determined by presence of `mediaId`, not `url`.
When the referenced media row is deleted, the resolver returns the
bare ref without a URL; we still need to show Remove so the user can
clear the dangling reference. */}
<div>
<Label>{t`Default Social Image`}</Label>
<p className="mt-1 text-sm text-kumo-subtle">
{t`Used as the fallback Open Graph image when a page has none. Recommended size: 1200×630.`}
</p>
{formData.seo?.defaultOgImage?.mediaId ? (
<div className="mt-2 space-y-2">
{formData.seo.defaultOgImage.url ? (
<img
src={formData.seo.defaultOgImage.url}
alt={formData.seo.defaultOgImage.alt || t`Default social image`}
className="h-32 rounded border bg-kumo-tint object-contain p-2"
/>
) : (
<div
className="flex min-h-32 items-center gap-2 rounded border border-dashed bg-kumo-tint px-3 py-2 text-sm text-kumo-subtle"
role="status"
>
<WarningCircle className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span>{t`The referenced image is no longer available. Pick a new one or remove the reference.`}</span>
</div>
)}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
icon={<Upload />}
onClick={() => setOgImagePickerOpen(true)}
>
{t`Change Image`}
</Button>
<Button
type="button"
variant="outline"
size="sm"
icon={<X />}
onClick={handleDefaultOgImageRemove}
>
{t`Remove`}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
icon={<Upload />}
onClick={() => setOgImagePickerOpen(true)}
className="mt-2"
>
{t`Select Image`}
</Button>
)}
</div>

<Input
label={t`Google Verification`}
value={formData.seo?.googleVerification || ""}
Expand Down Expand Up @@ -163,6 +252,21 @@ export function SeoSettings() {
</Button>
</div>
</form>

{/* Media Picker Modal --
localOnly: storage shape is `{ mediaId }`, so URL/provider selections would
yield references the server cannot resolve. See MediaPickerModalProps.localOnly.
mimeTypeFilters: social-card scrapers expect rasterised content; SVG also gets
served as `Content-Disposition: attachment` by the media file route, making it
unusable as an OG image. */}
<MediaPickerModal
open={ogImagePickerOpen}
onOpenChange={setOgImagePickerOpen}
onSelect={handleDefaultOgImageSelect}
mimeTypeFilters={["image/jpeg", "image/png", "image/webp", "image/gif"]}
localOnly
title={t`Select Default Social Image`}
/>
</div>
);
}
Expand Down
Loading
Loading