Port meetups, discussions, gigs, dating/chat, VIP pay (ZPay/XorPay), MiroTalk live, digital academy, and ebook reader. Add persistent community_store, git-first deploy docs, and env template for production secrets. Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
7.3 KiB
TypeScript
174 lines
7.3 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback } from "react";
|
||
import { useRouter, usePathname } from "next/navigation";
|
||
import { api } from "@/lib/api";
|
||
import { TOOL_LINKS } from "@/lib/tools";
|
||
import type { SearchResult } from "@/lib/types";
|
||
|
||
const TYPE_LABELS: Record<string, string> = {
|
||
destination: "目的地",
|
||
blog: "博客",
|
||
visa: "签证",
|
||
faq: "FAQ",
|
||
tool: "工具",
|
||
page: "页面",
|
||
};
|
||
|
||
const PAGE_RESULTS: SearchResult[] = [
|
||
{ type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" },
|
||
{ type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" },
|
||
{ type: "page", title: "下一站决策", subtitle: "预算网速气候智能推荐", emoji: "🧭", url: "/next-stop" },
|
||
{ type: "page", title: "游民活动", subtitle: "线上圆桌与线下聚会", emoji: "🎉", url: "/meetups" },
|
||
{ type: "page", title: "社区讨论", subtitle: "签证远程住宿经验交流", emoji: "💬", url: "/community" },
|
||
{ type: "page", title: "游民匹配", subtitle: "滑动匹配同路游民", emoji: "💕", url: "/dating" },
|
||
{ type: "page", title: "私信", subtitle: "与匹配成功的游民聊天", emoji: "✉️", url: "/chat" },
|
||
{ type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" },
|
||
{ type: "page", title: "赏金任务", subtitle: "远程小任务接单", emoji: "💼", url: "/gigs" },
|
||
{ type: "page", title: "会员定价", subtitle: "免费版与 VIP 方案", emoji: "💎", url: "/pricing" },
|
||
{ type: "page", title: "通知中心", subtitle: "匹配与社区动态", emoji: "🔔", url: "/notifications" },
|
||
{ type: "page", title: "开通会员", subtitle: "VIP 匹配直播与课程", emoji: "✨", url: "/join" },
|
||
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
|
||
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
|
||
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
|
||
{ type: "page", title: "隐私政策", subtitle: "Cookie 与数据说明", emoji: "🔒", url: "/privacy" },
|
||
];
|
||
|
||
function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] {
|
||
const s = q.toLowerCase();
|
||
const tools = TOOL_LINKS
|
||
.filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s) || t.id.includes(s))
|
||
.filter((t) => !hidePlanCompare || (t.href !== "/plan" && t.href !== "/compare"))
|
||
.map((t) => ({
|
||
type: "tool",
|
||
title: t.title,
|
||
subtitle: t.desc,
|
||
emoji: t.emoji,
|
||
url: t.href,
|
||
}));
|
||
const pages = PAGE_RESULTS.filter(
|
||
(p) => p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s)
|
||
).filter((p) => !hidePlanCompare || (p.url !== "/plan" && p.url !== "/compare"));
|
||
return [...tools, ...pages];
|
||
}
|
||
|
||
export default function GlobalSearch() {
|
||
const [open, setOpen] = useState(false);
|
||
const [query, setQuery] = useState("");
|
||
const [results, setResults] = useState<SearchResult[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [activeIdx, setActiveIdx] = useState(0);
|
||
const router = useRouter();
|
||
const isHome = usePathname() === "/";
|
||
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||
e.preventDefault();
|
||
setOpen((o) => !o);
|
||
}
|
||
if (e.key === "Escape") setOpen(false);
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, []);
|
||
|
||
const doSearch = useCallback(async (q: string) => {
|
||
if (!q.trim()) { setResults([]); return; }
|
||
setLoading(true);
|
||
const local = searchLocal(q, isHome);
|
||
try {
|
||
const data = await api.search(q);
|
||
const merged = [...local, ...data].slice(0, 20);
|
||
setResults(merged);
|
||
setActiveIdx(0);
|
||
} catch {
|
||
setResults(local);
|
||
setActiveIdx(0);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [isHome]);
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => doSearch(query), 250);
|
||
return () => clearTimeout(timer);
|
||
}, [query, doSearch]);
|
||
|
||
const navigate = (url: string) => {
|
||
setOpen(false);
|
||
setQuery("");
|
||
if (url.startsWith("/#")) {
|
||
router.push("/");
|
||
setTimeout(() => {
|
||
document.querySelector(url.replace("/", ""))?.scrollIntoView({ behavior: "smooth" });
|
||
}, 300);
|
||
} else {
|
||
router.push(url);
|
||
}
|
||
};
|
||
|
||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, results.length - 1)); }
|
||
if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); }
|
||
if (e.key === "Enter" && results[activeIdx]) navigate(results[activeIdx].url);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<button className="search-trigger" onClick={() => setOpen(true)} aria-label="搜索">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||
</svg>
|
||
<span>搜索...</span>
|
||
<kbd>⌘K</kbd>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="search-overlay" onClick={() => setOpen(false)}>
|
||
<div className="search-modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="search-input-wrap">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||
</svg>
|
||
<input
|
||
autoFocus
|
||
placeholder="搜索目的地、工具、签证、博客…"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
/>
|
||
<button onClick={() => setOpen(false)}>ESC</button>
|
||
</div>
|
||
<div className="search-results">
|
||
{loading && <p className="search-empty">搜索中...</p>}
|
||
{!loading && query && results.length === 0 && (
|
||
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
|
||
)}
|
||
{!loading && results.map((r, i) => (
|
||
<button key={`${r.type}-${r.url}-${r.title}`}
|
||
className={`search-result${i === activeIdx ? " active" : ""}`}
|
||
onClick={() => navigate(r.url)}
|
||
onMouseEnter={() => setActiveIdx(i)}>
|
||
<span className="search-result-emoji">{r.emoji}</span>
|
||
<div className="search-result-text">
|
||
<strong>{r.title}</strong>
|
||
<span>{r.subtitle}</span>
|
||
</div>
|
||
<span className="search-result-type">{TYPE_LABELS[r.type] || r.type}</span>
|
||
</button>
|
||
))}
|
||
{!query && (
|
||
<div className="search-hints">
|
||
<p>💡 试试:清迈、机票、税居、工具箱</p>
|
||
<p>⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|