feat(blocks): localize reusable application shell blocks

- move block messages beside appearance, chats, layout, navigation, and notification features\n- publish independently tree-shakeable catalogs for eight locales per block\n- localize controllers and leaf labels without rerendering surrounding interactive components\n- support descriptor-backed navigation labels, translated breadcrumbs, and compact-label truncation\n- add catalog composition helpers and coverage for reusable block consumers
This commit is contained in:
Maofeng
2026-07-30 14:48:06 +08:00
parent ae1798f6f4
commit 6af4d7b798
83 changed files with 1769 additions and 171 deletions
+11 -1
View File
@@ -34,12 +34,22 @@
"exports": { "exports": {
"./globals.css": "./src/styles/globals.css", "./globals.css": "./src/styles/globals.css",
"./appearance": "./src/blocks/appearance/index.ts", "./appearance": "./src/blocks/appearance/index.ts",
"./appearance/locales": "./src/blocks/appearance/locales/catalogs.ts",
"./appearance/locales/*": "./src/blocks/appearance/locales/*.ts",
"./chats": "./src/blocks/chats/index.ts", "./chats": "./src/blocks/chats/index.ts",
"./chats/locales": "./src/blocks/chats/locales/catalogs.ts",
"./chats/locales/*": "./src/blocks/chats/locales/*.ts",
"./components/*": "./src/components/*.tsx", "./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts", "./hooks/*": "./src/hooks/*.ts",
"./lib/*": "./src/lib/*.tsx", "./lib/*": "./src/lib/*.tsx",
"./layout": "./src/blocks/layout/index.ts", "./layout": "./src/blocks/layout/index.ts",
"./layout/locales": "./src/blocks/layout/locales/catalogs.ts",
"./layout/locales/*": "./src/blocks/layout/locales/*.ts",
"./navigation": "./src/blocks/navigation/index.ts", "./navigation": "./src/blocks/navigation/index.ts",
"./notifications": "./src/blocks/notifications/index.ts" "./navigation/locales": "./src/blocks/navigation/locales/catalogs.ts",
"./navigation/locales/*": "./src/blocks/navigation/locales/*.ts",
"./notifications": "./src/blocks/notifications/index.ts",
"./notifications/locales": "./src/blocks/notifications/locales/catalogs.ts",
"./notifications/locales/*": "./src/blocks/notifications/locales/*.ts"
} }
} }
@@ -1,5 +1,6 @@
import { useCallback, useState } from "react" import { useCallback, useState } from "react"
import { useHotkey } from "@tanstack/react-hotkeys" import { useHotkey } from "@tanstack/react-hotkeys"
import { useMessage } from "@workspace/i18n"
import { useResolvedTheme, useUiState } from "./ui-state" import { useResolvedTheme, useUiState } from "./ui-state"
import { import {
Dialog, Dialog,
@@ -15,6 +16,62 @@ import {
import { appearanceDialogHandle } from "./dialog" import { appearanceDialogHandle } from "./dialog"
import { CompactPreference, ModePreference } from "./preferences" import { CompactPreference, ModePreference } from "./preferences"
import { ThemeConfigPanel } from "./theme-config-panel" import { ThemeConfigPanel } from "./theme-config-panel"
import { LocalizedText } from "../../components/localized-text"
import { appearanceMessages } from "./messages"
interface AppearanceHotkeysProps {
onToggleDialog: VoidFunction
onToggleTheme: VoidFunction
}
function AppearanceHotkeys({
onToggleDialog,
onToggleTheme,
}: AppearanceHotkeysProps) {
const appearanceTitle = useMessage(appearanceMessages.appearance)
const appearanceDescription = useMessage(
appearanceMessages.appearanceCommandDescription
)
const toggleThemeTitle = useMessage(appearanceMessages.toggleTheme)
const toggleThemeDescription = useMessage(
appearanceMessages.toggleThemeDescription
)
useHotkey("Mod+,", onToggleDialog, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: appearanceTitle,
description: appearanceDescription,
},
})
useHotkey("Mod+D", onToggleTheme, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: toggleThemeTitle,
description: toggleThemeDescription,
},
})
return null
}
function AppearanceDialogHeader() {
return (
<DialogHeader>
<DialogTitle>
<LocalizedText message={appearanceMessages.appearance} />
</DialogTitle>
<DialogDescription>
<LocalizedText message={appearanceMessages.themeAndLayoutPreferences} />
</DialogDescription>
</DialogHeader>
)
}
export function AppearanceController() { export function AppearanceController() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@@ -33,37 +90,18 @@ export function AppearanceController() {
setThemeMode(resolvedTheme === "dark" ? "light" : "dark") setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
}, [resolvedTheme, setThemeMode]) }, [resolvedTheme, setThemeMode])
useHotkey("Mod+,", toggleDialog, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: "界面外观",
description: "打开主题与布局偏好",
},
})
useHotkey("Mod+D", toggleTheme, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: "切换主题",
description: "在浅色与深色主题之间切换",
},
})
return ( return (
<Dialog handle={appearanceDialogHandle} onOpenChange={setIsOpen}> <Dialog handle={appearanceDialogHandle} onOpenChange={setIsOpen}>
<AppearanceHotkeys
onToggleDialog={toggleDialog}
onToggleTheme={toggleTheme}
/>
<NavigationActionTarget <NavigationActionTarget
action={navigationActions.appearance} action={navigationActions.appearance}
onInvoke={() => appearanceDialogHandle.open(null)} onInvoke={() => appearanceDialogHandle.open(null)}
/> />
<DialogContent className="sm:max-w-max"> <DialogContent className="sm:max-w-max">
<DialogHeader> <AppearanceDialogHeader />
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="-mx-6 max-h-[75vh] space-y-10 overflow-y-auto px-6"> <div className="-mx-6 max-h-[75vh] space-y-10 overflow-y-auto px-6">
<div className="flex flex-col gap-6 lg:flex-row"> <div className="flex flex-col gap-6 lg:flex-row">
<ModePreference /> <ModePreference />
@@ -73,13 +111,8 @@ export function AppearanceController() {
<ThemeConfigPanel <ThemeConfigPanel
scheme="light" scheme="light"
active={resolvedTheme === "light"} active={resolvedTheme === "light"}
description="当系统设置为浅色模式时,将使用此主题"
/>
<ThemeConfigPanel
scheme="dark"
active={resolvedTheme === "dark"}
description="当系统设置为深色模式时,将使用此主题"
/> />
<ThemeConfigPanel scheme="dark" active={resolvedTheme === "dark"} />
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@@ -0,0 +1,35 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { appearanceMessages, themeColorMessages } from "../messages"
import { appearanceCatalogLocales } from "./catalogs"
import { messages as de } from "./de"
import { messages as en } from "./en"
import { messages as es } from "./es"
import { messages as fr } from "./fr"
import { messages as ja } from "./ja"
import { messages as ko } from "./ko"
import { messages as zhHans } from "./zh-Hans"
import { messages as zhHant } from "./zh-Hant"
describe("appearance locale catalogs", () => {
it("ship every appearance message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: appearanceCatalogLocales,
messageIds: [
...Object.values(appearanceMessages),
...Object.values(themeColorMessages),
].map((descriptor) => descriptor.id),
})
})
})
@@ -0,0 +1,12 @@
import type { appearanceMessages, themeColorMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as appearanceCatalogLocales }
export type AppearanceCatalogLocale = BlockCatalogLocale
export type AppearanceMessageCatalog = BlockMessageCatalog<
typeof appearanceMessages & typeof themeColorMessages
>
@@ -0,0 +1,53 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.appearance.active": "Aktiv",
"blocks.appearance.baseColor": "Basisfarbe",
"blocks.appearance.color.amber": "Bernstein",
"blocks.appearance.color.blue": "Blau",
"blocks.appearance.color.cyan": "Cyan",
"blocks.appearance.color.emerald": "Smaragd",
"blocks.appearance.color.fuchsia": "Fuchsia",
"blocks.appearance.color.green": "Grün",
"blocks.appearance.color.indigo": "Indigo",
"blocks.appearance.color.lime": "Limette",
"blocks.appearance.color.mauve": "Malve",
"blocks.appearance.color.mist": "Nebel",
"blocks.appearance.color.neutral": "Neutral",
"blocks.appearance.color.olive": "Oliv",
"blocks.appearance.color.orange": "Orange",
"blocks.appearance.color.pink": "Rosa",
"blocks.appearance.color.purple": "Lila",
"blocks.appearance.color.red": "Rot",
"blocks.appearance.color.rose": "Rose",
"blocks.appearance.color.sky": "Himmelblau",
"blocks.appearance.color.stone": "Stein",
"blocks.appearance.color.taupe": "Taupe",
"blocks.appearance.color.teal": "Blaugrün",
"blocks.appearance.color.violet": "Violett",
"blocks.appearance.color.yellow": "Gelb",
"blocks.appearance.color.zinc": "Zink",
"blocks.appearance.command.description": "Design- und Layout-Einstellungen öffnen",
"blocks.appearance.compact.description": "Eine kompakte Breite für den Hauptinhalt verwenden",
"blocks.appearance.compact.title": "Kompaktes Layout",
"blocks.appearance.description": "Design- und Layout-Einstellungen",
"blocks.appearance.mode.dark": "Dunkel",
"blocks.appearance.mode.dark.description": "Die Oberfläche verwendet immer das dunkle Design",
"blocks.appearance.mode.light": "Hell",
"blocks.appearance.mode.light.description": "Die Oberfläche verwendet immer das helle Design",
"blocks.appearance.mode.system": "System",
"blocks.appearance.mode.system.description": "Das Design der Oberfläche folgt der Systemeinstellung",
"blocks.appearance.mode.title": "Darstellungsmodus",
"blocks.appearance.primaryColor": "Primärfarbe",
"blocks.appearance.theme.dark": "Dunkles Design",
"blocks.appearance.theme.dark.description": "Dieses Design wird im dunklen Systemmodus verwendet",
"blocks.appearance.theme.light": "Helles Design",
"blocks.appearance.theme.light.description": "Dieses Design wird im hellen Systemmodus verwendet",
"blocks.appearance.title": "Darstellung",
"blocks.appearance.toggleTheme": "Design wechseln",
"blocks.appearance.toggleTheme.description":
"Zwischen hellem und dunklem Design wechseln",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,25 @@
import type { MessageDescriptor } from "@workspace/i18n"
import { appearanceMessages, themeColorMessages } from "../messages.ts"
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
function createSourceCatalog(
...groups: readonly Record<string, MessageDescriptor>[]
): AppearanceMessageCatalog {
return Object.fromEntries(
groups.flatMap((group) =>
Object.values(group).map((descriptor) => [
descriptor.id,
descriptor.message,
])
)
) as AppearanceMessageCatalog
}
export const messages = createSourceCatalog(
appearanceMessages,
themeColorMessages
) satisfies AppearanceMessageCatalog
@@ -0,0 +1,52 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.appearance.active": "Activo",
"blocks.appearance.baseColor": "Color base",
"blocks.appearance.color.amber": "Ámbar",
"blocks.appearance.color.blue": "Azul",
"blocks.appearance.color.cyan": "Cian",
"blocks.appearance.color.emerald": "Esmeralda",
"blocks.appearance.color.fuchsia": "Fucsia",
"blocks.appearance.color.green": "Verde",
"blocks.appearance.color.indigo": "Índigo",
"blocks.appearance.color.lime": "Lima",
"blocks.appearance.color.mauve": "Malva",
"blocks.appearance.color.mist": "Niebla",
"blocks.appearance.color.neutral": "Neutro",
"blocks.appearance.color.olive": "Oliva",
"blocks.appearance.color.orange": "Naranja",
"blocks.appearance.color.pink": "Rosa",
"blocks.appearance.color.purple": "Morado",
"blocks.appearance.color.red": "Rojo",
"blocks.appearance.color.rose": "Rosa intenso",
"blocks.appearance.color.sky": "Celeste",
"blocks.appearance.color.stone": "Piedra",
"blocks.appearance.color.taupe": "Topo",
"blocks.appearance.color.teal": "Verde azulado",
"blocks.appearance.color.violet": "Violeta",
"blocks.appearance.color.yellow": "Amarillo",
"blocks.appearance.color.zinc": "Zinc",
"blocks.appearance.command.description": "Abrir las preferencias de tema y diseño",
"blocks.appearance.compact.description": "Usar un ancho compacto para el contenido principal",
"blocks.appearance.compact.title": "Diseño compacto",
"blocks.appearance.description": "Preferencias de tema y diseño",
"blocks.appearance.mode.dark": "Oscuro",
"blocks.appearance.mode.dark.description": "La interfaz siempre usará el tema oscuro",
"blocks.appearance.mode.light": "Claro",
"blocks.appearance.mode.light.description": "La interfaz siempre usará el tema claro",
"blocks.appearance.mode.system": "Sistema",
"blocks.appearance.mode.system.description": "El tema de la interfaz seguirá la apariencia del sistema",
"blocks.appearance.mode.title": "Modo de tema",
"blocks.appearance.primaryColor": "Color principal",
"blocks.appearance.theme.dark": "Tema oscuro",
"blocks.appearance.theme.dark.description": "Este tema se usa cuando el sistema está en modo oscuro",
"blocks.appearance.theme.light": "Tema claro",
"blocks.appearance.theme.light.description": "Este tema se usa cuando el sistema está en modo claro",
"blocks.appearance.title": "Apariencia",
"blocks.appearance.toggleTheme": "Cambiar tema",
"blocks.appearance.toggleTheme.description": "Cambiar entre los temas claro y oscuro",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,52 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.appearance.active": "Actif",
"blocks.appearance.baseColor": "Couleur de base",
"blocks.appearance.color.amber": "Ambre",
"blocks.appearance.color.blue": "Bleu",
"blocks.appearance.color.cyan": "Cyan",
"blocks.appearance.color.emerald": "Émeraude",
"blocks.appearance.color.fuchsia": "Fuchsia",
"blocks.appearance.color.green": "Vert",
"blocks.appearance.color.indigo": "Indigo",
"blocks.appearance.color.lime": "Citron vert",
"blocks.appearance.color.mauve": "Mauve",
"blocks.appearance.color.mist": "Brume",
"blocks.appearance.color.neutral": "Neutre",
"blocks.appearance.color.olive": "Olive",
"blocks.appearance.color.orange": "Orange",
"blocks.appearance.color.pink": "Rose",
"blocks.appearance.color.purple": "Pourpre",
"blocks.appearance.color.red": "Rouge",
"blocks.appearance.color.rose": "Rose soutenu",
"blocks.appearance.color.sky": "Bleu ciel",
"blocks.appearance.color.stone": "Pierre",
"blocks.appearance.color.taupe": "Taupe",
"blocks.appearance.color.teal": "Sarcelle",
"blocks.appearance.color.violet": "Violet",
"blocks.appearance.color.yellow": "Jaune",
"blocks.appearance.color.zinc": "Zinc",
"blocks.appearance.command.description": "Ouvrir les préférences de thème et de mise en page",
"blocks.appearance.compact.description": "Utiliser une largeur compacte pour le contenu principal",
"blocks.appearance.compact.title": "Mise en page compacte",
"blocks.appearance.description": "Préférences de thème et de mise en page",
"blocks.appearance.mode.dark": "Sombre",
"blocks.appearance.mode.dark.description": "Linterface utilisera toujours le thème sombre",
"blocks.appearance.mode.light": "Clair",
"blocks.appearance.mode.light.description": "Linterface utilisera toujours le thème clair",
"blocks.appearance.mode.system": "Système",
"blocks.appearance.mode.system.description": "Le thème de linterface suivra lapparence du système",
"blocks.appearance.mode.title": "Mode du thème",
"blocks.appearance.primaryColor": "Couleur principale",
"blocks.appearance.theme.dark": "Thème sombre",
"blocks.appearance.theme.dark.description": "Ce thème est utilisé lorsque le système est en mode sombre",
"blocks.appearance.theme.light": "Thème clair",
"blocks.appearance.theme.light.description": "Ce thème est utilisé lorsque le système est en mode clair",
"blocks.appearance.title": "Apparence",
"blocks.appearance.toggleTheme": "Changer de thème",
"blocks.appearance.toggleTheme.description": "Basculer entre les thèmes clair et sombre",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,52 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.appearance.active": "使用中",
"blocks.appearance.baseColor": "ベースカラー",
"blocks.appearance.color.amber": "アンバー",
"blocks.appearance.color.blue": "ブルー",
"blocks.appearance.color.cyan": "シアン",
"blocks.appearance.color.emerald": "エメラルド",
"blocks.appearance.color.fuchsia": "フクシア",
"blocks.appearance.color.green": "グリーン",
"blocks.appearance.color.indigo": "インディゴ",
"blocks.appearance.color.lime": "ライム",
"blocks.appearance.color.mauve": "モーブ",
"blocks.appearance.color.mist": "ミスト",
"blocks.appearance.color.neutral": "ニュートラル",
"blocks.appearance.color.olive": "オリーブ",
"blocks.appearance.color.orange": "オレンジ",
"blocks.appearance.color.pink": "ピンク",
"blocks.appearance.color.purple": "パープル",
"blocks.appearance.color.red": "レッド",
"blocks.appearance.color.rose": "ローズ",
"blocks.appearance.color.sky": "スカイ",
"blocks.appearance.color.stone": "ストーン",
"blocks.appearance.color.taupe": "トープ",
"blocks.appearance.color.teal": "ティール",
"blocks.appearance.color.violet": "バイオレット",
"blocks.appearance.color.yellow": "イエロー",
"blocks.appearance.color.zinc": "ジンク",
"blocks.appearance.command.description": "テーマとレイアウト設定を開く",
"blocks.appearance.compact.description": "メインページのコンテンツをコンパクトな幅で表示します",
"blocks.appearance.compact.title": "コンパクトレイアウト",
"blocks.appearance.description": "テーマとレイアウトの設定",
"blocks.appearance.mode.dark": "ダーク",
"blocks.appearance.mode.dark.description": "常にダークテーマを使用します",
"blocks.appearance.mode.light": "ライト",
"blocks.appearance.mode.light.description": "常にライトテーマを使用します",
"blocks.appearance.mode.system": "システム",
"blocks.appearance.mode.system.description": "インターフェースのテーマをシステムの外観に合わせます",
"blocks.appearance.mode.title": "テーマモード",
"blocks.appearance.primaryColor": "プライマリカラー",
"blocks.appearance.theme.dark": "ダークテーマ",
"blocks.appearance.theme.dark.description": "システムがダークモードのときに使用するテーマです",
"blocks.appearance.theme.light": "ライトテーマ",
"blocks.appearance.theme.light.description": "システムがライトモードのときに使用するテーマです",
"blocks.appearance.title": "外観",
"blocks.appearance.toggleTheme": "テーマを切り替える",
"blocks.appearance.toggleTheme.description": "ライトテーマとダークテーマを切り替えます",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,52 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.appearance.active": "사용 중",
"blocks.appearance.baseColor": "기본 색상",
"blocks.appearance.color.amber": "호박색",
"blocks.appearance.color.blue": "파란색",
"blocks.appearance.color.cyan": "청록색",
"blocks.appearance.color.emerald": "에메랄드",
"blocks.appearance.color.fuchsia": "자홍색",
"blocks.appearance.color.green": "초록색",
"blocks.appearance.color.indigo": "남색",
"blocks.appearance.color.lime": "라임",
"blocks.appearance.color.mauve": "연보라",
"blocks.appearance.color.mist": "안개색",
"blocks.appearance.color.neutral": "중성색",
"blocks.appearance.color.olive": "올리브",
"blocks.appearance.color.orange": "주황색",
"blocks.appearance.color.pink": "분홍색",
"blocks.appearance.color.purple": "보라색",
"blocks.appearance.color.red": "빨간색",
"blocks.appearance.color.rose": "장미색",
"blocks.appearance.color.sky": "하늘색",
"blocks.appearance.color.stone": "돌색",
"blocks.appearance.color.taupe": "회갈색",
"blocks.appearance.color.teal": "틸",
"blocks.appearance.color.violet": "제비꽃색",
"blocks.appearance.color.yellow": "노란색",
"blocks.appearance.color.zinc": "아연색",
"blocks.appearance.command.description": "테마 및 레이아웃 환경설정 열기",
"blocks.appearance.compact.description": "기본 페이지 콘텐츠에 좁은 너비를 사용합니다",
"blocks.appearance.compact.title": "컴팩트 레이아웃",
"blocks.appearance.description": "테마 및 레이아웃 환경설정",
"blocks.appearance.mode.dark": "어둡게",
"blocks.appearance.mode.dark.description": "인터페이스에서 항상 어두운 테마를 사용합니다",
"blocks.appearance.mode.light": "밝게",
"blocks.appearance.mode.light.description": "인터페이스에서 항상 밝은 테마를 사용합니다",
"blocks.appearance.mode.system": "시스템",
"blocks.appearance.mode.system.description": "인터페이스 테마가 시스템 화면 모드를 따릅니다",
"blocks.appearance.mode.title": "테마 모드",
"blocks.appearance.primaryColor": "주요 색상",
"blocks.appearance.theme.dark": "어두운 테마",
"blocks.appearance.theme.dark.description": "시스템이 어두운 모드일 때 사용하는 테마입니다",
"blocks.appearance.theme.light": "밝은 테마",
"blocks.appearance.theme.light.description": "시스템이 밝은 모드일 때 사용하는 테마입니다",
"blocks.appearance.title": "화면 모양",
"blocks.appearance.toggleTheme": "테마 전환",
"blocks.appearance.toggleTheme.description": "밝은 테마와 어두운 테마 사이를 전환합니다",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,52 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"blocks.appearance.active": "使用中",
"blocks.appearance.baseColor": "基础颜色",
"blocks.appearance.color.amber": "琥珀色",
"blocks.appearance.color.blue": "蓝色",
"blocks.appearance.color.cyan": "青色",
"blocks.appearance.color.emerald": "翠绿色",
"blocks.appearance.color.fuchsia": "紫红色",
"blocks.appearance.color.green": "绿色",
"blocks.appearance.color.indigo": "靛蓝色",
"blocks.appearance.color.lime": "青柠色",
"blocks.appearance.color.mauve": "紫灰",
"blocks.appearance.color.mist": "雾灰",
"blocks.appearance.color.neutral": "中性灰",
"blocks.appearance.color.olive": "橄榄灰",
"blocks.appearance.color.orange": "橙色",
"blocks.appearance.color.pink": "粉色",
"blocks.appearance.color.purple": "紫色",
"blocks.appearance.color.red": "红色",
"blocks.appearance.color.rose": "玫红色",
"blocks.appearance.color.sky": "天蓝色",
"blocks.appearance.color.stone": "石灰",
"blocks.appearance.color.taupe": "灰褐",
"blocks.appearance.color.teal": "蓝绿色",
"blocks.appearance.color.violet": "紫罗兰色",
"blocks.appearance.color.yellow": "黄色",
"blocks.appearance.color.zinc": "锌灰",
"blocks.appearance.command.description": "打开主题与布局偏好",
"blocks.appearance.compact.description": "启用后,页面主内容将使用紧凑宽度",
"blocks.appearance.compact.title": "紧凑布局",
"blocks.appearance.description": "主题与布局偏好",
"blocks.appearance.mode.dark": "深色",
"blocks.appearance.mode.dark.description": "界面将始终使用深色主题",
"blocks.appearance.mode.light": "浅色",
"blocks.appearance.mode.light.description": "界面将始终使用浅色主题",
"blocks.appearance.mode.system": "系统",
"blocks.appearance.mode.system.description": "界面主题将跟随系统外观设置",
"blocks.appearance.mode.title": "主题模式",
"blocks.appearance.primaryColor": "主要颜色",
"blocks.appearance.theme.dark": "深色主题",
"blocks.appearance.theme.dark.description": "当系统设置为深色模式时,将使用此主题",
"blocks.appearance.theme.light": "浅色主题",
"blocks.appearance.theme.light.description": "当系统设置为浅色模式时,将使用此主题",
"blocks.appearance.title": "界面外观",
"blocks.appearance.toggleTheme": "切换主题",
"blocks.appearance.toggleTheme.description": "在浅色与深色主题之间切换",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,52 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.appearance.active": "使用中",
"blocks.appearance.baseColor": "基礎顏色",
"blocks.appearance.color.amber": "琥珀色",
"blocks.appearance.color.blue": "藍色",
"blocks.appearance.color.cyan": "青色",
"blocks.appearance.color.emerald": "翠綠色",
"blocks.appearance.color.fuchsia": "紫紅色",
"blocks.appearance.color.green": "綠色",
"blocks.appearance.color.indigo": "靛藍色",
"blocks.appearance.color.lime": "萊姆色",
"blocks.appearance.color.mauve": "淡紫色",
"blocks.appearance.color.mist": "霧灰色",
"blocks.appearance.color.neutral": "中性灰",
"blocks.appearance.color.olive": "橄欖色",
"blocks.appearance.color.orange": "橙色",
"blocks.appearance.color.pink": "粉紅色",
"blocks.appearance.color.purple": "紫色",
"blocks.appearance.color.red": "紅色",
"blocks.appearance.color.rose": "玫瑰色",
"blocks.appearance.color.sky": "天藍色",
"blocks.appearance.color.stone": "石灰色",
"blocks.appearance.color.taupe": "灰褐色",
"blocks.appearance.color.teal": "藍綠色",
"blocks.appearance.color.violet": "紫羅蘭色",
"blocks.appearance.color.yellow": "黃色",
"blocks.appearance.color.zinc": "鋅灰色",
"blocks.appearance.command.description": "開啟主題與版面配置偏好",
"blocks.appearance.compact.description": "使用較緊湊的主頁內容寬度",
"blocks.appearance.compact.title": "緊湊版面配置",
"blocks.appearance.description": "主題與版面配置偏好",
"blocks.appearance.mode.dark": "深色",
"blocks.appearance.mode.dark.description": "介面將一律使用深色主題",
"blocks.appearance.mode.light": "淺色",
"blocks.appearance.mode.light.description": "介面將一律使用淺色主題",
"blocks.appearance.mode.system": "系統",
"blocks.appearance.mode.system.description": "介面主題將跟隨系統外觀設定",
"blocks.appearance.mode.title": "主題模式",
"blocks.appearance.primaryColor": "主要顏色",
"blocks.appearance.theme.dark": "深色主題",
"blocks.appearance.theme.dark.description": "系統使用深色模式時套用此主題",
"blocks.appearance.theme.light": "淺色主題",
"blocks.appearance.theme.light.description": "系統使用淺色模式時套用此主題",
"blocks.appearance.title": "介面外觀",
"blocks.appearance.toggleTheme": "切換主題",
"blocks.appearance.toggleTheme.description": "在淺色與深色主題之間切換",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,187 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const appearanceMessages = {
active: /* i18n */ {
id: "blocks.appearance.active",
message: "Active",
},
appearance: /* i18n */ {
id: "blocks.appearance.title",
message: "Appearance",
},
appearanceCommandDescription: /* i18n */ {
id: "blocks.appearance.command.description",
message: "Open theme and layout preferences",
},
baseColor: /* i18n */ {
id: "blocks.appearance.baseColor",
message: "Base color",
},
compactDescription: /* i18n */ {
id: "blocks.appearance.compact.description",
message: "Use a compact width for the main page content",
},
compactLayout: /* i18n */ {
id: "blocks.appearance.compact.title",
message: "Compact layout",
},
darkMode: /* i18n */ {
id: "blocks.appearance.mode.dark",
message: "Dark",
},
darkModeDescription: /* i18n */ {
id: "blocks.appearance.mode.dark.description",
message: "The interface will always use the dark theme",
},
darkTheme: /* i18n */ {
id: "blocks.appearance.theme.dark",
message: "Dark theme",
},
darkThemeDescription: /* i18n */ {
id: "blocks.appearance.theme.dark.description",
message: "This theme is used when the system is in dark mode",
},
followSystem: /* i18n */ {
id: "blocks.appearance.mode.system",
message: "System",
},
followSystemDescription: /* i18n */ {
id: "blocks.appearance.mode.system.description",
message: "The interface theme will follow the system appearance",
},
lightMode: /* i18n */ {
id: "blocks.appearance.mode.light",
message: "Light",
},
lightModeDescription: /* i18n */ {
id: "blocks.appearance.mode.light.description",
message: "The interface will always use the light theme",
},
lightTheme: /* i18n */ {
id: "blocks.appearance.theme.light",
message: "Light theme",
},
lightThemeDescription: /* i18n */ {
id: "blocks.appearance.theme.light.description",
message: "This theme is used when the system is in light mode",
},
primaryColor: /* i18n */ {
id: "blocks.appearance.primaryColor",
message: "Primary color",
},
themeAndLayoutPreferences: /* i18n */ {
id: "blocks.appearance.description",
message: "Theme and layout preferences",
},
themeMode: /* i18n */ {
id: "blocks.appearance.mode.title",
message: "Theme mode",
},
toggleTheme: /* i18n */ {
id: "blocks.appearance.toggleTheme",
message: "Toggle theme",
},
toggleThemeDescription: /* i18n */ {
id: "blocks.appearance.toggleTheme.description",
message: "Switch between light and dark themes",
},
} as const satisfies Record<string, MessageDescriptor>
export const themeColorMessages = {
amber: /* i18n */ {
id: "blocks.appearance.color.amber",
message: "Amber",
},
blue: /* i18n */ {
id: "blocks.appearance.color.blue",
message: "Blue",
},
cyan: /* i18n */ {
id: "blocks.appearance.color.cyan",
message: "Cyan",
},
emerald: /* i18n */ {
id: "blocks.appearance.color.emerald",
message: "Emerald",
},
fuchsia: /* i18n */ {
id: "blocks.appearance.color.fuchsia",
message: "Fuchsia",
},
green: /* i18n */ {
id: "blocks.appearance.color.green",
message: "Green",
},
indigo: /* i18n */ {
id: "blocks.appearance.color.indigo",
message: "Indigo",
},
lime: /* i18n */ {
id: "blocks.appearance.color.lime",
message: "Lime",
},
mauve: /* i18n */ {
id: "blocks.appearance.color.mauve",
message: "Mauve",
},
mist: /* i18n */ {
id: "blocks.appearance.color.mist",
message: "Mist",
},
neutral: /* i18n */ {
id: "blocks.appearance.color.neutral",
message: "Neutral",
},
olive: /* i18n */ {
id: "blocks.appearance.color.olive",
message: "Olive",
},
orange: /* i18n */ {
id: "blocks.appearance.color.orange",
message: "Orange",
},
pink: /* i18n */ {
id: "blocks.appearance.color.pink",
message: "Pink",
},
purple: /* i18n */ {
id: "blocks.appearance.color.purple",
message: "Purple",
},
red: /* i18n */ {
id: "blocks.appearance.color.red",
message: "Red",
},
rose: /* i18n */ {
id: "blocks.appearance.color.rose",
message: "Rose",
},
sky: /* i18n */ {
id: "blocks.appearance.color.sky",
message: "Sky",
},
stone: /* i18n */ {
id: "blocks.appearance.color.stone",
message: "Stone",
},
taupe: /* i18n */ {
id: "blocks.appearance.color.taupe",
message: "Taupe",
},
teal: /* i18n */ {
id: "blocks.appearance.color.teal",
message: "Teal",
},
violet: /* i18n */ {
id: "blocks.appearance.color.violet",
message: "Violet",
},
yellow: /* i18n */ {
id: "blocks.appearance.color.yellow",
message: "Yellow",
},
zinc: /* i18n */ {
id: "blocks.appearance.color.zinc",
message: "Zinc",
},
} as const satisfies Record<string, MessageDescriptor>
@@ -1,4 +1,5 @@
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react" import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"
import { useMessage } from "@workspace/i18n"
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -10,12 +11,8 @@ import { Switch } from "@workspace/ui/components/switch"
import type { ThemeMode } from "./state" import type { ThemeMode } from "./state"
import { useUiState } from "./ui-state" import { useUiState } from "./ui-state"
import { LocalizedText } from "../../components/localized-text"
const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [ import { appearanceMessages } from "./messages"
{ label: "使用浅色", value: "light" },
{ label: "使用深色", value: "dark" },
{ label: "跟随系统", value: "system" },
]
function ModeIcon({ mode }: { mode: ThemeMode | null }) { function ModeIcon({ mode }: { mode: ThemeMode | null }) {
if (mode === "light") return <SunIcon /> if (mode === "light") return <SunIcon />
@@ -25,15 +22,29 @@ function ModeIcon({ mode }: { mode: ThemeMode | null }) {
export function ModePreference() { export function ModePreference() {
const [themeMode, setThemeMode] = useUiState("theme-mode") const [themeMode, setThemeMode] = useUiState("theme-mode")
const lightLabel = useMessage(appearanceMessages.lightMode)
const darkLabel = useMessage(appearanceMessages.darkMode)
const systemLabel = useMessage(appearanceMessages.followSystem)
const lightDescription = useMessage(appearanceMessages.lightModeDescription)
const darkDescription = useMessage(appearanceMessages.darkModeDescription)
const systemDescription = useMessage(
appearanceMessages.followSystemDescription
)
const title = useMessage(appearanceMessages.themeMode)
const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [
{ label: lightLabel, value: "light" },
{ label: darkLabel, value: "dark" },
{ label: systemLabel, value: "system" },
]
const description = { const description = {
light: "界面将始终使用浅色主题", light: lightDescription,
dark: "界面将始终使用深色主题", dark: darkDescription,
system: "界面主题将跟随系统外观设置", system: systemDescription,
}[themeMode] }[themeMode]
return ( return (
<div className="lg:flex-1"> <div className="lg:flex-1">
<h2 className="font-semibold"></h2> <h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p> <p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Select <Select
items={modeItems} items={modeItems}
@@ -47,9 +58,7 @@ export function ModePreference() {
{(value: ThemeMode | null) => ( {(value: ThemeMode | null) => (
<> <>
<ModeIcon mode={value} /> <ModeIcon mode={value} />
{modeItems {modeItems.find((item) => item.value === value)?.label}
.find((item) => item.value === value)
?.label.slice(-2)}
</> </>
)} )}
</SelectValue> </SelectValue>
@@ -72,9 +81,11 @@ export function CompactPreference() {
return ( return (
<div className="lg:flex-1"> <div className="lg:flex-1">
<h2 className="font-semibold"></h2> <h2 className="font-semibold">
<LocalizedText message={appearanceMessages.compactLayout} />
</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
使 <LocalizedText message={appearanceMessages.compactDescription} />
</p> </p>
<Switch <Switch
className="mt-2" className="mt-2"
@@ -1,5 +1,7 @@
import type { CSSProperties } from "react" import type { CSSProperties, ReactNode } from "react"
import { useMessage } from "@workspace/i18n"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { themeColorMessages } from "./messages"
import { import {
accentColors, accentColors,
baseColors, baseColors,
@@ -12,7 +14,6 @@ type ThemeColor = BaseColor | AccentColor
type ThemeColorSwatch<T extends ThemeColor> = { type ThemeColorSwatch<T extends ThemeColor> = {
name: T name: T
title: string
light: { light: {
background: string background: string
foreground: string foreground: string
@@ -31,7 +32,6 @@ function createSwatches<T extends ThemeColor>(
return { return {
name, name,
title: theme.title,
light: { light: {
background: theme.cssVars.light.primary, background: theme.cssVars.light.primary,
foreground: theme.cssVars.light["primary-foreground"], foreground: theme.cssVars.light["primary-foreground"],
@@ -54,7 +54,7 @@ export function ThemeColorPicker<T extends ThemeColor>({
onChange, onChange,
}: { }: {
swatches: readonly ThemeColorSwatch<T>[] swatches: readonly ThemeColorSwatch<T>[]
title: string title: ReactNode
value: T value: T
onChange: (color: T) => void onChange: (color: T) => void
}) { }) {
@@ -86,6 +86,7 @@ function ThemeColorButton({
active: boolean active: boolean
onClick: () => void onClick: () => void
}) { }) {
const swatchTitle = useMessage(themeColorMessages[swatch.name])
const style = { const style = {
"--light-bg": swatch.light.background, "--light-bg": swatch.light.background,
"--light-fg": swatch.light.foreground, "--light-fg": swatch.light.foreground,
@@ -96,7 +97,7 @@ function ThemeColorButton({
return ( return (
<button <button
type="button" type="button"
aria-label={swatch.title} aria-label={swatchTitle}
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
"h-8 min-w-8 appearance-none truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset", "h-8 min-w-8 appearance-none truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset",
@@ -108,7 +109,7 @@ function ThemeColorButton({
style={style} style={style}
onClick={onClick} onClick={onClick}
> >
{swatch.title} {swatchTitle}
</button> </button>
) )
} }
@@ -8,18 +8,26 @@ import {
} from "./theme-color-picker" } from "./theme-color-picker"
import { ThemePreview } from "./theme-preview" import { ThemePreview } from "./theme-preview"
import type { ThemeScheme } from "./theme" import type { ThemeScheme } from "./theme"
import { LocalizedText } from "../../components/localized-text"
import { appearanceMessages } from "./messages"
export function ThemeConfigPanel({ export function ThemeConfigPanel({
scheme, scheme,
active, active,
description,
}: { }: {
scheme: ThemeScheme scheme: ThemeScheme
active: boolean active: boolean
description: string
}) { }) {
const stateKey = scheme === "light" ? "theme-light" : "theme-dark" const stateKey = scheme === "light" ? "theme-light" : "theme-dark"
const [config, setConfig] = useUiState(stateKey) const [config, setConfig] = useUiState(stateKey)
const titleMessage =
scheme === "light"
? appearanceMessages.lightTheme
: appearanceMessages.darkTheme
const descriptionMessage =
scheme === "light"
? appearanceMessages.lightThemeDescription
: appearanceMessages.darkThemeDescription
return ( return (
<div className="w-md rounded-xl border"> <div className="w-md rounded-xl border">
@@ -29,15 +37,23 @@ export function ThemeConfigPanel({
) : ( ) : (
<MoonIcon className="size-5" /> <MoonIcon className="size-5" />
)} )}
<span>{scheme === "light" ? "浅色主题" : "深色主题"}</span> <span>
{active && <Badge className="ml-auto">使</Badge>} <LocalizedText message={titleMessage} />
</span>
{active && (
<Badge className="ml-auto">
<LocalizedText message={appearanceMessages.active} />
</Badge>
)}
</div> </div>
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<p>{description}</p> <p>
<LocalizedText message={descriptionMessage} />
</p>
<ThemePreview scheme={scheme} config={config} /> <ThemePreview scheme={scheme} config={config} />
<ThemeColorPicker <ThemeColorPicker
swatches={baseColorSwatches} swatches={baseColorSwatches}
title="基础颜色" title={<LocalizedText message={appearanceMessages.baseColor} />}
value={config.baseColor} value={config.baseColor}
onChange={(baseColor) => onChange={(baseColor) =>
setConfig((current) => ({ ...current, baseColor })) setConfig((current) => ({ ...current, baseColor }))
@@ -45,7 +61,7 @@ export function ThemeConfigPanel({
/> />
<ThemeColorPicker <ThemeColorPicker
swatches={accentColorSwatches} swatches={accentColorSwatches}
title="主要颜色" title={<LocalizedText message={appearanceMessages.primaryColor} />}
value={config.accentColor} value={config.accentColor}
onChange={(accentColor) => onChange={(accentColor) =>
setConfig((current) => ({ ...current, accentColor })) setConfig((current) => ({ ...current, accentColor }))
@@ -14,6 +14,7 @@ describe("appearance themes", () => {
expect(themes.map((theme) => theme.name)).toEqual(selectableColors) expect(themes.map((theme) => theme.name)).toEqual(selectableColors)
expect(new Set(selectableColors).size).toBe(selectableColors.length) expect(new Set(selectableColors).size).toBe(selectableColors.length)
expect(new Set(themes.map((theme) => theme.name)).size).toBe(themes.length)
expect(themes.every((theme) => theme.title.length > 0)).toBe(true) expect(themes.every((theme) => theme.title.length > 0)).toBe(true)
}) })
+24 -24
View File
@@ -69,7 +69,7 @@ export type Theme = {
export const themes: Theme[] = [ export const themes: Theme[] = [
{ {
name: "neutral", name: "neutral",
title: "中性灰", title: "Neutral",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -142,7 +142,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "stone", name: "stone",
title: "石灰", title: "Stone",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -215,7 +215,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "zinc", name: "zinc",
title: "锌灰", title: "Zinc",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -288,7 +288,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "mauve", name: "mauve",
title: "紫灰", title: "Mauve",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -361,7 +361,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "olive", name: "olive",
title: "橄榄灰", title: "Olive",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -434,7 +434,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "mist", name: "mist",
title: "雾灰", title: "Mist",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -507,7 +507,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "taupe", name: "taupe",
title: "灰褐", title: "Taupe",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -580,7 +580,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "amber", name: "amber",
title: "琥珀色", title: "Amber",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.555 0.163 48.998)", primary: "oklch(0.555 0.163 48.998)",
@@ -612,7 +612,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "blue", name: "blue",
title: "蓝色", title: "Blue",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.488 0.243 264.376)", primary: "oklch(0.488 0.243 264.376)",
@@ -644,7 +644,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "cyan", name: "cyan",
title: "青色", title: "Cyan",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.52 0.105 223.128)", primary: "oklch(0.52 0.105 223.128)",
@@ -676,7 +676,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "emerald", name: "emerald",
title: "翠绿色", title: "Emerald",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.508 0.118 165.612)", primary: "oklch(0.508 0.118 165.612)",
@@ -708,7 +708,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "fuchsia", name: "fuchsia",
title: "紫红色", title: "Fuchsia",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.518 0.253 323.949)", primary: "oklch(0.518 0.253 323.949)",
@@ -740,7 +740,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "green", name: "green",
title: "绿色", title: "Green",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.527 0.154 150.069)", primary: "oklch(0.527 0.154 150.069)",
@@ -772,7 +772,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "indigo", name: "indigo",
title: "靛蓝色", title: "Indigo",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.457 0.24 277.023)", primary: "oklch(0.457 0.24 277.023)",
@@ -804,7 +804,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "lime", name: "lime",
title: "青柠色", title: "Lime",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.841 0.238 128.85)", primary: "oklch(0.841 0.238 128.85)",
@@ -836,7 +836,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "orange", name: "orange",
title: "橙色", title: "Orange",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.553 0.195 38.402)", primary: "oklch(0.553 0.195 38.402)",
@@ -868,7 +868,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "pink", name: "pink",
title: "粉色", title: "Pink",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.525 0.223 3.958)", primary: "oklch(0.525 0.223 3.958)",
@@ -900,7 +900,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "purple", name: "purple",
title: "紫色", title: "Purple",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.496 0.265 301.924)", primary: "oklch(0.496 0.265 301.924)",
@@ -932,7 +932,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "red", name: "red",
title: "红色", title: "Red",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.505 0.213 27.518)", primary: "oklch(0.505 0.213 27.518)",
@@ -964,7 +964,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "rose", name: "rose",
title: "玫红色", title: "Rose",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.514 0.222 16.935)", primary: "oklch(0.514 0.222 16.935)",
@@ -997,7 +997,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "sky", name: "sky",
title: "天蓝色", title: "Sky",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.5 0.134 242.749)", primary: "oklch(0.5 0.134 242.749)",
@@ -1029,7 +1029,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "teal", name: "teal",
title: "蓝绿色", title: "Teal",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.511 0.096 186.391)", primary: "oklch(0.511 0.096 186.391)",
@@ -1061,7 +1061,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "violet", name: "violet",
title: "紫罗兰色", title: "Violet",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.491 0.27 292.581)", primary: "oklch(0.491 0.27 292.581)",
@@ -1093,7 +1093,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "yellow", name: "yellow",
title: "黄色", title: "Yellow",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.852 0.199 91.936)", primary: "oklch(0.852 0.199 91.936)",
@@ -1,3 +1,4 @@
import { useMessage } from "@workspace/i18n"
import { ItemGroup } from "@workspace/ui/components/item" import { ItemGroup } from "@workspace/ui/components/item"
import { import {
Popover, Popover,
@@ -8,6 +9,7 @@ import {
} from "@workspace/ui/components/popover" } from "@workspace/ui/components/popover"
import { useOnScroll } from "../../hooks/use-on-scroll" import { useOnScroll } from "../../hooks/use-on-scroll"
import { chatMessages } from "./messages"
import { chatPopoverHandle } from "./chat-popover-handle" import { chatPopoverHandle } from "./chat-popover-handle"
import { ChatThreadItem } from "./chat-thread-item" import { ChatThreadItem } from "./chat-thread-item"
@@ -18,7 +20,9 @@ export interface ChatPopoverProps {
title?: string title?: string
} }
export function ChatPopover({ threads, title = "Chats" }: ChatPopoverProps) { export function ChatPopover({ threads, title }: ChatPopoverProps) {
const defaultTitle = useMessage(chatMessages.title)
useOnScroll(() => { useOnScroll(() => {
chatPopoverHandle.close() chatPopoverHandle.close()
}) })
@@ -28,7 +32,7 @@ export function ChatPopover({ threads, title = "Chats" }: ChatPopoverProps) {
<PopoverContent className="h-120 p-2" alignOffset={-50} showArrow> <PopoverContent className="h-120 p-2" alignOffset={-50} showArrow>
<PopoverHeader className="px-4 pt-4"> <PopoverHeader className="px-4 pt-4">
<PopoverTitle className="text-lg"> <PopoverTitle className="text-lg">
{title}({threads.length}) {title ?? defaultTitle}({threads.length})
</PopoverTitle> </PopoverTitle>
<PopoverDescription /> <PopoverDescription />
</PopoverHeader> </PopoverHeader>
@@ -0,0 +1,34 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { chatMessages } from "../messages"
import { chatCatalogLocales } from "./catalogs"
import { messages as de } from "./de"
import { messages as en } from "./en"
import { messages as es } from "./es"
import { messages as fr } from "./fr"
import { messages as ja } from "./ja"
import { messages as ko } from "./ko"
import { messages as zhHans } from "./zh-Hans"
import { messages as zhHant } from "./zh-Hant"
describe("chat locale catalogs", () => {
it("ship every chat message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: chatCatalogLocales,
messageIds: Object.values(chatMessages).map(
(descriptor) => descriptor.id
),
})
})
})
@@ -0,0 +1,10 @@
import type { chatMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as chatCatalogLocales }
export type ChatCatalogLocale = BlockCatalogLocale
export type ChatMessageCatalog = BlockMessageCatalog<typeof chatMessages>
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.chats.title": "Chats",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"blocks.chats.title": "Chats",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.chats.title": "Chats",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.chats.title": "Discussions",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.chats.title": "チャット",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.chats.title": "채팅",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"blocks.chats.title": "聊天",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.chats.title": "聊天",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,8 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const chatMessages = {
title: /* i18n */ {
id: "blocks.chats.title",
message: "Chats",
},
} as const satisfies Record<string, MessageDescriptor>
@@ -1,5 +1,6 @@
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import * as React from "react" import * as React from "react"
import { useMessage } from "@workspace/i18n"
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbEllipsis, BreadcrumbEllipsis,
@@ -21,8 +22,13 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu" } from "@workspace/ui/components/dropdown-menu"
import { type NavigationInfo, useNavigationStack } from "../navigation" import {
NavigationLabel,
type NavigationInfo,
useNavigationStack,
} from "../navigation"
import { Icon } from "../../components/icon" import { Icon } from "../../components/icon"
import { layoutMessages } from "./messages"
const MAX_VISIBLE_ENTRIES = 3 const MAX_VISIBLE_ENTRIES = 3
@@ -185,7 +191,7 @@ function BreadcrumbEntryContent({ entry }: { entry: BreadcrumbEntry }) {
return ( return (
<> <>
{entry.icon && <Icon data={entry.icon} className="size-4 shrink-0" />} {entry.icon && <Icon data={entry.icon} className="size-4 shrink-0" />}
{entry.label} <NavigationLabel label={entry.label} />
</> </>
) )
} }
@@ -303,11 +309,13 @@ function BreadcrumbRouteLink({
} }
function BreadcrumbMenu({ entries }: { entries: readonly BreadcrumbEntry[] }) { function BreadcrumbMenu({ entries }: { entries: readonly BreadcrumbEntry[] }) {
const menuLabel = useMessage(layoutMessages.breadcrumbMenuLabel)
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button aria-label="打开面包屑菜单" size="icon-sm" variant="ghost" /> <Button aria-label={menuLabel} size="icon-sm" variant="ghost" />
} }
> >
<BreadcrumbEllipsis /> <BreadcrumbEllipsis />
@@ -320,7 +328,7 @@ function BreadcrumbMenu({ entries }: { entries: readonly BreadcrumbEntry[] }) {
{entry.icon && ( {entry.icon && (
<Icon data={entry.icon} className="size-4 shrink-0" /> <Icon data={entry.icon} className="size-4 shrink-0" />
)} )}
{entry.label} <NavigationLabel label={entry.label} />
</> </>
) )
@@ -405,7 +413,9 @@ export function AppBreadcrumb() {
className="size-4 shrink-0" className="size-4 shrink-0"
/> />
)} )}
<span className="truncate">{item.entry.label}</span> <span className="truncate">
<NavigationLabel label={item.entry.label} />
</span>
</BreadcrumbPage> </BreadcrumbPage>
) : ( ) : (
<BreadcrumbRouteLink <BreadcrumbRouteLink
@@ -432,7 +442,7 @@ export function AppBreadcrumb() {
{entry.icon && ( {entry.icon && (
<Icon data={entry.icon} className="size-4 shrink-0" /> <Icon data={entry.icon} className="size-4 shrink-0" />
)} )}
{entry.label} <NavigationLabel label={entry.label} />
</span> </span>
))} ))}
<span data-breadcrumb-measure-menu> <span data-breadcrumb-measure-menu>
@@ -38,6 +38,7 @@ import { AppQueryIndicator } from "./app-query-indicator"
export interface AppLayoutProps { export interface AppLayoutProps {
chatThreads: readonly ChatThread[] chatThreads: readonly ChatThread[]
children: React.ReactNode children: React.ReactNode
headerActions?: React.ReactNode
navigationGroups: readonly NavigationGroup[] navigationGroups: readonly NavigationGroup[]
notifications: UseNotificationsOptions notifications: UseNotificationsOptions
} }
@@ -45,6 +46,7 @@ export interface AppLayoutProps {
export function AppLayout({ export function AppLayout({
chatThreads, chatThreads,
children, children,
headerActions,
navigationGroups, navigationGroups,
notifications, notifications,
}: AppLayoutProps) { }: AppLayoutProps) {
@@ -55,7 +57,7 @@ export function AppLayout({
<NotificationCenterSheet {...notifications} /> <NotificationCenterSheet {...notifications} />
<ChatPopover threads={chatThreads} /> <ChatPopover threads={chatThreads} />
<div className="isolate min-h-svh w-svw"> <div className="isolate min-h-svh w-svw">
<AppHeader /> <AppHeader actions={headerActions} />
<div className="relative z-1 flex min-h-svh w-svw items-start"> <div className="relative z-1 flex min-h-svh w-svw items-start">
<AppHeaderBackground /> <AppHeaderBackground />
<AppSidebar groups={navigationGroups} /> <AppSidebar groups={navigationGroups} />
@@ -66,7 +68,7 @@ export function AppLayout({
) )
} }
function AppHeader() { function AppHeader({ actions }: { actions?: React.ReactNode }) {
return ( return (
<header className="sticky top-0 z-3 h-0"> <header className="sticky top-0 z-3 h-0">
<div className="flex h-(--ui-header-height) items-center"> <div className="flex h-(--ui-header-height) items-center">
@@ -157,6 +159,7 @@ function AppHeader() {
</div> </div>
<div className="ml-auto flex shrink-0 items-center gap-2"> <div className="ml-auto flex shrink-0 items-center gap-2">
<GlobalSearchTrigger /> <GlobalSearchTrigger />
{actions}
<NotificationCenterTrigger <NotificationCenterTrigger
render={<HeaderActionButton icon={BellIcon} showIndicator />} render={<HeaderActionButton icon={BellIcon} showIndicator />}
/> />
@@ -1,3 +1,4 @@
import { useMessage } from "@workspace/i18n"
import { useRefresh } from "../../hooks/use-refresh" import { useRefresh } from "../../hooks/use-refresh"
import { import {
Tooltip, Tooltip,
@@ -7,9 +8,12 @@ import {
import { HeaderActionButton } from "./header-action-button" import { HeaderActionButton } from "./header-action-button"
import { ReloadIcon } from "@hugeicons/core-free-icons" import { ReloadIcon } from "@hugeicons/core-free-icons"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { layoutMessages } from "./messages"
export function AppQueryIndicator({ className }: { className?: string }) { export function AppQueryIndicator({ className }: { className?: string }) {
const { canRefresh, isRefreshing, refresh } = useRefresh() const { canRefresh, isRefreshing, refresh } = useRefresh()
const refreshLabel = useMessage(layoutMessages.refreshLabel)
const refreshDescription = useMessage(layoutMessages.refreshDescription)
if (isRefreshing) { if (isRefreshing) {
return ( return (
@@ -29,7 +33,7 @@ export function AppQueryIndicator({ className }: { className?: string }) {
<TooltipTrigger <TooltipTrigger
render={ render={
<HeaderActionButton <HeaderActionButton
aria-label="刷新当前页面数据" aria-label={refreshLabel}
className={cn("[&_svg]:size-4", className)} className={cn("[&_svg]:size-4", className)}
disabled={!canRefresh} disabled={!canRefresh}
icon={ReloadIcon} icon={ReloadIcon}
@@ -37,11 +41,7 @@ export function AppQueryIndicator({ className }: { className?: string }) {
/> />
} }
/> />
<TooltipContent showArrow> <TooltipContent showArrow>{refreshDescription}</TooltipContent>
<br />
</TooltipContent>
</Tooltip> </Tooltip>
) )
} }
@@ -0,0 +1,34 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { layoutMessages } from "../messages"
import { layoutCatalogLocales } from "./catalogs"
import { messages as de } from "./de"
import { messages as en } from "./en"
import { messages as es } from "./es"
import { messages as fr } from "./fr"
import { messages as ja } from "./ja"
import { messages as ko } from "./ko"
import { messages as zhHans } from "./zh-Hans"
import { messages as zhHant } from "./zh-Hant"
describe("layout locale catalogs", () => {
it("ship every layout message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: layoutCatalogLocales,
messageIds: Object.values(layoutMessages).map(
(descriptor) => descriptor.id
),
})
})
})
@@ -0,0 +1,10 @@
import type { layoutMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as layoutCatalogLocales }
export type LayoutCatalogLocale = BlockCatalogLocale
export type LayoutMessageCatalog = BlockMessageCatalog<typeof layoutMessages>
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.breadcrumb.menuLabel": "Breadcrumb-Menü öffnen",
"blocks.refresh.description": "Daten der aktuellen Seite aktualisieren",
"blocks.refresh.label": "Aktuelle Seitendaten aktualisieren",
"blocks.userMenu.accountPassword": "Kontopasswort",
"blocks.userMenu.appearance": "Darstellung",
"blocks.userMenu.keybindings": "Tastenkürzel",
"blocks.userMenu.logout": "Abmelden",
"blocks.userMenu.open": "Benutzermenü öffnen",
"blocks.userMenu.title": "Benutzermenü",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"blocks.breadcrumb.menuLabel": "Open breadcrumb menu",
"blocks.refresh.description": "Refresh the current page data",
"blocks.refresh.label": "Refresh current page data",
"blocks.userMenu.accountPassword": "Account password",
"blocks.userMenu.appearance": "Appearance",
"blocks.userMenu.keybindings": "Keybindings",
"blocks.userMenu.logout": "Log out",
"blocks.userMenu.open": "Open user menu",
"blocks.userMenu.title": "User menu",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.breadcrumb.menuLabel": "Abrir el menú de ruta de navegación",
"blocks.refresh.description": "Actualizar los datos de la página actual",
"blocks.refresh.label": "Actualizar datos de la página actual",
"blocks.userMenu.accountPassword": "Contraseña de la cuenta",
"blocks.userMenu.appearance": "Apariencia",
"blocks.userMenu.keybindings": "Atajos de teclado",
"blocks.userMenu.logout": "Cerrar sesión",
"blocks.userMenu.open": "Abrir menú de usuario",
"blocks.userMenu.title": "Menú de usuario",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.breadcrumb.menuLabel": "Ouvrir le menu du fil dAriane",
"blocks.refresh.description": "Actualiser les données de la page actuelle",
"blocks.refresh.label": "Actualiser les données de la page",
"blocks.userMenu.accountPassword": "Mot de passe du compte",
"blocks.userMenu.appearance": "Apparence",
"blocks.userMenu.keybindings": "Raccourcis clavier",
"blocks.userMenu.logout": "Se déconnecter",
"blocks.userMenu.open": "Ouvrir le menu utilisateur",
"blocks.userMenu.title": "Menu utilisateur",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.breadcrumb.menuLabel": "パンくずメニューを開く",
"blocks.refresh.description": "現在のページのデータを更新します",
"blocks.refresh.label": "現在のページを更新",
"blocks.userMenu.accountPassword": "アカウントのパスワード",
"blocks.userMenu.appearance": "外観",
"blocks.userMenu.keybindings": "キーバインド",
"blocks.userMenu.logout": "ログアウト",
"blocks.userMenu.open": "ユーザーメニューを開く",
"blocks.userMenu.title": "ユーザーメニュー",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.breadcrumb.menuLabel": "이동 경로 메뉴 열기",
"blocks.refresh.description": "현재 페이지 데이터를 새로 고칩니다",
"blocks.refresh.label": "현재 페이지 데이터 새로 고침",
"blocks.userMenu.accountPassword": "계정 비밀번호",
"blocks.userMenu.appearance": "화면 모양",
"blocks.userMenu.keybindings": "키 바인딩",
"blocks.userMenu.logout": "로그아웃",
"blocks.userMenu.open": "사용자 메뉴 열기",
"blocks.userMenu.title": "사용자 메뉴",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"blocks.breadcrumb.menuLabel": "打开面包屑菜单",
"blocks.refresh.description": "刷新当前页面的数据",
"blocks.refresh.label": "刷新当前页面数据",
"blocks.userMenu.accountPassword": "账号密码",
"blocks.userMenu.appearance": "界面外观",
"blocks.userMenu.keybindings": "按键映射",
"blocks.userMenu.logout": "退出登录",
"blocks.userMenu.open": "打开用户菜单",
"blocks.userMenu.title": "用户菜单",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.breadcrumb.menuLabel": "開啟麵包屑選單",
"blocks.refresh.description": "重新整理目前頁面的資料",
"blocks.refresh.label": "重新整理目前頁面資料",
"blocks.userMenu.accountPassword": "帳號密碼",
"blocks.userMenu.appearance": "介面外觀",
"blocks.userMenu.keybindings": "按鍵綁定",
"blocks.userMenu.logout": "登出",
"blocks.userMenu.open": "開啟使用者選單",
"blocks.userMenu.title": "使用者選單",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,40 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const layoutMessages = {
accountPassword: /* i18n */ {
id: "blocks.userMenu.accountPassword",
message: "Account password",
},
appearance: /* i18n */ {
id: "blocks.userMenu.appearance",
message: "Appearance",
},
breadcrumbMenuLabel: /* i18n */ {
id: "blocks.breadcrumb.menuLabel",
message: "Open breadcrumb menu",
},
keybindings: /* i18n */ {
id: "blocks.userMenu.keybindings",
message: "Keybindings",
},
logout: /* i18n */ {
id: "blocks.userMenu.logout",
message: "Log out",
},
openUserMenu: /* i18n */ {
id: "blocks.userMenu.open",
message: "Open user menu",
},
refreshDescription: /* i18n */ {
id: "blocks.refresh.description",
message: "Refresh the current page data",
},
refreshLabel: /* i18n */ {
id: "blocks.refresh.label",
message: "Refresh current page data",
},
userMenuTitle: /* i18n */ {
id: "blocks.userMenu.title",
message: "User menu",
},
} as const satisfies Record<string, MessageDescriptor>
+37 -22
View File
@@ -1,4 +1,5 @@
import { formatForDisplay } from "@tanstack/react-hotkeys" import { formatForDisplay } from "@tanstack/react-hotkeys"
import { useMessage } from "@workspace/i18n"
import { Button, type ButtonProps } from "@workspace/ui/components/button" import { Button, type ButtonProps } from "@workspace/ui/components/button"
import { import {
createDialogHandle, createDialogHandle,
@@ -41,8 +42,10 @@ import {
ShieldKeyIcon, ShieldKeyIcon,
SwatchIcon, SwatchIcon,
} from "@hugeicons/core-free-icons" } from "@hugeicons/core-free-icons"
import { layoutMessages } from "./messages"
interface UserMenuItem { interface UserMenuItem {
id: string
action?: NavigationAction action?: NavigationAction
shortcut?: string shortcut?: string
icon: IconData icon: IconData
@@ -53,24 +56,6 @@ interface UserMenuTriggerProps {
className?: string className?: string
} }
const userMenuItems: readonly UserMenuItem[] = [
{
action: navigationActions.appearance,
icon: SwatchIcon,
label: "界面外观",
shortcut: "Mod+,",
},
{
icon: KeyboardIcon,
label: "按键映射",
},
{
icon: ShieldKeyIcon,
label: "账号密码",
shortcut: "Mod+P",
},
]
let userMenuHandles: let userMenuHandles:
| { | {
dialog: DialogHandle<void> dialog: DialogHandle<void>
@@ -123,8 +108,11 @@ function UserMenuPopoverTrigger({ className }: UserMenuTriggerProps) {
} }
function UserMenuTriggerButton({ className, ...props }: ButtonProps) { function UserMenuTriggerButton({ className, ...props }: ButtonProps) {
const openUserMenuLabel = useMessage(layoutMessages.openUserMenu)
return ( return (
<HeaderActionButton <HeaderActionButton
aria-label={openUserMenuLabel}
className={cn( className={cn(
"overflow-hidden after:pointer-events-none after:absolute after:inset-0 after:z-999 after:rounded-md after:border after:border-foreground/35", "overflow-hidden after:pointer-events-none after:absolute after:inset-0 after:z-999 after:rounded-md after:border after:border-foreground/35",
className className
@@ -140,6 +128,31 @@ function UserMenuTriggerButton({ className, ...props }: ButtonProps) {
} }
function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) { function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) {
const appearanceLabel = useMessage(layoutMessages.appearance)
const keybindingsLabel = useMessage(layoutMessages.keybindings)
const accountPasswordLabel = useMessage(layoutMessages.accountPassword)
const logoutLabel = useMessage(layoutMessages.logout)
const userMenuItems: readonly UserMenuItem[] = [
{
id: "appearance",
action: navigationActions.appearance,
icon: SwatchIcon,
label: appearanceLabel,
shortcut: "Mod+,",
},
{
id: "keybindings",
icon: KeyboardIcon,
label: keybindingsLabel,
},
{
id: "account-password",
icon: ShieldKeyIcon,
label: accountPasswordLabel,
shortcut: "Mod+P",
},
]
return ( return (
<div className="w-72"> <div className="w-72">
<div className="flex flex-col items-center pt-10 pb-6"> <div className="flex flex-col items-center pt-10 pb-6">
@@ -153,7 +166,7 @@ function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) {
<div className="border-t border-dashed border-border p-2"> <div className="border-t border-dashed border-border p-2">
<ul className="space-y"> <ul className="space-y">
{userMenuItems.map((item) => ( {userMenuItems.map((item) => (
<li key={item.label}> <li key={item.id}>
<Button <Button
className="w-full justify-start font-normal" className="w-full justify-start font-normal"
size="lg" size="lg"
@@ -176,7 +189,7 @@ function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) {
<div className="rounded-b-md border-t p-2"> <div className="rounded-b-md border-t p-2">
<Button className="w-full font-normal" variant="destructive" size="lg"> <Button className="w-full font-normal" variant="destructive" size="lg">
<Icon data={LogoutCircle02Icon} size="sm" /> <Icon data={LogoutCircle02Icon} size="sm" />
退 {logoutLabel}
<MenuShortcutSequence shortcuts={["Q", "Q", "Q"]} /> <MenuShortcutSequence shortcuts={["Q", "Q", "Q"]} />
</Button> </Button>
</div> </div>
@@ -213,12 +226,14 @@ function UserMenuDialogTrigger({ className }: UserMenuTriggerProps) {
function UserMenuDialog() { function UserMenuDialog() {
const { dialog } = getOrCreateUserMenuHandles() const { dialog } = getOrCreateUserMenuHandles()
const title = useMessage(layoutMessages.userMenuTitle)
return ( return (
<Dialog handle={dialog}> <Dialog handle={dialog}>
<DialogContent className="w-fit p-0"> <DialogContent className="w-fit p-0">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle></DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription></DialogDescription> <DialogDescription>{title}</DialogDescription>
</DialogHeader> </DialogHeader>
<UserMenuContent onItemSelect={() => dialog.close()} /> <UserMenuContent onItemSelect={() => dialog.close()} />
</DialogContent> </DialogContent>
@@ -9,6 +9,7 @@ import { Icon } from "../../components/icon"
import { resolveNavigationItemIcon } from "./item-icon" import { resolveNavigationItemIcon } from "./item-icon"
import { NavigationBadge, NavigationLink } from "./navigation-button" import { NavigationBadge, NavigationLink } from "./navigation-button"
import { NavigationLabel } from "./navigation-label"
import type { GetNavigationRouteState } from "./route-state" import type { GetNavigationRouteState } from "./route-state"
import type { NavigationItem } from "./types" import type { NavigationItem } from "./types"
@@ -37,7 +38,9 @@ export function SidebarFlyoutMenuItems({
className="h-9 gap-3 px-3 py-1 data-route-active:bg-primary/10 data-route-active:text-primary! data-route-active:**:text-primary!" className="h-9 gap-3 px-3 py-1 data-route-active:bg-primary/10 data-route-active:text-primary! data-route-active:**:text-primary!"
> >
{iconElement} {iconElement}
<span className="min-w-0 flex-1 truncate">{item.label}</span> <span className="min-w-0 flex-1 truncate">
<NavigationLabel label={item.label} />
</span>
<NavigationBadge className="ms-auto" itemId={item.id} /> <NavigationBadge className="ms-auto" itemId={item.id} />
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent className="grid w-fit max-w-(--available-width) min-w-44 gap-0.5"> <DropdownMenuSubContent className="grid w-fit max-w-(--available-width) min-w-44 gap-0.5">
@@ -59,7 +62,9 @@ export function SidebarFlyoutMenuItems({
render={item.to ? <NavigationLink to={item.to} /> : undefined} render={item.to ? <NavigationLink to={item.to} /> : undefined}
> >
{iconElement} {iconElement}
<span className="min-w-0 flex-1 truncate">{item.label}</span> <span className="min-w-0 flex-1 truncate">
<NavigationLabel label={item.label} />
</span>
<NavigationBadge className="ms-auto" itemId={item.id} /> <NavigationBadge className="ms-auto" itemId={item.id} />
</DropdownMenuItem> </DropdownMenuItem>
) )
@@ -4,6 +4,7 @@ export {
} from "./mobile-navigation" } from "./mobile-navigation"
export { resolveNavigationItemIcon } from "./item-icon" export { resolveNavigationItemIcon } from "./item-icon"
export { PrimaryNavigation } from "./primary-navigation" export { PrimaryNavigation } from "./primary-navigation"
export { NavigationLabel, TruncatedNavigationLabel } from "./navigation-label"
export { resolveNavigationRouteState } from "./route-state" export { resolveNavigationRouteState } from "./route-state"
export { export {
NavigationProvider, NavigationProvider,
@@ -16,4 +17,8 @@ export type {
NavigationRouteState, NavigationRouteState,
} from "./route-state" } from "./route-state"
export type { NavigationInfo, NavigationKey } from "./use-navigation" export type { NavigationInfo, NavigationKey } from "./use-navigation"
export type { NavigationGroup, NavigationItem } from "./types" export type {
NavigationGroup,
NavigationItem,
NavigationLabelValue,
} from "./types"
@@ -0,0 +1,34 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { navigationMessages } from "../messages"
import { navigationCatalogLocales } from "./catalogs"
import { messages as de } from "./de"
import { messages as en } from "./en"
import { messages as es } from "./es"
import { messages as fr } from "./fr"
import { messages as ja } from "./ja"
import { messages as ko } from "./ko"
import { messages as zhHans } from "./zh-Hans"
import { messages as zhHant } from "./zh-Hant"
describe("navigation locale catalogs", () => {
it("ship every navigation message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: navigationCatalogLocales,
messageIds: Object.values(navigationMessages).map(
(descriptor) => descriptor.id
),
})
})
})
@@ -0,0 +1,12 @@
import type { navigationMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as navigationCatalogLocales }
export type NavigationCatalogLocale = BlockCatalogLocale
export type NavigationMessageCatalog = BlockMessageCatalog<
typeof navigationMessages
>
@@ -0,0 +1,9 @@
import type { NavigationMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.navigation.description": "Durch die Anwendung navigieren.",
"blocks.navigation.open": "Navigation öffnen",
"blocks.navigation.title": "Navigation",
} as const satisfies NavigationMessageCatalog
@@ -0,0 +1,9 @@
import type { NavigationMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"blocks.navigation.description": "Navigate through the app.",
"blocks.navigation.open": "Open navigation",
"blocks.navigation.title": "Navigation",
} as const satisfies NavigationMessageCatalog
@@ -0,0 +1,9 @@
import type { NavigationMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.navigation.description": "Navega por la aplicación.",
"blocks.navigation.open": "Abrir navegación",
"blocks.navigation.title": "Navegación",
} as const satisfies NavigationMessageCatalog
@@ -0,0 +1,9 @@
import type { NavigationMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.navigation.description": "Naviguez dans lapplication.",
"blocks.navigation.open": "Ouvrir la navigation",
"blocks.navigation.title": "Navigation",
} as const satisfies NavigationMessageCatalog
@@ -0,0 +1,9 @@
import type { NavigationMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.navigation.description": "アプリ内のページを移動します。",
"blocks.navigation.open": "ナビゲーションを開く",
"blocks.navigation.title": "ナビゲーション",
} as const satisfies NavigationMessageCatalog
@@ -0,0 +1,9 @@
import type { NavigationMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.navigation.description": "앱의 페이지를 탐색합니다.",
"blocks.navigation.open": "탐색 메뉴 열기",
"blocks.navigation.title": "탐색",
} as const satisfies NavigationMessageCatalog
@@ -0,0 +1,9 @@
import type { NavigationMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"blocks.navigation.description": "浏览应用中的页面。",
"blocks.navigation.open": "打开导航",
"blocks.navigation.title": "导航",
} as const satisfies NavigationMessageCatalog
@@ -0,0 +1,9 @@
import type { NavigationMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.navigation.description": "瀏覽應用程式中的頁面。",
"blocks.navigation.open": "開啟導覽",
"blocks.navigation.title": "導覽",
} as const satisfies NavigationMessageCatalog
@@ -0,0 +1,16 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const navigationMessages = {
description: /* i18n */ {
id: "blocks.navigation.description",
message: "Navigate through the app.",
},
open: /* i18n */ {
id: "blocks.navigation.open",
message: "Open navigation",
},
title: /* i18n */ {
id: "blocks.navigation.title",
message: "Navigation",
},
} as const satisfies Record<string, MessageDescriptor>
@@ -1,4 +1,5 @@
import { useLocation } from "@tanstack/react-router" import { useLocation } from "@tanstack/react-router"
import { useMessage } from "@workspace/i18n"
import type { ButtonProps } from "@workspace/ui/components/button" import type { ButtonProps } from "@workspace/ui/components/button"
import { Separator } from "@workspace/ui/components/separator" import { Separator } from "@workspace/ui/components/separator"
import { import {
@@ -15,6 +16,8 @@ import { cn, separate } from "@workspace/ui/lib/utils"
import { ChevronRightIcon } from "lucide-react" import { ChevronRightIcon } from "lucide-react"
import { HeaderActionButton } from "../layout/header-action-button" import { HeaderActionButton } from "../layout/header-action-button"
import { navigationMessages } from "./messages"
import { NavigationLabel } from "./navigation-label"
import { import {
NavigationBadge, NavigationBadge,
@@ -37,11 +40,15 @@ export function MobileNavigationSheetTrigger({
className, className,
...props ...props
}: ButtonProps) { }: ButtonProps) {
const openNavigationLabel = useMessage(navigationMessages.open)
return ( return (
<SheetTrigger <SheetTrigger
handle={mobileNavigationSheetHandle} handle={mobileNavigationSheetHandle}
className={cn("-ml-2 hidden mobile:inline-flex", className)} className={cn("-ml-2 hidden mobile:inline-flex", className)}
render={<HeaderActionButton aria-label="打开导航" {...props} />} render={
<HeaderActionButton aria-label={openNavigationLabel} {...props} />
}
> >
<Icon <Icon
data={Menu02Icon} data={Menu02Icon}
@@ -57,6 +64,9 @@ export interface MobileNavigationSheetProps {
} }
export function MobileNavigationSheet({ groups }: MobileNavigationSheetProps) { export function MobileNavigationSheet({ groups }: MobileNavigationSheetProps) {
const title = useMessage(navigationMessages.title)
const description = useMessage(navigationMessages.description)
useOnBreakpoint((breakpoint) => { useOnBreakpoint((breakpoint) => {
if (breakpoint !== "mobile" && mobileNavigationSheetHandle.isOpen) { if (breakpoint !== "mobile" && mobileNavigationSheetHandle.isOpen) {
mobileNavigationSheetHandle.close() mobileNavigationSheetHandle.close()
@@ -75,8 +85,8 @@ export function MobileNavigationSheet({ groups }: MobileNavigationSheetProps) {
<Sheet handle={mobileNavigationSheetHandle}> <Sheet handle={mobileNavigationSheetHandle}>
<SheetContent className="max-w-72 gap-0 bg-popover/90" side="left"> <SheetContent className="max-w-72 gap-0 bg-popover/90" side="left">
<SheetHeader className="text-left"> <SheetHeader className="text-left">
<SheetTitle>Navigation</SheetTitle> <SheetTitle>{title}</SheetTitle>
<SheetDescription>Navigate through the app.</SheetDescription> <SheetDescription>{description}</SheetDescription>
</SheetHeader> </SheetHeader>
<div <div
className={cn( className={cn(
@@ -124,7 +134,7 @@ function FlatNavigationGroupList({
{items.map((group) => ( {items.map((group) => (
<div key={group.id}> <div key={group.id}>
<div className="mx-3 mb-2 truncate text-xs whitespace-nowrap tablet:hidden not-mobile:collapsed:hidden"> <div className="mx-3 mb-2 truncate text-xs whitespace-nowrap tablet:hidden not-mobile:collapsed:hidden">
{group.label} <NavigationLabel label={group.label} />
</div> </div>
<div className="rounded-md ring ring-sidebar-border"> <div className="rounded-md ring ring-sidebar-border">
{separate({ {separate({
@@ -8,6 +8,8 @@ import { cn } from "@workspace/ui/lib/utils"
import { ChevronRightIcon } from "lucide-react" import { ChevronRightIcon } from "lucide-react"
import { Icon, type IconData } from "../../components/icon" import { Icon, type IconData } from "../../components/icon"
import { NavigationLabel, TruncatedNavigationLabel } from "./navigation-label"
import type { NavigationLabelValue } from "./types"
export interface NavigationButtonProps extends Omit< export interface NavigationButtonProps extends Omit<
useRender.ComponentProps<"button", {}>, useRender.ComponentProps<"button", {}>,
@@ -16,7 +18,7 @@ export interface NavigationButtonProps extends Omit<
icon?: IconData icon?: IconData
isActive: boolean isActive: boolean
isCurrent: boolean isCurrent: boolean
label: string label: NavigationLabelValue
layout?: "inline" | "stacked" | "responsive" layout?: "inline" | "stacked" | "responsive"
className?: string | (() => string | undefined) | undefined className?: string | (() => string | undefined) | undefined
style?: style?:
@@ -105,21 +107,22 @@ export function NavigationButton({
)} )}
<span <span
className={cn( className={cn(
"flex-1 origin-top truncate whitespace-nowrap", "min-w-0 flex-1 origin-top overflow-hidden whitespace-nowrap",
layout === "stacked" layout === "stacked"
? [ ? [
"col-start-1 col-end-5 row-start-2 justify-self-center", "col-start-1 col-end-5 row-start-2 w-full max-w-full",
"scale-85 text-center text-xs", "scale-85 justify-center text-xs",
] ]
: layout === "responsive" : layout === "responsive"
? [ ? [
"tablet:col-start-1 tablet:col-end-5 tablet:row-start-2", "tablet:col-start-1 tablet:col-end-5 tablet:row-start-2",
"tablet:justify-self-center tablet:text-center tablet:text-xs", "tablet:w-full tablet:max-w-full tablet:justify-center tablet:text-xs",
"not-mobile:collapsed:col-start-1", "not-mobile:collapsed:col-start-1",
"not-mobile:collapsed:col-end-5", "not-mobile:collapsed:col-end-5",
"not-mobile:collapsed:row-start-2", "not-mobile:collapsed:row-start-2",
"not-mobile:collapsed:justify-self-center", "not-mobile:collapsed:w-full",
"not-mobile:collapsed:text-center", "not-mobile:collapsed:max-w-full",
"not-mobile:collapsed:justify-center",
"not-mobile:collapsed:text-xs", "not-mobile:collapsed:text-xs",
"tablet:scale-85", "tablet:scale-85",
"not-mobile:collapsed:scale-85", "not-mobile:collapsed:scale-85",
@@ -127,7 +130,11 @@ export function NavigationButton({
: undefined : undefined
)} )}
> >
{label} {layout === "inline" ? (
<NavigationLabel label={label} />
) : (
<TruncatedNavigationLabel label={label} />
)}
</span> </span>
{hasTrailingContent && ( {hasTrailingContent && (
<span <span
@@ -158,7 +165,7 @@ export function NavigationButton({
)} )}
</React.Fragment> </React.Fragment>
), ),
"aria-label": label, "aria-label": typeof label === "string" ? label : undefined,
} }
return useRender({ return useRender({
@@ -0,0 +1,44 @@
import { cn } from "@workspace/ui/lib/utils"
import { LocalizedText } from "../../components/localized-text"
import type { NavigationLabelValue } from "./types"
export interface NavigationLabelProps {
label: NavigationLabelValue
}
/**
* Resolves a navigation label without subscribing the surrounding navigation
* item to locale changes.
*/
export function NavigationLabel({ label }: NavigationLabelProps) {
if (typeof label === "string") {
return label
}
return <LocalizedText message={label} />
}
export interface TruncatedNavigationLabelProps extends NavigationLabelProps {
className?: string
}
/**
* Centers labels that fit while keeping overflowing labels anchored to the
* inline start edge and truncating their inline end.
*/
export function TruncatedNavigationLabel({
className,
label,
}: TruncatedNavigationLabelProps) {
return (
<span
className={cn(
"inline-block max-w-full truncate text-start align-bottom",
className
)}
>
<NavigationLabel label={label} />
</span>
)
}
@@ -17,6 +17,7 @@ import {
type NavigationButtonProps, type NavigationButtonProps,
} from "./navigation-button" } from "./navigation-button"
import { InlineNestedNavigationList } from "./nested-navigation" import { InlineNestedNavigationList } from "./nested-navigation"
import { NavigationLabel } from "./navigation-label"
import type { GetNavigationRouteState } from "./route-state" import type { GetNavigationRouteState } from "./route-state"
import type { NavigationGroup, NavigationItem } from "./types" import type { NavigationGroup, NavigationItem } from "./types"
@@ -172,7 +173,7 @@ export function PrimaryNavigation({
{items.map((group) => ( {items.map((group) => (
<div key={group.id}> <div key={group.id}>
<div className="mx-3 mb-2 truncate text-xs whitespace-nowrap tablet:hidden not-mobile:collapsed:hidden"> <div className="mx-3 mb-2 truncate text-xs whitespace-nowrap tablet:hidden not-mobile:collapsed:hidden">
{group.label} <NavigationLabel label={group.label} />
</div> </div>
<div className="tablet:space-y-1 not-mobile:collapsed:space-y-1"> <div className="tablet:space-y-1 not-mobile:collapsed:space-y-1">
{group.items.map((item) => ( {group.items.map((item) => (
@@ -1,9 +1,13 @@
import type { MessageDescriptor } from "@workspace/i18n"
import type { IconData } from "../../components/icon" import type { IconData } from "../../components/icon"
export type NavigationLabelValue = string | MessageDescriptor
export interface NavigationItem { export interface NavigationItem {
id: string id: string
to?: string to?: string
label: string label: NavigationLabelValue
icon?: IconData icon?: IconData
showIcon?: boolean showIcon?: boolean
items?: readonly NavigationItem[] items?: readonly NavigationItem[]
@@ -11,7 +15,7 @@ export interface NavigationItem {
export interface NavigationGroup { export interface NavigationGroup {
id: string id: string
label: string label: NavigationLabelValue
showIcon?: boolean showIcon?: boolean
items: readonly NavigationItem[] items: readonly NavigationItem[]
} }
@@ -3,7 +3,11 @@ import * as React from "react"
import type { IconData } from "../../components/icon" import type { IconData } from "../../components/icon"
import { resolveNavigationItemIcon } from "./item-icon" import { resolveNavigationItemIcon } from "./item-icon"
import type { NavigationGroup, NavigationItem } from "./types" import type {
NavigationGroup,
NavigationItem,
NavigationLabelValue,
} from "./types"
export type NavigationKey = NavigationItem["id"] export type NavigationKey = NavigationItem["id"]
@@ -11,7 +15,7 @@ export interface NavigationInfo {
id: NavigationKey id: NavigationKey
icon?: IconData icon?: IconData
items?: readonly NavigationInfo[] items?: readonly NavigationInfo[]
label: string label: NavigationLabelValue
to?: string to?: string
} }
@@ -0,0 +1,34 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { notificationMessages } from "../messages"
import { notificationCatalogLocales } from "./catalogs"
import { messages as de } from "./de"
import { messages as en } from "./en"
import { messages as es } from "./es"
import { messages as fr } from "./fr"
import { messages as ja } from "./ja"
import { messages as ko } from "./ko"
import { messages as zhHans } from "./zh-Hans"
import { messages as zhHant } from "./zh-Hant"
describe("notification locale catalogs", () => {
it("ship every notification message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: notificationCatalogLocales,
messageIds: Object.values(notificationMessages).map(
(descriptor) => descriptor.id
),
})
})
})
@@ -0,0 +1,12 @@
import type { notificationMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as notificationCatalogLocales }
export type NotificationCatalogLocale = BlockCatalogLocale
export type NotificationMessageCatalog = BlockMessageCatalog<
typeof notificationMessages
>
@@ -0,0 +1,21 @@
import type { NotificationMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.notifications.description":
"Benachrichtigungen anzeigen und verwalten",
"blocks.notifications.empty.description":
"Neue Aktivitäten werden hier angezeigt.",
"blocks.notifications.empty.title": "Keine Benachrichtigungen",
"blocks.notifications.error.description":
"Überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.",
"blocks.notifications.error.title":
"Benachrichtigungen konnten nicht geladen werden",
"blocks.notifications.justNow": "gerade eben",
"blocks.notifications.loading": "Benachrichtigungen werden geladen…",
"blocks.notifications.markAllAsRead": "Alle als gelesen markieren",
"blocks.notifications.retry": "Erneut versuchen",
"blocks.notifications.title": "Benachrichtigungen",
"blocks.notifications.viewAll": "Alle anzeigen",
} as const satisfies NotificationMessageCatalog
@@ -0,0 +1,18 @@
import type { NotificationMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"blocks.notifications.description": "View and manage notifications",
"blocks.notifications.empty.description": "New activity will appear here.",
"blocks.notifications.empty.title": "No notifications",
"blocks.notifications.error.description":
"Check your network connection and try again.",
"blocks.notifications.error.title": "Unable to load notifications",
"blocks.notifications.justNow": "just now",
"blocks.notifications.loading": "Loading notifications…",
"blocks.notifications.markAllAsRead": "Mark all as read",
"blocks.notifications.retry": "Try again",
"blocks.notifications.title": "Notifications",
"blocks.notifications.viewAll": "View all",
} as const satisfies NotificationMessageCatalog
@@ -0,0 +1,20 @@
import type { NotificationMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.notifications.description": "Ver y gestionar notificaciones",
"blocks.notifications.empty.description":
"La actividad nueva aparecerá aquí.",
"blocks.notifications.empty.title": "No hay notificaciones",
"blocks.notifications.error.description":
"Comprueba la conexión de red e inténtalo de nuevo.",
"blocks.notifications.error.title":
"No se pudieron cargar las notificaciones",
"blocks.notifications.justNow": "ahora mismo",
"blocks.notifications.loading": "Cargando notificaciones…",
"blocks.notifications.markAllAsRead": "Marcar todo como leído",
"blocks.notifications.retry": "Intentar de nuevo",
"blocks.notifications.title": "Notificaciones",
"blocks.notifications.viewAll": "Ver todo",
} as const satisfies NotificationMessageCatalog
@@ -0,0 +1,19 @@
import type { NotificationMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.notifications.description": "Afficher et gérer les notifications",
"blocks.notifications.empty.description":
"Les nouvelles activités apparaîtront ici.",
"blocks.notifications.empty.title": "Aucune notification",
"blocks.notifications.error.description":
"Vérifiez votre connexion réseau et réessayez.",
"blocks.notifications.error.title": "Impossible de charger les notifications",
"blocks.notifications.justNow": "à linstant",
"blocks.notifications.loading": "Chargement des notifications…",
"blocks.notifications.markAllAsRead": "Tout marquer comme lu",
"blocks.notifications.retry": "Réessayer",
"blocks.notifications.title": "Notifications",
"blocks.notifications.viewAll": "Tout afficher",
} as const satisfies NotificationMessageCatalog
@@ -0,0 +1,19 @@
import type { NotificationMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.notifications.description": "通知を表示・管理します",
"blocks.notifications.empty.description":
"新しいアクティビティがここに表示されます。",
"blocks.notifications.empty.title": "通知はありません",
"blocks.notifications.error.description":
"ネットワーク接続を確認して、もう一度お試しください。",
"blocks.notifications.error.title": "通知を読み込めませんでした",
"blocks.notifications.justNow": "たった今",
"blocks.notifications.loading": "通知を読み込み中…",
"blocks.notifications.markAllAsRead": "すべて既読にする",
"blocks.notifications.retry": "再試行",
"blocks.notifications.title": "通知",
"blocks.notifications.viewAll": "すべて表示",
} as const satisfies NotificationMessageCatalog
@@ -0,0 +1,18 @@
import type { NotificationMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.notifications.description": "알림 보기 및 관리",
"blocks.notifications.empty.description": "새 활동이 여기에 표시됩니다.",
"blocks.notifications.empty.title": "알림 없음",
"blocks.notifications.error.description":
"네트워크 연결을 확인한 후 다시 시도하세요.",
"blocks.notifications.error.title": "알림을 불러올 수 없음",
"blocks.notifications.justNow": "방금",
"blocks.notifications.loading": "알림 불러오는 중…",
"blocks.notifications.markAllAsRead": "모두 읽음으로 표시",
"blocks.notifications.retry": "다시 시도",
"blocks.notifications.title": "알림",
"blocks.notifications.viewAll": "모두 보기",
} as const satisfies NotificationMessageCatalog
@@ -0,0 +1,17 @@
import type { NotificationMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"blocks.notifications.description": "查看并处理通知",
"blocks.notifications.empty.description": "新的动态会显示在这里。",
"blocks.notifications.empty.title": "暂无通知",
"blocks.notifications.error.description": "请检查网络连接后重试。",
"blocks.notifications.error.title": "无法加载通知",
"blocks.notifications.justNow": "刚刚",
"blocks.notifications.loading": "正在加载通知…",
"blocks.notifications.markAllAsRead": "全部标记为已读",
"blocks.notifications.retry": "重新加载",
"blocks.notifications.title": "通知",
"blocks.notifications.viewAll": "查看全部",
} as const satisfies NotificationMessageCatalog
@@ -0,0 +1,17 @@
import type { NotificationMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.notifications.description": "檢視並管理通知",
"blocks.notifications.empty.description": "新的動態會顯示在這裡。",
"blocks.notifications.empty.title": "沒有通知",
"blocks.notifications.error.description": "請檢查網路連線後再試一次。",
"blocks.notifications.error.title": "無法載入通知",
"blocks.notifications.justNow": "剛剛",
"blocks.notifications.loading": "正在載入通知…",
"blocks.notifications.markAllAsRead": "全部標示為已讀",
"blocks.notifications.retry": "再試一次",
"blocks.notifications.title": "通知",
"blocks.notifications.viewAll": "檢視全部",
} as const satisfies NotificationMessageCatalog
@@ -0,0 +1,48 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const notificationMessages = {
description: /* i18n */ {
id: "blocks.notifications.description",
message: "View and manage notifications",
},
emptyDescription: /* i18n */ {
id: "blocks.notifications.empty.description",
message: "New activity will appear here.",
},
emptyTitle: /* i18n */ {
id: "blocks.notifications.empty.title",
message: "No notifications",
},
errorDescription: /* i18n */ {
id: "blocks.notifications.error.description",
message: "Check your network connection and try again.",
},
errorTitle: /* i18n */ {
id: "blocks.notifications.error.title",
message: "Unable to load notifications",
},
justNow: /* i18n */ {
id: "blocks.notifications.justNow",
message: "just now",
},
loading: /* i18n */ {
id: "blocks.notifications.loading",
message: "Loading notifications…",
},
markAllAsRead: /* i18n */ {
id: "blocks.notifications.markAllAsRead",
message: "Mark all as read",
},
retry: /* i18n */ {
id: "blocks.notifications.retry",
message: "Try again",
},
title: /* i18n */ {
id: "blocks.notifications.title",
message: "Notifications",
},
viewAll: /* i18n */ {
id: "blocks.notifications.viewAll",
message: "View all",
},
} as const satisfies Record<string, MessageDescriptor>
@@ -1,5 +1,6 @@
import * as React from "react" import * as React from "react"
import { CheckCheckIcon } from "lucide-react" import { CheckCheckIcon } from "lucide-react"
import { useMessage } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { import {
Empty, Empty,
@@ -15,6 +16,7 @@ import {
} from "@workspace/ui/components/tooltip" } from "@workspace/ui/components/tooltip"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { notificationMessages } from "./messages"
import { NotificationItem } from "./notification-item" import { NotificationItem } from "./notification-item"
import type { Notification, NotificationActionHandler } from "./types" import type { Notification, NotificationActionHandler } from "./types"
@@ -37,17 +39,30 @@ export interface NotificationCenterProps extends Omit<
export function NotificationCenter({ export function NotificationCenter({
className, className,
description = "查看并处理通知", description,
notifications, notifications,
onAction, onAction,
onMarkAllAsRead, onMarkAllAsRead,
onRefetch, onRefetch,
onViewAll, onViewAll,
status, status,
title = "Notifications", title,
viewAllLabel = "查看全部", viewAllLabel,
...props ...props
}: NotificationCenterProps) { }: NotificationCenterProps) {
const defaultDescription = useMessage(notificationMessages.description)
const defaultTitle = useMessage(notificationMessages.title)
const defaultViewAllLabel = useMessage(notificationMessages.viewAll)
const markAllAsReadLabel = useMessage(notificationMessages.markAllAsRead)
const loadingLabel = useMessage(notificationMessages.loading)
const errorTitle = useMessage(notificationMessages.errorTitle)
const errorDescription = useMessage(notificationMessages.errorDescription)
const retryLabel = useMessage(notificationMessages.retry)
const emptyTitle = useMessage(notificationMessages.emptyTitle)
const emptyDescription = useMessage(notificationMessages.emptyDescription)
const resolvedDescription = description ?? defaultDescription
const resolvedTitle = title ?? defaultTitle
const resolvedViewAllLabel = viewAllLabel ?? defaultViewAllLabel
const unreadCount = notifications.filter( const unreadCount = notifications.filter(
(notification) => !notification.isRead (notification) => !notification.isRead
).length ).length
@@ -59,14 +74,16 @@ export function NotificationCenter({
> >
<header className="grid grid-cols-[1fr_auto] p-4"> <header className="grid grid-cols-[1fr_auto] p-4">
<h2 className="order-1 font-heading font-medium text-foreground"> <h2 className="order-1 font-heading font-medium text-foreground">
{title} {resolvedTitle}
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="ms-1.5 text-sm font-normal text-muted-foreground"> <span className="ms-1.5 text-sm font-normal text-muted-foreground">
({unreadCount}) ({unreadCount})
</span> </span>
)} )}
</h2> </h2>
{description && <p className="sr-only">{description}</p>} {resolvedDescription && (
<p className="sr-only">{resolvedDescription}</p>
)}
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
delay={0} delay={0}
@@ -74,7 +91,7 @@ export function NotificationCenter({
disabled={unreadCount === 0} disabled={unreadCount === 0}
render={ render={
<Button <Button
aria-label="全部标记为已读" aria-label={markAllAsReadLabel}
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
/> />
@@ -84,7 +101,7 @@ export function NotificationCenter({
<CheckCheckIcon /> <CheckCheckIcon />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent align="end" showArrow> <TooltipContent align="end" showArrow>
{markAllAsReadLabel}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</header> </header>
@@ -94,23 +111,23 @@ export function NotificationCenter({
role="status" role="status"
className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground" className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground"
> >
{loadingLabel}
</div> </div>
) : status === "error" ? ( ) : status === "error" ? (
<Empty> <Empty>
<EmptyContent> <EmptyContent>
<EmptyTitle></EmptyTitle> <EmptyTitle>{errorTitle}</EmptyTitle>
<EmptyDescription></EmptyDescription> <EmptyDescription>{errorDescription}</EmptyDescription>
<Button variant="outline" onClick={onRefetch}> <Button variant="outline" onClick={onRefetch}>
{retryLabel}
</Button> </Button>
</EmptyContent> </EmptyContent>
</Empty> </Empty>
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
<Empty> <Empty>
<EmptyContent> <EmptyContent>
<EmptyTitle></EmptyTitle> <EmptyTitle>{emptyTitle}</EmptyTitle>
<EmptyDescription></EmptyDescription> <EmptyDescription>{emptyDescription}</EmptyDescription>
</EmptyContent> </EmptyContent>
</Empty> </Empty>
) : ( ) : (
@@ -137,7 +154,7 @@ export function NotificationCenter({
<footer className="mt-auto flex flex-col gap-2 p-4"> <footer className="mt-auto flex flex-col gap-2 p-4">
<Button size="lg" variant="ghost" className="h-12" onClick={onViewAll}> <Button size="lg" variant="ghost" className="h-12" onClick={onViewAll}>
{viewAllLabel} {resolvedViewAllLabel}
</Button> </Button>
</footer> </footer>
</section> </section>
@@ -1,5 +1,6 @@
import * as React from "react" import * as React from "react"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { useFormatters, useMessage } from "@workspace/i18n"
import { import {
Avatar, Avatar,
AvatarFallback, AvatarFallback,
@@ -17,6 +18,7 @@ import {
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { Icon } from "../../components/icon" import { Icon } from "../../components/icon"
import { notificationMessages } from "./messages"
import type { import type {
Notification, Notification,
@@ -43,10 +45,6 @@ const ICON_TONE_CLASSES: Record<NotificationTone, string> = {
warning: "bg-amber-500/10 text-amber-600 dark:text-amber-400", warning: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
} }
const RELATIVE_TIME_FORMAT = new Intl.RelativeTimeFormat("zh-CN", {
numeric: "auto",
})
const RELATIVE_TIME_UNITS: ReadonlyArray< const RELATIVE_TIME_UNITS: ReadonlyArray<
readonly [seconds: number, unit: Intl.RelativeTimeFormatUnit] readonly [seconds: number, unit: Intl.RelativeTimeFormatUnit]
> = [ > = [
@@ -71,7 +69,11 @@ function getInitials(media: Extract<NotificationMedia, { kind: "avatar" }>) {
.toUpperCase() .toUpperCase()
} }
function formatRelativeTime(createdAt: string) { function formatNotificationTime(
createdAt: string,
formatter: (value: number, unit: Intl.RelativeTimeFormatUnit) => string,
justNow: string
) {
const createdAtTime = new Date(createdAt).getTime() const createdAtTime = new Date(createdAt).getTime()
if (!Number.isFinite(createdAtTime)) { if (!Number.isFinite(createdAtTime)) {
@@ -83,14 +85,11 @@ function formatRelativeTime(createdAt: string) {
for (const [seconds, unit] of RELATIVE_TIME_UNITS) { for (const [seconds, unit] of RELATIVE_TIME_UNITS) {
if (absoluteDifference >= seconds) { if (absoluteDifference >= seconds) {
return RELATIVE_TIME_FORMAT.format( return formatter(Math.round(differenceInSeconds / seconds), unit)
Math.round(differenceInSeconds / seconds),
unit
)
} }
} }
return "刚刚" return justNow
} }
function NotificationItemMedia({ media }: { media: NotificationMedia }) { function NotificationItemMedia({ media }: { media: NotificationMedia }) {
@@ -148,6 +147,9 @@ export function NotificationItem({
ref, ref,
...props ...props
}: NotificationItemProps) { }: NotificationItemProps) {
const { formatRelativeTime } = useFormatters()
const justNow = useMessage(notificationMessages.justNow)
return ( return (
<BaseItem <BaseItem
ref={ref} ref={ref}
@@ -181,7 +183,12 @@ export function NotificationItem({
)} )}
<ItemDescription className="flex items-center gap-1"> <ItemDescription className="flex items-center gap-1">
<time dateTime={notification.createdAt}> <time dateTime={notification.createdAt}>
{formatRelativeTime(notification.createdAt)} {formatNotificationTime(
notification.createdAt,
(value, unit) =>
formatRelativeTime(value, unit, { numeric: "auto" }),
justNow
)}
</time> </time>
{notification.category && ( {notification.category && (
<> <>
@@ -1,4 +1,5 @@
import * as React from "react" import * as React from "react"
import { useMessage } from "@workspace/i18n"
import { import {
createSheetHandle, createSheetHandle,
Sheet, Sheet,
@@ -8,6 +9,7 @@ import {
SheetTrigger, SheetTrigger,
} from "@workspace/ui/components/sheet" } from "@workspace/ui/components/sheet"
import { notificationMessages } from "./messages"
import { NotificationCenter } from "./notification-center" import { NotificationCenter } from "./notification-center"
import type { Notification, NotificationActionHandler } from "./types" import type { Notification, NotificationActionHandler } from "./types"
import { import {
@@ -41,9 +43,12 @@ export function NotificationCenterSheet({
onViewAll, onViewAll,
queryFn, queryFn,
queryKey, queryKey,
title = "Notifications", title,
}: NotificationCenterSheetProps) { }: NotificationCenterSheetProps) {
const notifications = useNotifications({ queryFn, queryKey }) const notifications = useNotifications({ queryFn, queryKey })
const defaultDescription = useMessage(notificationMessages.description)
const defaultTitle = useMessage(notificationMessages.title)
const resolvedTitle = title ?? defaultTitle
const handleAction = React.useCallback<NotificationActionHandler>( const handleAction = React.useCallback<NotificationActionHandler>(
(event) => { (event) => {
@@ -65,8 +70,10 @@ export function NotificationCenterSheet({
className="w-105 gap-0 bg-popover/90" className="w-105 gap-0 bg-popover/90"
showCloseButton={false} showCloseButton={false}
> >
<SheetTitle className="sr-only">{title}</SheetTitle> <SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
<SheetDescription className="sr-only"></SheetDescription> <SheetDescription className="sr-only">
{defaultDescription}
</SheetDescription>
<NotificationCenter <NotificationCenter
notifications={notifications.notifications} notifications={notifications.notifications}
onAction={handleAction} onAction={handleAction}
@@ -74,7 +81,7 @@ export function NotificationCenterSheet({
onRefetch={notifications.refetch} onRefetch={notifications.refetch}
onViewAll={onViewAll} onViewAll={onViewAll}
status={notifications.status} status={notifications.status}
title={title} title={resolvedTitle}
/> />
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -0,0 +1,9 @@
import { useMessage, type MessageDescriptor } from "@workspace/i18n"
export interface LocalizedTextProps {
message: MessageDescriptor
}
export function LocalizedText({ message }: LocalizedTextProps) {
return useMessage(message)
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expectTypeOf, it } from "vitest"
import type { ChatMessageCatalog } from "../blocks/chats/locales/catalogs"
import type { NavigationMessageCatalog } from "../blocks/navigation/locales/catalogs"
describe("block message catalog types", () => {
it("preserves semantic message IDs as literal keys", () => {
expectTypeOf<
keyof ChatMessageCatalog
>().toEqualTypeOf<"blocks.chats.title">()
expectTypeOf<keyof NavigationMessageCatalog>().toEqualTypeOf<
| "blocks.navigation.description"
| "blocks.navigation.open"
| "blocks.navigation.title"
>()
})
})
+23
View File
@@ -0,0 +1,23 @@
export const blockCatalogLocales = [
"de",
"en",
"es",
"fr",
"ja",
"ko",
"zh-Hans",
"zh-Hant",
] as const
interface MessageDescriptorMap {
readonly [key: string]: {
readonly id: string
}
}
type MessageId<T extends MessageDescriptorMap> = T[keyof T]["id"]
export type BlockCatalogLocale = (typeof blockCatalogLocales)[number]
export type BlockMessageCatalog<T extends MessageDescriptorMap> = Readonly<
Record<MessageId<T>, string>
>
+19
View File
@@ -0,0 +1,19 @@
import { expect } from "vitest"
export function expectCompleteCatalogs({
catalogs,
locales,
messageIds,
}: {
catalogs: Readonly<Record<string, Readonly<Record<string, string>>>>
locales: readonly string[]
messageIds: readonly string[]
}) {
expect(Object.keys(catalogs)).toEqual([...locales])
const expectedIds = [...messageIds].sort()
for (const messages of Object.values(catalogs)) {
expect(Object.keys(messages).sort()).toEqual(expectedIds)
}
}
+2
View File
@@ -4,12 +4,14 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"paths": { "paths": {
"@workspace/blocks/*": ["./src/*"], "@workspace/blocks/*": ["./src/*"],
"@workspace/i18n": ["../i18n/src/index.ts"],
"@workspace/ui/*": ["../ui/src/*"] "@workspace/ui/*": ["../ui/src/*"]
} }
}, },