686 lines
17 KiB
Vue
686 lines
17 KiB
Vue
<template>
|
|
<Header
|
|
:title="pageTitle"
|
|
:logo-url="logoUrl"
|
|
:sidebar-enabled="sidebarEnabled"
|
|
:top-bar-enabled="topbarEnabled"
|
|
:navigation="navigation"
|
|
@toggle-sidebar="toggleSidebar"
|
|
>
|
|
<template #toolbar>
|
|
<QuickSearch
|
|
v-if="!loginRequired && headerSearchEnabled"
|
|
:items="searchIndexItems"
|
|
:auto-import-routes="false"
|
|
:search-fields="['title', 'description', 'category']"
|
|
:max-results="15"
|
|
placeholder="Search actions, dashboards, entities…"
|
|
/>
|
|
<div
|
|
v-if="bannerMessage"
|
|
id="banner"
|
|
:style="bannerCss"
|
|
>
|
|
<p>{{ bannerMessage }}</p>
|
|
</div>
|
|
</template>
|
|
|
|
<template #user-info>
|
|
<ConnectionBanner />
|
|
<div
|
|
class="flex-row user-info"
|
|
style="gap: .5em;"
|
|
>
|
|
<span
|
|
v-if="!isLoggedIn && showLoginLink"
|
|
id="link-login"
|
|
><router-link to="/login">{{ t('login-button') }}</router-link></span>
|
|
<router-link
|
|
v-else-if="isLoggedIn"
|
|
to="/user"
|
|
class="user-link"
|
|
>
|
|
<span id="username-text">{{ username }}</span>
|
|
</router-link>
|
|
<HugeiconsIcon
|
|
v-if="isLoggedIn"
|
|
:icon="UserCircle02Icon"
|
|
width="1.5em"
|
|
height="1.5em"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</Header>
|
|
|
|
<div id="layout">
|
|
<Navigation ref="navigation">
|
|
<Sidebar
|
|
v-if="sidebarEnabled && showNavigation"
|
|
id="mainnav"
|
|
ref="sidebar"
|
|
/>
|
|
</Navigation>
|
|
|
|
<div
|
|
id="content"
|
|
:initial-martial-complete="hasLoaded"
|
|
>
|
|
<main title="Main content">
|
|
<router-view :key="$route.fullPath" />
|
|
</main>
|
|
|
|
<footer
|
|
v-if="showFooter"
|
|
title="footer"
|
|
>
|
|
<p>
|
|
<img
|
|
title="application icon"
|
|
:src="logoUrl"
|
|
alt="OliveTin logo"
|
|
style="height: 1em;"
|
|
class="logo"
|
|
>
|
|
OliveTin <span v-if="showVersionNumber">{{ currentVersion }}</span>
|
|
</p>
|
|
<p>
|
|
<span>
|
|
<a
|
|
href="https://docs.olivetin.app"
|
|
target="_new"
|
|
>{{ t('docs') }}</a>
|
|
</span>
|
|
|
|
<span>
|
|
<a
|
|
href="https://github.com/OliveTin/OliveTin/issues/new/choose"
|
|
target="_new"
|
|
>{{ t('raise-issue') }}</a>
|
|
</span>
|
|
|
|
<span>
|
|
<a
|
|
href="#"
|
|
@click.prevent="openLanguageDialog"
|
|
>{{ currentLanguageName }}</a>
|
|
</span>
|
|
|
|
<span v-if="availableThemes.length > 1">
|
|
<a
|
|
href="#"
|
|
@click.prevent="openThemeDialog"
|
|
>{{ currentThemeName }}</a>
|
|
</span>
|
|
</p>
|
|
<p v-if="showVersionNumber">
|
|
<a
|
|
id="available-version"
|
|
href="http://olivetin.app"
|
|
target="_blank"
|
|
hidden
|
|
>?</a>
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
|
|
<dialog
|
|
ref="languageDialog"
|
|
class="language-dialog"
|
|
@click="handleLanguageDialogClick"
|
|
>
|
|
<div
|
|
class="dialog-content"
|
|
@click.stop
|
|
>
|
|
<h2>{{ t('language-dialog.title') }}</h2>
|
|
<select
|
|
v-model="selectedLanguage"
|
|
class="language-select"
|
|
@change="changeLanguage"
|
|
>
|
|
<option
|
|
v-for="(name, code) in availableLanguages"
|
|
:key="code"
|
|
:value="code"
|
|
>
|
|
{{ code === 'auto' ? name : `${name} (${code})` }}
|
|
</option>
|
|
</select>
|
|
<p class="browser-languages">
|
|
{{ t('language-dialog.browser-languages') }}:
|
|
<span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
|
|
<span v-else>{{ t('language-dialog.not-available') }}</span>
|
|
</p>
|
|
<div class="dialog-buttons">
|
|
<button @click="closeLanguageDialog">
|
|
{{ t('language-dialog.close') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<dialog
|
|
ref="themeDialog"
|
|
class="theme-dialog"
|
|
@click="handleThemeDialogClick"
|
|
>
|
|
<div
|
|
class="dialog-content"
|
|
@click.stop
|
|
>
|
|
<h2>{{ t('theme-dialog.title') }}</h2>
|
|
<select
|
|
v-model="selectedTheme"
|
|
class="language-select"
|
|
@change="changeTheme"
|
|
>
|
|
<option value="">
|
|
{{ t('theme-dialog.default') }}
|
|
</option>
|
|
<option
|
|
v-for="theme in availableThemes"
|
|
:key="theme"
|
|
:value="theme"
|
|
>
|
|
{{ theme }}
|
|
</option>
|
|
</select>
|
|
<div class="dialog-buttons">
|
|
<button @click="closeThemeDialog">
|
|
{{ t('theme-dialog.close') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, computed } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import Sidebar from 'picocrank/vue/components/Sidebar.vue'
|
|
import Navigation from 'picocrank/vue/components/Navigation.vue'
|
|
import Header from 'picocrank/vue/components/Header.vue'
|
|
import QuickSearch from 'picocrank/vue/components/QuickSearch.vue'
|
|
import ConnectionBanner from './components/ConnectionBanner.vue'
|
|
import { connectEventStreamIfNeeded } from '../../js/websocket.js'
|
|
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
import { UserCircle02Icon, DashboardSquare01Icon } from '@hugeicons/core-free-icons'
|
|
import logoUrl from '../../OliveTinLogo.png'
|
|
import { useI18n } from 'vue-i18n'
|
|
import combinedTranslations from '../../../lang/combined_output.json'
|
|
import { searchIndexItems, clearSearchIndex, indexSystemNavigation, indexSearchHints, indexRootDashboardEntries } from './stores/searchIndex.js'
|
|
const { t } = useI18n()
|
|
|
|
const router = useRouter()
|
|
|
|
const sidebar = ref(null)
|
|
const navigation = ref(null)
|
|
const username = ref('notset')
|
|
const isLoggedIn = ref(false)
|
|
const currentVersion = ref('?')
|
|
const pageTitle = ref('OliveTin')
|
|
const bannerMessage = ref('')
|
|
const bannerCss = ref('')
|
|
const hasLoaded = ref(false)
|
|
const showFooter = ref(true)
|
|
const showNavigation = ref(true)
|
|
const showLogs = ref(true)
|
|
const showDiagnostics = ref(true)
|
|
const showVersionNumber = ref(true)
|
|
const showLoginLink = ref(true)
|
|
const sectionNavigationStyle = ref('sidebar')
|
|
const loginRequired = ref(false)
|
|
const headerSearchEnabled = ref(false)
|
|
|
|
const languageDialog = ref(null)
|
|
const browserLanguages = ref([])
|
|
|
|
const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-language') : null
|
|
const languagePreference = ref(initialLanguagePreference || 'auto')
|
|
const selectedLanguage = ref(languagePreference.value)
|
|
|
|
const themeDialog = ref(null)
|
|
const availableThemes = ref([])
|
|
const initialThemePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-theme') : null
|
|
const themePreference = ref(initialThemePreference || '')
|
|
const selectedTheme = ref(themePreference.value)
|
|
|
|
// Available languages with display names
|
|
const availableLanguages = {
|
|
auto: 'Browser Language',
|
|
en: 'English',
|
|
'de-DE': 'Deutsch',
|
|
'es-ES': 'Español',
|
|
'it-IT': 'Italiano',
|
|
'zh-Hans-CN': '简体中文'
|
|
}
|
|
|
|
// Computed property to get current language display name
|
|
const currentLanguageName = computed(() => {
|
|
if (languagePreference.value === 'auto') {
|
|
return availableLanguages.auto
|
|
}
|
|
|
|
return availableLanguages[languagePreference.value] || languagePreference.value
|
|
})
|
|
|
|
// Computed property to get current theme display name
|
|
const currentThemeName = computed(() => {
|
|
if (!themePreference.value || themePreference.value === '') {
|
|
return t('theme-dialog.default')
|
|
}
|
|
return themePreference.value
|
|
})
|
|
|
|
// Computed properties for navigation style
|
|
const topbarEnabled = computed(() => {
|
|
return sectionNavigationStyle.value === 'topbar'
|
|
})
|
|
|
|
const sidebarEnabled = computed(() => {
|
|
return sectionNavigationStyle.value !== 'topbar' && showNavigation.value
|
|
})
|
|
|
|
function normalizeBrowserLanguage () {
|
|
const available = Object.keys(combinedTranslations.messages || {})
|
|
|
|
if (navigator.languages && navigator.languages.length > 0) {
|
|
for (const candidate of navigator.languages) {
|
|
const lowerCandidate = candidate.toLowerCase()
|
|
|
|
// Try exact match (case-insensitive)
|
|
const exact = available.find(locale => locale.toLowerCase() === lowerCandidate)
|
|
if (exact) {
|
|
return exact
|
|
}
|
|
|
|
// Try prefix match (e.g., "zh-CN" -> "zh-Hans-CN")
|
|
const prefix = available.find(locale => locale.toLowerCase().startsWith(lowerCandidate.split('-')[0] + '-'))
|
|
if (prefix) {
|
|
return prefix
|
|
}
|
|
}
|
|
}
|
|
|
|
return 'en'
|
|
}
|
|
|
|
function toggleSidebar () {
|
|
if (sidebar.value && showNavigation.value) {
|
|
sidebar.value.toggle()
|
|
}
|
|
}
|
|
|
|
function updateHeaderFromInit () {
|
|
if (!window.initResponse) {
|
|
return
|
|
}
|
|
|
|
// Rebuild the in-memory search index from this Init; never persist it.
|
|
clearSearchIndex()
|
|
|
|
const authenticatedUser = window.initResponse.authenticatedUser
|
|
loginRequired.value = !!window.initResponse.loginRequired
|
|
headerSearchEnabled.value = !!window.initResponse.features?.headerSearch
|
|
|
|
username.value = authenticatedUser
|
|
isLoggedIn.value = authenticatedUser !== '' && authenticatedUser !== 'guest'
|
|
currentVersion.value = window.initResponse.currentVersion
|
|
pageTitle.value = window.initResponse.pageTitle || 'OliveTin'
|
|
bannerMessage.value = window.initResponse.bannerMessage || ''
|
|
bannerCss.value = window.initResponse.bannerCss || ''
|
|
showFooter.value = window.initResponse.showFooter
|
|
showNavigation.value = window.initResponse.showNavigation
|
|
showLogs.value = window.initResponse.showLogList
|
|
showDiagnostics.value = window.initResponse.showDiagnostics
|
|
showVersionNumber.value = window.initResponse.effectivePolicy?.showVersionNumber ?? true
|
|
sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar'
|
|
availableThemes.value = window.initResponse.availableThemes || []
|
|
|
|
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
|
|
showLoginLink.value = false
|
|
}
|
|
|
|
applyStyleMods()
|
|
loadCustomJsIfEnabled()
|
|
|
|
renderNavigation()
|
|
applyTheme()
|
|
|
|
if (loginRequired.value) {
|
|
connectEventStreamIfNeeded()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
|
|
if (headerSearchEnabled.value) {
|
|
indexSearchHints(window.initResponse.searchHints)
|
|
indexRootDashboardEntries(getRootDashboardEntries())
|
|
}
|
|
|
|
connectEventStreamIfNeeded()
|
|
}
|
|
|
|
function getRootDashboardEntries () {
|
|
const entries = window.initResponse?.rootDashboardEntries
|
|
|
|
if (entries && entries.length > 0) {
|
|
return entries.map((entry) => ({
|
|
title: entry.title,
|
|
category: entry.category || ''
|
|
}))
|
|
}
|
|
|
|
return (window.initResponse?.rootDashboards || []).map((title) => ({
|
|
title,
|
|
category: ''
|
|
}))
|
|
}
|
|
|
|
function addDashboardNavLink (title) {
|
|
navigation.value.addNavigationLink({
|
|
id: title,
|
|
name: title,
|
|
title,
|
|
path: title === 'Actions' ? '/' : `/dashboards/${title}`,
|
|
icon: DashboardSquare01Icon
|
|
})
|
|
}
|
|
|
|
function addCategorizedDashboardLinks (entries) {
|
|
const categoryOrder = []
|
|
const byCategory = new Map()
|
|
|
|
for (const entry of entries) {
|
|
const category = entry.category.trim()
|
|
|
|
if (!byCategory.has(category)) {
|
|
byCategory.set(category, [])
|
|
categoryOrder.push(category)
|
|
}
|
|
|
|
byCategory.get(category).push(entry.title)
|
|
}
|
|
|
|
for (const category of categoryOrder) {
|
|
navigation.value.addSection(category)
|
|
|
|
for (const title of byCategory.get(category)) {
|
|
addDashboardNavLink(title)
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderNavigation () {
|
|
if (!navigation.value) {
|
|
return
|
|
}
|
|
|
|
if (typeof navigation.value.clearNavigationLinks === 'function') {
|
|
navigation.value.clearNavigationLinks()
|
|
}
|
|
|
|
const entries = getRootDashboardEntries()
|
|
const uncategorized = entries.filter((entry) => !entry.category.trim())
|
|
const categorized = entries.filter((entry) => entry.category.trim())
|
|
|
|
for (const entry of uncategorized) {
|
|
addDashboardNavLink(entry.title)
|
|
}
|
|
|
|
addCategorizedDashboardLinks(categorized)
|
|
addSystemNavLinks()
|
|
}
|
|
|
|
function addSystemNavLinks () {
|
|
const systemLinks = []
|
|
|
|
systemLinks.push({
|
|
routeName: 'Entities',
|
|
title: t('nav.entities')
|
|
})
|
|
|
|
if (showLogs.value) {
|
|
systemLinks.push({
|
|
routeName: 'Logs',
|
|
title: t('nav.logs')
|
|
})
|
|
}
|
|
|
|
if (showDiagnostics.value) {
|
|
const issueCount = window.initResponse?.configIssueCount || 0
|
|
systemLinks.push({
|
|
routeName: 'Diagnostics',
|
|
title: t('nav.diagnostics'),
|
|
options: { count: issueCount }
|
|
})
|
|
}
|
|
|
|
if (!loginRequired.value && headerSearchEnabled.value) {
|
|
indexRootDashboardEntries(getRootDashboardEntries())
|
|
indexSystemNavigation({
|
|
showLogs: showLogs.value,
|
|
showDiagnostics: showDiagnostics.value,
|
|
entitiesTitle: t('nav.entities'),
|
|
logsTitle: t('nav.logs'),
|
|
diagnosticsTitle: t('nav.diagnostics')
|
|
})
|
|
}
|
|
|
|
if (systemLinks.length === 0) {
|
|
return
|
|
}
|
|
|
|
navigation.value.addSection(t('nav.system'))
|
|
|
|
for (const link of systemLinks) {
|
|
navigation.value.addRouterLink(link.routeName, link.title, link.options || {})
|
|
}
|
|
}
|
|
|
|
function openLanguageDialog () {
|
|
selectedLanguage.value = languagePreference.value
|
|
|
|
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
|
|
browserLanguages.value = navigator.languages
|
|
} else {
|
|
browserLanguages.value = []
|
|
}
|
|
|
|
if (languageDialog.value) {
|
|
languageDialog.value.showModal()
|
|
}
|
|
}
|
|
|
|
function closeLanguageDialog () {
|
|
if (languageDialog.value) {
|
|
languageDialog.value.close()
|
|
}
|
|
}
|
|
|
|
function changeLanguage () {
|
|
if (!window.i18n || !selectedLanguage.value) {
|
|
return
|
|
}
|
|
|
|
if (selectedLanguage.value === 'auto') {
|
|
localStorage.removeItem('olivetin-language')
|
|
languagePreference.value = 'auto'
|
|
window.i18n.locale.value = normalizeBrowserLanguage()
|
|
} else {
|
|
window.i18n.locale.value = selectedLanguage.value
|
|
localStorage.setItem('olivetin-language', selectedLanguage.value)
|
|
languagePreference.value = selectedLanguage.value
|
|
}
|
|
|
|
// Update navigation with new translations
|
|
if (navigation.value) {
|
|
renderNavigation()
|
|
}
|
|
|
|
closeLanguageDialog()
|
|
}
|
|
|
|
function handleLanguageDialogClick (event) {
|
|
// Close dialog when clicking on the backdrop
|
|
if (event.target === languageDialog.value) {
|
|
closeLanguageDialog()
|
|
}
|
|
}
|
|
|
|
function openThemeDialog () {
|
|
selectedTheme.value = themePreference.value || ''
|
|
|
|
if (themeDialog.value) {
|
|
themeDialog.value.showModal()
|
|
}
|
|
}
|
|
|
|
function closeThemeDialog () {
|
|
if (themeDialog.value) {
|
|
themeDialog.value.close()
|
|
}
|
|
}
|
|
|
|
function changeTheme () {
|
|
if (!selectedTheme.value || selectedTheme.value === '') {
|
|
localStorage.removeItem('olivetin-theme')
|
|
themePreference.value = ''
|
|
} else {
|
|
localStorage.setItem('olivetin-theme', selectedTheme.value)
|
|
themePreference.value = selectedTheme.value
|
|
}
|
|
|
|
applyTheme()
|
|
closeThemeDialog()
|
|
}
|
|
|
|
function applyTheme () {
|
|
let themeStyle = document.getElementById('theme-style')
|
|
|
|
if (!themeStyle) {
|
|
themeStyle = document.createElement('style')
|
|
themeStyle.id = 'theme-style'
|
|
themeStyle.type = 'text/css'
|
|
document.head.appendChild(themeStyle)
|
|
}
|
|
|
|
// Load theme into @layer theme so it takes precedence over @layer components
|
|
if (themePreference.value && themePreference.value !== '') {
|
|
themeStyle.textContent = `@import url('/custom-webui/themes/${themePreference.value}/theme.css') layer(theme);`
|
|
} else {
|
|
themeStyle.textContent = '@import url(\'/theme.css\') layer(theme);'
|
|
}
|
|
}
|
|
|
|
function loadCustomJsIfEnabled () {
|
|
if (!window.initResponse?.enableCustomJs || document.getElementById('olivetin-custom-js')) {
|
|
return
|
|
}
|
|
const script = document.createElement('script')
|
|
script.src = '/custom-webui/custom.js'
|
|
script.async = true
|
|
script.id = 'olivetin-custom-js'
|
|
document.head.appendChild(script)
|
|
}
|
|
|
|
function applyStyleMods () {
|
|
if (!window.initResponse || !window.initResponse.styleMods) {
|
|
return
|
|
}
|
|
|
|
for (const styleMod of window.initResponse.styleMods) {
|
|
if (styleMod) {
|
|
document.body.classList.add(styleMod)
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleThemeDialogClick (event) {
|
|
if (event.target === themeDialog.value) {
|
|
closeThemeDialog()
|
|
}
|
|
}
|
|
|
|
window.updateHeaderFromInit = updateHeaderFromInit
|
|
|
|
onMounted(() => {
|
|
updateHeaderFromInit()
|
|
|
|
// Initialize selected language from stored preference
|
|
selectedLanguage.value = languagePreference.value
|
|
|
|
// Initialize selected theme from stored preference
|
|
selectedTheme.value = themePreference.value || ''
|
|
|
|
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
|
|
browserLanguages.value = navigator.languages
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.user-info span {
|
|
margin-left: 1em;
|
|
}
|
|
|
|
.user-link {
|
|
text-decoration: none;
|
|
color: inherit;
|
|
}
|
|
|
|
.user-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.language-dialog,
|
|
.theme-dialog {
|
|
border: 1px solid var(--border-color, #ccc);
|
|
border-radius: 0.5rem;
|
|
padding: 0;
|
|
max-width: 400px;
|
|
width: 90%;
|
|
}
|
|
|
|
.language-dialog::backdrop,
|
|
.theme-dialog::backdrop {
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.dialog-content {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.dialog-content h2 {
|
|
margin-top: 0;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.language-select {
|
|
width: 100%;
|
|
padding: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
font-size: 1rem;
|
|
border: 1px solid var(--border-color, #ccc);
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.dialog-buttons {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.dialog-buttons button {
|
|
padding: 0.5rem 1rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.browser-languages {
|
|
font-size: 0.875rem;
|
|
color: var(--fg2, #555);
|
|
margin-bottom: 1rem;
|
|
}
|
|
</style>
|