2026-05-28 18:28:28 +08:00
|
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
2026-05-28 22:36:08 +08:00
|
|
|
import { Link, useLocation } from "react-router-dom";
|
2026-05-16 00:18:22 +08:00
|
|
|
import { useEffect, useRef, useState } from "react";
|
2026-05-28 23:09:18 +08:00
|
|
|
import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
|
2026-05-26 14:46:05 +08:00
|
|
|
import { CategoryIcon } from "../../components/CategoryIcon";
|
|
|
|
|
import { FigmaBanner } from "../../components/FigmaBanner";
|
2026-05-29 14:53:52 +08:00
|
|
|
import { PopularRankList } from "../../components/PopularRankList";
|
2026-05-26 14:46:05 +08:00
|
|
|
import { RecommendedCard } from "../../components/RecommendedCard";
|
|
|
|
|
import { SectionHeader } from "../../components/SectionHeader";
|
2026-05-28 15:31:45 +08:00
|
|
|
import { MessageBubble } from "../../components/messageStream/MessageBubble";
|
2026-05-26 14:46:05 +08:00
|
|
|
import { langQuery, useI18n } from "../../i18n";
|
2026-06-01 16:35:40 +08:00
|
|
|
import { useLocalizedPath } from "../../useLocalizedPath";
|
2026-05-27 11:33:48 +08:00
|
|
|
import { sourceLanguageQuery } from "../../i18nLanguages";
|
2026-05-28 15:49:08 +08:00
|
|
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
2026-05-26 12:07:13 +08:00
|
|
|
import {
|
|
|
|
|
postToResource,
|
|
|
|
|
type PostBackedResource,
|
2026-05-26 14:46:05 +08:00
|
|
|
} from "../../utils/postResourceAdapter";
|
|
|
|
|
import type { Post } from "../../types/post";
|
2026-05-29 11:50:27 +08:00
|
|
|
import { Reveal } from "../../motion";
|
2026-05-16 00:18:22 +08:00
|
|
|
|
2026-05-28 15:49:08 +08:00
|
|
|
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",
|
|
|
|
|
];
|
2026-05-28 15:36:24 +08:00
|
|
|
|
|
|
|
|
function figmaCategoryRank(category: Category): number {
|
|
|
|
|
const index = FIGMA_CATEGORY_ORDER.indexOf(category.slug);
|
|
|
|
|
return index === -1 ? FIGMA_CATEGORY_ORDER.length : index;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:18:22 +08:00
|
|
|
export function Home() {
|
|
|
|
|
const { t, lang } = useI18n();
|
2026-06-01 16:35:40 +08:00
|
|
|
const lp = useLocalizedPath();
|
2026-05-28 15:55:37 +08:00
|
|
|
const { hash } = useLocation();
|
2026-05-16 00:18:22 +08:00
|
|
|
const [cats, setCats] = useState<Category[]>([]);
|
2026-05-26 12:07:13 +08:00
|
|
|
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
2026-05-28 15:31:45 +08:00
|
|
|
const [latestPosts, setLatestPosts] = useState<Post[]>([]);
|
2026-05-28 15:55:37 +08:00
|
|
|
const [popular, setPopular] = useState<PostBackedResource[]>([]);
|
|
|
|
|
const [popularPosts, setPopularPosts] = useState<Post[]>([]);
|
2026-05-16 00:18:22 +08:00
|
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
|
const recRowRef = useRef<HTMLDivElement>(null);
|
2026-05-28 15:11:13 +08:00
|
|
|
const categoryRowRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const [activeCategoryPage, setActiveCategoryPage] = useState(0);
|
2026-05-19 00:34:29 +08:00
|
|
|
const [canScrollRec, setCanScrollRec] = useState(false);
|
2026-05-28 09:16:32 +08:00
|
|
|
const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 });
|
2026-05-16 00:18:22 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-28 23:09:18 +08:00
|
|
|
let cancelled = false;
|
2026-05-27 11:33:48 +08:00
|
|
|
const langParam = encodeURIComponent(langQuery(lang));
|
|
|
|
|
const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
|
|
|
|
|
const catQ = `?lang=${langParam}`;
|
|
|
|
|
const postQ = `?lang=${langParam}&language=${languageParam}`;
|
2026-05-28 23:09:18 +08:00
|
|
|
const categoriesUrl = `/api/categories${catQ}`;
|
|
|
|
|
const recommendedUrl = `/api/posts/recommended${postQ}&limit=12`;
|
|
|
|
|
const latestUrl = `/api/posts${postQ}&sort=latest&limit=12`;
|
|
|
|
|
const popularUrl = `/api/posts${postQ}&sort=popular&limit=5`;
|
|
|
|
|
|
|
|
|
|
const applyHomeData = (
|
|
|
|
|
c: Category[],
|
|
|
|
|
r: { items: Post[] },
|
|
|
|
|
l: { items: Post[] },
|
|
|
|
|
p: { items: Post[] },
|
|
|
|
|
) => {
|
|
|
|
|
const categoryItems = itemsOrEmpty(c);
|
|
|
|
|
setCats(categoryItems);
|
|
|
|
|
setRec(
|
|
|
|
|
itemsOrEmpty(r.items).map((post) =>
|
|
|
|
|
postToResource(post, lang, categoryItems),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
const latestItems = itemsOrEmpty(l.items);
|
|
|
|
|
setLatestPosts(latestItems);
|
|
|
|
|
const popularItems = itemsOrEmpty<Post>(p.items);
|
|
|
|
|
setPopularPosts(popularItems);
|
|
|
|
|
setPopular(
|
|
|
|
|
popularItems.map((post) => postToResource(post, lang, categoryItems)),
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setErr(null);
|
|
|
|
|
const cachedCategories = readJSONCache<Category[]>(categoriesUrl);
|
|
|
|
|
const cachedRecommended = readJSONCache<{ items: Post[] }>(recommendedUrl);
|
|
|
|
|
const cachedLatest = readJSONCache<{ items: Post[] }>(latestUrl);
|
|
|
|
|
const cachedPopular = readJSONCache<{ items: Post[] }>(popularUrl);
|
|
|
|
|
const showedCached = !!(
|
|
|
|
|
cachedCategories &&
|
|
|
|
|
cachedRecommended &&
|
|
|
|
|
cachedLatest
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (showedCached) {
|
|
|
|
|
applyHomeData(
|
|
|
|
|
cachedCategories,
|
|
|
|
|
cachedRecommended,
|
|
|
|
|
cachedLatest,
|
|
|
|
|
cachedPopular ?? { items: [] },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:18:22 +08:00
|
|
|
Promise.all([
|
2026-05-28 23:09:18 +08:00
|
|
|
getJSON<Category[]>(categoriesUrl),
|
|
|
|
|
getJSON<{ items: Post[] }>(recommendedUrl),
|
|
|
|
|
getJSON<{ items: Post[] }>(latestUrl),
|
|
|
|
|
getJSON<{ items: Post[] }>(popularUrl).catch(
|
|
|
|
|
(): { items: Post[] } => cachedPopular ?? { items: [] },
|
|
|
|
|
),
|
2026-05-16 00:18:22 +08:00
|
|
|
])
|
2026-05-28 15:55:37 +08:00
|
|
|
.then(([c, r, l, p]) => {
|
2026-05-28 23:09:18 +08:00
|
|
|
if (cancelled) return;
|
|
|
|
|
applyHomeData(c, r, l, p);
|
2026-05-16 00:18:22 +08:00
|
|
|
})
|
2026-05-28 23:09:18 +08:00
|
|
|
.catch((e) => {
|
|
|
|
|
if (!cancelled && !showedCached) setErr(String(e));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2026-05-16 00:18:22 +08:00
|
|
|
}, [lang]);
|
|
|
|
|
|
2026-05-28 15:36:24 +08:00
|
|
|
const figmaOrderedCategories = [...cats].sort(
|
|
|
|
|
(a, b) => figmaCategoryRank(a) - figmaCategoryRank(b),
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-28 15:11:13 +08:00
|
|
|
const categoryPages: Category[][] = [];
|
2026-05-28 15:36:24 +08:00
|
|
|
for (let index = 0; index < figmaOrderedCategories.length; index += 9) {
|
|
|
|
|
categoryPages.push(figmaOrderedCategories.slice(index, index + 9));
|
2026-05-28 15:11:13 +08:00
|
|
|
}
|
2026-05-30 00:43:54 +08:00
|
|
|
// Use the tallest page so the carousel height doesn't shrink between
|
|
|
|
|
// pages — otherwise the section below jumps up when swiping to a page
|
|
|
|
|
// with fewer categories.
|
|
|
|
|
const maxCategoryRows = categoryPages.reduce(
|
|
|
|
|
(max, page) => Math.max(max, Math.ceil(page.length / 3)),
|
|
|
|
|
0,
|
|
|
|
|
);
|
2026-05-28 15:31:45 +08:00
|
|
|
const mobileCategoryHeight =
|
2026-05-30 00:43:54 +08:00
|
|
|
maxCategoryRows * 88 + Math.max(0, maxCategoryRows - 1) * 8;
|
2026-05-28 15:11:13 +08:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-05-19 00:34:29 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
const row = recRowRef.current;
|
|
|
|
|
if (!row) {
|
|
|
|
|
setCanScrollRec(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 09:16:32 +08:00
|
|
|
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)),
|
|
|
|
|
});
|
2026-05-19 00:34:29 +08:00
|
|
|
};
|
|
|
|
|
|
2026-05-28 09:16:32 +08:00
|
|
|
update();
|
|
|
|
|
const resizeObserver = new ResizeObserver(update);
|
2026-05-19 00:34:29 +08:00
|
|
|
resizeObserver.observe(row);
|
2026-05-28 09:16:32 +08:00
|
|
|
row.addEventListener("scroll", update, { passive: true });
|
|
|
|
|
return () => {
|
|
|
|
|
resizeObserver.disconnect();
|
|
|
|
|
row.removeEventListener("scroll", update);
|
|
|
|
|
};
|
2026-05-19 00:34:29 +08:00
|
|
|
}, [rec.length]);
|
|
|
|
|
|
2026-05-31 02:32:15 +08:00
|
|
|
// Reveal one more card per click, fully, inside the arrow "lanes" (the left
|
|
|
|
|
// and right padding the arrows float in) so the edge card is never half-shown
|
|
|
|
|
// or tucked under an arrow.
|
|
|
|
|
// - Right arrow: bring the first card clipped on the right so its RIGHT edge
|
|
|
|
|
// rests just left of the right arrow.
|
|
|
|
|
// - Left arrow: bring the last card clipped on the left so its LEFT edge
|
|
|
|
|
// rests just right of the left arrow.
|
2026-05-16 00:18:22 +08:00
|
|
|
const scrollRec = (dir: 1 | -1) => {
|
2026-05-31 02:32:15 +08:00
|
|
|
const row = recRowRef.current;
|
|
|
|
|
if (!row) return;
|
|
|
|
|
const children = Array.from(row.children) as HTMLElement[];
|
|
|
|
|
if (children.length === 0) return;
|
|
|
|
|
const rowLeft = row.getBoundingClientRect().left;
|
|
|
|
|
const style = getComputedStyle(row);
|
|
|
|
|
const padLeft = parseFloat(style.paddingLeft) || 0;
|
|
|
|
|
const padRight = parseFloat(style.paddingRight) || 0;
|
|
|
|
|
const epsilon = 2;
|
|
|
|
|
let delta: number;
|
|
|
|
|
if (dir === 1) {
|
|
|
|
|
const laneRight = row.clientWidth - padRight;
|
|
|
|
|
const next = children
|
|
|
|
|
.map((c) => c.getBoundingClientRect().right - rowLeft)
|
|
|
|
|
.find((right) => right > laneRight + epsilon);
|
|
|
|
|
delta = next !== undefined ? next - laneRight : 0;
|
|
|
|
|
} else {
|
|
|
|
|
const lefts = children
|
|
|
|
|
.map((c) => c.getBoundingClientRect().left - rowLeft)
|
|
|
|
|
.filter((left) => left < padLeft - epsilon);
|
2026-05-31 02:56:34 +08:00
|
|
|
delta = lefts.length
|
|
|
|
|
? lefts[lefts.length - 1] - padLeft
|
|
|
|
|
: -row.scrollLeft;
|
2026-05-31 02:32:15 +08:00
|
|
|
}
|
|
|
|
|
const maxScroll = Math.max(0, row.scrollWidth - row.clientWidth);
|
|
|
|
|
const target = Math.max(0, Math.min(maxScroll, row.scrollLeft + delta));
|
|
|
|
|
row.scrollTo({ left: target, behavior: "smooth" });
|
2026-05-16 00:18:22 +08:00
|
|
|
};
|
|
|
|
|
|
2026-05-28 15:55:37 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!hash) return;
|
|
|
|
|
const id = hash.slice(1);
|
|
|
|
|
if (!id) return;
|
|
|
|
|
const frame = window.requestAnimationFrame(() => {
|
2026-05-28 16:07:08 +08:00
|
|
|
document.getElementById(id)?.scrollIntoView({
|
|
|
|
|
block: id === "latest" ? "center" : "start",
|
|
|
|
|
});
|
2026-05-28 15:55:37 +08:00
|
|
|
});
|
|
|
|
|
return () => window.cancelAnimationFrame(frame);
|
2026-05-29 17:49:58 +08:00
|
|
|
}, [hash, cats.length, rec.length, latestPosts.length, popular.length]);
|
2026-05-28 15:55:37 +08:00
|
|
|
|
2026-05-28 17:40:35 +08:00
|
|
|
const hasPopular = popular.length > 0 || popularPosts.length > 0;
|
2026-05-28 16:07:08 +08:00
|
|
|
const recommendedDotCount = rec.length;
|
|
|
|
|
const activeRecommendedDot =
|
|
|
|
|
recommendedDotCount > 0
|
|
|
|
|
? Math.min(
|
|
|
|
|
recommendedDotCount - 1,
|
|
|
|
|
Math.round(recScroll.progress * (recommendedDotCount - 1)),
|
|
|
|
|
)
|
|
|
|
|
: 0;
|
2026-05-31 02:04:26 +08:00
|
|
|
// Hide the arrow that points to an edge we're already at.
|
|
|
|
|
const recAtStart = recScroll.progress <= 0.01;
|
|
|
|
|
const recAtEnd = recScroll.progress >= 0.99;
|
2026-05-16 00:18:22 +08:00
|
|
|
|
|
|
|
|
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 (
|
2026-05-30 23:28:06 +08:00
|
|
|
<div className="space-y-2.5 pb-4 md:space-y-10 md:pb-16 xl:space-y-[34px]">
|
2026-05-28 15:31:45 +08:00
|
|
|
<section className="md:mt-0">
|
2026-05-16 00:18:22 +08:00
|
|
|
<FigmaBanner />
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-05-29 11:50:27 +08:00
|
|
|
<Reveal delay={0}>
|
2026-05-29 12:49:22 +08:00
|
|
|
<section id="categories" className="scroll-mt-16 md:scroll-mt-24">
|
2026-05-31 02:32:15 +08:00
|
|
|
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
2026-05-31 02:56:34 +08:00
|
|
|
<div className="px-4 md:px-0">
|
|
|
|
|
<SectionHeader
|
|
|
|
|
title={t("categorySection")}
|
|
|
|
|
viewAllTo="/categories"
|
|
|
|
|
viewAllLabel={t("viewAll")}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-05-29 12:49:22 +08:00
|
|
|
|
2026-05-31 02:56:34 +08:00
|
|
|
<div className="mt-2.5 md:hidden">
|
|
|
|
|
<div
|
|
|
|
|
ref={categoryRowRef}
|
|
|
|
|
className="flex snap-x snap-mandatory items-start overflow-x-auto overflow-y-hidden scroll-smooth [-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) => (
|
|
|
|
|
<Link
|
|
|
|
|
key={c.id}
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp(`/category/${encodeURIComponent(c.slug)}`)}
|
2026-05-31 02:56:34 +08:00
|
|
|
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>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{categoryPages.length > 1 ? (
|
2026-05-29 12:49:22 +08:00
|
|
|
<div
|
2026-05-31 02:56:34 +08:00
|
|
|
className="flex h-[30px] items-center justify-center gap-1.5"
|
|
|
|
|
aria-label="Category pagination"
|
2026-05-29 12:49:22 +08:00
|
|
|
>
|
2026-05-31 02:56:34 +08:00
|
|
|
{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]"
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
2026-05-29 12:49:22 +08:00
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-31 02:56:34 +08:00
|
|
|
) : 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, index) => (
|
|
|
|
|
<Reveal key={c.id} delay={Math.min(index, 8) * 0.05}>
|
|
|
|
|
<Link
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp(`/category/${encodeURIComponent(c.slug)}`)}
|
2026-05-31 02:56:34 +08:00
|
|
|
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>
|
|
|
|
|
</Link>
|
|
|
|
|
</Reveal>
|
2026-05-29 12:49:22 +08:00
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-31 02:56:34 +08:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</Reveal>
|
2026-05-29 12:49:22 +08:00
|
|
|
|
2026-05-31 02:56:34 +08:00
|
|
|
<Reveal>
|
|
|
|
|
<section id="official" className="scroll-mt-16 md:scroll-mt-24">
|
|
|
|
|
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
|
|
|
|
<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="mt-2.5 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}>
|
|
|
|
|
<Reveal delay={Math.min(index, 8) * 0.05}>
|
|
|
|
|
<RecommendedCard
|
|
|
|
|
r={r}
|
|
|
|
|
visualIndex={index}
|
|
|
|
|
useFigmaDesign
|
|
|
|
|
/>
|
|
|
|
|
</Reveal>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-28 15:11:13 +08:00
|
|
|
<div
|
2026-05-29 12:49:22 +08:00
|
|
|
className="flex h-[30px] items-center justify-center gap-1.5"
|
2026-05-31 02:56:34 +08:00
|
|
|
aria-label="Recommended pagination"
|
2026-05-28 15:11:13 +08:00
|
|
|
>
|
2026-05-31 02:56:34 +08:00
|
|
|
{Array.from({ length: recommendedDotCount }).map((_, index) => (
|
2026-05-29 12:49:22 +08:00
|
|
|
<button
|
2026-05-31 02:56:34 +08:00
|
|
|
key={`recommended-dot-${index}`}
|
2026-05-29 12:49:22 +08:00
|
|
|
type="button"
|
2026-05-31 02:56:34 +08:00
|
|
|
aria-label={`Go to recommendation page ${index + 1}`}
|
|
|
|
|
aria-current={activeRecommendedDot === index}
|
2026-05-29 12:49:22 +08:00
|
|
|
onClick={() => {
|
2026-05-31 02:56:34 +08:00
|
|
|
const row = recRowRef.current;
|
2026-05-29 12:49:22 +08:00
|
|
|
if (!row) return;
|
2026-05-31 02:56:34 +08:00
|
|
|
const maxScroll = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
row.scrollWidth - row.clientWidth,
|
|
|
|
|
);
|
2026-05-29 12:49:22 +08:00
|
|
|
row.scrollTo({
|
2026-05-31 02:56:34 +08:00
|
|
|
left:
|
|
|
|
|
recommendedDotCount === 1
|
|
|
|
|
? 0
|
|
|
|
|
: (maxScroll * index) / (recommendedDotCount - 1),
|
2026-05-29 12:49:22 +08:00
|
|
|
behavior: "smooth",
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
className={`h-1.5 rounded-full transition-all ${
|
2026-05-31 02:56:34 +08:00
|
|
|
activeRecommendedDot === index
|
2026-05-29 12:49:22 +08:00
|
|
|
? "w-6 bg-ark-gold"
|
|
|
|
|
: "w-1.5 bg-[#7C7C7C]"
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
2026-05-28 15:11:13 +08:00
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-31 02:56:34 +08:00
|
|
|
{canScrollRec && !recAtStart ? (
|
|
|
|
|
<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"
|
2026-05-29 12:49:22 +08:00
|
|
|
>
|
2026-05-31 02:56:34 +08:00
|
|
|
<ChevronLeft className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
{canScrollRec && !recAtEnd ? (
|
2026-05-28 15:11:13 +08:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-05-31 02:56:34 +08:00
|
|
|
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}
|
2026-05-31 02:04:26 +08:00
|
|
|
</div>
|
2026-05-16 00:18:22 +08:00
|
|
|
</div>
|
2026-05-29 12:49:22 +08:00
|
|
|
</section>
|
2026-05-29 11:50:27 +08:00
|
|
|
</Reveal>
|
2026-05-16 00:18:22 +08:00
|
|
|
|
2026-05-29 11:50:27 +08:00
|
|
|
<Reveal>
|
2026-05-29 12:49:22 +08:00
|
|
|
<section id="latest" className="scroll-mt-16 md:scroll-mt-24">
|
2026-05-29 17:49:58 +08:00
|
|
|
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
|
|
|
|
<div className="px-4 md:px-0">
|
|
|
|
|
<SectionHeader
|
|
|
|
|
title={t("latestSection")}
|
|
|
|
|
viewAllTo="/browse"
|
|
|
|
|
viewAllLabel={t("viewAll")}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-05-31 18:35:57 +08:00
|
|
|
<div className="mt-2.5 flex flex-col gap-3 md:hidden">
|
2026-05-29 17:49:58 +08:00
|
|
|
{latestPosts.slice(0, 5).map((post, index) => (
|
|
|
|
|
<Reveal key={post.id} delay={Math.min(index, 8) * 0.05}>
|
|
|
|
|
<MessageBubble post={post} />
|
|
|
|
|
</Reveal>
|
2026-05-29 12:49:22 +08:00
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-31 19:24:18 +08:00
|
|
|
{/* Desktop: masonry that matches the Figma layout. 2 columns at
|
|
|
|
|
md (with horizontal padding so cards don't kiss the screen
|
|
|
|
|
edge), 3 columns at lg+. */}
|
|
|
|
|
<div className="mt-7 hidden gap-4 px-4 md:block md:columns-2 lg:columns-3 lg:px-0">
|
2026-05-31 18:35:57 +08:00
|
|
|
{latestPosts.map((post, index) => (
|
|
|
|
|
<Reveal
|
|
|
|
|
key={post.id}
|
|
|
|
|
delay={Math.min(index, 8) * 0.05}
|
|
|
|
|
className="mb-4 break-inside-avoid"
|
|
|
|
|
>
|
|
|
|
|
<MessageBubble post={post} fluid />
|
|
|
|
|
</Reveal>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-28 17:40:35 +08:00
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-05-29 12:49:22 +08:00
|
|
|
</Reveal>
|
|
|
|
|
|
|
|
|
|
{hasPopular ? (
|
|
|
|
|
<Reveal>
|
|
|
|
|
<section id="popular" className="scroll-mt-16 md:scroll-mt-24">
|
2026-05-31 02:32:15 +08:00
|
|
|
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
|
|
|
|
<div className="px-4 md:px-0">
|
|
|
|
|
<SectionHeader
|
|
|
|
|
title={t("popularSection")}
|
|
|
|
|
viewAllTo="/browse?sort=popular"
|
|
|
|
|
viewAllLabel={t("viewAll")}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2.5 md:mt-7">
|
|
|
|
|
<PopularRankList posts={popularPosts} categories={cats} />
|
|
|
|
|
</div>
|
2026-05-29 12:49:22 +08:00
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-05-29 11:50:27 +08:00
|
|
|
</Reveal>
|
2026-05-28 17:40:35 +08:00
|
|
|
) : null}
|
2026-05-16 00:18:22 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|