mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-08 03:08:29 +09:00
142 lines
4.2 KiB
TypeScript
142 lines
4.2 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { ExternalLink, Loader2, Search } from 'lucide-react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
type Result = {
|
|
id: string;
|
|
type: 'group' | 'topic' | 'person' | 'message' | 'link';
|
|
title: string;
|
|
subtitle: string;
|
|
href: string;
|
|
external?: boolean;
|
|
};
|
|
|
|
type SearchResponse = {
|
|
ok: boolean;
|
|
results: Result[];
|
|
};
|
|
|
|
const TYPE_LABEL: Record<Result['type'], string> = {
|
|
group: '群',
|
|
topic: '话题',
|
|
person: '人',
|
|
message: '消息',
|
|
link: '链接',
|
|
};
|
|
|
|
export default function GlobalSearch() {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<Result[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!boxRef.current?.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', onDown);
|
|
return () => document.removeEventListener('mousedown', onDown);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const q = query.trim();
|
|
if (q.length < 2) {
|
|
return;
|
|
}
|
|
const ctl = new AbortController();
|
|
const timer = window.setTimeout(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const r = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
|
|
cache: 'no-store',
|
|
signal: ctl.signal,
|
|
});
|
|
const j = (await r.json()) as SearchResponse;
|
|
if (j.ok) {
|
|
setResults(j.results);
|
|
setOpen(true);
|
|
}
|
|
} catch (e) {
|
|
if (!(e instanceof DOMException && e.name === 'AbortError')) console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, 220);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
ctl.abort();
|
|
};
|
|
}, [query]);
|
|
|
|
return (
|
|
<div ref={boxRef} className="relative w-[280px]">
|
|
<div className="control-surface flex items-center gap-2 rounded-md px-2.5 py-1.5">
|
|
<Search size={13} className="text-[var(--text-3)]" />
|
|
<input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onFocus={() => query.trim().length >= 2 && setOpen(true)}
|
|
className="min-w-0 flex-1 bg-transparent text-[12px] outline-none placeholder:text-[var(--text-3)]"
|
|
placeholder="搜索群、话题、人、关键词"
|
|
/>
|
|
{loading && <Loader2 size={12} className="animate-spin text-[var(--text-3)]" />}
|
|
</div>
|
|
|
|
{open && query.trim().length >= 2 && (
|
|
<div className="absolute right-0 top-[calc(100%+8px)] z-40 max-h-[520px] w-[420px] overflow-y-auto rounded-lg border border-[var(--border-soft)] bg-[var(--surface)] p-2 shadow-[var(--shadow)]">
|
|
{results.length === 0 && !loading ? (
|
|
<div className="px-3 py-8 text-center text-[12px] text-[var(--text-3)]">
|
|
没找到匹配结果
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{results.map((item) => (
|
|
<SearchItem key={item.id} item={item} onClick={() => setOpen(false)} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SearchItem({ item, onClick }: { item: Result; onClick: () => void }) {
|
|
const inner = (
|
|
<>
|
|
<span className="mt-0.5 rounded border border-[var(--border-soft)] px-1.5 py-0.5 text-[10px] text-[var(--text-3)]">
|
|
{TYPE_LABEL[item.type]}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-[13px] font-medium text-[var(--text)]">
|
|
{item.title}
|
|
</span>
|
|
<span className="mt-0.5 block truncate text-[11px] text-[var(--text-3)]">
|
|
{item.subtitle}
|
|
</span>
|
|
</span>
|
|
{item.external && <ExternalLink size={12} className="text-[var(--text-3)]" />}
|
|
</>
|
|
);
|
|
|
|
const className =
|
|
'flex items-start gap-2 rounded-md px-2.5 py-2 transition-colors hover:bg-[var(--surface-2)]';
|
|
|
|
if (item.external) {
|
|
return (
|
|
<a href={item.href} target="_blank" rel="noreferrer" className={className} onClick={onClick}>
|
|
{inner}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link href={item.href} className={className} onClick={onClick}>
|
|
{inner}
|
|
</Link>
|
|
);
|
|
}
|