App.vue 15 KB

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