diff --git a/apps/web/src/lib/language.tsx b/apps/web/src/lib/language.tsx new file mode 100644 index 0000000..323df70 --- /dev/null +++ b/apps/web/src/lib/language.tsx @@ -0,0 +1,470 @@ + +import * as React from "react" + +export type Language = "zh-CN" | "zh-TW" | "en" + +export const LANGUAGE_STORAGE_KEY = "lanqin:language" + +export const languageOptions: { value: Language; label: string; shortLabel: string; htmlLang: string }[] = [ + { value: "zh-CN", label: "简体中文", shortLabel: "简", htmlLang: "zh-CN" }, + { value: "zh-TW", label: "繁體中文", shortLabel: "繁", htmlLang: "zh-TW" }, + { value: "en", label: "English", shortLabel: "EN", htmlLang: "en" }, +] + +const languageValues = new Set(languageOptions.map((item) => item.value)) + +export function getInitialLanguage(): Language { + if (typeof window === "undefined") return "zh-CN" + const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY) + if (stored && languageValues.has(stored as Language)) return stored as Language + const browserLanguage = window.navigator.language.toLowerCase() + if (browserLanguage.startsWith("zh-tw") || browserLanguage.startsWith("zh-hk") || browserLanguage.startsWith("zh-mo")) return "zh-TW" + if (browserLanguage.startsWith("en")) return "en" + return "zh-CN" +} + +export function setStoredLanguage(language: Language) { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language) + window.dispatchEvent(new CustomEvent("lanqin:language", { detail: language })) +} + +export function useLanguage() { + const [language, setLanguageState] = React.useState(getInitialLanguage) + React.useEffect(() => { + function sync() { + setLanguageState(getInitialLanguage()) + } + window.addEventListener("storage", sync) + window.addEventListener("lanqin:language", sync) + return () => { + window.removeEventListener("storage", sync) + window.removeEventListener("lanqin:language", sync) + } + }, []) + const setLanguage = React.useCallback((nextLanguage: Language) => { + setStoredLanguage(nextLanguage) + setLanguageState(nextLanguage) + }, []) + return [language, setLanguage] as const +} + +type Translation = { "zh-TW": string; en: string } + +const exactTranslations: Record = { + "收件箱": { "zh-TW": "收件匣", en: "Inbox" }, + "已发送": { "zh-TW": "已傳送", en: "Sent" }, + "草稿箱": { "zh-TW": "草稿匣", en: "Drafts" }, + "归档": { "zh-TW": "封存", en: "Archive" }, + "垃圾邮件": { "zh-TW": "垃圾郵件", en: "Spam" }, + "回收站": { "zh-TW": "垃圾桶", en: "Trash" }, + "全部邮件": { "zh-TW": "全部郵件", en: "All mail" }, + "未读邮件": { "zh-TW": "未讀郵件", en: "Unread" }, + "星标邮件": { "zh-TW": "星號郵件", en: "Starred" }, + "有附件": { "zh-TW": "有附件", en: "Has attachments" }, + "切换语言": { "zh-TW": "切換語言", en: "Switch language" }, + "写邮件": { "zh-TW": "寫郵件", en: "Compose" }, + "邮件夹": { "zh-TW": "郵件匣", en: "Folders" }, + "外部邮箱": { "zh-TW": "外部信箱", en: "External mailbox" }, + "标签": { "zh-TW": "標籤", en: "Labels" }, + "暂无标签": { "zh-TW": "暫無標籤", en: "No labels" }, + "收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" }, + "选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" }, + "没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" }, + "邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" }, + "打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" }, + "邮箱导航": { "zh-TW": "信箱導覽", en: "Mailbox navigation" }, + "刷新邮件": { "zh-TW": "重新整理郵件", en: "Refresh mail" }, + "自动刷新中": { "zh-TW": "自動重新整理中", en: "Auto-refreshing" }, + "自动刷新中...": { "zh-TW": "自動重新整理中...", en: "Auto-refreshing..." }, + "自动刷新已开启": { "zh-TW": "自動重新整理已開啟", en: "Auto-refresh enabled" }, + "全部已读": { "zh-TW": "全部已讀", en: "Mark all read" }, + "搜索邮件": { "zh-TW": "搜尋郵件", en: "Search mail" }, + "搜索远端邮件": { "zh-TW": "搜尋遠端郵件", en: "Search remote mail" }, + "搜索发送队列": { "zh-TW": "搜尋傳送佇列", en: "Search send queue" }, + "搜索待发送": { "zh-TW": "搜尋待傳送", en: "Search scheduled mail" }, + "选择当前页邮件": { "zh-TW": "選擇目前頁面的郵件", en: "Select messages on this page" }, + "加载中...": { "zh-TW": "載入中...", en: "Loading..." }, + "加载更多": { "zh-TW": "載入更多", en: "Load more" }, + "选择一封邮件阅读": { "zh-TW": "選擇一封郵件閱讀", en: "Select a message to read" }, + "回复": { "zh-TW": "回覆", en: "Reply" }, + "转发": { "zh-TW": "轉寄", en: "Forward" }, + "投递时间线": { "zh-TW": "投遞時間軸", en: "Delivery timeline" }, + "取消归档": { "zh-TW": "取消封存", en: "Unarchive" }, + "删除": { "zh-TW": "刪除", en: "Delete" }, + "附件": { "zh-TW": "附件", en: "Attachments" }, + "确认": { "zh-TW": "確認", en: "Confirm" }, + "取消": { "zh-TW": "取消", en: "Cancel" }, + "关闭": { "zh-TW": "關閉", en: "Close" }, + "打开": { "zh-TW": "開啟", en: "Open" }, + "刷新": { "zh-TW": "重新整理", en: "Refresh" }, + "新建文件夹": { "zh-TW": "新增資料夾", en: "New folder" }, + "文件夹名称": { "zh-TW": "資料夾名稱", en: "Folder name" }, + "创建": { "zh-TW": "建立", en: "Create" }, + "创建中...": { "zh-TW": "建立中...", en: "Creating..." }, + "例如:客户、账单、项目归档": { "zh-TW": "例如:客戶、帳單、專案封存", en: "e.g. Clients, invoices, project archive" }, + "新建标签": { "zh-TW": "新增標籤", en: "New label" }, + "删除标签失败": { "zh-TW": "刪除標籤失敗", en: "Failed to delete label" }, + "添加标签失败": { "zh-TW": "新增標籤失敗", en: "Failed to add label" }, + "移除标签失败": { "zh-TW": "移除標籤失敗", en: "Failed to remove label" }, + "创建标签失败": { "zh-TW": "建立標籤失敗", en: "Failed to create label" }, + "添加标签": { "zh-TW": "新增標籤", en: "Add label" }, + "请先在侧栏新建标签": { "zh-TW": "請先在側欄新增標籤", en: "Create a label in the sidebar first" }, + "无": { "zh-TW": "無", en: "None" }, + "无主题": { "zh-TW": "無主旨", en: "No subject" }, + "(无主题)": { "zh-TW": "(無主旨)", en: "(No subject)" }, + "未知发件人": { "zh-TW": "未知寄件者", en: "Unknown sender" }, + "未知发件人地址": { "zh-TW": "未知寄件者地址", en: "Unknown sender address" }, + "发件人": { "zh-TW": "寄件者", en: "Sender" }, + "发件人地址": { "zh-TW": "寄件者地址", en: "Sender address" }, + "收件人": { "zh-TW": "收件者", en: "Recipients" }, + "抄送": { "zh-TW": "副本", en: "Cc" }, + "密送": { "zh-TW": "密件副本", en: "Bcc" }, + "投递邮箱": { "zh-TW": "投遞信箱", en: "Delivered mailbox" }, + "发送时间": { "zh-TW": "傳送時間", en: "Send time" }, + "接收时间": { "zh-TW": "接收時間", en: "Received time" }, + "未填写收件人": { "zh-TW": "未填寫收件者", en: "No recipients" }, + "邮件正文": { "zh-TW": "郵件內文", en: "Message body" }, + "邮件详情": { "zh-TW": "郵件詳情", en: "Message details" }, + "上一封": { "zh-TW": "上一封", en: "Previous" }, + "下一封": { "zh-TW": "下一封", en: "Next" }, + "更多操作": { "zh-TW": "更多操作", en: "More actions" }, + "邮件不存在": { "zh-TW": "郵件不存在", en: "Message not found" }, + "标为已读": { "zh-TW": "標為已讀", en: "Mark as read" }, + "标为未读": { "zh-TW": "標為未讀", en: "Mark as unread" }, + "添加星标": { "zh-TW": "加上星號", en: "Add star" }, + "取消星标": { "zh-TW": "移除星號", en: "Remove star" }, + "移入回收站": { "zh-TW": "移至垃圾桶", en: "Move to trash" }, + "移入垃圾邮件": { "zh-TW": "移至垃圾郵件", en: "Move to spam" }, + "批量操作": { "zh-TW": "批次操作", en: "Bulk actions" }, + "移动到": { "zh-TW": "移動到", en: "Move to" }, + "移到最上": { "zh-TW": "移到最上方", en: "Move to top" }, + "上移一位": { "zh-TW": "上移一位", en: "Move up" }, + "下移一位": { "zh-TW": "下移一位", en: "Move down" }, + "移到最下": { "zh-TW": "移到最下方", en: "Move to bottom" }, + "删除文件夹": { "zh-TW": "刪除資料夾", en: "Delete folder" }, + "远端文件夹没有邮件": { "zh-TW": "遠端資料夾沒有郵件", en: "No messages in remote folder" }, + "当前筛选条件下没有远端邮件": { "zh-TW": "目前篩選條件下沒有遠端郵件", en: "No remote messages match the current filters" }, + "没有待发送邮件": { "zh-TW": "沒有待傳送郵件", en: "No scheduled mail" }, + "当前搜索没有匹配的定时邮件": { "zh-TW": "目前搜尋沒有符合的定時郵件", en: "No scheduled messages match your search" }, + "发送队列为空": { "zh-TW": "傳送佇列為空", en: "Send queue is empty" }, + "当前搜索没有匹配的发送任务": { "zh-TW": "目前搜尋沒有符合的傳送任務", en: "No send tasks match your search" }, + "当前筛选条件下没有邮件": { "zh-TW": "目前篩選條件下沒有郵件", en: "No messages match the current filters" }, + "暂无星标邮件": { "zh-TW": "暫無星號郵件", en: "No starred mail" }, + "当前标签没有邮件": { "zh-TW": "目前標籤沒有郵件", en: "No messages with this label" }, + "收件箱暂时为空": { "zh-TW": "收件匣暫時為空", en: "Inbox is empty" }, + "还没有草稿": { "zh-TW": "還沒有草稿", en: "No drafts yet" }, + "还没有已发送邮件": { "zh-TW": "還沒有已傳送郵件", en: "No sent mail yet" }, + "回收站是空的": { "zh-TW": "垃圾桶是空的", en: "Trash is empty" }, + "暂无垃圾邮件": { "zh-TW": "暫無垃圾郵件", en: "No spam" }, + "当前文件夹没有邮件": { "zh-TW": "目前資料夾沒有郵件", en: "No messages in this folder" }, + "待发送": { "zh-TW": "待傳送", en: "Scheduled" }, + "发送队列": { "zh-TW": "傳送佇列", en: "Send queue" }, + "全部状态": { "zh-TW": "全部狀態", en: "All statuses" }, + "排队中": { "zh-TW": "佇列中", en: "Queued" }, + "发送中": { "zh-TW": "傳送中", en: "Sending" }, + "发送失败": { "zh-TW": "傳送失敗", en: "Failed" }, + "已投递": { "zh-TW": "已投遞", en: "Delivered" }, + "已取消": { "zh-TW": "已取消", en: "Canceled" }, + "等待发送": { "zh-TW": "等待傳送", en: "Pending" }, + "清除": { "zh-TW": "清除", en: "Clear" }, + "未记录收件人": { "zh-TW": "未記錄收件者", en: "No recorded recipients" }, + "时间线": { "zh-TW": "時間軸", en: "Timeline" }, + "重试": { "zh-TW": "重試", en: "Retry" }, + "暂无投递事件": { "zh-TW": "暫無投遞事件", en: "No delivery events" }, + "队列事件": { "zh-TW": "佇列事件", en: "Queue event" }, + "定时发送": { "zh-TW": "定時傳送", en: "Scheduled send" }, + "未知": { "zh-TW": "未知", en: "Unknown" }, + "同步": { "zh-TW": "同步", en: "Sync" }, + "直连": { "zh-TW": "直連", en: "Direct" }, + "还没有可用邮箱": { "zh-TW": "還沒有可用信箱", en: "No mailbox available" }, + "请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。": { "zh-TW": "請在個人中心申請信箱,或聯絡管理員為目前帳號分配信箱。", en: "Apply for a mailbox in Profile, or contact an administrator to assign one to this account." }, + "前往个人中心": { "zh-TW": "前往個人中心", en: "Go to profile" }, + "无邮箱前台权限": { "zh-TW": "無信箱前台權限", en: "No mailbox access" }, + "当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." }, + "无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" }, + "当前账号可以访问邮箱前台,但未开启邮件查看权限。": { "zh-TW": "目前帳號可以存取信箱前台,但未開啟郵件檢視權限。", en: "This account can access mailbox UI, but mail reading is not enabled." }, + "无定时发送权限": { "zh-TW": "無定時傳送權限", en: "No scheduled send permission" }, + "当前账号不能查看或管理定时发送任务。": { "zh-TW": "目前帳號不能檢視或管理定時傳送任務。", en: "This account cannot view or manage scheduled send tasks." }, + "无发送队列权限": { "zh-TW": "無傳送佇列權限", en: "No send queue permission" }, + "当前账号不能查看发送队列。": { "zh-TW": "目前帳號不能檢視傳送佇列。", en: "This account cannot view the send queue." }, + "操作失败": { "zh-TW": "操作失敗", en: "Operation failed" }, + "请稍后重试": { "zh-TW": "請稍後重試", en: "Please try again later" }, + "已取消定时发送": { "zh-TW": "已取消定時傳送", en: "Scheduled send canceled" }, + "已移除失败记录": { "zh-TW": "已移除失敗記錄", en: "Failed record removed" }, + "文件夹已创建": { "zh-TW": "資料夾已建立", en: "Folder created" }, + "创建文件夹失败": { "zh-TW": "建立資料夾失敗", en: "Failed to create folder" }, + "文件夹排序失败": { "zh-TW": "資料夾排序失敗", en: "Failed to reorder folders" }, + "文件夹已删除": { "zh-TW": "資料夾已刪除", en: "Folder deleted" }, + "删除文件夹失败": { "zh-TW": "刪除資料夾失敗", en: "Failed to delete folder" }, + "已重新加入发送队列": { "zh-TW": "已重新加入傳送佇列", en: "Added back to send queue" }, + "重试失败": { "zh-TW": "重試失敗", en: "Retry failed" }, + "已取消发送任务": { "zh-TW": "已取消傳送任務", en: "Send task canceled" }, + "取消失败": { "zh-TW": "取消失敗", en: "Cancel failed" }, + "当前没有未读邮件": { "zh-TW": "目前沒有未讀郵件", en: "No unread messages" }, + "删除所选邮件?": { "zh-TW": "刪除所選郵件?", en: "Delete selected messages?" }, + "删除邮件": { "zh-TW": "刪除郵件", en: "Delete messages" }, + "批量操作失败": { "zh-TW": "批次操作失敗", en: "Bulk action failed" }, + "删除这封邮件?": { "zh-TW": "刪除這封郵件?", en: "Delete this message?" }, + "这封草稿已在待发送队列中": { "zh-TW": "這封草稿已在待傳送佇列中", en: "This draft is already scheduled" }, + "请先取消定时发送,再继续编辑。": { "zh-TW": "請先取消定時傳送,再繼續編輯。", en: "Cancel the scheduled send before editing." }, + "打开草稿失败": { "zh-TW": "開啟草稿失敗", en: "Failed to open draft" }, + "文件夹内的邮件会移回收件箱,不会被删除。": { "zh-TW": "資料夾內的郵件會移回收件匣,不會被刪除。", en: "Messages in this folder will be moved back to Inbox, not deleted." }, + "编辑草稿": { "zh-TW": "編輯草稿", en: "Edit draft" }, + "打开邮件": { "zh-TW": "開啟郵件", en: "Open message" }, + "发件邮箱": { "zh-TW": "寄件信箱", en: "From mailbox" }, + "未选择": { "zh-TW": "未選擇", en: "Not selected" }, + "分别发送": { "zh-TW": "分別傳送", en: "Send separately" }, + "主 题": { "zh-TW": "主 旨", en: "Subject" }, + "输入主题": { "zh-TW": "輸入主旨", en: "Enter subject" }, + "写信": { "zh-TW": "寫信", en: "Compose" }, + "正在保存草稿...": { "zh-TW": "正在儲存草稿...", en: "Saving draft..." }, + "草稿保存失败": { "zh-TW": "草稿儲存失敗", en: "Failed to save draft" }, + "发送": { "zh-TW": "傳送", en: "Send" }, + "发送中...": { "zh-TW": "傳送中...", en: "Sending..." }, + "发送成功": { "zh-TW": "傳送成功", en: "Sent" }, + "定时": { "zh-TW": "定時", en: "Schedule" }, + "继续": { "zh-TW": "繼續", en: "Continue" }, + "继续发送": { "zh-TW": "繼續傳送", en: "Continue sending" }, + "继续分别发送": { "zh-TW": "繼續分別傳送", en: "Continue sending separately" }, + "继续定时发送": { "zh-TW": "繼續定時傳送", en: "Continue scheduling" }, + "请选择发件邮箱": { "zh-TW": "請選擇寄件信箱", en: "Select a from mailbox" }, + "确认发送这封邮件?": { "zh-TW": "確認傳送這封郵件?", en: "Send this message?" }, + "确认定时发送?": { "zh-TW": "確認定時傳送?", en: "Schedule this message?" }, + "请选择发送时间": { "zh-TW": "請選擇傳送時間", en: "Select a send time" }, + "发送时间需要晚于当前时间": { "zh-TW": "傳送時間需要晚於目前時間", en: "Send time must be in the future" }, + "确认定时": { "zh-TW": "確認定時", en: "Confirm schedule" }, + "正在设置...": { "zh-TW": "正在設定...", en: "Scheduling..." }, + "输入正文": { "zh-TW": "輸入內文", en: "Write your message" }, + "正文": { "zh-TW": "內文", en: "Body" }, + "撤销": { "zh-TW": "復原", en: "Undo" }, + "重做": { "zh-TW": "重做", en: "Redo" }, + "插入": { "zh-TW": "插入", en: "Insert" }, + "链接": { "zh-TW": "連結", en: "Link" }, + "图片链接": { "zh-TW": "圖片連結", en: "Image link" }, + "分隔线": { "zh-TW": "分隔線", en: "Divider" }, + "日程": { "zh-TW": "行程", en: "Event" }, + "表情": { "zh-TW": "表情", en: "Emoji" }, + "格式": { "zh-TW": "格式", en: "Format" }, + "预览": { "zh-TW": "預覽", en: "Preview" }, + "签名": { "zh-TW": "簽名", en: "Signature" }, + "清除格式": { "zh-TW": "清除格式", en: "Clear formatting" }, + "加粗": { "zh-TW": "粗體", en: "Bold" }, + "斜体": { "zh-TW": "斜體", en: "Italic" }, + "下划线": { "zh-TW": "底線", en: "Underline" }, + "删除线": { "zh-TW": "刪除線", en: "Strikethrough" }, + "文字颜色": { "zh-TW": "文字顏色", en: "Text color" }, + "高亮": { "zh-TW": "醒目提示", en: "Highlight" }, + "无序列表": { "zh-TW": "項目符號清單", en: "Bulleted list" }, + "有序列表": { "zh-TW": "編號清單", en: "Numbered list" }, + "减少缩进": { "zh-TW": "減少縮排", en: "Decrease indent" }, + "增加缩进": { "zh-TW": "增加縮排", en: "Increase indent" }, + "左对齐": { "zh-TW": "靠左對齊", en: "Align left" }, + "居中": { "zh-TW": "置中", en: "Center" }, + "右对齐": { "zh-TW": "靠右對齊", en: "Align right" }, + "引用": { "zh-TW": "引用", en: "Quote" }, + "代码块": { "zh-TW": "程式碼區塊", en: "Code block" }, + "邮件预览": { "zh-TW": "郵件預覽", en: "Message preview" }, + "编辑链接": { "zh-TW": "編輯連結", en: "Edit link" }, + "插入链接": { "zh-TW": "插入連結", en: "Insert link" }, + "编辑图片": { "zh-TW": "編輯圖片", en: "Edit image" }, + "插入图片": { "zh-TW": "插入圖片", en: "Insert image" }, + "链接地址": { "zh-TW": "連結地址", en: "Link URL" }, + "图片地址": { "zh-TW": "圖片地址", en: "Image URL" }, + "显示文字": { "zh-TW": "顯示文字", en: "Display text" }, + "默认使用链接地址": { "zh-TW": "預設使用連結地址", en: "Defaults to the URL" }, + "替代文字": { "zh-TW": "替代文字", en: "Alt text" }, + "图片说明": { "zh-TW": "圖片說明", en: "Image description" }, + "更新": { "zh-TW": "更新", en: "Update" }, + "默认字体": { "zh-TW": "預設字體", en: "Default font" }, + "微软雅黑": { "zh-TW": "微軟正黑體", en: "Microsoft YaHei" }, + "小号": { "zh-TW": "小號", en: "Small" }, + "中号": { "zh-TW": "中號", en: "Medium" }, + "大号": { "zh-TW": "大號", en: "Large" }, + "默认": { "zh-TW": "預設", en: "Default" }, + "红色": { "zh-TW": "紅色", en: "Red" }, + "蓝色": { "zh-TW": "藍色", en: "Blue" }, + "绿色": { "zh-TW": "綠色", en: "Green" }, + "紫色": { "zh-TW": "紫色", en: "Purple" }, + "黄色": { "zh-TW": "黃色", en: "Yellow" }, + "粉色": { "zh-TW": "粉色", en: "Pink" }, + "无高亮": { "zh-TW": "無醒目提示", en: "No highlight" }, + "新建日程": { "zh-TW": "新增行程", en: "New event" }, + "输入日程主题": { "zh-TW": "輸入行程主旨", en: "Enter event title" }, + "请输入日程主题": { "zh-TW": "請輸入行程主旨", en: "Enter an event title" }, + "开始": { "zh-TW": "開始", en: "Start" }, + "全天": { "zh-TW": "全天", en: "All day" }, + "持续": { "zh-TW": "持續", en: "Duration" }, + "自定义": { "zh-TW": "自訂", en: "Custom" }, + "提醒": { "zh-TW": "提醒", en: "Reminder" }, + "重复": { "zh-TW": "重複", en: "Repeat" }, + "农历": { "zh-TW": "農曆", en: "Lunar" }, + "位置": { "zh-TW": "位置", en: "Location" }, + "请输入位置": { "zh-TW": "請輸入位置", en: "Enter location" }, + "描述": { "zh-TW": "描述", en: "Description" }, + "输入描述": { "zh-TW": "輸入描述", en: "Enter description" }, + "确定": { "zh-TW": "確定", en: "OK" }, + "准时": { "zh-TW": "準時", en: "On time" }, + "永不": { "zh-TW": "永不", en: "Never" }, + "每天": { "zh-TW": "每天", en: "Daily" }, + "每周": { "zh-TW": "每週", en: "Weekly" }, + "每月": { "zh-TW": "每月", en: "Monthly" }, + "每年": { "zh-TW": "每年", en: "Yearly" }, + "明早 9 点": { "zh-TW": "明早 9 點", en: "Tomorrow 9 AM" }, + "下周一 9 点": { "zh-TW": "下週一 9 點", en: "Next Monday 9 AM" }, + "时间": { "zh-TW": "時間", en: "Time" }, +} + +const templateTranslations: { pattern: RegExp; "zh-TW": string; en: string }[] = [ + { pattern: /^已刷新 (.+)$/, "zh-TW": "已重新整理 $1", en: "Refreshed $1" }, + { pattern: /^已选 (\d+) 封$/, "zh-TW": "已選 $1 封", en: "$1 selected" }, + { pattern: /^(\d+) \/ (\d+) 封邮件$/, "zh-TW": "$1 / $2 封郵件", en: "$1 / $2 messages" }, + { pattern: /^(\d+) \/ (\d+) 封$/, "zh-TW": "$1 / $2 封", en: "$1 / $2 messages" }, + { pattern: /^(\d+) \/ (\d+) 封定时邮件$/, "zh-TW": "$1 / $2 封定時郵件", en: "$1 / $2 scheduled messages" }, + { pattern: /^(\d+) \/ (\d+) 个发送任务$/, "zh-TW": "$1 / $2 個傳送任務", en: "$1 / $2 send tasks" }, + { pattern: /^收到 (\d+) 封新邮件$/, "zh-TW": "收到 $1 封新郵件", en: "$1 new messages" }, + { pattern: /^新邮件:(.+)$/, "zh-TW": "新郵件:$1", en: "New message: $1" }, + { pattern: /^(.+) 等发来新邮件$/, "zh-TW": "$1 等寄來新郵件", en: "$1 and others sent new mail" }, + { pattern: /^已标记 (\d+) 封邮件为已读$/, "zh-TW": "已標記 $1 封郵件為已讀", en: "Marked $1 messages as read" }, + { pattern: /^已处理 (\d+) 封邮件$/, "zh-TW": "已處理 $1 封郵件", en: "Processed $1 messages" }, + { pattern: /^将删除当前选中的 (\d+) 封邮件,此操作无法从邮件列表中恢复。$/, "zh-TW": "將刪除目前選取的 $1 封郵件,此操作無法從郵件清單中復原。", en: "This will delete the $1 selected messages. This cannot be undone from the message list." }, + { pattern: /^邮件“(.+)”将被删除。$/, "zh-TW": "郵件「$1」將被刪除。", en: "Message “$1” will be deleted." }, + { pattern: /^删除文件夹“(.+)”?$/, "zh-TW": "刪除資料夾「$1」?", en: "Delete folder “$1”?" }, + { pattern: /^删除标签 (.+)$/, "zh-TW": "刪除標籤 $1", en: "Delete label $1" }, + { pattern: /^移除标签 (.+)$/, "zh-TW": "移除標籤 $1", en: "Remove label $1" }, + { pattern: /^移除 (.+)$/, "zh-TW": "移除 $1", en: "Remove $1" }, + { pattern: /^已将 (\d+) 封邮件移回收件箱$/, "zh-TW": "已將 $1 封郵件移回收件匣", en: "Moved $1 messages back to Inbox" }, + { pattern: /^发给 (.+)$/, "zh-TW": "寄給 $1", en: "To $1" }, + { pattern: /^来源:(.+)$/, "zh-TW": "來源:$1", en: "Source: $1" }, + { pattern: /^尝试:(\d+)\/(\d+)$/, "zh-TW": "嘗試:$1/$2", en: "Attempts: $1/$2" }, + { pattern: /^下次:(.+)$/, "zh-TW": "下次:$1", en: "Next: $1" }, + { pattern: /^投递于 (.+)$/, "zh-TW": "投遞於 $1", en: "Delivered at $1" }, + { pattern: /^尝试次数:(\d+)$/, "zh-TW": "嘗試次數:$1", en: "Attempts: $1" }, + { pattern: /^已分别发送 (\d+) 封邮件$/, "zh-TW": "已分別傳送 $1 封郵件", en: "Sent $1 messages separately" }, + { pattern: /^已定时发送 (.+)$/, "zh-TW": "已定時傳送 $1", en: "Scheduled for $1" }, + { pattern: /^草稿已保存 (.+)$/, "zh-TW": "草稿已儲存 $1", en: "Draft saved $1" }, + { pattern: /^发送时间:(.+)$/, "zh-TW": "傳送時間:$1", en: "Send time: $1" }, + { pattern: /^当前单个附件上限 (.+)$/, "zh-TW": "目前單個附件上限 $1", en: "Current per-attachment limit: $1" }, + { pattern: /^(\d+)分钟$/, "zh-TW": "$1分鐘", en: "$1 min" }, + { pattern: /^(\d+)分钟前$/, "zh-TW": "$1分鐘前", en: "$1 min before" }, + { pattern: /^(\d+) 分钟后$/, "zh-TW": "$1 分鐘後", en: "In $1 min" }, + { pattern: /^(\d+)小时$/, "zh-TW": "$1小時", en: "$1 hr" }, + { pattern: /^(\d+)小时前$/, "zh-TW": "$1小時前", en: "$1 hr before" }, + { pattern: /^(\d+) 小时后$/, "zh-TW": "$1 小時後", en: "In $1 hr" }, + { pattern: /^(\d+)天$/, "zh-TW": "$1天", en: "$1 day" }, + { pattern: /^(\d+)天前$/, "zh-TW": "$1天前", en: "$1 day before" }, + { pattern: /^(.+)前$/, "zh-TW": "$1前", en: "$1 before" }, +] + +export function translateUiText(value: string, language: Language): string { + if (language === "zh-CN" || !value) return value + const match = value.match(/^(\s*)([\s\S]*?)(\s*)$/) + const leading = match?.[1] || "" + const text = match?.[2] || value + const trailing = match?.[3] || "" + if (!text) return value + const translated = translateCore(text, language) + return `${leading}${translated}${trailing}` +} + +function translateCore(text: string, language: Exclude): string { + const exact = exactTranslations[text] + if (exact) return exact[language] + for (const rule of templateTranslations) { + if (rule.pattern.test(text)) return text.replace(rule.pattern, rule[language]) + } + return text +} + +const textSources = new WeakMap() +const textLastApplied = new WeakMap() +const attrSources = new WeakMap>>() +const attrLastApplied = new WeakMap>>() +const translatableAttributes = ["placeholder", "title", "aria-label"] as const +let translateTimer: number | undefined + +export function LanguageDomSync() { + const [language] = useLanguage() + React.useEffect(() => { + document.documentElement.lang = languageOptions.find((item) => item.value === language)?.htmlLang || language + scheduleLocalize(language) + const observer = new MutationObserver(() => scheduleLocalize(language)) + observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: [...translatableAttributes] }) + return () => { + observer.disconnect() + if (translateTimer) window.clearTimeout(translateTimer) + } + }, [language]) + return null +} + +function scheduleLocalize(language: Language) { + if (typeof window === "undefined") return + if (translateTimer) window.clearTimeout(translateTimer) + translateTimer = window.setTimeout(() => localizeDocument(language), 0) +} + +function localizeDocument(language: Language) { + if (!document.body) return + localizeElement(document.body, language) +} + +function localizeElement(root: Element, language: Language) { + if (shouldSkipElement(root)) return + localizeAttributes(root, language) + for (const child of Array.from(root.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) localizeTextNode(child as Text, language) + else if (child.nodeType === Node.ELEMENT_NODE) localizeElement(child as Element, language) + } +} + +function localizeTextNode(node: Text, language: Language) { + if (!node.parentElement || shouldSkipElement(node.parentElement)) return + const current = node.textContent || "" + if (!current.trim()) return + let source = textSources.get(node) + const last = textLastApplied.get(node) + if (!source || (last !== undefined && current !== last && shouldTranslate(current))) { + source = current + textSources.set(node, source) + } + if (!source || !shouldTranslate(source)) return + const next = translateUiText(source, language) + textLastApplied.set(node, next) + if (current !== next) node.textContent = next +} + +function localizeAttributes(element: Element, language: Language) { + if (shouldSkipElement(element)) return + for (const attr of translatableAttributes) { + const current = element.getAttribute(attr) + if (!current || !current.trim()) continue + let sources = attrSources.get(element) + if (!sources) { + sources = {} + attrSources.set(element, sources) + } + let applied = attrLastApplied.get(element) + if (!applied) { + applied = {} + attrLastApplied.set(element, applied) + } + if (!sources[attr] || (applied[attr] !== undefined && current !== applied[attr] && shouldTranslate(current))) sources[attr] = current + const source = sources[attr] + if (!source || !shouldTranslate(source)) continue + const next = translateUiText(source, language) + applied[attr] = next + if (current !== next) element.setAttribute(attr, next) + } +} + +function shouldSkipElement(element: Element) { + const tag = element.tagName.toLowerCase() + if (["script", "style", "code", "pre", "textarea", "option"].includes(tag)) return true + return Boolean(element.closest("[data-lanqin-i18n-ignore], [contenteditable='true'], .ProseMirror")) +} + +function shouldTranslate(value: string) { + const text = value.trim() + if (!text) return false + return Boolean(exactTranslations[text]) || templateTranslations.some((rule) => rule.pattern.test(text)) +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 985e4e6..787c4e1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom" import { Toaster } from "@/components/ui/toaster" +import { LanguageDomSync } from "@/lib/language" import { ProtectedLayout } from "@/components/protected-layout" import { AdminOnly } from "@/components/admin-only" import { LoginPage } from "@/pages/login" @@ -32,6 +33,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( + , ) diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index e9c8980..6a23d98 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -17,6 +17,7 @@ import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, Ma import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { useDisplayMode } from "@/lib/display-mode" +import { Language, languageOptions, useLanguage } from "@/lib/language" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" @@ -102,6 +103,7 @@ export function MailPage() { const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState([]) const [externalFolder, setExternalFolder] = React.useState("INBOX") const [darkMode, setDarkMode] = React.useState(getInitialTheme) + const [language, setLanguage] = useLanguage() const [displayMode] = useDisplayMode() const isMobile = useIsMobile() const [refreshing, setRefreshing] = React.useState(false) @@ -980,6 +982,8 @@ export function MailPage() { email={me.data?.user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((value) => !value)} + language={language} + onLanguageChange={setLanguage} onSettings={openSettings} />
@@ -2591,8 +2595,9 @@ function NewLabelButton({ collapsed, pending, onCreate, editing, onEditingChange ) } -function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onSettings: () => void }) { +function AccountHeader({ collapsed, name, email, darkMode, language, onToggleTheme, onLanguageChange, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; language: Language; onToggleTheme: () => void; onLanguageChange: (language: Language) => void; onSettings: () => void }) { const displayName = cleanAccountName(name, email) + const currentLanguage = languageOptions.find((item) => item.value === language) || languageOptions[0] if (collapsed) { return (
@@ -2616,6 +2621,21 @@ function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onSett + + + + + + {languageOptions.map((item) => ( + onLanguageChange(item.value)} className="gap-2"> + {item.label} + + + ))} + +