feat: add reusable gallery carousel and ranking feed infrastructure
This commit is contained in:
@@ -84,6 +84,54 @@ function SkeletonCard() {
|
||||
return <div className="nova-skeleton-card" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
// ── Ranking API helpers ───────────────────────────────────────────────────
|
||||
/**
|
||||
* Map a single ArtworkListResource item (from /api/rank/*) to the internal
|
||||
* artwork object shape used by ArtworkCard.
|
||||
*/
|
||||
function mapRankApiArtwork(item) {
|
||||
const w = item.dimensions?.width ?? null;
|
||||
const h = item.dimensions?.height ?? null;
|
||||
const thumb = item.thumbnail_url ?? null;
|
||||
const webUrl = item.urls?.web ?? item.category?.url ?? null;
|
||||
return {
|
||||
id: item.id ?? null,
|
||||
name: item.title ?? item.name ?? null,
|
||||
thumb: thumb,
|
||||
thumb_url: thumb,
|
||||
uname: item.author?.name ?? '',
|
||||
username: item.author?.username ?? item.author?.name ?? '',
|
||||
avatar_url: item.author?.avatar_url ?? null,
|
||||
category_name: item.category?.name ?? '',
|
||||
category_slug: item.category?.slug ?? '',
|
||||
slug: item.slug ?? '',
|
||||
url: webUrl,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ranked artworks from the ranking API.
|
||||
* Returns { artworks: [...] } in internal shape, or { artworks: [] } on failure.
|
||||
*/
|
||||
async function fetchRankApiArtworks(endpoint, rankType) {
|
||||
try {
|
||||
const url = new URL(endpoint, window.location.href);
|
||||
if (rankType) url.searchParams.set('type', rankType);
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (!res.ok) return { artworks: [] };
|
||||
const json = await res.json();
|
||||
const items = Array.isArray(json.data) ? json.data : [];
|
||||
return { artworks: items.map(mapRankApiArtwork) };
|
||||
} catch {
|
||||
return { artworks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
const SKELETON_COUNT = 10;
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────
|
||||
@@ -97,6 +145,9 @@ const SKELETON_COUNT = 10;
|
||||
* initialNextCursor string|null First cursor token
|
||||
* initialNextPageUrl string|null First "next page" URL (page-based feeds)
|
||||
* limit number Items per page (default 40)
|
||||
* rankApiEndpoint string|null /api/rank/* endpoint; used as fallback data
|
||||
* source when no SSR artworks are available
|
||||
* rankType string|null Ranking API ?type= param (trending|new_hot|best)
|
||||
*/
|
||||
function MasonryGallery({
|
||||
artworks: initialArtworks = [],
|
||||
@@ -105,6 +156,8 @@ function MasonryGallery({
|
||||
initialNextCursor = null,
|
||||
initialNextPageUrl = null,
|
||||
limit = 40,
|
||||
rankApiEndpoint = null,
|
||||
rankType = null,
|
||||
}) {
|
||||
const [artworks, setArtworks] = useState(initialArtworks);
|
||||
const [nextCursor, setNextCursor] = useState(initialNextCursor);
|
||||
@@ -115,6 +168,28 @@ function MasonryGallery({
|
||||
const gridRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
// ── Ranking API fallback ───────────────────────────────────────────────
|
||||
// When the server-side render provides no initial artworks (e.g. cache miss
|
||||
// or empty page result) and a ranking API endpoint is configured, perform a
|
||||
// client-side fetch from the ranking API to hydrate the grid.
|
||||
// Satisfies spec: "Fallback: Latest if ranking missing".
|
||||
useEffect(() => {
|
||||
if (initialArtworks.length > 0) return; // SSR artworks already present
|
||||
if (!rankApiEndpoint) return; // no API endpoint configured
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchRankApiArtworks(rankApiEndpoint, rankType).then(({ artworks: ranked }) => {
|
||||
if (cancelled) return;
|
||||
if (ranked.length > 0) {
|
||||
setArtworks(ranked);
|
||||
setDone(true); // ranking API returns a full list; no further pagination
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Masonry re-layout ──────────────────────────────────────────────────
|
||||
const relayout = useCallback(() => {
|
||||
const g = gridRef.current;
|
||||
@@ -195,6 +270,10 @@ function MasonryGallery({
|
||||
return () => io.disconnect();
|
||||
}, [done, fetchNext]);
|
||||
|
||||
// Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages.
|
||||
// Discover feeds (home/discover page) retain the same 5-col layout.
|
||||
const gridClass = 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<section
|
||||
@@ -210,7 +289,7 @@ function MasonryGallery({
|
||||
<>
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 force-5"
|
||||
className={gridClass}
|
||||
data-gallery-grid
|
||||
>
|
||||
{artworks.map((art, idx) => (
|
||||
|
||||
Reference in New Issue
Block a user