Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

267 lines
9.3 KiB
TypeScript

"use client"
import * as React from "react"
import { Search, X, ImagePlus, ArrowLeft, Clock, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { getHistory, clearHistory } from "@/lib/history"
// The embedding model is trained on natural-language questions (SimpleQA-style),
// which retrieve the target page most precisely — Ask mode leans on that. The
// search grid deliberately uses bare, slightly ambiguous titles instead: "The
// Starry Night" fans out to Van Gogh + Munch + Millet + the Dutch Sterrennacht,
// a far more striking visual spread than one painting repeated five times.
const EXAMPLE_QUERIES = [
"The Starry Night",
"Periodic table",
"Taj Mahal",
"The Great Wave off Kanagawa",
"清明上河图",
]
// The first two are verified "ChatGPT gets these wrong" questions: the answers
// live in table/infobox cells (match-stats table, RTO code box) that parametric
// memory and text scraping both fumble — but reading the rendered page nails.
const ASK_EXAMPLES = [
"How many shots on target did Inter have in the 2010 Champions League final?",
"Which district in Nagaland has the RTO code NL-03?",
"Explain Van Gogh's The Starry Night",
"介绍一下兵马俑",
]
interface SearchBarProps {
onSearch: (query: string, image?: string) => void
onReset?: () => void
isLoading: boolean
hasResults?: boolean
defaultValue?: string
mode?: "search" | "ask"
}
export function SearchBar({ onSearch, onReset, isLoading, hasResults, defaultValue = "", mode = "search" }: SearchBarProps) {
const [query, setQuery] = React.useState(defaultValue)
const [imageData, setImageData] = React.useState<string | undefined>()
const [imageName, setImageName] = React.useState<string>("")
const [isDragOver, setIsDragOver] = React.useState(false)
const fileInputRef = React.useRef<HTMLInputElement>(null)
const inputWrapperRef = React.useRef<HTMLDivElement>(null)
const [showHistory, setShowHistory] = React.useState(false)
const [history, setHistory] = React.useState<string[]>([])
const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
// Refresh history from localStorage whenever dropdown might show
function refreshHistory() {
setHistory(getHistory())
}
// Cmd+K / Ctrl+K global shortcut to focus search input
React.useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
const input = inputWrapperRef.current?.querySelector("input")
input?.focus()
}
}
window.addEventListener("keydown", handleKey)
return () => window.removeEventListener("keydown", handleKey)
}, [])
function handleFile(file: File) {
if (!file.type.startsWith("image/")) return
setImageName(file.name)
const reader = new FileReader()
reader.onload = (e) => {
setImageData(e.target?.result as string)
}
reader.readAsDataURL(file)
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!query.trim() && !imageData) return
onSearch(query.trim(), imageData)
}
function clearImage() {
setImageData(undefined)
setImageName("")
if (fileInputRef.current) fileInputRef.current.value = ""
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setIsDragOver(false)
const file = e.dataTransfer.files[0]
if (file) handleFile(file)
}
function handleDragOver(e: React.DragEvent) {
e.preventDefault()
setIsDragOver(true)
}
function handleDragLeave(e: React.DragEvent) {
e.preventDefault()
setIsDragOver(false)
}
function handlePaste(e: React.ClipboardEvent) {
const file = Array.from(e.clipboardData.items)
.find((it) => it.type.startsWith("image/"))
?.getAsFile()
if (file) {
e.preventDefault()
handleFile(file)
}
}
return (
<form onSubmit={handleSubmit} className="w-full max-w-2xl space-y-3">
<div
className={`search-bar-glow flex items-center gap-2 rounded-xl border bg-card p-2 transition-colors ${
isDragOver
? "border-primary bg-primary/5"
: "border-border"
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onPaste={handlePaste}
>
{hasResults && onReset && (
<button
type="button"
onClick={() => {
setQuery("")
setImageData(undefined)
setImageName("")
onReset()
}}
className="shrink-0 rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="New search"
>
<ArrowLeft className="h-4 w-4" />
</button>
)}
{imageData && (
<div className="relative shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageData}
alt={imageName}
className="h-10 w-10 rounded-md object-cover"
/>
<button
type="button"
onClick={clearImage}
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-destructive-foreground"
>
<X className="h-3 w-3" />
</button>
</div>
)}
<div ref={inputWrapperRef} className="relative flex-1">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
refreshHistory()
setShowHistory(true)
}}
onBlur={() => {
blurTimeoutRef.current = setTimeout(() => setShowHistory(false), 150)
}}
placeholder={mode === "ask" ? "Ask anything about Wikipedia..." : "Search Wikipedia with pixels..."}
className="w-full border-0 bg-transparent pr-10 shadow-none focus-visible:ring-0 focus-visible:border-transparent"
/>
<kbd className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
K
</kbd>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
{mode === "search" && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="h-4 w-4" />
</Button>
)}
<Button type="submit" size="default" disabled={isLoading || (!query.trim() && !imageData)}>
{mode === "ask" ? <Sparkles className="h-4 w-4" /> : <Search className="h-4 w-4" />}
{mode === "ask" ? "Ask" : isLoading ? "Searching..." : "Search"}
</Button>
</div>
{/* History dropdown */}
{showHistory && !query && !hasResults && history.length > 0 && (
<div className="w-full rounded-lg border border-border bg-card shadow-lg">
<div className="py-1">
{history.map((item) => (
<button
key={item}
type="button"
onMouseDown={(e) => {
e.preventDefault() // prevent blur
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current)
setQuery(item)
setShowHistory(false)
onSearch(item)
}}
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-foreground/90 transition-colors hover:bg-muted"
>
<Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item}</span>
</button>
))}
</div>
<div className="border-t border-border px-3 py-1.5">
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current)
clearHistory()
setHistory([])
setShowHistory(false)
}}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Clear history
</button>
</div>
</div>
)}
{!hasResults && <div className="flex items-start gap-1.5 text-xs text-muted-foreground">
<span className="mt-1 shrink-0">Try:</span>
<div className="flex flex-wrap gap-1.5">
{(mode === "ask" ? ASK_EXAMPLES : EXAMPLE_QUERIES).map((q) => (
<button
key={q}
type="button"
onClick={() => {
setQuery(q)
onSearch(q)
}}
className="query-chip rounded-full border border-border px-2.5 py-0.5 hover:border-primary/60 hover:text-foreground hover:bg-primary/5"
>
{q}
</button>
))}
</div>
</div>}
</form>
)
}