feat: add category and recommendations pages
This commit is contained in:
@@ -3,6 +3,8 @@ import { I18nProvider } from "./i18n";
|
|||||||
import { PublicLayout } from "./layouts/PublicLayout";
|
import { PublicLayout } from "./layouts/PublicLayout";
|
||||||
import { Home } from "./pages/Home";
|
import { Home } from "./pages/Home";
|
||||||
import { Browse } from "./pages/Browse";
|
import { Browse } from "./pages/Browse";
|
||||||
|
import { CategoriesPage } from "./pages/Categories";
|
||||||
|
import { OfficialRecommendationsPage } from "./pages/OfficialRecommendations";
|
||||||
import { SearchPage } from "./pages/Search";
|
import { SearchPage } from "./pages/Search";
|
||||||
import { PostRedirect } from "./pages/PostRedirect";
|
import { PostRedirect } from "./pages/PostRedirect";
|
||||||
import { AboutPage } from "./pages/About";
|
import { AboutPage } from "./pages/About";
|
||||||
@@ -26,6 +28,11 @@ export default function App() {
|
|||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/browse" element={<Browse />} />
|
<Route path="/browse" element={<Browse />} />
|
||||||
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
|
<Route
|
||||||
|
path="/official-recommendations"
|
||||||
|
element={<OfficialRecommendationsPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/category/:slug"
|
path="/category/:slug"
|
||||||
element={<Navigate to="/browse" replace />}
|
element={<Navigate to="/browse" replace />}
|
||||||
|
|||||||
114
src/pages/Categories/index.tsx
Normal file
114
src/pages/Categories/index.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
||||||
|
import { CategoryIcon } from "../../components/CategoryIcon";
|
||||||
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
||||||
|
|
||||||
|
const FIGMA_CATEGORY_ORDER = [
|
||||||
|
"project-ppt",
|
||||||
|
"daily-class",
|
||||||
|
"official-announcement",
|
||||||
|
"academy-materials",
|
||||||
|
"global-evangelism",
|
||||||
|
"daily-poster",
|
||||||
|
"community-tweets",
|
||||||
|
"video-hub",
|
||||||
|
"subsidy-policy",
|
||||||
|
"how-to",
|
||||||
|
"official-assets",
|
||||||
|
"media-coverage",
|
||||||
|
"academy-video",
|
||||||
|
"general",
|
||||||
|
];
|
||||||
|
|
||||||
|
function figmaCategoryRank(category: Category): number {
|
||||||
|
const index = FIGMA_CATEGORY_ORDER.indexOf(category.slug);
|
||||||
|
return index === -1 ? FIGMA_CATEGORY_ORDER.length : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoriesPage() {
|
||||||
|
const { t, lang } = useI18n();
|
||||||
|
const [cats, setCats] = useState<Category[]>([]);
|
||||||
|
const [unavailableOpen, setUnavailableOpen] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getJSON<Category[]>(
|
||||||
|
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`,
|
||||||
|
)
|
||||||
|
.then((items) =>
|
||||||
|
setCats(
|
||||||
|
itemsOrEmpty(items).sort(
|
||||||
|
(a, b) => figmaCategoryRank(a) - figmaCategoryRank(b),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((e) => setErr(String(e)));
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-red-900 bg-red-950/40 p-4 text-red-200">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title={t("categorySection")} />
|
||||||
|
<div className="mt-7 grid grid-cols-3 gap-2 md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
|
||||||
|
{cats.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnavailableOpen(true)}
|
||||||
|
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
|
>
|
||||||
|
<CategoryIcon
|
||||||
|
iconKey={category.iconKey}
|
||||||
|
categorySlug={category.slug}
|
||||||
|
className="h-9 w-9 shrink-0 text-ark-gold"
|
||||||
|
/>
|
||||||
|
<div className="w-full text-center text-[13px] font-medium leading-[19.5px] text-white line-clamp-2">
|
||||||
|
{cleanCategoryDisplayName(category.name)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unavailableOpen ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 px-6 backdrop-blur-sm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="category-unavailable-title"
|
||||||
|
onClick={() => setUnavailableOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-[320px] rounded-2xl border border-[#27292E] bg-[#1D1E23] p-5 text-center shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="category-unavailable-title"
|
||||||
|
className="text-xl font-bold text-white"
|
||||||
|
>
|
||||||
|
{t("featureUnavailable")}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#A8A9AE]">
|
||||||
|
{t("featureUnavailableDesc")}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnavailableOpen(false)}
|
||||||
|
className="mt-5 h-10 w-full rounded-full bg-ark-gold text-sm font-semibold text-black transition hover:bg-ark-gold2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1D1E23]"
|
||||||
|
>
|
||||||
|
{t("confirm")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/pages/OfficialRecommendations/index.tsx
Normal file
61
src/pages/OfficialRecommendations/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
||||||
|
import { RecommendedCard } from "../../components/RecommendedCard";
|
||||||
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import { sourceLanguageQuery } from "../../i18nLanguages";
|
||||||
|
import type { Post } from "../../types/post";
|
||||||
|
import {
|
||||||
|
postToResource,
|
||||||
|
type PostBackedResource,
|
||||||
|
} from "../../utils/postResourceAdapter";
|
||||||
|
|
||||||
|
export function OfficialRecommendationsPage() {
|
||||||
|
const { t, lang } = useI18n();
|
||||||
|
const [items, setItems] = useState<PostBackedResource[]>([]);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const langParam = encodeURIComponent(langQuery(lang));
|
||||||
|
const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
|
||||||
|
Promise.all([
|
||||||
|
getJSON<Category[]>(`/api/categories?lang=${langParam}`),
|
||||||
|
getJSON<{ items: Post[] }>(
|
||||||
|
`/api/posts/recommended?lang=${langParam}&language=${languageParam}&limit=100`,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(([categories, recommended]) => {
|
||||||
|
const cats = itemsOrEmpty(categories);
|
||||||
|
setItems(
|
||||||
|
itemsOrEmpty(recommended.items).map((post) =>
|
||||||
|
postToResource(post, lang, cats),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => setErr(String(e)));
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-red-900 bg-red-950/40 p-4 text-red-200">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title={t("officialSection")} />
|
||||||
|
<div className="mt-7 grid grid-cols-[repeat(auto-fill,208px)] justify-center gap-3 md:grid-cols-[repeat(auto-fill,240px)] md:justify-start md:gap-4 lg:grid-cols-[repeat(auto-fill,246.4px)]">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<RecommendedCard
|
||||||
|
key={item.id}
|
||||||
|
r={item}
|
||||||
|
visualIndex={index}
|
||||||
|
useFigmaDesign
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user