From 6f901f48e1195dbc9e6cf64e40914b8404760f1d Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 28 May 2026 18:51:55 +0800 Subject: [PATCH] feat: add mobile search panel --- src/components/SearchPanel.tsx | 215 +++++++++++++++++++++++++++++++++ src/i18n.tsx | 19 +++ src/layouts/PublicLayout.tsx | 44 ++++--- 3 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 src/components/SearchPanel.tsx diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx new file mode 100644 index 0000000..d8b7555 --- /dev/null +++ b/src/components/SearchPanel.tsx @@ -0,0 +1,215 @@ +import { Info, Search as SearchIcon, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { getJSON, itemsOrEmpty } 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; +}; + +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, +}: 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(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + let cancelled = false; + setIsTagLoading(true); + getJSON( + buildPostsUrl({ + lang: langParam, + sort: "latest", + limit: TAG_SOURCE_LIMIT, + }), + ) + .then((res) => { + if (cancelled) return; + setTags(extractTags(itemsOrEmpty(res.items))); + }) + .catch(() => { + if (!cancelled) setTags([]); + }) + .finally(() => { + if (!cancelled) setIsTagLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [langParam]); + + const showTagPosts = (tag: string) => { + setSelectedTag(tag); + onQueryChange(tag); + setIsPostLoading(true); + getJSON( + buildSearchUrl({ lang: langParam, q: tag, limit: TAG_RESULT_LIMIT }), + ) + .then((res) => { + const exactMatches = itemsOrEmpty(res.items).filter((post) => + post.tags?.some((postTag) => postTag.trim() === tag), + ); + setTagPosts(exactMatches); + }) + .catch(() => setTagPosts([])) + .finally(() => setIsPostLoading(false)); + }; + + return ( +
+
+
+
+ + onQueryChange(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onSearch()} + placeholder={t("searchPanelPlaceholder")} + className="min-w-0 flex-1 bg-transparent text-sm text-neutral-100 outline-none placeholder:text-[#777985]" + /> + +
+ +
+ +
+ + {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} +
+
+ ); +} diff --git a/src/i18n.tsx b/src/i18n.tsx index 1154da7..300e840 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -21,7 +21,16 @@ const zhDict: Dict = { popular: "热门资料", search: "搜索", searchPlaceholder: "搜索资料...", + searchPanelPlaceholder: "搜索 PPT、影片、海报、公告、教程、文...", searchNow: "立即搜索资料", + searchSubmit: "搜索", + cancel: "取消", + clear: "清除", + searchPanelHint: "支持搜索 标题・分类・标签・简介・文件类型・正文", + currentTags: "现有标签", + noTagsAvailable: "暂无可选择的标签。", + tagPostsTitle: "#{{tag}} 相关资料", + noTagPosts: "暂时找不到带有此标签的资料。", viewAll: "查看全部", heroTitle: "ARK 官方数据库", heroSub: @@ -138,7 +147,17 @@ const enDict: Dict = { popular: "Popular", search: "Search", searchPlaceholder: "Search resources...", + searchPanelPlaceholder: "Search PPT, videos, posters, news, guides...", searchNow: "Search now", + searchSubmit: "Search", + cancel: "Cancel", + clear: "Clear", + searchPanelHint: + "Search supports title, category, tags, summary, file type, and body text.", + currentTags: "Available tags", + noTagsAvailable: "No tags available yet.", + tagPostsTitle: "#{{tag}} related posts", + noTagPosts: "No posts with this tag yet.", viewAll: "View all", heroTitle: "ARK Official Library", heroSub: diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index be43405..5d26f3a 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -2,6 +2,7 @@ import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { ArkLogoMark } from "../components/ArkLogoMark"; +import { SearchPanel } from "../components/SearchPanel"; import { useI18n, type Lang } from "../i18n"; import { LANG_OPTIONS } from "../i18nLanguages"; @@ -356,21 +357,6 @@ export function PublicLayout() { - {mobileSearchOpen ? ( -
-
- - setQ(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && goSearch()} - placeholder={t("searchPlaceholder")} - className="min-w-0 flex-1 bg-transparent text-sm text-neutral-100 outline-none placeholder:text-[#777985]" - /> -
-
- ) : null} -
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
@@ -441,7 +427,14 @@ export function PublicLayout() {
- + setQ(e.target.value)} @@ -471,7 +464,14 @@ export function PublicLayout() { {open ? (
- + setQ(e.target.value)} @@ -546,6 +546,16 @@ export function PublicLayout() { ) : null} + {mobileSearchOpen ? ( + + ) : null} +