import { ChevronLeft, ChevronRight } from "lucide-react"; import { Link, useLocation } from "react-router-dom"; import { useEffect, useMemo, useRef, useState } from "react"; import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api"; import { CategoryIcon } from "../../components/CategoryIcon"; import { FigmaBanner } from "../../components/FigmaBanner"; import { PopularRankList } from "../../components/PopularRankList"; import { RecommendedCard } from "../../components/RecommendedCard"; import { SectionHeader } from "../../components/SectionHeader"; import { MessageBubble } from "../../components/messageStream/MessageBubble"; import { langQuery, useI18n } from "../../i18n"; import { useLocalizedPath } from "../../useLocalizedPath"; import { sourceLanguageQuery } from "../../i18nLanguages"; import { cleanCategoryDisplayName } from "../../utils/categoryDisplay"; import { postToResource, type PostBackedResource, } from "../../utils/postResourceAdapter"; import type { Post } from "../../types/post"; import { Reveal } from "../../motion"; 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; } type LatestPostColumnItem = { post: Post; originalIndex: number; }; function estimateLatestPostHeight(post: Post): number { const textLength = (post.text ?? post.title ?? "").length; const textRows = Math.ceil(textLength / 72); const textHeight = Math.min(180, Math.max(0, textRows * 22)); const previewHeight = post.linkPreview ? 132 : 0; const [firstAttachment] = post.attachments; if (!firstAttachment) return 72 + textHeight + previewHeight; if (post.attachments.length >= 2) { const mediaHeight = firstAttachment.kind === "video" ? 340 : 300; return mediaHeight + textHeight + previewHeight + 42; } if (firstAttachment.kind === "video") { return 300 + textHeight + previewHeight + 42; } if (firstAttachment.kind === "image") { return (post.text ? 300 : 260) + textHeight + previewHeight + 42; } return 96 + post.attachments.length * 72 + textHeight + previewHeight; } function splitLatestPostsIntoColumns( posts: Post[], columnCount: number, ): LatestPostColumnItem[][] { const safeColumnCount = Math.max(1, columnCount); const columns = Array.from( { length: safeColumnCount }, () => [] as LatestPostColumnItem[], ); const columnHeights = Array.from({ length: safeColumnCount }, () => 0); posts.forEach((post, originalIndex) => { const targetColumn = originalIndex < safeColumnCount ? originalIndex : columnHeights.indexOf(Math.min(...columnHeights)); columns[targetColumn].push({ post, originalIndex }); columnHeights[targetColumn] += estimateLatestPostHeight(post) + 16; }); return columns; } export function Home() { const { t, lang } = useI18n(); const lp = useLocalizedPath(); const { hash } = useLocation(); // Seed from cache on the first render so the categories (and their icons) // are present immediately when navigating back to the home page, instead of // flashing empty for a frame before the effect re-applies the cached data. const [cats, setCats] = useState(() => { const cached = readJSONCache( `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`, ); return cached ? itemsOrEmpty(cached) : []; }); const [rec, setRec] = useState([]); const [latestPosts, setLatestPosts] = useState([]); const [popular, setPopular] = useState([]); const [popularPosts, setPopularPosts] = useState([]); const [err, setErr] = useState(null); const recRowRef = useRef(null); const categoryRowRef = useRef(null); const [activeCategoryPage, setActiveCategoryPage] = useState(0); const [canScrollRec, setCanScrollRec] = useState(false); const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 }); const [latestDesktopColumnCount, setLatestDesktopColumnCount] = useState( () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(min-width: 1024px)").matches ? 3 : 2, ); useEffect(() => { if (typeof window.matchMedia !== "function") return; const media = window.matchMedia("(min-width: 1024px)"); const updateColumnCount = () => { setLatestDesktopColumnCount(media.matches ? 3 : 2); }; updateColumnCount(); media.addEventListener("change", updateColumnCount); return () => media.removeEventListener("change", updateColumnCount); }, []); useEffect(() => { let cancelled = false; const langParam = encodeURIComponent(langQuery(lang)); const languageParam = encodeURIComponent(sourceLanguageQuery(lang)); const catQ = `?lang=${langParam}`; const postQ = `?lang=${langParam}&language=${languageParam}`; 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(p.items); setPopularPosts(popularItems); setPopular( popularItems.map((post) => postToResource(post, lang, categoryItems)), ); }; setErr(null); const cachedCategories = readJSONCache(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: [] }, ); } Promise.all([ getJSON(categoriesUrl), getJSON<{ items: Post[] }>(recommendedUrl), getJSON<{ items: Post[] }>(latestUrl), getJSON<{ items: Post[] }>(popularUrl).catch( (): { items: Post[] } => cachedPopular ?? { items: [] }, ), ]) .then(([c, r, l, p]) => { if (cancelled) return; applyHomeData(c, r, l, p); }) .catch((e) => { if (!cancelled && !showedCached) setErr(String(e)); }); return () => { cancelled = true; }; }, [lang]); 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)); } // 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, ); const mobileCategoryHeight = maxCategoryRows * 88 + Math.max(0, maxCategoryRows - 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]); // 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. const scrollRec = (dir: 1 | -1) => { 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); delta = lefts.length ? lefts[lefts.length - 1] - padLeft : -row.scrollLeft; } 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" }); }; 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, latestPosts.length, popular.length]); const hasPopular = popular.length > 0 || popularPosts.length > 0; const recommendedDotCount = rec.length; const activeRecommendedDot = recommendedDotCount > 0 ? Math.min( recommendedDotCount - 1, Math.round(recScroll.progress * (recommendedDotCount - 1)), ) : 0; const latestDesktopColumns = useMemo( () => splitLatestPostsIntoColumns(latestPosts, latestDesktopColumnCount), [latestPosts, latestDesktopColumnCount], ); // Hide the arrow that points to an edge we're already at. const recAtStart = recScroll.progress <= 0.01; const recAtEnd = recScroll.progress >= 0.99; if (err) { return (
{err}
); } return (
{categoryPages.map((page, pageIndex) => (
{page.map((c) => (
{cleanCategoryDisplayName(c.name)}
))}
))}
{categoryPages.length > 1 ? (
{categoryPages.map((_, index) => (
) : null}
{figmaOrderedCategories.map((c, index) => (
{cleanCategoryDisplayName(c.name)}
))}
{rec.map((r, index) => (
))}
{Array.from({ length: recommendedDotCount }).map((_, index) => (
{canScrollRec && !recAtStart ? ( ) : null} {canScrollRec && !recAtEnd ? ( ) : null}
{latestPosts.slice(0, 5).map((post, index) => ( ))}
{/* Desktop: explicit balanced columns avoid the uneven gaps that CSS multi-column masonry can create with variable-height cards. */}
{latestDesktopColumns.map((column, columnIndex) => (
{column.map(({ post, originalIndex }) => ( ))}
))}
{hasPopular ? ( ) : null}
); }