diff options
| author | Adam <[email protected]> | 2026-02-06 08:54:51 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 08:54:51 -0600 |
| commit | 812597bb8b101896a8988493d37261ff851ae502 (patch) | |
| tree | 3b713c13f49c65665e6a7296ed483a3f6f8befbf /packages/console/app/src/lib | |
| parent | 0ec5f6608bdfea5be62dbbdc4c04a61de6d3e67c (diff) | |
| download | opencode-812597bb8b101896a8988493d37261ff851ae502.tar.gz opencode-812597bb8b101896a8988493d37261ff851ae502.zip | |
feat(web): i18n (#12471)
Diffstat (limited to 'packages/console/app/src/lib')
| -rw-r--r-- | packages/console/app/src/lib/form-error.ts | 83 | ||||
| -rw-r--r-- | packages/console/app/src/lib/language.ts | 175 |
2 files changed, 258 insertions, 0 deletions
diff --git a/packages/console/app/src/lib/form-error.ts b/packages/console/app/src/lib/form-error.ts new file mode 100644 index 000000000..1f6e2ea1e --- /dev/null +++ b/packages/console/app/src/lib/form-error.ts @@ -0,0 +1,83 @@ +import type { Key } from "~/i18n" + +export const formError = { + invalidPlan: "error.invalidPlan", + workspaceRequired: "error.workspaceRequired", + alreadySubscribed: "error.alreadySubscribed", + limitRequired: "error.limitRequired", + monthlyLimitInvalid: "error.monthlyLimitInvalid", + workspaceNameRequired: "error.workspaceNameRequired", + nameTooLong: "error.nameTooLong", + emailRequired: "error.emailRequired", + roleRequired: "error.roleRequired", + idRequired: "error.idRequired", + nameRequired: "error.nameRequired", + providerRequired: "error.providerRequired", + apiKeyRequired: "error.apiKeyRequired", + modelRequired: "error.modelRequired", +} as const + +const map = { + [formError.invalidPlan]: "error.invalidPlan", + [formError.workspaceRequired]: "error.workspaceRequired", + [formError.alreadySubscribed]: "error.alreadySubscribed", + [formError.limitRequired]: "error.limitRequired", + [formError.monthlyLimitInvalid]: "error.monthlyLimitInvalid", + [formError.workspaceNameRequired]: "error.workspaceNameRequired", + [formError.nameTooLong]: "error.nameTooLong", + [formError.emailRequired]: "error.emailRequired", + [formError.roleRequired]: "error.roleRequired", + [formError.idRequired]: "error.idRequired", + [formError.nameRequired]: "error.nameRequired", + [formError.providerRequired]: "error.providerRequired", + [formError.apiKeyRequired]: "error.apiKeyRequired", + [formError.modelRequired]: "error.modelRequired", + "Invalid plan": "error.invalidPlan", + "Workspace ID is required": "error.workspaceRequired", + "Workspace ID is required.": "error.workspaceRequired", + "This workspace already has a subscription": "error.alreadySubscribed", + "Limit is required.": "error.limitRequired", + "Set a valid monthly limit": "error.monthlyLimitInvalid", + "Set a valid monthly limit.": "error.monthlyLimitInvalid", + "Workspace name is required.": "error.workspaceNameRequired", + "Name must be 255 characters or less.": "error.nameTooLong", + "Email is required": "error.emailRequired", + "Role is required": "error.roleRequired", + "ID is required": "error.idRequired", + "Name is required": "error.nameRequired", + "Provider is required": "error.providerRequired", + "API key is required": "error.apiKeyRequired", + "Model is required": "error.modelRequired", +} as const satisfies Record<string, Key> + +export function formErrorReloadAmountMin(amount: number) { + return `error.reloadAmountMin:${amount}` +} + +export function formErrorReloadTriggerMin(amount: number) { + return `error.reloadTriggerMin:${amount}` +} + +export function localizeError(t: (key: Key, params?: Record<string, string | number>) => string, error?: string) { + if (!error) return "" + + if (error.startsWith("error.reloadAmountMin:")) { + const amount = Number(error.split(":")[1] ?? 0) + return t("error.reloadAmountMin", { amount }) + } + + if (error.startsWith("error.reloadTriggerMin:")) { + const amount = Number(error.split(":")[1] ?? 0) + return t("error.reloadTriggerMin", { amount }) + } + + const amount = error.match(/^Reload amount must be at least \$(\d+)$/) + if (amount) return t("error.reloadAmountMin", { amount: Number(amount[1]) }) + + const trigger = error.match(/^Balance trigger must be at least \$(\d+)$/) + if (trigger) return t("error.reloadTriggerMin", { amount: Number(trigger[1]) }) + + const key = map[error as keyof typeof map] + if (key) return t(key) + return error +} diff --git a/packages/console/app/src/lib/language.ts b/packages/console/app/src/lib/language.ts new file mode 100644 index 000000000..e1e62d8fd --- /dev/null +++ b/packages/console/app/src/lib/language.ts @@ -0,0 +1,175 @@ +export const LOCALES = [ + "en", + "zh", + "zht", + "ko", + "de", + "es", + "fr", + "it", + "da", + "ja", + "pl", + "ru", + "ar", + "no", + "br", + "th", + "tr", +] as const + +export type Locale = (typeof LOCALES)[number] + +export const LOCALE_COOKIE = "oc_locale" as const + +const LABEL = { + en: "English", + zh: "简体中文", + zht: "繁體中文", + ko: "한국어", + de: "Deutsch", + es: "Español", + fr: "Français", + it: "Italiano", + da: "Dansk", + ja: "日本語", + pl: "Polski", + ru: "Русский", + ar: "العربية", + no: "Norsk", + br: "Português (Brasil)", + th: "ไทย", + tr: "Türkçe", +} satisfies Record<Locale, string> + +const TAG = { + en: "en", + zh: "zh-Hans", + zht: "zh-Hant", + ko: "ko", + de: "de", + es: "es", + fr: "fr", + it: "it", + da: "da", + ja: "ja", + pl: "pl", + ru: "ru", + ar: "ar", + no: "no", + br: "pt-BR", + th: "th", + tr: "tr", +} satisfies Record<Locale, string> + +export function parseLocale(value: unknown): Locale | null { + if (typeof value !== "string") return null + if ((LOCALES as readonly string[]).includes(value)) return value as Locale + return null +} + +export function label(locale: Locale) { + return LABEL[locale] +} + +export function tag(locale: Locale) { + return TAG[locale] +} + +export function dir(locale: Locale) { + if (locale === "ar") return "rtl" + return "ltr" +} + +function match(input: string): Locale | null { + const value = input.trim().toLowerCase() + if (!value) return null + + if (value.startsWith("zh")) { + if (value.includes("hant") || value.includes("-tw") || value.includes("-hk") || value.includes("-mo")) return "zht" + return "zh" + } + + if (value.startsWith("ko")) return "ko" + if (value.startsWith("de")) return "de" + if (value.startsWith("es")) return "es" + if (value.startsWith("fr")) return "fr" + if (value.startsWith("it")) return "it" + if (value.startsWith("da")) return "da" + if (value.startsWith("ja")) return "ja" + if (value.startsWith("pl")) return "pl" + if (value.startsWith("ru")) return "ru" + if (value.startsWith("ar")) return "ar" + if (value.startsWith("tr")) return "tr" + if (value.startsWith("th")) return "th" + if (value.startsWith("pt")) return "br" + if (value.startsWith("no") || value.startsWith("nb") || value.startsWith("nn")) return "no" + if (value.startsWith("en")) return "en" + return null +} + +export function detectFromLanguages(languages: readonly string[]) { + for (const language of languages) { + const locale = match(language) + if (locale) return locale + } + return "en" satisfies Locale +} + +export function detectFromAcceptLanguage(header: string | null) { + if (!header) return "en" satisfies Locale + + const items = header + .split(",") + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => { + const parts = raw.split(";").map((x) => x.trim()) + const lang = parts[0] ?? "" + const q = parts + .slice(1) + .find((x) => x.startsWith("q=")) + ?.slice(2) + return { + lang, + q: q ? Number.parseFloat(q) : 1, + } + }) + .sort((a, b) => b.q - a.q) + + for (const item of items) { + if (!item.lang || item.lang === "*") continue + const locale = match(item.lang) + if (locale) return locale + } + + return "en" satisfies Locale +} + +export function localeFromCookieHeader(header: string | null) { + if (!header) return null + + const raw = header + .split(";") + .map((x) => x.trim()) + .find((x) => x.startsWith(`${LOCALE_COOKIE}=`)) + ?.slice(`${LOCALE_COOKIE}=`.length) + + if (!raw) return null + return parseLocale(decodeURIComponent(raw)) +} + +export function localeFromRequest(request: Request) { + return ( + localeFromCookieHeader(request.headers.get("cookie")) ?? + detectFromAcceptLanguage(request.headers.get("accept-language")) + ) +} + +export function cookie(locale: Locale) { + return `${LOCALE_COOKIE}=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` +} + +export function clearCookie() { + return `${LOCALE_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax` +} |
