fix: preview search results

This commit is contained in:
TerryM
2026-05-31 03:10:56 +08:00
parent 92a8a83585
commit 345ccb0a25
3 changed files with 162 additions and 6 deletions

View File

@@ -1,9 +1,18 @@
import { Info, Search as SearchIcon, X } from "lucide-react"; 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 { getJSON, itemsOrEmpty, readJSONCache } from "../api";
import { langQuery, type Lang } from "../i18n"; import { langQuery, type Lang } from "../i18n";
import type { Post, PostListResponse } from "../types/post"; import type { Post, PostListResponse } from "../types/post";
import { MessageBubble } from "./messageStream/MessageBubble"; import { MessageBubble } from "./messageStream/MessageBubble";
import { postDisplayText, postTitleText } from "./messageStream/utils/postText";
type TagItem = { type TagItem = {
name: string; name: string;
@@ -19,10 +28,13 @@ type SearchPanelProps = {
variant?: "mobile" | "desktop"; variant?: "mobile" | "desktop";
showInput?: boolean; showInput?: boolean;
panelRef?: Ref<HTMLDivElement>; panelRef?: Ref<HTMLDivElement>;
onResultClick?: () => void;
}; };
const TAG_SOURCE_LIMIT = 80; const TAG_SOURCE_LIMIT = 80;
const TAG_RESULT_LIMIT = 12; const TAG_RESULT_LIMIT = 12;
const SEARCH_PREVIEW_LIMIT = 5;
const SEARCH_DEBOUNCE_MS = 300;
function buildPostsUrl(params: Record<string, string | number | undefined>) { function buildPostsUrl(params: Record<string, string | number | undefined>) {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
@@ -40,6 +52,52 @@ function buildSearchUrl(params: Record<string, string | number | undefined>) {
return `/api/posts/search?${sp.toString()}`; 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() ? (
<mark
key={`${part}-${index}`}
className="rounded bg-ark-gold/20 px-0.5 text-ark-gold"
>
{part}
</mark>
) : (
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[] { function extractTags(posts: Post[]): TagItem[] {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
posts.forEach((post) => { posts.forEach((post) => {
@@ -65,6 +123,7 @@ export function SearchPanel({
variant = "mobile", variant = "mobile",
showInput = true, showInput = true,
panelRef, panelRef,
onResultClick,
}: SearchPanelProps) { }: SearchPanelProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [tags, setTags] = useState<TagItem[]>([]); const [tags, setTags] = useState<TagItem[]>([]);
@@ -72,8 +131,11 @@ export function SearchPanel({
const [tagPosts, setTagPosts] = useState<Post[]>([]); const [tagPosts, setTagPosts] = useState<Post[]>([]);
const [isTagLoading, setIsTagLoading] = useState(false); const [isTagLoading, setIsTagLoading] = useState(false);
const [isPostLoading, setIsPostLoading] = useState(false); const [isPostLoading, setIsPostLoading] = useState(false);
const [previewPosts, setPreviewPosts] = useState<Post[]>([]);
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const langParam = useMemo(() => langQuery(lang), [lang]); const langParam = useMemo(() => langQuery(lang), [lang]);
const cleanQuery = query.trim();
useEffect(() => { useEffect(() => {
if (!showInput) return; if (!showInput) return;
@@ -112,6 +174,50 @@ export function SearchPanel({
}; };
}, [langParam]); }, [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<PostListResponse>(searchUrl);
if (cachedPosts) setPreviewPosts(itemsOrEmpty(cachedPosts.items));
setIsPreviewLoading(!cachedPosts);
const timer = window.setTimeout(() => {
getJSON<PostListResponse>(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) => { const showTagPosts = (tag: string) => {
// Tapping the active tag again clears it (toggle) instead of staying stuck. // Tapping the active tag again clears it (toggle) instead of staying stuck.
if (selectedTag === tag) { if (selectedTag === tag) {
@@ -205,7 +311,57 @@ export function SearchPanel({
</div> </div>
)} )}
<section className={showInput ? "mt-5" : "mt-0"}> {cleanQuery ? (
<section className={showInput ? "mt-5" : "mt-0"}>
<div className="mb-3 flex items-center justify-between">
<h2 className="border-l-4 border-ark-gold pl-3 text-base font-bold text-neutral-100">
{t("related")}
</h2>
{isPreviewLoading ? (
<span className="text-xs text-neutral-500"></span>
) : null}
</div>
{previewPosts.length > 0 ? (
<div className="flex flex-col gap-2">
{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 (
<Link
key={post.id}
to={`/browse?post=${encodeURIComponent(post.id)}`}
onClick={onResultClick}
className="block rounded-2xl border border-white/10 bg-[#191921] px-4 py-3 transition hover:border-ark-gold/60 hover:bg-[#22232D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
>
<div className="line-clamp-2 text-sm font-semibold leading-5 text-neutral-100">
{highlightText(title, cleanQuery)}
</div>
{meta ? (
<div className="mt-1 line-clamp-1 text-[12px] leading-4 text-[#A8A9AE]">
{highlightText(meta, cleanQuery)}
</div>
) : null}
{snippet ? (
<div className="mt-1 line-clamp-2 text-[13px] leading-[18px] text-neutral-400">
{highlightText(snippet, cleanQuery)}
</div>
) : null}
</Link>
);
})}
</div>
) : !isPreviewLoading ? (
<p className="py-3 text-sm text-neutral-500">{t("noResults")}</p>
) : null}
</section>
) : null}
<section className={cleanQuery ? "mt-6" : showInput ? "mt-5" : "mt-0"}>
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h2 className="border-l-4 border-ark-gold pl-3 text-base font-bold text-neutral-100"> <h2 className="border-l-4 border-ark-gold pl-3 text-base font-bold text-neutral-100">
{t("currentTags")} {t("currentTags")}

View File

@@ -722,6 +722,7 @@ export function PublicLayout() {
query={q} query={q}
onQueryChange={setQ} onQueryChange={setQ}
onSearch={goSearch} onSearch={goSearch}
onResultClick={() => setMobileSearchOpen(false)}
/> />
) : null} ) : null}
@@ -733,6 +734,7 @@ export function PublicLayout() {
query={q} query={q}
onQueryChange={setQ} onQueryChange={setQ}
onSearch={goSearch} onSearch={goSearch}
onResultClick={() => setDesktopSearchOpen(false)}
variant="desktop" variant="desktop"
showInput={false} showInput={false}
/> />

View File

@@ -5,11 +5,9 @@ import { useI18n } from "../../i18n";
export function Browse() { export function Browse() {
const { t } = useI18n(); const { t } = useI18n();
const [sp] = useSearchParams(); const [sp] = useSearchParams();
const q = sp.get("q") || "";
const sort = sp.get("sort") || ""; const sort = sp.get("sort") || "";
const title = q const title =
? `${t("search")}: ${q}` sort === "latest"
: sort === "latest"
? t("latest") ? t("latest")
: sort === "recommended" : sort === "recommended"
? t("official") ? t("official")