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
5 changes: 5 additions & 0 deletions .changeset/public-women-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---

Fixes the long tail of untranslated English strings in the admin UI: settings panels, marketplace, sandboxed-plugin host, auth flows, taxonomy/menu management, and lib/api fallback messages. After this PR, EmDash admin UI is fully localizable across all known surfaces.
6 changes: 4 additions & 2 deletions packages/admin/src/components/BlockKitFieldWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Input, Switch } from "@cloudflare/kumo";
import type { Element } from "@emdash-cms/blocks";
import { useLingui } from "@lingui/react/macro";
import * as React from "react";

import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField";
Expand Down Expand Up @@ -65,6 +66,7 @@ function BlockKitFieldElement({
value: unknown;
onChange: (actionId: string, value: unknown) => void;
}) {
const { t } = useLingui();
switch (element.type) {
case "text_input":
return (
Expand Down Expand Up @@ -105,7 +107,7 @@ function BlockKitFieldElement({
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(element.action_id, e.target.value)}
>
<option value="">Select...</option>
<option value="">{t`Select...`}</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
Expand All @@ -129,7 +131,7 @@ function BlockKitFieldElement({
default:
return (
<div className="text-sm text-kumo-subtle">
Unsupported widget element type: {(element as { type: string }).type}
{t`Unsupported widget element type: ${(element as { type: string }).type}`}
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ export function ContentEditor({
<div
className="flex items-center text-xs text-kumo-subtle"
role="status"
aria-label="Autosave status"
aria-label={t`Autosave status`}
aria-live="polite"
>
{isAutosaving ? (
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/components/DeviceAuthorizePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function DeviceAuthorizePage() {
body: JSON.stringify({ user_code: trimmed, action: "approve" }),
});

const data = await parseApiResponse<{ authorized: boolean }>(res, "Authorization failed");
const data = await parseApiResponse<{ authorized: boolean }>(res, t`Authorization failed`);
setPageState(data.authorized ? "success" : "denied");
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : "Network error");
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/components/FieldEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
label={t`Options (one per line)`}
value={options}
onChange={(e) => setField("options", e.target.value)}
placeholder={"Option 1\nOption 2\nOption 3"}
placeholder={t`Option 1\nOption 2\nOption 3`}
rows={5}
/>
)}
Expand Down
15 changes: 8 additions & 7 deletions packages/admin/src/components/MarketplaceBrowse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
*/

import { Badge, Button } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import type { MessageDescriptor } from "@lingui/core";
import { msg, plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
MagnifyingGlass,
Expand Down Expand Up @@ -37,11 +38,11 @@ function isSortOption(value: string): value is SortOption {
return SORT_OPTIONS.has(value);
}

const SORT_LABELS: Record<SortOption, string> = {
installs: "Most Popular",
updated: "Recently Updated",
created: "Newest",
name: "Name",
const SORT_LABELS: Record<SortOption, MessageDescriptor> = {
installs: msg`Most Popular`,
updated: msg`Recently Updated`,
created: msg`Newest`,
name: msg`Name`,
};

export interface MarketplaceBrowseProps {
Expand Down Expand Up @@ -123,7 +124,7 @@ export function MarketplaceBrowse({ installedPluginIds = new Set() }: Marketplac
>
{Object.entries(SORT_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
{t(label)}
</option>
))}
</select>
Expand Down
8 changes: 4 additions & 4 deletions packages/admin/src/components/MediaLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,12 @@ export function MediaLibrary({
if (activeProvider === "local") {
return {
id: "local",
name: "Library",
name: t`Library`,
capabilities: { browse: true, search: false, upload: true, delete: true },
} as MediaProviderInfo;
}
return providers?.find((p) => p.id === activeProvider);
}, [activeProvider, providers]);
}, [activeProvider, providers, t]);

// Update selected item when items change (e.g., after metadata update)
React.useEffect(() => {
Expand Down Expand Up @@ -199,7 +199,7 @@ export function MediaLibrary({
// Build provider tabs
const providerTabs = React.useMemo(() => {
const tabs: Array<{ id: string; name: string; icon?: string }> = [
{ id: "local", name: "Library", icon: undefined },
{ id: "local", name: t`Library`, icon: undefined },
];
if (providers) {
for (const p of providers) {
Expand All @@ -209,7 +209,7 @@ export function MediaLibrary({
}
}
return tabs;
}, [providers]);
}, [providers, t]);

// Get current items based on active provider
const currentItems = activeProvider === "local" ? items : [];
Expand Down
13 changes: 8 additions & 5 deletions packages/admin/src/components/MediaPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,17 @@ export interface MediaPickerModalProps {
/**
* Probe image URL to get dimensions
*/
function probeImageDimensions(url: string): Promise<{ width: number; height: number }> {
function probeImageDimensions(
url: string,
errorMessage: string,
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
reject(new Error("Failed to load image"));
reject(new Error(errorMessage));
};
img.src = url;
});
Expand Down Expand Up @@ -309,7 +312,7 @@ export function MediaPickerModal({
setUrlError(null);

try {
const dimensions = await probeImageDimensions(url.href);
const dimensions = await probeImageDimensions(url.href, t`Failed to load image`);
const externalItem: MediaItem = {
id: "",
filename: url.pathname.split("/").pop() || "external-image",
Expand Down Expand Up @@ -392,7 +395,7 @@ export function MediaPickerModal({
<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"
placeholder={t`https://example.com/image.jpg`}
aria-label={t`Image URL`}
value={imageUrl}
onChange={(e) => {
Expand Down Expand Up @@ -491,7 +494,7 @@ export function MediaPickerModal({
accept={mimeTypeFilter ? `${mimeTypeFilter}*` : undefined}
className="sr-only"
onChange={handleFileSelect}
aria-label="Upload file"
aria-label={t`Upload file`}
/>
</>
)}
Expand Down
7 changes: 6 additions & 1 deletion packages/admin/src/components/MenuList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
*/

import { Button, Dialog, Input, Toast, buttonVariants } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react/macro";
import { Plus, Pencil, Trash, List as ListIcon } from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
Expand Down Expand Up @@ -209,7 +211,10 @@ export function MenuList() {
) : null}
</h3>
<p className="text-sm text-kumo-subtle">
{menu.name} • {menu.itemCount || 0} items
<Trans>
{menu.name} •{" "}
{plural(menu.itemCount ?? 0, { one: "# item", other: "# items" })}
</Trans>
</p>
</div>
</Link>
Expand Down
6 changes: 3 additions & 3 deletions packages/admin/src/components/Redirects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,15 @@ function RedirectFormDialog({
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={t`Source path`}
placeholder="/old-page or /blog/[slug]"
placeholder={t`/old-page or /blog/[slug]`}
value={source}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSource(e.target.value)}
required
/>

<Input
label={t`Destination path`}
placeholder="/new-page or /articles/[slug]"
placeholder={t`/new-page or /articles/[slug]`}
value={destination}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDestination(e.target.value)}
required
Expand All @@ -158,7 +158,7 @@ function RedirectFormDialog({

<Input
label={t`Group (optional)`}
placeholder="e.g. import, blog"
placeholder={t`e.g. import, blog`}
value={groupName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGroupName(e.target.value)}
/>
Expand Down
8 changes: 5 additions & 3 deletions packages/admin/src/components/SandboxedPluginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { BlockRenderer } from "@emdash-cms/blocks";
import type { Block, BlockInteraction, BlockResponse } from "@emdash-cms/blocks";
import { useLingui } from "@lingui/react/macro";
import { CircleNotch, WarningCircle } from "@phosphor-icons/react";
import { useCallback, useEffect, useState } from "react";

Expand All @@ -18,6 +19,7 @@ interface SandboxedPluginPageProps {
}

export function SandboxedPluginPage({ pluginId, page }: SandboxedPluginPageProps) {
const { t } = useLingui();
const [blocks, setBlocks] = useState<Block[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
Expand All @@ -35,7 +37,7 @@ export function SandboxedPluginPage({ pluginId, page }: SandboxedPluginPageProps

if (!response.ok) {
const text = await response.text();
setError(`Plugin responded with ${response.status}: ${text}`);
setError(t`Plugin responded with ${response.status}: ${text}`);
return;
}

Expand All @@ -49,7 +51,7 @@ export function SandboxedPluginPage({ pluginId, page }: SandboxedPluginPageProps
setTimeout(setToast, 4000, null);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to communicate with plugin");
setError(err instanceof Error ? err.message : t`Failed to communicate with plugin`);
}
},
[pluginId],
Expand Down Expand Up @@ -84,7 +86,7 @@ export function SandboxedPluginPage({ pluginId, page }: SandboxedPluginPageProps
<div className="flex items-start gap-3">
<WarningCircle className="h-5 w-5 shrink-0 text-kumo-danger" />
<div>
<h3 className="font-semibold text-kumo-danger">Plugin Error</h3>
<h3 className="font-semibold text-kumo-danger">{t`Plugin Error`}</h3>
<p className="mt-1 text-sm text-kumo-subtle">{error}</p>
</div>
</div>
Expand Down
8 changes: 5 additions & 3 deletions packages/admin/src/components/SandboxedPluginWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { BlockRenderer } from "@emdash-cms/blocks";
import type { Block, BlockInteraction, BlockResponse } from "@emdash-cms/blocks";
import { useLingui } from "@lingui/react/macro";
import { CircleNotch } from "@phosphor-icons/react";
import { useCallback, useEffect, useState } from "react";

Expand All @@ -18,6 +19,7 @@ interface SandboxedPluginWidgetProps {
}

export function SandboxedPluginWidget({ pluginId, widgetId }: SandboxedPluginWidgetProps) {
const { t } = useLingui();
const [blocks, setBlocks] = useState<Block[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
Expand All @@ -32,7 +34,7 @@ export function SandboxedPluginWidget({ pluginId, widgetId }: SandboxedPluginWid
});

if (!response.ok) {
setError(`Plugin error (${response.status})`);
setError(t`Plugin error (${response.status})`);
return;
}

Expand All @@ -41,7 +43,7 @@ export function SandboxedPluginWidget({ pluginId, widgetId }: SandboxedPluginWid
setBlocks(data.blocks);
setError(null);
} catch {
setError("Failed to load widget");
setError(t`Failed to load widget`);
}
},
[pluginId],
Expand Down Expand Up @@ -75,7 +77,7 @@ export function SandboxedPluginWidget({ pluginId, widgetId }: SandboxedPluginWid
}

if (blocks.length === 0) {
return <p className="text-sm text-kumo-subtle">No content</p>;
return <p className="text-sm text-kumo-subtle">{t`No content`}</p>;
}

return <BlockRenderer blocks={blocks} onAction={handleAction} />;
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/components/SectionEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ function SectionEditorForm({ section, isSaving, onSave }: SectionEditorFormProps
label={t`Keywords`}
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
placeholder="hero, banner, cta"
placeholder={t`hero, banner, cta`}
/>
<p className="text-xs text-kumo-subtle mt-1">{t`Comma-separated keywords for search.`}</p>
</div>
Expand Down
18 changes: 10 additions & 8 deletions packages/admin/src/components/Sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
*/

import { Button, Dialog, Input, InputArea, Toast } from "@cloudflare/kumo";
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
Plus,
Expand Down Expand Up @@ -39,10 +41,10 @@ const sourceIcons: Record<SectionSource, React.ElementType> = {
import: FileArrowDown,
};

const sourceLabels: Record<SectionSource, string> = {
theme: "Theme",
user: "Custom",
import: "Imported",
const sourceLabels: Record<SectionSource, MessageDescriptor> = {
theme: msg`Theme`,
user: msg`Custom`,
import: msg`Imported`,
};

export function Sections() {
Expand Down Expand Up @@ -175,7 +177,7 @@ export function Sections() {
}
}}
required
placeholder="Hero Banner"
placeholder={t`Hero Banner`}
/>
<div>
<Input
Expand All @@ -198,7 +200,7 @@ export function Sections() {
label={t`Description`}
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="A full-width hero banner with heading, text, and CTA button"
placeholder={t`A full-width hero banner with heading, text, and CTA button`}
rows={3}
/>
<DialogError message={createError || getMutationError(createMutation.error)} />
Expand Down Expand Up @@ -351,10 +353,10 @@ function SectionCard({
</div>
<div
className="flex items-center gap-1 text-xs text-kumo-subtle"
title={sourceLabels[section.source]}
title={t(sourceLabels[section.source])}
>
<SourceIcon className="h-3 w-3" />
<span>{sourceLabels[section.source]}</span>
<span>{t(sourceLabels[section.source])}</span>
</div>
</div>

Expand Down
Loading
Loading