diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json
index 0b233bbdbb..754702d4fe 100644
--- a/web/src/assets/locales/en.json
+++ b/web/src/assets/locales/en.json
@@ -12,8 +12,6 @@
"password": "Password",
"url": "URL",
"back": "Back",
- "color_scheme_light": "Switch to dark mode",
- "color_scheme_dark": "Switch to light mode",
"unknown_error": "An unknown error occurred",
"documentation_for": "Documentation for \"{topic}\"",
"pipeline_feed": "Pipeline feed",
@@ -453,7 +451,13 @@
"settings": "User Settings",
"general": {
"general": "General",
- "language": "Language"
+ "language": "Language",
+ "theme": {
+ "theme": "Theme",
+ "light": "Light",
+ "dark": "Dark",
+ "auto": "Auto"
+ }
},
"secrets": {
"secrets": "Secrets",
diff --git a/web/src/components/layout/Panel.vue b/web/src/components/layout/Panel.vue
index c4960079f2..6a8431660f 100644
--- a/web/src/components/layout/Panel.vue
+++ b/web/src/components/layout/Panel.vue
@@ -39,13 +39,14 @@ import Icon from '~/components/atomic/Icon.vue';
const props = defineProps<{
title?: string;
collapsable?: boolean;
+ collapsedByDefault?: boolean;
}>();
/**
* _collapsed is used to store the internal state of the panel, but is
* ignored if the panel is not collapsable.
*/
-const _collapsed = ref(false);
+const _collapsed = ref(props.collapsedByDefault || false);
const collapsed = computed(() => props.collapsable && _collapsed.value);
diff --git a/web/src/components/layout/header/Navbar.vue b/web/src/components/layout/header/Navbar.vue
index 0f36f6f8bf..f664571845 100644
--- a/web/src/components/layout/header/Navbar.vue
+++ b/web/src/components/layout/header/Navbar.vue
@@ -26,13 +26,6 @@
-
-
+
+
+
@@ -17,8 +27,10 @@ import { useI18n } from 'vue-i18n';
import SelectField from '~/components/form/SelectField.vue';
import Settings from '~/components/layout/Settings.vue';
import { setI18nLanguage } from '~/compositions/useI18n';
+import { useTheme } from '~/compositions/useTheme';
const { locale } = useI18n();
+const { storeTheme } = useTheme();
const localeOptions = computed(() =>
SUPPORTED_LOCALES.map((supportedLocale) => ({
diff --git a/web/src/compositions/useDarkMode.ts b/web/src/compositions/useDarkMode.ts
deleted file mode 100644
index c28c4aefb1..0000000000
--- a/web/src/compositions/useDarkMode.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { computed, ref, watch } from 'vue';
-
-const LS_DARK_MODE = 'woodpecker:dark-mode';
-const isDarkModeActive = ref(false);
-
-watch(isDarkModeActive, (isActive) => {
- if (isActive) {
- document.documentElement.classList.remove('light');
- document.documentElement.classList.add('dark');
- document.documentElement.setAttribute('data-theme', 'dark');
- document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#2A2E3A'); // internal-wp-secondary-600 (see windi.config.ts)
- } else {
- document.documentElement.classList.remove('dark');
- document.documentElement.classList.add('light');
- document.documentElement.setAttribute('data-theme', 'light');
- document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#369943'); // internal-wp-primary-400
- }
-});
-
-function setDarkMode(isActive: boolean) {
- isDarkModeActive.value = isActive;
- localStorage.setItem(LS_DARK_MODE, isActive ? 'dark' : 'light');
-}
-
-function load() {
- const isActive = localStorage.getItem(LS_DARK_MODE) as 'dark' | 'light' | null;
- if (isActive === null) {
- setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches);
- } else {
- setDarkMode(isActive === 'dark');
- }
-}
-
-load();
-
-export function useDarkMode() {
- return {
- darkMode: computed({
- get() {
- return isDarkModeActive.value;
- },
- set(isActive: boolean) {
- setDarkMode(isActive);
- },
- }),
- };
-}
diff --git a/web/src/compositions/useFavicon.ts b/web/src/compositions/useFavicon.ts
index f3fbcc3deb..4fa29c676c 100644
--- a/web/src/compositions/useFavicon.ts
+++ b/web/src/compositions/useFavicon.ts
@@ -1,10 +1,11 @@
import { computed, ref, watch } from 'vue';
import useConfig from '~/compositions/useConfig';
-import { useDarkMode } from '~/compositions/useDarkMode';
+import { useTheme } from '~/compositions/useTheme';
import { PipelineStatus } from '~/lib/api/types';
-const darkMode = computed(() => (useDarkMode().darkMode.value ? 'dark' : 'light'));
+const { theme } = useTheme();
+const darkMode = computed(() => theme.value);
type Status = 'default' | 'success' | 'pending' | 'error';
const faviconStatus = ref('default');
diff --git a/web/src/compositions/useTheme.ts b/web/src/compositions/useTheme.ts
new file mode 100644
index 0000000000..159fe8ed9a
--- /dev/null
+++ b/web/src/compositions/useTheme.ts
@@ -0,0 +1,31 @@
+import { useColorMode } from '@vueuse/core';
+import { watch } from 'vue';
+
+const { store: storeTheme, state: resolvedTheme } = useColorMode({
+ storageKey: 'woodpecker:theme',
+});
+
+function updateTheme() {
+ if (resolvedTheme.value === 'dark') {
+ document.documentElement.classList.remove('light');
+ document.documentElement.classList.add('dark');
+ document.documentElement.setAttribute('data-theme', 'dark');
+ document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#2A2E3A'); // internal-wp-secondary-600 (see windi.config.ts)
+ } else {
+ document.documentElement.classList.remove('dark');
+ document.documentElement.classList.add('light');
+ document.documentElement.setAttribute('data-theme', 'light');
+ document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#369943'); // internal-wp-primary-400
+ }
+}
+
+watch(storeTheme, updateTheme);
+
+updateTheme();
+
+export function useTheme() {
+ return {
+ theme: resolvedTheme,
+ storeTheme,
+ };
+}
diff --git a/web/src/views/repo/pipeline/PipelineConfig.vue b/web/src/views/repo/pipeline/PipelineConfig.vue
index f63a551543..6c9b398276 100644
--- a/web/src/views/repo/pipeline/PipelineConfig.vue
+++ b/web/src/views/repo/pipeline/PipelineConfig.vue
@@ -4,6 +4,7 @@
v-for="pipelineConfig in pipelineConfigs || []"
:key="pipelineConfig.hash"
collapsable
+ collapsed-by-default
:title="pipelineConfig.name"
>