241 lines
7.1 KiB
TypeScript
241 lines
7.1 KiB
TypeScript
"use client"
|
|
|
|
import React, { useState, useEffect, forwardRef, useImperativeHandle } from "react"
|
|
import { Input } from "@/components/ui/input"
|
|
import { motion, AnimatePresence } from "framer-motion"
|
|
import { Search, Send, BarChart2, Globe, Video, PlaneTakeoff, AudioLines } from "lucide-react"
|
|
import useDebounce from "@/hooks/use-debounce"
|
|
|
|
interface Action {
|
|
id: string
|
|
label: string
|
|
icon: React.ReactNode
|
|
description?: string
|
|
short?: string
|
|
end?: string
|
|
}
|
|
|
|
interface SearchResult {
|
|
actions: Action[]
|
|
}
|
|
|
|
const allActions = [
|
|
{
|
|
id: "1",
|
|
label: "Book tickets",
|
|
icon: <PlaneTakeoff className="h-4 w-4 text-blue-500" />,
|
|
description: "Operator",
|
|
short: "⌘K",
|
|
end: "Agent",
|
|
},
|
|
{
|
|
id: "2",
|
|
label: "Summarize",
|
|
icon: <BarChart2 className="h-4 w-4 text-orange-500" />,
|
|
description: "gpt-4o",
|
|
short: "⌘cmd+p",
|
|
end: "Command",
|
|
},
|
|
{
|
|
id: "3",
|
|
label: "Screen Studio",
|
|
icon: <Video className="h-4 w-4 text-purple-500" />,
|
|
description: "gpt-4o",
|
|
short: "",
|
|
end: "Application",
|
|
},
|
|
{
|
|
id: "4",
|
|
label: "Talk to Jarvis",
|
|
icon: <AudioLines className="h-4 w-4 text-green-500" />,
|
|
description: "gpt-4o voice",
|
|
short: "",
|
|
end: "Active",
|
|
},
|
|
{
|
|
id: "5",
|
|
label: "Translate",
|
|
icon: <Globe className="h-4 w-4 text-blue-500" />,
|
|
description: "gpt-4o",
|
|
short: "",
|
|
end: "Command",
|
|
},
|
|
]
|
|
|
|
interface ActionSearchBarProps {
|
|
actions?: Action[]
|
|
autoFocus?: boolean
|
|
isFloating?: boolean
|
|
}
|
|
|
|
const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>(
|
|
({ actions = allActions, autoFocus = false, isFloating = false }, ref) => {
|
|
const [query, setQuery] = useState("")
|
|
const [result, setResult] = useState<SearchResult | null>(null)
|
|
const [isFocused, setIsFocused] = useState(autoFocus)
|
|
const [selectedAction, setSelectedAction] = useState<Action | null>(null)
|
|
const debouncedQuery = useDebounce(query, 200)
|
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
|
|
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)
|
|
|
|
useEffect(() => {
|
|
if (autoFocus && inputRef.current) {
|
|
inputRef.current.focus()
|
|
}
|
|
}, [autoFocus])
|
|
|
|
useEffect(() => {
|
|
if (!debouncedQuery) {
|
|
setResult({ actions: allActions })
|
|
return
|
|
}
|
|
|
|
const normalizedQuery = debouncedQuery.toLowerCase().trim()
|
|
const filteredActions = allActions.filter((action) => {
|
|
const searchableText = action.label.toLowerCase()
|
|
return searchableText.includes(normalizedQuery)
|
|
})
|
|
|
|
setResult({ actions: filteredActions })
|
|
}, [debouncedQuery])
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setQuery(e.target.value)
|
|
}
|
|
|
|
const container = {
|
|
hidden: { opacity: 0, height: 0 },
|
|
show: {
|
|
opacity: 1,
|
|
height: "auto",
|
|
transition: {
|
|
height: {
|
|
duration: 0.4,
|
|
},
|
|
staggerChildren: 0.1,
|
|
},
|
|
},
|
|
exit: {
|
|
opacity: 0,
|
|
height: 0,
|
|
transition: {
|
|
height: {
|
|
duration: 0.3,
|
|
},
|
|
opacity: {
|
|
duration: 0.2,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
const item = {
|
|
hidden: { opacity: 0, y: 20 },
|
|
show: {
|
|
opacity: 1,
|
|
y: 0,
|
|
transition: {
|
|
duration: 0.3,
|
|
},
|
|
},
|
|
exit: {
|
|
opacity: 0,
|
|
y: -10,
|
|
transition: {
|
|
duration: 0.2,
|
|
},
|
|
},
|
|
}
|
|
|
|
return (
|
|
<div className={`relative w-full ${isFloating ? "bg-background rounded-lg shadow-lg" : ""}`}>
|
|
<div className="relative">
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="What's up?"
|
|
value={query}
|
|
onChange={handleInputChange}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => !isFloating && setTimeout(() => setIsFocused(false), 200)}
|
|
className="pl-3 pr-9 py-1.5 h-9 text-sm rounded-lg focus-visible:ring-offset-0"
|
|
/>
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4">
|
|
<AnimatePresence mode="popLayout">
|
|
{query.length > 0 ? (
|
|
<motion.div
|
|
key="send"
|
|
initial={{ y: -20, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={{ y: 20, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<Send className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="search"
|
|
initial={{ y: -20, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={{ y: 20, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<Search className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{(isFocused || isFloating) && result && !selectedAction && (
|
|
<motion.div
|
|
className={`${isFloating ? "relative mt-2" : "absolute left-0 right-0 z-50"} border rounded-md shadow-lg overflow-hidden dark:border-gray-800 bg-background`}
|
|
variants={container}
|
|
initial="hidden"
|
|
animate="show"
|
|
exit="exit"
|
|
>
|
|
<motion.ul>
|
|
{result.actions.map((action) => (
|
|
<motion.li
|
|
key={action.id}
|
|
className="px-3 py-2 flex items-center justify-between hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
|
variants={item}
|
|
layout
|
|
onClick={() => setSelectedAction(action)}
|
|
>
|
|
<div className="flex items-center gap-2 justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-gray-500">{action.icon}</span>
|
|
<span className="text-sm font-medium">{action.label}</span>
|
|
<span className="text-xs text-muted-foreground">{action.description}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">{action.short}</span>
|
|
<span className="text-xs text-muted-foreground text-right">{action.end}</span>
|
|
</div>
|
|
</motion.li>
|
|
))}
|
|
</motion.ul>
|
|
<div className="mt-2 px-3 py-2 border-t border-border">
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>Press ⌘K to open commands</span>
|
|
<span>ESC to cancel</span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
},
|
|
)
|
|
|
|
ActionSearchBar.displayName = "ActionSearchBar"
|
|
|
|
export default ActionSearchBar
|
|
|