feat: unify search with browse page
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user