Skip to content

Commit 14091e2

Browse files
committed
feat: add admin interface localization
1 parent 8d5de6e commit 14091e2

14 files changed

Lines changed: 308 additions & 68 deletions

File tree

admin/frontend/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ declare module 'vue' {
3030
General: typeof import('./src/components/sites/settings/General.vue')['default']
3131
Git: typeof import('./src/components/settings/Git.vue')['default']
3232
InstallAppDialog: typeof import('./src/components/InstallAppDialog.vue')['default']
33+
LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default']
3334
LogView: typeof import('./src/components/LogView.vue')['default']
3435
MarketplaceAppCard: typeof import('./src/components/MarketplaceAppCard.vue')['default']
3536
NewBenchDialog: typeof import('./src/components/NewBenchDialog.vue')['default']

admin/frontend/src/App.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<template>
22
<FrappeUIProvider>
33
<ReconnectOverlay :paused="awaitingTerminal" />
4+
<LanguageSwitcher v-if="isFullScreen" class="top-4 right-4 z-20 fixed" />
45
<RouterView v-if="isFullScreen" />
56
<MainLayout v-else>
67
<RouterView />
@@ -9,17 +10,27 @@
910
</template>
1011

1112
<script setup>
12-
import { computed } from 'vue'
13+
import { computed, watchEffect } from 'vue'
1314
import { useRoute } from 'vue-router'
1415
import { useTheme, FrappeUIProvider } from 'frappe-ui'
1516
import ReconnectOverlay from './components/ReconnectOverlay.vue'
1617
import MainLayout from './layouts/MainLayout.vue'
18+
import LanguageSwitcher from './components/LanguageSwitcher.vue'
1719
import { useSetupHandoff } from './composables/useSetupHandoff'
20+
import { useI18n } from './i18n'
1821
1922
const route = useRoute()
2023
const isFullScreen = computed(() => route.meta.fullScreen === true)
2124
const { awaitingTerminal } = useSetupHandoff()
2225
const { initializeTheme } = useTheme()
26+
const { locale, t } = useI18n()
27+
28+
watchEffect(() => {
29+
locale.value
30+
if (route.name !== 'SiteDetail') {
31+
document.title = route.meta?.titleKey ? `${t(route.meta.titleKey)} - Pilot` : 'Pilot'
32+
}
33+
})
2334
2435
initializeTheme()
2536
</script>

admin/frontend/src/components/AppSidebar.vue

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import SettingsDialog from '@/components/SettingsDialog.vue'
99
import BenchSwitcherDialog from '@/components/BenchSwitcherDialog.vue'
1010
import NewBenchDialog from '@/components/NewBenchDialog.vue'
1111
import PilotLogo from '@/components/PilotLogo.vue'
12+
import { useI18n } from '@/i18n'
1213
const { setTheme } = useTheme()
14+
const { locale, setLocale, t } = useI18n()
1315
1416
const route = useRoute()
1517
const router = useRouter()
16-
const sections = sidebarSections()
18+
const sections = computed(() => sidebarSections(t))
1719
const isMobile = useIsMobile()
1820
1921
const showSettings = ref(false)
@@ -37,29 +39,37 @@ const header = computed(() => ({
3739
title: 'Pilot',
3840
menuItems: [
3941
{
40-
label: 'Central',
42+
label: t('account.central'),
4143
icon: 'lucide-cloud',
4244
},
4345
{
44-
label: 'Settings',
46+
label: t('account.settings'),
4547
icon: 'lucide-settings',
4648
onClick: () => (showSettings.value = true),
4749
},
4850
{
49-
label: 'Switch Bench',
51+
label: t('account.switchBench'),
5052
icon: 'lucide-repeat',
5153
onClick: () => (showBenches.value = true),
5254
},
5355
{
54-
label: 'Theme',
56+
label: t('account.theme'),
5557
icon: 'lucide-sun-moon',
5658
submenu: [
57-
{ label: 'Light', icon: 'lucide-sun', onClick: () => setTheme('light') },
58-
{ label: 'Dark', icon: 'lucide-moon', onClick: () => setTheme('dark') },
59-
{ label: 'System', icon: 'lucide-monitor', onClick: () => setTheme('system') },
59+
{ label: t('account.light'), icon: 'lucide-sun', onClick: () => setTheme('light') },
60+
{ label: t('account.dark'), icon: 'lucide-moon', onClick: () => setTheme('dark') },
61+
{ label: t('account.system'), icon: 'lucide-monitor', onClick: () => setTheme('system') },
6062
],
6163
},
62-
{ label: 'Logout', icon: 'lucide-log-out', onClick: logout },
64+
{
65+
label: t('common.language'),
66+
icon: 'lucide-languages',
67+
submenu: [
68+
{ label: t('common.english'), onClick: () => setLocale('en'), disabled: locale.value === 'en' },
69+
{ label: t('common.chinese'), onClick: () => setLocale('zh-CN'), disabled: locale.value === 'zh-CN' },
70+
],
71+
},
72+
{ label: t('account.logout'), icon: 'lucide-log-out', onClick: logout },
6373
],
6474
}))
6575
</script>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script setup>
2+
import { Button, Dropdown } from 'frappe-ui'
3+
import { useI18n } from '@/i18n'
4+
5+
const { locale, localeLabel, setLocale, t } = useI18n()
6+
7+
const options = () => [
8+
{ label: t('common.english'), onClick: () => setLocale('en'), disabled: locale.value === 'en' },
9+
{ label: t('common.chinese'), onClick: () => setLocale('zh-CN'), disabled: locale.value === 'zh-CN' },
10+
]
11+
</script>
12+
13+
<template>
14+
<Dropdown :options="options()" placement="bottom-end">
15+
<template #default="{ open }">
16+
<Button variant="ghost" :active="open" :label="localeLabel">
17+
<template #prefix><span class="size-4 lucide-languages" /></template>
18+
</Button>
19+
</template>
20+
</Dropdown>
21+
</template>

admin/frontend/src/i18n/index.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { computed, readonly, ref } from 'vue'
2+
import english from './locales/en'
3+
import simplifiedChinese from './locales/zh-CN'
4+
5+
const STORAGE_KEY = 'pilot.locale'
6+
const DEFAULT_LOCALE = 'en'
7+
const messages = { en: english, 'zh-CN': simplifiedChinese }
8+
9+
function detectLocale() {
10+
const saved = localStorage.getItem(STORAGE_KEY)
11+
if (saved in messages) return saved
12+
return navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : DEFAULT_LOCALE
13+
}
14+
15+
const locale = ref(detectLocale())
16+
17+
function resolveMessage(key) {
18+
return key.split('.').reduce((value, part) => value?.[part], messages[locale.value])
19+
?? key.split('.').reduce((value, part) => value?.[part], messages[DEFAULT_LOCALE])
20+
?? key
21+
}
22+
23+
function translate(key, params = {}) {
24+
let message = resolveMessage(key)
25+
if (message.includes('|')) {
26+
const choices = message.split('|').map((choice) => choice.trim())
27+
message = Number(params.count) === 1 ? choices[0] : choices[1]
28+
}
29+
return message.replace(/\{(\w+)\}/g, (_, name) => params[name] ?? `{${name}}`)
30+
}
31+
32+
function setLocale(value) {
33+
if (!(value in messages)) return
34+
locale.value = value
35+
localStorage.setItem(STORAGE_KEY, value)
36+
document.documentElement.lang = value
37+
}
38+
39+
document.documentElement.lang = locale.value
40+
41+
export const i18n = {
42+
install(app) {
43+
app.config.globalProperties.$t = translate
44+
},
45+
}
46+
47+
export function useI18n() {
48+
return {
49+
locale: readonly(locale),
50+
localeLabel: computed(() => translate(locale.value === 'en' ? 'common.english' : 'common.chinese')),
51+
setLocale,
52+
t: translate,
53+
}
54+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
export default {
2+
common: {
3+
language: 'Language',
4+
english: 'English',
5+
chinese: 'Simplified Chinese',
6+
},
7+
navigation: {
8+
setup: 'Setup',
9+
login: 'Login',
10+
sites: 'Sites',
11+
marketplace: 'Marketplace',
12+
insights: 'Insights',
13+
analytics: 'Analytics',
14+
logs: 'Logs',
15+
tasks: 'Tasks',
16+
devTools: 'Dev tools',
17+
sqlPlayground: 'SQL playground',
18+
},
19+
account: {
20+
central: 'Central',
21+
settings: 'Settings',
22+
switchBench: 'Switch Bench',
23+
theme: 'Theme',
24+
light: 'Light',
25+
dark: 'Dark',
26+
system: 'System',
27+
logout: 'Logout',
28+
},
29+
login: {
30+
title: 'Sign In',
31+
welcome: 'Welcome! Please sign in to continue.',
32+
password: 'Password',
33+
passwordPlaceholder: 'Enter password',
34+
forgotPassword: 'Forgot password?',
35+
continue: 'Continue',
36+
administrator: 'Frappe Bench Administrator',
37+
resetPassword: 'Reset password',
38+
sshInstruction: 'SSH into the server.',
39+
runInstruction: 'Run',
40+
failed: 'Login failed',
41+
unreachable: 'Could not reach the server',
42+
},
43+
sites: {
44+
title: 'Your sites',
45+
search: 'Search',
46+
status: 'Status',
47+
active: 'Active',
48+
broken: 'Broken',
49+
paused: 'Paused',
50+
creating: 'Creating',
51+
site: 'Site',
52+
apps: 'Apps',
53+
appCount: '{count} app | {count} apps',
54+
empty: 'No sites found.',
55+
newSite: 'New site',
56+
openSite: 'Open site',
57+
backupNow: 'Back up now',
58+
loggingIn: 'Logging in as admin',
59+
loggedIn: 'Logged in as admin',
60+
loginFailed: 'Could not log in as admin',
61+
backupFailed: 'Could not start backup',
62+
},
63+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
export default {
2+
common: {
3+
language: '语言',
4+
english: 'English',
5+
chinese: '简体中文',
6+
},
7+
navigation: {
8+
setup: '设置向导',
9+
login: '登录',
10+
sites: '站点',
11+
marketplace: '应用市场',
12+
insights: '洞察',
13+
analytics: '分析',
14+
logs: '日志',
15+
tasks: '任务',
16+
devTools: '开发工具',
17+
sqlPlayground: 'SQL 工作台',
18+
},
19+
account: {
20+
central: '控制中心',
21+
settings: '设置',
22+
switchBench: '切换 Bench',
23+
theme: '主题',
24+
light: '浅色',
25+
dark: '深色',
26+
system: '跟随系统',
27+
logout: '退出登录',
28+
},
29+
login: {
30+
title: '登录',
31+
welcome: '欢迎回来,请登录以继续。',
32+
password: '密码',
33+
passwordPlaceholder: '输入密码',
34+
forgotPassword: '忘记密码?',
35+
continue: '继续',
36+
administrator: 'Frappe Bench 管理员',
37+
resetPassword: '重置密码',
38+
sshInstruction: '通过 SSH 登录服务器。',
39+
runInstruction: '运行',
40+
failed: '登录失败',
41+
unreachable: '无法连接服务器',
42+
},
43+
sites: {
44+
title: '你的站点',
45+
search: '搜索',
46+
status: '状态',
47+
active: '运行中',
48+
broken: '异常',
49+
paused: '已暂停',
50+
creating: '创建中',
51+
site: '站点',
52+
apps: '应用',
53+
appCount: '{count} 个应用',
54+
empty: '未找到站点。',
55+
newSite: '新建站点',
56+
openSite: '打开站点',
57+
backupNow: '立即备份',
58+
loggingIn: '正在以管理员身份登录',
59+
loggedIn: '已以管理员身份登录',
60+
loginFailed: '无法以管理员身份登录',
61+
backupFailed: '无法开始备份',
62+
},
63+
}

admin/frontend/src/layouts/MainLayout.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { Breadcrumbs } from 'frappe-ui'
55
import AppSidebar from '@/components/AppSidebar.vue'
66
import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
77
import { useIsMobile } from '@/composables/useIsMobile'
8+
import { useI18n } from '@/i18n'
89
910
const route = useRoute()
1011
const { items, resetBreadcrumbs } = useBreadcrumbs()
1112
const isMobile = useIsMobile()
13+
const { t } = useI18n()
1214
1315
watch(() => route.name, resetBreadcrumbs)
1416
@@ -17,8 +19,8 @@ const breadcrumbs = computed(() => {
1719
return isMobile.value ? all.slice(-1) : all
1820
})
1921
20-
function breadcrumbsFromRouteMeta({ title = '', group }) {
21-
return group ? [{ label: group }, { label: title }] : [{ label: title }]
22+
function breadcrumbsFromRouteMeta({ titleKey = '', groupKey }) {
23+
return groupKey ? [{ label: t(groupKey) }, { label: t(titleKey) }] : [{ label: t(titleKey) }]
2224
}
2325
</script>
2426

admin/frontend/src/main.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import 'frappe-ui/style.css'
44
import './index.css'
55
import App from './App.vue'
66
import { router } from './router.js'
7+
import { i18n } from './i18n'
78

89
const app = createApp(App)
910
app.use(router)
11+
app.use(i18n)
1012
app.use(FrappeUI, { resources: false, call: false, socketio: false })
1113

1214
router.isReady().then(() => app.mount('#app'))

0 commit comments

Comments
 (0)