From 345ccb0a259920009a6d9350bfb72926e4170b7b Mon Sep 17 00:00:00 2001 From: TerryM Date: Sun, 31 May 2026 03:10:56 +0800 Subject: [PATCH] fix: preview search results --- src/components/SearchPanel.tsx | 160 ++++++++++++++++++++++++++++++++- src/layouts/PublicLayout.tsx | 2 + src/pages/Browse/index.tsx | 6 +- 3 files changed, 162 insertions(+), 6 deletions(-) diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index c2a2931..7f5a948 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -1,9 +1,18 @@ import { Info, Search as SearchIcon, X } from "lucide-react"; -import { useEffect, useMemo, useRef, useState, type Ref } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type ReactNode, + type Ref, +} from "react"; +import { Link } from "react-router-dom"; import { getJSON, itemsOrEmpty, readJSONCache } from "../api"; import { langQuery, type Lang } from "../i18n"; import type { Post, PostListResponse } from "../types/post"; import { MessageBubble } from "./messageStream/MessageBubble"; +import { postDisplayText, postTitleText } from "./messageStream/utils/postText"; type TagItem = { name: string; @@ -19,10 +28,13 @@ type SearchPanelProps = { variant?: "mobile" | "desktop"; showInput?: boolean; panelRef?: Ref; + onResultClick?: () => void; }; const TAG_SOURCE_LIMIT = 80; const TAG_RESULT_LIMIT = 12; +const SEARCH_PREVIEW_LIMIT = 5; +const SEARCH_DEBOUNCE_MS = 300; function buildPostsUrl(params: Record) { const sp = new URLSearchParams(); @@ -40,6 +52,52 @@ function buildSearchUrl(params: Record) { return `/api/posts/search?${sp.toString()}`; } +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function highlightText(text: string, query: string): ReactNode { + const clean = query.trim(); + if (!clean || !text) return text; + + const parts = text.split(new RegExp(`(${escapeRegex(clean)})`, "gi")); + return parts.map((part, index) => + part.toLowerCase() === clean.toLowerCase() ? ( + + {part} + + ) : ( + part + ), + ); +} + +function snippetFor(post: Post, lang: Lang, query: string): string { + const text = postDisplayText(post, lang).replace(/\s+/g, " ").trim(); + if (!text) return ""; + + const clean = query.trim().toLowerCase(); + const index = clean ? text.toLowerCase().indexOf(clean) : -1; + if (index === -1) return text.slice(0, 110); + + const start = Math.max(0, index - 36); + const end = Math.min(text.length, index + clean.length + 74); + return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`; +} + +function metaFor(post: Post): string { + const parts = [ + post.categorySlug, + post.postType, + ...(post.tags ?? []).map((tag) => `#${tag}`), + post.attachments[0]?.filename, + ].filter(Boolean); + return parts.slice(0, 4).join(" · "); +} + function extractTags(posts: Post[]): TagItem[] { const counts = new Map(); posts.forEach((post) => { @@ -65,6 +123,7 @@ export function SearchPanel({ variant = "mobile", showInput = true, panelRef, + onResultClick, }: SearchPanelProps) { const inputRef = useRef(null); const [tags, setTags] = useState([]); @@ -72,8 +131,11 @@ export function SearchPanel({ const [tagPosts, setTagPosts] = useState([]); const [isTagLoading, setIsTagLoading] = useState(false); const [isPostLoading, setIsPostLoading] = useState(false); + const [previewPosts, setPreviewPosts] = useState([]); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); const langParam = useMemo(() => langQuery(lang), [lang]); + const cleanQuery = query.trim(); useEffect(() => { if (!showInput) return; @@ -112,6 +174,50 @@ export function SearchPanel({ }; }, [langParam]); + useEffect(() => { + if (selectedTag && selectedTag !== cleanQuery) { + setSelectedTag(""); + setTagPosts([]); + } + }, [cleanQuery, selectedTag]); + + useEffect(() => { + let cancelled = false; + const q = cleanQuery; + if (!q) { + setPreviewPosts([]); + setIsPreviewLoading(false); + return; + } + + const searchUrl = buildSearchUrl({ + lang: langParam, + q, + limit: SEARCH_PREVIEW_LIMIT, + }); + const cachedPosts = readJSONCache(searchUrl); + if (cachedPosts) setPreviewPosts(itemsOrEmpty(cachedPosts.items)); + + setIsPreviewLoading(!cachedPosts); + const timer = window.setTimeout(() => { + getJSON(searchUrl) + .then((res) => { + if (!cancelled) setPreviewPosts(itemsOrEmpty(res.items)); + }) + .catch(() => { + if (!cancelled && !cachedPosts) setPreviewPosts([]); + }) + .finally(() => { + if (!cancelled) setIsPreviewLoading(false); + }); + }, SEARCH_DEBOUNCE_MS); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [cleanQuery, langParam]); + const showTagPosts = (tag: string) => { // Tapping the active tag again clears it (toggle) instead of staying stuck. if (selectedTag === tag) { @@ -205,7 +311,57 @@ export function SearchPanel({ )} -
+ {cleanQuery ? ( +
+
+

+ {t("related")} +

+ {isPreviewLoading ? ( + + ) : null} +
+ + {previewPosts.length > 0 ? ( +
+ {previewPosts.map((post) => { + const title = + postTitleText(post, lang) || + post.attachments[0]?.filename || + post.id; + const snippet = snippetFor(post, lang, cleanQuery); + const meta = metaFor(post); + return ( + +
+ {highlightText(title, cleanQuery)} +
+ {meta ? ( +
+ {highlightText(meta, cleanQuery)} +
+ ) : null} + {snippet ? ( +
+ {highlightText(snippet, cleanQuery)} +
+ ) : null} + + ); + })} +
+ ) : !isPreviewLoading ? ( +

{t("noResults")}

+ ) : null} +
+ ) : null} + +

{t("currentTags")} diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index c23edda..459978b 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -722,6 +722,7 @@ export function PublicLayout() { query={q} onQueryChange={setQ} onSearch={goSearch} + onResultClick={() => setMobileSearchOpen(false)} /> ) : null} @@ -733,6 +734,7 @@ export function PublicLayout() { query={q} onQueryChange={setQ} onSearch={goSearch} + onResultClick={() => setDesktopSearchOpen(false)} variant="desktop" showInput={false} /> diff --git a/src/pages/Browse/index.tsx b/src/pages/Browse/index.tsx index 426d7f3..de58526 100644 --- a/src/pages/Browse/index.tsx +++ b/src/pages/Browse/index.tsx @@ -5,11 +5,9 @@ import { useI18n } from "../../i18n"; export function Browse() { const { t } = useI18n(); const [sp] = useSearchParams(); - const q = sp.get("q") || ""; const sort = sp.get("sort") || ""; - const title = q - ? `${t("search")}: ${q}` - : sort === "latest" + const title = + sort === "latest" ? t("latest") : sort === "recommended" ? t("official")