olivetin/frontend/resources/vue/App.vue

651 lines
16 KiB
Vue

<template>
<Header
:title="pageTitle"
:logo-url="logoUrl"
:sidebar-enabled="sidebarEnabled"
:top-bar-enabled="topbarEnabled"
:navigation="navigation"
@toggle-sidebar="toggleSidebar"
>
<template #toolbar>
<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 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'
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 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
}
username.value = window.initResponse.authenticatedUser
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.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 (window.initResponse.loginRequired) {
connectEventStreamIfNeeded()
router.push('/login')
return
}
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 (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>