192 lines
6.2 KiB
TypeScript
192 lines
6.2 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { getJSON, itemsOrEmpty, postJSON, type Resource } from "../api";
|
|
import { ResourceCard } from "../components/ResourceCard";
|
|
import { ResourceListFooter } from "../components/ResourceListFooter";
|
|
import { useI18n } from "../i18n";
|
|
import { typeFilterLabel } from "../resourceTypeLabels";
|
|
|
|
const types = [
|
|
"all",
|
|
"image",
|
|
"video",
|
|
"ppt",
|
|
"pdf",
|
|
"text",
|
|
"link",
|
|
"archive",
|
|
] as const;
|
|
const resourceLangCodes = ["", "zh-TW", "zh-CN", "en"] as const;
|
|
|
|
function resourceLangLabel(t: (k: string) => string, code: string) {
|
|
if (!code) return t("filterLanguageAll");
|
|
if (code === "zh-TW") return t("lang_zh_TW");
|
|
if (code === "zh-CN") return t("lang_zh_CN");
|
|
return t("lang_en");
|
|
}
|
|
|
|
export function SearchPage() {
|
|
const { t, lang } = useI18n();
|
|
const [sp, setSp] = useSearchParams();
|
|
const q = sp.get("q") || "";
|
|
const sort = sp.get("sort") || "latest";
|
|
const type = sp.get("type") || "all";
|
|
const resourceLang = sp.get("language") || "";
|
|
const page = Math.max(1, parseInt(sp.get("page") || "1", 10) || 1);
|
|
const limit = 24;
|
|
|
|
const [items, setItems] = useState<Resource[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
const query = useMemo(() => {
|
|
const p = new URLSearchParams();
|
|
p.set("lang", lang);
|
|
p.set("limit", String(limit));
|
|
p.set("page", String(page));
|
|
p.set("sort", sort);
|
|
if (q) p.set("q", q);
|
|
if (type && type !== "all") p.set("type", type);
|
|
if (resourceLang) p.set("language", resourceLang);
|
|
return p.toString();
|
|
}, [lang, q, sort, type, resourceLang, page]);
|
|
|
|
useEffect(() => {
|
|
setErr(null);
|
|
if (!q) {
|
|
setItems([]);
|
|
setTotal(0);
|
|
return;
|
|
}
|
|
postJSON("/api/search-log", { query: q }).catch(() => {});
|
|
getJSON<{ items: Resource[]; total?: number }>(`/api/resources?${query}`)
|
|
.then((r) => {
|
|
setItems(itemsOrEmpty(r.items));
|
|
setTotal(typeof r.total === "number" ? r.total : 0);
|
|
})
|
|
.catch((e) => setErr(String(e)));
|
|
}, [query, q]);
|
|
|
|
const setPage = (next: number) => {
|
|
const n = new URLSearchParams(sp);
|
|
if (next <= 1) n.delete("page");
|
|
else n.set("page", String(next));
|
|
setSp(n);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-2xl font-bold">
|
|
{t("search")}: {q || "—"}
|
|
</h1>
|
|
|
|
{q ? (
|
|
<>
|
|
<div className="flex flex-wrap gap-2">
|
|
{(
|
|
[
|
|
["latest", t("latest")],
|
|
["recommended", t("official")],
|
|
["popular", t("popular")],
|
|
["published", t("sortPublished")],
|
|
] as const
|
|
).map(([k, label]) => (
|
|
<button
|
|
key={k}
|
|
type="button"
|
|
onClick={() => {
|
|
const n = new URLSearchParams(sp);
|
|
n.set("sort", k);
|
|
n.delete("page");
|
|
setSp(n);
|
|
}}
|
|
className={`rounded-full border px-3 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
|
sort === k
|
|
? "border-ark-gold text-ark-gold2"
|
|
: "border-ark-line"
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{types.map((tp) => (
|
|
<button
|
|
key={tp}
|
|
type="button"
|
|
onClick={() => {
|
|
const n = new URLSearchParams(sp);
|
|
n.delete("page");
|
|
if (tp === "all") n.delete("type");
|
|
else n.set("type", tp);
|
|
setSp(n);
|
|
}}
|
|
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
|
type === tp || (tp === "all" && !sp.get("type"))
|
|
? "border-ark-gold text-ark-gold2"
|
|
: "border-ark-line"
|
|
}`}
|
|
>
|
|
{typeFilterLabel(t, tp)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
|
{t("resourceLangFilter")}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{resourceLangCodes.map((code) => (
|
|
<button
|
|
key={code || "all"}
|
|
type="button"
|
|
onClick={() => {
|
|
const n = new URLSearchParams(sp);
|
|
n.delete("page");
|
|
if (!code) n.delete("language");
|
|
else n.set("language", code);
|
|
setSp(n);
|
|
}}
|
|
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
|
(code === "" && !resourceLang) || resourceLang === code
|
|
? "border-ark-gold text-ark-gold2"
|
|
: "border-ark-line"
|
|
}`}
|
|
>
|
|
{resourceLangLabel(t, code)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</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="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{items.map((r) => (
|
|
<ResourceCard key={r.id} r={r} />
|
|
))}
|
|
</div>
|
|
|
|
{q ? (
|
|
<ResourceListFooter
|
|
page={page}
|
|
limit={limit}
|
|
total={total}
|
|
t={t}
|
|
onPrev={() => setPage(page - 1)}
|
|
onNext={() => setPage(page + 1)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|