/** * SEO Settings sub-page * * Title separator, search engine verification codes, and robots.txt. */ import { Button, Input, InputArea, Label } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; 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, type MediaItem } from "../../lib/api"; import { EditorHeader } from "../EditorHeader"; import { MediaPickerModal } from "../MediaPickerModal"; import { BackToSettingsLink } from "./BackToSettingsLink.js"; export function SeoSettings() { const { t } = useLingui(); const queryClient = useQueryClient(); const { data: settings, isLoading } = useQuery({ queryKey: ["settings"], queryFn: fetchSettings, staleTime: Infinity, }); const [formData, setFormData] = React.useState>({}); const [saveStatus, setSaveStatus] = React.useState<{ type: "success" | "error"; message: string; } | null>(null); const [ogImagePickerOpen, setOgImagePickerOpen] = React.useState(false); React.useEffect(() => { if (settings) setFormData(settings); }, [settings]); React.useEffect(() => { if (saveStatus) { const timer = setTimeout(setSaveStatus, 3000, null); return () => clearTimeout(timer); } }, [saveStatus]); const saveMutation = useMutation({ mutationFn: (data: Partial) => updateSettings(data), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["settings"] }); setSaveStatus({ type: "success", message: t`SEO settings saved` }); }, onError: (error) => { setSaveStatus({ type: "error", message: error instanceof Error ? error.message : t`Failed to save settings`, }); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); saveMutation.mutate(formData); }; const handleSeoChange = (key: string, value: unknown) => { setFormData((prev) => ({ ...prev, seo: { ...prev.seo, [key]: value, }, })); }; 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 (

{t`SEO Settings`}

{t`Loading settings...`}

); } return (
{/* Sticky header — see GeneralSettings for the same pattern. */} } actions={ } >

{t`SEO Settings`}

{/* Status banner */} {saveStatus && (
{saveStatus.type === "success" ? ( ) : ( )} {saveStatus.message}
)}

{t`Search Engine Optimization`}

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. */}

{t`Used as the fallback Open Graph image when a page has none. Recommended size: 1200×630.`}

{formData.seo?.defaultOgImage?.mediaId ? (
{formData.seo.defaultOgImage.url ? ( {formData.seo.defaultOgImage.alt ) : (
)}
) : ( )}
handleSeoChange("googleVerification", e.target.value)} description={t`Meta tag content for Google Search Console verification`} /> handleSeoChange("bingVerification", e.target.value)} description={t`Meta tag content for Bing Webmaster Tools verification`} /> handleSeoChange("robotsTxt", e.target.value)} rows={5} description={t`Custom robots.txt content. Leave empty to use the default.`} />
{/* Save Button */}
{/* 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. */}
); } export default SeoSettings;