524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { useLocation } from "react-router-dom";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
|
import { CategoryIcon } from "../../components/CategoryIcon";
|
|
import { FigmaBanner } from "../../components/FigmaBanner";
|
|
import {
|
|
ComingSoonLatestUpdateRow,
|
|
LatestUpdateRow,
|
|
} from "../../components/LatestUpdateRow";
|
|
import { RecommendedCard } from "../../components/RecommendedCard";
|
|
import { SectionHeader } from "../../components/SectionHeader";
|
|
import { MessageBubble } from "../../components/messageStream/MessageBubble";
|
|
import { langQuery, useI18n } from "../../i18n";
|
|
import { sourceLanguageQuery } from "../../i18nLanguages";
|
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
|
import {
|
|
postToResource,
|
|
type PostBackedResource,
|
|
} from "../../utils/postResourceAdapter";
|
|
import type { Post } from "../../types/post";
|
|
|
|
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 Home() {
|
|
const { t, lang } = useI18n();
|
|
const { hash } = useLocation();
|
|
const [cats, setCats] = useState<Category[]>([]);
|
|
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
|
const [latest, setLatest] = useState<PostBackedResource[]>([]);
|
|
const [latestPosts, setLatestPosts] = useState<Post[]>([]);
|
|
const [popular, setPopular] = useState<PostBackedResource[]>([]);
|
|
const [popularPosts, setPopularPosts] = useState<Post[]>([]);
|
|
const [categoryUnavailableOpen, setCategoryUnavailableOpen] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const recRowRef = useRef<HTMLDivElement>(null);
|
|
const latestRowRef = useRef<HTMLDivElement>(null);
|
|
const categoryRowRef = useRef<HTMLDivElement>(null);
|
|
const [activeCategoryPage, setActiveCategoryPage] = useState(0);
|
|
const [canScrollRec, setCanScrollRec] = useState(false);
|
|
const [canScrollLatest, setCanScrollLatest] = useState(false);
|
|
const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 });
|
|
|
|
useEffect(() => {
|
|
const langParam = encodeURIComponent(langQuery(lang));
|
|
const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
|
|
const catQ = `?lang=${langParam}`;
|
|
const postQ = `?lang=${langParam}&language=${languageParam}`;
|
|
Promise.all([
|
|
getJSON<Category[]>(`/api/categories${catQ}`),
|
|
getJSON<{ items: Post[] }>(`/api/posts/recommended${postQ}&limit=12`),
|
|
getJSON<{ items: Post[] }>(`/api/posts${postQ}&sort=latest&limit=12`),
|
|
getJSON<{ items: Post[] }>(
|
|
`/api/posts${postQ}&sort=popular&limit=5`,
|
|
).catch((): { items: Post[] } => ({ items: [] })),
|
|
])
|
|
.then(([c, r, l, p]) => {
|
|
setCats(itemsOrEmpty(c));
|
|
setRec(
|
|
itemsOrEmpty(r.items).map((post) =>
|
|
postToResource(post, lang, itemsOrEmpty(c)),
|
|
),
|
|
);
|
|
const latestItems = itemsOrEmpty(l.items);
|
|
setLatestPosts(latestItems);
|
|
setLatest(
|
|
latestItems.map((post) =>
|
|
postToResource(post, lang, itemsOrEmpty(c)),
|
|
),
|
|
);
|
|
const popularItems = itemsOrEmpty<Post>(p.items);
|
|
setPopularPosts(popularItems);
|
|
setPopular(
|
|
popularItems.map((post) =>
|
|
postToResource(post, lang, itemsOrEmpty(c)),
|
|
),
|
|
);
|
|
})
|
|
.catch((e) => setErr(String(e)));
|
|
}, [lang]);
|
|
|
|
const iconKeyForResource = (r: PostBackedResource) =>
|
|
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
|
|
|
|
const figmaOrderedCategories = [...cats].sort(
|
|
(a, b) => figmaCategoryRank(a) - figmaCategoryRank(b),
|
|
);
|
|
|
|
const categoryPages: Category[][] = [];
|
|
for (let index = 0; index < figmaOrderedCategories.length; index += 9) {
|
|
categoryPages.push(figmaOrderedCategories.slice(index, index + 9));
|
|
}
|
|
const activeCategoryCount = categoryPages[activeCategoryPage]?.length ?? 0;
|
|
const activeCategoryRows = Math.ceil(activeCategoryCount / 3);
|
|
const mobileCategoryHeight =
|
|
activeCategoryRows * 88 + Math.max(0, activeCategoryRows - 1) * 8;
|
|
|
|
useEffect(() => {
|
|
const row = categoryRowRef.current;
|
|
if (!row) return;
|
|
|
|
const update = () => {
|
|
const width = row.clientWidth || 1;
|
|
const next = Math.round(row.scrollLeft / width);
|
|
setActiveCategoryPage((prev) => (prev === next ? prev : next));
|
|
};
|
|
|
|
update();
|
|
row.addEventListener("scroll", update, { passive: true });
|
|
return () => row.removeEventListener("scroll", update);
|
|
}, [cats.length]);
|
|
|
|
useEffect(() => {
|
|
setActiveCategoryPage(0);
|
|
categoryRowRef.current?.scrollTo({ left: 0 });
|
|
}, [lang]);
|
|
|
|
useEffect(() => {
|
|
const row = recRowRef.current;
|
|
if (!row) {
|
|
setCanScrollRec(false);
|
|
return;
|
|
}
|
|
|
|
const update = () => {
|
|
const overflow = row.scrollWidth > row.clientWidth + 1;
|
|
setCanScrollRec(overflow);
|
|
const ratio = overflow ? row.clientWidth / row.scrollWidth : 1;
|
|
const maxScroll = Math.max(1, row.scrollWidth - row.clientWidth);
|
|
const progress = overflow ? row.scrollLeft / maxScroll : 0;
|
|
setRecScroll({
|
|
ratio: Math.min(1, Math.max(0.15, ratio)),
|
|
progress: Math.min(1, Math.max(0, progress)),
|
|
});
|
|
};
|
|
|
|
update();
|
|
const resizeObserver = new ResizeObserver(update);
|
|
resizeObserver.observe(row);
|
|
row.addEventListener("scroll", update, { passive: true });
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
row.removeEventListener("scroll", update);
|
|
};
|
|
}, [rec.length]);
|
|
|
|
const scrollRec = (dir: 1 | -1) => {
|
|
recRowRef.current?.scrollBy({ left: dir * 280, behavior: "smooth" });
|
|
};
|
|
|
|
useEffect(() => {
|
|
const row = latestRowRef.current;
|
|
if (!row) {
|
|
setCanScrollLatest(false);
|
|
return;
|
|
}
|
|
|
|
const update = () => {
|
|
setCanScrollLatest(row.scrollWidth > row.clientWidth + 1);
|
|
};
|
|
|
|
update();
|
|
const resizeObserver = new ResizeObserver(update);
|
|
resizeObserver.observe(row);
|
|
row.addEventListener("scroll", update, { passive: true });
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
row.removeEventListener("scroll", update);
|
|
};
|
|
}, [latest.length]);
|
|
|
|
const scrollLatest = (dir: 1 | -1) => {
|
|
latestRowRef.current?.scrollBy({ left: dir * 360, behavior: "smooth" });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!hash) return;
|
|
const id = hash.slice(1);
|
|
if (!id) return;
|
|
const frame = window.requestAnimationFrame(() => {
|
|
document.getElementById(id)?.scrollIntoView({
|
|
block: id === "latest" ? "center" : "start",
|
|
});
|
|
});
|
|
return () => window.cancelAnimationFrame(frame);
|
|
}, [hash, cats.length, rec.length, latest.length, popular.length]);
|
|
|
|
const latestPlaceholderCount = Math.max(0, 5 - latest.length);
|
|
const hasPopular = popular.length > 0 || popularPosts.length > 0;
|
|
const popularPlaceholderCount = Math.max(0, 5 - popular.length);
|
|
const recommendedDotCount = rec.length;
|
|
const activeRecommendedDot =
|
|
recommendedDotCount > 0
|
|
? Math.min(
|
|
recommendedDotCount - 1,
|
|
Math.round(recScroll.progress * (recommendedDotCount - 1)),
|
|
)
|
|
: 0;
|
|
|
|
if (err) {
|
|
return (
|
|
<div className="mt-6 rounded-xl border border-red-900 bg-red-950/40 p-4 text-red-200 md:mt-0">
|
|
{err}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="pb-4 md:space-y-10 md:pb-16 xl:space-y-[34px]">
|
|
<section className="md:mt-0">
|
|
<FigmaBanner />
|
|
</section>
|
|
|
|
<section id="categories" className="scroll-mt-16 md:scroll-mt-24">
|
|
<div className="px-4 md:px-0">
|
|
<SectionHeader
|
|
title={t("categorySection")}
|
|
viewAllTo="/categories"
|
|
viewAllLabel={t("viewAll")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="md:hidden">
|
|
<div
|
|
ref={categoryRowRef}
|
|
className="flex snap-x snap-mandatory items-start overflow-x-auto overflow-y-hidden scroll-smooth transition-[height] duration-300 ease-out motion-reduce:transition-none [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
style={{ height: `${mobileCategoryHeight}px` }}
|
|
aria-label={t("categorySection")}
|
|
>
|
|
{categoryPages.map((page, pageIndex) => (
|
|
<div
|
|
key={`category-page-${pageIndex}`}
|
|
className="grid w-full shrink-0 snap-start grid-cols-3 gap-2 px-4"
|
|
>
|
|
{page.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() => setCategoryUnavailableOpen(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={c.iconKey}
|
|
categorySlug={c.slug}
|
|
className="h-9 w-9 shrink-0 text-ark-gold"
|
|
/>
|
|
<div className="w-full truncate text-[13px] font-medium leading-[19.5px] text-white">
|
|
{cleanCategoryDisplayName(c.name)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{categoryPages.length > 1 ? (
|
|
<div
|
|
className="flex h-[30px] items-center justify-center gap-1.5"
|
|
aria-label="Category pagination"
|
|
>
|
|
{categoryPages.map((_, index) => (
|
|
<button
|
|
key={`category-dot-${index}`}
|
|
type="button"
|
|
aria-label={`Go to category page ${index + 1}`}
|
|
aria-current={activeCategoryPage === index}
|
|
onClick={() => {
|
|
const row = categoryRowRef.current;
|
|
if (!row) return;
|
|
row.scrollTo({
|
|
left: row.clientWidth * index,
|
|
behavior: "smooth",
|
|
});
|
|
setActiveCategoryPage(index);
|
|
}}
|
|
className={`h-1.5 rounded-full transition-all ${
|
|
activeCategoryPage === index
|
|
? "w-6 bg-ark-gold"
|
|
: "w-1.5 bg-[#7C7C7C]"
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-7 hidden grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
|
|
{figmaOrderedCategories.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() => setCategoryUnavailableOpen(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={c.iconKey}
|
|
categorySlug={c.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(c.name)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section id="official" className="scroll-mt-16 md:scroll-mt-24">
|
|
<div className="px-4 md:px-0">
|
|
<SectionHeader
|
|
title={t("officialSection")}
|
|
viewAllTo="/official-recommendations"
|
|
viewAllLabel={t("viewAll")}
|
|
/>
|
|
</div>
|
|
<div className="relative">
|
|
<div
|
|
ref={recRowRef}
|
|
className="flex gap-3 overflow-x-auto overflow-y-hidden px-4 pb-0 pr-4 scroll-smooth [-ms-overflow-style:none] [scrollbar-width:none] md:mt-7 md:gap-4 md:px-0 md:pr-0 md:pb-5 [&::-webkit-scrollbar]:hidden"
|
|
>
|
|
{rec.map((r, index) => (
|
|
<div key={r.id}>
|
|
<RecommendedCard r={r} visualIndex={index} useFigmaDesign />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div
|
|
className="flex h-[30px] items-center justify-center gap-1.5 md:hidden"
|
|
aria-label="Recommended pagination"
|
|
>
|
|
{Array.from({ length: recommendedDotCount }).map((_, index) => (
|
|
<button
|
|
key={`recommended-dot-${index}`}
|
|
type="button"
|
|
aria-label={`Go to recommendation page ${index + 1}`}
|
|
aria-current={activeRecommendedDot === index}
|
|
onClick={() => {
|
|
const row = recRowRef.current;
|
|
if (!row) return;
|
|
const maxScroll = Math.max(
|
|
0,
|
|
row.scrollWidth - row.clientWidth,
|
|
);
|
|
row.scrollTo({
|
|
left:
|
|
recommendedDotCount === 1
|
|
? 0
|
|
: (maxScroll * index) / (recommendedDotCount - 1),
|
|
behavior: "smooth",
|
|
});
|
|
}}
|
|
className={`h-1.5 rounded-full transition-all ${
|
|
activeRecommendedDot === index
|
|
? "w-6 bg-ark-gold"
|
|
: "w-1.5 bg-[#7C7C7C]"
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
{canScrollRec ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollRec(-1)}
|
|
className="absolute left-0 top-[45%] hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-lg border border-ark-line bg-[#292a31]/95 text-neutral-200 shadow-lg backdrop-blur transition hover:border-ark-gold hover:text-ark-gold md:flex"
|
|
aria-label="Previous recommendations"
|
|
>
|
|
<ChevronLeft className="h-5 w-5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollRec(1)}
|
|
className="absolute right-0 top-[45%] hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-lg border border-ark-line bg-[#292a31]/95 text-neutral-200 shadow-lg backdrop-blur transition hover:border-ark-gold hover:text-ark-gold md:flex"
|
|
aria-label="Next recommendations"
|
|
>
|
|
<ChevronRight className="h-5 w-5" />
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<section id="latest" className="scroll-mt-16 md:scroll-mt-24">
|
|
<div className="px-4 md:px-0">
|
|
<SectionHeader
|
|
title={t("latestSection")}
|
|
viewAllTo="/browse"
|
|
viewAllLabel={t("viewAll")}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-3 md:hidden">
|
|
{latestPosts.slice(0, 5).map((post) => (
|
|
<MessageBubble key={post.id} post={post} />
|
|
))}
|
|
</div>
|
|
<div className="relative hidden md:block">
|
|
<div
|
|
ref={latestRowRef}
|
|
className="mt-7 flex gap-4 overflow-x-auto overflow-y-hidden pb-5 scroll-smooth [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
>
|
|
{latest.map((r) => (
|
|
<div key={r.id} className="w-[340px] shrink-0 xl:w-[352px]">
|
|
<LatestUpdateRow r={r} iconKey={iconKeyForResource(r)} />
|
|
</div>
|
|
))}
|
|
{Array.from({ length: latestPlaceholderCount }).map((_, index) => (
|
|
<div
|
|
key={`latest-coming-soon-${index}`}
|
|
className="w-[340px] shrink-0 xl:w-[352px]"
|
|
>
|
|
<ComingSoonLatestUpdateRow index={latest.length + index} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
{canScrollLatest ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollLatest(-1)}
|
|
className="absolute left-0 top-[45%] hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-lg border border-ark-line bg-[#292a31]/95 text-neutral-200 shadow-lg backdrop-blur transition hover:border-ark-gold hover:text-ark-gold md:flex"
|
|
aria-label="Previous latest updates"
|
|
>
|
|
<ChevronLeft className="h-5 w-5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollLatest(1)}
|
|
className="absolute right-0 top-[45%] hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-lg border border-ark-line bg-[#292a31]/95 text-neutral-200 shadow-lg backdrop-blur transition hover:border-ark-gold hover:text-ark-gold md:flex"
|
|
aria-label="Next latest updates"
|
|
>
|
|
<ChevronRight className="h-5 w-5" />
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
{hasPopular ? (
|
|
<section id="popular" className="scroll-mt-16 md:scroll-mt-24">
|
|
<div className="px-4 md:px-0">
|
|
<SectionHeader
|
|
title={t("popularSection")}
|
|
viewAllTo="/browse?sort=popular"
|
|
viewAllLabel={t("viewAll")}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-3 md:hidden">
|
|
{popularPosts.slice(0, 5).map((post) => (
|
|
<MessageBubble key={post.id} post={post} />
|
|
))}
|
|
</div>
|
|
<div className="mt-7 hidden grid-cols-1 gap-3 min-[576px]:grid-cols-2 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3 xl:grid-cols-5">
|
|
{popular.map((r) => (
|
|
<LatestUpdateRow
|
|
key={r.id}
|
|
r={r}
|
|
iconKey={iconKeyForResource(r)}
|
|
/>
|
|
))}
|
|
{Array.from({ length: popularPlaceholderCount }).map((_, index) => (
|
|
<ComingSoonLatestUpdateRow
|
|
key={`popular-coming-soon-${index}`}
|
|
index={popular.length + index}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{categoryUnavailableOpen ? (
|
|
<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={() => setCategoryUnavailableOpen(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={() => setCategoryUnavailableOpen(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}
|
|
</div>
|
|
);
|
|
}
|