2026-05-16 00:18:22 +08:00
|
|
|
import { ChevronRight } from "lucide-react";
|
|
|
|
|
import { Link } from "react-router-dom";
|
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
2026-05-26 14:46:05 +08:00
|
|
|
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
|
|
|
|
import { CategoryIcon } from "../../components/CategoryIcon";
|
|
|
|
|
import { FigmaBanner } from "../../components/FigmaBanner";
|
2026-05-16 00:18:22 +08:00
|
|
|
import {
|
|
|
|
|
ComingSoonLatestUpdateRow,
|
|
|
|
|
LatestUpdateRow,
|
2026-05-26 14:46:05 +08:00
|
|
|
} from "../../components/LatestUpdateRow";
|
|
|
|
|
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-05-27 11:33:48 +08:00
|
|
|
import { sourceLanguageQuery } from "../../i18nLanguages";
|
2026-05-26 14:46:05 +08:00
|
|
|
import { categoryCardLines } 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-16 00:18:22 +08:00
|
|
|
|
|
|
|
|
export function Home() {
|
|
|
|
|
const { t, lang } = useI18n();
|
|
|
|
|
const [cats, setCats] = useState<Category[]>([]);
|
2026-05-26 12:07:13 +08:00
|
|
|
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
|
|
|
|
const [latest, setLatest] = useState<PostBackedResource[]>([]);
|
2026-05-28 15:31:45 +08:00
|
|
|
const [latestPosts, setLatestPosts] = 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-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-16 00:18:22 +08:00
|
|
|
Promise.all([
|
2026-05-27 11:33:48 +08:00
|
|
|
getJSON<Category[]>(`/api/categories${catQ}`),
|
|
|
|
|
getJSON<{ items: Post[] }>(`/api/posts/recommended${postQ}&limit=12`),
|
|
|
|
|
getJSON<{ items: Post[] }>(`/api/posts/latest${postQ}&limit=8`),
|
2026-05-16 00:18:22 +08:00
|
|
|
])
|
|
|
|
|
.then(([c, r, l]) => {
|
|
|
|
|
setCats(itemsOrEmpty(c));
|
2026-05-26 12:07:13 +08:00
|
|
|
setRec(
|
|
|
|
|
itemsOrEmpty(r.items).map((post) =>
|
|
|
|
|
postToResource(post, lang, itemsOrEmpty(c)),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-05-28 15:31:45 +08:00
|
|
|
const latestItems = itemsOrEmpty(l.items);
|
|
|
|
|
setLatestPosts(latestItems);
|
2026-05-26 12:07:13 +08:00
|
|
|
setLatest(
|
2026-05-28 15:31:45 +08:00
|
|
|
latestItems.map((post) =>
|
2026-05-26 12:07:13 +08:00
|
|
|
postToResource(post, lang, itemsOrEmpty(c)),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-05-16 00:18:22 +08:00
|
|
|
})
|
|
|
|
|
.catch((e) => setErr(String(e)));
|
|
|
|
|
}, [lang]);
|
|
|
|
|
|
2026-05-26 12:07:13 +08:00
|
|
|
const iconKeyForResource = (r: PostBackedResource) =>
|
2026-05-16 00:18:22 +08:00
|
|
|
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
|
|
|
|
|
|
2026-05-28 15:11:13 +08:00
|
|
|
const categoryPages: Category[][] = [];
|
|
|
|
|
for (let index = 0; index < cats.length; index += 9) {
|
|
|
|
|
categoryPages.push(cats.slice(index, index + 9));
|
|
|
|
|
}
|
2026-05-28 15:31:45 +08:00
|
|
|
const activeCategoryCount = categoryPages[activeCategoryPage]?.length ?? 0;
|
|
|
|
|
const activeCategoryRows = Math.ceil(activeCategoryCount / 3);
|
|
|
|
|
const mobileCategoryHeight =
|
|
|
|
|
activeCategoryRows * 88 + Math.max(0, activeCategoryRows - 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-16 00:18:22 +08:00
|
|
|
const scrollRec = (dir: 1 | -1) => {
|
|
|
|
|
recRowRef.current?.scrollBy({ left: dir * 280, behavior: "smooth" });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const latestPlaceholderCount = Math.max(0, 5 - latest.length);
|
2026-05-28 15:31:45 +08:00
|
|
|
const recommendedDotCount = Math.max(1, Math.min(4, rec.length || 4));
|
|
|
|
|
const activeRecommendedDot = Math.min(
|
|
|
|
|
recommendedDotCount - 1,
|
|
|
|
|
Math.round(recScroll.progress * (recommendedDotCount - 1)),
|
|
|
|
|
);
|
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-28 15:31:45 +08:00
|
|
|
<div className="pb-4 md:space-y-10 md:pb-16 xl:space-y-[34px]">
|
|
|
|
|
<section className="md:mt-0">
|
2026-05-16 00:18:22 +08:00
|
|
|
<FigmaBanner />
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section id="categories" className="scroll-mt-24">
|
2026-05-28 15:31:45 +08:00
|
|
|
<div className="px-4 md:px-0">
|
|
|
|
|
<SectionHeader
|
|
|
|
|
title={t("categorySection")}
|
|
|
|
|
viewAllTo="/browse"
|
|
|
|
|
viewAllLabel={t("viewAll")}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-05-28 15:11:13 +08:00
|
|
|
|
|
|
|
|
<div className="md:hidden">
|
|
|
|
|
<div
|
|
|
|
|
ref={categoryRowRef}
|
2026-05-28 15:31:45 +08:00
|
|
|
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` }}
|
2026-05-28 15:11:13 +08:00
|
|
|
aria-label={t("categorySection")}
|
|
|
|
|
>
|
|
|
|
|
{categoryPages.map((page, pageIndex) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`category-page-${pageIndex}`}
|
2026-05-28 15:31:45 +08:00
|
|
|
className="grid w-full shrink-0 snap-start grid-cols-3 gap-2 px-4"
|
2026-05-28 15:11:13 +08:00
|
|
|
>
|
|
|
|
|
{page.map((c) => (
|
|
|
|
|
<Link
|
|
|
|
|
key={c.id}
|
|
|
|
|
to={`/category/${c.slug}`}
|
|
|
|
|
className="group flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl bg-[#1D1E23] px-4 py-3 text-center transition hover:bg-[#252630] focus-visible:outline-none 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">
|
|
|
|
|
{c.name}
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</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">
|
2026-05-16 00:18:22 +08:00
|
|
|
{cats.map((c) => {
|
|
|
|
|
const { line1, line2 } = categoryCardLines(c.name);
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
key={c.id}
|
|
|
|
|
to={`/category/${c.slug}`}
|
|
|
|
|
className="group flex min-h-[111px] min-w-0 flex-col items-center justify-center gap-3 rounded-xl border border-ark-line bg-ark-panel px-2.5 py-4 text-center transition hover:border-ark-gold/55 hover:shadow-[0_0_0_1px_rgba(238,183,38,0.12)] md:min-h-24 md:flex-row md:justify-start md:gap-4 md:px-5 md:text-left"
|
|
|
|
|
>
|
|
|
|
|
<CategoryIcon
|
|
|
|
|
iconKey={c.iconKey}
|
|
|
|
|
categorySlug={c.slug}
|
|
|
|
|
className="h-9 w-9 shrink-0 text-ark-gold md:h-9 md:w-9"
|
|
|
|
|
/>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="text-[15px] font-bold leading-snug text-white line-clamp-2 md:text-sm">
|
|
|
|
|
{line1}
|
|
|
|
|
</div>
|
|
|
|
|
{line2 ? (
|
|
|
|
|
<div className="mt-0.5 text-[15px] font-bold leading-snug text-white line-clamp-2 md:text-sm">
|
|
|
|
|
{line2}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-05-28 15:31:45 +08:00
|
|
|
<section id="official" className="scroll-mt-24">
|
|
|
|
|
<div className="px-4 md:px-0">
|
|
|
|
|
<SectionHeader
|
|
|
|
|
title={t("officialSection")}
|
|
|
|
|
viewAllTo="/browse?sort=recommended"
|
|
|
|
|
viewAllLabel={t("viewAll")}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="relative">
|
2026-05-16 00:18:22 +08:00
|
|
|
<div
|
|
|
|
|
ref={recRowRef}
|
2026-05-28 15:31:45 +08:00
|
|
|
className="flex snap-x snap-mandatory 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:pb-5 [&::-webkit-scrollbar]:hidden"
|
2026-05-16 00:18:22 +08:00
|
|
|
>
|
|
|
|
|
{rec.map((r, index) => (
|
|
|
|
|
<div key={r.id} className="snap-start">
|
|
|
|
|
<RecommendedCard r={r} visualIndex={index} />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-28 15:31:45 +08:00
|
|
|
<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]"
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
2026-05-16 00:18:22 +08:00
|
|
|
</div>
|
2026-05-19 00:34:29 +08:00
|
|
|
{canScrollRec ? (
|
|
|
|
|
<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={t("viewAll")}
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
2026-05-16 00:18:22 +08:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-05-28 15:31:45 +08:00
|
|
|
<section id="latest" className="scroll-mt-24">
|
|
|
|
|
<div className="px-4 md:px-0">
|
|
|
|
|
<SectionHeader
|
|
|
|
|
title={t("latestSection")}
|
|
|
|
|
viewAllTo="/browse?sort=latest"
|
|
|
|
|
viewAllLabel={t("viewAll")}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col gap-3 md:hidden">
|
|
|
|
|
{latestPosts.slice(0, 4).map((post) => (
|
|
|
|
|
<MessageBubble key={post.id} post={post} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-7 hidden gap-3 min-[576px]:grid-cols-2 md:grid md:gap-4 lg:grid-cols-3 xl:grid-cols-5">
|
2026-05-16 00:18:22 +08:00
|
|
|
{latest.map((r) => (
|
|
|
|
|
<LatestUpdateRow key={r.id} r={r} iconKey={iconKeyForResource(r)} />
|
|
|
|
|
))}
|
|
|
|
|
{Array.from({ length: latestPlaceholderCount }).map((_, index) => (
|
|
|
|
|
<ComingSoonLatestUpdateRow
|
|
|
|
|
key={`latest-coming-soon-${index}`}
|
|
|
|
|
index={latest.length + index}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-05-28 15:31:45 +08:00
|
|
|
|
|
|
|
|
<span id="popular" className="block scroll-mt-24" aria-hidden="true" />
|
2026-05-16 00:18:22 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|