import { Info, Search as SearchIcon, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type Ref } from "react"; import { getJSON, itemsOrEmpty, readJSONCache } from "../api"; import { langQuery, type Lang } from "../i18n"; import type { Post, PostListResponse } from "../types/post"; import { MessageBubble } from "./messageStream/MessageBubble"; 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; }; const TAG_SOURCE_LIMIT = 80; const TAG_RESULT_LIMIT = 12; 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 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, }: 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 langParam = useMemo(() => langQuery(lang), [lang]); 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]); 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")}
)}

{t("currentTags")}

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

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

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

{t("noTagPosts")}

)}
) : null}
); }