- Custom tooltip directive (WCAG AAA) on every button in the app - Two-column timer layout with sticky hero and recent entries sidebar - Timer font selector with 16 monospace Google Fonts and live preview - UI font selector with 15+ Google Fonts - Close-to-tray and minimize-to-tray settings - New app icons (no-glow variants), platform icon set - Mini timer pop-out window - Favorites strip with drag-reorder and inline actions - README with feature documentation - Remove tracked files that belong in gitignore
193 lines
6.2 KiB
Vue
193 lines
6.2 KiB
Vue
<script setup lang="ts">
|
|
import { watch, ref, computed, nextTick } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import { useFocusTrap } from '../utils/focusTrap'
|
|
import { useProjectsStore } from '../stores/projects'
|
|
import { useInvoicesStore } from '../stores/invoices'
|
|
import { Search, FolderKanban, Users, Clock, FileText } from 'lucide-vue-next'
|
|
|
|
const props = defineProps<{ show: boolean }>()
|
|
const emit = defineEmits<{ close: [] }>()
|
|
|
|
const router = useRouter()
|
|
const projectsStore = useProjectsStore()
|
|
const invoicesStore = useInvoicesStore()
|
|
const { activate, deactivate } = useFocusTrap()
|
|
const dialogRef = ref<HTMLElement | null>(null)
|
|
const inputRef = ref<HTMLInputElement | null>(null)
|
|
|
|
const query = ref('')
|
|
const activeIndex = ref(0)
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
interface SearchResult {
|
|
type: 'project' | 'client' | 'entry' | 'invoice'
|
|
id: number
|
|
label: string
|
|
sublabel: string
|
|
color?: string
|
|
route: string
|
|
}
|
|
|
|
const entryResults = ref<SearchResult[]>([])
|
|
const searching = ref(false)
|
|
|
|
const localResults = computed((): SearchResult[] => {
|
|
const q = query.value.toLowerCase().trim()
|
|
if (!q) return []
|
|
const results: SearchResult[] = []
|
|
|
|
for (const p of projectsStore.projects) {
|
|
if (results.length >= 5) break
|
|
if (p.name.toLowerCase().includes(q)) {
|
|
results.push({ type: 'project', id: p.id!, label: p.name, sublabel: p.archived ? 'Archived' : 'Active', color: p.color, route: '/projects' })
|
|
}
|
|
}
|
|
|
|
for (const inv of invoicesStore.invoices) {
|
|
if (results.length >= 10) break
|
|
if (inv.invoice_number.toLowerCase().includes(q)) {
|
|
results.push({ type: 'invoice', id: inv.id!, label: inv.invoice_number, sublabel: inv.status, route: '/invoices' })
|
|
}
|
|
}
|
|
|
|
return results
|
|
})
|
|
|
|
const allResults = computed(() => [...localResults.value, ...entryResults.value])
|
|
|
|
async function searchEntries(q: string) {
|
|
if (!q.trim()) {
|
|
entryResults.value = []
|
|
return
|
|
}
|
|
searching.value = true
|
|
try {
|
|
const rows = await invoke<any[]>('search_entries', { query: q, limit: 5 })
|
|
entryResults.value = rows.map(r => ({
|
|
type: 'entry' as const,
|
|
id: r.id,
|
|
label: r.description || '(no description)',
|
|
sublabel: r.project_name || 'Unknown project',
|
|
color: r.project_color,
|
|
route: '/entries',
|
|
}))
|
|
} catch {
|
|
entryResults.value = []
|
|
} finally {
|
|
searching.value = false
|
|
}
|
|
}
|
|
|
|
function onInput() {
|
|
activeIndex.value = 0
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(() => searchEntries(query.value), 200)
|
|
}
|
|
|
|
function navigate(result: SearchResult) {
|
|
router.push(result.route)
|
|
emit('close')
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
const total = allResults.value.length
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
activeIndex.value = (activeIndex.value + 1) % Math.max(total, 1)
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
activeIndex.value = (activeIndex.value - 1 + Math.max(total, 1)) % Math.max(total, 1)
|
|
} else if (e.key === 'Enter' && allResults.value[activeIndex.value]) {
|
|
e.preventDefault()
|
|
navigate(allResults.value[activeIndex.value])
|
|
} else if (e.key === 'Escape') {
|
|
emit('close')
|
|
}
|
|
}
|
|
|
|
const typeIcon: Record<string, any> = {
|
|
project: FolderKanban,
|
|
client: Users,
|
|
entry: Clock,
|
|
invoice: FileText,
|
|
}
|
|
|
|
watch(() => props.show, (val) => {
|
|
if (val) {
|
|
query.value = ''
|
|
entryResults.value = []
|
|
activeIndex.value = 0
|
|
nextTick(() => {
|
|
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
|
|
inputRef.value?.focus()
|
|
})
|
|
} else {
|
|
deactivate()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Transition name="modal">
|
|
<div
|
|
v-if="show"
|
|
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-start justify-center pt-[15vh] p-4 z-50"
|
|
@click.self="$emit('close')"
|
|
>
|
|
<div
|
|
ref="dialogRef"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Search"
|
|
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md overflow-hidden"
|
|
@keydown="onKeydown"
|
|
>
|
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle">
|
|
<Search class="w-4 h-4 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
|
|
<input
|
|
ref="inputRef"
|
|
v-model="query"
|
|
@input="onInput"
|
|
type="text"
|
|
class="flex-1 bg-transparent text-[0.875rem] text-text-primary placeholder-text-tertiary outline-none"
|
|
placeholder="Search projects, entries, invoices..."
|
|
aria-label="Search"
|
|
/>
|
|
<kbd class="text-[0.625rem] text-text-tertiary border border-border-subtle rounded px-1.5 py-0.5">Esc</kbd>
|
|
</div>
|
|
|
|
<div v-if="!query.trim()" class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
|
|
Type to search...
|
|
</div>
|
|
|
|
<div v-else-if="allResults.length === 0 && !searching" class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
|
|
No results for "{{ query }}"
|
|
</div>
|
|
|
|
<ul v-else class="max-h-80 overflow-y-auto py-2" role="listbox">
|
|
<li
|
|
v-for="(result, idx) in allResults"
|
|
:key="result.type + '-' + result.id"
|
|
role="option"
|
|
:aria-selected="idx === activeIndex"
|
|
class="flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors duration-100"
|
|
:class="idx === activeIndex ? 'bg-accent/10' : 'hover:bg-bg-elevated'"
|
|
@click="navigate(result)"
|
|
@mouseenter="activeIndex = idx"
|
|
>
|
|
<component :is="typeIcon[result.type]" class="w-4 h-4 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
|
|
<span v-if="result.color" class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: result.color }" aria-hidden="true" />
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-[0.8125rem] text-text-primary truncate">{{ result.label }}</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary truncate">{{ result.sublabel }}</p>
|
|
</div>
|
|
<span class="text-[0.5625rem] text-text-tertiary uppercase tracking-wider shrink-0">{{ result.type }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</template>
|