feat(ui): add Command-K search modal, remove inline search bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,179 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
||||||
import { Search } from 'lucide-react'
|
|
||||||
import { useTasks } from '../api/tasks.ts'
|
|
||||||
import { CATEGORY_COLORS, COLUMN_CONFIG } from '../lib/constants.ts'
|
|
||||||
import type { WorkTask } from '../types/index.ts'
|
|
||||||
|
|
||||||
interface SearchBarProps {
|
|
||||||
onSelect: (taskId: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SearchBar({ onSelect }: SearchBarProps) {
|
|
||||||
const { data: tasks } = useTasks()
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
// Debounce the query by 200ms
|
|
||||||
useEffect(() => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
setDebouncedQuery(query)
|
|
||||||
}, 200)
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
|
||||||
}
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
// Filter tasks based on debounced query
|
|
||||||
const results: WorkTask[] = (() => {
|
|
||||||
if (!debouncedQuery.trim() || !tasks) return []
|
|
||||||
const q = debouncedQuery.toLowerCase()
|
|
||||||
return tasks
|
|
||||||
.filter(
|
|
||||||
(t) =>
|
|
||||||
t.title.toLowerCase().includes(q) ||
|
|
||||||
(t.description && t.description.toLowerCase().includes(q))
|
|
||||||
)
|
|
||||||
.slice(0, 8)
|
|
||||||
})()
|
|
||||||
|
|
||||||
// Open/close dropdown based on results
|
|
||||||
useEffect(() => {
|
|
||||||
setIsOpen(results.length > 0)
|
|
||||||
setSelectedIndex(0)
|
|
||||||
}, [results.length])
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
||||||
setIsOpen(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(taskId: number) => {
|
|
||||||
onSelect(taskId)
|
|
||||||
setQuery('')
|
|
||||||
setDebouncedQuery('')
|
|
||||||
setIsOpen(false)
|
|
||||||
inputRef.current?.blur()
|
|
||||||
},
|
|
||||||
[onSelect]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (!isOpen) return
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault()
|
|
||||||
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
|
|
||||||
break
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault()
|
|
||||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
|
||||||
break
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault()
|
|
||||||
if (results[selectedIndex]) {
|
|
||||||
handleSelect(results[selectedIndex].id)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault()
|
|
||||||
setIsOpen(false)
|
|
||||||
inputRef.current?.blur()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isOpen, results, selectedIndex, handleSelect]
|
|
||||||
)
|
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
|
||||||
const col = COLUMN_CONFIG.find((c) => c.status === status)
|
|
||||||
return col ? col.label : 'Unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
const col = COLUMN_CONFIG.find((c) => c.status === status)
|
|
||||||
return col ? col.color : '#64748b'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="relative w-[300px]">
|
|
||||||
<div className="relative">
|
|
||||||
<Search
|
|
||||||
size={16}
|
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[#94a3b8] pointer-events-none"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onFocus={() => {
|
|
||||||
if (results.length > 0) setIsOpen(true)
|
|
||||||
}}
|
|
||||||
placeholder="Search tasks..."
|
|
||||||
className="w-full h-8 pl-9 pr-3 rounded-full bg-[#1a1d27] text-white text-sm placeholder-[#94a3b8] border border-white/5 focus:border-indigo-500/50 focus:outline-none transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOpen && results.length > 0 && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-2 rounded-lg bg-[#1a1d27] border border-white/10 shadow-xl shadow-black/40 z-50 overflow-hidden">
|
|
||||||
{results.map((task, index) => {
|
|
||||||
const categoryColor =
|
|
||||||
CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
|
|
||||||
const statusColor = getStatusColor(task.status)
|
|
||||||
const statusLabel = getStatusLabel(task.status)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={task.id}
|
|
||||||
onClick={() => handleSelect(task.id)}
|
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${
|
|
||||||
index === selectedIndex ? 'bg-[#2a2d37]' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Status dot */}
|
|
||||||
<span
|
|
||||||
className="shrink-0 w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: statusColor }}
|
|
||||||
title={statusLabel}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<span className="flex-1 text-sm text-white truncate">{task.title}</span>
|
|
||||||
|
|
||||||
{/* Category badge */}
|
|
||||||
{task.category && (
|
|
||||||
<span
|
|
||||||
className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: categoryColor + '20',
|
|
||||||
color: categoryColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
192
TaskTracker.Web/src/components/SearchModal.tsx
Normal file
192
TaskTracker.Web/src/components/SearchModal.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { Search, ArrowRight } from 'lucide-react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useTasks } from '../api/tasks.ts'
|
||||||
|
import { CATEGORY_COLORS, COLUMN_CONFIG } from '../lib/constants.ts'
|
||||||
|
import type { WorkTask } from '../types/index.ts'
|
||||||
|
|
||||||
|
interface SearchModalProps {
|
||||||
|
onSelect: (taskId: number) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchModal({ onSelect, onClose }: SearchModalProps) {
|
||||||
|
const { data: tasks } = useTasks()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Filter tasks
|
||||||
|
const results: WorkTask[] = (() => {
|
||||||
|
if (!tasks) return []
|
||||||
|
if (!query.trim()) {
|
||||||
|
// Show recent/active tasks when no query
|
||||||
|
return tasks
|
||||||
|
.filter((t) => t.status === 'Active' || t.status === 'Paused' || t.status === 'Pending')
|
||||||
|
.slice(0, 8)
|
||||||
|
}
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return tasks
|
||||||
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
t.title.toLowerCase().includes(q) ||
|
||||||
|
(t.description && t.description.toLowerCase().includes(q)) ||
|
||||||
|
(t.category && t.category.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
.slice(0, 8)
|
||||||
|
})()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (results[selectedIndex]) {
|
||||||
|
onSelect(results[selectedIndex].id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[results, selectedIndex, onSelect, onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const col = COLUMN_CONFIG.find((c) => c.status === status)
|
||||||
|
return col ? col.color : '#64748b'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full max-w-lg bg-[var(--color-elevated)] border border-[var(--color-border)] rounded-xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)]">
|
||||||
|
<Search size={16} className="text-[var(--color-text-secondary)] shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Search tasks..."
|
||||||
|
className="flex-1 bg-transparent text-[var(--color-text-primary)] text-sm placeholder-[var(--color-text-tertiary)] outline-none"
|
||||||
|
/>
|
||||||
|
<kbd className="text-[10px] font-mono text-[var(--color-text-tertiary)] bg-white/[0.06] px-1.5 py-0.5 rounded border border-[var(--color-border)]">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<div className="max-h-[300px] overflow-y-auto py-1">
|
||||||
|
{!query.trim() && (
|
||||||
|
<div className="px-4 py-1.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
|
||||||
|
Recent tasks
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{results.map((task, index) => {
|
||||||
|
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
|
||||||
|
const statusColor = getStatusColor(task.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => onSelect(task.id)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||||
|
index === selectedIndex ? 'bg-white/[0.06]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Status dot */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<span className="flex-1 text-sm text-[var(--color-text-primary)] truncate">
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
{task.category && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: categoryColor + '15',
|
||||||
|
color: categoryColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Arrow hint on selected */}
|
||||||
|
{index === selectedIndex && (
|
||||||
|
<ArrowRight size={12} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : query.trim() ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-[var(--color-text-secondary)]">
|
||||||
|
No tasks found
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center gap-4 px-4 py-2 border-t border-[var(--color-border)] text-[10px] text-[var(--color-text-tertiary)]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">↑↓</kbd>
|
||||||
|
Navigate
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">↵</kbd>
|
||||||
|
Open
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user