Files
zeroclock/src/views/Settings.vue

504 lines
19 KiB
Vue

<template>
<div class="flex h-full">
<!-- Sidebar -->
<aside class="w-44 bg-bg-surface border-r border-border-subtle shrink-0 p-3">
<h1 class="text-[0.8125rem] font-medium text-text-tertiary uppercase tracking-[0.08em] px-2 mb-3">Settings</h1>
<nav class="flex flex-col gap-0.5">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
class="relative flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[0.8125rem] transition-colors duration-150"
:class="activeTab === tab.id
? 'bg-bg-elevated text-text-primary'
: 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'"
>
<!-- Active indicator -->
<div
v-if="activeTab === tab.id"
class="absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-full bg-accent"
/>
<component :is="tab.icon" class="w-4 h-4 shrink-0" :stroke-width="1.5" />
{{ tab.label }}
</button>
</nav>
</aside>
<!-- Content pane -->
<div class="flex-1 p-6 overflow-y-auto">
<!-- General -->
<div v-if="activeTab === 'general'">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">General</h2>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">UI Scale</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Adjust the interface zoom level</p>
</div>
<div class="flex items-center gap-2">
<button
@click="decreaseZoom"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="zoomLevel <= 80"
>
<Minus class="w-3.5 h-3.5" />
</button>
<span class="w-12 text-center text-sm font-mono text-text-primary">{{ zoomLevel }}%</span>
<button
@click="increaseZoom"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="zoomLevel >= 150"
>
<Plus class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div class="flex items-center justify-between mt-5">
<div>
<p class="text-[0.8125rem] text-text-primary">Locale</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Date and number formatting</p>
</div>
<div class="w-56">
<AppSelect
v-model="locale"
:options="LOCALES"
label-key="name"
value-key="code"
placeholder="System Default"
:placeholder-value="'system'"
:searchable="true"
@update:model-value="saveLocaleSettings"
/>
</div>
</div>
<div class="flex items-center justify-between mt-5">
<div>
<p class="text-[0.8125rem] text-text-primary">Currency</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Currency symbol and formatting</p>
</div>
<div class="w-56">
<AppSelect
v-model="currency"
:options="currencyOptions"
label-key="name"
value-key="code"
placeholder="US Dollar"
:placeholder-value="'USD'"
:searchable="true"
@update:model-value="saveLocaleSettings"
/>
</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>
<!-- Timer -->
<div v-if="activeTab === 'timer'">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Timer</h2>
<div class="space-y-5">
<!-- Idle Detection toggle -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Idle Detection</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Detect when you're idle and prompt to continue or stop</p>
</div>
<button
@click="toggleIdleDetection"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
idleDetection ? 'bg-status-running' : 'bg-bg-elevated'
]"
>
<span
:class="[
'inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
idleDetection ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
<!-- Idle sub-settings — progressive disclosure -->
<div v-if="idleDetection" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Idle Timeout</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes before idle pause triggers</p>
</div>
<AppNumberInput
v-model="idleTimeout"
:min="1"
:max="60"
:step="1"
:precision="0"
suffix="min"
@update:model-value="saveSettings"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Reminder Interval</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes between reminders while running</p>
</div>
<AppNumberInput
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>
<!-- Billing -->
<div v-if="activeTab === 'billing'">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Billing</h2>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Default Hourly Rate</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Applied to new projects</p>
</div>
<AppNumberInput
v-model="hourlyRate"
:min="0"
:step="1"
:precision="2"
:prefix="getCurrencySymbol()"
@update:model-value="saveSettings"
/>
</div>
</div>
<!-- Data -->
<div v-if="activeTab === 'data'">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Data</h2>
<div class="space-y-6">
<!-- Export -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Export All Data</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Download as JSON backup</p>
</div>
<button
@click="exportData"
class="px-4 py-1.5 border border-border-visible text-text-primary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Export
</button>
</div>
<!-- Danger Zone -->
<div class="mt-8 rounded-xl border border-status-error/20 p-5">
<h3 class="text-xs text-status-error uppercase tracking-[0.08em] font-medium mb-4">Danger Zone</h3>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Clear All Data</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Permanently delete all entries, projects, clients, and invoices</p>
</div>
<button
@click="showClearDataDialog = true"
class="px-4 py-1.5 border border-status-error text-status-error text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Clear Data
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Clear Data Confirmation Dialog -->
<div
v-if="showClearDataDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showClearDataDialog = false"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6 animate-modal-enter">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Clear All Data</h2>
<p class="text-[0.75rem] text-text-secondary mb-4">
Are you sure? This action cannot be undone.
</p>
<p class="text-[0.6875rem] text-status-error mb-6">
All time entries, projects, clients, and invoices will be permanently deleted.
</p>
<div class="flex justify-end gap-3">
<button
@click="showClearDataDialog = false"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="clearAllData"
class="px-4 py-2 bg-status-error text-white font-medium rounded-lg hover:bg-status-error/80 transition-colors duration-150"
>
Delete Everything
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, markRaw } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Settings as SettingsIcon, Clock, Receipt, Database, Plus, Minus } from 'lucide-vue-next'
import { useSettingsStore } from '../stores/settings'
import { useToastStore } from '../stores/toast'
import AppNumberInput from '../components/AppNumberInput.vue'
import AppSelect from '../components/AppSelect.vue'
import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale'
const settingsStore = useSettingsStore()
const toastStore = useToastStore()
// Tabs
const tabs = [
{ id: 'general', label: 'General', icon: markRaw(SettingsIcon) },
{ id: 'timer', label: 'Timer', icon: markRaw(Clock) },
{ id: 'billing', label: 'Billing', icon: markRaw(Receipt) },
{ id: 'data', label: 'Data', icon: markRaw(Database) },
]
const activeTab = ref('general')
// Settings state
const hourlyRate = ref(0)
const idleDetection = ref(true)
const idleTimeout = ref(5)
const reminderInterval = ref(15)
const appTrackingMode = ref('auto')
const appCheckInterval = ref(5)
const zoomLevel = ref(100)
const locale = ref('system')
const currency = ref('USD')
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
const showClearDataDialog = ref(false)
// Zoom steps
const zoomSteps = [80, 90, 100, 110, 120, 130, 150]
function increaseZoom() {
const currentIndex = zoomSteps.indexOf(zoomLevel.value)
if (currentIndex < zoomSteps.length - 1) {
zoomLevel.value = zoomSteps[currentIndex + 1]
} else if (currentIndex === -1) {
const next = zoomSteps.find(s => s > zoomLevel.value)
if (next) zoomLevel.value = next
}
applyZoom()
}
function decreaseZoom() {
const currentIndex = zoomSteps.indexOf(zoomLevel.value)
if (currentIndex > 0) {
zoomLevel.value = zoomSteps[currentIndex - 1]
} else if (currentIndex === -1) {
const prev = [...zoomSteps].reverse().find(s => s < zoomLevel.value)
if (prev) zoomLevel.value = prev
}
applyZoom()
}
function applyZoom() {
const app = document.getElementById('app')
if (app) {
(app.style as any).zoom = `${zoomLevel.value}%`
}
settingsStore.updateSetting('ui_zoom', zoomLevel.value.toString())
}
// Toggle idle detection with auto-save
function toggleIdleDetection() {
idleDetection.value = !idleDetection.value
saveSettings()
}
// Save settings (called on any change)
async function saveSettings() {
try {
await settingsStore.updateSetting('hourly_rate', hourlyRate.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('app_tracking_mode', appTrackingMode.value)
await settingsStore.updateSetting('app_check_interval', appCheckInterval.value.toString())
} catch (error) {
console.error('Failed to save settings:', error)
toastStore.error('Failed to save settings')
}
}
// Save locale/currency settings
async function saveLocaleSettings() {
await settingsStore.updateSetting('locale', locale.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
async function exportData() {
try {
const data = await invoke('export_data')
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `zeroclock-export-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error('Failed to export data:', error)
toastStore.error('Failed to export data')
}
}
// Clear all data
async function clearAllData() {
try {
await invoke('clear_all_data')
showClearDataDialog.value = false
toastStore.success('All data has been cleared')
} catch (error) {
console.error('Failed to clear data:', error)
toastStore.error('Failed to clear data')
}
}
// Load settings on mount
onMounted(async () => {
await settingsStore.fetchSettings()
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
idleDetection.value = settingsStore.settings.idle_detection !== 'false'
idleTimeout.value = parseInt(settingsStore.settings.idle_timeout) || 5
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
locale.value = settingsStore.settings.locale || 'system'
currency.value = settingsStore.settings.currency || 'USD'
themeMode.value = settingsStore.settings.theme_mode || 'dark'
accentColor.value = settingsStore.settings.accent_color || 'amber'
})
</script>