Page MenuHomeDevCentral

D3989.id10380.diff
No OneTemporary

D3989.id10380.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 {
@@ -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,105 @@
+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(),
+ })),
+})
+
+vi.mock('@/plugins/api', () => ({
+ configApi: {
+ getAll: vi.fn(() => Promise.resolve({ theme: { mode: 'system', toggleable: true } })),
+ },
+}))
+
+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()
+
+ await new Promise((r) => setTimeout(r, 10))
+
+ 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()
+
+ await new Promise((r) => setTimeout(r, 10))
+
+ 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()
+
+ await new Promise((r) => setTimeout(r, 10))
+
+ 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()
+
+ await new Promise((r) => setTimeout(r, 10))
+
+ 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()
+
+ await new Promise((r) => setTimeout(r, 10))
+
+ 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,57 @@
+import { ref, computed } from 'vue'
+import { configApi } from '@/plugins/api'
+
+const STORAGE_KEY = 'servpulse_dark_mode'
+const theme = ref('light')
+const isToggleable = ref(true)
+
+function applyTheme(value) {
+ document.documentElement.setAttribute('data-theme', value)
+}
+
+async function initTheme() {
+ try {
+ const config = await configApi.getAll()
+ if (config.theme) {
+ isToggleable.value = config.theme.toggleable !== false
+
+ if (config.theme.mode === 'dark') {
+ theme.value = 'dark'
+ } else if (config.theme.mode === 'light') {
+ theme.value = 'light'
+ } else {
+ theme.value = window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light'
+ }
+ }
+ } catch {
+ theme.value = window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light'
+ }
+
+ const stored = localStorage.getItem(STORAGE_KEY)
+ if (stored !== null && isToggleable.value) {
+ theme.value = stored
+ }
+
+ 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)
+}
+
+initTheme()
+
+export function useDarkMode() {
+ const isDark = computed(() => theme.value === 'dark')
+
+ return { isDark, theme, isToggleable, toggleDarkMode }
+}
diff --git a/frontend/src/views/AdminDashboard.vue b/frontend/src/views/AdminDashboard.vue
--- a/frontend/src/views/AdminDashboard.vue
+++ b/frontend/src/views/AdminDashboard.vue
@@ -148,6 +148,7 @@
// Settings
const settingsForm = ref({
navbar: { title: 'ServPulse', buttons_left: [] },
+ theme: { mode: 'system', toggleable: true },
footer: {
line1: { text: '', link1_label: '', link1_url: '', link2_label: '', link2_url: '' },
line2: { text: '', link_label: '', link_url: '' },
@@ -167,7 +168,9 @@
if (!data.footer) data.footer = { ...defaultFooter }
if (!data.footer.line1) data.footer.line1 = { ...defaultFooter.line1 }
if (!data.footer.line2) data.footer.line2 = { ...defaultFooter.line2 }
+ if (!data.theme) data.theme = { mode: 'system', toggleable: true }
settingsForm.value = data
+
} catch (err) {
alert('Error loading settings: ' + err.message)
}
@@ -455,8 +458,8 @@
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Settings</h2>
</div>
-
<form @submit.prevent="saveSettings" class="space-y-6">
+
<!-- Site Title -->
<div class="card p-5">
<h3 class="text-sm font-semibold mb-3">Site Title</h3>
@@ -466,6 +469,28 @@
</div>
</div>
+ <!-- Theme -->
+ <div class="card p-5">
+ <h3 class="text-sm font-semibold mb-3">Theme</h3>
+ <div class="space-y-3">
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Default Theme</label>
+ <select v-model="settingsForm.theme.mode" class="input-field max-w-sm">
+ <option value="system">System preference</option>
+ <option value="light">Light</option>
+ <option value="dark">Dark</option>
+ </select>
+ </div>
+ <label class="flex items-center gap-2">
+ <input v-model="settingsForm.theme.toggleable" type="checkbox" class="rounded border-gray-300 dark:border-gray-600" />
+ <span class="text-xs font-medium text-gray-600 dark:text-gray-400">Allow users to toggle theme</span>
+ </label>
+ <p v-if="!settingsForm.theme.toggleable" class="text-xs text-gray-400">
+ The theme toggle button will be hidden from the navbar.
+ </p>
+ </div>
+ </div>
+
<!-- Navigation Buttons -->
<div class="card p-5">
<div class="flex justify-between items-center mb-3">
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -4,7 +4,7 @@
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
- darkMode: 'class',
+ darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
colors: {

File Metadata

Mime Type
text/plain
Expires
Fri, Mar 6, 01:26 (11 h, 48 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3500008
Default Alt Text
D3989.id10380.diff (9 KB)

Event Timeline