feat: unify search with browse page
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { postJSON } from "../../api";
|
||||
import { useI18n } from "../../i18n";
|
||||
import type { PostScope } from "../../types/post";
|
||||
import { FilterChips } from "./FilterChips";
|
||||
@@ -16,8 +17,12 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
const [sp, setSp] = useSearchParams();
|
||||
|
||||
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 } =
|
||||
usePostStream(params);
|
||||
@@ -34,6 +39,10 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
isLoadingRef.current = isLoading;
|
||||
}, [isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (q) postJSON("/api/search-log", { query: q }).catch(() => {});
|
||||
}, [q]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
|
||||
@@ -14,6 +14,7 @@ export type PostStreamParams = {
|
||||
scope: PostScope;
|
||||
type?: string;
|
||||
language?: string;
|
||||
q?: string;
|
||||
lang: Lang;
|
||||
};
|
||||
|
||||
@@ -56,7 +57,20 @@ function filterMock(params: PostStreamParams): Post[] {
|
||||
p.categorySlug !== params.scope.slug
|
||||
)
|
||||
return false;
|
||||
const q = params.q?.trim().toLowerCase();
|
||||
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;
|
||||
return true;
|
||||
}).sort(
|
||||
@@ -67,13 +81,15 @@ function filterMock(params: PostStreamParams): Post[] {
|
||||
|
||||
function buildRealUrl(params: PostStreamParams, cursor?: string): string {
|
||||
const sp = new URLSearchParams();
|
||||
const q = params.q?.trim();
|
||||
sp.set("lang", langQuery(params.lang));
|
||||
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.type && params.type !== "all") sp.set("type", params.type);
|
||||
if (params.language) sp.set("language", sourceLanguageQuery(params.language));
|
||||
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 {
|
||||
@@ -146,6 +162,7 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
||||
params.scope.kind === "category" ? params.scope.slug : "",
|
||||
params.type,
|
||||
params.language,
|
||||
params.q,
|
||||
params.lang,
|
||||
]);
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { ArkLogoMark } from "../components/ArkLogoMark";
|
||||
import { useI18n, type Lang } from "../i18n";
|
||||
import { LANG_OPTIONS } from "../i18nLanguages";
|
||||
import { adminUiPrefix } from "../adminPaths";
|
||||
|
||||
type PublicNavWhich =
|
||||
| "home"
|
||||
@@ -268,7 +267,7 @@ export function PublicLayout() {
|
||||
const goSearch = () => {
|
||||
const s = q.trim();
|
||||
if (!s) return;
|
||||
nav(`/search?q=${encodeURIComponent(s)}`);
|
||||
nav(`/browse?q=${encodeURIComponent(s)}`);
|
||||
setOpen(false);
|
||||
setMobileSearchOpen(false);
|
||||
};
|
||||
@@ -545,14 +544,6 @@ export function PublicLayout() {
|
||||
>
|
||||
{t("footerAbout")}
|
||||
</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>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { MessageStream } from "../../components/messageStream/MessageStream";
|
||||
import { useI18n } from "../../i18n";
|
||||
|
||||
export function Browse() {
|
||||
const { t } = useI18n();
|
||||
const [sp] = useSearchParams();
|
||||
const q = sp.get("q") || "";
|
||||
return (
|
||||
<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]">
|
||||
{t("all")}
|
||||
{q ? `${t("search")}: ${q}` : t("all")}
|
||||
</h1>
|
||||
<MessageStream scope={{ kind: "all" }} />
|
||||
</section>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { RecommendedCard } from "../../components/RecommendedCard";
|
||||
import { SectionHeader } from "../../components/SectionHeader";
|
||||
import { langQuery, useI18n } from "../../i18n";
|
||||
import { sourceLanguageQuery } from "../../i18nLanguages";
|
||||
import { categoryCardLines } from "../../utils/categoryDisplay";
|
||||
import {
|
||||
postToResource,
|
||||
@@ -28,11 +29,14 @@ export function Home() {
|
||||
const [canScrollRec, setCanScrollRec] = useState(false);
|
||||
|
||||
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([
|
||||
getJSON<Category[]>(`/api/categories${q}`),
|
||||
getJSON<{ items: Post[] }>(`/api/posts/recommended${q}&limit=12`),
|
||||
getJSON<{ items: Post[] }>(`/api/posts/latest${q}&limit=8`),
|
||||
getJSON<Category[]>(`/api/categories${catQ}`),
|
||||
getJSON<{ items: Post[] }>(`/api/posts/recommended${postQ}&limit=12`),
|
||||
getJSON<{ items: Post[] }>(`/api/posts/latest${postQ}&limit=8`),
|
||||
])
|
||||
.then(([c, r, l]) => {
|
||||
setCats(itemsOrEmpty(c));
|
||||
|
||||
@@ -1,126 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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;
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
export function SearchPage() {
|
||||
const { t, lang } = useI18n();
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const q = sp.get("q") || "";
|
||||
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>
|
||||
);
|
||||
const [sp] = useSearchParams();
|
||||
const query = sp.toString();
|
||||
return <Navigate to={`/browse${query ? `?${query}` : ""}`} replace />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user