Initial frontend import
This commit is contained in:
156
src/pages/CategoryPage.tsx
Normal file
156
src/pages/CategoryPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user