Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,551 @@
import React, {
useState, useEffect, useRef, useCallback, memo,
} from 'react';
import ArtworkGallery from '../artwork/ArtworkGallery';
import './MasonryGallery.css';
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
}
async function sendDiscoveryEvent(endpoint, payload) {
if (!endpoint) return;
try {
await fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
});
} catch {
// Discovery telemetry should never block the gallery UX.
}
}
// ── Masonry helpers ────────────────────────────────────────────────────────
const ROW_SIZE = 8;
const ROW_GAP = 16;
function applyMasonry(grid) {
if (!grid) return;
Array.from(grid.querySelectorAll('.nova-card')).forEach((card) => {
const media = card.querySelector('.nova-card-media') || card;
let height = media.getBoundingClientRect().height || 200;
// Clamp to the computed max-height so the span never over-reserves rows
// when CSS max-height kicks in (e.g. portrait images capped to 2×16:9).
const cssMaxH = parseFloat(getComputedStyle(media).maxHeight);
if (!isNaN(cssMaxH) && cssMaxH > 0 && cssMaxH < height) {
height = cssMaxH;
}
const span = Math.max(1, Math.ceil((height + ROW_GAP) / (ROW_SIZE + ROW_GAP)));
card.style.gridRowEnd = `span ${span}`;
});
}
function waitForImages(el) {
return Promise.all(
Array.from(el.querySelectorAll('img')).map((img) =>
img.decode ? img.decode().catch(() => null) : Promise.resolve(),
),
);
}
// ── Page fetch helpers ─────────────────────────────────────────────────────
/**
* Fetch the next page of data.
*
* The response is either:
* - JSON { artworks: [...], next_cursor: '...' } when X-Requested-With is
* sent and the controller returns JSON (future enhancement)
* - HTML page we parse [data-react-masonry-gallery] from it and read its
* data-artworks / data-next-cursor / data-next-page-url attributes.
*/
async function fetchPageData(url) {
const res = await fetch(url, {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const ct = res.headers.get('content-type') || '';
// JSON fast-path (if controller ever returns JSON)
if (ct.includes('application/json')) {
const json = await res.json();
// Support multiple API payload shapes across endpoints.
const artworks = Array.isArray(json.artworks)
? json.artworks
: Array.isArray(json.data)
? json.data
: Array.isArray(json.items)
? json.items
: Array.isArray(json.results)
? json.results
: [];
const nextCursor = json.next_cursor
?? json.nextCursor
?? json.meta?.next_cursor
?? null;
const nextPageUrl = json.next_page_url
?? json.nextPageUrl
?? json.meta?.next_page_url
?? null;
const hasMore = typeof json.has_more === 'boolean'
? json.has_more
: typeof json.hasMore === 'boolean'
? json.hasMore
: null;
return {
artworks,
nextCursor,
nextPageUrl,
hasMore,
meta: json.meta ?? null,
};
}
// HTML: parse and extract mount-container data attributes
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const el = doc.querySelector('[data-react-masonry-gallery]');
if (!el) return { artworks: [], nextCursor: null, nextPageUrl: null };
let artworks = [];
try { artworks = JSON.parse(el.dataset.artworks || '[]'); } catch { /* empty */ }
return {
artworks,
nextCursor: el.dataset.nextCursor || null,
nextPageUrl: el.dataset.nextPageUrl || null,
hasMore: null,
meta: null,
};
}
// ── Skeleton row ──────────────────────────────────────────────────────────
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;
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null;
return {
id: item.id ?? null,
name: item.title ?? item.name ?? null,
thumb: thumb,
thumb_url: thumb,
uname: item.author?.name ?? '',
username: publisher?.type === 'group' ? '' : (item.author?.username ?? ''),
avatar_url: item.author?.avatar_url ?? null,
profile_url: publisher?.profile_url ?? item.author?.profile_url ?? null,
published_as_type: publisher?.type ?? null,
publisher: publisher,
content_type_name: item.category?.content_type_name ?? item.category?.content_type_slug ?? item.category?.content_type ?? '',
content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '',
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;
function getMasonryCardProps(art, idx) {
const title = (art.name || art.title || 'Untitled artwork').trim();
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
const categorySlug = (art.category_slug || '').toLowerCase();
const categoryName = (art.category_name || art.category || '').toLowerCase();
const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper'];
const wideCategoryNames = ['photography', 'wallpapers'];
const isWideEligible =
aspectRatio !== null &&
aspectRatio > 2.0 &&
(wideCategories.includes(categorySlug) || wideCategoryNames.includes(categoryName));
return {
articleClassName: `nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`,
articleStyle: isWideEligible ? { gridColumn: 'span 2' } : undefined,
frameClassName: 'rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 hover:ring-white/15 hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]',
mediaClassName: 'nova-card-media relative w-full overflow-hidden bg-neutral-900',
mediaStyle: hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : undefined,
imageSrcSet: art.thumb_srcset || undefined,
imageSizes: '(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw',
imageWidth: hasDimensions ? art.width : undefined,
imageHeight: hasDimensions ? art.height : undefined,
loading: idx < 8 ? 'eager' : 'lazy',
decoding: idx < 8 ? 'sync' : 'async',
fetchPriority: idx === 0 ? 'high' : undefined,
imageClassName: 'nova-card-main-image absolute inset-0 h-full w-full object-cover group-hover:scale-[1.03]',
metricBadge: art.recommendation_reason
? {
label: art.recommendation_reason,
className: 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
}
: null,
};
}
// ── Main component ────────────────────────────────────────────────────────
/**
* MasonryGallery
*
* Props (all optional set via data attributes in entry-masonry-gallery.jsx):
* artworks [] Initial artwork objects
* galleryType string Maps to data-gallery-type (e.g. 'trending')
* cursorEndpoint string|null Route for cursor-based feeds (e.g. For You)
* 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)
* gridClassName string|null Optional CSS class override for grid columns/gaps
*/
function MasonryGallery({
artworks: initialArtworks = [],
galleryType = 'discover',
cursorEndpoint = null,
initialNextCursor = null,
initialNextPageUrl = null,
limit = 40,
rankApiEndpoint = null,
rankType = null,
gridClassName = null,
discoveryEndpoint = null,
algoVersion: initialAlgoVersion = null,
}) {
const [artworks, setArtworks] = useState(initialArtworks);
const [nextCursor, setNextCursor] = useState(initialNextCursor);
const [nextPageUrl, setNextPageUrl] = useState(initialNextPageUrl);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(!initialNextCursor && !initialNextPageUrl);
const [algoVersion, setAlgoVersion] = useState(initialAlgoVersion);
const gridRef = useRef(null);
const triggerRef = useRef(null);
const viewedArtworkIdsRef = useRef(new Set());
const clickedArtworkIdsRef = useRef(new Set());
// ── 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;
if (!g) return;
applyMasonry(g);
waitForImages(g).then(() => applyMasonry(g));
}, []);
// Re-layout whenever artworks list changes.
// Defer by one requestAnimationFrame so the browser has resolved
// aspect-ratio heights before we measure with getBoundingClientRect().
useEffect(() => {
const raf = requestAnimationFrame(() => relayout());
return () => cancelAnimationFrame(raf);
}, [artworks, relayout]);
// Re-layout on container resize (column width changes)
useEffect(() => {
const g = gridRef.current;
if (!g || !('ResizeObserver' in window)) return;
const ro = new ResizeObserver(relayout);
ro.observe(g);
return () => ro.disconnect();
}, [relayout]);
// ── Load more ──────────────────────────────────────────────────────────
const fetchNext = useCallback(async () => {
if (loading || done) return;
// Build the URL to fetch
let fetchUrl = null;
if (cursorEndpoint && nextCursor) {
const u = new URL(cursorEndpoint, window.location.href);
u.searchParams.set('cursor', nextCursor);
u.searchParams.set('limit', String(limit));
fetchUrl = u.toString();
} else if (nextPageUrl) {
fetchUrl = nextPageUrl;
}
if (!fetchUrl) { setDone(true); return; }
setLoading(true);
try {
const { artworks: newItems, nextCursor: nc, nextPageUrl: np, hasMore, meta } =
await fetchPageData(fetchUrl);
if (meta?.algo_version) {
setAlgoVersion(meta.algo_version);
}
if (!newItems.length) {
setDone(true);
} else {
setArtworks((prev) => [...prev, ...newItems]);
if (cursorEndpoint) {
setNextCursor(nc);
if (hasMore === false || !nc) setDone(true);
} else {
setNextPageUrl(np);
if (!np) setDone(true);
}
}
} catch {
setDone(true);
} finally {
setLoading(false);
}
}, [loading, done, cursorEndpoint, nextCursor, nextPageUrl, limit]);
// ── Intersection observer for infinite scroll ──────────────────────────
useEffect(() => {
if (done) return;
const trigger = triggerRef.current;
if (!trigger || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) fetchNext(); },
{ rootMargin: '900px', threshold: 0 },
);
io.observe(trigger);
return () => io.disconnect();
}, [done, fetchNext]);
useEffect(() => {
if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined;
const grid = gridRef.current;
if (!grid || !(window.IntersectionObserver)) return undefined;
const artworkIndex = new Map(artworks.map((art, index) => [String(art.id), { art, index }]));
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio < 0.65) {
return;
}
const card = entry.target.closest('[data-art-id]');
const artworkId = card?.getAttribute('data-art-id');
if (!artworkId || viewedArtworkIdsRef.current.has(artworkId)) {
return;
}
const candidate = artworkIndex.get(artworkId);
if (!candidate?.art?.id) {
return;
}
viewedArtworkIdsRef.current.add(artworkId);
observer.unobserve(entry.target);
sendDiscoveryEvent(discoveryEndpoint, {
event_type: 'view',
artwork_id: Number(candidate.art.id),
algo_version: candidate.art.recommendation_algo_version || algoVersion || undefined,
meta: {
gallery_type: galleryType,
position: candidate.index + 1,
source: candidate.art.recommendation_source || null,
reason: candidate.art.recommendation_reason || null,
score: candidate.art.recommendation_score ?? null,
},
});
});
},
{ threshold: [0.65] },
);
const cards = grid.querySelectorAll('[data-art-id]');
cards.forEach((card) => observer.observe(card));
return () => observer.disconnect();
}, [algoVersion, artworks, discoveryEndpoint, galleryType]);
useEffect(() => {
if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined;
const grid = gridRef.current;
if (!grid) return undefined;
const handleClick = (event) => {
const card = event.target.closest('[data-art-id]');
if (!card) return;
const artworkId = card.getAttribute('data-art-id');
if (!artworkId || clickedArtworkIdsRef.current.has(artworkId)) {
return;
}
const artwork = artworks.find((item) => String(item.id) === artworkId);
if (!artwork?.id) {
return;
}
clickedArtworkIdsRef.current.add(artworkId);
sendDiscoveryEvent(discoveryEndpoint, {
event_type: 'click',
artwork_id: Number(artwork.id),
algo_version: artwork.recommendation_algo_version || algoVersion || undefined,
meta: {
gallery_type: galleryType,
source: artwork.recommendation_source || null,
reason: artwork.recommendation_reason || null,
score: artwork.recommendation_score ?? null,
target_url: artwork.url || null,
},
});
};
grid.addEventListener('click', handleClick, true);
return () => grid.removeEventListener('click', handleClick, true);
}, [algoVersion, artworks, discoveryEndpoint, galleryType]);
// 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 = gridClassName || 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
// ── Render ─────────────────────────────────────────────────────────────
return (
<section
className="pb-10 pt-2 is-enhanced"
data-nova-gallery
data-gallery-type={galleryType}
data-react-masonry-gallery
data-artworks={JSON.stringify(artworks)}
data-next-cursor={nextCursor ?? undefined}
data-next-page-url={nextPageUrl ?? undefined}
>
{artworks.length > 0 ? (
<>
<div
ref={gridRef}
>
<ArtworkGallery
items={artworks}
layout="masonry"
className={gridClass}
containerProps={{ 'data-gallery-grid': true }}
resolveCardProps={getMasonryCardProps}
/>
</div>
{/* Infinite scroll sentinel placed after the grid */}
{!done && (
<div
ref={triggerRef}
className="h-px w-full"
aria-hidden="true"
/>
)}
{/* Loading indicator */}
{loading && (
<div className="flex justify-center items-center gap-2 mt-8 py-4 text-white/30 text-sm">
<svg
className="animate-spin h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12" cy="12" r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v8H4z"
/>
</svg>
Loading more
</div>
)}
{done && artworks.length > 0 && (
<p className="text-center text-xs text-white/20 mt-8 py-2">
All caught up
</p>
)}
</>
) : (
/* Empty state gallery-type-specific messaging handled by caller */
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<p className="text-white/40 text-sm">No artworks found for this section yet.</p>
</div>
)}
</section>
);
}
export default memo(MasonryGallery);