App.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  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 { connectEventStreamIfNeeded } from '../../js/websocket.js';
  93. import { HugeiconsIcon } from '@hugeicons/vue'
  94. import { Menu01Icon } from '@hugeicons/core-free-icons'
  95. import { UserCircle02Icon } from '@hugeicons/core-free-icons'
  96. import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
  97. import logoUrl from '../../OliveTinLogo.png';
  98. import { useI18n } from 'vue-i18n';
  99. import combinedTranslations from '../../../lang/combined_output.json';
  100. const { t, locale } = useI18n();
  101. const router = useRouter();
  102. const sidebar = ref(null);
  103. const navigation = ref(null);
  104. const username = ref('notset');
  105. const isLoggedIn = ref(false);
  106. const currentVersion = ref('?');
  107. const pageTitle = ref('OliveTin');
  108. const bannerMessage = ref('');
  109. const bannerCss = ref('');
  110. const hasLoaded = ref(false);
  111. const showFooter = ref(true)
  112. const showNavigation = ref(true)
  113. const showLogs = ref(true)
  114. const showDiagnostics = ref(true)
  115. const showVersionNumber = ref(true)
  116. const showLoginLink = ref(true)
  117. const sectionNavigationStyle = ref('sidebar')
  118. const languageDialog = ref(null)
  119. const browserLanguages = ref([])
  120. const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-language') : null
  121. const languagePreference = ref(initialLanguagePreference || 'auto')
  122. const selectedLanguage = ref(languagePreference.value)
  123. const themeDialog = ref(null)
  124. const availableThemes = ref([])
  125. const initialThemePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-theme') : null
  126. const themePreference = ref(initialThemePreference || '')
  127. const selectedTheme = ref(themePreference.value)
  128. // Available languages with display names
  129. const availableLanguages = {
  130. 'auto': 'Browser Language',
  131. 'en': 'English',
  132. 'de-DE': 'Deutsch',
  133. 'es-ES': 'Español',
  134. 'it-IT': 'Italiano',
  135. 'zh-Hans-CN': '简体中文'
  136. }
  137. // Computed property to get current language display name
  138. const currentLanguageName = computed(() => {
  139. if (languagePreference.value === 'auto') {
  140. return availableLanguages['auto']
  141. }
  142. return availableLanguages[languagePreference.value] || languagePreference.value
  143. })
  144. // Computed property to get current theme display name
  145. const currentThemeName = computed(() => {
  146. if (!themePreference.value || themePreference.value === '') {
  147. return t('theme-dialog.default')
  148. }
  149. return themePreference.value
  150. })
  151. // Computed properties for navigation style
  152. const topbarEnabled = computed(() => {
  153. return sectionNavigationStyle.value === 'topbar'
  154. })
  155. const sidebarEnabled = computed(() => {
  156. return sectionNavigationStyle.value !== 'topbar' && showNavigation.value
  157. })
  158. function normalizeBrowserLanguage() {
  159. const available = Object.keys(combinedTranslations.messages || {})
  160. if (navigator.languages && navigator.languages.length > 0) {
  161. for (const candidate of navigator.languages) {
  162. const lowerCandidate = candidate.toLowerCase()
  163. // Try exact match (case-insensitive)
  164. const exact = available.find(locale => locale.toLowerCase() === lowerCandidate)
  165. if (exact) {
  166. return exact
  167. }
  168. // Try prefix match (e.g., "zh-CN" -> "zh-Hans-CN")
  169. const prefix = available.find(locale => locale.toLowerCase().startsWith(lowerCandidate.split('-')[0] + '-'))
  170. if (prefix) {
  171. return prefix
  172. }
  173. }
  174. }
  175. return 'en'
  176. }
  177. function toggleSidebar() {
  178. if (sidebar.value && showNavigation.value) {
  179. sidebar.value.toggle()
  180. }
  181. }
  182. function updateHeaderFromInit() {
  183. if (!window.initResponse) {
  184. return
  185. }
  186. username.value = window.initResponse.authenticatedUser
  187. isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
  188. currentVersion.value = window.initResponse.currentVersion
  189. pageTitle.value = window.initResponse.pageTitle || 'OliveTin'
  190. bannerMessage.value = window.initResponse.bannerMessage || ''
  191. bannerCss.value = window.initResponse.bannerCss || ''
  192. showFooter.value = window.initResponse.showFooter
  193. showNavigation.value = window.initResponse.showNavigation
  194. showLogs.value = window.initResponse.showLogList
  195. showDiagnostics.value = window.initResponse.showDiagnostics
  196. showVersionNumber.value = window.initResponse.effectivePolicy?.showVersionNumber ?? true
  197. sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar'
  198. availableThemes.value = window.initResponse.availableThemes || []
  199. if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
  200. showLoginLink.value = false
  201. }
  202. applyStyleMods()
  203. loadCustomJsIfEnabled()
  204. renderNavigation()
  205. applyTheme()
  206. if (window.initResponse.loginRequired) {
  207. connectEventStreamIfNeeded()
  208. router.push('/login')
  209. return
  210. }
  211. connectEventStreamIfNeeded()
  212. }
  213. function renderNavigation() {
  214. if (!navigation.value) {
  215. return
  216. }
  217. const rootDashboards = window.initResponse?.rootDashboards || []
  218. if (typeof navigation.value.clear === 'function') {
  219. navigation.value.clear()
  220. }
  221. for (const rootDashboard of rootDashboards) {
  222. navigation.value.addNavigationLink({
  223. id: rootDashboard,
  224. name: rootDashboard,
  225. title: rootDashboard,
  226. path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
  227. icon: DashboardSquare01Icon,
  228. })
  229. }
  230. navigation.value.addSeparator()
  231. navigation.value.addRouterLink('Entities', t('nav.entities'))
  232. if (showLogs.value) {
  233. navigation.value.addRouterLink('Logs', t('nav.logs'))
  234. }
  235. if (showDiagnostics.value) {
  236. navigation.value.addRouterLink('Diagnostics', t('nav.diagnostics'))
  237. }
  238. }
  239. function openLanguageDialog() {
  240. selectedLanguage.value = languagePreference.value
  241. if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
  242. browserLanguages.value = navigator.languages
  243. } else {
  244. browserLanguages.value = []
  245. }
  246. if (languageDialog.value) {
  247. languageDialog.value.showModal()
  248. }
  249. }
  250. function closeLanguageDialog() {
  251. if (languageDialog.value) {
  252. languageDialog.value.close()
  253. }
  254. }
  255. function changeLanguage() {
  256. if (!window.i18n || !selectedLanguage.value) {
  257. return
  258. }
  259. if (selectedLanguage.value === 'auto') {
  260. localStorage.removeItem('olivetin-language')
  261. languagePreference.value = 'auto'
  262. window.i18n.locale.value = normalizeBrowserLanguage()
  263. } else {
  264. window.i18n.locale.value = selectedLanguage.value
  265. localStorage.setItem('olivetin-language', selectedLanguage.value)
  266. languagePreference.value = selectedLanguage.value
  267. }
  268. // Update navigation with new translations
  269. if (navigation.value) {
  270. renderNavigation()
  271. }
  272. closeLanguageDialog()
  273. }
  274. function handleLanguageDialogClick(event) {
  275. // Close dialog when clicking on the backdrop
  276. if (event.target === languageDialog.value) {
  277. closeLanguageDialog()
  278. }
  279. }
  280. function openThemeDialog() {
  281. selectedTheme.value = themePreference.value || ''
  282. if (themeDialog.value) {
  283. themeDialog.value.showModal()
  284. }
  285. }
  286. function closeThemeDialog() {
  287. if (themeDialog.value) {
  288. themeDialog.value.close()
  289. }
  290. }
  291. function changeTheme() {
  292. if (!selectedTheme.value || selectedTheme.value === '') {
  293. localStorage.removeItem('olivetin-theme')
  294. themePreference.value = ''
  295. } else {
  296. localStorage.setItem('olivetin-theme', selectedTheme.value)
  297. themePreference.value = selectedTheme.value
  298. }
  299. applyTheme()
  300. closeThemeDialog()
  301. }
  302. function applyTheme() {
  303. let themeStyle = document.getElementById('theme-style')
  304. if (!themeStyle) {
  305. themeStyle = document.createElement('style')
  306. themeStyle.id = 'theme-style'
  307. themeStyle.type = 'text/css'
  308. document.head.appendChild(themeStyle)
  309. }
  310. // Load theme into @layer theme so it takes precedence over @layer components
  311. if (themePreference.value && themePreference.value !== '') {
  312. themeStyle.textContent = `@import url('/custom-webui/themes/${themePreference.value}/theme.css') layer(theme);`
  313. } else {
  314. themeStyle.textContent = `@import url('/theme.css') layer(theme);`
  315. }
  316. }
  317. function loadCustomJsIfEnabled() {
  318. if (!window.initResponse?.enableCustomJs || document.getElementById('olivetin-custom-js')) {
  319. return
  320. }
  321. const script = document.createElement('script')
  322. script.src = '/custom-webui/custom.js'
  323. script.async = true
  324. script.id = 'olivetin-custom-js'
  325. document.head.appendChild(script)
  326. }
  327. function applyStyleMods() {
  328. if (!window.initResponse || !window.initResponse.styleMods) {
  329. return
  330. }
  331. for (const styleMod of window.initResponse.styleMods) {
  332. if (styleMod) {
  333. document.body.classList.add(styleMod)
  334. }
  335. }
  336. }
  337. function handleThemeDialogClick(event) {
  338. if (event.target === themeDialog.value) {
  339. closeThemeDialog()
  340. }
  341. }
  342. window.updateHeaderFromInit = updateHeaderFromInit
  343. onMounted(() => {
  344. updateHeaderFromInit()
  345. // Initialize selected language from stored preference
  346. selectedLanguage.value = languagePreference.value
  347. // Initialize selected theme from stored preference
  348. selectedTheme.value = themePreference.value || ''
  349. if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
  350. browserLanguages.value = navigator.languages
  351. }
  352. })
  353. </script>
  354. <style scoped>
  355. .user-info span {
  356. margin-left: 1em;
  357. }
  358. .user-link {
  359. text-decoration: none;
  360. color: inherit;
  361. }
  362. .user-link:hover {
  363. text-decoration: underline;
  364. }
  365. .language-dialog,
  366. .theme-dialog {
  367. border: 1px solid var(--border-color, #ccc);
  368. border-radius: 0.5rem;
  369. padding: 0;
  370. max-width: 400px;
  371. width: 90%;
  372. }
  373. .language-dialog::backdrop,
  374. .theme-dialog::backdrop {
  375. background-color: rgba(0, 0, 0, 0.5);
  376. }
  377. .dialog-content {
  378. padding: 1.5rem;
  379. }
  380. .dialog-content h2 {
  381. margin-top: 0;
  382. margin-bottom: 1rem;
  383. }
  384. .language-select {
  385. width: 100%;
  386. padding: 0.5rem;
  387. margin-bottom: 1rem;
  388. font-size: 1rem;
  389. border: 1px solid var(--border-color, #ccc);
  390. border-radius: 0.25rem;
  391. }
  392. .dialog-buttons {
  393. display: flex;
  394. justify-content: flex-end;
  395. gap: 0.5rem;
  396. }
  397. .dialog-buttons button {
  398. padding: 0.5rem 1rem;
  399. cursor: pointer;
  400. }
  401. .browser-languages {
  402. font-size: 0.875rem;
  403. color: var(--fg2, #555);
  404. margin-bottom: 1rem;
  405. }
  406. </style>