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" >