feat: unify search with browse page

This commit is contained in:
TerryM
2026-05-27 11:33:48 +08:00
parent f169144378
commit 3f0a395f40
6 changed files with 45 additions and 140 deletions

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { postJSON } from "../../api";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import type { PostScope } from "../../types/post"; import type { PostScope } from "../../types/post";
import { FilterChips } from "./FilterChips"; import { FilterChips } from "./FilterChips";
@@ -16,8 +17,12 @@ export function MessageStream({ scope }: MessageStreamProps) {
const [sp, setSp] = useSearchParams(); const [sp, setSp] = useSearchParams();
const type = sp.get("type") || "all"; const type = sp.get("type") || "all";
const q = (sp.get("q") || "").trim();
const params = useMemo(() => ({ scope, type, lang }), [scope, type, lang]); const params = useMemo(
() => ({ scope, type, q, lang }),
[scope, type, q, lang],
);
const { items, isLoading, error, hasMore, loadMore, reset } = const { items, isLoading, error, hasMore, loadMore, reset } =
usePostStream(params); usePostStream(params);
@@ -34,6 +39,10 @@ export function MessageStream({ scope }: MessageStreamProps) {
isLoadingRef.current = isLoading; isLoadingRef.current = isLoading;
}, [isLoading]); }, [isLoading]);
useEffect(() => {
if (q) postJSON("/api/search-log", { query: q }).catch(() => {});
}, [q]);
useEffect(() => { useEffect(() => {
const el = sentinelRef.current; const el = sentinelRef.current;
if (!el) return; if (!el) return;

View File

@@ -14,6 +14,7 @@ export type PostStreamParams = {
scope: PostScope; scope: PostScope;
type?: string; type?: string;
language?: string; language?: string;
q?: string;
lang: Lang; lang: Lang;
}; };
@@ -56,7 +57,20 @@ function filterMock(params: PostStreamParams): Post[] {
p.categorySlug !== params.scope.slug p.categorySlug !== params.scope.slug
) )
return false; return false;
const q = params.q?.trim().toLowerCase();
if (params.language && p.language !== params.language) return false; if (params.language && p.language !== params.language) return false;
if (q) {
const haystack = [
p.text ?? "",
p.categorySlug,
p.postType ?? "",
...(p.tags ?? []),
...p.attachments.flatMap((a) => [a.filename, a.mime, a.kind]),
]
.join("\n")
.toLowerCase();
if (!haystack.includes(q)) return false;
}
if (!postMatchesType(p, params.type ?? "all")) return false; if (!postMatchesType(p, params.type ?? "all")) return false;
return true; return true;
}).sort( }).sort(
@@ -67,13 +81,15 @@ function filterMock(params: PostStreamParams): Post[] {
function buildRealUrl(params: PostStreamParams, cursor?: string): string { function buildRealUrl(params: PostStreamParams, cursor?: string): string {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
const q = params.q?.trim();
sp.set("lang", langQuery(params.lang)); sp.set("lang", langQuery(params.lang));
sp.set("limit", String(PAGE_SIZE)); sp.set("limit", String(PAGE_SIZE));
if (q) sp.set("q", q);
if (params.scope.kind === "category") sp.set("category", params.scope.slug); if (params.scope.kind === "category") sp.set("category", params.scope.slug);
if (params.type && params.type !== "all") sp.set("type", params.type); if (params.type && params.type !== "all") sp.set("type", params.type);
if (params.language) sp.set("language", sourceLanguageQuery(params.language)); if (params.language) sp.set("language", sourceLanguageQuery(params.language));
if (cursor) sp.set("cursor", cursor); if (cursor) sp.set("cursor", cursor);
return `/api/posts?${sp.toString()}`; return `${q ? "/api/posts/search" : "/api/posts"}?${sp.toString()}`;
} }
export function usePostStream(params: PostStreamParams): PostStreamResult { export function usePostStream(params: PostStreamParams): PostStreamResult {
@@ -146,6 +162,7 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
params.scope.kind === "category" ? params.scope.slug : "", params.scope.kind === "category" ? params.scope.slug : "",
params.type, params.type,
params.language, params.language,
params.q,
params.lang, params.lang,
]); ]);

View File

@@ -11,7 +11,6 @@ import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { ArkLogoMark } from "../components/ArkLogoMark"; import { ArkLogoMark } from "../components/ArkLogoMark";
import { useI18n, type Lang } from "../i18n"; import { useI18n, type Lang } from "../i18n";
import { LANG_OPTIONS } from "../i18nLanguages"; import { LANG_OPTIONS } from "../i18nLanguages";
import { adminUiPrefix } from "../adminPaths";
type PublicNavWhich = type PublicNavWhich =
| "home" | "home"
@@ -268,7 +267,7 @@ export function PublicLayout() {
const goSearch = () => { const goSearch = () => {
const s = q.trim(); const s = q.trim();
if (!s) return; if (!s) return;
nav(`/search?q=${encodeURIComponent(s)}`); nav(`/browse?q=${encodeURIComponent(s)}`);
setOpen(false); setOpen(false);
setMobileSearchOpen(false); setMobileSearchOpen(false);
}; };
@@ -545,14 +544,6 @@ export function PublicLayout() {
> >
{t("footerAbout")} {t("footerAbout")}
</Link> </Link>
{import.meta.env.VITE_DISABLE_ADMIN !== "true" ? (
<Link
to={`${adminUiPrefix}/login`}
className="rounded-sm outline-none hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
{t("footerAdminLogin")}
</Link>
) : null}
</div> </div>
</footer> </footer>

View File

@@ -1,12 +1,15 @@
import { useSearchParams } from "react-router-dom";
import { MessageStream } from "../../components/messageStream/MessageStream"; import { MessageStream } from "../../components/messageStream/MessageStream";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
export function Browse() { export function Browse() {
const { t } = useI18n(); const { t } = useI18n();
const [sp] = useSearchParams();
const q = sp.get("q") || "";
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<h1 className="mx-auto max-w-full px-3 text-2xl font-bold md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]"> <h1 className="mx-auto max-w-full px-3 text-2xl font-bold md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
{t("all")} {q ? `${t("search")}: ${q}` : t("all")}
</h1> </h1>
<MessageStream scope={{ kind: "all" }} /> <MessageStream scope={{ kind: "all" }} />
</section> </section>

View File

@@ -11,6 +11,7 @@ import {
import { RecommendedCard } from "../../components/RecommendedCard"; import { RecommendedCard } from "../../components/RecommendedCard";
import { SectionHeader } from "../../components/SectionHeader"; import { SectionHeader } from "../../components/SectionHeader";
import { langQuery, useI18n } from "../../i18n"; import { langQuery, useI18n } from "../../i18n";
import { sourceLanguageQuery } from "../../i18nLanguages";
import { categoryCardLines } from "../../utils/categoryDisplay"; import { categoryCardLines } from "../../utils/categoryDisplay";
import { import {
postToResource, postToResource,
@@ -28,11 +29,14 @@ export function Home() {
const [canScrollRec, setCanScrollRec] = useState(false); const [canScrollRec, setCanScrollRec] = useState(false);
useEffect(() => { useEffect(() => {
const q = `?lang=${encodeURIComponent(langQuery(lang))}`; const langParam = encodeURIComponent(langQuery(lang));
const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
const catQ = `?lang=${langParam}`;
const postQ = `?lang=${langParam}&language=${languageParam}`;
Promise.all([ Promise.all([
getJSON<Category[]>(`/api/categories${q}`), getJSON<Category[]>(`/api/categories${catQ}`),
getJSON<{ items: Post[] }>(`/api/posts/recommended${q}&limit=12`), getJSON<{ items: Post[] }>(`/api/posts/recommended${postQ}&limit=12`),
getJSON<{ items: Post[] }>(`/api/posts/latest${q}&limit=8`), getJSON<{ items: Post[] }>(`/api/posts/latest${postQ}&limit=8`),
]) ])
.then(([c, r, l]) => { .then(([c, r, l]) => {
setCats(itemsOrEmpty(c)); setCats(itemsOrEmpty(c));

View File

@@ -1,126 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { Navigate, useSearchParams } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
import { getJSON, itemsOrEmpty, postJSON } from "../../api";
import { langQuery, useI18n } from "../../i18n";
import {
LANG_OPTIONS,
languageLabel,
sourceLanguageQuery,
} from "../../i18nLanguages";
import { MessageBubble } from "../../components/messageStream/MessageBubble";
import { typeFilterLabel } from "../../resourceTypeLabels";
import type { Post } from "../../types/post";
const types = [
"all",
"image",
"video",
"music",
"ppt",
"pdf",
"text",
"link",
"archive",
] as const;
const resourceLangCodes = ["", ...LANG_OPTIONS.map((x) => x.code)] as const;
export function SearchPage() { export function SearchPage() {
const { t, lang } = useI18n(); const [sp] = useSearchParams();
const [sp, setSp] = useSearchParams(); const query = sp.toString();
const q = sp.get("q") || ""; return <Navigate to={`/browse${query ? `?${query}` : ""}`} replace />;
const type = sp.get("type") || "all";
const resourceLang = sp.get("language") || "";
const [items, setItems] = useState<Post[]>([]);
const [err, setErr] = useState<string | null>(null);
const query = useMemo(() => {
const p = new URLSearchParams();
p.set("lang", langQuery(lang));
p.set("limit", "50");
p.set("q", q);
if (type && type !== "all") p.set("type", type);
if (resourceLang) p.set("language", sourceLanguageQuery(resourceLang));
return p.toString();
}, [lang, q, type, resourceLang]);
useEffect(() => {
setErr(null);
if (!q.trim()) {
setItems([]);
return;
}
postJSON("/api/search-log", { query: q }).catch(() => {});
getJSON<{ items: Post[] }>(`/api/posts/search?${query}`)
.then((r) => setItems(itemsOrEmpty(r.items)))
.catch((e) => setErr(String(e)));
}, [query, q]);
return (
<div className="mx-auto max-w-[640px] space-y-4 px-3">
<h1 className="text-2xl font-bold">
{t("search")}: {q || "—"}
</h1>
{q ? (
<>
<div className="flex flex-wrap gap-2">
{types.map((tp) => (
<button
key={tp}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
if (tp === "all") n.delete("type");
else n.set("type", tp);
setSp(n, { replace: true });
}}
className={`rounded-full border px-3 py-1 text-xs transition ${
type === tp || (tp === "all" && !sp.get("type"))
? "border-ark-gold text-ark-gold2"
: "border-ark-line text-neutral-300"
}`}
>
{typeFilterLabel(t, tp)}
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
{resourceLangCodes.map((code) => (
<button
key={code || "all"}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
if (!code) n.delete("language");
else n.set("language", code);
setSp(n, { replace: true });
}}
className={`rounded-full border px-3 py-1 text-xs transition ${
(code === "" && !resourceLang) || resourceLang === code
? "border-ark-gold text-ark-gold2"
: "border-ark-line text-neutral-300"
}`}
>
{languageLabel(t, code)}
</button>
))}
</div>
</>
) : null}
{err ? <div className="text-red-300">{err}</div> : null}
{!q ? <p className="text-neutral-400">{t("noResults")}</p> : null}
{q && items.length === 0 && !err ? (
<p className="text-neutral-400">{t("noResults")}</p>
) : null}
<div className="flex flex-col gap-2">
{items.map((post) => (
<MessageBubble key={post.id} post={post} />
))}
</div>
</div>
);
} }