157 lines
5.1 KiB
TypeScript
157 lines
5.1 KiB
TypeScript
|
|
import { useEffect, useMemo, useState } from "react";
|
||
|
|
import { useParams, useSearchParams } from "react-router-dom";
|
||
|
|
import { getJSON, itemsOrEmpty, type Category, type Resource } from "../api";
|
||
|
|
import { ResourceCard } from "../components/ResourceCard";
|
||
|
|
import { ResourceListFooter } from "../components/ResourceListFooter";
|
||
|
|
import { useI18n } from "../i18n";
|
||
|
|
import { typeFilterLabel } from "../resourceTypeLabels";
|
||
|
|
|
||
|
|
const TYPE_FILTERS = [
|
||
|
|
"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 CategoryPage() {
|
||
|
|
const { slug } = useParams();
|
||
|
|
const { t, lang } = useI18n();
|
||
|
|
const [sp, setSp] = useSearchParams();
|
||
|
|
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 [categoryTitle, setCategoryTitle] = useState<string>("");
|
||
|
|
const [err, setErr] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const query = useMemo(() => {
|
||
|
|
const p = new URLSearchParams();
|
||
|
|
p.set("lang", lang);
|
||
|
|
p.set("category", slug || "");
|
||
|
|
p.set("limit", String(limit));
|
||
|
|
p.set("page", String(page));
|
||
|
|
if (type !== "all") p.set("type", type);
|
||
|
|
if (resourceLang) p.set("language", resourceLang);
|
||
|
|
return p.toString();
|
||
|
|
}, [lang, slug, type, resourceLang, page]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!slug) return;
|
||
|
|
setErr(null);
|
||
|
|
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, slug]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!slug) return;
|
||
|
|
const langQ = encodeURIComponent(lang);
|
||
|
|
getJSON<Category[]>(`/api/categories?lang=${langQ}`)
|
||
|
|
.then((cats) => {
|
||
|
|
const c = itemsOrEmpty(cats).find((x) => x.slug === slug);
|
||
|
|
setCategoryTitle(c?.name ?? slug);
|
||
|
|
})
|
||
|
|
.catch(() => setCategoryTitle(slug));
|
||
|
|
}, [slug, lang]);
|
||
|
|
|
||
|
|
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">{categoryTitle || slug}</h1>
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
{TYPE_FILTERS.map((tp) => (
|
||
|
|
<button
|
||
|
|
key={tp}
|
||
|
|
type="button"
|
||
|
|
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"
|
||
|
|
}`}
|
||
|
|
onClick={() => {
|
||
|
|
const n = new URLSearchParams(sp);
|
||
|
|
n.delete("page");
|
||
|
|
if (tp === "all") n.delete("type");
|
||
|
|
else n.set("type", tp);
|
||
|
|
setSp(n);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{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>
|
||
|
|
|
||
|
|
{err ? <div className="text-red-300">{err}</div> : null}
|
||
|
|
{!err && items.length === 0 ? (
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<ResourceListFooter
|
||
|
|
page={page}
|
||
|
|
limit={limit}
|
||
|
|
total={total}
|
||
|
|
t={t}
|
||
|
|
onPrev={() => setPage(page - 1)}
|
||
|
|
onNext={() => setPage(page + 1)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|