fix: preview search results
This commit is contained in:
@@ -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<HTMLDivElement>;
|
||||
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<string, string | number | undefined>) {
|
||||
const sp = new URLSearchParams();
|
||||
@@ -40,6 +52,52 @@ function buildSearchUrl(params: Record<string, string | number | undefined>) {
|
||||
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[] {
|
||||
const counts = new Map<string, number>();
|
||||
posts.forEach((post) => {
|
||||
@@ -65,6 +123,7 @@ export function SearchPanel({
|
||||
variant = "mobile",
|
||||
showInput = true,
|
||||
panelRef,
|
||||
onResultClick,
|
||||
}: SearchPanelProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [tags, setTags] = useState<TagItem[]>([]);
|
||||
@@ -72,8 +131,11 @@ export function SearchPanel({
|
||||
const [tagPosts, setTagPosts] = useState<Post[]>([]);
|
||||
const [isTagLoading, setIsTagLoading] = useState(false);
|
||||
const [isPostLoading, setIsPostLoading] = useState(false);
|
||||
const [previewPosts, setPreviewPosts] = useState<Post[]>([]);
|
||||
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<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) => {
|
||||
// Tapping the active tag again clears it (toggle) instead of staying stuck.
|
||||
if (selectedTag === tag) {
|
||||
@@ -205,7 +311,57 @@ export function SearchPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<h2 className="border-l-4 border-ark-gold pl-3 text-base font-bold text-neutral-100">
|
||||
{t("currentTags")}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user