feat: language indicator

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-09-13 22:01:02 +08:00
parent d81c2b5791
commit bf7da0431f
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
17 changed files with 136 additions and 83 deletions

View File

@ -0,0 +1,60 @@
import fs from "node:fs"
import path from "node:path"
type LanguageCompletion = Record<string, number>
function getLanguageFiles(dir: string): string[] {
return fs.readdirSync(dir).filter((file) => file.endsWith(".json"))
}
function getNamespaces(localesDir: string): string[] {
return fs
.readdirSync(localesDir)
.filter((file) => fs.statSync(path.join(localesDir, file)).isDirectory())
}
function countKeys(obj: any): number {
let count = 0
for (const key in obj) {
if (typeof obj[key] === "object") {
count += countKeys(obj[key])
} else {
count++
}
}
return count
}
function calculateCompleteness(localesDir: string): LanguageCompletion {
const namespaces = getNamespaces(localesDir)
const languages = new Set<string>()
const keyCount: Record<string, number> = {}
namespaces.forEach((namespace) => {
const namespaceDir = path.join(localesDir, namespace)
const files = getLanguageFiles(namespaceDir)
files.forEach((file) => {
const lang = path.basename(file, ".json")
languages.add(lang)
const content = JSON.parse(fs.readFileSync(path.join(namespaceDir, file), "utf-8"))
keyCount[lang] = (keyCount[lang] || 0) + countKeys(content)
})
})
const enCount = keyCount["en"] || 0
const completeness: LanguageCompletion = {}
languages.forEach((lang) => {
if (lang !== "en") {
const percent = Math.round((keyCount[lang] / enCount) * 100)
completeness[lang] = percent
}
})
return completeness
}
const i18n = calculateCompleteness(path.resolve(__dirname, "../locales"))
export default i18n

View File

@ -11,6 +11,7 @@ import { prerelease } from "semver"
import type { Plugin, UserConfig } from "vite"
import { getGitHash } from "../scripts/lib"
import i18nCompleteness from "./i18n-completeness"
const pkg = JSON.parse(readFileSync("package.json", "utf8"))
const isCI = process.env.CI === "true" || process.env.CI === "1"
@ -134,6 +135,8 @@ export const viteRenderBaseConfig = {
RELEASE_CHANNEL: JSON.stringify((prerelease(pkg.version)?.[0] as string) || "stable"),
DEBUG: process.env.DEBUG === "true",
I18N_COMPLETENESS_MAP: JSON.stringify({ ...i18nCompleteness, en: 100 }),
},
} satisfies UserConfig

View File

@ -2,6 +2,9 @@
"ai_daily.title": "Top News - {{title}}",
"ai_daily.tooltip.content": "Here is news selected by AI from your timeline (<From /> - <To />) that may be important to you.",
"ai_daily.tooltip.update_schedule": "Update daily at 8 AM and 8 PM.",
"app.app_name": "APP_NAME",
"app.copy_logo_svg": "Copy Logo SVG",
"app.toggle_sidebar": "Toggle Sidebar",
"discover.any_url_or_keyword": "Any URL or Keyword",
"discover.default_option": " (default)",
"discover.feed_description": "The description of this feed is as follows, and you can fill out the parameter form with the relevant information.",

View File

@ -2,6 +2,9 @@
"ai_daily.title": "头条 - {{title}}",
"ai_daily.tooltip.content": "这里是通过 AI 从您的时间线中选择的头条新闻(<From /> - <To />),可能对您很重要。",
"ai_daily.tooltip.update_schedule": "每天早上 8 点、晚上 8 点更新。",
"app.app_name": "APP_NAME",
"app.copy_logo_svg": "复制 Logo SVG",
"app.toggle_sidebar": "切换侧边栏",
"discover.any_url_or_keyword": "任何 URL 或关键词",
"discover.default_option": " (默认)",
"discover.feed_description": "此 Feed 的描述如下,您可以填写相关信息。",

View File

@ -1,4 +1,5 @@
{
"app.copied_to_clipboard": "Copied to clipboard",
"cancel": "Cancel",
"confirm": "Confirm",
"ok": "OK",

28
locales/common/ja.json Normal file
View File

@ -0,0 +1,28 @@
{
"app.copied_to_clipboard": "クリップボードにコピーしました",
"cancel": "キャンセル",
"confirm": "確認",
"ok": "OK",
"quantifier.piece": "個",
"time.last_night": "昨夜",
"time.the_night_before_last": "一昨夜",
"time.today": "今日",
"time.yesterday": "昨日",
"tips.load-lng-error": "言語パックの読み込みに失敗しました",
"words.back": "戻る",
"words.copy": "コピー",
"words.edit": "編集",
"words.entry": "エントリー",
"words.id": "ID",
"words.items_one": "アイテム",
"words.items_other": "アイテム",
"words.local": "ローカル",
"words.record": "記録",
"words.record_one": "記録",
"words.record_other": "記録",
"words.result": "結果",
"words.result_one": "結果",
"words.result_other": "結果",
"words.space": "",
"words.which.all": "すべて"
}

View File

@ -1,4 +1,5 @@
{
"app.copied_to_clipboard": "已复制到剪贴板",
"cancel": "取消",
"confirm": "确认",
"ok": "好",

View File

@ -1,5 +1,6 @@
{
"langs.en": "English",
"langs.zh-CN": "简体中文(部分完成)",
"langs.ja": "日本語",
"langs.zh-CN": "简体中文",
"name": "English"
}

6
locales/lang/ja.json Normal file
View File

@ -0,0 +1,6 @@
{
"langs.en": "English",
"langs.ja": "日本語",
"langs.zh-CN": "简体中文",
"name": "日本語"
}

View File

@ -1,5 +1,6 @@
{
"langs.en": "English",
"langs.zh-CN": "简体中文(部分完成)",
"name": "English"
"langs.ja": "日本語",
"langs.zh-CN": "简体中文",
"name": "简体中文"
}

View File

@ -1,16 +1,9 @@
export const currentSupportedLanguages = (() => {
const langsFiles = import.meta.glob("../../../../locales/app/*.json")
const langs = [] as string[]
for (const key in langsFiles) {
langs.push(key.split("/").pop()?.replace(".json", "") as string)
}
return langs
})()
export const currentSupportedLanguages = ["en", "ja", "zh-CN"]
export const dayjsLocaleImportMap = {
en: ["en", () => import("dayjs/locale/en")],
["zh-CN"]: ["zh-cn", () => import("dayjs/locale/zh-cn")],
["ja"]: ["ja", () => import("dayjs/locale/ja")],
}
export const ns = ["app", "common", "lang", "settings", "shortcuts"] as const

View File

@ -1,8 +1,10 @@
import en from "../../../../locales/app/en.json"
import common_en from "../../../../locales/common/en.json"
import common_ja from "../../../../locales/common/ja.json"
import common_zhCN from "../../../../locales/common/zh-CN.json"
import external_en from "../../../../locales/external/en.json"
import lang_en from "../../../../locales/lang/en.json"
import lang_ja from "../../../../locales/lang/ja.json"
import lang_zhCN from "../../../../locales/lang/zh-CN.json"
import settings_en from "../../../../locales/settings/en.json"
import shortcuts_en from "../../../../locales/shortcuts/en.json"
@ -28,4 +30,8 @@ export const defaultResources = {
lang: lang_zhCN,
common: common_zhCN,
},
ja: {
lang: lang_ja,
common: common_ja,
},
}

View File

@ -2,3 +2,4 @@
declare const APP_VERSION: string
declare const APP_NAME: string
declare const RELEASE_CHANNEL: string
declare const I18N_COMPLETENESS_MAP: Record<string, number>

View File

@ -1,7 +1,5 @@
import { PopoverPortal } from "@radix-ui/react-popover"
import { ActionButton, Button, IconButton } from "@renderer/components/ui/button"
import { Kbd, KbdCombined } from "@renderer/components/ui/kbd/Kbd"
import { Popover, PopoverContent, PopoverTrigger } from "@renderer/components/ui/popover"
import { RootPortal } from "@renderer/components/ui/portal"
import { HotKeyScopeMap } from "@renderer/constants"
import { shortcuts } from "@renderer/constants/shortcuts"
@ -176,65 +174,6 @@ const ConfirmMarkAllReadInfo = ({ undo }: { undo: () => any }) => {
)
}
/**
* @deprecated
*/
export const MarkAllReadPopover = forwardRef<HTMLButtonElement, MarkAllButtonProps>(
({ filter, className, which = "all", shortcut }, ref) => {
const [markPopoverOpen, setMarkPopoverOpen] = useState(false)
const handleMarkAllAsRead = useMarkAllByRoute(filter)
return (
<Popover open={markPopoverOpen} onOpenChange={setMarkPopoverOpen}>
<PopoverTrigger asChild>
<ActionButton
shortcut={shortcut ? shortcuts.entries.markAllAsRead.key : undefined}
tooltip={
<span>
Mark
<span> </span>
{which}
<span> </span>
as read
</span>
}
className={className}
ref={ref}
>
<i className="i-mgc-check-circle-cute-re" />
</ActionButton>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent className="flex w-fit flex-col items-center justify-center gap-3 !py-3 [&_button]:text-xs">
<div className="text-sm">
<Trans
i18nKey="mark_all_read_button.mark_as_read"
values={{
// @ts-expect-error https://www.i18next.com/overview/typescript#type-error-template-literal
// should be fixed by using `as const` but it's not working
which: commonT(`words.which.${which}`),
}}
/>
</div>
<div className="space-x-4">
<IconButton
icon={<i className="i-mgc-check-filled" />}
onClick={() => {
handleMarkAllAsRead()
setMarkPopoverOpen(false)
}}
>
Confirm
</IconButton>
</div>
</PopoverContent>
</PopoverPortal>
</Popover>
)
},
)
export const FlatMarkAllReadButton: FC<MarkAllButtonProps> = (props) => {
const t = useI18n()

View File

@ -6,6 +6,7 @@ import { ActionButton } from "@renderer/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@renderer/components/ui/popover"
import { ProfileButton } from "@renderer/components/user-button"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import { useI18n } from "@renderer/hooks/common"
import { stopPropagation } from "@renderer/lib/dom"
import { cn } from "@renderer/lib/utils"
import { m } from "framer-motion"
@ -53,7 +54,6 @@ export const FeedColumnHeader = memo(() => {
}}
>
<Logo className="mr-1 size-6" />
{APP_NAME}
</div>
</LogoContextMenu>
@ -80,10 +80,11 @@ const LayoutActionButton = () => {
width: !feedColumnShow ? "auto" : 0,
})
const t = useI18n()
return (
<m.div initial={animation} animate={animation} className="overflow-hidden">
<ActionButton
tooltip="Toggle Sidebar"
tooltip={t("app.toggle_sidebar")}
icon={
<i
className={cn(
@ -103,6 +104,7 @@ const LayoutActionButton = () => {
const LogoContextMenu: FC<PropsWithChildren> = ({ children }) => {
const [open, setOpen] = useState(false)
const logoRef = useRef<SVGSVGElement>(null)
const t = useI18n()
return (
<Popover open={open} onOpenChange={setOpen}>
@ -120,7 +122,7 @@ const LogoContextMenu: FC<PropsWithChildren> = ({ children }) => {
onClick={() => {
navigator.clipboard.writeText(logoRef.current?.outerHTML || "")
setOpen(false)
toast.success("Copied to clipboard")
toast.success(t.common("app.copied_to_clipboard"))
}}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-1 py-0.5 text-sm outline-none",
@ -129,7 +131,7 @@ const LogoContextMenu: FC<PropsWithChildren> = ({ children }) => {
)}
>
<Logo ref={logoRef} />
<span>Copy Logo SVG</span>
<span>{t("app.copy_logo_svg")}</span>
</button>
</PopoverContent>
</Popover>

View File

@ -221,11 +221,16 @@ export const LanguageSelector = () => {
<SelectValue />
</SelectTrigger>
<SelectContent position="item-aligned">
{currentSupportedLanguages.map((lang) => (
<SelectItem key={lang} value={lang}>
{langT(`langs.${lang}` as any)}
</SelectItem>
))}
{currentSupportedLanguages.map((lang) => {
const percent = I18N_COMPLETENESS_MAP[lang]
return (
<SelectItem key={lang} value={lang}>
{langT(`langs.${lang}` as any)}{" "}
{typeof percent === "number" ? (percent === 100 ? null : `(${percent}%)`) : null}
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>

View File

@ -9,7 +9,7 @@
"src/env.ts",
"./src/hono.ts",
"src/shared/src/global.d.ts",
"configs/vite.render.config.ts",
"configs/*",
"./scripts/**/*"
],
"compilerOptions": {