App.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. <template>
  2. <Header :title="pageTitle" :logoUrl="logoUrl" @toggleSidebar="toggleSidebar" :sidebarEnabled="sidebarEnabled" :topBarEnabled="topbarEnabled" :navigation="navigation">
  3. <template #toolbar>
  4. <div id="banner" v-if="bannerMessage" :style="bannerCss">
  5. <p>{{ bannerMessage }}</p>
  6. </div>
  7. </template>
  8. <template #user-info>
  9. <ConnectionBanner />
  10. <div class="flex-row user-info" style="gap: .5em;">
  11. <span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">{{ t('login-button') }}</router-link></span>
  12. <router-link v-else to="/user" class="user-link" v-if="isLoggedIn">
  13. <span id="username-text">{{ username }}</span>
  14. </router-link>
  15. <HugeiconsIcon :icon="UserCircle02Icon" width = "1.5em" height = "1.5em" v-if="isLoggedIn" />
  16. </div>
  17. </template>
  18. </Header>
  19. <div id="layout">
  20. <Navigation ref="navigation">
  21. <Sidebar ref="sidebar" id = "mainnav" v-if="sidebarEnabled && showNavigation"/>
  22. </Navigation>
  23. <div id="content" initial-martial-complete="{{ hasLoaded }}">
  24. <main title="Main content">
  25. <router-view :key="$route.fullPath" />
  26. </main>
  27. <footer title="footer" v-if="showFooter">
  28. <p>
  29. <img title="application icon" :src="logoUrl" alt="OliveTin logo" style="height: 1em;" class="logo" />
  30. OliveTin <span v-if="showVersionNumber">{{ currentVersion }}</span>
  31. </p>
  32. <p>
  33. <span>
  34. <a href="https://docs.olivetin.app" target="_new">{{ t('docs') }}</a>
  35. </span>
  36. <span>
  37. <a href="https://github.com/OliveTin/OliveTin/issues/new/choose" target="_new">{{ t('raise-issue') }}</a>
  38. </span>
  39. <span>
  40. <a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a>
  41. </span>
  42. <span v-if="availableThemes.length > 1">
  43. <a href="#" @click.prevent="openThemeDialog">{{ currentThemeName }}</a>
  44. </span>
  45. </p>
  46. <p v-if="showVersionNumber">
  47. <a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
  48. </p>
  49. </footer>
  50. </div>
  51. </div>
  52. <dialog ref="languageDialog" class="language-dialog" @click="handleLanguageDialogClick">
  53. <div class="dialog-content" @click.stop>
  54. <h2>{{ t('language-dialog.title') }}</h2>
  55. <select v-model="selectedLanguage" @change="changeLanguage" class="language-select">
  56. <option v-for="(name, code) in availableLanguages" :key="code" :value="code">
  57. {{ code === 'auto' ? name : `${name} (${code})` }}
  58. </option>
  59. </select>
  60. <p class="browser-languages">
  61. {{ t('language-dialog.browser-languages') }}:
  62. <span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
  63. <span v-else>{{ t('language-dialog.not-available') }}</span>
  64. </p>
  65. <div class="dialog-buttons">
  66. <button @click="closeLanguageDialog">{{ t('language-dialog.close') }}</button>
  67. </div>
  68. </div>
  69. </dialog>
  70. <dialog ref="themeDialog" class="theme-dialog" @click="handleThemeDialogClick">
  71. <div class="dialog-content" @click.stop>
  72. <h2>{{ t('theme-dialog.title') }}</h2>
  73. <select v-model="selectedTheme" @change="changeTheme" class="language-select">
  74. <option value="">{{ t('theme-dialog.default') }}</option>
  75. <option v-for="theme in availableThemes" :key="theme" :value="theme">
  76. {{ theme }}
  77. </option>
  78. </select>
  79. <div class="dialog-buttons">
  80. <button @click="closeThemeDialog">{{ t('theme-dialog.close') }}</button>
  81. </div>
  82. </div>
  83. </dialog>
  84. </template>
  85. <script setup>
  86. import { ref, onMounted, computed } from 'vue';
  87. import { useRouter } from 'vue-router';
  88. import Sidebar from 'picocrank/vue/components/Sidebar.vue';
  89. import Navigation from 'picocrank/vue/components/Navigation.vue';
  90. import Header from 'picocrank/vue/components/Header.vue';
  91. import ConnectionBanner from './components/ConnectionBanner.vue';
  92. import { HugeiconsIcon } from '@hugeicons/vue'
  93. import { Menu01Icon } from '@hugeicons/core-free-icons'
  94. import { UserCircle02Icon } from '@hugeicons/core-free-icons'
  95. import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
  96. import logoUrl from '../../OliveTinLogo.png';
  97. import { useI18n } from 'vue-i18n';
  98. import combinedTranslations from '../../../lang/combined_output.json';
  99. const { t, locale } = useI18n();
  100. const router = useRouter();
  101. const sidebar = ref(null);
  102. const navigation = ref(null);
  103. const username = ref('notset');
  104. const isLoggedIn = ref(false);
  105. const currentVersion = ref('?');
  106. const pageTitle = ref('OliveTin');
  107. const bannerMessage = ref('');
  108. const bannerCss = ref('');
  109. const hasLoaded = ref(false);
  110. const showFooter = ref(true)
  111. const showNavigation = ref(true)
  112. const showLogs = ref(true)
  113. const showDiagnostics = ref(true)
  114. const showVersionNumber = ref(true)
  115. const showLoginLink = ref(true)
  116. const sectionNavigationStyle = ref('sidebar')
  117. const languageDialog = ref(null)
  118. const browserLanguages = ref([])
  119. const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-language') : null
  120. const languagePreference = ref(initialLanguagePreference || 'auto')
  121. const selectedLanguage = ref(languagePreference.value)
  122. const themeDialog = ref(null)
  123. const availableThemes = ref([])
  124. const initialThemePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-theme') : null
  125. const themePreference = ref(initialThemePreference || '')
  126. const selectedTheme = ref(themePreference.value)
  127. // Available languages with display names
  128. const availableLanguages = {
  129. 'auto': 'Browser Language',
  130. 'en': 'English',
  131. 'de-DE': 'Deutsch',
  132. 'es-ES': 'Español',
  133. 'it-IT': 'Italiano',
  134. 'zh-Hans-CN': '简体中文'
  135. }
  136. // Computed property to get current language display name
  137. const currentLanguageName = computed(() => {
  138. if (languagePreference.value === 'auto') {
  139. return availableLanguages['auto']
  140. }
  141. return availableLanguages[languagePreference.value] || languagePreference.value
  142. })
  143. // Computed property to get current theme display name
  144. const currentThemeName = computed(() => {
  145. if (!themePreference.value || themePreference.value === '') {
  146. return t('theme-dialog.default')
  147. }
  148. return themePreference.value
  149. })
  150. // Computed properties for navigation style
  151. const topbarEnabled = computed(() => {
  152. return sectionNavigationStyle.value === 'topbar'
  153. })
  154. const sidebarEnabled = computed(() => {
  155. return sectionNavigationStyle.value !== 'topbar' && showNavigation.value
  156. })
  157. function normalizeBrowserLanguage() {
  158. const available = Object.keys(combinedTranslations.messages || {})
  159. if (navigator.languages && navigator.languages.length > 0) {
  160. for (const candidate of navigator.languages) {
  161. const lowerCandidate = candidate.toLowerCase()
  162. // Try exact match (case-insensitive)
  163. const exact = available.find(locale => locale.toLowerCase() === lowerCandidate)
  164. if (exact) {
  165. return exact
  166. }
  167. // Try prefix match (e.g., "zh-CN" -> "zh-Hans-CN")
  168. const prefix = available.find(locale => locale.toLowerCase().startsWith(lowerCandidate.split('-')[0] + '-'))
  169. if (prefix) {
  170. return prefix
  171. }
  172. }
  173. }
  174. return 'en'
  175. }
  176. function toggleSidebar() {
  177. if (sidebar.value && showNavigation.value) {
  178. sidebar.value.toggle()
  179. }
  180. }
  181. function updateHeaderFromInit() {
  182. if (!window.initResponse) {
  183. return
  184. }
  185. username.value = window.initResponse.authenticatedUser
  186. isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
  187. currentVersion.value = window.initResponse.currentVersion
  188. pageTitle.value = window.initResponse.pageTitle || 'OliveTin'
  189. bannerMessage.value = window.initResponse.bannerMessage || ''
  190. bannerCss.value = window.initResponse.bannerCss || ''
  191. showFooter.value = window.initResponse.showFooter
  192. showNavigation.value = window.initResponse.showNavigation
  193. showLogs.value = window.initResponse.showLogList
  194. showDiagnostics.value = window.initResponse.showDiagnostics
  195. showVersionNumber.value = window.initResponse.effectivePolicy?.showVersionNumber ?? true
  196. sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar'
  197. availableThemes.value = window.initResponse.availableThemes || []
  198. if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
  199. showLoginLink.value = false
  200. }
  201. applyStyleMods()
  202. loadCustomJsIfEnabled()
  203. renderNavigation()
  204. applyTheme()
  205. if (window.initResponse.loginRequired) {
  206. router.push('/login')
  207. return
  208. }
  209. }
  210. function renderNavigation() {
  211. if (!navigation.value) {
  212. return
  213. }
  214. const rootDashboards = window.initResponse?.rootDashboards || []
  215. if (typeof navigation.value.clear === 'function') {
  216. navigation.value.clear()
  217. }
  218. for (const rootDashboard of rootDashboards) {
  219. navigation.value.addNavigationLink({
  220. id: rootDashboard,
  221. name: rootDashboard,
  222. title: rootDashboard,
  223. path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
  224. icon: DashboardSquare01Icon,
  225. })
  226. }
  227. navigation.value.addSeparator()
  228. navigation.value.addRouterLink('Entities', t('nav.entities'))
  229. if (showLogs.value) {
  230. navigation.value.addRouterLink('Logs', t('nav.logs'))
  231. }
  232. if (showDiagnostics.value) {
  233. navigation.value.addRouterLink('Diagnostics', t('nav.diagnostics'))
  234. }
  235. }
  236. function openLanguageDialog() {
  237. selectedLanguage.value = languagePreference.value
  238. if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
  239. browserLanguages.value = navigator.languages
  240. } else {
  241. browserLanguages.value = []
  242. }
  243. if (languageDialog.value) {
  244. languageDialog.value.showModal()
  245. }
  246. }
  247. function closeLanguageDialog() {
  248. if (languageDialog.value) {
  249. languageDialog.value.close()
  250. }
  251. }
  252. function changeLanguage() {
  253. if (!window.i18n || !selectedLanguage.value) {
  254. return
  255. }
  256. if (selectedLanguage.value === 'auto') {
  257. localStorage.removeItem('olivetin-language')
  258. languagePreference.value = 'auto'
  259. window.i18n.locale.value = normalizeBrowserLanguage()
  260. } else {
  261. window.i18n.locale.value = selectedLanguage.value
  262. localStorage.setItem('olivetin-language', selectedLanguage.value)
  263. languagePreference.value = selectedLanguage.value
  264. }
  265. // Update navigation with new translations
  266. if (navigation.value) {
  267. renderNavigation()
  268. }
  269. closeLanguageDialog()
  270. }
  271. function handleLanguageDialogClick(event) {
  272. // Close dialog when clicking on the backdrop
  273. if (event.target === languageDialog.value) {
  274. closeLanguageDialog()
  275. }
  276. }
  277. function openThemeDialog() {
  278. selectedTheme.value = themePreference.value || ''
  279. if (themeDialog.value) {
  280. themeDialog.value.showModal()
  281. }
  282. }
  283. function closeThemeDialog() {
  284. if (themeDialog.value) {
  285. themeDialog.value.close()
  286. }
  287. }
  288. function changeTheme() {
  289. if (!selectedTheme.value || selectedTheme.value === '') {
  290. localStorage.removeItem('olivetin-theme')
  291. themePreference.value = ''
  292. } else {
  293. localStorage.setItem('olivetin-theme', selectedTheme.value)
  294. themePreference.value = selectedTheme.value
  295. }
  296. applyTheme()
  297. closeThemeDialog()
  298. }
  299. function applyTheme() {
  300. let themeStyle = document.getElementById('theme-style')
  301. if (!themeStyle) {
  302. themeStyle = document.createElement('style')
  303. themeStyle.id = 'theme-style'
  304. themeStyle.type = 'text/css'
  305. document.head.appendChild(themeStyle)
  306. }
  307. // Load theme into @layer theme so it takes precedence over @layer components
  308. if (themePreference.value && themePreference.value !== '') {
  309. themeStyle.textContent = `@import url('/custom-webui/themes/${themePreference.value}/theme.css') layer(theme);`
  310. } else {
  311. themeStyle.textContent = `@import url('/theme.css') layer(theme);`
  312. }
  313. }
  314. function loadCustomJsIfEnabled() {
  315. if (!window.initResponse?.enableCustomJs || document.getElementById('olivetin-custom-js')) {
  316. return
  317. }
  318. const script = document.createElement('script')
  319. script.src = '/custom-webui/custom.js'
  320. script.async = true
  321. script.id = 'olivetin-custom-js'
  322. document.head.appendChild(script)
  323. }
  324. function applyStyleMods() {
  325. if (!window.initResponse || !window.initResponse.styleMods) {
  326. return
  327. }
  328. for (const styleMod of window.initResponse.styleMods) {
  329. if (styleMod) {
  330. document.body.classList.add(styleMod)
  331. }
  332. }
  333. }
  334. function handleThemeDialogClick(event) {
  335. if (event.target === themeDialog.value) {
  336. closeThemeDialog()
  337. }
  338. }
  339. window.updateHeaderFromInit = updateHeaderFromInit
  340. onMounted(() => {
  341. updateHeaderFromInit()
  342. // Initialize selected language from stored preference
  343. selectedLanguage.value = languagePreference.value
  344. // Initialize selected theme from stored preference
  345. selectedTheme.value = themePreference.value || ''
  346. if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
  347. browserLanguages.value = navigator.languages
  348. }
  349. })
  350. </script>
  351. <style scoped>
  352. .user-info span {
  353. margin-left: 1em;
  354. }
  355. .user-link {
  356. text-decoration: none;
  357. color: inherit;
  358. }
  359. .user-link:hover {
  360. text-decoration: underline;
  361. }
  362. .language-dialog,
  363. .theme-dialog {
  364. border: 1px solid var(--border-color, #ccc);
  365. border-radius: 0.5rem;
  366. padding: 0;
  367. max-width: 400px;
  368. width: 90%;
  369. }
  370. .language-dialog::backdrop,
  371. .theme-dialog::backdrop {
  372. background-color: rgba(0, 0, 0, 0.5);
  373. }
  374. .dialog-content {
  375. padding: 1.5rem;
  376. }
  377. .dialog-content h2 {
  378. margin-top: 0;
  379. margin-bottom: 1rem;
  380. }
  381. .language-select {
  382. width: 100%;
  383. padding: 0.5rem;
  384. margin-bottom: 1rem;
  385. font-size: 1rem;
  386. border: 1px solid var(--border-color, #ccc);
  387. border-radius: 0.25rem;
  388. }
  389. .dialog-buttons {
  390. display: flex;
  391. justify-content: flex-end;
  392. gap: 0.5rem;
  393. }
  394. .dialog-buttons button {
  395. padding: 0.5rem 1rem;
  396. cursor: pointer;
  397. }
  398. .browser-languages {
  399. font-size: 0.875rem;
  400. color: var(--fg2, #555);
  401. margin-bottom: 1rem;
  402. }
  403. </style>