Page MenuHomeDevCentral

D3989.id10355.diff
No OneTemporary

D3989.id10355.diff

diff --git a/frontend/src/components/AppNavbar.vue b/frontend/src/components/AppNavbar.vue
--- a/frontend/src/components/AppNavbar.vue
+++ b/frontend/src/components/AppNavbar.vue
@@ -3,9 +3,11 @@
import { RouterLink } from 'vue-router'
import { configApi } from '@/plugins/api'
import { useAuth } from '@/composables/useAuth'
+import { useDarkMode } from '@/composables/useDarkMode'
const config = ref(null)
const { isAuthenticated, logout } = useAuth()
+const { isDark, isToggleable, toggleDarkMode } = useDarkMode()
onMounted(async () => {
try {
@@ -26,7 +28,7 @@
<div class="max-w-5xl mx-auto px-4 sm:px-6">
<div class="flex items-center justify-between h-14">
<div class="flex items-center gap-4">
- <a
+
v-for="btn in config?.navbar?.buttons_left"
:key="btn.name"
:href="btn.link"
@@ -41,6 +43,20 @@
</RouterLink>
<div class="flex items-center gap-4">
+ <!-- Icons: Heroicons (https://heroicons.com/) -->
+ <button
+ v-if="isToggleable"
+ @click="toggleDarkMode"
+ class="text-gray-400 hover:text-white transition-colors"
+ :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
+ >
+ <svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
+ <path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" />
+ </svg>
+ <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
+ <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
+ </svg>
+ </button>
<RouterLink
v-if="isAuthenticated"
to="/admin"
@@ -62,7 +78,6 @@
>
Admin
</RouterLink>
-
</div>
</div>
</div>
diff --git a/frontend/src/components/__tests__/useDarkMode.test.js b/frontend/src/components/__tests__/useDarkMode.test.js
new file mode 100644
--- /dev/null
+++ b/frontend/src/components/__tests__/useDarkMode.test.js
@@ -0,0 +1,89 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const localStorageMock = (() => {
+ let store = {}
+ return {
+ getItem: vi.fn((key) => store[key] || null),
+ setItem: vi.fn((key, value) => { store[key] = String(value) }),
+ removeItem: vi.fn((key) => { delete store[key] }),
+ clear: () => { store = {} },
+ }
+})()
+
+Object.defineProperty(window, 'localStorage', { value: localStorageMock })
+
+Object.defineProperty(window, 'matchMedia', {
+ value: vi.fn((query) => ({
+ matches: false,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })),
+})
+
+describe('useDarkMode', () => {
+ beforeEach(() => {
+ localStorageMock.clear()
+ vi.clearAllMocks()
+ document.documentElement.removeAttribute('data-theme')
+ vi.resetModules()
+ })
+
+ it('defaults to system preference when no stored value', async () => {
+ window.matchMedia.mockReturnValue({ matches: true })
+ const { useDarkMode } = await import('@/composables/useDarkMode')
+ const { isDark, toggleDarkMode, isToggleable } = useDarkMode()
+
+ expect(isDark.value).toBe(true)
+ expect(isToggleable.value).toBe(true)
+ expect(typeof toggleDarkMode).toBe('function')
+ })
+
+ it('toggleDarkMode switches the value', async () => {
+ const { useDarkMode } = await import('@/composables/useDarkMode')
+ const { isDark, toggleDarkMode } = useDarkMode()
+
+ const initial = isDark.value
+ toggleDarkMode()
+
+ expect(isDark.value).toBe(!initial)
+ })
+
+ it('toggleDarkMode persists to localStorage', async () => {
+ const { useDarkMode } = await import('@/composables/useDarkMode')
+ const { toggleDarkMode } = useDarkMode()
+
+ toggleDarkMode()
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'servpulse_dark_mode',
+ expect.any(String)
+ )
+ })
+
+ it('toggleDarkMode sets data-theme to dark', async () => {
+ const { useDarkMode } = await import('@/composables/useDarkMode')
+ const { isDark, toggleDarkMode } = useDarkMode()
+
+ if (isDark.value) {
+ toggleDarkMode()
+ }
+
+ toggleDarkMode()
+
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
+ })
+
+ it('toggleDarkMode sets data-theme to light', async () => {
+ const { useDarkMode } = await import('@/composables/useDarkMode')
+ const { isDark, toggleDarkMode } = useDarkMode()
+
+ if (!isDark.value) {
+ toggleDarkMode()
+ }
+
+ toggleDarkMode()
+
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light')
+ })
+})
diff --git a/frontend/src/composables/useDarkMode.js b/frontend/src/composables/useDarkMode.js
new file mode 100644
--- /dev/null
+++ b/frontend/src/composables/useDarkMode.js
@@ -0,0 +1,40 @@
+import { ref, computed } from 'vue'
+
+const STORAGE_KEY = 'servpulse_dark_mode'
+
+function getInitialTheme() {
+ const stored = localStorage.getItem(STORAGE_KEY)
+ if (stored !== null) {
+ return stored
+ }
+
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ return 'dark'
+ }
+
+ return 'light'
+}
+
+const theme = ref(getInitialTheme())
+const isToggleable = ref(true)
+
+function applyTheme(value) {
+ document.documentElement.setAttribute('data-theme', value)
+}
+
+applyTheme(theme.value)
+
+function toggleDarkMode() {
+ if (!isToggleable.value) {
+ return
+ }
+ theme.value = theme.value === 'dark' ? 'light' : 'dark'
+ localStorage.setItem(STORAGE_KEY, theme.value)
+ applyTheme(theme.value)
+}
+
+export function useDarkMode() {
+ const isDark = computed(() => theme.value === 'dark')
+
+ return { isDark, theme, isToggleable, toggleDarkMode }
+}

File Metadata

Mime Type
text/plain
Expires
Fri, Mar 6, 01:25 (11 h, 47 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3500249
Default Alt Text
D3989.id10355.diff (6 KB)

Event Timeline