LazyBoy2/web/src/i18n.tsx

639 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export const LOCALES = ["zh-Hant", "zh-Hans", "en", "ja"] as const;
export type Locale = (typeof LOCALES)[number];
export const LOCALE_OPTIONS: { id: Locale; native: string }[] = [
{ id: "zh-Hant", native: "繁體中文" },
{ id: "zh-Hans", native: "简体中文" },
{ id: "en", native: "English" },
{ id: "ja", native: "日本語" },
];
const STORAGE_KEY = "lazyboy.locale";
const zhHant = {
handoverTitle: "需要你操作",
handoverHint: "請直接在下方原網站輸入密碼或驗證碼,不要貼到聊天訊息。",
handoverLocal: "請在本機已開啟的工具瀏覽器操作。",
handoverDone: "完成並繼續",
handoverReturning: "正在交回操作權…",
executionDetails: "執行詳情",
language: "語言",
settings: "設定",
about: "關於",
general: "一般",
appearance: "外觀",
theme: "主題",
themeSystem: "跟隨系統",
themeLight: "淺色",
themeDark: "深色",
updates: "更新",
copyVersionInfo: "複製版本資訊",
copied: "已複製",
copyright: "Copyright © 2026 Daniel Wang",
settingsLabel: "LazyBoy 設定",
workspaceDetail: "Agent 與對話都留在這台機器。",
versionLine: "版本 {version}",
computerUpdateCopy: "更新所有助手共用的電腦映像。每個 Agent 的檔案與登入會留在自己的電腦。",
updateComputer: "更新電腦",
restartComputer: "重啟電腦",
restartComputerCopy: "重啟目前這個 Agent 的電腦。瀏覽器登入會保留。",
appComputer: "LazyBoy 的電腦",
computerBusyCopy: "有 Agent 正在工作。現在操作會中斷它。",
localBuild: "本機版本",
close: "關閉",
add: "新增",
addAgent: "新增 Agent",
createAgentLead: "先把人設設好。名稱和簡介必填,這個 Agent 會照這個角色說話。",
addChannel: "新增頻道",
channels: "頻道",
channelName: "頻道名稱",
channelNamePlaceholder: "例如:專案討論",
channelDescription: "選至少兩個 Agent 拉進這個頻道。",
chooseAgents: "選擇 Agent",
channelHostHint: "這個頻道的訊息會由 {name} 主回。",
createChannel: "建立頻道",
deleteChannel: "刪除頻道",
membersCount: "{count} 位成員",
creating: "建立中…",
cancel: "取消",
create: "建立",
deleteAgent: "刪除 Agent",
deleteAgentConfirm: "刪除 {name}?對話與記憶會一併刪掉,無法復原。",
agentSettings: "Agent 設定",
avatarAppearance: "頭像",
avatarAppearanceHint: "選 Blobatar 的 10 種形狀,或上傳自己的照片。",
avatarBot: "Bot",
avatarUpload: "上傳",
avatarDrop: "拖放或貼上圖片",
avatarOr: "或",
avatarBrowse: "選擇檔案",
avatarSet: "設為頭像",
avatarReset: "重設",
avatarRestart: "重來",
avatarReposition: "拖曳調整位置",
avatarZoom: "縮放",
avatarPasted: "貼上的圖片",
color: "顏色",
shape: "形狀",
chooseColor: "選擇顏色 {value}",
customColor: "自訂顏色",
name: "名稱",
tags: "標籤(選填)",
tagsPlaceholder: "",
tagsLimit: "用逗號分開,其他 Agent 找人時看得到。",
shortTitle: "標題",
shortTitlePlaceholder: "",
description: "描述",
descriptionPlaceholder: "這個 Agent 是誰、怎麼說話、擅長什麼。會用來產生人設。",
saveSettings: "儲存",
saving: "儲存中…",
settingsFailed: "設定儲存失敗",
search: "搜尋",
agentNamePlaceholder: "Agent 名稱",
agents: "Agent",
noAgents: "還沒有 Agent",
noMatchingAgents: "沒有符合的 Agent",
queuedWork: "等待開始…",
activityUnknown: "暫時無法確認工作狀態,正在重新連線。",
agentWorking: "工作中",
newChat: "新對話",
localWorkspace: "本機工作區",
chats: "聊天",
backToChats: "返回聊天列表",
pickAgent: "選擇聊天對象",
workTools: "工作工具",
stop: "停止",
computer: "電腦",
startWorkWith: "開始跟 {name} 工作",
startWorkHint: "傳一則訊息,或打開右邊的電腦看它動手做。",
workingWith: "{name} 正在工作…",
moreActions: "更多動作",
messageTo: "傳訊息給 {name}",
pickAgentFirst: "先選一個 Agent",
send: "送出",
collapse: "收合",
computerOf: "{name} 的電腦",
enlarge: "放大",
loginHint: "在這台機器人自己的瀏覽器登入。Session 留在它的電腦,不要把密碼打在聊天裡。",
chat: "聊天",
mainMenu: "主選單",
restart: "重啟",
update: "更新",
restartTitle: "重啟這台電腦",
updateTitle: "重建映像並在有更新時重開",
restartConfirm: "重啟會暫時中斷桌面與正在跑的操作。瀏覽器登入會保留。確定重啟?",
updateConfirm:
"更新會依目前的 Dockerfile 重建這台電腦的映像。登入與 /workspace 會留在 volume。若映像有變就會重開桌面,可能需要幾分鐘。確定更新?",
computerStarting: "電腦啟動中…",
computerRestarting: "正在重啟電腦…",
computerUpdating: "正在更新電腦映像…",
computerRunning: "執行中",
computerUpToDate: "映像已是最新",
computerNotReady: "電腦未就緒",
computerNotStarted: "尚未啟動",
computerOff: "未啟動",
computerCrowded: "同時開著的電腦已達上限,已為這個 Agent 啟動,較閒的電腦先關掉。",
thinking: "思考中",
needsReply: "需要回覆",
stepComputer: "操作電腦",
stepSearch: "搜尋中",
stepFetch: "讀取網頁",
stepBrowser: "操作瀏覽器",
stepBox: "在電腦上執行",
stepShell: "執行指令",
stepWrite: "改檔案",
stepRead: "讀檔",
stepWorking: "處理中",
} as const;
export type MessageKey = keyof typeof zhHant;
export type Messages = Record<MessageKey, string>;
const zhHans: Messages = {
handoverTitle: "需要你操作",
handoverHint: "请直接在下方原网站输入密码或验证码,不要贴到聊天消息。",
handoverLocal: "请在本机已打开的工具浏览器操作。",
handoverDone: "完成并继续",
handoverReturning: "正在交回操作权…",
executionDetails: "执行详情",
language: "语言",
settings: "设置",
about: "关于",
general: "一般",
appearance: "外观",
theme: "主题",
themeSystem: "跟随系统",
themeLight: "浅色",
themeDark: "深色",
updates: "更新",
copyVersionInfo: "复制版本信息",
copied: "已复制",
copyright: "Copyright © 2026 Daniel Wang",
settingsLabel: "LazyBoy 设置",
workspaceDetail: "Agent 和对话都留在这台机器。",
versionLine: "版本 {version}",
computerUpdateCopy: "更新所有助手共用的电脑镜像。每个 Agent 的文件和登录会留在自己的电脑。",
updateComputer: "更新电脑",
restartComputer: "重启电脑",
restartComputerCopy: "重启当前这个 Agent 的电脑。浏览器登录会保留。",
appComputer: "LazyBoy 的电脑",
computerBusyCopy: "有 Agent 正在工作。现在操作会中断它。",
localBuild: "本地版本",
close: "关闭",
add: "新增",
addAgent: "新增 Agent",
createAgentLead: "先把人设设好。名称和简介必填,这个 Agent 会照这个角色说话。",
addChannel: "新增频道",
channels: "频道",
channelName: "频道名称",
channelNamePlaceholder: "例如:项目讨论",
channelDescription: "选至少两个 Agent 拉进这个频道。",
chooseAgents: "选择 Agent",
channelHostHint: "这个频道的消息会由 {name} 主回。",
createChannel: "建立频道",
deleteChannel: "删除频道",
membersCount: "{count} 位成员",
creating: "建立中…",
cancel: "取消",
create: "建立",
deleteAgent: "删除 Agent",
deleteAgentConfirm: "删除 {name}?对话和记忆会一并删掉,无法恢复。",
agentSettings: "Agent 设置",
avatarAppearance: "头像",
avatarAppearanceHint: "选 Blobatar 的 10 种形状,或上传自己的照片。",
avatarBot: "Bot",
avatarUpload: "上传",
avatarDrop: "拖放或粘贴图片",
avatarOr: "或",
avatarBrowse: "选择文件",
avatarSet: "设为头像",
avatarReset: "重设",
avatarRestart: "重来",
avatarReposition: "拖曳调整位置",
avatarZoom: "缩放",
avatarPasted: "粘贴的图片",
color: "颜色",
shape: "形状",
chooseColor: "选择颜色 {value}",
customColor: "自定义颜色",
name: "名称",
tags: "标签(选填)",
tagsPlaceholder: "",
tagsLimit: "用逗号分开,其他 Agent 找人时看得到。",
shortTitle: "标题",
shortTitlePlaceholder: "",
description: "描述",
descriptionPlaceholder: "这个 Agent 是谁、怎么说话、擅长什么。会用来产生人设。",
saveSettings: "保存",
saving: "保存中…",
settingsFailed: "设置保存失败",
search: "搜索",
agentNamePlaceholder: "Agent 名称",
agents: "Agent",
noAgents: "还没有 Agent",
noMatchingAgents: "没有符合的 Agent",
queuedWork: "等待开始…",
activityUnknown: "暂时无法确认工作状态,正在重新连接。",
agentWorking: "工作中",
newChat: "新对话",
localWorkspace: "本地工作区",
chats: "聊天",
backToChats: "返回聊天列表",
pickAgent: "选择聊天对象",
workTools: "工作工具",
stop: "停止",
computer: "电脑",
startWorkWith: "开始和 {name} 工作",
startWorkHint: "发一条消息,或打开右边的电脑看它动手做。",
workingWith: "{name} 正在工作…",
moreActions: "更多操作",
messageTo: "发消息给 {name}",
pickAgentFirst: "先选一个 Agent",
send: "发送",
collapse: "收起",
computerOf: "{name} 的电脑",
enlarge: "放大",
loginHint: "在这台机器人自己的浏览器登录。Session 留在它的电脑,不要把密码打在聊天里。",
chat: "聊天",
mainMenu: "主菜单",
restart: "重启",
update: "更新",
restartTitle: "重启这台电脑",
updateTitle: "重建镜像并在有更新时重开",
restartConfirm: "重启会暂时中断桌面和正在跑的操作。浏览器登录会保留。确定重启?",
updateConfirm:
"更新会按当前的 Dockerfile 重建这台电脑的镜像。登录和 /workspace 会留在 volume。若镜像有变就会重开桌面,可能需要几分钟。确定更新?",
computerStarting: "电脑启动中…",
computerRestarting: "正在重启电脑…",
computerUpdating: "正在更新电脑镜像…",
computerRunning: "运行中",
computerUpToDate: "镜像已是最新",
computerNotReady: "电脑未就绪",
computerNotStarted: "尚未启动",
computerOff: "未启动",
computerCrowded: "同时开着的电脑已达上限,已为这个 Agent 启动,较闲的电脑先关掉。",
thinking: "思考中",
needsReply: "需要回复",
stepComputer: "操作电脑",
stepSearch: "搜索中",
stepFetch: "读取网页",
stepBrowser: "操作浏览器",
stepBox: "在电脑上执行",
stepShell: "执行命令",
stepWrite: "改文件",
stepRead: "读文件",
stepWorking: "处理中",
};
const en: Messages = {
handoverTitle: "Your action is needed",
handoverHint: "Enter passwords or verification codes on the original website below, not in chat.",
handoverLocal: "Use the tool browser already open on your computer.",
handoverDone: "Done, continue",
handoverReturning: "Returning control…",
executionDetails: "Execution details",
language: "Language",
settings: "Settings",
about: "About",
general: "General",
appearance: "Appearance",
theme: "Theme",
themeSystem: "Follow System",
themeLight: "Light",
themeDark: "Dark",
updates: "Updates",
copyVersionInfo: "Copy version info",
copied: "Copied",
copyright: "Copyright © 2026 Daniel Wang",
settingsLabel: "LazyBoy settings",
workspaceDetail: "Agents and chats stay on this machine.",
versionLine: "Version {version}",
computerUpdateCopy: "Rebuilds the image shared by every assistant. Each agent's files and logins stay on its own computer.",
updateComputer: "Update LazyBoy's Computer",
restartComputer: "Restart LazyBoy's Computer",
restartComputerCopy: "Restart this agent's computer. Browser logins are kept.",
appComputer: "LazyBoy's Computer",
computerBusyCopy: "An agent is working. This will interrupt it.",
localBuild: "Local build",
close: "Close",
add: "Add",
addAgent: "New agent",
createAgentLead: "Set the persona first. Name and description are required — this agent will speak from that identity.",
addChannel: "New channel",
channels: "Channels",
channelName: "Channel name",
channelNamePlaceholder: "e.g. Project chat",
channelDescription: "Pick at least two agents to pull into this channel.",
chooseAgents: "Choose agents",
channelHostHint: "{name} will answer in this channel.",
createChannel: "Create channel",
deleteChannel: "Delete channel",
membersCount: "{count} members",
creating: "Creating…",
cancel: "Cancel",
create: "Create",
deleteAgent: "Delete agent",
deleteAgentConfirm: "Delete {name}? Its chats and memory will be removed. This cannot be undone.",
agentSettings: "Agent settings",
avatarAppearance: "Avatar",
avatarAppearanceHint: "Pick one of Blobatar's 10 shapes, or upload your own photo.",
avatarBot: "Bot",
avatarUpload: "Upload",
avatarDrop: "Drag, drop, or paste an image",
avatarOr: "or",
avatarBrowse: "Browse files",
avatarSet: "Set avatar",
avatarReset: "Reset",
avatarRestart: "Restart",
avatarReposition: "Drag to reposition",
avatarZoom: "Zoom",
avatarPasted: "Pasted image",
color: "Color",
shape: "Shape",
chooseColor: "Choose color {value}",
customColor: "Custom color",
name: "Name",
tags: "Tags (optional)",
tagsPlaceholder: "",
tagsLimit: "Comma-separated. Other agents can find this one by tags.",
shortTitle: "Title",
shortTitlePlaceholder: "",
description: "Description",
descriptionPlaceholder: "Who this agent is, how they speak, and what they are good at. Used to form their persona.",
saveSettings: "Save",
saving: "Saving…",
settingsFailed: "Could not save settings",
search: "Search",
agentNamePlaceholder: "Agent name",
agents: "Agents",
noAgents: "No agents yet",
noMatchingAgents: "No matching agents",
queuedWork: "Queued…",
activityUnknown: "Work status is unavailable. Reconnecting.",
agentWorking: "Working",
newChat: "New chat",
localWorkspace: "Local workspace",
chats: "Chats",
backToChats: "Back to chats",
pickAgent: "Choose who to chat with",
workTools: "Work tools",
stop: "Stop",
computer: "Computer",
startWorkWith: "Start working with {name}",
startWorkHint: "Send a message, or open the computer on the right to watch it work.",
workingWith: "{name} is working…",
moreActions: "More actions",
messageTo: "Message {name}",
pickAgentFirst: "Choose an agent first",
send: "Send",
collapse: "Collapse",
computerOf: "{name}'s computer",
enlarge: "Expand",
loginHint: "Sign in inside this agent's own browser. The session stays on its computer — don't type passwords in chat.",
chat: "Chat",
mainMenu: "Main menu",
restart: "Restart",
update: "Update",
restartTitle: "Restart this computer",
updateTitle: "Rebuild the image and reopen if it changed",
restartConfirm: "Restart will briefly interrupt the desktop and any running work. Browser logins are kept. Restart now?",
updateConfirm:
"Update rebuilds this computer's image from the current Dockerfile. Logins and /workspace stay on the volume. If the image changed, the desktop will reopen. This can take several minutes. Update now?",
computerStarting: "Starting computer…",
computerRestarting: "Restarting computer…",
computerUpdating: "Updating computer image…",
computerRunning: "Running",
computerUpToDate: "Image is up to date",
computerNotReady: "Computer is not ready",
computerNotStarted: "Not started yet",
computerOff: "Off",
computerCrowded: "Too many computers were running, so an idle one was stopped to start this agent's.",
thinking: "Thinking",
needsReply: "Needs a reply",
stepComputer: "Using the computer",
stepSearch: "Searching",
stepFetch: "Reading a page",
stepBrowser: "Using the browser",
stepBox: "Running on the computer",
stepShell: "Running a command",
stepWrite: "Editing a file",
stepRead: "Reading a file",
stepWorking: "Working",
};
const ja: Messages = {
handoverTitle: "操作が必要です",
handoverHint: "パスワードや認証コードは下の元のサイトに入力してください。チャットには入力しないでください。",
handoverLocal: "既に開いているツールブラウザーで操作してください。",
handoverDone: "完了して続行",
handoverReturning: "操作を引き継いでいます…",
executionDetails: "実行の詳細",
language: "言語",
settings: "設定",
about: "バージョン情報",
general: "一般",
appearance: "外観",
theme: "テーマ",
themeSystem: "システムに合わせる",
themeLight: "ライト",
themeDark: "ダーク",
updates: "アップデート",
copyVersionInfo: "バージョン情報をコピー",
copied: "コピーしました",
copyright: "Copyright © 2026 Daniel Wang",
settingsLabel: "LazyBoy の設定",
workspaceDetail: "Agent と会話はこのマシンに残ります。",
versionLine: "バージョン {version}",
computerUpdateCopy: "全アシスタント共用のイメージを更新します。各 Agent のファイルとログインは自分のコンピュータに残ります。",
updateComputer: "コンピュータを更新",
restartComputer: "コンピュータを再起動",
restartComputerCopy: "この Agent のコンピュータを再起動します。ブラウザのログインは残ります。",
appComputer: "LazyBoy のコンピュータ",
computerBusyCopy: "Agent が作業中です。今操作すると中断されます。",
localBuild: "ローカルビルド",
close: "閉じる",
add: "追加",
addAgent: "Agent を追加",
createAgentLead: "先にペルソナを決めてください。名前と紹介は必須です。この Agent はその設定どおりに話します。",
addChannel: "チャンネルを追加",
channels: "チャンネル",
channelName: "チャンネル名",
channelNamePlaceholder: "例:プロジェクト",
channelDescription: "チャンネルに入れる Agent を 2 人以上選んでください。",
chooseAgents: "Agent を選ぶ",
channelHostHint: "このチャンネルの返信は {name} が担当します。",
createChannel: "チャンネルを作成",
deleteChannel: "チャンネルを削除",
membersCount: "{count} 人",
creating: "作成中…",
cancel: "キャンセル",
create: "作成",
deleteAgent: "Agent を削除",
deleteAgentConfirm: "{name} を削除しますか?会話と記憶も消えます。元に戻せません。",
agentSettings: "Agent の設定",
avatarAppearance: "アバター",
avatarAppearanceHint: "Blobatar の 10 種類の形から選ぶか、写真をアップロードします。",
avatarBot: "Bot",
avatarUpload: "アップロード",
avatarDrop: "画像をドロップまたは貼り付け",
avatarOr: "または",
avatarBrowse: "ファイルを選ぶ",
avatarSet: "アバターに設定",
avatarReset: "リセット",
avatarRestart: "やり直す",
avatarReposition: "ドラッグして位置を調整",
avatarZoom: "ズーム",
avatarPasted: "貼り付けた画像",
color: "色",
shape: "形",
chooseColor: "色 {value} を選ぶ",
customColor: "カスタムカラー",
name: "名前",
tags: "タグ(任意)",
tagsPlaceholder: "",
tagsLimit: "読点またはカンマで区切ります。他の Agent が探すときに使います。",
shortTitle: "肩書き",
shortTitlePlaceholder: "",
description: "説明",
descriptionPlaceholder: "この Agent は誰か、話し方、得意なこと。ペルソナの材料になります。",
saveSettings: "保存",
saving: "保存中…",
settingsFailed: "設定を保存できませんでした",
search: "検索",
agentNamePlaceholder: "Agent 名",
agents: "Agent",
noAgents: "まだ Agent がありません",
noMatchingAgents: "一致する Agent がありません",
queuedWork: "開始待ち…",
activityUnknown: "作業状態を確認できません。再接続しています。",
agentWorking: "作業中",
newChat: "新しい会話",
localWorkspace: "ローカルワークスペース",
chats: "チャット",
backToChats: "チャット一覧に戻る",
pickAgent: "チャット相手を選ぶ",
workTools: "作業ツール",
stop: "停止",
computer: "コンピュータ",
startWorkWith: "{name} と作業を始める",
startWorkHint: "メッセージを送るか、右のコンピュータを開いて作業を見てください。",
workingWith: "{name} が作業中…",
moreActions: "その他",
messageTo: "{name} にメッセージ",
pickAgentFirst: "先に Agent を選んでください",
send: "送信",
collapse: "折りたたむ",
computerOf: "{name} のコンピュータ",
enlarge: "拡大",
loginHint: "このエージェント自身のブラウザでログインしてください。セッションはそのコンピュータに残ります。パスワードをチャットに入力しないでください。",
chat: "チャット",
mainMenu: "メインメニュー",
restart: "再起動",
update: "更新",
restartTitle: "このコンピュータを再起動",
updateTitle: "イメージを再構築し、変更があれば開き直す",
restartConfirm: "再起動するとデスクトップと実行中の操作が一時的に中断されます。ブラウザのログインは残ります。再起動しますか?",
updateConfirm:
"更新すると現在の Dockerfile からこのコンピュータのイメージを再構築します。ログインと /workspace は volume に残ります。イメージが変わった場合はデスクトップを開き直します。数分かかることがあります。更新しますか?",
computerStarting: "コンピュータを起動中…",
computerRestarting: "コンピュータを再起動中…",
computerUpdating: "コンピュータのイメージを更新中…",
computerRunning: "実行中",
computerUpToDate: "イメージは最新です",
computerNotReady: "コンピュータの準備ができていません",
computerNotStarted: "まだ起動していません",
computerOff: "停止中",
computerCrowded: "同時に起動できるコンピュータ数の上限に達したため、この Agent を起動し、使っていないコンピュータを止めました。",
thinking: "考え中",
needsReply: "返信が必要です",
stepComputer: "コンピュータを操作中",
stepSearch: "検索中",
stepFetch: "ページを読み取り中",
stepBrowser: "ブラウザを操作中",
stepBox: "コンピュータで実行中",
stepShell: "コマンドを実行中",
stepWrite: "ファイルを編集中",
stepRead: "ファイルを読み取り中",
stepWorking: "処理中",
};
export const MESSAGES: Record<Locale, Messages> = {
"zh-Hant": zhHant,
"zh-Hans": zhHans,
en,
ja,
};
export function isLocale(value: string | null | undefined): value is Locale {
return LOCALES.includes(value as Locale);
}
export function detectLocale(): Locale {
try {
const saved = localStorage.getItem(STORAGE_KEY) || localStorage.getItem("grokboy.locale");
if (isLocale(saved)) return saved;
} catch {
/* ignore */
}
const nav = typeof navigator === "undefined" ? "" : navigator.language || "";
const lower = nav.toLowerCase();
if (lower.startsWith("ja")) return "ja";
if (lower.startsWith("en")) return "en";
if (lower.startsWith("zh")) {
if (lower.includes("cn") || lower.includes("sg") || lower.includes("hans")) return "zh-Hans";
return "zh-Hant";
}
return "zh-Hant";
}
export function formatMessage(template: string, vars?: Record<string, string>): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (_, key: string) => vars[key] ?? `{${key}}`);
}
type I18nValue = {
locale: Locale;
setLocale: (locale: Locale) => void;
t: Messages;
format: (key: MessageKey, vars?: Record<string, string>) => string;
};
const I18nContext = createContext<I18nValue | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(detectLocale);
useEffect(() => {
document.documentElement.lang = locale;
try {
localStorage.setItem(STORAGE_KEY, locale);
} catch {
/* ignore */
}
}, [locale]);
const value = useMemo<I18nValue>(() => {
const t = MESSAGES[locale];
return {
locale,
setLocale: setLocaleState,
t,
format: (key, vars) => formatMessage(t[key], vars),
};
}, [locale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nValue {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useI18n must be used within I18nProvider");
return ctx;
}