From 4c409ceb81bc6f33880c5feaba0cd557b0f15c69 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 29 Aug 2026 12:25:50 +0200 Subject: [PATCH] Rebuild Inertia SSR artifacts after deferred artwork UI. Keep bootstrap/ssr in sync with the client Vite build shipped in this stack. --- bootstrap/ssr/ssr-manifest.json | 2 + bootstrap/ssr/ssr.js | 422 +++++++++++++++++++++++++------- 2 files changed, 341 insertions(+), 83 deletions(-) diff --git a/bootstrap/ssr/ssr-manifest.json b/bootstrap/ssr/ssr-manifest.json index bf340c57..d8c76881 100644 --- a/bootstrap/ssr/ssr-manifest.json +++ b/bootstrap/ssr/ssr-manifest.json @@ -2354,6 +2354,7 @@ "resources/js/components/profile/tabs/TabArtworks.jsx": [], "resources/js/components/profile/tabs/TabCollections.jsx": [], "resources/js/components/profile/tabs/TabFavourites.jsx": [], + "resources/js/components/profile/tabs/TabFollowers.jsx": [], "resources/js/components/profile/tabs/TabPosts.jsx": [], "resources/js/components/profile/tabs/TabStats.jsx": [], "resources/js/components/profile/tabs/TabStories.jsx": [], @@ -2471,6 +2472,7 @@ "resources/js/lib/uploadEndpoints.js": [], "resources/js/lib/uploadNotices.js": [], "resources/js/lib/uploadUtils.js": [], + "resources/js/lib/useDeferredSimilarAi.js": [], "resources/js/lib/useNavContext.js": [], "resources/js/lib/worldAnalytics.js": [], "resources/js/ssr.jsx": [], diff --git a/bootstrap/ssr/ssr.js b/bootstrap/ssr/ssr.js index 1b251a73..d7f0c1dd 100644 --- a/bootstrap/ssr/ssr.js +++ b/bootstrap/ssr/ssr.js @@ -41436,49 +41436,91 @@ function Skeleton$1() { } function ArtworkComments({ artworkId, - comments: initialComments = [], isLoggedIn = false, loginUrl = "/login" }) { - const [comments, setComments] = reactExports.useState(initialComments); + const [comments, setComments2] = reactExports.useState([]); const [loading, setLoading] = reactExports.useState(false); + const [error, setError] = reactExports.useState(false); const [page, setPage] = reactExports.useState(1); const [lastPage, setLastPage] = reactExports.useState(1); - const [total, setTotal] = reactExports.useState(initialComments.length); - const initialized = reactExports.useRef(false); + const [total, setTotal] = reactExports.useState(0); + const sectionRef = reactExports.useRef(null); + const requestRef = reactExports.useRef(null); + const generationRef = reactExports.useRef(0); const loadComments = reactExports.useCallback( async (p = 1) => { - if (!artworkId) return; + if (!artworkId || requestRef.current) return; + const generation = generationRef.current; + const controller = new AbortController(); + requestRef.current = controller; setLoading(true); + setError(false); try { - const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`); + const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`, { signal: controller.signal }); + if (generation !== generationRef.current) return; if (p === 1) { - setComments(data.data ?? []); + setComments2(data.data ?? []); } else { - setComments((prev) => [...prev, ...data.data ?? []]); + setComments2((prev) => [...prev, ...data.data ?? []]); } setPage(data.meta?.current_page ?? p); setLastPage(data.meta?.last_page ?? 1); setTotal(data.meta?.total ?? 0); } catch { + if (generation === generationRef.current && !controller.signal.aborted) setError(true); } finally { - setLoading(false); + if (requestRef.current === controller) { + requestRef.current = null; + if (generation === generationRef.current) setLoading(false); + } } }, [artworkId] ); reactExports.useEffect(() => { - if (initialized.current) return; - initialized.current = true; - if (artworkId && initialComments.length === 0) { - loadComments(1); - } else { - setTotal(initialComments.length); + generationRef.current += 1; + requestRef.current?.abort(); + requestRef.current = null; + setComments2([]); + setLoading(false); + setError(false); + setPage(1); + setLastPage(1); + setTotal(0); + const section = sectionRef.current; + if (!section || !artworkId) return void 0; + let fallbackTimer = null; + let observer = null; + let established = false; + const trigger = () => { + observer?.disconnect(); + if (!requestRef.current) loadComments(1); + }; + if (typeof window !== "undefined" && typeof window.IntersectionObserver === "function") { + try { + observer = new window.IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) trigger(); + }, { rootMargin: "1000px 0px" }); + observer.observe(section); + established = true; + } catch { + observer?.disconnect(); + } } - }, [artworkId, initialComments.length, loadComments]); + if (!established && typeof window !== "undefined") { + fallbackTimer = window.setTimeout(trigger, 1200); + } + return () => { + observer?.disconnect(); + if (fallbackTimer !== null && typeof window !== "undefined") window.clearTimeout(fallbackTimer); + requestRef.current?.abort(); + requestRef.current = null; + }; + }, [artworkId, loadComments]); const handlePosted = reactExports.useCallback((newComment) => { const comment = { ...newComment, replies: newComment.replies || [] }; - setComments((prev) => [comment, ...prev]); + setComments2((prev) => [comment, ...prev]); setTotal((t) => t + 1); }, []); const handleReplyPosted = reactExports.useCallback((parentId, newReply) => { @@ -41491,10 +41533,10 @@ function ArtworkComments({ } return c; }); - setComments((prev) => insertReply(prev)); + setComments2((prev) => insertReply(prev)); setTotal((t) => t + 1); }, []); - return /* @__PURE__ */ React.createElement("section", { "aria-label": "Comments", className: "space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold tracking-tight text-white sm:text-xl" }, "Comments"), total > 0 && /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium tabular-nums text-white/50" }, total)), loading && comments.length === 0 ? /* @__PURE__ */ React.createElement(Skeleton$1, null) : comments.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center" }, /* @__PURE__ */ React.createElement(ChatBubbleIcon$1, null), /* @__PURE__ */ React.createElement("p", { className: "text-sm font-medium text-white/40" }, "No comments yet"), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-white/25" }, "Be the first to share your thoughts.")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("ul", { className: "space-y-3 sm:space-y-4" }, comments.map((comment) => /* @__PURE__ */ React.createElement( + return /* @__PURE__ */ React.createElement("section", { ref: sectionRef, "aria-label": "Comments", className: "space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold tracking-tight text-white sm:text-xl" }, "Comments"), total > 0 && /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium tabular-nums text-white/50" }, total)), loading && comments.length === 0 ? /* @__PURE__ */ React.createElement(Skeleton$1, null) : error && comments.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-8 text-center" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-medium text-white/45" }, "Comments could not be loaded."), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => loadComments(1), className: "mt-3 rounded-full border border-white/[0.1] px-4 py-2 text-xs font-medium text-white/65 hover:bg-white/[0.06]" }, "Try again")) : comments.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center" }, /* @__PURE__ */ React.createElement(ChatBubbleIcon$1, null), /* @__PURE__ */ React.createElement("p", { className: "text-sm font-medium text-white/40" }, "No comments yet"), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-white/25" }, "Be the first to share your thoughts.")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("ul", { className: "space-y-3 sm:space-y-4" }, comments.map((comment) => /* @__PURE__ */ React.createElement( CommentItem$3, { key: comment.id, @@ -42906,6 +42948,20 @@ function ArtworkRecommendationsRails({ artwork, related = [], similarApiData = [ return /* @__PURE__ */ React.createElement("div", { className: "space-y-14" }, /* @__PURE__ */ React.createElement(Rail, { title: "Similar Artworks", emoji: "✨", items: similarItems, seeAllHref: similarHref }), /* @__PURE__ */ React.createElement(Rail, { title: trendingLabel, emoji: "🔥", items: trendingRailItems, seeAllHref: trendingHref })); } const fallbackCache = /* @__PURE__ */ new Map(); +function normalizeNavigation(data) { + if (!data || typeof data !== "object") return null; + const hasPrev = data.prev_id == null || data.prev_url && data.prev_slug; + const hasNext = data.next_id == null || data.next_url && data.next_slug; + if (!hasPrev || !hasNext) return null; + return { + prevId: data.prev_id ?? null, + nextId: data.next_id ?? null, + prevUrl: data.prev_url ?? null, + nextUrl: data.next_url ?? null, + prevPreview: data.prev_preview ?? null, + nextPreview: data.next_preview ?? null + }; +} async function fetchFallback(artworkId) { const key = String(artworkId); if (fallbackCache.has(key)) return fallbackCache.get(key); @@ -42913,27 +42969,25 @@ async function fetchFallback(artworkId) { const res = await fetch(`/api/artworks/navigation/${artworkId}`, { headers: { Accept: "application/json" } }); - if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null }; + if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null }; const data = await res.json(); - const result = { - prevId: data.prev_id ?? null, - nextId: data.next_id ?? null, - prevUrl: data.prev_url ?? null, - nextUrl: data.next_url ?? null - }; + const result = normalizeNavigation(data) || { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null }; fallbackCache.set(key, result); return result; } catch { - return { prevId: null, nextId: null, prevUrl: null, nextUrl: null }; + return { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null }; } } -function useNavContext(currentArtworkId) { +function useNavContext(currentArtworkId, embeddedNavigation = null) { const getNeighbors = reactExports.useCallback(async () => { + const embedded = normalizeNavigation(embeddedNavigation); + if (embedded) return embedded; return fetchFallback(currentArtworkId); - }, [currentArtworkId]); + }, [currentArtworkId, embeddedNavigation]); return { getNeighbors }; } const preloadCache = /* @__PURE__ */ new Set(); +const prefetchCache = /* @__PURE__ */ new Set(); function scheduleIdleTask(callback, delay = 1200) { if (typeof window === "undefined") { callback(); @@ -42953,8 +43007,8 @@ function preloadImage(src2) { const img = new Image(); img.src = src2; } -function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) { - const { getNeighbors } = useNavContext(artworkId); +function ArtworkNavigator({ artworkId, navigation: navigation2 = null, onNavigate, onOpenViewer, onReady }) { + const { getNeighbors } = useNavContext(artworkId, navigation2); const [neighbors, setNeighbors] = reactExports.useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null }); const navigatingRef = reactExports.useRef(false); const neighborsRef = reactExports.useRef(neighbors); @@ -42989,22 +43043,45 @@ function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) { reactExports.useEffect(() => { const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean); if (ids.length === 0) return void 0; + const previews = /* @__PURE__ */ new Map([ + [neighbors.prevId, neighbors.prevPreview], + [neighbors.nextId, neighbors.nextPreview] + ]); let cancelled = false; const cancelIdleTask = scheduleIdleTask(() => { ids.forEach((id) => { - fetch(`/api/artworks/${id}/page`, { headers: { Accept: "application/json" } }).then((r) => r.ok ? r.json() : null).then((data) => { - if (cancelled || !data) return; + if (prefetchCache.has(id)) return; + prefetchCache.add(id); + const preview = previews.get(id); + if (preview) { + if (!cancelled) { + const imgUrl = preview.thumbs?.lg?.url || preview.thumbs?.md?.url; + if (imgUrl) preloadImage(imgUrl); + } + return; + } + fetch(`/api/artworks/${id}/page?prefetch=1`, { headers: { Accept: "application/json" } }).then((r) => { + if (!r.ok) { + prefetchCache.delete(id); + return null; + } + return r.json(); + }).then((data) => { + if (!data) { + prefetchCache.delete(id); + return; + } + if (cancelled) return; const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url; if (imgUrl) preloadImage(imgUrl); - }).catch(() => { - }); + }).catch(() => prefetchCache.delete(id)); }); }); return () => { cancelled = true; cancelIdleTask(); }; - }, [neighbors.prevId, neighbors.nextId]); + }, [neighbors.prevId, neighbors.nextId, neighbors.prevPreview, neighbors.nextPreview]); const navigate = reactExports.useCallback(async (targetId, targetUrl) => { if (!targetId && !targetUrl) return; if (navigatingRef.current) return; @@ -43194,6 +43271,140 @@ function GroupSummaryPanel({ group, artwork }) { if (!group) return null; return /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[28px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_36%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-5 py-5 shadow-[0_22px_55px_rgba(0,0,0,0.26)] backdrop-blur-xl sm:px-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-4" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public || "/groups", className: "flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]" }, group.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: group.avatar_url, alt: group.name, className: "h-full w-full object-cover", loading: "lazy" }) : /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-people-group text-slate-300" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Published as a group"), /* @__PURE__ */ React.createElement("a", { href: group.urls?.public || "/groups", className: "mt-2 block text-xl font-semibold tracking-[-0.02em] text-white transition hover:text-sky-300" }, group.name), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-white/65" }, group.headline || group.bio_excerpt || `${artwork?.title || "This artwork"} is credited to a collaborative group identity on Skinbase.`))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, (Array.isArray(group.trust_signals) ? group.trust_signals : []).slice(0, 3).map((signal) => /* @__PURE__ */ React.createElement(GroupBadgePill, { key: signal.key, label: signal.label, tone: signal.tone })), group.is_recruiting ? /* @__PURE__ */ React.createElement(GroupBadgePill, { label: "Recruiting", tone: "emerald" }) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-black/20 p-3 text-center" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-lg font-semibold text-white" }, NUMBER_FORMATTER.format(Number(group.counts?.artworks || 0))), /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-500" }, "Artworks")), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-lg font-semibold text-white" }, NUMBER_FORMATTER.format(Number(group.counts?.members || 0))), /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-500" }, "Members")), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-lg font-semibold text-white" }, NUMBER_FORMATTER.format(Number(group.counts?.followers || 0))), /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-500" }, "Followers"))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public || "/groups", className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15" }, "Open group page"), group.urls?.releases ? /* @__PURE__ */ React.createElement("a", { href: group.urls.releases, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]" }, "Browse releases") : null)); } +const REGISTRY = /* @__PURE__ */ new Map(); +const ROOT_MARGIN = "800px 0px"; +const IDLE_TIMEOUT = 3e3; +function scheduleIdle(callback) { + if (typeof window.requestIdleCallback === "function") { + const handle2 = window.requestIdleCallback(callback, { timeout: IDLE_TIMEOUT }); + return () => window.cancelIdleCallback?.(handle2); + } + const handle = window.setTimeout(callback, IDLE_TIMEOUT); + return () => window.clearTimeout(handle); +} +function useDeferredSimilarAi(artworkId, anchorRef) { + const [state, setState] = reactExports.useState(() => ({ + id: artworkId, + items: REGISTRY.get(Number(artworkId || 0))?.items || [] + })); + const [loading, setLoading] = reactExports.useState(false); + const [error, setError] = reactExports.useState(false); + reactExports.useEffect(() => { + let cancelled = false; + const id = Number(artworkId || 0); + const controller = new AbortController(); + let attachedEntry = null; + let triggered = false; + const currentEntry = REGISTRY.get(id); + const cached = currentEntry?.status === "completed" ? currentEntry.items : null; + setState({ id, items: cached || [] }); + setLoading(false); + setError(false); + if (!id || cached) { + return () => controller.abort(); + } + let stopIdle = () => { + }; + let observer = null; + let observationEstablished = false; + const detach = () => { + if (!attachedEntry) return; + attachedEntry.subscribers -= 1; + const entry = attachedEntry; + attachedEntry = null; + if (entry.subscribers === 0 && entry.status === "in-flight") { + entry.controller.abort(); + } + }; + const attach = (entry) => { + if (cancelled || attachedEntry === entry) return; + attachedEntry = entry; + entry.subscribers += 1; + setLoading(true); + entry.promise.then((nextItems) => { + if (!cancelled) setState({ id, items: nextItems }); + }).catch((requestError) => { + if (!cancelled && requestError?.name !== "AbortError") { + setState({ id, items: [] }); + setError(true); + } + }).finally(() => { + if (!cancelled) setLoading(false); + if (attachedEntry === entry) { + attachedEntry = null; + entry.subscribers -= 1; + } + }); + }; + const load = async () => { + if (triggered || cancelled) return; + triggered = true; + observer?.disconnect(); + stopIdle(); + let entry = REGISTRY.get(id); + if (entry?.status === "completed") { + if (!cancelled) setState({ id, items: entry.items }); + return; + } + if (entry?.status !== "in-flight" || entry.controller.signal.aborted) { + const requestController = new AbortController(); + entry = { + status: "in-flight", + controller: requestController, + promise: null, + subscribers: 0 + }; + REGISTRY.set(id, entry); + entry.promise = Promise.resolve().then(() => fetch(`/api/art/${id}/similar-ai`, { + credentials: "same-origin", + signal: requestController.signal + })).then((response) => { + if (!response.ok) throw new Error("similar fetch failed"); + return response.json(); + }).then((payload) => { + const nextItems = Array.isArray(payload?.data) ? payload.data : []; + if (REGISTRY.get(id) === entry) { + REGISTRY.set(id, { status: "completed", items: nextItems }); + } + return nextItems; + }).catch((requestError) => { + if (REGISTRY.get(id) === entry) REGISTRY.delete(id); + throw requestError; + }); + entry.promise.catch(() => { + }); + } + attach(entry); + }; + if (typeof window.IntersectionObserver === "function" && anchorRef?.current) { + try { + observer = new window.IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) load(); + }, { rootMargin: ROOT_MARGIN }); + observer.observe(anchorRef.current); + observationEstablished = true; + } catch { + observer?.disconnect(); + observer = null; + } + } + if (!observationEstablished) { + stopIdle = scheduleIdle(load); + } + return () => { + cancelled = true; + observer?.disconnect(); + stopIdle(); + detach(); + controller.abort(); + }; + }, [artworkId, anchorRef]); + return { + items: Number(state.id || 0) === Number(artworkId || 0) ? state.items : [], + loading: Number(state.id || 0) === Number(artworkId || 0) ? loading : false, + error: Number(state.id || 0) === Number(artworkId || 0) ? error : false + }; +} function publisherToGroupSummary(publisher) { if (!publisher || publisher.type !== "group") return null; return { @@ -43215,7 +43426,7 @@ function publisherToGroupSummary(publisher) { } }; } -function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [], groupSummary: initialGroupSummary = null, reactionTotals: initialReactionTotals = {}, seo = null }) { +function ArtworkPage({ artwork: initialArtwork, navigation: initialNavigation = null, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, groupSummary: initialGroupSummary = null, reactionTotals: initialReactionTotals = {}, seo = null }) { const [viewerOpen, setViewerOpen] = reactExports.useState(false); const [showMatureArtwork, setShowMatureArtwork] = reactExports.useState(false); const openViewer = reactExports.useCallback(() => setViewerOpen(true), []); @@ -43236,12 +43447,12 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present const [presentXl, setPresentXl] = reactExports.useState(initialXl); const [presentSq, setPresentSq] = reactExports.useState(initialSq); const [related, setRelated] = reactExports.useState(initialRelated); - const [comments, setComments] = reactExports.useState(initialComments); const [canonicalUrl, setCanonicalUrl] = reactExports.useState(initialCanonical); const [groupSummary, setGroupSummary] = reactExports.useState(initialGroupSummary || publisherToGroupSummary(initialArtwork?.publisher)); const [selectedMediaId, setSelectedMediaId] = reactExports.useState("cover"); - const [similarRecommendations, setSimilarRecommendations] = reactExports.useState([]); const [trendingRecommendations, setTrendingRecommendations] = reactExports.useState([]); + const similarAiAnchorRef = reactExports.useRef(null); + const { items: deferredSimilarRecommendations } = useDeferredSimilarAi(artwork?.id, similarAiAnchorRef); const [navState, setNavState] = reactExports.useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null }); const initialArtworkIdRef = reactExports.useRef(initialArtwork?.id); const [reactionTotals, setReactionTotals] = reactExports.useState(initialReactionTotals ?? {}); @@ -43250,27 +43461,6 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present if (artwork.id === initialArtworkIdRef.current) return; axios.get(`/api/artworks/${artwork.id}/reactions`).then(({ data }) => setReactionTotals(data.totals ?? {})).catch(() => setReactionTotals({})); }, [artwork?.id]); - reactExports.useEffect(() => { - let isCancelled = false; - const loadSimilarRecommendations = async () => { - if (!artwork?.id) { - setSimilarRecommendations([]); - return; - } - try { - const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: "same-origin" }); - if (!response.ok) throw new Error("similar fetch failed"); - const payload = await response.json(); - if (!isCancelled) setSimilarRecommendations(payload?.data || []); - } catch { - if (!isCancelled) setSimilarRecommendations([]); - } - }; - loadSimilarRecommendations(); - return () => { - isCancelled = true; - }; - }, [artwork?.id]); reactExports.useEffect(() => { let isCancelled = false; const loadTrendingRecommendations = async () => { @@ -43308,7 +43498,6 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present setCanonicalUrl(data.canonical_url ?? window.location.href); setGroupSummary(data.group_summary ?? publisherToGroupSummary(data.publisher)); setSelectedMediaId("cover"); - setSimilarRecommendations([]); setTrendingRecommendations([]); setViewerOpen(false); setShowMatureArtwork(false); @@ -43424,22 +43613,22 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present ArtworkComments, { artworkId: artwork.id, - comments, isLoggedIn: isAuthenticated, loginUrl: "/login" } - )), /* @__PURE__ */ React.createElement("aside", { className: "space-y-5 lg:sticky lg:top-6 lg:self-start" }, /* @__PURE__ */ React.createElement(CreatorSpotlight, { artwork, presentSq, related }), groupSummary ? /* @__PURE__ */ React.createElement(GroupSummaryPanel, { group: groupSummary, artwork }) : null, /* @__PURE__ */ React.createElement(ArtworkDetailsPanel, { artwork, stats: liveStats }), /* @__PURE__ */ React.createElement(ArtworkAwards, { artwork, initialAwards, isAuthenticated })))), /* @__PURE__ */ React.createElement("div", { className: "mt-14 w-full max-w-screen-2xl mx-auto min-h-[640px]" }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement("aside", { className: "space-y-5 lg:sticky lg:top-6 lg:self-start" }, /* @__PURE__ */ React.createElement(CreatorSpotlight, { artwork, presentSq, related }), groupSummary ? /* @__PURE__ */ React.createElement(GroupSummaryPanel, { group: groupSummary, artwork }) : null, /* @__PURE__ */ React.createElement(ArtworkDetailsPanel, { artwork, stats: liveStats }), /* @__PURE__ */ React.createElement(ArtworkAwards, { artwork, initialAwards, isAuthenticated })))), /* @__PURE__ */ React.createElement("div", { ref: similarAiAnchorRef, className: "mt-14 w-full max-w-screen-2xl mx-auto min-h-[640px]" }, /* @__PURE__ */ React.createElement( ArtworkRecommendationsRails, { artwork, related, - similarApiData: similarRecommendations, + similarApiData: deferredSimilarRecommendations, trendingData: trendingRecommendations } ))), /* @__PURE__ */ React.createElement( ArtworkNavigator, { artworkId: artwork.id, + navigation: artwork.navigation || initialNavigation, onNavigate: handleNavigate, onOpenViewer: openViewer, onReady: setNavState @@ -79881,7 +80070,7 @@ function CollectionShow() { reportEndpoint } = props; const [collection, setCollection] = React.useState(initialCollection); - const [comments, setComments] = React.useState(initialComments || []); + const [comments, setComments2] = React.useState(initialComments || []); const [submissions, setSubmissions] = React.useState(initialSubmissions || []); const [selectedArtworkId, setSelectedArtworkId] = React.useState(submissionArtworkOptions?.[0]?.id || ""); const [state, setState] = React.useState({ @@ -80148,12 +80337,12 @@ function CollectionShow() { method: "POST", body: { body: body2 } }); - setComments(payload?.comments || []); + setComments2(payload?.comments || []); setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count })); } async function handleDeleteComment(commentId) { const payload = await requestJson$l(`${commentsEndpoint}/${commentId}`, { method: "DELETE" }); - setComments(payload?.comments || []); + setComments2(payload?.comments || []); setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count })); } async function handleSubmitArtwork() { @@ -82717,7 +82906,7 @@ const FILTER_TABS = [ ]; function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenticated = false }) { const [activeFilter, setActiveFilter] = reactExports.useState("all"); - const [comments, setComments] = reactExports.useState(initialComments); + const [comments, setComments2] = reactExports.useState(initialComments); const [meta, setMeta] = reactExports.useState(initialMeta); const [loading, setLoading] = reactExports.useState(false); const [error, setError] = reactExports.useState(null); @@ -82733,7 +82922,7 @@ function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenti }); if (res.status === 401) { setError("Please log in to view this feed."); - setComments([]); + setComments2([]); setMeta({}); return; } @@ -82742,7 +82931,7 @@ function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenti return; } const json = await res.json(); - setComments(json.data ?? []); + setComments2(json.data ?? []); setMeta(json.meta ?? {}); } catch { setError("Network error. Please try again."); @@ -83131,7 +83320,7 @@ function formatRelative$1(isoString) { return `${d2}d ago`; } function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) { - const [comments, setComments] = reactExports.useState([]); + const [comments, setComments2] = reactExports.useState([]); const [loading, setLoading] = reactExports.useState(false); const [submitting, setSubmitting] = reactExports.useState(false); const [body2, setBody] = reactExports.useState(""); @@ -83144,7 +83333,7 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) { setLoading(true); try { const { data } = await axios.get(`/api/posts/${postId}/comments`, { params: { page: p } }); - setComments((prev) => p === 1 ? data.data : [...prev, ...data.data]); + setComments2((prev) => p === 1 ? data.data : [...prev, ...data.data]); setHasMore(data.meta.current_page < data.meta.last_page); setPage(p); } catch { @@ -83163,7 +83352,7 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) { setError(null); try { const { data } = await axios.post(`/api/posts/${postId}/comments`, { body: body2 }); - setComments((prev) => [...prev, data.comment]); + setComments2((prev) => [...prev, data.comment]); setBody(""); } catch (err) { setError(err.response?.data?.message ?? "Failed to post comment."); @@ -83175,7 +83364,7 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) { if (!window.confirm("Delete this comment?")) return; try { await axios.delete(`/api/posts/${postId}/comments/${commentId}`); - setComments((prev) => prev.filter((c) => c.id !== commentId)); + setComments2((prev) => prev.filter((c) => c.id !== commentId)); } catch { } }; @@ -83183,12 +83372,12 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) { try { if (comment.is_highlighted) { await axios.delete(`/api/posts/${postId}/comments/${comment.id}/highlight`); - setComments( + setComments2( (prev) => prev.map((c) => c.id === comment.id ? { ...c, is_highlighted: false } : c) ); } else { await axios.post(`/api/posts/${postId}/comments/${comment.id}/highlight`); - setComments( + setComments2( (prev) => prev.map((c) => ({ ...c, is_highlighted: c.id === comment.id })) ); } @@ -94339,7 +94528,7 @@ function Skeleton() { ))); } function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) { - const [comments, setComments] = reactExports.useState([]); + const [comments, setComments2] = reactExports.useState([]); const [loading, setLoading] = reactExports.useState(false); const [page, setPage] = reactExports.useState(1); const [lastPage, setLastPage] = reactExports.useState(1); @@ -94352,9 +94541,9 @@ function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) { try { const { data } = await axios.get(`/api/news/articles/${articleId}/comments?page=${nextPage}`); if (nextPage === 1) { - setComments(data.data ?? []); + setComments2(data.data ?? []); } else { - setComments((current) => [...current, ...data.data ?? []]); + setComments2((current) => [...current, ...data.data ?? []]); } setPage(data.meta?.current_page ?? nextPage); setLastPage(data.meta?.last_page ?? 1); @@ -94371,7 +94560,7 @@ function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) { }, [loadComments]); const handlePosted = reactExports.useCallback((newComment) => { const comment = { ...newComment, replies: newComment.replies || [] }; - setComments((current) => [comment, ...current]); + setComments2((current) => [comment, ...current]); setTotal((current) => current + 1); }, []); const handleReplyPosted = reactExports.useCallback((parentId, newReply) => { @@ -94384,7 +94573,7 @@ function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) { } return comment; }); - setComments((current) => insertReply(current)); + setComments2((current) => insertReply(current)); setTotal((current) => current + 1); }, []); const handleDelete = reactExports.useCallback(async (commentId) => { @@ -95510,7 +95699,8 @@ const TABS$1 = [ { id: "about", label: "About", icon: "fa-id-card" }, { id: "stats", label: "Stats", icon: "fa-chart-bar" }, { id: "favourites", label: "Favourites", icon: "fa-heart" }, - { id: "activity", label: "Activity", icon: "fa-bolt" } + { id: "activity", label: "Activity", icon: "fa-bolt" }, + { id: "followers", label: "Followers", icon: "fa-users" } ]; function ProfileTabs({ activeTab, onTabChange }) { const navRef = reactExports.useRef(null); @@ -98120,12 +98310,65 @@ function TabWorlds({ worldHistory, isOwner }) { /* @__PURE__ */ React.createElement(ProfileWorldHistorySection, { history: worldHistory, isOwner }) ); } +function formatFollowedAt(value) { + if (!value) return null; + try { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleDateString(void 0, { + year: "numeric", + month: "short", + day: "numeric" + }); + } catch { + return null; + } +} +function TabFollowers({ listing, username, followerCount }) { + const items = Array.isArray(listing?.data) ? listing.data : []; + const total = Number(listing?.total ?? followerCount ?? 0); + const currentPage = Number(listing?.current_page ?? 1); + const lastPage = Number(listing?.last_page ?? 1); + const prevUrl = listing?.prev_page_url ?? null; + const nextUrl = listing?.next_page_url ?? null; + return /* @__PURE__ */ React.createElement( + "div", + { + id: "tabpanel-followers", + role: "tabpanel", + "aria-labelledby": "tab-followers", + className: "pt-6" + }, + /* @__PURE__ */ React.createElement("h2", { className: "mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-users text-sky-400 fa-fw" }), "Followers", /* @__PURE__ */ React.createElement("span", { className: "rounded bg-white/5 px-1.5 py-0.5 text-[10px] font-medium normal-case tracking-normal text-slate-400" }, total.toLocaleString())), + items.length === 0 ? /* @__PURE__ */ React.createElement("p", { className: "rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-8 text-center text-sm text-slate-400" }, username ? `${username} has no public followers yet.` : "No public followers yet.") : /* @__PURE__ */ React.createElement("ul", { className: "grid gap-3 sm:grid-cols-2" }, items.map((follower) => { + const handle = follower.username || follower.display_name || follower.name; + const followedAt = formatFollowedAt(follower.followed_at); + return /* @__PURE__ */ React.createElement("li", { key: follower.id }, /* @__PURE__ */ React.createElement( + "a", + { + href: follower.profile_url, + className: "flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-3 py-3 transition-colors hover:border-sky-300/25 hover:bg-white/[0.06]" + }, + follower.avatar_url ? /* @__PURE__ */ React.createElement( + "img", + { + src: follower.avatar_url, + alt: "", + className: "h-12 w-12 rounded-xl object-cover" + } + ) : /* @__PURE__ */ React.createElement("span", { className: "flex h-12 w-12 items-center justify-center rounded-xl bg-white/5 text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user" })), + /* @__PURE__ */ React.createElement("span", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("span", { className: "block truncate text-sm font-semibold text-white" }, handle), follower.username ? /* @__PURE__ */ React.createElement("span", { className: "block truncate text-xs text-slate-400" }, "@", follower.username) : null, /* @__PURE__ */ React.createElement("span", { className: "mt-1 block text-[11px] text-slate-500" }, [follower.rank, followedAt ? `Followed ${followedAt}` : null].filter(Boolean).join(" · "))) + )); + })), + lastPage > 1 ? /* @__PURE__ */ React.createElement("nav", { className: "mt-6 flex items-center justify-center gap-3 text-sm", "aria-label": "Followers pagination" }, prevUrl ? /* @__PURE__ */ React.createElement("a", { href: prevUrl, className: "rounded-xl border border-white/10 px-3 py-2 text-slate-300 hover:bg-white/5" }, "Previous") : /* @__PURE__ */ React.createElement("span", { className: "rounded-xl border border-white/5 px-3 py-2 text-slate-600" }, "Previous"), /* @__PURE__ */ React.createElement("span", { className: "text-slate-500" }, "Page ", currentPage, " of ", lastPage), nextUrl ? /* @__PURE__ */ React.createElement("a", { href: nextUrl, className: "rounded-xl border border-white/10 px-3 py-2 text-slate-300 hover:bg-white/5" }, "Next") : /* @__PURE__ */ React.createElement("span", { className: "rounded-xl border border-white/5 px-3 py-2 text-slate-600" }, "Next")) : null + ); +} function GroupProfileSummary({ contributions = [], href = null }) { if (!Array.isArray(contributions) || contributions.length === 0) return null; const featured = contributions.slice(0, 3); return /* @__PURE__ */ React.createElement("section", { className: "mx-auto mt-8 max-w-6xl px-4 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.12),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_18px_55px_rgba(2,6,23,0.28)] backdrop-blur-xl sm:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80" }, "Group footprint"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Collaborative work across public groups"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-6 text-slate-400" }, "See the groups this creator contributes to through releases, credited artworks, and shared publishing activity.")), href ? /* @__PURE__ */ React.createElement("a", { href, className: "text-sm font-semibold text-sky-200 transition hover:text-white" }, "View full contribution history") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-4 lg:grid-cols-3" }, featured.map((entry) => /* @__PURE__ */ React.createElement("a", { key: entry.group?.slug || entry.group?.id, href: entry.group?.profile_url || "/groups", className: "rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20 hover:bg-black/30" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]" }, entry.group?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: entry.group.avatar_url, alt: entry.group?.name, className: "h-full w-full object-cover", loading: "lazy" }) : /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-people-group text-slate-300" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "truncate text-base font-semibold text-white" }, entry.group?.name), entry.role?.label ? /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, entry.role.label) : null, entry.summary ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-slate-300" }, entry.summary) : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, entry.trusted ? /* @__PURE__ */ React.createElement(GroupBadgePill, { label: "Trusted contributor", tone: "sky" }) : null, entry.recent_release_titles?.length ? /* @__PURE__ */ React.createElement(GroupBadgePill, { label: "Recent releases", tone: "amber" }) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-4 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.artworks || 0).toLocaleString("en-US"), " artworks"), /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.releases || 0).toLocaleString("en-US"), " releases"), /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.projects || 0).toLocaleString("en-US"), " projects"))))))); } -const VALID_TABS = ["posts", "artworks", "stories", "achievements", "worlds", "collections", "about", "stats", "favourites", "activity"]; +const VALID_TABS = ["posts", "artworks", "stories", "achievements", "worlds", "collections", "about", "stats", "favourites", "activity", "followers"]; function getInitialTab(initialTab = "posts") { if (typeof window === "undefined") { return VALID_TABS.includes(initialTab) ? initialTab : "posts"; @@ -98154,6 +98397,7 @@ function ProfileShow() { socialLinks, followerCount, recentFollowers, + followersListing, followContext, followAnalytics, suggestedUsers, @@ -98184,6 +98428,11 @@ function ProfileShow() { const [activeTab, setActiveTab] = reactExports.useState(() => getInitialTab(initialTab)); const handleTabChange = reactExports.useCallback((tab2) => { if (!VALID_TABS.includes(tab2)) return; + if (tab2 === "followers") { + const targetBase = profileTabUrls?.followers || `${profileUrl || ""}/followers`; + window.location.assign(targetBase); + return; + } setActiveTab(tab2); try { const currentUrl = new URL(window.location.href); @@ -98344,6 +98593,13 @@ function ProfileShow() { followerCount, creatorStories } + ), activeTab === "followers" && /* @__PURE__ */ React.createElement( + TabFollowers, + { + listing: followersListing, + username: user.username || user.name, + followerCount + } )))); } const __vite_glob_0_110 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({