Explorar o código

feat: add theme selector support, load themes in a layer.

jamesread hai 5 meses
pai
achega
87913a6ff3

+ 4 - 1
config.yaml

@@ -124,7 +124,10 @@ actions:
   #
   # Docs: https://docs.olivetin.app/reference/reference_themes_for_users.html
   - title: Get OliveTin Theme
-    shell: olivetin-get-theme {{ themeGitRepo }} {{ themeFolderName }}
+    exec: 
+      - "olivetin-get-theme"
+      - "{{ themeGitRepo }}"
+      - "{{ themeFolderName }}"
     icon: theme
     arguments:
       - name: themeGitRepo

+ 0 - 1
frontend/index.html

@@ -8,7 +8,6 @@
 
 		<title>OliveTin</title>
 
-		<link rel = "stylesheet" type = "text/css" href = "/theme.css" />
 		<link rel = "stylesheet" href = "node_modules/@xterm/xterm/css/xterm.css" />
 
 		<link rel = "shortcut icon" type = "image/png" href = "OliveTinLogo.png" />

+ 1 - 0
frontend/package-lock.json

@@ -21,6 +21,7 @@
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^30.0.0",
 				"vite": "^7.3.1",
+				"vue": "^3.5.26",
 				"vue-i18n": "^11.2.8",
 				"vue-router": "^4.6.4"
 			},

+ 7 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -1451,6 +1451,13 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
    * @generated from field: bool login_required = 23;
    */
   loginRequired: boolean;
+
+  /**
+   * List of available theme names
+   *
+   * @generated from field: repeated string available_themes = 24;
+   */
+  availableThemes: string[];
 };
 
 /**

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 89 - 87
frontend/resources/vue/ActionButton.vue

@@ -318,109 +318,111 @@ watch(
 
 </script>
 
-<style scoped>
-.action-button {
-	display: flex;
-	flex-direction: column;
-	flex-grow: 1;
-}
+<style>
 
-.action-button button {
-	display: flex;
-	flex-direction: column;
-	flex-grow: 1;
-	justify-content: center;
-	padding: 0.5em;
-	border: 1px solid #ccc;
-	border-radius: 4px;
-	background: #fff;
-	cursor: pointer;
-	transition: all 0.2s ease;
-	box-shadow: 0 0 .6em #aaa;
-	font-size: .85em;
-	border-radius: .7em;
-}
+@layer components {
+	.action-button {
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+	}
 
-.action-button button:hover:not(:disabled) {
-	background: #f5f5f5;
-	border-color: #999;
-}
+	.action-button button {
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+		justify-content: center;
+		padding: 0.5em;
+		border: 1px solid #ccc;
+		border-radius: 4px;
+		background: #fff;
+		cursor: pointer;
+		transition: all 0.2s ease;
+		box-shadow: 0 0 .6em #aaa;
+		font-size: .85em;
+		border-radius: .7em;
+	}
 
-.action-button button:disabled {
-	opacity: 0.6;
-	cursor: not-allowed;
-}
+	.action-button button:hover:not(:disabled) {
+		background: #f5f5f5;
+		border-color: #999;
+	}
 
-.action-button button .icon {
-	font-size: 3em;
-	flex-grow: 1;
-	align-content: center;
-}
+	.action-button button:disabled {
+		opacity: 0.6;
+		cursor: not-allowed;
+	}
 
-.action-button button .title {
-	font-weight: 500;
+	.action-button button .icon {
+		font-size: 3em;
+		flex-grow: 1;
+		align-content: center;
+	}
 
-	padding: 0.2em;
-}
+	.action-button button .title {
+		font-weight: 500;
 
-.action-button button .rate-limit-message {
-	font-size: 0.75em;
-	color: #856404;
-	padding: 0.2em;
-	font-weight: normal;
-}
+		padding: 0.2em;
+	}
 
-/* Animation classes */
-.action-button button.action-timeout {
-	background: #fff3cd;
-	border-color: #ffeaa7;
-	color: #856404;
-}
+	.action-button button .rate-limit-message {
+		font-size: 0.75em;
+		color: #856404;
+		padding: 0.2em;
+		font-weight: normal;
+	}
 
-.action-button button.action-blocked {
-	background: #f8d7da !important;
-	border-color: #f5c6cb;
-	color: #721c24;
-}
+	/* Animation classes */
+	.action-button button.action-timeout {
+		background: #fff3cd;
+		border-color: #ffeaa7;
+		color: #856404;
+	}
 
-.action-button button.action-nonzero-exit {
-	background: #f8d7da !important;
-	border-color: #f5c6cb;
-	color: #721c24;
-}
+	.action-button button.action-blocked {
+		background: #f8d7da !important;
+		border-color: #f5c6cb;
+		color: #721c24;
+	}
 
-.action-button button.action-success {
-	background: #d4edda !important;
-	border-color: #c3e6cb;
-	color: #155724;
-}
+	.action-button button.action-nonzero-exit {
+		background: #f8d7da !important;
+		border-color: #f5c6cb;
+		color: #721c24;
+	}
 
-.action-button-footer {
-	margin-top: 0.5em;
-}
+	.action-button button.action-success {
+		background: #d4edda !important;
+		border-color: #c3e6cb;
+		color: #155724;
+	}
 
-.navigate-on-start-container {
-	position: relative;
-	margin-left: auto;
-	height: 0;
-	right: 0;
-	top: 0;
-}
+	.action-button-footer {
+		margin-top: 0.5em;
+	}
 
-@media (prefers-color-scheme: dark) {
-	.action-button button {
-		background: #111;
-		border-color: #000;
-		box-shadow: 0 0 6px #000;
-		color: #fff;
+	.navigate-on-start-container {
+		position: relative;
+		margin-left: auto;
+		height: 0;
+		right: 0;
+		top: 0;
 	}
 
-	.action-button button:hover:not(:disabled) {
-		background: #222;
-		border-color: #000;
-		box-shadow: 0 0 6px #444;
-		color: #fff;
+	@media (prefers-color-scheme: dark) {
+		.action-button button {
+			background: #111;
+			border-color: #000;
+			box-shadow: 0 0 6px #000;
+			color: #fff;
+		}
+
+		.action-button button:hover:not(:disabled) {
+			background: #222;
+			border-color: #000;
+			box-shadow: 0 0 6px #444;
+			color: #fff;
+		}
 	}
 }
-
 </style>

+ 94 - 4
frontend/resources/vue/App.vue

@@ -46,6 +46,10 @@
                         <a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a>
                     </span>
 
+                    <span v-if="availableThemes.length > 1">
+                        <a href="#" @click.prevent="openThemeDialog">{{ currentThemeName }}</a>
+                    </span>
+
                     <span>{{ t('connected') }}</span>
                 </p>
                 <p>
@@ -55,7 +59,7 @@
         </div>
     </div>
 
-    <dialog ref="languageDialog" class="language-dialog" @click="handleDialogClick">
+    <dialog ref="languageDialog" class="language-dialog" @click="handleLanguageDialogClick">
         <div class="dialog-content" @click.stop>
             <h2>{{ t('language-dialog.title') }}</h2>
             <select v-model="selectedLanguage" @change="changeLanguage" class="language-select">
@@ -73,6 +77,21 @@
             </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" @change="changeTheme" class="language-select">
+                <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>
@@ -117,6 +136,12 @@ const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.g
 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',
@@ -136,6 +161,14 @@ const currentLanguageName = computed(() => {
     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'
@@ -191,12 +224,14 @@ function updateHeaderFromInit() {
     showLogs.value = window.initResponse.showLogList
     showDiagnostics.value = window.initResponse.showDiagnostics
     sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar'
+    availableThemes.value = window.initResponse.availableThemes || []
 
     if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
         showLoginLink.value = false
     }
 
     renderNavigation()
+    applyTheme()
 
     if (window.initResponse.loginRequired) {
         router.push('/login')
@@ -280,13 +315,63 @@ function changeLanguage() {
     closeLanguageDialog()
 }
 
-function handleDialogClick(event) {
+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)
+    }
+
+    if (themePreference.value && themePreference.value !== '') {
+        themeStyle.textContent = `@import url('/custom-webui/themes/${themePreference.value}/theme.css') layer(theme);`
+    } else {
+        themeStyle.textContent = ''
+    }
+}
+
+function handleThemeDialogClick(event) {
+    if (event.target === themeDialog.value) {
+        closeThemeDialog()
+    }
+}
+
 window.updateHeaderFromInit = updateHeaderFromInit
 
 onMounted(() => {
@@ -295,6 +380,9 @@ onMounted(() => {
     
     // 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
@@ -316,7 +404,8 @@ onMounted(() => {
     text-decoration: underline;
 }
 
-.language-dialog {
+.language-dialog,
+.theme-dialog {
     border: 1px solid var(--border-color, #ccc);
     border-radius: 0.5rem;
     padding: 0;
@@ -324,7 +413,8 @@ onMounted(() => {
     width: 90%;
 }
 
-.language-dialog::backdrop {
+.language-dialog::backdrop,
+.theme-dialog::backdrop {
     background-color: rgba(0, 0, 0, 0.5);
 }
 

+ 3 - 1
frontend/style.css

@@ -1,3 +1,5 @@
+@layer components, karma, theme;
+
 header {
 	position: fixed;
 	width: 100%;
@@ -36,4 +38,4 @@ div.buttons button svg {
 
 section.small {
 	border-radius: .4em;
-}
+}

+ 9 - 0
frontend/vite.config.js

@@ -18,6 +18,15 @@ export default defineConfig({
         target: 'http://localhost:1337',
         changeOrigin: true,
         secure: false,
+      },
+      '/theme.css': {
+        target: 'http://localhost:1337',
+        changeOrigin: true,
+        secure: false,
+      },
+      "/custom-webui": {
+        target: "http://localhost:1337",
+        changeOrigin: true,
       }
     },
   },

+ 15 - 0
lang/combined_output.json

@@ -49,6 +49,9 @@
             "raise-issue": "Ein Problem melden auf GitHub",
             "return-to-index": "Zurück zur Startseite",
             "search-filter": "Filter aktuelle Seite",
+            "theme-dialog.close": "Schließen",
+            "theme-dialog.default": "Standard-Design",
+            "theme-dialog.title": "Design auswählen",
             "welcome": "Willkommen bei OliveTin"
         },
         "en": {
@@ -99,6 +102,9 @@
             "raise-issue": "Raise an issue on GitHub",
             "return-to-index": "Return to index",
             "search-filter": "Filter current page",
+            "theme-dialog.close": "Close",
+            "theme-dialog.default": "Default Theme",
+            "theme-dialog.title": "Select Theme",
             "welcome": "Welcome to OliveTin"
         },
         "es-ES": {
@@ -149,6 +155,9 @@
             "raise-issue": "Reportar un problema en GitHub",
             "return-to-index": "Volver a la página principal",
             "search-filter": "Filtrar página actual",
+            "theme-dialog.close": "Cerrar",
+            "theme-dialog.default": "Tema Predeterminado",
+            "theme-dialog.title": "Seleccionar tema",
             "welcome": "Bienvenido a OliveTin"
         },
         "it-IT": {
@@ -199,6 +208,9 @@
             "raise-issue": "Segnala un problema su GitHub",
             "return-to-index": "Torna alla pagina principale",
             "search-filter": "Filtra la pagina corrente",
+            "theme-dialog.close": "Chiudi",
+            "theme-dialog.default": "Tema Predefinito",
+            "theme-dialog.title": "Seleziona tema",
             "welcome": "Benvenuto in OliveTin"
         },
         "zh-Hans-CN": {
@@ -249,6 +261,9 @@
             "raise-issue": "在 GitHub 上报告问题",
             "return-to-index": "返回首页",
             "search-filter": "过滤当前页面",
+            "theme-dialog.close": "关闭",
+            "theme-dialog.default": "默认主题",
+            "theme-dialog.title": "选择主题",
             "welcome": "欢迎使用 OliveTin"
         }
     }

+ 4 - 1
lang/de-DE.yaml

@@ -47,4 +47,7 @@ translations:
   language-dialog.title: Sprache auswählen
   language-dialog.browser-languages: Browser-Sprachen
   language-dialog.not-available: Nicht verfügbar
-  language-dialog.close: Schließen
+  language-dialog.close: Schließen
+  theme-dialog.title: Design auswählen
+  theme-dialog.default: Standard-Design
+  theme-dialog.close: Schließen

+ 4 - 1
lang/en.yaml

@@ -47,4 +47,7 @@ translations:
   language-dialog.title: Select Language
   language-dialog.browser-languages: Browser languages
   language-dialog.not-available: Not available
-  language-dialog.close: Close
+  language-dialog.close: Close
+  theme-dialog.title: Select Theme
+  theme-dialog.default: Default Theme
+  theme-dialog.close: Close

+ 4 - 1
lang/es-ES.yaml

@@ -47,4 +47,7 @@ translations:
   language-dialog.title: Seleccionar idioma
   language-dialog.browser-languages: Idiomas del navegador
   language-dialog.not-available: No disponible
-  language-dialog.close: Cerrar
+  language-dialog.close: Cerrar
+  theme-dialog.title: Seleccionar tema
+  theme-dialog.default: Tema Predeterminado
+  theme-dialog.close: Cerrar

+ 4 - 1
lang/it-IT.yaml

@@ -47,4 +47,7 @@ translations:
   language-dialog.title: Seleziona lingua
   language-dialog.browser-languages: Lingue del browser
   language-dialog.not-available: Non disponibile
-  language-dialog.close: Chiudi
+  language-dialog.close: Chiudi
+  theme-dialog.title: Seleziona tema
+  theme-dialog.default: Tema Predefinito
+  theme-dialog.close: Chiudi

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

@@ -22,6 +22,9 @@ translations:
   language-dialog.browser-languages: 浏览器语言
   language-dialog.not-available: 不可用
   language-dialog.close: 关闭
+  theme-dialog.title: 选择主题
+  theme-dialog.default: 默认主题
+  theme-dialog.close: 关闭
   logs.timed-out: 超时
   logs.blocked: 阻塞
   logs.exit-code: 退出代码

+ 1 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -332,6 +332,7 @@ message InitResponse {
 	bool show_diagnostics = 21;
 	bool show_log_list = 22;
 	bool login_required = 23;
+	repeated string available_themes = 24; // List of available theme names
 }
 
 message AdditionalLink {

+ 11 - 2
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -3236,6 +3236,7 @@ type InitResponse struct {
 	ShowDiagnostics           bool                   `protobuf:"varint,21,opt,name=show_diagnostics,json=showDiagnostics,proto3" json:"show_diagnostics,omitempty"`
 	ShowLogList               bool                   `protobuf:"varint,22,opt,name=show_log_list,json=showLogList,proto3" json:"show_log_list,omitempty"`
 	LoginRequired             bool                   `protobuf:"varint,23,opt,name=login_required,json=loginRequired,proto3" json:"login_required,omitempty"`
+	AvailableThemes           []string               `protobuf:"bytes,24,rep,name=available_themes,json=availableThemes,proto3" json:"available_themes,omitempty"` // List of available theme names
 	unknownFields             protoimpl.UnknownFields
 	sizeCache                 protoimpl.SizeCache
 }
@@ -3431,6 +3432,13 @@ func (x *InitResponse) GetLoginRequired() bool {
 	return false
 }
 
+func (x *InitResponse) GetAvailableThemes() []string {
+	if x != nil {
+		return x.AvailableThemes
+	}
+	return nil
+}
+
 type AdditionalLink struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Title         string                 `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
@@ -4088,7 +4096,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\x16GetDiagnosticsResponse\x12 \n" +
 	"\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" +
 	"\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\"\r\n" +
-	"\vInitRequest\"\xa2\b\n" +
+	"\vInitRequest\"\xcd\b\n" +
 	"\fInitResponse\x12\x1e\n" +
 	"\n" +
 	"showFooter\x18\x01 \x01(\bR\n" +
@@ -4116,7 +4124,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"banner_css\x18\x14 \x01(\tR\tbannerCss\x12)\n" +
 	"\x10show_diagnostics\x18\x15 \x01(\bR\x0fshowDiagnostics\x12\"\n" +
 	"\rshow_log_list\x18\x16 \x01(\bR\vshowLogList\x12%\n" +
-	"\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\"8\n" +
+	"\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\x12)\n" +
+	"\x10available_themes\x18\x18 \x03(\tR\x0favailableThemes\"8\n" +
 	"\x0eAdditionalLink\x12\x14\n" +
 	"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
 	"\x03url\x18\x02 \x01(\tR\x03url\"L\n" +

+ 35 - 0
service/internal/api/api.go

@@ -3,6 +3,8 @@ package api
 import (
 	ctx "context"
 	"encoding/json"
+	"os"
+	"path"
 	"sort"
 
 	"connectrpc.com/connect"
@@ -902,11 +904,44 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
 		ShowDiagnostics:           user.EffectivePolicy.ShowDiagnostics,
 		ShowLogList:               user.EffectivePolicy.ShowLogList,
 		LoginRequired:             loginRequired,
+		AvailableThemes:           discoverAvailableThemes(api.cfg),
 	}
 
 	return connect.NewResponse(res), nil
 }
 
+// discoverAvailableThemes finds all available themes in the custom-webui/themes directory.
+// A theme is considered available if it has a theme.css file.
+func discoverAvailableThemes(cfg *config.Config) []string {
+	themesDir := path.Join(cfg.GetDir(), "custom-webui", "themes")
+	
+	entries, err := os.ReadDir(themesDir)
+	if err != nil {
+		log.WithFields(log.Fields{
+			"themesDir": themesDir,
+			"error":     err,
+		}).Tracef("Could not read themes directory")
+		return []string{}
+	}
+
+	var themes []string
+	for _, entry := range entries {
+		if !entry.IsDir() {
+			continue
+		}
+
+		themeName := entry.Name()
+		themeCssPath := path.Join(themesDir, themeName, "theme.css")
+		
+		if _, err := os.Stat(themeCssPath); err == nil {
+			themes = append(themes, themeName)
+		}
+	}
+
+	sort.Strings(themes)
+	return themes
+}
+
 func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
 	var rootDashboards []string
 	dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio