feat(ui): translate Nostr, Google Chat, and config views to Chinese

- Add i18n to channels.nostr.ts and channels.nostr-profile-form.ts
- Add i18n to channels.googlechat.ts
- Add i18n to channels.config.ts (channel configuration section)
- Add translation keys for profile form fields and config messages
- Complete Chinese translations for all channel views

https://claude.ai/code/session_01UK3kVX7BRyE1zEHVh3vrFY
This commit is contained in:
Claude 2026-01-29 10:26:37 +00:00
parent d0a086c921
commit cdaf6b86da
No known key found for this signature in database
6 changed files with 180 additions and 98 deletions

View File

@ -268,27 +268,62 @@ export const enUS = {
// Google Chat
googlechat: {
title: "Google Chat",
desc: "Google Chat via service account.",
desc: "Service account status and channel configuration.",
credential: "Credential",
audience: "Audience",
lastStart: "Last start",
lastProbe: "Last probe",
probe: "Probe",
probeOk: "ok",
probeFailed: "failed",
},
// Nostr
nostr: {
title: "Nostr",
desc: "Nostr protocol via NIP-04 DMs.",
desc: "Decentralized DMs via Nostr relays (NIP-04).",
publicKey: "Public Key",
lastStart: "Last start",
profile: "Profile",
editProfile: "Edit Profile",
profilePicture: "Profile picture",
displayName: "Display Name",
noProfile: "No profile set. Click \"Edit Profile\" to add your name, bio, and avatar.",
profileForm: {
title: "Edit Nostr Profile",
name: "Display Name",
about: "About",
picture: "Picture URL",
nip05: "NIP-05 Identifier",
lud16: "Lightning Address",
title: "Edit Profile",
account: "Account",
name: "Username",
nameHelp: "Short username (e.g., satoshi)",
namePlaceholder: "satoshi",
displayName: "Display Name",
displayNameHelp: "Your full display name",
displayNamePlaceholder: "Satoshi Nakamoto",
about: "Bio",
aboutHelp: "A brief bio or description",
aboutPlaceholder: "Tell people about yourself...",
picture: "Avatar URL",
pictureHelp: "HTTPS URL to your profile picture",
picturePlaceholder: "https://example.com/avatar.jpg",
picturePreview: "Profile picture preview",
advanced: "Advanced",
banner: "Banner URL",
bannerHelp: "HTTPS URL to a banner image",
bannerPlaceholder: "https://example.com/banner.jpg",
website: "Website",
showAdvanced: "Show advanced fields",
hideAdvanced: "Hide advanced fields",
importFromRelays: "Import from relays",
websiteHelp: "Your personal website",
websitePlaceholder: "https://example.com",
nip05: "NIP-05 Identifier",
nip05Help: "Verifiable identifier (e.g., you@domain.com)",
nip05Placeholder: "you@example.com",
lud16: "Lightning Address",
lud16Help: "Lightning address for tips (LUD-16)",
lud16Placeholder: "you@getalby.com",
showAdvanced: "Show Advanced",
hideAdvanced: "Hide Advanced",
importFromRelays: "Import from Relays",
importing: "Importing…",
savePublish: "Save & Publish",
unsavedChanges: "You have unsaved changes",
},
},
@ -298,6 +333,10 @@ export const enUS = {
saveChanges: "Save Changes",
reloadConfig: "Reload Config",
unsavedChanges: "Unsaved changes",
loadingSchema: "Loading config schema…",
schemaUnavailable: "Schema unavailable. Use Raw.",
channelSchemaUnavailable: "Channel config schema unavailable.",
reload: "Reload",
},
},

View File

@ -275,27 +275,62 @@ export const zhTW = {
// Google Chat
googlechat: {
title: "Google Chat",
desc: "透過服務帳戶連接 Google Chat。",
desc: "服務帳戶狀態與頻道設定。",
credential: "憑證",
audience: "對象",
lastStart: "上次啟動",
lastProbe: "上次探測",
probe: "探測",
probeOk: "正常",
probeFailed: "失敗",
},
// Nostr
nostr: {
title: "Nostr",
desc: "透過 NIP-04 私訊連接 Nostr 協定。",
desc: "透過 Nostr 中繼站進行去中心化私訊NIP-04。",
publicKey: "公鑰",
lastStart: "上次啟動",
profile: "個人檔案",
editProfile: "編輯個人檔案",
profilePicture: "個人頭像",
displayName: "顯示名稱",
noProfile: "尚未設定個人檔案。點擊「編輯個人檔案」來新增您的名稱、簡介和頭像。",
profileForm: {
title: "編輯 Nostr 個人檔案",
name: "顯示名稱",
about: "關於",
title: "編輯個人檔案",
account: "帳號",
name: "使用者名稱",
nameHelp: "簡短的使用者名稱satoshi",
namePlaceholder: "satoshi",
displayName: "顯示名稱",
displayNameHelp: "您的完整顯示名稱",
displayNamePlaceholder: "Satoshi Nakamoto",
about: "個人簡介",
aboutHelp: "簡短的自我介紹",
aboutPlaceholder: "介紹一下您自己...",
picture: "頭像網址",
nip05: "NIP-05 識別碼",
lud16: "閃電網路地址",
pictureHelp: "您的頭像的 HTTPS 網址",
picturePlaceholder: "https://example.com/avatar.jpg",
picturePreview: "頭像預覽",
advanced: "進階設定",
banner: "橫幅圖片網址",
bannerHelp: "橫幅圖片的 HTTPS 網址",
bannerPlaceholder: "https://example.com/banner.jpg",
website: "網站",
showAdvanced: "顯示進階欄位",
hideAdvanced: "隱藏進階欄位",
websiteHelp: "您的個人網站",
websitePlaceholder: "https://example.com",
nip05: "NIP-05 識別碼",
nip05Help: "可驗證的識別碼you@domain.com",
nip05Placeholder: "you@example.com",
lud16: "閃電網路地址",
lud16Help: "用於接收打賞的閃電網路地址LUD-16",
lud16Placeholder: "you@getalby.com",
showAdvanced: "顯示進階設定",
hideAdvanced: "隱藏進階設定",
importFromRelays: "從中繼站匯入",
importing: "匯入中…",
savePublish: "儲存並發布",
unsavedChanges: "有未儲存的變更",
},
},
@ -305,6 +340,10 @@ export const zhTW = {
saveChanges: "儲存變更",
reloadConfig: "重新載入組態",
unsavedChanges: "有未儲存的變更",
loadingSchema: "載入組態結構中…",
schemaUnavailable: "結構不可用。請使用原始模式。",
channelSchemaUnavailable: "頻道組態結構不可用。",
reload: "重新載入",
},
},

View File

@ -1,5 +1,6 @@
import { html } from "lit";
import { t } from "../../i18n";
import type { ConfigUiHints } from "../types";
import type { ChannelsProps } from "./channels.types";
import {
@ -71,11 +72,11 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema;
if (!normalized) {
return html`<div class="callout danger">Schema unavailable. Use Raw.</div>`;
return html`<div class="callout danger">${t("channels.config.schemaUnavailable")}</div>`;
}
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
if (!node) {
return html`<div class="callout danger">Channel config schema unavailable.</div>`;
return html`<div class="callout danger">${t("channels.config.channelSchemaUnavailable")}</div>`;
}
const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId);
@ -104,7 +105,7 @@ export function renderChannelConfigSection(params: {
return html`
<div style="margin-top: 16px;">
${props.configSchemaLoading
? html`<div class="muted">Loading config schema…</div>`
? html`<div class="muted">${t("channels.config.loadingSchema")}</div>`
: renderChannelConfigForm({
channelId,
configValue: props.configForm,
@ -119,14 +120,14 @@ export function renderChannelConfigSection(params: {
?disabled=${disabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save"}
${props.configSaving ? t("common.saving") : t("common.save")}
</button>
<button
class="btn"
?disabled=${disabled}
@click=${() => props.onConfigReload()}
>
Reload
${t("channels.config.reload")}
</button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type { GoogleChatStatus } from "../types";
import { renderChannelConfigSection } from "./channels.config";
@ -15,37 +16,37 @@ export function renderGoogleChatCard(params: {
return html`
<div class="card">
<div class="card-title">Google Chat</div>
<div class="card-sub">Chat API webhook status and channel configuration.</div>
<div class="card-sub">${t("channels.googlechat.desc")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${googleChat ? (googleChat.configured ? t("common.yes") : t("common.no")) : t("common.na")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${googleChat ? (googleChat.running ? t("common.yes") : t("common.no")) : t("common.na")}</span>
</div>
<div>
<span class="label">Credential</span>
<span>${googleChat?.credentialSource ?? "n/a"}</span>
<span class="label">${t("channels.googlechat.credential")}</span>
<span>${googleChat?.credentialSource ?? t("common.na")}</span>
</div>
<div>
<span class="label">Audience</span>
<span class="label">${t("channels.googlechat.audience")}</span>
<span>
${googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"}
: t("common.na")}
</span>
</div>
<div>
<span class="label">Last start</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.googlechat.lastStart")}</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.googlechat.lastProbe")}</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -57,7 +58,7 @@ export function renderGoogleChatCard(params: {
${googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${t("channels.googlechat.probe")} ${googleChat.probe.ok ? t("channels.googlechat.probeOk") : t("channels.googlechat.probeFailed")} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>`
: nothing}
@ -66,7 +67,7 @@ export function renderGoogleChatCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.googlechat.probe")}
</button>
</div>
</div>

View File

@ -6,6 +6,7 @@
import { html, nothing, type TemplateResult } from "lit";
import { t } from "../../i18n";
import type { NostrProfile as NostrProfileType } from "../types";
// ============================================================================
@ -147,7 +148,7 @@ export function renderNostrProfileForm(params: {
<div style="margin-bottom: 12px;">
<img
src=${picture}
alt="Profile picture preview"
alt="${t("channels.nostr.profileForm.picturePreview")}"
style="max-width: 80px; max-height: 80px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => {
const img = e.target as HTMLImageElement;
@ -165,8 +166,8 @@ export function renderNostrProfileForm(params: {
return html`
<div class="nostr-profile-form" style="padding: 16px; background: var(--bg-secondary); border-radius: 8px; margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<div style="font-weight: 600; font-size: 16px;">Edit Profile</div>
<div style="font-size: 12px; color: var(--text-muted);">Account: ${accountId}</div>
<div style="font-weight: 600; font-size: 16px;">${t("channels.nostr.profileForm.title")}</div>
<div style="font-size: 12px; color: var(--text-muted);">${t("channels.nostr.profileForm.account")}: ${accountId}</div>
</div>
${state.error
@ -179,56 +180,56 @@ export function renderNostrProfileForm(params: {
${renderPicturePreview()}
${renderField("name", "Username", {
placeholder: "satoshi",
${renderField("name", t("channels.nostr.profileForm.name"), {
placeholder: t("channels.nostr.profileForm.namePlaceholder"),
maxLength: 256,
help: "Short username (e.g., satoshi)",
help: t("channels.nostr.profileForm.nameHelp"),
})}
${renderField("displayName", "Display Name", {
placeholder: "Satoshi Nakamoto",
${renderField("displayName", t("channels.nostr.profileForm.displayName"), {
placeholder: t("channels.nostr.profileForm.displayNamePlaceholder"),
maxLength: 256,
help: "Your full display name",
help: t("channels.nostr.profileForm.displayNameHelp"),
})}
${renderField("about", "Bio", {
${renderField("about", t("channels.nostr.profileForm.about"), {
type: "textarea",
placeholder: "Tell people about yourself...",
placeholder: t("channels.nostr.profileForm.aboutPlaceholder"),
maxLength: 2000,
help: "A brief bio or description",
help: t("channels.nostr.profileForm.aboutHelp"),
})}
${renderField("picture", "Avatar URL", {
${renderField("picture", t("channels.nostr.profileForm.picture"), {
type: "url",
placeholder: "https://example.com/avatar.jpg",
help: "HTTPS URL to your profile picture",
placeholder: t("channels.nostr.profileForm.picturePlaceholder"),
help: t("channels.nostr.profileForm.pictureHelp"),
})}
${state.showAdvanced
? html`
<div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;">
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div>
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">${t("channels.nostr.profileForm.advanced")}</div>
${renderField("banner", "Banner URL", {
${renderField("banner", t("channels.nostr.profileForm.banner"), {
type: "url",
placeholder: "https://example.com/banner.jpg",
help: "HTTPS URL to a banner image",
placeholder: t("channels.nostr.profileForm.bannerPlaceholder"),
help: t("channels.nostr.profileForm.bannerHelp"),
})}
${renderField("website", "Website", {
${renderField("website", t("channels.nostr.profileForm.website"), {
type: "url",
placeholder: "https://example.com",
help: "Your personal website",
placeholder: t("channels.nostr.profileForm.websitePlaceholder"),
help: t("channels.nostr.profileForm.websiteHelp"),
})}
${renderField("nip05", "NIP-05 Identifier", {
placeholder: "you@example.com",
help: "Verifiable identifier (e.g., you@domain.com)",
${renderField("nip05", t("channels.nostr.profileForm.nip05"), {
placeholder: t("channels.nostr.profileForm.nip05Placeholder"),
help: t("channels.nostr.profileForm.nip05Help"),
})}
${renderField("lud16", "Lightning Address", {
placeholder: "you@getalby.com",
help: "Lightning address for tips (LUD-16)",
${renderField("lud16", t("channels.nostr.profileForm.lud16"), {
placeholder: t("channels.nostr.profileForm.lud16Placeholder"),
help: t("channels.nostr.profileForm.lud16Help"),
})}
</div>
`
@ -240,7 +241,7 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onSave}
?disabled=${state.saving || !isDirty}
>
${state.saving ? "Saving..." : "Save & Publish"}
${state.saving ? t("common.saving") : t("channels.nostr.profileForm.savePublish")}
</button>
<button
@ -248,14 +249,14 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onImport}
?disabled=${state.importing || state.saving}
>
${state.importing ? "Importing..." : "Import from Relays"}
${state.importing ? t("channels.nostr.profileForm.importing") : t("channels.nostr.profileForm.importFromRelays")}
</button>
<button
class="btn"
@click=${callbacks.onToggleAdvanced}
>
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
${state.showAdvanced ? t("channels.nostr.profileForm.hideAdvanced") : t("channels.nostr.profileForm.showAdvanced")}
</button>
<button
@ -263,13 +264,13 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onCancel}
?disabled=${state.saving}
>
Cancel
${t("common.cancel")}
</button>
</div>
${isDirty
? html`<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px;">
You have unsaved changes
${t("channels.nostr.profileForm.unsavedChanges")}
</div>`
: nothing}
</div>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, NostrStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
@ -14,7 +15,7 @@ import {
* Truncate a pubkey for display (shows first and last 8 chars)
*/
function truncatePubkey(pubkey: string | null | undefined): string {
if (!pubkey) return "n/a";
if (!pubkey) return t("common.na");
if (pubkey.length <= 20) return pubkey;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
@ -64,20 +65,20 @@ export function renderNostrCard(params: {
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${account.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${account.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="label">${t("channels.nostr.publicKey")}</span>
<span class="monospace" title="${publicKey ?? ""}">${truncatePubkey(publicKey)}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span class="label">${t("channels.lastInbound")}</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}</span>
</div>
${account.lastError
? html`
@ -117,7 +118,7 @@ export function renderNostrCard(params: {
return html`
<div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 500;">Profile</div>
<div style="font-weight: 500;">${t("channels.nostr.profile")}</div>
${summaryConfigured
? html`
<button
@ -125,7 +126,7 @@ export function renderNostrCard(params: {
@click=${onEditProfile}
style="font-size: 12px; padding: 4px 8px;"
>
Edit Profile
${t("channels.nostr.editProfile")}
</button>
`
: nothing}
@ -138,7 +139,7 @@ export function renderNostrCard(params: {
<div style="margin-bottom: 8px;">
<img
src=${picture}
alt="Profile picture"
alt="${t("channels.nostr.profilePicture")}"
style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none";
@ -147,19 +148,19 @@ export function renderNostrCard(params: {
</div>
`
: nothing}
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : nothing}
${name ? html`<div><span class="label">${t("channels.nostr.profileForm.name")}</span><span>${name}</span></div>` : nothing}
${displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
? html`<div><span class="label">${t("channels.nostr.displayName")}</span><span>${displayName}</span></div>`
: nothing}
${about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
? html`<div><span class="label">${t("channels.nostr.profileForm.about")}</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing}
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
${nip05 ? html`<div><span class="label">${t("channels.nostr.profileForm.nip05")}</span><span>${nip05}</span></div>` : nothing}
</div>
`
: html`
<div style="color: var(--text-muted); font-size: 13px;">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
${t("channels.nostr.noProfile")}
</div>
`}
</div>
@ -169,7 +170,7 @@ export function renderNostrCard(params: {
return html`
<div class="card">
<div class="card-title">Nostr</div>
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
<div class="card-sub">${t("channels.nostr.desc")}</div>
${accountCountLabel}
${hasMultipleAccounts
@ -181,22 +182,22 @@ export function renderNostrCard(params: {
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${summaryConfigured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${summaryConfigured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${summaryRunning ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${summaryRunning ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="label">${t("channels.nostr.publicKey")}</span>
<span class="monospace" title="${summaryPublicKey ?? ""}"
>${truncatePubkey(summaryPublicKey)}</span
>
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
<span class="label">${t("channels.nostr.lastStart")}</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : t("common.na")}</span>
</div>
</div>
`}
@ -210,7 +211,7 @@ export function renderNostrCard(params: {
${renderChannelConfigSection({ channelId: "nostr", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button>
<button class="btn" @click=${() => props.onRefresh(false)}>${t("common.refresh")}</button>
</div>
</div>
`;