import { Info, Search as SearchIcon, X } from "lucide-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; count: number; }; type SearchPanelProps = { lang: Lang; t: (key: string) => string; query: string; onQueryChange: (value: string) => void; onSearch: () => void; 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(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== "") sp.set(key, String(value)); }); return `/api/posts?${sp.toString()}`; } function buildSearchUrl(params: Record) { const sp = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== "") sp.set(key, String(value)); }); 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) => { post.tags?.forEach((tag) => { const clean = tag.trim(); if (!clean) return; counts.set(clean, (counts.get(clean) ?? 0) + 1); }); }); return [...counts.entries()] .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) .slice(0, 12); } export function SearchPanel({ lang, t, query, onQueryChange, onSearch, variant = "mobile", showInput = true, panelRef, onResultClick, }: SearchPanelProps) { const inputRef = useRef(null); const [tags, setTags] = useState([]); const [selectedTag, setSelectedTag] = useState(""); 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; // Avoid scroll-into-view: browsers default-scroll the focused element // into the viewport, which moves the underlying page when the search // overlay opens from a scrolled position. `preventScroll` keeps the page // exactly where it was. inputRef.current?.focus({ preventScroll: true }); }, [showInput]); useEffect(() => { let cancelled = false; const tagsUrl = buildPostsUrl({ lang: langParam, sort: "latest", limit: TAG_SOURCE_LIMIT, }); const cachedTags = readJSONCache(tagsUrl); if (cachedTags) setTags(extractTags(itemsOrEmpty(cachedTags.items))); setIsTagLoading(!cachedTags); getJSON(tagsUrl) .then((res) => { if (cancelled) return; setTags(extractTags(itemsOrEmpty(res.items))); }) .catch(() => { if (!cancelled && !cachedTags) setTags([]); }) .finally(() => { if (!cancelled) setIsTagLoading(false); }); return () => { cancelled = true; }; }, [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) { setSelectedTag(""); setTagPosts([]); onQueryChange(""); return; } setSelectedTag(tag); onQueryChange(tag); const searchUrl = buildSearchUrl({ lang: langParam, q: tag, limit: TAG_RESULT_LIMIT, }); const cachedPosts = readJSONCache(searchUrl); if (cachedPosts) { setTagPosts( itemsOrEmpty(cachedPosts.items).filter((post) => post.tags?.some((postTag) => postTag.trim() === tag), ), ); } setIsPostLoading(!cachedPosts); getJSON(searchUrl) .then((res) => { const exactMatches = itemsOrEmpty(res.items).filter((post) => post.tags?.some((postTag) => postTag.trim() === tag), ); setTagPosts(exactMatches); }) .catch(() => { if (!cachedPosts) setTagPosts([]); }) .finally(() => setIsPostLoading(false)); }; const panelClassName = variant === "desktop" ? "ark-header-menu-enter fixed left-1/2 top-[72px] z-50 max-h-[min(70dvh,640px)] w-[min(720px,calc(100vw-3rem))] -translate-x-1/2 overflow-y-auto overscroll-contain rounded-3xl border border-white/10 bg-[#0f0f13] shadow-2xl shadow-black/60" : "ark-header-menu-enter fixed inset-x-0 bottom-0 top-[64px] z-50 overflow-y-auto overscroll-contain bg-[#0f0f13] md:hidden"; const innerClassName = variant === "desktop" ? "px-5 pb-6 pt-4" : "border-t border-white/10 px-5 pb-6 pt-3 max-[360px]:px-3"; return (
{showInput ? ( <>
onQueryChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder={t("searchPanelPlaceholder")} className="min-w-0 flex-1 bg-transparent text-base text-neutral-100 outline-none placeholder:text-[#777985]" />
{t("searchPanelHint")}
) : (
{t("searchPanelHint")}
)} {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} {!cleanQuery ? (

{t("currentTags")}

{isTagLoading ? (
{t("loading")}
) : tags.length > 0 ? (
{tags.map((tag) => { const active = selectedTag === tag.name; return ( ); })}
) : (
{t("noTagsAvailable")}
)}
) : null} {selectedTag ? (

{t("tagPostsTitle").replace("{{tag}}", selectedTag)}

{isPostLoading ? (
) : tagPosts.length > 0 ? (
{tagPosts.map((post) => ( ))}
) : (

{t("noTagPosts")}

)}
) : null}
); }