Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions admin/frontend/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ declare module 'vue' {
General: typeof import('./src/components/sites/settings/General.vue')['default']
Git: typeof import('./src/components/settings/Git.vue')['default']
InstallAppDialog: typeof import('./src/components/InstallAppDialog.vue')['default']
LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default']
LogView: typeof import('./src/components/LogView.vue')['default']
MarketplaceAppCard: typeof import('./src/components/MarketplaceAppCard.vue')['default']
NewBenchDialog: typeof import('./src/components/NewBenchDialog.vue')['default']
Expand Down
13 changes: 12 additions & 1 deletion admin/frontend/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<FrappeUIProvider>
<ReconnectOverlay :paused="awaitingTerminal" />
<LanguageSwitcher v-if="isFullScreen" class="top-4 right-4 z-20 fixed" />
<RouterView v-if="isFullScreen" />
<MainLayout v-else>
<RouterView />
Expand All @@ -9,17 +10,27 @@
</template>

<script setup>
import { computed } from 'vue'
import { computed, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { useTheme, FrappeUIProvider } from 'frappe-ui'
import ReconnectOverlay from './components/ReconnectOverlay.vue'
import MainLayout from './layouts/MainLayout.vue'
import LanguageSwitcher from './components/LanguageSwitcher.vue'
import { useSetupHandoff } from './composables/useSetupHandoff'
import { useI18n } from './i18n'

const route = useRoute()
const isFullScreen = computed(() => route.meta.fullScreen === true)
const { awaitingTerminal } = useSetupHandoff()
const { initializeTheme } = useTheme()
const { locale, t } = useI18n()

watchEffect(() => {
locale.value
if (route.name !== 'SiteDetail') {
document.title = route.meta?.titleKey ? `${t(route.meta.titleKey)} - Pilot` : 'Pilot'
}
})

initializeTheme()
</script>
28 changes: 19 additions & 9 deletions admin/frontend/src/components/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import SettingsDialog from '@/components/SettingsDialog.vue'
import BenchSwitcherDialog from '@/components/BenchSwitcherDialog.vue'
import NewBenchDialog from '@/components/NewBenchDialog.vue'
import PilotLogo from '@/components/PilotLogo.vue'
import { useI18n } from '@/i18n'
const { setTheme } = useTheme()
const { locale, setLocale, t } = useI18n()

const route = useRoute()
const router = useRouter()
const sections = sidebarSections()
const sections = computed(() => sidebarSections(t))
const isMobile = useIsMobile()

const showSettings = ref(false)
Expand All @@ -37,29 +39,37 @@ const header = computed(() => ({
title: 'Pilot',
menuItems: [
{
label: 'Central',
label: t('account.central'),
icon: 'lucide-cloud',
},
{
label: 'Settings',
label: t('account.settings'),
icon: 'lucide-settings',
onClick: () => (showSettings.value = true),
},
{
label: 'Switch Bench',
label: t('account.switchBench'),
icon: 'lucide-repeat',
onClick: () => (showBenches.value = true),
},
{
label: 'Theme',
label: t('account.theme'),
icon: 'lucide-sun-moon',
submenu: [
{ label: 'Light', icon: 'lucide-sun', onClick: () => setTheme('light') },
{ label: 'Dark', icon: 'lucide-moon', onClick: () => setTheme('dark') },
{ label: 'System', icon: 'lucide-monitor', onClick: () => setTheme('system') },
{ label: t('account.light'), icon: 'lucide-sun', onClick: () => setTheme('light') },
{ label: t('account.dark'), icon: 'lucide-moon', onClick: () => setTheme('dark') },
{ label: t('account.system'), icon: 'lucide-monitor', onClick: () => setTheme('system') },
],
},
{ label: 'Logout', icon: 'lucide-log-out', onClick: logout },
{
label: t('common.language'),
icon: 'lucide-languages',
submenu: [
{ label: t('common.english'), onClick: () => setLocale('en'), disabled: locale.value === 'en' },
{ label: t('common.chinese'), onClick: () => setLocale('zh-CN'), disabled: locale.value === 'zh-CN' },
],
},
{ label: t('account.logout'), icon: 'lucide-log-out', onClick: logout },
],
}))
</script>
Expand Down
21 changes: 21 additions & 0 deletions admin/frontend/src/components/LanguageSwitcher.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup>
import { Button, Dropdown } from 'frappe-ui'
import { useI18n } from '@/i18n'

const { locale, localeLabel, setLocale, t } = useI18n()

const options = () => [
{ label: t('common.english'), onClick: () => setLocale('en'), disabled: locale.value === 'en' },
{ label: t('common.chinese'), onClick: () => setLocale('zh-CN'), disabled: locale.value === 'zh-CN' },
]
</script>

<template>
<Dropdown :options="options()" placement="bottom-end">
<template #default="{ open }">
<Button variant="ghost" :active="open" :label="localeLabel">
<template #prefix><span class="size-4 lucide-languages" /></template>
</Button>
</template>
</Dropdown>
</template>
54 changes: 54 additions & 0 deletions admin/frontend/src/i18n/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { computed, readonly, ref } from 'vue'
import english from './locales/en'
import simplifiedChinese from './locales/zh-CN'

const STORAGE_KEY = 'pilot.locale'
const DEFAULT_LOCALE = 'en'
const messages = { en: english, 'zh-CN': simplifiedChinese }

function detectLocale() {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved in messages) return saved
return navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : DEFAULT_LOCALE
}

const locale = ref(detectLocale())

function resolveMessage(key) {
return key.split('.').reduce((value, part) => value?.[part], messages[locale.value])
?? key.split('.').reduce((value, part) => value?.[part], messages[DEFAULT_LOCALE])
?? key
}

function translate(key, params = {}) {
let message = resolveMessage(key)
if (message.includes('|')) {
const choices = message.split('|').map((choice) => choice.trim())
message = Number(params.count) === 1 ? choices[0] : choices[1]
}
return message.replace(/\{(\w+)\}/g, (_, name) => params[name] ?? `{${name}}`)
}

function setLocale(value) {
if (!(value in messages)) return
locale.value = value
localStorage.setItem(STORAGE_KEY, value)
document.documentElement.lang = value
}

document.documentElement.lang = locale.value

export const i18n = {
install(app) {
app.config.globalProperties.$t = translate
},
}

export function useI18n() {
return {
locale: readonly(locale),
localeLabel: computed(() => translate(locale.value === 'en' ? 'common.english' : 'common.chinese')),
setLocale,
t: translate,
}
}
63 changes: 63 additions & 0 deletions admin/frontend/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
export default {
common: {
language: 'Language',
english: 'English',
chinese: 'Simplified Chinese',
},
navigation: {
setup: 'Setup',
login: 'Login',
sites: 'Sites',
marketplace: 'Marketplace',
insights: 'Insights',
analytics: 'Analytics',
logs: 'Logs',
tasks: 'Tasks',
devTools: 'Dev tools',
sqlPlayground: 'SQL playground',
},
account: {
central: 'Central',
settings: 'Settings',
switchBench: 'Switch Bench',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
system: 'System',
logout: 'Logout',
},
login: {
title: 'Sign In',
welcome: 'Welcome! Please sign in to continue.',
password: 'Password',
passwordPlaceholder: 'Enter password',
forgotPassword: 'Forgot password?',
continue: 'Continue',
administrator: 'Frappe Bench Administrator',
resetPassword: 'Reset password',
sshInstruction: 'SSH into the server.',
runInstruction: 'Run',
failed: 'Login failed',
unreachable: 'Could not reach the server',
},
sites: {
title: 'Your sites',
search: 'Search',
status: 'Status',
active: 'Active',
broken: 'Broken',
paused: 'Paused',
creating: 'Creating',
site: 'Site',
apps: 'Apps',
appCount: '{count} app | {count} apps',
empty: 'No sites found.',
newSite: 'New site',
openSite: 'Open site',
backupNow: 'Back up now',
loggingIn: 'Logging in as admin',
loggedIn: 'Logged in as admin',
loginFailed: 'Could not log in as admin',
backupFailed: 'Could not start backup',
},
}
63 changes: 63 additions & 0 deletions admin/frontend/src/i18n/locales/zh-CN.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
export default {
common: {
language: '语言',
english: 'English',
chinese: '简体中文',
},
navigation: {
setup: '设置向导',
login: '登录',
sites: '站点',
marketplace: '应用市场',
insights: '洞察',
analytics: '分析',
logs: '日志',
tasks: '任务',
devTools: '开发工具',
sqlPlayground: 'SQL 工作台',
},
account: {
central: '控制中心',
settings: '设置',
switchBench: '切换 Bench',
theme: '主题',
light: '浅色',
dark: '深色',
system: '跟随系统',
logout: '退出登录',
},
login: {
title: '登录',
welcome: '欢迎回来,请登录以继续。',
password: '密码',
passwordPlaceholder: '输入密码',
forgotPassword: '忘记密码?',
continue: '继续',
administrator: 'Frappe Bench 管理员',
resetPassword: '重置密码',
sshInstruction: '通过 SSH 登录服务器。',
runInstruction: '运行',
failed: '登录失败',
unreachable: '无法连接服务器',
},
sites: {
title: '你的站点',
search: '搜索',
status: '状态',
active: '运行中',
broken: '异常',
paused: '已暂停',
creating: '创建中',
site: '站点',
apps: '应用',
appCount: '{count} 个应用',
empty: '未找到站点。',
newSite: '新建站点',
openSite: '打开站点',
backupNow: '立即备份',
loggingIn: '正在以管理员身份登录',
loggedIn: '已以管理员身份登录',
loginFailed: '无法以管理员身份登录',
backupFailed: '无法开始备份',
},
}
6 changes: 4 additions & 2 deletions admin/frontend/src/layouts/MainLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { Breadcrumbs } from 'frappe-ui'
import AppSidebar from '@/components/AppSidebar.vue'
import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
import { useIsMobile } from '@/composables/useIsMobile'
import { useI18n } from '@/i18n'

const route = useRoute()
const { items, resetBreadcrumbs } = useBreadcrumbs()
const isMobile = useIsMobile()
const { t } = useI18n()

watch(() => route.name, resetBreadcrumbs)

Expand All @@ -17,8 +19,8 @@ const breadcrumbs = computed(() => {
return isMobile.value ? all.slice(-1) : all
})

function breadcrumbsFromRouteMeta({ title = '', group }) {
return group ? [{ label: group }, { label: title }] : [{ label: title }]
function breadcrumbsFromRouteMeta({ titleKey = '', groupKey }) {
return groupKey ? [{ label: t(groupKey) }, { label: t(titleKey) }] : [{ label: t(titleKey) }]
}
</script>

Expand Down
2 changes: 2 additions & 0 deletions admin/frontend/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import 'frappe-ui/style.css'
import './index.css'
import App from './App.vue'
import { router } from './router.js'
import { i18n } from './i18n'

const app = createApp(App)
app.use(router)
app.use(i18n)
app.use(FrappeUI, { resources: false, call: false, socketio: false })

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