terry-staging #16
@@ -1,6 +1,6 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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";
|
||||
@@ -41,6 +41,60 @@ function figmaCategoryRank(category: Category): number {
|
||||
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();
|
||||
@@ -64,6 +118,27 @@ export function Home() {
|
||||
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;
|
||||
@@ -73,7 +148,7 @@ export function Home() {
|
||||
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 latestUrl = `/api/posts${postQ}&sort=latest&limit=9`;
|
||||
const popularUrl = `/api/posts${postQ}&sort=popular&limit=5`;
|
||||
|
||||
const applyHomeData = (
|
||||
@@ -264,6 +339,10 @@ export function Home() {
|
||||
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;
|
||||
@@ -481,20 +560,25 @@ export function Home() {
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
{/* 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">
|
||||
{latestPosts.map((post, index) => (
|
||||
{/* Desktop: explicit balanced columns avoid the uneven gaps that
|
||||
CSS multi-column masonry can create with variable-height cards. */}
|
||||
<div className="mt-7 hidden gap-4 px-4 md:grid md:grid-cols-2 lg:grid-cols-3 lg:px-0">
|
||||
{latestDesktopColumns.map((column, columnIndex) => (
|
||||
<div
|
||||
key={`latest-desktop-column-${columnIndex}`}
|
||||
className="flex min-w-0 flex-col gap-4"
|
||||
>
|
||||
{column.map(({ post, originalIndex }) => (
|
||||
<Reveal
|
||||
key={post.id}
|
||||
delay={Math.min(index, 8) * 0.05}
|
||||
className="mb-4 break-inside-avoid"
|
||||
delay={Math.min(originalIndex, 8) * 0.05}
|
||||
>
|
||||
<MessageBubble post={post} fluid />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Reveal>
|
||||
|
||||
Reference in New Issue
Block a user