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

This commit is contained in:
jamesread 2026-01-11 21:32:55 +00:00
parent dceb509ab9
commit 87913a6ff3
18 changed files with 299 additions and 111 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -46,6 +46,10 @@
<a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a> <a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a>
</span> </span>
<span v-if="availableThemes.length > 1">
<a href="#" @click.prevent="openThemeDialog">{{ currentThemeName }}</a>
</span>
<span>{{ t('connected') }}</span> <span>{{ t('connected') }}</span>
</p> </p>
<p> <p>
@ -55,7 +59,7 @@
</div> </div>
</div> </div>
<dialog ref="languageDialog" class="language-dialog" @click="handleDialogClick"> <dialog ref="languageDialog" class="language-dialog" @click="handleLanguageDialogClick">
<div class="dialog-content" @click.stop> <div class="dialog-content" @click.stop>
<h2>{{ t('language-dialog.title') }}</h2> <h2>{{ t('language-dialog.title') }}</h2>
<select v-model="selectedLanguage" @change="changeLanguage" class="language-select"> <select v-model="selectedLanguage" @change="changeLanguage" class="language-select">
@ -73,6 +77,21 @@
</div> </div>
</div> </div>
</dialog> </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> </template>
<script setup> <script setup>
@ -117,6 +136,12 @@ const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.g
const languagePreference = ref(initialLanguagePreference || 'auto') const languagePreference = ref(initialLanguagePreference || 'auto')
const selectedLanguage = ref(languagePreference.value) 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 // Available languages with display names
const availableLanguages = { const availableLanguages = {
'auto': 'Browser Language', 'auto': 'Browser Language',
@ -136,6 +161,14 @@ const currentLanguageName = computed(() => {
return availableLanguages[languagePreference.value] || languagePreference.value 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 // Computed properties for navigation style
const topbarEnabled = computed(() => { const topbarEnabled = computed(() => {
return sectionNavigationStyle.value === 'topbar' return sectionNavigationStyle.value === 'topbar'
@ -191,12 +224,14 @@ function updateHeaderFromInit() {
showLogs.value = window.initResponse.showLogList showLogs.value = window.initResponse.showLogList
showDiagnostics.value = window.initResponse.showDiagnostics showDiagnostics.value = window.initResponse.showDiagnostics
sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar' sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar'
availableThemes.value = window.initResponse.availableThemes || []
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) { if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
showLoginLink.value = false showLoginLink.value = false
} }
renderNavigation() renderNavigation()
applyTheme()
if (window.initResponse.loginRequired) { if (window.initResponse.loginRequired) {
router.push('/login') router.push('/login')
@ -280,13 +315,63 @@ function changeLanguage() {
closeLanguageDialog() closeLanguageDialog()
} }
function handleDialogClick(event) { function handleLanguageDialogClick(event) {
// Close dialog when clicking on the backdrop // Close dialog when clicking on the backdrop
if (event.target === languageDialog.value) { if (event.target === languageDialog.value) {
closeLanguageDialog() 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 window.updateHeaderFromInit = updateHeaderFromInit
onMounted(() => { onMounted(() => {
@ -295,6 +380,9 @@ onMounted(() => {
// Initialize selected language from stored preference // Initialize selected language from stored preference
selectedLanguage.value = languagePreference.value selectedLanguage.value = languagePreference.value
// Initialize selected theme from stored preference
selectedTheme.value = themePreference.value || ''
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) { if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
browserLanguages.value = navigator.languages browserLanguages.value = navigator.languages
@ -316,7 +404,8 @@ onMounted(() => {
text-decoration: underline; text-decoration: underline;
} }
.language-dialog { .language-dialog,
.theme-dialog {
border: 1px solid var(--border-color, #ccc); border: 1px solid var(--border-color, #ccc);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0; padding: 0;
@ -324,7 +413,8 @@ onMounted(() => {
width: 90%; width: 90%;
} }
.language-dialog::backdrop { .language-dialog::backdrop,
.theme-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
} }

View File

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

View File

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

View File

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

View File

@ -47,4 +47,7 @@ translations:
language-dialog.title: Sprache auswählen language-dialog.title: Sprache auswählen
language-dialog.browser-languages: Browser-Sprachen language-dialog.browser-languages: Browser-Sprachen
language-dialog.not-available: Nicht verfügbar 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

View File

@ -47,4 +47,7 @@ translations:
language-dialog.title: Select Language language-dialog.title: Select Language
language-dialog.browser-languages: Browser languages language-dialog.browser-languages: Browser languages
language-dialog.not-available: Not available 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

View File

@ -47,4 +47,7 @@ translations:
language-dialog.title: Seleccionar idioma language-dialog.title: Seleccionar idioma
language-dialog.browser-languages: Idiomas del navegador language-dialog.browser-languages: Idiomas del navegador
language-dialog.not-available: No disponible 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

View File

@ -47,4 +47,7 @@ translations:
language-dialog.title: Seleziona lingua language-dialog.title: Seleziona lingua
language-dialog.browser-languages: Lingue del browser language-dialog.browser-languages: Lingue del browser
language-dialog.not-available: Non disponibile 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

View File

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

View File

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

View File

@ -3236,6 +3236,7 @@ type InitResponse struct {
ShowDiagnostics bool `protobuf:"varint,21,opt,name=show_diagnostics,json=showDiagnostics,proto3" json:"show_diagnostics,omitempty"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -3431,6 +3432,13 @@ func (x *InitResponse) GetLoginRequired() bool {
return false return false
} }
func (x *InitResponse) GetAvailableThemes() []string {
if x != nil {
return x.AvailableThemes
}
return nil
}
type AdditionalLink struct { type AdditionalLink struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` 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" + "\x16GetDiagnosticsResponse\x12 \n" +
"\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" + "\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" +
"\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\"\r\n" + "\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\"\r\n" +
"\vInitRequest\"\xa2\b\n" + "\vInitRequest\"\xcd\b\n" +
"\fInitResponse\x12\x1e\n" + "\fInitResponse\x12\x1e\n" +
"\n" + "\n" +
"showFooter\x18\x01 \x01(\bR\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" + "banner_css\x18\x14 \x01(\tR\tbannerCss\x12)\n" +
"\x10show_diagnostics\x18\x15 \x01(\bR\x0fshowDiagnostics\x12\"\n" + "\x10show_diagnostics\x18\x15 \x01(\bR\x0fshowDiagnostics\x12\"\n" +
"\rshow_log_list\x18\x16 \x01(\bR\vshowLogList\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" + "\x0eAdditionalLink\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
"\x03url\x18\x02 \x01(\tR\x03url\"L\n" + "\x03url\x18\x02 \x01(\tR\x03url\"L\n" +

View File

@ -3,6 +3,8 @@ package api
import ( import (
ctx "context" ctx "context"
"encoding/json" "encoding/json"
"os"
"path"
"sort" "sort"
"connectrpc.com/connect" "connectrpc.com/connect"
@ -902,11 +904,44 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
ShowDiagnostics: user.EffectivePolicy.ShowDiagnostics, ShowDiagnostics: user.EffectivePolicy.ShowDiagnostics,
ShowLogList: user.EffectivePolicy.ShowLogList, ShowLogList: user.EffectivePolicy.ShowLogList,
LoginRequired: loginRequired, LoginRequired: loginRequired,
AvailableThemes: discoverAvailableThemes(api.cfg),
} }
return connect.NewResponse(res), nil 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 { func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
var rootDashboards []string var rootDashboards []string
dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "") dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")