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:
@@ -34,12 +34,22 @@
|
||||
"exports": {
|
||||
"./globals.css": "./src/styles/globals.css",
|
||||
"./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/locales": "./src/blocks/chats/locales/catalogs.ts",
|
||||
"./chats/locales/*": "./src/blocks/chats/locales/*.ts",
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./hooks/*": "./src/hooks/*.ts",
|
||||
"./lib/*": "./src/lib/*.tsx",
|
||||
"./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",
|
||||
"./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 { useHotkey } from "@tanstack/react-hotkeys"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { useResolvedTheme, useUiState } from "./ui-state"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,6 +16,62 @@ import {
|
||||
import { appearanceDialogHandle } from "./dialog"
|
||||
import { CompactPreference, ModePreference } from "./preferences"
|
||||
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() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -33,37 +90,18 @@ export function AppearanceController() {
|
||||
setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}, [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 (
|
||||
<Dialog handle={appearanceDialogHandle} onOpenChange={setIsOpen}>
|
||||
<AppearanceHotkeys
|
||||
onToggleDialog={toggleDialog}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
<NavigationActionTarget
|
||||
action={navigationActions.appearance}
|
||||
onInvoke={() => appearanceDialogHandle.open(null)}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-max">
|
||||
<DialogHeader>
|
||||
<DialogTitle>界面外观</DialogTitle>
|
||||
<DialogDescription>主题与布局偏好</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AppearanceDialogHeader />
|
||||
<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">
|
||||
<ModePreference />
|
||||
@@ -73,13 +111,8 @@ export function AppearanceController() {
|
||||
<ThemeConfigPanel
|
||||
scheme="light"
|
||||
active={resolvedTheme === "light"}
|
||||
description="当系统设置为浅色模式时,将使用此主题"
|
||||
/>
|
||||
<ThemeConfigPanel
|
||||
scheme="dark"
|
||||
active={resolvedTheme === "dark"}
|
||||
description="当系统设置为深色模式时,将使用此主题"
|
||||
/>
|
||||
<ThemeConfigPanel scheme="dark" active={resolvedTheme === "dark"} />
|
||||
</div>
|
||||
</div>
|
||||
</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": "L’interface utilisera toujours le thème sombre",
|
||||
"blocks.appearance.mode.light": "Clair",
|
||||
"blocks.appearance.mode.light.description": "L’interface utilisera toujours le thème clair",
|
||||
"blocks.appearance.mode.system": "Système",
|
||||
"blocks.appearance.mode.system.description": "Le thème de l’interface suivra l’apparence 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 { useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -10,12 +11,8 @@ import { Switch } from "@workspace/ui/components/switch"
|
||||
|
||||
import type { ThemeMode } from "./state"
|
||||
import { useUiState } from "./ui-state"
|
||||
|
||||
const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [
|
||||
{ label: "使用浅色", value: "light" },
|
||||
{ label: "使用深色", value: "dark" },
|
||||
{ label: "跟随系统", value: "system" },
|
||||
]
|
||||
import { LocalizedText } from "../../components/localized-text"
|
||||
import { appearanceMessages } from "./messages"
|
||||
|
||||
function ModeIcon({ mode }: { mode: ThemeMode | null }) {
|
||||
if (mode === "light") return <SunIcon />
|
||||
@@ -25,15 +22,29 @@ function ModeIcon({ mode }: { mode: ThemeMode | null }) {
|
||||
|
||||
export function ModePreference() {
|
||||
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 = {
|
||||
light: "界面将始终使用浅色主题",
|
||||
dark: "界面将始终使用深色主题",
|
||||
system: "界面主题将跟随系统外观设置",
|
||||
light: lightDescription,
|
||||
dark: darkDescription,
|
||||
system: systemDescription,
|
||||
}[themeMode]
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Select
|
||||
items={modeItems}
|
||||
@@ -47,9 +58,7 @@ export function ModePreference() {
|
||||
{(value: ThemeMode | null) => (
|
||||
<>
|
||||
<ModeIcon mode={value} />
|
||||
{modeItems
|
||||
.find((item) => item.value === value)
|
||||
?.label.slice(-2)}
|
||||
{modeItems.find((item) => item.value === value)?.label}
|
||||
</>
|
||||
)}
|
||||
</SelectValue>
|
||||
@@ -72,9 +81,11 @@ export function CompactPreference() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
启用后,页面主内容将使用紧凑宽度
|
||||
<LocalizedText message={appearanceMessages.compactDescription} />
|
||||
</p>
|
||||
<Switch
|
||||
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 { themeColorMessages } from "./messages"
|
||||
import {
|
||||
accentColors,
|
||||
baseColors,
|
||||
@@ -12,7 +14,6 @@ type ThemeColor = BaseColor | AccentColor
|
||||
|
||||
type ThemeColorSwatch<T extends ThemeColor> = {
|
||||
name: T
|
||||
title: string
|
||||
light: {
|
||||
background: string
|
||||
foreground: string
|
||||
@@ -31,7 +32,6 @@ function createSwatches<T extends ThemeColor>(
|
||||
|
||||
return {
|
||||
name,
|
||||
title: theme.title,
|
||||
light: {
|
||||
background: theme.cssVars.light.primary,
|
||||
foreground: theme.cssVars.light["primary-foreground"],
|
||||
@@ -54,7 +54,7 @@ export function ThemeColorPicker<T extends ThemeColor>({
|
||||
onChange,
|
||||
}: {
|
||||
swatches: readonly ThemeColorSwatch<T>[]
|
||||
title: string
|
||||
title: ReactNode
|
||||
value: T
|
||||
onChange: (color: T) => void
|
||||
}) {
|
||||
@@ -86,6 +86,7 @@ function ThemeColorButton({
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const swatchTitle = useMessage(themeColorMessages[swatch.name])
|
||||
const style = {
|
||||
"--light-bg": swatch.light.background,
|
||||
"--light-fg": swatch.light.foreground,
|
||||
@@ -96,7 +97,7 @@ function ThemeColorButton({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={swatch.title}
|
||||
aria-label={swatchTitle}
|
||||
aria-pressed={active}
|
||||
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",
|
||||
@@ -108,7 +109,7 @@ function ThemeColorButton({
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{swatch.title}
|
||||
{swatchTitle}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,18 +8,26 @@ import {
|
||||
} from "./theme-color-picker"
|
||||
import { ThemePreview } from "./theme-preview"
|
||||
import type { ThemeScheme } from "./theme"
|
||||
import { LocalizedText } from "../../components/localized-text"
|
||||
import { appearanceMessages } from "./messages"
|
||||
|
||||
export function ThemeConfigPanel({
|
||||
scheme,
|
||||
active,
|
||||
description,
|
||||
}: {
|
||||
scheme: ThemeScheme
|
||||
active: boolean
|
||||
description: string
|
||||
}) {
|
||||
const stateKey = scheme === "light" ? "theme-light" : "theme-dark"
|
||||
const [config, setConfig] = useUiState(stateKey)
|
||||
const titleMessage =
|
||||
scheme === "light"
|
||||
? appearanceMessages.lightTheme
|
||||
: appearanceMessages.darkTheme
|
||||
const descriptionMessage =
|
||||
scheme === "light"
|
||||
? appearanceMessages.lightThemeDescription
|
||||
: appearanceMessages.darkThemeDescription
|
||||
|
||||
return (
|
||||
<div className="w-md rounded-xl border">
|
||||
@@ -29,15 +37,23 @@ export function ThemeConfigPanel({
|
||||
) : (
|
||||
<MoonIcon className="size-5" />
|
||||
)}
|
||||
<span>{scheme === "light" ? "浅色主题" : "深色主题"}</span>
|
||||
{active && <Badge className="ml-auto">使用中</Badge>}
|
||||
<span>
|
||||
<LocalizedText message={titleMessage} />
|
||||
</span>
|
||||
{active && (
|
||||
<Badge className="ml-auto">
|
||||
<LocalizedText message={appearanceMessages.active} />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 p-4">
|
||||
<p>{description}</p>
|
||||
<p>
|
||||
<LocalizedText message={descriptionMessage} />
|
||||
</p>
|
||||
<ThemePreview scheme={scheme} config={config} />
|
||||
<ThemeColorPicker
|
||||
swatches={baseColorSwatches}
|
||||
title="基础颜色"
|
||||
title={<LocalizedText message={appearanceMessages.baseColor} />}
|
||||
value={config.baseColor}
|
||||
onChange={(baseColor) =>
|
||||
setConfig((current) => ({ ...current, baseColor }))
|
||||
@@ -45,7 +61,7 @@ export function ThemeConfigPanel({
|
||||
/>
|
||||
<ThemeColorPicker
|
||||
swatches={accentColorSwatches}
|
||||
title="主要颜色"
|
||||
title={<LocalizedText message={appearanceMessages.primaryColor} />}
|
||||
value={config.accentColor}
|
||||
onChange={(accentColor) =>
|
||||
setConfig((current) => ({ ...current, accentColor }))
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("appearance themes", () => {
|
||||
|
||||
expect(themes.map((theme) => theme.name)).toEqual(selectableColors)
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export type Theme = {
|
||||
export const themes: Theme[] = [
|
||||
{
|
||||
name: "neutral",
|
||||
title: "中性灰",
|
||||
title: "Neutral",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
@@ -142,7 +142,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "stone",
|
||||
title: "石灰",
|
||||
title: "Stone",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
@@ -215,7 +215,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "zinc",
|
||||
title: "锌灰",
|
||||
title: "Zinc",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
@@ -288,7 +288,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "mauve",
|
||||
title: "紫灰",
|
||||
title: "Mauve",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
@@ -361,7 +361,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "olive",
|
||||
title: "橄榄灰",
|
||||
title: "Olive",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
@@ -434,7 +434,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "mist",
|
||||
title: "雾灰",
|
||||
title: "Mist",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
@@ -507,7 +507,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "taupe",
|
||||
title: "灰褐",
|
||||
title: "Taupe",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
@@ -580,7 +580,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "amber",
|
||||
title: "琥珀色",
|
||||
title: "Amber",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.555 0.163 48.998)",
|
||||
@@ -612,7 +612,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "blue",
|
||||
title: "蓝色",
|
||||
title: "Blue",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.488 0.243 264.376)",
|
||||
@@ -644,7 +644,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "cyan",
|
||||
title: "青色",
|
||||
title: "Cyan",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.52 0.105 223.128)",
|
||||
@@ -676,7 +676,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "emerald",
|
||||
title: "翠绿色",
|
||||
title: "Emerald",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.508 0.118 165.612)",
|
||||
@@ -708,7 +708,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "fuchsia",
|
||||
title: "紫红色",
|
||||
title: "Fuchsia",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.518 0.253 323.949)",
|
||||
@@ -740,7 +740,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "green",
|
||||
title: "绿色",
|
||||
title: "Green",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.527 0.154 150.069)",
|
||||
@@ -772,7 +772,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "indigo",
|
||||
title: "靛蓝色",
|
||||
title: "Indigo",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.457 0.24 277.023)",
|
||||
@@ -804,7 +804,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "lime",
|
||||
title: "青柠色",
|
||||
title: "Lime",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.841 0.238 128.85)",
|
||||
@@ -836,7 +836,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "orange",
|
||||
title: "橙色",
|
||||
title: "Orange",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.553 0.195 38.402)",
|
||||
@@ -868,7 +868,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "pink",
|
||||
title: "粉色",
|
||||
title: "Pink",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.525 0.223 3.958)",
|
||||
@@ -900,7 +900,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "purple",
|
||||
title: "紫色",
|
||||
title: "Purple",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.496 0.265 301.924)",
|
||||
@@ -932,7 +932,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "red",
|
||||
title: "红色",
|
||||
title: "Red",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.505 0.213 27.518)",
|
||||
@@ -964,7 +964,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "rose",
|
||||
title: "玫红色",
|
||||
title: "Rose",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.514 0.222 16.935)",
|
||||
@@ -997,7 +997,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "sky",
|
||||
title: "天蓝色",
|
||||
title: "Sky",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.5 0.134 242.749)",
|
||||
@@ -1029,7 +1029,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "teal",
|
||||
title: "蓝绿色",
|
||||
title: "Teal",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.511 0.096 186.391)",
|
||||
@@ -1061,7 +1061,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "violet",
|
||||
title: "紫罗兰色",
|
||||
title: "Violet",
|
||||
cssVars: {
|
||||
light: {
|
||||
primary: "oklch(0.491 0.27 292.581)",
|
||||
@@ -1093,7 +1093,7 @@ export const themes: Theme[] = [
|
||||
},
|
||||
{
|
||||
name: "yellow",
|
||||
title: "黄色",
|
||||
title: "Yellow",
|
||||
cssVars: {
|
||||
light: {
|
||||
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 {
|
||||
Popover,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "@workspace/ui/components/popover"
|
||||
|
||||
import { useOnScroll } from "../../hooks/use-on-scroll"
|
||||
import { chatMessages } from "./messages"
|
||||
|
||||
import { chatPopoverHandle } from "./chat-popover-handle"
|
||||
import { ChatThreadItem } from "./chat-thread-item"
|
||||
@@ -18,7 +20,9 @@ export interface ChatPopoverProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function ChatPopover({ threads, title = "Chats" }: ChatPopoverProps) {
|
||||
export function ChatPopover({ threads, title }: ChatPopoverProps) {
|
||||
const defaultTitle = useMessage(chatMessages.title)
|
||||
|
||||
useOnScroll(() => {
|
||||
chatPopoverHandle.close()
|
||||
})
|
||||
@@ -28,7 +32,7 @@ export function ChatPopover({ threads, title = "Chats" }: ChatPopoverProps) {
|
||||
<PopoverContent className="h-120 p-2" alignOffset={-50} showArrow>
|
||||
<PopoverHeader className="px-4 pt-4">
|
||||
<PopoverTitle className="text-lg">
|
||||
{title}({threads.length})
|
||||
{title ?? defaultTitle}({threads.length})
|
||||
</PopoverTitle>
|
||||
<PopoverDescription />
|
||||
</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 * as React from "react"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
@@ -21,8 +22,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} 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 { layoutMessages } from "./messages"
|
||||
|
||||
const MAX_VISIBLE_ENTRIES = 3
|
||||
|
||||
@@ -185,7 +191,7 @@ function BreadcrumbEntryContent({ entry }: { entry: BreadcrumbEntry }) {
|
||||
return (
|
||||
<>
|
||||
{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[] }) {
|
||||
const menuLabel = useMessage(layoutMessages.breadcrumbMenuLabel)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button aria-label="打开面包屑菜单" size="icon-sm" variant="ghost" />
|
||||
<Button aria-label={menuLabel} size="icon-sm" variant="ghost" />
|
||||
}
|
||||
>
|
||||
<BreadcrumbEllipsis />
|
||||
@@ -320,7 +328,7 @@ function BreadcrumbMenu({ entries }: { entries: readonly BreadcrumbEntry[] }) {
|
||||
{entry.icon && (
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{item.entry.label}</span>
|
||||
<span className="truncate">
|
||||
<NavigationLabel label={item.entry.label} />
|
||||
</span>
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbRouteLink
|
||||
@@ -432,7 +442,7 @@ export function AppBreadcrumb() {
|
||||
{entry.icon && (
|
||||
<Icon data={entry.icon} className="size-4 shrink-0" />
|
||||
)}
|
||||
{entry.label}
|
||||
<NavigationLabel label={entry.label} />
|
||||
</span>
|
||||
))}
|
||||
<span data-breadcrumb-measure-menu>
|
||||
|
||||
@@ -38,6 +38,7 @@ import { AppQueryIndicator } from "./app-query-indicator"
|
||||
export interface AppLayoutProps {
|
||||
chatThreads: readonly ChatThread[]
|
||||
children: React.ReactNode
|
||||
headerActions?: React.ReactNode
|
||||
navigationGroups: readonly NavigationGroup[]
|
||||
notifications: UseNotificationsOptions
|
||||
}
|
||||
@@ -45,6 +46,7 @@ export interface AppLayoutProps {
|
||||
export function AppLayout({
|
||||
chatThreads,
|
||||
children,
|
||||
headerActions,
|
||||
navigationGroups,
|
||||
notifications,
|
||||
}: AppLayoutProps) {
|
||||
@@ -55,7 +57,7 @@ export function AppLayout({
|
||||
<NotificationCenterSheet {...notifications} />
|
||||
<ChatPopover threads={chatThreads} />
|
||||
<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">
|
||||
<AppHeaderBackground />
|
||||
<AppSidebar groups={navigationGroups} />
|
||||
@@ -66,7 +68,7 @@ export function AppLayout({
|
||||
)
|
||||
}
|
||||
|
||||
function AppHeader() {
|
||||
function AppHeader({ actions }: { actions?: React.ReactNode }) {
|
||||
return (
|
||||
<header className="sticky top-0 z-3 h-0">
|
||||
<div className="flex h-(--ui-header-height) items-center">
|
||||
@@ -157,6 +159,7 @@ function AppHeader() {
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<GlobalSearchTrigger />
|
||||
{actions}
|
||||
<NotificationCenterTrigger
|
||||
render={<HeaderActionButton icon={BellIcon} showIndicator />}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { useRefresh } from "../../hooks/use-refresh"
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -7,9 +8,12 @@ import {
|
||||
import { HeaderActionButton } from "./header-action-button"
|
||||
import { ReloadIcon } from "@hugeicons/core-free-icons"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { layoutMessages } from "./messages"
|
||||
|
||||
export function AppQueryIndicator({ className }: { className?: string }) {
|
||||
const { canRefresh, isRefreshing, refresh } = useRefresh()
|
||||
const refreshLabel = useMessage(layoutMessages.refreshLabel)
|
||||
const refreshDescription = useMessage(layoutMessages.refreshDescription)
|
||||
|
||||
if (isRefreshing) {
|
||||
return (
|
||||
@@ -29,7 +33,7 @@ export function AppQueryIndicator({ className }: { className?: string }) {
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<HeaderActionButton
|
||||
aria-label="刷新当前页面数据"
|
||||
aria-label={refreshLabel}
|
||||
className={cn("[&_svg]:size-4", className)}
|
||||
disabled={!canRefresh}
|
||||
icon={ReloadIcon}
|
||||
@@ -37,11 +41,7 @@ export function AppQueryIndicator({ className }: { className?: string }) {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent showArrow>
|
||||
点击可以刷新
|
||||
<br />
|
||||
当前页面数据
|
||||
</TooltipContent>
|
||||
<TooltipContent showArrow>{refreshDescription}</TooltipContent>
|
||||
</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 d’Ariane",
|
||||
"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>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatForDisplay } from "@tanstack/react-hotkeys"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { Button, type ButtonProps } from "@workspace/ui/components/button"
|
||||
import {
|
||||
createDialogHandle,
|
||||
@@ -41,8 +42,10 @@ import {
|
||||
ShieldKeyIcon,
|
||||
SwatchIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { layoutMessages } from "./messages"
|
||||
|
||||
interface UserMenuItem {
|
||||
id: string
|
||||
action?: NavigationAction
|
||||
shortcut?: string
|
||||
icon: IconData
|
||||
@@ -53,24 +56,6 @@ interface UserMenuTriggerProps {
|
||||
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:
|
||||
| {
|
||||
dialog: DialogHandle<void>
|
||||
@@ -123,8 +108,11 @@ function UserMenuPopoverTrigger({ className }: UserMenuTriggerProps) {
|
||||
}
|
||||
|
||||
function UserMenuTriggerButton({ className, ...props }: ButtonProps) {
|
||||
const openUserMenuLabel = useMessage(layoutMessages.openUserMenu)
|
||||
|
||||
return (
|
||||
<HeaderActionButton
|
||||
aria-label={openUserMenuLabel}
|
||||
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",
|
||||
className
|
||||
@@ -140,6 +128,31 @@ function UserMenuTriggerButton({ className, ...props }: ButtonProps) {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="w-72">
|
||||
<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">
|
||||
<ul className="space-y">
|
||||
{userMenuItems.map((item) => (
|
||||
<li key={item.label}>
|
||||
<li key={item.id}>
|
||||
<Button
|
||||
className="w-full justify-start font-normal"
|
||||
size="lg"
|
||||
@@ -176,7 +189,7 @@ function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) {
|
||||
<div className="rounded-b-md border-t p-2">
|
||||
<Button className="w-full font-normal" variant="destructive" size="lg">
|
||||
<Icon data={LogoutCircle02Icon} size="sm" />
|
||||
退出登录
|
||||
{logoutLabel}
|
||||
<MenuShortcutSequence shortcuts={["Q", "Q", "Q"]} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -213,12 +226,14 @@ function UserMenuDialogTrigger({ className }: UserMenuTriggerProps) {
|
||||
|
||||
function UserMenuDialog() {
|
||||
const { dialog } = getOrCreateUserMenuHandles()
|
||||
const title = useMessage(layoutMessages.userMenuTitle)
|
||||
|
||||
return (
|
||||
<Dialog handle={dialog}>
|
||||
<DialogContent className="w-fit p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle></DialogTitle>
|
||||
<DialogDescription></DialogDescription>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{title}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<UserMenuContent onItemSelect={() => dialog.close()} />
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Icon } from "../../components/icon"
|
||||
|
||||
import { resolveNavigationItemIcon } from "./item-icon"
|
||||
import { NavigationBadge, NavigationLink } from "./navigation-button"
|
||||
import { NavigationLabel } from "./navigation-label"
|
||||
import type { GetNavigationRouteState } from "./route-state"
|
||||
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!"
|
||||
>
|
||||
{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} />
|
||||
</DropdownMenuSubTrigger>
|
||||
<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}
|
||||
>
|
||||
{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} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
} from "./mobile-navigation"
|
||||
export { resolveNavigationItemIcon } from "./item-icon"
|
||||
export { PrimaryNavigation } from "./primary-navigation"
|
||||
export { NavigationLabel, TruncatedNavigationLabel } from "./navigation-label"
|
||||
export { resolveNavigationRouteState } from "./route-state"
|
||||
export {
|
||||
NavigationProvider,
|
||||
@@ -16,4 +17,8 @@ export type {
|
||||
NavigationRouteState,
|
||||
} from "./route-state"
|
||||
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 l’application.",
|
||||
"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 { useMessage } from "@workspace/i18n"
|
||||
import type { ButtonProps } from "@workspace/ui/components/button"
|
||||
import { Separator } from "@workspace/ui/components/separator"
|
||||
import {
|
||||
@@ -15,6 +16,8 @@ import { cn, separate } from "@workspace/ui/lib/utils"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
import { HeaderActionButton } from "../layout/header-action-button"
|
||||
import { navigationMessages } from "./messages"
|
||||
import { NavigationLabel } from "./navigation-label"
|
||||
|
||||
import {
|
||||
NavigationBadge,
|
||||
@@ -37,11 +40,15 @@ export function MobileNavigationSheetTrigger({
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const openNavigationLabel = useMessage(navigationMessages.open)
|
||||
|
||||
return (
|
||||
<SheetTrigger
|
||||
handle={mobileNavigationSheetHandle}
|
||||
className={cn("-ml-2 hidden mobile:inline-flex", className)}
|
||||
render={<HeaderActionButton aria-label="打开导航" {...props} />}
|
||||
render={
|
||||
<HeaderActionButton aria-label={openNavigationLabel} {...props} />
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
data={Menu02Icon}
|
||||
@@ -57,6 +64,9 @@ export interface MobileNavigationSheetProps {
|
||||
}
|
||||
|
||||
export function MobileNavigationSheet({ groups }: MobileNavigationSheetProps) {
|
||||
const title = useMessage(navigationMessages.title)
|
||||
const description = useMessage(navigationMessages.description)
|
||||
|
||||
useOnBreakpoint((breakpoint) => {
|
||||
if (breakpoint !== "mobile" && mobileNavigationSheetHandle.isOpen) {
|
||||
mobileNavigationSheetHandle.close()
|
||||
@@ -75,8 +85,8 @@ export function MobileNavigationSheet({ groups }: MobileNavigationSheetProps) {
|
||||
<Sheet handle={mobileNavigationSheetHandle}>
|
||||
<SheetContent className="max-w-72 gap-0 bg-popover/90" side="left">
|
||||
<SheetHeader className="text-left">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Navigate through the app.</SheetDescription>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>{description}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -124,7 +134,7 @@ function FlatNavigationGroupList({
|
||||
{items.map((group) => (
|
||||
<div key={group.id}>
|
||||
<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 className="rounded-md ring ring-sidebar-border">
|
||||
{separate({
|
||||
|
||||
@@ -8,6 +8,8 @@ import { cn } from "@workspace/ui/lib/utils"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
import { Icon, type IconData } from "../../components/icon"
|
||||
import { NavigationLabel, TruncatedNavigationLabel } from "./navigation-label"
|
||||
import type { NavigationLabelValue } from "./types"
|
||||
|
||||
export interface NavigationButtonProps extends Omit<
|
||||
useRender.ComponentProps<"button", {}>,
|
||||
@@ -16,7 +18,7 @@ export interface NavigationButtonProps extends Omit<
|
||||
icon?: IconData
|
||||
isActive: boolean
|
||||
isCurrent: boolean
|
||||
label: string
|
||||
label: NavigationLabelValue
|
||||
layout?: "inline" | "stacked" | "responsive"
|
||||
className?: string | (() => string | undefined) | undefined
|
||||
style?:
|
||||
@@ -105,21 +107,22 @@ export function NavigationButton({
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 origin-top truncate whitespace-nowrap",
|
||||
"min-w-0 flex-1 origin-top overflow-hidden whitespace-nowrap",
|
||||
layout === "stacked"
|
||||
? [
|
||||
"col-start-1 col-end-5 row-start-2 justify-self-center",
|
||||
"scale-85 text-center text-xs",
|
||||
"col-start-1 col-end-5 row-start-2 w-full max-w-full",
|
||||
"scale-85 justify-center text-xs",
|
||||
]
|
||||
: layout === "responsive"
|
||||
? [
|
||||
"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-end-5",
|
||||
"not-mobile:collapsed:row-start-2",
|
||||
"not-mobile:collapsed:justify-self-center",
|
||||
"not-mobile:collapsed:text-center",
|
||||
"not-mobile:collapsed:w-full",
|
||||
"not-mobile:collapsed:max-w-full",
|
||||
"not-mobile:collapsed:justify-center",
|
||||
"not-mobile:collapsed:text-xs",
|
||||
"tablet:scale-85",
|
||||
"not-mobile:collapsed:scale-85",
|
||||
@@ -127,7 +130,11 @@ export function NavigationButton({
|
||||
: undefined
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{layout === "inline" ? (
|
||||
<NavigationLabel label={label} />
|
||||
) : (
|
||||
<TruncatedNavigationLabel label={label} />
|
||||
)}
|
||||
</span>
|
||||
{hasTrailingContent && (
|
||||
<span
|
||||
@@ -158,7 +165,7 @@ export function NavigationButton({
|
||||
)}
|
||||
</React.Fragment>
|
||||
),
|
||||
"aria-label": label,
|
||||
"aria-label": typeof label === "string" ? label : undefined,
|
||||
}
|
||||
|
||||
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,
|
||||
} from "./navigation-button"
|
||||
import { InlineNestedNavigationList } from "./nested-navigation"
|
||||
import { NavigationLabel } from "./navigation-label"
|
||||
import type { GetNavigationRouteState } from "./route-state"
|
||||
import type { NavigationGroup, NavigationItem } from "./types"
|
||||
|
||||
@@ -172,7 +173,7 @@ export function PrimaryNavigation({
|
||||
{items.map((group) => (
|
||||
<div key={group.id}>
|
||||
<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 className="tablet:space-y-1 not-mobile:collapsed:space-y-1">
|
||||
{group.items.map((item) => (
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
import type { IconData } from "../../components/icon"
|
||||
|
||||
export type NavigationLabelValue = string | MessageDescriptor
|
||||
|
||||
export interface NavigationItem {
|
||||
id: string
|
||||
to?: string
|
||||
label: string
|
||||
label: NavigationLabelValue
|
||||
icon?: IconData
|
||||
showIcon?: boolean
|
||||
items?: readonly NavigationItem[]
|
||||
@@ -11,7 +15,7 @@ export interface NavigationItem {
|
||||
|
||||
export interface NavigationGroup {
|
||||
id: string
|
||||
label: string
|
||||
label: NavigationLabelValue
|
||||
showIcon?: boolean
|
||||
items: readonly NavigationItem[]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import * as React from "react"
|
||||
|
||||
import type { IconData } from "../../components/icon"
|
||||
import { resolveNavigationItemIcon } from "./item-icon"
|
||||
import type { NavigationGroup, NavigationItem } from "./types"
|
||||
import type {
|
||||
NavigationGroup,
|
||||
NavigationItem,
|
||||
NavigationLabelValue,
|
||||
} from "./types"
|
||||
|
||||
export type NavigationKey = NavigationItem["id"]
|
||||
|
||||
@@ -11,7 +15,7 @@ export interface NavigationInfo {
|
||||
id: NavigationKey
|
||||
icon?: IconData
|
||||
items?: readonly NavigationInfo[]
|
||||
label: string
|
||||
label: NavigationLabelValue
|
||||
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": "à l’instant",
|
||||
"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 { CheckCheckIcon } from "lucide-react"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
Empty,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from "@workspace/ui/components/tooltip"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { notificationMessages } from "./messages"
|
||||
import { NotificationItem } from "./notification-item"
|
||||
import type { Notification, NotificationActionHandler } from "./types"
|
||||
|
||||
@@ -37,17 +39,30 @@ export interface NotificationCenterProps extends Omit<
|
||||
|
||||
export function NotificationCenter({
|
||||
className,
|
||||
description = "查看并处理通知",
|
||||
description,
|
||||
notifications,
|
||||
onAction,
|
||||
onMarkAllAsRead,
|
||||
onRefetch,
|
||||
onViewAll,
|
||||
status,
|
||||
title = "Notifications",
|
||||
viewAllLabel = "查看全部",
|
||||
title,
|
||||
viewAllLabel,
|
||||
...props
|
||||
}: 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(
|
||||
(notification) => !notification.isRead
|
||||
).length
|
||||
@@ -59,14 +74,16 @@ export function NotificationCenter({
|
||||
>
|
||||
<header className="grid grid-cols-[1fr_auto] p-4">
|
||||
<h2 className="order-1 font-heading font-medium text-foreground">
|
||||
{title}
|
||||
{resolvedTitle}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ms-1.5 text-sm font-normal text-muted-foreground">
|
||||
({unreadCount})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{description && <p className="sr-only">{description}</p>}
|
||||
{resolvedDescription && (
|
||||
<p className="sr-only">{resolvedDescription}</p>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
delay={0}
|
||||
@@ -74,7 +91,7 @@ export function NotificationCenter({
|
||||
disabled={unreadCount === 0}
|
||||
render={
|
||||
<Button
|
||||
aria-label="全部标记为已读"
|
||||
aria-label={markAllAsReadLabel}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
/>
|
||||
@@ -84,7 +101,7 @@ export function NotificationCenter({
|
||||
<CheckCheckIcon />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" showArrow>
|
||||
全部标记为已读
|
||||
{markAllAsReadLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</header>
|
||||
@@ -94,23 +111,23 @@ export function NotificationCenter({
|
||||
role="status"
|
||||
className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
正在加载通知…
|
||||
{loadingLabel}
|
||||
</div>
|
||||
) : status === "error" ? (
|
||||
<Empty>
|
||||
<EmptyContent>
|
||||
<EmptyTitle>无法加载通知</EmptyTitle>
|
||||
<EmptyDescription>请检查网络连接后重试。</EmptyDescription>
|
||||
<EmptyTitle>{errorTitle}</EmptyTitle>
|
||||
<EmptyDescription>{errorDescription}</EmptyDescription>
|
||||
<Button variant="outline" onClick={onRefetch}>
|
||||
重新加载
|
||||
{retryLabel}
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : notifications.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyContent>
|
||||
<EmptyTitle>暂无通知</EmptyTitle>
|
||||
<EmptyDescription>新的动态会显示在这里。</EmptyDescription>
|
||||
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
@@ -137,7 +154,7 @@ export function NotificationCenter({
|
||||
|
||||
<footer className="mt-auto flex flex-col gap-2 p-4">
|
||||
<Button size="lg" variant="ghost" className="h-12" onClick={onViewAll}>
|
||||
{viewAllLabel}
|
||||
{resolvedViewAllLabel}
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useFormatters, useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { Icon } from "../../components/icon"
|
||||
import { notificationMessages } from "./messages"
|
||||
|
||||
import type {
|
||||
Notification,
|
||||
@@ -43,10 +45,6 @@ const ICON_TONE_CLASSES: Record<NotificationTone, string> = {
|
||||
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<
|
||||
readonly [seconds: number, unit: Intl.RelativeTimeFormatUnit]
|
||||
> = [
|
||||
@@ -71,7 +69,11 @@ function getInitials(media: Extract<NotificationMedia, { kind: "avatar" }>) {
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
function formatRelativeTime(createdAt: string) {
|
||||
function formatNotificationTime(
|
||||
createdAt: string,
|
||||
formatter: (value: number, unit: Intl.RelativeTimeFormatUnit) => string,
|
||||
justNow: string
|
||||
) {
|
||||
const createdAtTime = new Date(createdAt).getTime()
|
||||
|
||||
if (!Number.isFinite(createdAtTime)) {
|
||||
@@ -83,14 +85,11 @@ function formatRelativeTime(createdAt: string) {
|
||||
|
||||
for (const [seconds, unit] of RELATIVE_TIME_UNITS) {
|
||||
if (absoluteDifference >= seconds) {
|
||||
return RELATIVE_TIME_FORMAT.format(
|
||||
Math.round(differenceInSeconds / seconds),
|
||||
unit
|
||||
)
|
||||
return formatter(Math.round(differenceInSeconds / seconds), unit)
|
||||
}
|
||||
}
|
||||
|
||||
return "刚刚"
|
||||
return justNow
|
||||
}
|
||||
|
||||
function NotificationItemMedia({ media }: { media: NotificationMedia }) {
|
||||
@@ -148,6 +147,9 @@ export function NotificationItem({
|
||||
ref,
|
||||
...props
|
||||
}: NotificationItemProps) {
|
||||
const { formatRelativeTime } = useFormatters()
|
||||
const justNow = useMessage(notificationMessages.justNow)
|
||||
|
||||
return (
|
||||
<BaseItem
|
||||
ref={ref}
|
||||
@@ -181,7 +183,12 @@ export function NotificationItem({
|
||||
)}
|
||||
<ItemDescription className="flex items-center gap-1">
|
||||
<time dateTime={notification.createdAt}>
|
||||
{formatRelativeTime(notification.createdAt)}
|
||||
{formatNotificationTime(
|
||||
notification.createdAt,
|
||||
(value, unit) =>
|
||||
formatRelativeTime(value, unit, { numeric: "auto" }),
|
||||
justNow
|
||||
)}
|
||||
</time>
|
||||
{notification.category && (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
createSheetHandle,
|
||||
Sheet,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet"
|
||||
|
||||
import { notificationMessages } from "./messages"
|
||||
import { NotificationCenter } from "./notification-center"
|
||||
import type { Notification, NotificationActionHandler } from "./types"
|
||||
import {
|
||||
@@ -41,9 +43,12 @@ export function NotificationCenterSheet({
|
||||
onViewAll,
|
||||
queryFn,
|
||||
queryKey,
|
||||
title = "Notifications",
|
||||
title,
|
||||
}: NotificationCenterSheetProps) {
|
||||
const notifications = useNotifications({ queryFn, queryKey })
|
||||
const defaultDescription = useMessage(notificationMessages.description)
|
||||
const defaultTitle = useMessage(notificationMessages.title)
|
||||
const resolvedTitle = title ?? defaultTitle
|
||||
|
||||
const handleAction = React.useCallback<NotificationActionHandler>(
|
||||
(event) => {
|
||||
@@ -65,8 +70,10 @@ export function NotificationCenterSheet({
|
||||
className="w-105 gap-0 bg-popover/90"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<SheetTitle className="sr-only">{title}</SheetTitle>
|
||||
<SheetDescription className="sr-only">查看并处理通知</SheetDescription>
|
||||
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{defaultDescription}
|
||||
</SheetDescription>
|
||||
<NotificationCenter
|
||||
notifications={notifications.notifications}
|
||||
onAction={handleAction}
|
||||
@@ -74,7 +81,7 @@ export function NotificationCenterSheet({
|
||||
onRefetch={notifications.refetch}
|
||||
onViewAll={onViewAll}
|
||||
status={notifications.status}
|
||||
title={title}
|
||||
title={resolvedTitle}
|
||||
/>
|
||||
</SheetContent>
|
||||
</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)
|
||||
}
|
||||
@@ -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"
|
||||
>()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@workspace/blocks/*": ["./src/*"],
|
||||
"@workspace/i18n": ["../i18n/src/index.ts"],
|
||||
"@workspace/ui/*": ["../ui/src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user