feat: add theme customization with accent colors and light mode
This commit is contained in:
25
src/App.vue
25
src/App.vue
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, watch } from 'vue'
|
||||||
import TitleBar from './components/TitleBar.vue'
|
import TitleBar from './components/TitleBar.vue'
|
||||||
import NavRail from './components/NavRail.vue'
|
import NavRail from './components/NavRail.vue'
|
||||||
import ToastNotification from './components/ToastNotification.vue'
|
import ToastNotification from './components/ToastNotification.vue'
|
||||||
@@ -7,6 +7,20 @@ import { useSettingsStore } from './stores/settings'
|
|||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
const el = document.documentElement
|
||||||
|
const mode = settingsStore.settings.theme_mode || 'dark'
|
||||||
|
const accent = settingsStore.settings.accent_color || 'amber'
|
||||||
|
|
||||||
|
if (mode === 'system') {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
el.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
|
||||||
|
} else {
|
||||||
|
el.setAttribute('data-theme', mode)
|
||||||
|
}
|
||||||
|
el.setAttribute('data-accent', accent)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await settingsStore.fetchSettings()
|
await settingsStore.fetchSettings()
|
||||||
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
|
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
|
||||||
@@ -14,6 +28,15 @@ onMounted(async () => {
|
|||||||
if (app) {
|
if (app) {
|
||||||
(app.style as any).zoom = `${zoom}%`
|
(app.style as any).zoom = `${zoom}%`
|
||||||
}
|
}
|
||||||
|
applyTheme()
|
||||||
|
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
if (settingsStore.settings.theme_mode === 'system') applyTheme()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => [settingsStore.settings.theme_mode, settingsStore.settings.accent_color], () => {
|
||||||
|
applyTheme()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,63 @@
|
|||||||
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Accent color overrides */
|
||||||
|
[data-accent="amber"] {
|
||||||
|
--color-accent: #D97706;
|
||||||
|
--color-accent-hover: #B45309;
|
||||||
|
--color-accent-muted: rgba(217, 119, 6, 0.12);
|
||||||
|
--color-accent-text: #FBBF24;
|
||||||
|
}
|
||||||
|
[data-accent="blue"] {
|
||||||
|
--color-accent: #3B82F6;
|
||||||
|
--color-accent-hover: #2563EB;
|
||||||
|
--color-accent-muted: rgba(59, 130, 246, 0.12);
|
||||||
|
--color-accent-text: #60A5FA;
|
||||||
|
}
|
||||||
|
[data-accent="purple"] {
|
||||||
|
--color-accent: #8B5CF6;
|
||||||
|
--color-accent-hover: #7C3AED;
|
||||||
|
--color-accent-muted: rgba(139, 92, 246, 0.12);
|
||||||
|
--color-accent-text: #A78BFA;
|
||||||
|
}
|
||||||
|
[data-accent="green"] {
|
||||||
|
--color-accent: #10B981;
|
||||||
|
--color-accent-hover: #059669;
|
||||||
|
--color-accent-muted: rgba(16, 185, 129, 0.12);
|
||||||
|
--color-accent-text: #34D399;
|
||||||
|
}
|
||||||
|
[data-accent="red"] {
|
||||||
|
--color-accent: #EF4444;
|
||||||
|
--color-accent-hover: #DC2626;
|
||||||
|
--color-accent-muted: rgba(239, 68, 68, 0.12);
|
||||||
|
--color-accent-text: #F87171;
|
||||||
|
}
|
||||||
|
[data-accent="pink"] {
|
||||||
|
--color-accent: #EC4899;
|
||||||
|
--color-accent-hover: #DB2777;
|
||||||
|
--color-accent-muted: rgba(236, 72, 153, 0.12);
|
||||||
|
--color-accent-text: #F472B6;
|
||||||
|
}
|
||||||
|
[data-accent="cyan"] {
|
||||||
|
--color-accent: #06B6D4;
|
||||||
|
--color-accent-hover: #0891B2;
|
||||||
|
--color-accent-muted: rgba(6, 182, 212, 0.12);
|
||||||
|
--color-accent-text: #22D3EE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--color-bg-base: #FAFAF9;
|
||||||
|
--color-bg-surface: #FFFFFF;
|
||||||
|
--color-bg-elevated: #F5F5F4;
|
||||||
|
--color-bg-inset: #E7E5E4;
|
||||||
|
--color-text-primary: #1C1917;
|
||||||
|
--color-text-secondary: #57534E;
|
||||||
|
--color-text-tertiary: #A8A29E;
|
||||||
|
--color-border-subtle: #E7E5E4;
|
||||||
|
--color-border-visible: #D6D3D1;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -173,3 +230,18 @@
|
|||||||
.animate-pulse-colon {
|
.animate-pulse-colon {
|
||||||
animation: pulse-colon 1s ease-in-out infinite;
|
animation: pulse-colon 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown inline styles */
|
||||||
|
.markdown-inline strong { font-weight: 600; }
|
||||||
|
.markdown-inline em { font-style: italic; }
|
||||||
|
.markdown-inline code {
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.markdown-inline a {
|
||||||
|
color: var(--color-accent-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,6 +91,44 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-border-subtle mt-5 pt-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.8125rem] text-text-primary">Theme</p>
|
||||||
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Light or dark appearance</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-40">
|
||||||
|
<AppSelect
|
||||||
|
v-model="themeMode"
|
||||||
|
:options="themeModes"
|
||||||
|
label-key="label"
|
||||||
|
value-key="value"
|
||||||
|
placeholder="Dark"
|
||||||
|
:placeholder-value="'dark'"
|
||||||
|
@update:model-value="saveThemeSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-5">
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.8125rem] text-text-primary">Accent Color</p>
|
||||||
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Primary interface color</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="ac in accentColors"
|
||||||
|
:key="ac.value"
|
||||||
|
@click="accentColor = ac.value; saveThemeSettings()"
|
||||||
|
class="w-6 h-6 rounded-full border-2 transition-all hover:scale-110 cursor-pointer"
|
||||||
|
:class="accentColor === ac.value ? 'border-text-primary scale-110' : 'border-transparent'"
|
||||||
|
:style="{ backgroundColor: ac.color }"
|
||||||
|
:title="ac.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timer -->
|
<!-- Timer -->
|
||||||
@@ -120,22 +158,76 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reminder Interval — progressive disclosure -->
|
<!-- Idle sub-settings — progressive disclosure -->
|
||||||
<div
|
<div v-if="idleDetection" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1">
|
||||||
v-if="idleDetection"
|
<div class="flex items-center justify-between">
|
||||||
class="flex items-center justify-between pl-4 border-l-2 border-border-subtle ml-1"
|
<div>
|
||||||
>
|
<p class="text-[0.8125rem] text-text-primary">Idle Timeout</p>
|
||||||
<div>
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes before idle pause triggers</p>
|
||||||
<p class="text-[0.8125rem] text-text-primary">Reminder Interval</p>
|
</div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes between reminders while running</p>
|
<AppNumberInput
|
||||||
|
v-model="idleTimeout"
|
||||||
|
:min="1"
|
||||||
|
:max="60"
|
||||||
|
:step="1"
|
||||||
|
:precision="0"
|
||||||
|
suffix="min"
|
||||||
|
@update:model-value="saveSettings"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div class="flex items-center justify-between">
|
||||||
v-model.number="reminderInterval"
|
<div>
|
||||||
type="number"
|
<p class="text-[0.8125rem] text-text-primary">Reminder Interval</p>
|
||||||
min="0"
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes between reminders while running</p>
|
||||||
max="120"
|
</div>
|
||||||
class="w-20 px-3 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary text-right font-mono focus:outline-none focus:border-border-visible"
|
<AppNumberInput
|
||||||
@change="saveSettings"
|
v-model="reminderInterval"
|
||||||
|
:min="0"
|
||||||
|
:max="120"
|
||||||
|
:step="5"
|
||||||
|
:precision="0"
|
||||||
|
suffix="min"
|
||||||
|
@update:model-value="saveSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="border-t border-border-subtle" />
|
||||||
|
|
||||||
|
<!-- App Tracking Mode -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.8125rem] text-text-primary">App Tracking Mode</p>
|
||||||
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">What happens when a tracked app leaves focus</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-48">
|
||||||
|
<AppSelect
|
||||||
|
v-model="appTrackingMode"
|
||||||
|
:options="appTrackingModes"
|
||||||
|
label-key="label"
|
||||||
|
value-key="value"
|
||||||
|
placeholder="Auto-pause"
|
||||||
|
:placeholder-value="'auto'"
|
||||||
|
@update:model-value="saveSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Check Interval -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.8125rem] text-text-primary">Check Interval</p>
|
||||||
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">How often to check idle & app visibility</p>
|
||||||
|
</div>
|
||||||
|
<AppNumberInput
|
||||||
|
v-model="appCheckInterval"
|
||||||
|
:min="1"
|
||||||
|
:max="60"
|
||||||
|
:step="1"
|
||||||
|
:precision="0"
|
||||||
|
suffix="sec"
|
||||||
|
@update:model-value="saveSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,11 +351,38 @@ const activeTab = ref('general')
|
|||||||
// Settings state
|
// Settings state
|
||||||
const hourlyRate = ref(0)
|
const hourlyRate = ref(0)
|
||||||
const idleDetection = ref(true)
|
const idleDetection = ref(true)
|
||||||
|
const idleTimeout = ref(5)
|
||||||
const reminderInterval = ref(15)
|
const reminderInterval = ref(15)
|
||||||
|
const appTrackingMode = ref('auto')
|
||||||
|
const appCheckInterval = ref(5)
|
||||||
const zoomLevel = ref(100)
|
const zoomLevel = ref(100)
|
||||||
const locale = ref('system')
|
const locale = ref('system')
|
||||||
const currency = ref('USD')
|
const currency = ref('USD')
|
||||||
const currencyOptions = getCurrencies()
|
const currencyOptions = getCurrencies()
|
||||||
|
const themeMode = ref('dark')
|
||||||
|
const accentColor = ref('amber')
|
||||||
|
|
||||||
|
const themeModes = [
|
||||||
|
{ value: 'dark', label: 'Dark' },
|
||||||
|
{ value: 'light', label: 'Light' },
|
||||||
|
{ value: 'system', label: 'System' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const accentColors = [
|
||||||
|
{ value: 'amber', label: 'Amber', color: '#D97706' },
|
||||||
|
{ value: 'blue', label: 'Blue', color: '#3B82F6' },
|
||||||
|
{ value: 'purple', label: 'Purple', color: '#8B5CF6' },
|
||||||
|
{ value: 'green', label: 'Green', color: '#10B981' },
|
||||||
|
{ value: 'red', label: 'Red', color: '#EF4444' },
|
||||||
|
{ value: 'pink', label: 'Pink', color: '#EC4899' },
|
||||||
|
{ value: 'cyan', label: 'Cyan', color: '#06B6D4' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const appTrackingModes = [
|
||||||
|
{ value: 'auto', label: 'Auto-pause/resume' },
|
||||||
|
{ value: 'notify', label: 'Notify + auto-resume' },
|
||||||
|
{ value: 'prompt', label: 'Prompt user' },
|
||||||
|
]
|
||||||
|
|
||||||
// Dialog state
|
// Dialog state
|
||||||
const showClearDataDialog = ref(false)
|
const showClearDataDialog = ref(false)
|
||||||
@@ -312,7 +431,10 @@ async function saveSettings() {
|
|||||||
try {
|
try {
|
||||||
await settingsStore.updateSetting('hourly_rate', hourlyRate.value.toString())
|
await settingsStore.updateSetting('hourly_rate', hourlyRate.value.toString())
|
||||||
await settingsStore.updateSetting('idle_detection', idleDetection.value.toString())
|
await settingsStore.updateSetting('idle_detection', idleDetection.value.toString())
|
||||||
|
await settingsStore.updateSetting('idle_timeout', idleTimeout.value.toString())
|
||||||
await settingsStore.updateSetting('reminder_interval', reminderInterval.value.toString())
|
await settingsStore.updateSetting('reminder_interval', reminderInterval.value.toString())
|
||||||
|
await settingsStore.updateSetting('app_tracking_mode', appTrackingMode.value)
|
||||||
|
await settingsStore.updateSetting('app_check_interval', appCheckInterval.value.toString())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save settings:', error)
|
console.error('Failed to save settings:', error)
|
||||||
toastStore.error('Failed to save settings')
|
toastStore.error('Failed to save settings')
|
||||||
@@ -325,6 +447,12 @@ async function saveLocaleSettings() {
|
|||||||
await settingsStore.updateSetting('currency', currency.value)
|
await settingsStore.updateSetting('currency', currency.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save theme settings
|
||||||
|
async function saveThemeSettings() {
|
||||||
|
await settingsStore.updateSetting('theme_mode', themeMode.value)
|
||||||
|
await settingsStore.updateSetting('accent_color', accentColor.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Export all data
|
// Export all data
|
||||||
async function exportData() {
|
async function exportData() {
|
||||||
try {
|
try {
|
||||||
@@ -362,9 +490,14 @@ onMounted(async () => {
|
|||||||
|
|
||||||
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
|
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
|
||||||
idleDetection.value = settingsStore.settings.idle_detection !== 'false'
|
idleDetection.value = settingsStore.settings.idle_detection !== 'false'
|
||||||
|
idleTimeout.value = parseInt(settingsStore.settings.idle_timeout) || 5
|
||||||
reminderInterval.value = parseInt(settingsStore.settings.reminder_interval) || 15
|
reminderInterval.value = parseInt(settingsStore.settings.reminder_interval) || 15
|
||||||
|
appTrackingMode.value = settingsStore.settings.app_tracking_mode || 'auto'
|
||||||
|
appCheckInterval.value = parseInt(settingsStore.settings.app_check_interval) || 5
|
||||||
zoomLevel.value = parseInt(settingsStore.settings.ui_zoom) || 100
|
zoomLevel.value = parseInt(settingsStore.settings.ui_zoom) || 100
|
||||||
locale.value = settingsStore.settings.locale || 'system'
|
locale.value = settingsStore.settings.locale || 'system'
|
||||||
currency.value = settingsStore.settings.currency || 'USD'
|
currency.value = settingsStore.settings.currency || 'USD'
|
||||||
|
themeMode.value = settingsStore.settings.theme_mode || 'dark'
|
||||||
|
accentColor.value = settingsStore.settings.accent_color || 'amber'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user