feat: add global keyboard shortcuts for timer toggle and show app
Register CmdOrCtrl+Shift+T (toggle timer) and CmdOrCtrl+Shift+Z (show app) via tauri-plugin-global-shortcut. Shortcut keys are configurable in Settings Timer tab. Shortcuts re-register on change.
This commit is contained in:
33
src/App.vue
33
src/App.vue
@@ -4,9 +4,37 @@ import TitleBar from './components/TitleBar.vue'
|
||||
import NavRail from './components/NavRail.vue'
|
||||
import ToastNotification from './components/ToastNotification.vue'
|
||||
import { useSettingsStore } from './stores/settings'
|
||||
import { useTimerStore } from './stores/timer'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
async function registerShortcuts() {
|
||||
try {
|
||||
const { unregisterAll, register } = await import('@tauri-apps/plugin-global-shortcut')
|
||||
await unregisterAll()
|
||||
const toggleKey = settingsStore.settings.shortcut_toggle_timer || 'CmdOrCtrl+Shift+T'
|
||||
const showKey = settingsStore.settings.shortcut_show_app || 'CmdOrCtrl+Shift+Z'
|
||||
|
||||
await register(toggleKey, () => {
|
||||
const timerStore = useTimerStore()
|
||||
if (timerStore.isStopped) {
|
||||
if (timerStore.selectedProjectId) timerStore.start()
|
||||
} else {
|
||||
timerStore.stop()
|
||||
}
|
||||
})
|
||||
|
||||
await register(showKey, async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
const win = getCurrentWindow()
|
||||
await win.show()
|
||||
await win.setFocus()
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to register shortcuts:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const el = document.documentElement
|
||||
const mode = settingsStore.settings.theme_mode || 'dark'
|
||||
@@ -29,6 +57,7 @@ onMounted(async () => {
|
||||
(app.style as any).zoom = `${zoom}%`
|
||||
}
|
||||
applyTheme()
|
||||
registerShortcuts()
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (settingsStore.settings.theme_mode === 'system') applyTheme()
|
||||
@@ -38,6 +67,10 @@ onMounted(async () => {
|
||||
watch(() => [settingsStore.settings.theme_mode, settingsStore.settings.accent_color], () => {
|
||||
applyTheme()
|
||||
})
|
||||
|
||||
watch(() => [settingsStore.settings.shortcut_toggle_timer, settingsStore.settings.shortcut_show_app], () => {
|
||||
registerShortcuts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -230,6 +230,40 @@
|
||||
@update:model-value="saveSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<!-- Keyboard Shortcuts -->
|
||||
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Keyboard Shortcuts</h3>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Toggle Timer</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Start/stop the active timer</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="shortcutToggleTimer"
|
||||
type="text"
|
||||
class="w-48 px-3 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono text-center focus:outline-none focus:border-border-visible"
|
||||
placeholder="CmdOrCtrl+Shift+T"
|
||||
@change="saveShortcutSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Show App</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Bring the app to front</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="shortcutShowApp"
|
||||
type="text"
|
||||
class="w-48 px-3 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono text-center focus:outline-none focus:border-border-visible"
|
||||
placeholder="CmdOrCtrl+Shift+Z"
|
||||
@change="saveShortcutSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -272,6 +306,55 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Import Data -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-[0.8125rem] font-medium text-text-primary mb-4">Import Data</h3>
|
||||
|
||||
<div class="flex items-end gap-3 mb-4">
|
||||
<div class="w-48">
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Format</label>
|
||||
<AppSelect
|
||||
v-model="importFormat"
|
||||
:options="importFormats"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="handleImportFile"
|
||||
class="flex items-center gap-2 px-4 py-2 border border-border-visible text-text-primary text-xs rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
||||
>
|
||||
<Upload class="w-3.5 h-3.5" />
|
||||
Choose File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="importFileName" class="mb-4">
|
||||
<p class="text-[0.75rem] text-text-secondary mb-2">File: {{ importFileName }}</p>
|
||||
|
||||
<!-- CSV Preview -->
|
||||
<div v-if="importPreview.length > 0" class="overflow-x-auto mb-3">
|
||||
<table class="text-[0.6875rem]">
|
||||
<tr v-for="(row, i) in importPreview" :key="i" :class="i === 0 ? 'text-text-tertiary font-medium' : 'text-text-secondary'">
|
||||
<td v-for="(cell, j) in row" :key="j" class="pr-4 py-0.5 whitespace-nowrap">{{ cell }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="executeImport"
|
||||
:disabled="isImporting"
|
||||
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-40"
|
||||
>
|
||||
{{ isImporting ? 'Importing...' : 'Import' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="importStatus" class="text-[0.75rem] mt-2" :class="importStatus.startsWith('Error') ? 'text-status-error' : 'text-status-running'">
|
||||
{{ importStatus }}
|
||||
</p>
|
||||
</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>
|
||||
@@ -328,12 +411,13 @@
|
||||
<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 { Settings as SettingsIcon, Clock, Receipt, Database, Plus, Minus, Upload } 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'
|
||||
import { parseCSV, mapTogglCSV, mapGenericCSV, type ImportEntry } from '../utils/import'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const toastStore = useToastStore()
|
||||
@@ -361,6 +445,8 @@ const currency = ref('USD')
|
||||
const currencyOptions = getCurrencies()
|
||||
const themeMode = ref('dark')
|
||||
const accentColor = ref('amber')
|
||||
const shortcutToggleTimer = ref('CmdOrCtrl+Shift+T')
|
||||
const shortcutShowApp = ref('CmdOrCtrl+Shift+Z')
|
||||
|
||||
const themeModes = [
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
@@ -387,6 +473,21 @@ const appTrackingModes = [
|
||||
// Dialog state
|
||||
const showClearDataDialog = ref(false)
|
||||
|
||||
// Import state
|
||||
const importFormat = ref('toggl')
|
||||
const importFileContent = ref('')
|
||||
const importFileName = ref('')
|
||||
const importPreview = ref<string[][]>([])
|
||||
const importStatus = ref('')
|
||||
const isImporting = ref(false)
|
||||
|
||||
const importFormats = [
|
||||
{ label: 'Toggl CSV', value: 'toggl' },
|
||||
{ label: 'Clockify CSV', value: 'clockify' },
|
||||
{ label: 'Generic CSV', value: 'generic' },
|
||||
{ label: 'ZeroClock JSON', value: 'json' },
|
||||
]
|
||||
|
||||
// Zoom steps
|
||||
const zoomSteps = [80, 90, 100, 110, 120, 130, 150]
|
||||
|
||||
@@ -453,6 +554,74 @@ async function saveThemeSettings() {
|
||||
await settingsStore.updateSetting('accent_color', accentColor.value)
|
||||
}
|
||||
|
||||
// Save shortcut settings
|
||||
async function saveShortcutSettings() {
|
||||
await settingsStore.updateSetting('shortcut_toggle_timer', shortcutToggleTimer.value)
|
||||
await settingsStore.updateSetting('shortcut_show_app', shortcutShowApp.value)
|
||||
}
|
||||
|
||||
// Import file handling
|
||||
async function handleImportFile() {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const selected = await open({
|
||||
filters: [
|
||||
{ name: 'Data Files', extensions: ['csv', 'json'] }
|
||||
]
|
||||
})
|
||||
if (!selected) return
|
||||
|
||||
const filePath = selected as string
|
||||
// @ts-ignore - plugin-fs types are available at runtime
|
||||
const { readTextFile } = await import('@tauri-apps/plugin-fs')
|
||||
const content: string = await readTextFile(filePath)
|
||||
importFileContent.value = content
|
||||
importFileName.value = filePath.split(/[/\\]/).pop() || 'file'
|
||||
|
||||
if (importFormat.value === 'json') {
|
||||
importPreview.value = []
|
||||
} else {
|
||||
importPreview.value = parseCSV(content).slice(0, 6)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to read file:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function executeImport() {
|
||||
if (!importFileContent.value) return
|
||||
isImporting.value = true
|
||||
importStatus.value = 'Importing...'
|
||||
|
||||
try {
|
||||
if (importFormat.value === 'json') {
|
||||
const data = JSON.parse(importFileContent.value)
|
||||
await invoke('import_json_data', { data })
|
||||
importStatus.value = 'Import complete!'
|
||||
} else {
|
||||
const rows = parseCSV(importFileContent.value)
|
||||
let entries: ImportEntry[]
|
||||
|
||||
if (importFormat.value === 'toggl' || importFormat.value === 'clockify') {
|
||||
entries = mapTogglCSV(rows)
|
||||
} else {
|
||||
entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 })
|
||||
}
|
||||
|
||||
await invoke('import_entries', { entries })
|
||||
importStatus.value = `Imported ${entries.length} entries!`
|
||||
}
|
||||
|
||||
importFileContent.value = ''
|
||||
importFileName.value = ''
|
||||
importPreview.value = []
|
||||
} catch (e) {
|
||||
importStatus.value = `Error: ${e}`
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Export all data
|
||||
async function exportData() {
|
||||
try {
|
||||
@@ -499,5 +668,7 @@ onMounted(async () => {
|
||||
currency.value = settingsStore.settings.currency || 'USD'
|
||||
themeMode.value = settingsStore.settings.theme_mode || 'dark'
|
||||
accentColor.value = settingsStore.settings.accent_color || 'amber'
|
||||
shortcutToggleTimer.value = settingsStore.settings.shortcut_toggle_timer || 'CmdOrCtrl+Shift+T'
|
||||
shortcutShowApp.value = settingsStore.settings.shortcut_show_app || 'CmdOrCtrl+Shift+Z'
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user