Procházet zdrojové kódy

feat: Add translations and language support

jamesread před 7 měsíci
rodič
revize
2ed564a403

+ 52 - 11
frontend/main.js

@@ -12,43 +12,84 @@ import { createConnectTransport } from '@connectrpc/connect-web'
 import { OliveTinApiService } from './resources/scripts/gen/olivetin/api/v1/olivetin_pb'
 
 import { createApp } from 'vue'
+import { createI18n } from 'vue-i18n'
+
 import router from './resources/vue/router.js'
 import App from './resources/vue/App.vue'
 
+import combinedTranslations from '../lang/combined_output.json'
+
 import {
   initMarshaller
 } from './js/marshaller.js'
 
 import { checkWebsocketConnection } from './js/websocket.js'
 
-function initClient () {
+function getSelectedLanguage() {
+  const storedLanguage = localStorage.getItem('olivetin-language');
+
+  if (storedLanguage && storedLanguage !== 'auto') {
+    return storedLanguage;
+  }
+
+  if (storedLanguage === 'auto') {
+    localStorage.removeItem('olivetin-language');
+  }
+
+  if (navigator.languages && navigator.languages.length > 0) {
+    return navigator.languages[0];
+  }
+
+  return 'en';
+}
+
+async function initClient () {
   const transport = createConnectTransport({
     baseUrl: window.location.protocol + '//' + window.location.host + '/api/'
-
   })
 
   window.client = createClient(OliveTinApiService, transport)
+  window.initResponse = await window.client.init({})
+  
+  const i18nSettings = createI18n({
+    legacy: false,
+    locale: getSelectedLanguage(),
+    fallbackLocale: 'en',
+    messages: combinedTranslations.messages,
+    postTranslation: (translated) => {
+      const params = new URLSearchParams(window.location.search)
+
+      if (params.has('debug-translations')) {
+        return '____'
+      } else {
+        return translated
+      }
+    }
+  })
+
+  return i18nSettings
 }
 
-function setupVue () {
+function setupVue (i18nSettings) {
   const app = createApp(App)
 
   app.use(router)
+  app.use(i18nSettings)
+  
+  // Make i18n instance accessible globally for language switching
+  window.i18n = i18nSettings.global
+  
   app.mount('#app')
 }
 
-function main () {
-  initClient()
-
-  // Expose websocket connection function globally so App.vue can call it after successful init
+async function main () {
   window.checkWebsocketConnection = checkWebsocketConnection
 
-  setupVue()
+  const i18nSettings = await initClient()
 
-  initMarshaller()
+  setupVue(i18nSettings)
 
-//  window.addEventListener('EventConfigChanged', fetchGetDashboardComponents)
-//  window.addEventListener('EventEntityChanged', fetchGetDashboardComponents)
+  initMarshaller()
 }
 
 main() // call self

+ 69 - 4
frontend/package-lock.json

@@ -17,9 +17,10 @@
 				"@xterm/addon-fit": "^0.10.0",
 				"@xterm/xterm": "^5.5.0",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.8.0",
+				"picocrank": "^1.8.1",
 				"unplugin-vue-components": "^30.0.0",
 				"vite": "^7.1.12",
+				"vue-i18n": "^11.1.12",
 				"vue-router": "^4.6.3"
 			},
 			"devDependencies": {
@@ -732,6 +733,50 @@
 			"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
 			"license": "MIT"
 		},
+		"node_modules/@intlify/core-base": {
+			"version": "11.1.12",
+			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.12.tgz",
+			"integrity": "sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==",
+			"license": "MIT",
+			"dependencies": {
+				"@intlify/message-compiler": "11.1.12",
+				"@intlify/shared": "11.1.12"
+			},
+			"engines": {
+				"node": ">= 16"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/kazupon"
+			}
+		},
+		"node_modules/@intlify/message-compiler": {
+			"version": "11.1.12",
+			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.12.tgz",
+			"integrity": "sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@intlify/shared": "11.1.12",
+				"source-map-js": "^1.0.2"
+			},
+			"engines": {
+				"node": ">= 16"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/kazupon"
+			}
+		},
+		"node_modules/@intlify/shared": {
+			"version": "11.1.12",
+			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.12.tgz",
+			"integrity": "sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==",
+			"license": "MIT",
+			"engines": {
+				"node": ">= 16"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/kazupon"
+			}
+		},
 		"node_modules/@jridgewell/gen-mapping": {
 			"version": "0.3.13",
 			"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2362,9 +2407,9 @@
 			"license": "ISC"
 		},
 		"node_modules/picocrank": {
-			"version": "1.8.0",
-			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.8.0.tgz",
-			"integrity": "sha512-YPGmXvw7vvjIcgrAe3io87kZDM+NUa+aiEYxk8CVqBzgI4koXeF+2VEGPHBwknZBBEbJfXsSdnxVwXrLKpWKfw==",
+			"version": "1.8.1",
+			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.8.1.tgz",
+			"integrity": "sha512-g3JIVY8W5EVDGG+tG83Z0vzhMnj+J/RYkt6/ssdzZofR+6EWfFqE1+DQiyqb6rzjNuj0Y3xy3bpIprtzY9SQ6Q==",
 			"license": "ISC",
 			"dependencies": {
 				"@hugeicons/core-free-icons": "^1.0.16",
@@ -3307,6 +3352,26 @@
 				}
 			}
 		},
+		"node_modules/vue-i18n": {
+			"version": "11.1.12",
+			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.12.tgz",
+			"integrity": "sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==",
+			"license": "MIT",
+			"dependencies": {
+				"@intlify/core-base": "11.1.12",
+				"@intlify/shared": "11.1.12",
+				"@vue/devtools-api": "^6.5.0"
+			},
+			"engines": {
+				"node": ">= 16"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/kazupon"
+			},
+			"peerDependencies": {
+				"vue": "^3.0.0"
+			}
+		},
 		"node_modules/vue-router": {
 			"version": "4.6.3",
 			"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",

+ 2 - 1
frontend/package.json

@@ -30,9 +30,10 @@
 		"@xterm/addon-fit": "^0.10.0",
 		"@xterm/xterm": "^5.5.0",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.8.0",
+		"picocrank": "^1.8.1",
 		"unplugin-vue-components": "^30.0.0",
 		"vite": "^7.1.12",
+		"vue-i18n": "^11.1.12",
 		"vue-router": "^4.6.3"
 	}
 }

+ 235 - 105
frontend/resources/vue/App.vue

@@ -8,7 +8,7 @@
 
         <template #user-info>
             <div class="flex-row user-info" style="gap: .5em;">
-                <span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">Login</router-link></span>
+                <span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">{{ t('login-button') }}</router-link></span>
                 <router-link v-else to="/user" class="user-link" v-if="isLoggedIn">
                     <span id="username-text">{{ username }}</span>
                 </router-link>
@@ -19,35 +19,32 @@
     </Header>
 
     <div id="layout">
-        <Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation && !initError" />
+        <Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation" />
 
 		<div id="content" initial-martial-complete="{{ hasLoaded }}">
             <main title="Main content">
-                <section v-if="initError" class="error-container error" style="text-align: center; padding: 2em;">
-                    <h2>Failed to Initialize OliveTin</h2>
-                    <p><strong>Error Message:</strong> {{ initErrorMessage }}</p>
-                    <p>Please check the your browser console first, and then the server logs for more details.</p>
-                    <button @click="retryInit" class="bad">Retry</button>
-                </section>
-                <router-view v-else :key="$route.fullPath" />
+                <router-view :key="$route.fullPath" />
             </main>
 
-            <footer title="footer" v-if="showFooter && !initError">
+            <footer title="footer" v-if="showFooter">
                 <p>
                     <img title="application icon" :src="logoUrl" alt="OliveTin logo" style="height: 1em;" class="logo" />
                     OliveTin {{ currentVersion }}
                 </p>
                 <p>
                     <span>
-                        <a href="https://docs.olivetin.app" target="_new">Documentation</a>
+                        <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">Raise an issue on
-                            GitHub</a>
+                        <a href="https://github.com/OliveTin/OliveTin/issues/new/choose" target="_new">{{ t('raise-issue') }}</a>
                     </span>
 
-                    <span>{{ serverConnection }}</span>
+                    <span>{{ t('connected') }}</span>
+
+                    <span>
+                        <a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a>
+                    </span>
                 </p>
                 <p>
                     <a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
@@ -55,10 +52,29 @@
             </footer>
         </div>
     </div>
+
+    <dialog ref="languageDialog" class="language-dialog" @click="handleDialogClick">
+        <div class="dialog-content" @click.stop>
+            <h2>Select Language</h2>
+            <select v-model="selectedLanguage" @change="changeLanguage" class="language-select">
+                <option v-for="(name, code) in availableLanguages" :key="code" :value="code">
+                    {{ name }}
+                </option>
+            </select>
+            <p class="browser-languages">
+                Browser languages: 
+                <span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
+                <span v-else>Not available</span>
+            </p>
+            <div class="dialog-buttons">
+                <button @click="closeLanguageDialog">Close</button>
+            </div>
+        </div>
+    </dialog>
 </template>
 
 <script setup>
-import { ref, onMounted } from 'vue';
+import { ref, onMounted, computed } from 'vue';
 import { useRouter } from 'vue-router';
 import Sidebar from 'picocrank/vue/components/Sidebar.vue';
 import Header from 'picocrank/vue/components/Header.vue';
@@ -67,13 +83,16 @@ import { Menu01Icon } from '@hugeicons/core-free-icons'
 import { UserCircle02Icon } from '@hugeicons/core-free-icons'
 import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
 import logoUrl from '../../OliveTinLogo.png';
+import { useI18n } from 'vue-i18n';
+
+const { t, locale } = useI18n();
 
 const router = useRouter();
 
 const sidebar = ref(null);
 const username = ref('notset');
 const isLoggedIn = ref(false);
-const serverConnection = ref('Connected');
+const serverConnection = ref(true);
 const currentVersion = ref('?');
 const bannerMessage = ref('');
 const bannerCss = ref('');
@@ -82,10 +101,46 @@ const showFooter = ref(true)
 const showNavigation = ref(true)
 const showLogs = ref(true)
 const showDiagnostics = ref(true)
-const initError = ref(false)
-const initErrorMessage = ref('')
 const showLoginLink = ref(true)
 
+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)
+
+// 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
+})
+
+function getBrowserLanguage() {
+    if (navigator.languages && navigator.languages.length > 0) {
+        return navigator.languages[0]
+    }
+
+    if (navigator.language) {
+        return navigator.language
+    }
+
+    return 'en'
+}
+
 function toggleSidebar() {
     if (sidebar.value && showNavigation.value) {
         sidebar.value.toggle()
@@ -93,101 +148,129 @@ function toggleSidebar() {
 }
 
 function updateHeaderFromInit() {
-    if (window.initResponse) {
-        username.value = window.initResponse.authenticatedUser
-        isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
-        currentVersion.value = window.initResponse.currentVersion
-        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
+    if (!window.initResponse) {
+        return
+    }
+
+    username.value = window.initResponse.authenticatedUser
+    isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
+    currentVersion.value = window.initResponse.currentVersion
+    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
+
+    if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
+        showLoginLink.value = false
+    }
 
-        if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
-            showLoginLink.value = false
-        }
+    renderSidebar()
+
+    if (window.checkWebsocketConnection) {
+        window.checkWebsocketConnection()
+    }
+
+    if (initResponse.loginRequired) {
+        router.push('/login')
+        return
     }
 }
 
-// Export the function to window so other components can call it
-window.updateHeaderFromInit = updateHeaderFromInit
+function renderSidebar() {
+    if (!sidebar.value) {
+        return
+    }
 
-async function requestInit() {
-    try {
-        const initResponse = await window.client.init({})
-
-        // Store init response first so the login view can read options (e.g., authLocalLogin)
-        window.initResponse = initResponse
-        
-        // Check if login is required and redirect if so (after storing initResponse)
-        if (initResponse.loginRequired) {
-            router.push('/login')
-            return
-        }
-        window.initError = false
-        window.initErrorMessage = ''
-        window.initCompleted = true
-
-        window.updateHeaderFromInit()
-
-        if (showNavigation.value && sidebar.value) {
-            for (const rootDashboard of initResponse.rootDashboards) {
-                sidebar.value.addNavigationLink({
-                    id: rootDashboard,
-                    name: rootDashboard,
-                    title: rootDashboard,
-                    path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
-                    icon: DashboardSquare01Icon,
-                })
-            }
-
-            sidebar.value.addSeparator()
-            sidebar.value.addRouterLink('Entities')
-
-            if (showLogs.value) {
-                sidebar.value.addRouterLink('Logs')
-            }
-
-            if (showDiagnostics.value) {
-                sidebar.value.addRouterLink('Diagnostics')
-            }
-        }
-
-        hasLoaded.value = true;
-        initError.value = false;
-        
-        // Only start websocket connection after successful init
-        if (window.checkWebsocketConnection) {
-            window.checkWebsocketConnection()
-        }
-    } catch (error) {
-        console.error("Error initializing client", error)
-        initError.value = true
-        initErrorMessage.value = error.message || 'Failed to connect to OliveTin server'
-        window.initError = true
-        window.initErrorMessage = error.message || 'Failed to connect to OliveTin server'
-        window.initCompleted = false
-        serverConnection.value = 'Disconnected'
-    }
-}
-
-function retryInit() {
-    initError.value = false
-    initErrorMessage.value = ''
-    window.initError = false
-    window.initErrorMessage = ''
-    window.initCompleted = false
-    requestInit()
+    if (typeof sidebar.value.clear === 'function') {
+        sidebar.value.clear()
+    }
+
+    for (const rootDashboard of initResponse.rootDashboards) {
+        sidebar.value.addNavigationLink({
+            id: rootDashboard,
+            name: rootDashboard,
+            title: rootDashboard,
+            path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
+            icon: DashboardSquare01Icon,
+        })
+    }
+
+    sidebar.value.addSeparator()
+    sidebar.value.addRouterLink('Entities', t('nav.entities'))
+
+    if (showLogs.value) {
+        sidebar.value.addRouterLink('Logs', t('nav.logs'))
+    }
+
+    if (showDiagnostics.value) {
+        sidebar.value.addRouterLink('Diagnostics', t('nav.diagnostics'))
+    }
 }
 
+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 = getBrowserLanguage()
+    } else {
+        window.i18n.locale.value = selectedLanguage.value
+        localStorage.setItem('olivetin-language', selectedLanguage.value)
+        languagePreference.value = selectedLanguage.value
+    }
+
+    // Update sidebar with new translations
+    if (sidebar.value) {
+        renderSidebar()
+    }
+
+    closeLanguageDialog()
+}
+
+function handleDialogClick(event) {
+    // Close dialog when clicking on the backdrop
+    if (event.target === languageDialog.value) {
+        closeLanguageDialog()
+    }
+}
+
+window.updateHeaderFromInit = updateHeaderFromInit
+
 onMounted(() => {
-    serverConnection.value = 'Connected';
-    // Initialize global state
-    window.initError = false
-    window.initErrorMessage = ''
-    window.initCompleted = false
-    requestInit()
+    serverConnection.value = true;
+    updateHeaderFromInit()
+    
+    // Initialize selected language from stored preference
+    selectedLanguage.value = languagePreference.value
+
+    if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
+        browserLanguages.value = navigator.languages
+    }
 })
 </script>
 
@@ -204,4 +287,51 @@ onMounted(() => {
 .user-link:hover {
     text-decoration: underline;
 }
+
+.language-dialog {
+    border: 1px solid var(--border-color, #ccc);
+    border-radius: 0.5rem;
+    padding: 0;
+    max-width: 400px;
+    width: 90%;
+}
+
+.language-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>

+ 2 - 2
frontend/resources/vue/Dashboard.vue

@@ -105,7 +105,7 @@ function waitForInitAndLoadDashboard() {
     }, 1000)
     
     // Check if init has completed successfully
-    if (window.initCompleted && window.initResponse) {
+    if (window.initResponse) {
         getDashboard()
     } else if (window.initError) {
         // Init failed, show error immediately
@@ -118,7 +118,7 @@ function waitForInitAndLoadDashboard() {
     } else {
         // Init hasn't completed yet, poll for completion
         checkInitInterval = setInterval(() => {
-            if (window.initCompleted && window.initResponse) {
+            if (window.initResponse) {
                 clearInterval(checkInitInterval)
                 checkInitInterval = null
                 getDashboard()

+ 12 - 11
frontend/resources/vue/views/LogsListView.vue

@@ -1,12 +1,12 @@
 <template>
-  <Section title="Logs" :padding="false">
+  <Section :title="t('logs.title')" :padding="false">
       <template #toolbar>
         <label class="input-with-icons">
           <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
             <path fill="currentColor"
               d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
           </svg>
-          <input placeholder="Filter current page" v-model="searchText" />
+          <input :placeholder="t('search-filter')" v-model="searchText" />
           <button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
             <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
               <path fill="currentColor"
@@ -16,26 +16,24 @@
         </label>
       </template>
 
-      <p class = "padding">This is a list of logs from actions that have been executed. You can filter the list by action title.</p>
+      <p class = "padding">{{ t('logs.page-description') }}</p>
       <div v-show="filteredLogs.length > 0">
         <table class="logs-table">
           <thead>
             <tr>
-              <th>Timestamp</th>
-              <th>Action</th>
-              <th>Metadata</th>
-              <th>Status</th>
+              <th>{{ t('logs.timestamp') }}</th>
+              <th>{{ t('logs.action') }}</th>
+              <th>{{ t('logs.metadata') }}</th>
+              <th>{{ t('logs.status') }}</th>
             </tr>
           </thead>
           <tbody>
             <tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
               <td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
-              <td>
                 <span class="icon" v-html="log.actionIcon"></span>
                 <router-link :to="`/logs/${log.executionTrackingId}`">
                   {{ log.actionTitle }}
                 </router-link>
-              </td>
               <td class="tags">
                 <span class="annotation">
                   <span class="annotation-key">User:</span>
@@ -59,8 +57,8 @@
       </div>
 
       <div v-show="logs.length === 0" class="empty-state">
-        <p>There are no logs to display.</p>
-        <router-link to="/">Return to index</router-link>
+        <p>{{ t('logs.no-logs-to-display') }}</p>
+        <router-link to="/">{{ t('return-to-index') }}</router-link>
       </div>
   </Section>
 </template>
@@ -69,6 +67,7 @@
 import { ref, computed, onMounted } from 'vue'
 import Pagination from '../components/Pagination.vue'
 import Section from 'picocrank/vue/components/Section.vue'
+import { useI18n } from 'vue-i18n'
 
 const logs = ref([])
 const searchText = ref('')
@@ -77,6 +76,8 @@ const currentPage = ref(1)
 const loading = ref(false)
 const totalCount = ref(0)
 
+const { t } = useI18n()
+
 const filteredLogs = computed(() => {
   if (!searchText.value) {
     return logs.value

+ 2 - 0
lang/Makefile

@@ -0,0 +1,2 @@
+default:
+	go run main.go

+ 36 - 0
lang/README.md

@@ -0,0 +1,36 @@
+Hey, thanks for reading this quick introduction to translations. The project founder
+only speaks two languages; English, and Bad English (!), so the initial translations
+have all been AI-generated. It is assumed that "something is better than nothing". 
+
+It would be most welcome to have human contributors who are native speakers improve these
+translations, or add new ones.
+
+## How to contribute
+
+If a translation file does not exist for your locale, you can create one by copying
+en.yaml and changing the locale code in the filename. You can view the language that
+your browser reports by opening the "Select Language" dialog from the footer.
+
+## File format
+
+Internally, OliveTin uses the vue-i18n library for translations. This does support
+language pluralization and other advanced features. For docs, check the following;
+
+https://vue-i18n.intlify.dev/guide/essentials/pluralization.html
+
+The translation files are in YAML format. Each file contains key-value pairs. 
+
+OliveTin developers then "process" these files into JSON format used for the app.
+
+If you are able, it would be appreciated if you run `make` in the language directory 
+to process your language file before submitting a PR. This will ensure that the JSON
+file is up to date. If you don't understand how to do this, don't worry; just submit
+the YAML file and the developers will take care of it.
+
+## Contributing improvements
+
+Please check out the file `CONTRIBUTING.md` for instructions on how to submit a pull 
+request with your improvements.
+
+As always, if you need any help, please feel free to raise an issue on GitHub or 
+jump into the Discord server for OliveTin.

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
lang/combined_output.json


+ 20 - 0
lang/de-DE.yaml

@@ -0,0 +1,20 @@
+schemaVersion: 1
+translations:
+  welcome: Willkommen bei OliveTin
+  nav.actions: Aktionen
+  nav.logs: Protokolle
+  nav.entities: Entitäten
+  nav.diagnostics: Diagnostik
+  connected: Verbunden
+  login-button: Login
+  raise-issue: Ein Problem melden auf GitHub
+  docs: Dokumentation
+  logs.title: Protokolle
+  logs.page-description: Dies ist eine Liste von Protokollen von Aktionen, die ausgeführt wurden. Sie können die Liste nach Aktionstitel filtern.
+  logs.timestamp: Zeitstempel
+  logs.action: Aktion
+  logs.metadata: Metadaten
+  logs.status: Status
+  logs.no-logs-to-display: Es gibt keine Protokolle zu anzeigen.
+  return-to-index: Zurück zur Startseite
+  search-filter: Filter aktuelle Seite

+ 20 - 0
lang/en.yaml

@@ -0,0 +1,20 @@
+schemaVersion: 1
+translations:
+  welcome: Welcome to OliveTin
+  docs: Documentation
+  raise-issue: Raise an issue on GitHub
+  nav.actions: Actions
+  nav.logs: Logs
+  nav.entities: Entities
+  nav.diagnostics: Diagnostics
+  connected: Connected
+  login-button: Login
+  logs.title: Logs
+  logs.page-description: This is a list of logs from actions that have been executed. You can filter the list by action title.
+  logs.timestamp: Timestamp
+  logs.action: Action
+  logs.metadata: Metadata
+  logs.status: Status
+  logs.no-logs-to-display: There are no logs to display.
+  return-to-index: Return to index
+  search-filter: Filter current page

+ 20 - 0
lang/es-ES.yaml

@@ -0,0 +1,20 @@
+schemaVersion: 1
+translations:
+  welcome: Bienvenido a OliveTin
+  nav.actions: Acciones
+  nav.logs: Registros
+  nav.entities: Entidades
+  nav.diagnostics: Diagnósticos
+  connected: Conectado
+  login-button: Iniciar sesión
+  raise-issue: Reportar un problema en GitHub
+  docs: Documentación
+  logs.title: Registros
+  logs.page-description: Esta es una lista de registros de acciones que han sido ejecutadas. Puede filtrar la lista por título de acción.
+  logs.timestamp: Timestamp
+  logs.action: Acción
+  logs.metadata: Metadatos
+  logs.status: Status
+  logs.no-logs-to-display: No hay registros para mostrar.
+  return-to-index: Volver a la página principal
+  search-filter: Filtrar página actual

+ 11 - 0
lang/go.mod

@@ -0,0 +1,11 @@
+module github.com/OliveTin/OliveTin/langtool
+
+go 1.24.9
+
+require (
+	github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c
+	github.com/sirupsen/logrus v1.9.3
+	gopkg.in/yaml.v3 v3.0.1
+)
+
+require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect

+ 20 - 0
lang/go.sum

@@ -0,0 +1,20 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c h1:v8gN2xXFQjkF0PsoGSqDviRNmPHcBsvl6rMSbvXz1sM=
+github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 21 - 0
lang/it-IT.yaml

@@ -0,0 +1,21 @@
+schemaVersion: 1
+translations:
+  welcome: Benvenuto in OliveTin
+  nav.actions: Azioni
+  nav.logs: Registri
+  nav.entities: Entità
+  nav.diagnostics: Diagnostica
+  docs: Documentazione
+  connected: Connesso
+  login-button: Login
+  raise-issue: Segnala un problema su GitHub
+  logs.title: Registri
+  logs.page-description: Questa è una lista di registri delle azioni che sono state eseguite. Puoi filtrare la lista per titolo dell'azione.
+  logs.timestamp: Timestamp
+  logs.action: Azione
+  logs.metadata: Metadati
+  logs.status: Status
+  logs.no-logs-to-display: Non ci sono registri da mostrare.
+  return-to-index: Torna alla pagina principale
+  search-filter: Filtra la pagina corrente
+  logs.user: Utente

+ 125 - 0
lang/main.go

@@ -0,0 +1,125 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+
+	"github.com/jamesread/golure/pkg/dirs"
+	log "github.com/sirupsen/logrus"
+)
+
+type LanguageFilev1 struct {
+	SchemaVersion int               `json:"schemaVersion"`
+	Translations  map[string]string `json:"translations"`
+}
+
+type CombinedTranslationsOutput struct {
+	Messages map[string]map[string]string `json:"messages"`
+}
+
+func main() {
+	combinedContent := getCombinedLanguageContent()
+
+	jsonData, err := json.Marshal(combinedContent)
+
+	if err != nil {
+		log.Fatalf("Error marshalling combined language content: %v", err)
+	}
+
+	err = os.WriteFile("combined_output.json", jsonData, 0644)
+
+	if err != nil {
+		log.Fatalf("Error saving combined language content to file: %v", err)
+		return
+	}
+
+	log.Infof("Combined language content saved to combined_output.json")
+}
+
+func getLanguageDir() string {
+	dirsToSearch := []string{
+		"../lang",
+		"../../../../lang/", // Relative to this file, for unit tests
+		"/app/lang/",
+	}
+
+	dir, _ := dirs.GetFirstExistingDirectory("lang", dirsToSearch)
+
+	return dir
+}
+
+func getCombinedLanguageContent() *CombinedTranslationsOutput {
+	output := &CombinedTranslationsOutput{
+		Messages: make(map[string]map[string]string),
+	}
+
+	languageDir := getLanguageDir()
+
+	files, err := os.ReadDir(languageDir)
+
+	if err != nil {
+		log.Errorf("Error reading language directory %s: %v", languageDir, err)
+		return output
+	}
+
+	for _, file := range filterLanguageFiles(files) {
+		languageName := strings.Replace(file.Name(), ".yaml", "", 1)
+
+		fullPath := filepath.Join(languageDir, file.Name())
+		log.Infof("Loading language file: %s", fullPath)
+
+		content, err := os.ReadFile(fullPath)
+
+		if err != nil {
+			log.Errorf("Error reading language file %s: %v", fullPath, err)
+			continue
+		}
+
+		var yamlData LanguageFilev1
+
+		err = yaml.Unmarshal(content, &yamlData)
+
+		if err != nil {
+			log.Errorf("Error reading language file %s: %v", fullPath, err)
+			continue
+		}
+
+		output.Messages[languageName] = yamlData.Translations
+	}
+
+	return output
+}
+
+func filterLanguageFiles(files []os.DirEntry) []os.DirEntry {
+	ret := make([]os.DirEntry, 0)
+
+	for _, file := range files {
+		if file.IsDir() {
+			continue
+		}
+
+		if !strings.HasSuffix(file.Name(), ".yaml") {
+			continue
+		}
+
+		ret = append(ret, file)
+	}
+
+	return ret
+}
+
+func parseAcceptLanguages(headerLanguage string) []string {
+	acceptLanguages := make([]string, 0)
+
+	for _, lang := range strings.Split(headerLanguage, ",") {
+		lang = strings.TrimSpace(lang)
+
+		acceptLanguages = append(acceptLanguages, lang)
+	}
+
+	return acceptLanguages
+}

+ 20 - 0
lang/zh-Hans-CN.yaml

@@ -0,0 +1,20 @@
+schemaVersion: 1
+translations:
+  welcome: 欢迎使用 OliveTin
+  nav.actions: 动作
+  nav.logs: 日志
+  nav.entities: 实体
+  nav.diagnostics: 诊断
+  connected: 已连接
+  login-button: 登录
+  raise-issue: 报告问题 on GitHub
+  docs: 文档
+  logs.title: 日志
+  logs.page-description: 这是一个动作执行日志列表。您可以按动作标题过滤列表。
+  logs.timestamp: 时间戳
+  logs.action: 动作
+  logs.metadata: 元数据
+  logs.status: 状态
+  logs.no-logs-to-display: 没有日志可显示。
+  return-to-index: 返回首页
+  search-filter: 过滤当前页面

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů