Skip to content

Commit 0b8a319

Browse files
authored
fix(admin): translate long-tail i18n leaks in admin UI (#940)
* fix(admin): translate long-tail i18n leaks in admin UI Wrap remaining user-facing English strings across settings panels, marketplace, sandboxed-plugin host, auth flows (Signup, Login, Passkey, DeviceAuthorize, SetupWizard), taxonomy/menu management, content editor remnants, and lib/api module-level functions. For lib/api fallback messages, uses the i18n._(msg``) pattern since module-level code can't call useLingui(). MenuList item count uses plural() which fixed an existing grammar bug (1 items -> 1 item; test regex updated). * fix(admin): restore <span> wrappers around BlockMenu i18n labels The previous commit dropped the <span> wrappers around Back/Turn into/Duplicate/Delete labels when wrapping in t``. The wrappers are needed for consistent flex layout with the icon and to match the transforms-list pattern.
1 parent 3b55cd8 commit 0b8a319

41 files changed

Lines changed: 225 additions & 150 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/public-women-behave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@emdash-cms/admin": patch
3+
---
4+
5+
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.

packages/admin/src/components/BlockKitFieldWidget.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Input, Switch } from "@cloudflare/kumo";
22
import type { Element } from "@emdash-cms/blocks";
3+
import { useLingui } from "@lingui/react/macro";
34
import * as React from "react";
45

56
import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField";
@@ -65,6 +66,7 @@ function BlockKitFieldElement({
6566
value: unknown;
6667
onChange: (actionId: string, value: unknown) => void;
6768
}) {
69+
const { t } = useLingui();
6870
switch (element.type) {
6971
case "text_input":
7072
return (
@@ -105,7 +107,7 @@ function BlockKitFieldElement({
105107
value={typeof value === "string" ? value : ""}
106108
onChange={(e) => onChange(element.action_id, e.target.value)}
107109
>
108-
<option value="">Select...</option>
110+
<option value="">{t`Select...`}</option>
109111
{options.map((opt) => (
110112
<option key={opt.value} value={opt.value}>
111113
{opt.label}
@@ -129,7 +131,7 @@ function BlockKitFieldElement({
129131
default:
130132
return (
131133
<div className="text-sm text-kumo-subtle">
132-
Unsupported widget element type: {(element as { type: string }).type}
134+
{t`Unsupported widget element type: ${(element as { type: string }).type}`}
133135
</div>
134136
);
135137
}

packages/admin/src/components/ContentEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,7 +613,7 @@ export function ContentEditor({
613613
<div
614614
className="flex items-center text-xs text-kumo-subtle"
615615
role="status"
616-
aria-label="Autosave status"
616+
aria-label={t`Autosave status`}
617617
aria-live="polite"
618618
>
619619
{isAutosaving ? (

packages/admin/src/components/DeviceAuthorizePage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export function DeviceAuthorizePage() {
102102
body: JSON.stringify({ user_code: trimmed, action: "approve" }),
103103
});
104104

105-
const data = await parseApiResponse<{ authorized: boolean }>(res, "Authorization failed");
105+
const data = await parseApiResponse<{ authorized: boolean }>(res, t`Authorization failed`);
106106
setPageState(data.authorized ? "success" : "denied");
107107
} catch (err) {
108108
setErrorMessage(err instanceof Error ? err.message : "Network error");

packages/admin/src/components/FieldEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
513513
label={t`Options (one per line)`}
514514
value={options}
515515
onChange={(e) => setField("options", e.target.value)}
516-
placeholder={"Option 1\nOption 2\nOption 3"}
516+
placeholder={t`Option 1\nOption 2\nOption 3`}
517517
rows={5}
518518
/>
519519
)}

packages/admin/src/components/MarketplaceBrowse.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
*/
77

88
import { Badge, Button } from "@cloudflare/kumo";
9-
import { plural } from "@lingui/core/macro";
9+
import type { MessageDescriptor } from "@lingui/core";
10+
import { msg, plural } from "@lingui/core/macro";
1011
import { useLingui } from "@lingui/react/macro";
1112
import {
1213
MagnifyingGlass,
@@ -37,11 +38,11 @@ function isSortOption(value: string): value is SortOption {
3738
return SORT_OPTIONS.has(value);
3839
}
3940

40-
const SORT_LABELS: Record<SortOption, string> = {
41-
installs: "Most Popular",
42-
updated: "Recently Updated",
43-
created: "Newest",
44-
name: "Name",
41+
const SORT_LABELS: Record<SortOption, MessageDescriptor> = {
42+
installs: msg`Most Popular`,
43+
updated: msg`Recently Updated`,
44+
created: msg`Newest`,
45+
name: msg`Name`,
4546
};
4647

4748
export interface MarketplaceBrowseProps {
@@ -123,7 +124,7 @@ export function MarketplaceBrowse({ installedPluginIds = new Set() }: Marketplac
123124
>
124125
{Object.entries(SORT_LABELS).map(([value, label]) => (
125126
<option key={value} value={value}>
126-
{label}
127+
{t(label)}
127128
</option>
128129
))}
129130
</select>

packages/admin/src/components/MediaLibrary.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,12 @@ export function MediaLibrary({
7979
if (activeProvider === "local") {
8080
return {
8181
id: "local",
82-
name: "Library",
82+
name: t`Library`,
8383
capabilities: { browse: true, search: false, upload: true, delete: true },
8484
} as MediaProviderInfo;
8585
}
8686
return providers?.find((p) => p.id === activeProvider);
87-
}, [activeProvider, providers]);
87+
}, [activeProvider, providers, t]);
8888

8989
// Update selected item when items change (e.g., after metadata update)
9090
React.useEffect(() => {
@@ -199,7 +199,7 @@ export function MediaLibrary({
199199
// Build provider tabs
200200
const providerTabs = React.useMemo(() => {
201201
const tabs: Array<{ id: string; name: string; icon?: string }> = [
202-
{ id: "local", name: "Library", icon: undefined },
202+
{ id: "local", name: t`Library`, icon: undefined },
203203
];
204204
if (providers) {
205205
for (const p of providers) {
@@ -209,7 +209,7 @@ export function MediaLibrary({
209209
}
210210
}
211211
return tabs;
212-
}, [providers]);
212+
}, [providers, t]);
213213

214214
// Get current items based on active provider
215215
const currentItems = activeProvider === "local" ? items : [];

packages/admin/src/components/MediaPickerModal.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,17 @@ export interface MediaPickerModalProps {
5959
/**
6060
* Probe image URL to get dimensions
6161
*/
62-
function probeImageDimensions(url: string): Promise<{ width: number; height: number }> {
62+
function probeImageDimensions(
63+
url: string,
64+
errorMessage: string,
65+
): Promise<{ width: number; height: number }> {
6366
return new Promise((resolve, reject) => {
6467
const img = new window.Image();
6568
img.onload = () => {
6669
resolve({ width: img.naturalWidth, height: img.naturalHeight });
6770
};
6871
img.onerror = () => {
69-
reject(new Error("Failed to load image"));
72+
reject(new Error(errorMessage));
7073
};
7174
img.src = url;
7275
});
@@ -309,7 +312,7 @@ export function MediaPickerModal({
309312
setUrlError(null);
310313

311314
try {
312-
const dimensions = await probeImageDimensions(url.href);
315+
const dimensions = await probeImageDimensions(url.href, t`Failed to load image`);
313316
const externalItem: MediaItem = {
314317
id: "",
315318
filename: url.pathname.split("/").pop() || "external-image",
@@ -392,7 +395,7 @@ export function MediaPickerModal({
392395
<Globe className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
393396
<Input
394397
type="url"
395-
placeholder="https://example.com/image.jpg"
398+
placeholder={t`https://example.com/image.jpg`}
396399
aria-label={t`Image URL`}
397400
value={imageUrl}
398401
onChange={(e) => {
@@ -491,7 +494,7 @@ export function MediaPickerModal({
491494
accept={mimeTypeFilter ? `${mimeTypeFilter}*` : undefined}
492495
className="sr-only"
493496
onChange={handleFileSelect}
494-
aria-label="Upload file"
497+
aria-label={t`Upload file`}
495498
/>
496499
</>
497500
)}

packages/admin/src/components/MenuList.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
*/
66

77
import { Button, Dialog, Input, Toast, buttonVariants } from "@cloudflare/kumo";
8+
import { plural } from "@lingui/core/macro";
9+
import { Trans } from "@lingui/react/macro";
810
import { useLingui } from "@lingui/react/macro";
911
import { Plus, Pencil, Trash, List as ListIcon } from "@phosphor-icons/react";
1012
import { X } from "@phosphor-icons/react";
@@ -209,7 +211,10 @@ export function MenuList() {
209211
) : null}
210212
</h3>
211213
<p className="text-sm text-kumo-subtle">
212-
{menu.name}{menu.itemCount || 0} items
214+
<Trans>
215+
{menu.name}{" "}
216+
{plural(menu.itemCount ?? 0, { one: "# item", other: "# items" })}
217+
</Trans>
213218
</p>
214219
</div>
215220
</Link>

packages/admin/src/components/Redirects.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,15 @@ function RedirectFormDialog({
126126
<form onSubmit={handleSubmit} className="space-y-4">
127127
<Input
128128
label={t`Source path`}
129-
placeholder="/old-page or /blog/[slug]"
129+
placeholder={t`/old-page or /blog/[slug]`}
130130
value={source}
131131
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSource(e.target.value)}
132132
required
133133
/>
134134

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

159159
<Input
160160
label={t`Group (optional)`}
161-
placeholder="e.g. import, blog"
161+
placeholder={t`e.g. import, blog`}
162162
value={groupName}
163163
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGroupName(e.target.value)}
164164
/>

0 commit comments

Comments
 (0)