Compare commits

..
2 Commits
Author SHA1 Message Date
test 937f484cc9 Defer profile world history until needed 2026-08-30 12:02:07 +02:00
test 5c80402aed fix 2026-08-30 11:06:07 +02:00
10 changed files with 510 additions and 70 deletions
@@ -63,6 +63,8 @@ use Inertia\Inertia;
class ProfileController extends Controller
{
private const SSR_WORLD_HISTORY_TABS = ['achievements', 'worlds'];
private const PROFILE_TABS = [
'posts',
'artworks',
@@ -207,6 +209,26 @@ class ProfileController extends Controller
]);
}
/**
* Read-only world history payload for profile tabs that are loaded client-side.
* This deliberately does not render the profile document or track a view.
*/
public function worldHistory(Request $request, string $username): JsonResponse
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
if (! $user) {
return response()->json(['error' => 'User not found'], 404);
}
$isOwner = Auth::check() && Auth::id() === $user->id;
return response()->json($isOwner
? $this->worldProfileHistory->ownerPayloadForUser($user)
: $this->worldProfileHistory->publicPayloadForUser($user));
}
public function legacyById(Request $request, int $id, ?string $username = null)
{
$user = User::query()->findOrFail($id);
@@ -1292,9 +1314,11 @@ class ProfileController extends Controller
->all();
$achievementSummary = $this->achievements->summary((int) $user->id);
$worldRewardSummary = $this->worldRewards->summaryForUser($user);
$worldHistory = $isOwner
? $this->worldProfileHistory->ownerPayloadForUser($user)
: $this->worldProfileHistory->publicPayloadForUser($user);
$worldHistory = in_array($initialTab, self::SSR_WORLD_HISTORY_TABS, true)
? ($isOwner
? $this->worldProfileHistory->ownerPayloadForUser($user)
: $this->worldProfileHistory->publicPayloadForUser($user))
: null;
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
$groupContributionHistory = $this->buildGroupContributionHistory($user);
$journey = $this->creatorJourney->publicPayloadForUser($user);
+3
View File
@@ -2467,12 +2467,15 @@
"resources/js/hooks/upload/useVisionTags.js": [],
"resources/js/hooks/useWebShare.js": [],
"resources/js/lib/academyAnalytics.js": [],
"resources/js/lib/profileFormatters.js": [],
"resources/js/lib/security/botFingerprint.js": [],
"resources/js/lib/uploadAnalytics.js": [],
"resources/js/lib/uploadEndpoints.js": [],
"resources/js/lib/uploadNotices.js": [],
"resources/js/lib/uploadUtils.js": [],
"resources/js/lib/useDeferredSimilarAi.js": [],
"resources/js/lib/useLazyProfileFavourites.js": [],
"resources/js/lib/useLazyProfileFeaturedArtworks.js": [],
"resources/js/lib/useNavContext.js": [],
"resources/js/lib/worldAnalytics.js": [],
"resources/js/ssr.jsx": [],
+225 -59
View File
@@ -42455,6 +42455,21 @@ function NovaConfirmDialog({
document.body
);
}
const PROFILE_LOCALE = "en-US";
const PROFILE_TIME_ZONE = "UTC";
function formatProfileNumber(value) {
const numeric = Number(value ?? 0);
return Number.isFinite(numeric) ? new Intl.NumberFormat(PROFILE_LOCALE).format(numeric) : "0";
}
function formatProfileDate(value, options = {}) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat(PROFILE_LOCALE, {
...options,
timeZone: PROFILE_TIME_ZONE
}).format(date);
}
function FollowButton({
username,
initialFollowing = false,
@@ -42523,7 +42538,7 @@ function FollowButton({
},
/* @__PURE__ */ React.createElement("i", { className: `fa-solid fa-fw ${loading ? "fa-circle-notch fa-spin" : following ? hovered ? "fa-user-minus" : "fa-user-check" : "fa-user-plus"}` }),
/* @__PURE__ */ React.createElement("span", null, following ? hovered ? "Unfollow" : "Following" : "Follow"),
showCount ? /* @__PURE__ */ React.createElement("span", { className: "text-xs opacity-70" }, count.toLocaleString()) : null
showCount ? /* @__PURE__ */ React.createElement("span", { className: "text-xs opacity-70" }, formatProfileNumber(count)) : null
), /* @__PURE__ */ React.createElement(
NovaConfirmDialog,
{
@@ -76057,13 +76072,7 @@ async function requestJson$p(url, { method = "GET", body: body2 } = {}) {
}
function formatUpdated(value) {
if (!value) return "Updated recently";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Updated recently";
return new Intl.DateTimeFormat(void 0, {
month: "short",
day: "numeric",
year: "numeric"
}).format(date);
return formatProfileDate(value, { month: "short", day: "numeric", year: "numeric" }) || "Updated recently";
}
function StatPill$1({ icon, label, value }) {
return /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[11px] font-medium text-slate-300" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${icon} text-[10px] text-slate-400` }), /* @__PURE__ */ React.createElement("span", { className: "text-white" }, value), /* @__PURE__ */ React.createElement("span", null, label));
@@ -76140,7 +76149,7 @@ function CollectionCard({ collection, isOwner, onDelete, onToggleFeature, onMove
className: `group relative overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] shadow-[0_22px_60px_rgba(2,6,23,0.22)] transition-all duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:bg-white/[0.06] ${busy ? "opacity-70" : ""} lg:max-w-[360px] lg:mx-auto`
},
/* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(249,115,22,0.12),transparent_28%)] opacity-0 transition duration-300 group-hover:opacity-100" }),
/* @__PURE__ */ React.createElement("div", { className: "relative" }, /* @__PURE__ */ React.createElement(CoverMedia, { collection, isOwner }), /* @__PURE__ */ React.createElement("div", { className: "p-5" }, /* @__PURE__ */ React.createElement("div", { className: "mb-3 flex flex-wrap items-center gap-2" }, collection?.is_featured ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Featured") : null, collection?.mode === "smart" ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, "Smart") : null, !isOwner && collection?.program_key ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-lime-300/25 bg-lime-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-lime-100" }, "Program · ", collection.program_key) : null, !isOwner && collection?.partner_label ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200" }, "Partner · ", collection.partner_label) : null, !isOwner && collection?.sponsorship_label ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Sponsor · ", collection.sponsorship_label) : null, isOwner ? /* @__PURE__ */ React.createElement(CollectionVisibilityBadge, { visibility: collection?.visibility }) : null), /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("h3", { className: "truncate text-lg font-semibold tracking-[-0.03em] text-white" }, collection?.title), collection?.subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 truncate text-sm text-slate-300" }, collection.subtitle) : null, collection?.owner?.name ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 truncate text-sm text-slate-400" }, "Curated by ", collection.owner.name, collection?.owner?.username ? ` • @${collection.owner.username}` : "") : null, /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, (collection?.artworks_count ?? 0).toLocaleString(), " artworks"))), collection?.description_excerpt ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300" }, collection.description_excerpt) : collection?.smart_summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300" }, collection.smart_summary) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement(StatPill$1, { icon: "fa-heart", label: "likes", value: (collection?.likes_count ?? 0).toLocaleString() }), /* @__PURE__ */ React.createElement(StatPill$1, { icon: "fa-bell", label: "followers", value: (collection?.followers_count ?? 0).toLocaleString() }), collection?.collaborators_count > 1 ? /* @__PURE__ */ React.createElement(StatPill$1, { icon: "fa-user-group", label: "curators", value: (collection?.collaborators_count ?? 0).toLocaleString() }) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex items-center justify-between gap-3 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, collection?.is_featured ? "Featured" : "Updated", " ", formatUpdated(collection?.featured_at || collection?.updated_at)), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-2", onClick: stop }, !isOwner && (collection?.save_url || collection?.unsave_url || collection?.login_url) ? /* @__PURE__ */ React.createElement(
/* @__PURE__ */ React.createElement("div", { className: "relative" }, /* @__PURE__ */ React.createElement(CoverMedia, { collection, isOwner }), /* @__PURE__ */ React.createElement("div", { className: "p-5" }, /* @__PURE__ */ React.createElement("div", { className: "mb-3 flex flex-wrap items-center gap-2" }, collection?.is_featured ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Featured") : null, collection?.mode === "smart" ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, "Smart") : null, !isOwner && collection?.program_key ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-lime-300/25 bg-lime-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-lime-100" }, "Program · ", collection.program_key) : null, !isOwner && collection?.partner_label ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200" }, "Partner · ", collection.partner_label) : null, !isOwner && collection?.sponsorship_label ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Sponsor · ", collection.sponsorship_label) : null, isOwner ? /* @__PURE__ */ React.createElement(CollectionVisibilityBadge, { visibility: collection?.visibility }) : null), /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("h3", { className: "truncate text-lg font-semibold tracking-[-0.03em] text-white" }, collection?.title), collection?.subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 truncate text-sm text-slate-300" }, collection.subtitle) : null, collection?.owner?.name ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 truncate text-sm text-slate-400" }, "Curated by ", collection.owner.name, collection?.owner?.username ? ` • @${collection.owner.username}` : "") : null, /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, formatProfileNumber(collection?.artworks_count ?? 0), " artworks"))), collection?.description_excerpt ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300" }, collection.description_excerpt) : collection?.smart_summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300" }, collection.smart_summary) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement(StatPill$1, { icon: "fa-heart", label: "likes", value: formatProfileNumber(collection?.likes_count ?? 0) }), /* @__PURE__ */ React.createElement(StatPill$1, { icon: "fa-bell", label: "followers", value: formatProfileNumber(collection?.followers_count ?? 0) }), collection?.collaborators_count > 1 ? /* @__PURE__ */ React.createElement(StatPill$1, { icon: "fa-user-group", label: "curators", value: formatProfileNumber(collection?.collaborators_count ?? 0) }) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex items-center justify-between gap-3 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, collection?.is_featured ? "Featured" : "Updated", " ", formatUpdated(collection?.featured_at || collection?.updated_at)), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-2", onClick: stop }, !isOwner && (collection?.save_url || collection?.unsave_url || collection?.login_url) ? /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
@@ -94913,7 +94922,7 @@ function ProfileCoverEditor({
)))));
}
function formatXp(value) {
return new Intl.NumberFormat().format(Number(value || 0));
return formatProfileNumber(value);
}
function clampPercent(value) {
const numeric = Number(value || 0);
@@ -94977,8 +94986,7 @@ function shinyFlagUrl(countryCode, filesCdnUrl = "") {
return base ? `${base}${relativePath}` : relativePath;
}
function formatCompactNumber$1(value) {
const numeric = Number(value ?? 0);
return numeric.toLocaleString("en-US");
return formatProfileNumber(value);
}
function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
const { props } = X$1();
@@ -94990,7 +94998,7 @@ function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount,
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url);
const uname = user.username || user.name || "Unknown";
const displayName = user.name || uname;
const joinDate = user.created_at ? new Date(user.created_at).toLocaleDateString("en-US", { month: "long", year: "numeric" }) : null;
const joinDate = user.created_at ? formatProfileDate(user.created_at, { month: "long", year: "numeric" }) : null;
const progressPercent = Math.round(Number(user?.progress_percent ?? 0));
const heroStats = [
{ label: "Followers", value: formatCompactNumber$1(count) },
@@ -95752,7 +95760,7 @@ function slugify(value) {
return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function formatNumber$9(value) {
return Number(value ?? 0).toLocaleString();
return formatProfileNumber(value);
}
function sortByPublishedAt(items) {
return [...items].sort((left, right) => {
@@ -95873,7 +95881,7 @@ function FullGalleryCta({ galleryUrl, username }) {
"Browse full gallery"
)));
}
function TabArtworks({ artworks, featuredArtworks, username, galleryUrl }) {
function TabArtworks({ artworks, featuredArtworks, featuredLoading = false, featuredError = false, onRetryFeatured, username, galleryUrl }) {
const initialItems = artworks?.data ?? artworks ?? [];
const trendingItems = useArtworkPreview(username, "trending");
const popularItems = useArtworkPreview(username, "views");
@@ -95890,6 +95898,7 @@ function TabArtworks({ artworks, featuredArtworks, username, galleryUrl }) {
"aria-labelledby": "tab-artworks",
className: "mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
},
featuredLoading ? /* @__PURE__ */ React.createElement("div", { className: "mb-8 rounded-[28px] border border-white/10 bg-white/[0.03] px-5 py-4 text-sm text-slate-400", role: "status" }, "Loading featured artworks…") : featuredError ? /* @__PURE__ */ React.createElement("div", { className: "mb-8 rounded-[28px] border border-rose-400/20 bg-rose-400/10 px-5 py-4 text-sm text-rose-100", role: "alert" }, "Featured artworks could not be loaded.", onRetryFeatured ? /* @__PURE__ */ React.createElement("button", { type: "button", className: "ml-3 underline", onClick: onRetryFeatured }, "Retry") : null) : null,
/* @__PURE__ */ React.createElement(FeaturedShowcase, { featuredArtworks: featuredArtworks ?? [] }),
/* @__PURE__ */ React.createElement(
PreviewRail,
@@ -95974,7 +95983,7 @@ function AchievementCard({ achievement, className = "" }) {
style: { width: `${unlocked ? 100 : percent}%` }
}
)),
achievement?.unlocked_at ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-xs text-slate-500" }, "Unlocked ", new Date(achievement.unlocked_at).toLocaleDateString()) : null
achievement?.unlocked_at ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-xs text-slate-500" }, "Unlocked ", formatProfileDate(achievement.unlocked_at, { year: "numeric", month: "short", day: "numeric" })) : null
);
}
function AchievementsList({ unlocked = [], locked = [], limitLocked }) {
@@ -96013,11 +96022,7 @@ function TabAchievements({ achievements, worldRewards, worldHistory, onTabChange
function formatDate$a(value) {
if (!value) return null;
try {
return new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
year: "numeric"
}).format(new Date(value));
return formatProfileDate(value, { month: "short", day: "numeric", year: "numeric" });
} catch {
return null;
}
@@ -96025,7 +96030,7 @@ function formatDate$a(value) {
function formatYear(value) {
if (!value) return null;
try {
return new Intl.DateTimeFormat("en", { year: "numeric" }).format(new Date(value));
return formatProfileDate(value, { year: "numeric" });
} catch {
return null;
}
@@ -96156,8 +96161,8 @@ function YearlyProductivityChart({ recaps }) {
className: `absolute inset-y-0 left-0 rounded-full ${colors.bar} opacity-80 transition-all duration-500 group-hover:opacity-100`,
style: { width: `${(pct * 100).toFixed(1)}%` }
}
), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-y-0 left-0 flex w-full items-center px-3 opacity-0 transition-opacity group-hover:opacity-100" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold text-white drop-shadow-sm" }, uploads, " upload", uploads !== 1 ? "s" : "", (item.metrics?.views ?? 0) > 0 ? ` · ${Number(item.metrics.views).toLocaleString()} views` : "", (item.metrics?.downloads ?? 0) > 0 ? ` · ${Number(item.metrics.downloads).toLocaleString()} dl` : ""))), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-1.5" }, /* @__PURE__ */ React.createElement("span", { className: `text-[13px] font-bold tabular-nums ${isBest ? "text-white" : "text-slate-300"}` }, uploads), isBest && /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-400/25 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-amber-300" }, "best"), (item.metrics?.featured_count ?? 0) > 0 && !isBest && /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-400/20 bg-sky-400/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-sky-300" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-star text-[8px]" }), " ", item.metrics.featured_count)));
})), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-4 border-t border-white/5 pt-4 text-[12px] text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, sorted.length), " active years"), /* @__PURE__ */ React.createElement("span", null, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, sorted.reduce((s2, r) => s2 + r.metrics.uploads_count, 0).toLocaleString()), " total uploads"), /* @__PURE__ */ React.createElement("span", null, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, Number(sorted.reduce((s2, r) => s2 + (r.metrics.views ?? 0), 0)).toLocaleString()), " total views")));
), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-y-0 left-0 flex w-full items-center px-3 opacity-0 transition-opacity group-hover:opacity-100" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold text-white drop-shadow-sm" }, uploads, " upload", uploads !== 1 ? "s" : "", (item.metrics?.views ?? 0) > 0 ? ` · ${formatProfileNumber(item.metrics.views)} views` : "", (item.metrics?.downloads ?? 0) > 0 ? ` · ${formatProfileNumber(item.metrics.downloads)} dl` : ""))), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-1.5" }, /* @__PURE__ */ React.createElement("span", { className: `text-[13px] font-bold tabular-nums ${isBest ? "text-white" : "text-slate-300"}` }, uploads), isBest && /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-400/25 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-amber-300" }, "best"), (item.metrics?.featured_count ?? 0) > 0 && !isBest && /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-400/20 bg-sky-400/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-sky-300" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-star text-[8px]" }), " ", item.metrics.featured_count)));
})), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-4 border-t border-white/5 pt-4 text-[12px] text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, sorted.length), " active years"), /* @__PURE__ */ React.createElement("span", null, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, formatProfileNumber(sorted.reduce((s2, r) => s2 + r.metrics.uploads_count, 0))), " total uploads"), /* @__PURE__ */ React.createElement("span", null, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, formatProfileNumber(sorted.reduce((s2, r) => s2 + (r.metrics.views ?? 0), 0))), " total views")));
}
const RELATION_LABELS = {
remake_of: "Remake",
@@ -96243,7 +96248,7 @@ function CreatorJourneySection({ journey, username }) {
comeback: "bg-amber-400/12 text-amber-200",
quiet: "bg-slate-700 text-slate-400"
};
return /* @__PURE__ */ React.createElement("article", { key: item.id, className: "rounded-2xl border border-white/10 bg-white/[0.04] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold uppercase tracking-[0.2em] text-slate-400" }, item.value), status2 && /* @__PURE__ */ React.createElement("span", { className: `rounded-full px-2 py-0.5 text-[10px] font-semibold capitalize ${statusColors[status2] ?? statusColors.steady}` }, status2)), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, item.headline)), /* @__PURE__ */ React.createElement("div", { className: "flex h-11 w-11 items-center justify-center rounded-2xl bg-amber-400/12 text-amber-200" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-chart-column" }))), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-relaxed text-slate-300" }, item.summary), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-2 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement(StatPill, { label: "Views", value: (item.metrics?.views ?? 0).toLocaleString() }), /* @__PURE__ */ React.createElement(StatPill, { label: "Downloads", value: (item.metrics?.downloads ?? 0).toLocaleString() }), (item.metrics?.featured_count ?? 0) > 0 && /* @__PURE__ */ React.createElement(StatPill, { label: "Featured", value: item.metrics.featured_count }), item.metrics?.top_category && /* @__PURE__ */ React.createElement(StatPill, { label: "Top category", value: item.metrics.top_category })));
return /* @__PURE__ */ React.createElement("article", { key: item.id, className: "rounded-2xl border border-white/10 bg-white/[0.04] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold uppercase tracking-[0.2em] text-slate-400" }, item.value), status2 && /* @__PURE__ */ React.createElement("span", { className: `rounded-full px-2 py-0.5 text-[10px] font-semibold capitalize ${statusColors[status2] ?? statusColors.steady}` }, status2)), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, item.headline)), /* @__PURE__ */ React.createElement("div", { className: "flex h-11 w-11 items-center justify-center rounded-2xl bg-amber-400/12 text-amber-200" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-chart-column" }))), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-relaxed text-slate-300" }, item.summary), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-2 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement(StatPill, { label: "Views", value: formatProfileNumber(item.metrics?.views ?? 0) }), /* @__PURE__ */ React.createElement(StatPill, { label: "Downloads", value: formatProfileNumber(item.metrics?.downloads ?? 0) }), (item.metrics?.featured_count ?? 0) > 0 && /* @__PURE__ */ React.createElement(StatPill, { label: "Featured", value: item.metrics.featured_count }), item.metrics?.top_category && /* @__PURE__ */ React.createElement(StatPill, { label: "Top category", value: item.metrics.top_category })));
})))), /* @__PURE__ */ React.createElement(StreaksSection, { streaks }), /* @__PURE__ */ React.createElement(YearlyProductivityChart, { recaps: allRecaps }), /* @__PURE__ */ React.createElement(EvolutionSection, { evolution }));
}
const SOCIAL_ICONS = {
@@ -96304,7 +96309,7 @@ function getContentTypeIcon(label) {
return CONTENT_TYPE_ICONS[key] ?? null;
}
function formatNumber$8(value) {
return Number(value ?? 0).toLocaleString();
return formatProfileNumber(value);
}
function formatRelativeDate(value) {
if (!value) return null;
@@ -96314,7 +96319,7 @@ function formatRelativeDate(value) {
const now = /* @__PURE__ */ new Date();
const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1e3);
const absSeconds = Math.abs(diffSeconds);
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const formatter = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });
if (absSeconds < 3600) {
return formatter.format(Math.round(diffSeconds / 60), "minute");
}
@@ -96337,7 +96342,7 @@ function formatShortDate$2(value) {
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
return formatProfileDate(date, { month: "short", day: "numeric", year: "numeric" });
} catch {
return null;
}
@@ -96353,7 +96358,7 @@ function formatContributionDate(value) {
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
return formatProfileDate(date, { month: "short", day: "numeric", year: "numeric" });
} catch {
return null;
}
@@ -96408,11 +96413,10 @@ function TabAbout({ user, profile, stats, achievements, worldRewards, artworks,
const about = profile?.about;
const website = profile?.website;
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url);
const joinDate = user.created_at ? new Date(user.created_at).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : null;
const joinDate = user.created_at ? formatProfileDate(user.created_at, { month: "long", day: "numeric", year: "numeric" }) : null;
const lastVisit = user.last_visit_at ? (() => {
try {
const d2 = new Date(user.last_visit_at);
return d2.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
return formatProfileDate(user.last_visit_at, { month: "short", day: "numeric", year: "numeric" });
} catch {
return null;
}
@@ -96421,7 +96425,7 @@ function TabAbout({ user, profile, stats, achievements, worldRewards, artworks,
const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null;
const birthDate = profile?.birthdate ? (() => {
try {
return new Date(profile.birthdate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
return formatProfileDate(profile.birthdate, { month: "long", day: "numeric", year: "numeric" });
} catch {
return null;
}
@@ -96483,7 +96487,7 @@ function TabAbout({ user, profile, stats, achievements, worldRewards, artworks,
href: entry.group?.profile_url || "#",
className: "rounded-[24px] border border-white/8 bg-white/[0.03] p-4 transition-colors hover:border-white/14 hover:bg-white/[0.06]"
},
/* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-3" }, entry.group?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: entry.group.avatar_url, alt: entry.group?.name, className: "h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-people-group" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("div", { className: "truncate text-sm font-semibold text-white" }, entry.group?.name), entry.role ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, String(entry.role).replaceAll("_", " ")) : null, entry.trusted_indicator ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100" }, "Trusted") : null), entry.group?.headline ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, truncateText$1(entry.group.headline, 100)) : null, entry.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, entry.summary) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-3 text-xs text-slate-500" }, /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.credited_artworks || 0).toLocaleString(), " credited artworks"), /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.releases || 0).toLocaleString(), " releases"), /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.projects || 0).toLocaleString(), " projects"), entry.joined_at ? /* @__PURE__ */ React.createElement("span", null, "Joined ", formatContributionDate(entry.joined_at)) : null), Array.isArray(entry.role_labels) && entry.role_labels.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, entry.role_labels.map((label) => /* @__PURE__ */ React.createElement("span", { key: `${entry.group?.slug}-${label}`, className: "rounded-full border border-sky-300/15 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100" }, label))) : null, Array.isArray(entry.recent_release_titles) && entry.recent_release_titles.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-xs text-slate-400" }, "Recent releases: ", entry.recent_release_titles.join(" • ")) : null))
/* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-3" }, entry.group?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: entry.group.avatar_url, alt: entry.group?.name, className: "h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-people-group" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("div", { className: "truncate text-sm font-semibold text-white" }, entry.group?.name), entry.role ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, String(entry.role).replaceAll("_", " ")) : null, entry.trusted_indicator ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100" }, "Trusted") : null), entry.group?.headline ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, truncateText$1(entry.group.headline, 100)) : null, entry.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, entry.summary) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-3 text-xs text-slate-500" }, /* @__PURE__ */ React.createElement("span", null, formatProfileNumber(entry.counts?.credited_artworks || 0), " credited artworks"), /* @__PURE__ */ React.createElement("span", null, formatProfileNumber(entry.counts?.releases || 0), " releases"), /* @__PURE__ */ React.createElement("span", null, formatProfileNumber(entry.counts?.projects || 0), " projects"), entry.joined_at ? /* @__PURE__ */ React.createElement("span", null, "Joined ", formatContributionDate(entry.joined_at)) : null), Array.isArray(entry.role_labels) && entry.role_labels.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, entry.role_labels.map((label) => /* @__PURE__ */ React.createElement("span", { key: `${entry.group?.slug}-${label}`, className: "rounded-full border border-sky-300/15 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100" }, label))) : null, Array.isArray(entry.recent_release_titles) && entry.recent_release_titles.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-xs text-slate-400" }, "Recent releases: ", entry.recent_release_titles.join(" • ")) : null))
)))) : null, followers.length > 0 ? /* @__PURE__ */ React.createElement(SectionCard$2, { icon: "fa-solid fa-user-group", eyebrow: "Community", title: "Recent followers" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-2" }, followers.slice(0, 6).map((follower) => /* @__PURE__ */ React.createElement(
"a",
{
@@ -96587,7 +96591,7 @@ function TabAbout({ user, profile, stats, achievements, worldRewards, artworks,
);
}
function KpiCard$1({ icon, label, value, color: color2 = "text-sky-400" }) {
return /* @__PURE__ */ React.createElement("div", { className: "bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20 backdrop-blur flex items-center gap-4" }, /* @__PURE__ */ React.createElement("div", { className: `w-12 h-12 rounded-xl bg-white/5 flex items-center justify-center shrink-0 ${color2}` }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${icon} text-xl` })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-2xl font-bold text-white tabular-nums" }, Number(value ?? 0).toLocaleString()), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-500 mt-0.5 uppercase tracking-wider" }, label)));
return /* @__PURE__ */ React.createElement("div", { className: "bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20 backdrop-blur flex items-center gap-4" }, /* @__PURE__ */ React.createElement("div", { className: `w-12 h-12 rounded-xl bg-white/5 flex items-center justify-center shrink-0 ${color2}` }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${icon} text-xl` })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-2xl font-bold text-white tabular-nums" }, formatProfileNumber(value)), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-500 mt-0.5 uppercase tracking-wider" }, label)));
}
function TabStats({ stats, followerCount, followAnalytics }) {
const medalTotals = stats?.medal_totals ?? null;
@@ -96616,18 +96620,18 @@ function TabStats({ stats, followerCount, followAnalytics }) {
"aria-labelledby": "tab-stats",
className: "pt-6"
},
!hasStats ? /* @__PURE__ */ React.createElement("div", { className: "bg-white/4 ring-1 ring-white/10 rounded-2xl p-10 text-center shadow-xl shadow-black/20" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-chart-bar text-3xl text-slate-600 mb-3 block" }), /* @__PURE__ */ React.createElement("p", { className: "text-slate-400 font-medium" }, "No stats available yet"), /* @__PURE__ */ React.createElement("p", { className: "text-slate-600 text-sm mt-1" }, "Stats will appear once there is activity on this profile.")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("h2", { className: "text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-chart-bar text-green-400 fa-fw" }), "Lifetime Statistics"), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" }, kpis.map((kpi) => /* @__PURE__ */ React.createElement(KpiCard$1, { key: kpi.label, ...kpi }))), /* @__PURE__ */ React.createElement("div", { className: "mt-8 rounded-2xl border border-white/10 bg-white/4 p-5 shadow-xl shadow-black/20" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", { className: "text-xs font-semibold uppercase tracking-widest text-slate-500" }, "Medal Breakdown"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, "Real medal totals collected across all public artworks.")), /* @__PURE__ */ React.createElement("div", { className: "text-right" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-widest text-slate-500" }, "Weighted Score"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-2xl font-bold text-white tabular-nums" }, Number(medalTotals?.score_total ?? 0).toLocaleString()))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid grid-cols-2 gap-3 md:grid-cols-4" }, [
!hasStats ? /* @__PURE__ */ React.createElement("div", { className: "bg-white/4 ring-1 ring-white/10 rounded-2xl p-10 text-center shadow-xl shadow-black/20" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-chart-bar text-3xl text-slate-600 mb-3 block" }), /* @__PURE__ */ React.createElement("p", { className: "text-slate-400 font-medium" }, "No stats available yet"), /* @__PURE__ */ React.createElement("p", { className: "text-slate-600 text-sm mt-1" }, "Stats will appear once there is activity on this profile.")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("h2", { className: "text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-chart-bar text-green-400 fa-fw" }), "Lifetime Statistics"), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" }, kpis.map((kpi) => /* @__PURE__ */ React.createElement(KpiCard$1, { key: kpi.label, ...kpi }))), /* @__PURE__ */ React.createElement("div", { className: "mt-8 rounded-2xl border border-white/10 bg-white/4 p-5 shadow-xl shadow-black/20" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", { className: "text-xs font-semibold uppercase tracking-widest text-slate-500" }, "Medal Breakdown"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, "Real medal totals collected across all public artworks.")), /* @__PURE__ */ React.createElement("div", { className: "text-right" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-widest text-slate-500" }, "Weighted Score"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-2xl font-bold text-white tabular-nums" }, formatProfileNumber(medalTotals?.score_total ?? 0)))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid grid-cols-2 gap-3 md:grid-cols-4" }, [
{ label: "Gold", value: medalTotals?.gold ?? 0, color: "text-amber-300" },
{ label: "Silver", value: medalTotals?.silver ?? 0, color: "text-slate-300" },
{ label: "Bronze", value: medalTotals?.bronze ?? 0, color: "text-orange-300" },
{ label: "Total Medals", value: medalTotals?.count ?? 0, color: "text-cyan-300" }
].map((item) => /* @__PURE__ */ React.createElement("div", { key: item.label, className: "rounded-2xl border border-white/10 bg-black/15 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-widest text-slate-500" }, item.label), /* @__PURE__ */ React.createElement("div", { className: `mt-2 text-2xl font-semibold tabular-nums ${item.color}` }, Number(item.value).toLocaleString()))))), /* @__PURE__ */ React.createElement("h3", { className: "mt-8 mb-4 text-xs font-semibold uppercase tracking-widest text-slate-500 flex items-center gap-2" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user-group text-emerald-400 fa-fw" }), "Follow Growth"), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3" }, trendCards.map((card) => /* @__PURE__ */ React.createElement(KpiCard$1, { key: card.label, ...card }))), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-600 mt-6 text-center" }, "More detailed analytics (charts, trends) coming soon."))
].map((item) => /* @__PURE__ */ React.createElement("div", { key: item.label, className: "rounded-2xl border border-white/10 bg-black/15 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-widest text-slate-500" }, item.label), /* @__PURE__ */ React.createElement("div", { className: `mt-2 text-2xl font-semibold tabular-nums ${item.color}` }, formatProfileNumber(item.value)))))), /* @__PURE__ */ React.createElement("h3", { className: "mt-8 mb-4 text-xs font-semibold uppercase tracking-widest text-slate-500 flex items-center gap-2" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user-group text-emerald-400 fa-fw" }), "Follow Growth"), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3" }, trendCards.map((card) => /* @__PURE__ */ React.createElement(KpiCard$1, { key: card.label, ...card }))), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-600 mt-6 text-center" }, "More detailed analytics (charts, trends) coming soon."))
);
}
function FavSkeleton() {
return /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl overflow-hidden bg-white/5 animate-pulse" }, /* @__PURE__ */ React.createElement("div", { className: "aspect-square bg-white/8" }));
}
function TabFavourites({ favourites, isOwner, username }) {
function TabFavourites({ favourites, isOwner, username, loading = false, error = false, onRetry }) {
const initialItems = Array.isArray(favourites) ? favourites : favourites?.data ?? [];
const [items, setItems] = reactExports.useState(initialItems);
const [nextCursor, setNextCursor] = reactExports.useState(favourites?.next_cursor ?? null);
@@ -96681,7 +96685,9 @@ function TabFavourites({ favourites, isOwner, username }) {
className: "pt-6"
},
/* @__PURE__ */ React.createElement("h2", { className: "text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-heart text-pink-400 fa-fw" }), isOwner ? "Your Favourites" : "Favourites"),
items.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-col items-center justify-center py-20 text-center" }, /* @__PURE__ */ React.createElement("div", { className: "w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-heart text-3xl" })), /* @__PURE__ */ React.createElement("p", { className: "text-slate-400 font-medium" }, "No favourites yet"), /* @__PURE__ */ React.createElement("p", { className: "text-slate-600 text-sm mt-1" }, "Artworks added to favourites will appear here.")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
loading && items.length === 0 && /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4" }, Array.from({ length: 4 }).map((_2, i) => /* @__PURE__ */ React.createElement(FavSkeleton, { key: `initial-sk-${i}` }))),
error && items.length === 0 && /* @__PURE__ */ React.createElement("div", { className: "py-12 text-center text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Favourites could not be loaded."), onRetry ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: onRetry, className: "mt-4 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200" }, "Retry") : null),
!loading && !error && items.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-col items-center justify-center py-20 text-center" }, /* @__PURE__ */ React.createElement("div", { className: "w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-heart text-3xl" })), /* @__PURE__ */ React.createElement("p", { className: "text-slate-400 font-medium" }, "No favourites yet"), /* @__PURE__ */ React.createElement("p", { className: "text-slate-600 text-sm mt-1" }, "Artworks added to favourites will appear here.")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
ArtworkGallery,
{
items,
@@ -98197,9 +98203,7 @@ function ProfileWorldRecognitionBadge({ recognition, isPrimary = false }) {
function formatDate$9(value) {
if (!value) return null;
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
return formatProfileDate(value, { month: "short", day: "numeric", year: "numeric" });
} catch {
return null;
}
@@ -98221,9 +98225,7 @@ function ProfileWorldHistorySummary({ history: history2 }) {
function formatDate$8(value) {
if (!value) return null;
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
return formatProfileDate(value, { month: "short", day: "numeric", year: "numeric" });
} catch {
return null;
}
@@ -98313,13 +98315,7 @@ function TabWorlds({ 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"
});
return formatProfileDate(value, { year: "numeric", month: "short", day: "numeric" });
} catch {
return null;
}
@@ -98339,7 +98335,7 @@ function TabFollowers({ listing, username, followerCount }) {
"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())),
/* @__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" }, formatProfileNumber(total))),
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);
@@ -98363,10 +98359,164 @@ function TabFollowers({ listing, username, followerCount }) {
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 normalizeGroupContribution(entry = {}) {
const counts = entry?.counts || {};
const role = entry?.role;
return {
...entry,
roleLabel: typeof role === "string" ? role : role?.label || null,
trusted: entry?.trusted_indicator ?? entry?.trusted ?? false,
counts: {
...counts,
credited_artworks: counts.credited_artworks ?? counts.artworks ?? 0,
releases: counts.releases ?? 0,
projects: counts.projects ?? 0,
review_actions: counts.review_actions ?? 0
},
role_labels: Array.isArray(entry?.role_labels) ? entry.role_labels : [],
recent_release_titles: Array.isArray(entry?.recent_release_titles) ? entry.recent_release_titles : []
};
}
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 featured = contributions.slice(0, 3).map(normalizeGroupContribution);
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.roleLabel ? /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, entry.roleLabel) : 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, entry.role_labels.map((label, index2) => /* @__PURE__ */ React.createElement(GroupBadgePill, { key: `${label}-${index2}`, label, tone: "slate" }))), entry.recent_release_titles.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-slate-300" }, "Recent releases:"), " ", entry.recent_release_titles.join(", ")) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-4 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, formatProfileNumber(entry.counts.credited_artworks), " artworks"), /* @__PURE__ */ React.createElement("span", null, formatProfileNumber(entry.counts.releases), " releases"), /* @__PURE__ */ React.createElement("span", null, formatProfileNumber(entry.counts.projects), " projects"), /* @__PURE__ */ React.createElement("span", null, formatProfileNumber(entry.counts.review_actions), " reviews")))))));
}
function useLazyProfileFeaturedArtworks({ username, initialFeaturedArtworks, enabled }) {
const hasInitialValue = Array.isArray(initialFeaturedArtworks);
const [items, setItems] = reactExports.useState(() => hasInitialValue ? initialFeaturedArtworks : []);
const [loaded, setLoaded] = reactExports.useState(hasInitialValue);
const [loading, setLoading] = reactExports.useState(false);
const [error, setError] = reactExports.useState(false);
const requestKeyRef = reactExports.useRef(username);
const requestedRef = reactExports.useRef(hasInitialValue);
const abortRef = reactExports.useRef(null);
reactExports.useEffect(() => {
if (requestKeyRef.current === username) return void 0;
abortRef.current?.abort();
abortRef.current = null;
requestKeyRef.current = username;
requestedRef.current = false;
setItems([]);
setLoaded(false);
setLoading(false);
setError(false);
return void 0;
}, [username]);
reactExports.useEffect(() => {
if (!hasInitialValue || requestKeyRef.current !== username) return;
abortRef.current?.abort();
abortRef.current = null;
requestedRef.current = true;
setItems(initialFeaturedArtworks);
setLoaded(true);
setLoading(false);
setError(false);
}, [hasInitialValue, initialFeaturedArtworks, username]);
const load = reactExports.useCallback(async () => {
if (!enabled || hasInitialValue || requestedRef.current || !username) return;
requestedRef.current = true;
const controller = new AbortController();
const requestKey = requestKeyRef.current;
abortRef.current = controller;
setLoading(true);
setError(false);
try {
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/featured-artworks`, {
headers: { Accept: "application/json" },
credentials: "same-origin",
signal: controller.signal
});
if (!response.ok) throw new Error("Featured artworks could not be loaded.");
const payload = await response.json();
if (requestKey !== requestKeyRef.current || controller.signal.aborted) return;
setItems(Array.isArray(payload?.data) ? payload.data : []);
setLoaded(true);
} catch (caught) {
if (caught?.name === "AbortError" || requestKey !== requestKeyRef.current) return;
requestedRef.current = false;
setError(true);
} finally {
if (requestKey === requestKeyRef.current) {
abortRef.current = null;
setLoading(false);
}
}
}, [enabled, hasInitialValue, username]);
reactExports.useEffect(() => {
if (enabled) load();
}, [enabled, load]);
reactExports.useEffect(() => () => abortRef.current?.abort(), []);
return { items, loaded, loading, error, retry: load };
}
function useLazyProfileFavourites({ username, initialFavourites, enabled }) {
const hasInitialValue = initialFavourites !== null && initialFavourites !== void 0;
const initialItems = Array.isArray(initialFavourites) ? initialFavourites : initialFavourites?.data ?? [];
const [items, setItems] = reactExports.useState(() => initialItems);
const [nextCursor, setNextCursor] = reactExports.useState(() => initialFavourites?.next_cursor ?? null);
const [loading, setLoading] = reactExports.useState(false);
const [error, setError] = reactExports.useState(false);
const requestKeyRef = reactExports.useRef(username);
const requestedRef = reactExports.useRef(hasInitialValue);
const abortRef = reactExports.useRef(null);
reactExports.useEffect(() => {
if (requestKeyRef.current === username) return void 0;
abortRef.current?.abort();
abortRef.current = null;
requestKeyRef.current = username;
requestedRef.current = false;
setItems([]);
setNextCursor(null);
setLoading(false);
setError(false);
return void 0;
}, [username]);
reactExports.useEffect(() => {
if (requestKeyRef.current !== username || !hasInitialValue) return void 0;
abortRef.current?.abort();
abortRef.current = null;
requestedRef.current = true;
setItems(initialItems);
setNextCursor(initialFavourites?.next_cursor ?? null);
setLoading(false);
setError(false);
return void 0;
}, [hasInitialValue, initialFavourites, initialItems, username]);
const load = reactExports.useCallback(async () => {
if (!enabled || hasInitialValue || requestedRef.current || !username) return;
requestedRef.current = true;
const controller = new AbortController();
const requestKey = requestKeyRef.current;
abortRef.current = controller;
setLoading(true);
setError(false);
try {
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/favourites`, {
headers: { Accept: "application/json" },
credentials: "same-origin",
signal: controller.signal
});
if (!response.ok) throw new Error("Favourites could not be loaded.");
const payload = await response.json();
if (requestKey !== requestKeyRef.current || controller.signal.aborted) return;
setItems(Array.isArray(payload?.data) ? payload.data : []);
setNextCursor(payload?.next_cursor ?? null);
} catch (caught) {
if (caught?.name === "AbortError" || requestKey !== requestKeyRef.current) return;
requestedRef.current = false;
setError(true);
} finally {
if (requestKey === requestKeyRef.current) {
abortRef.current = null;
setLoading(false);
}
}
}, [enabled, hasInitialValue, username]);
reactExports.useEffect(() => {
if (enabled) load();
}, [enabled, load]);
reactExports.useEffect(() => () => abortRef.current?.abort(), []);
return { items, nextCursor, loading, error, retry: load };
}
const VALID_TABS = ["posts", "artworks", "stories", "achievements", "worlds", "collections", "about", "stats", "favourites", "activity", "followers"];
function getInitialTab(initialTab = "posts") {
@@ -98426,6 +98576,16 @@ function ProfileShow() {
seo
} = props;
const [activeTab, setActiveTab] = reactExports.useState(() => getInitialTab(initialTab));
const featuredState = useLazyProfileFeaturedArtworks({
username: user?.username || user?.name || "",
initialFeaturedArtworks: featuredArtworks,
enabled: activeTab === "artworks"
});
const favouritesState = useLazyProfileFavourites({
username: user?.username || user?.name || "",
initialFavourites: favourites,
enabled: activeTab === "favourites"
});
const handleTabChange = reactExports.useCallback((tab2) => {
if (!VALID_TABS.includes(tab2)) return;
if (tab2 === "followers") {
@@ -98454,8 +98614,8 @@ function ProfileShow() {
const isLoggedIn = !!auth?.user;
const artworkList = Array.isArray(artworks) ? artworks : artworks?.data ?? [];
const artworkNextCursor = artworks?.next_cursor ?? null;
const favouriteList = Array.isArray(favourites) ? favourites : favourites?.data ?? [];
const favouriteNextCursor = favourites?.next_cursor ?? null;
const favouriteList = favouritesState.items;
const favouriteNextCursor = favouritesState.nextCursor;
const socialLinksObj = Array.isArray(socialLinks) ? socialLinks.reduce((acc, l3) => {
acc[l3.platform] = l3;
return acc;
@@ -98510,7 +98670,10 @@ function ProfileShow() {
TabArtworks,
{
artworks: { data: artworkList, next_cursor: artworkNextCursor },
featuredArtworks,
featuredArtworks: featuredState.items,
featuredLoading: featuredState.loading,
featuredError: featuredState.error,
onRetryFeatured: featuredState.retry,
username: user.username || user.name,
galleryUrl,
isActive: true
@@ -98580,7 +98743,10 @@ function ProfileShow() {
{
favourites: { data: favouriteList, next_cursor: favouriteNextCursor },
isOwner,
username: user.username || user.name
username: user.username || user.name,
loading: favouritesState.loading,
error: favouritesState.error,
onRetry: favouritesState.retry
}
), activeTab === "activity" && /* @__PURE__ */ React.createElement(
TabActivity,
+8 -2
View File
@@ -17,6 +17,7 @@ import TabFollowers from '../../components/profile/tabs/TabFollowers'
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites'
import useLazyProfileWorldHistory from '../../lib/useLazyProfileWorldHistory'
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
@@ -98,6 +99,11 @@ export default function ProfileShow() {
initialFavourites: favourites,
enabled: activeTab === 'favourites',
})
const worldHistoryState = useLazyProfileWorldHistory({
username: user?.username || user?.name || '',
initialWorldHistory: worldHistory,
enabled: activeTab === 'worlds' || activeTab === 'achievements',
})
const handleTabChange = useCallback((tab) => {
if (!VALID_TABS.includes(tab)) return
@@ -239,10 +245,10 @@ export default function ProfileShow() {
/>
)}
{activeTab === 'achievements' && (
<TabAchievements achievements={achievements} worldRewards={worldRewards} worldHistory={worldHistory} onTabChange={handleTabChange} />
<TabAchievements achievements={achievements} worldRewards={worldRewards} worldHistory={worldHistoryState.data} onTabChange={handleTabChange} />
)}
{activeTab === 'worlds' && (
<TabWorlds worldHistory={worldHistory} isOwner={isOwner} />
<TabWorlds worldHistory={worldHistoryState.data} isOwner={isOwner} loading={worldHistoryState.loading} error={worldHistoryState.error} onRetry={worldHistoryState.retry} />
)}
{activeTab === 'collections' && (
<TabCollections
@@ -1,7 +1,7 @@
import React from 'react'
import ProfileWorldHistorySection from '../worlds/ProfileWorldHistorySection'
export default function TabWorlds({ worldHistory, isOwner }) {
export default function TabWorlds({ worldHistory, isOwner, loading = false, error = false, onRetry }) {
return (
<div
id="tabpanel-worlds"
@@ -9,7 +9,14 @@ export default function TabWorlds({ worldHistory, isOwner }) {
aria-labelledby="tab-worlds"
className="pt-6"
>
{loading ? <p className="mb-4 text-sm text-slate-400">Loading worlds history…</p> : null}
{error ? (
<div className="mb-4 flex items-center gap-3 text-sm text-rose-300">
<span>Worlds history could not be loaded.</span>
<button type="button" onClick={onRetry} className="underline underline-offset-2">Retry</button>
</div>
) : null}
<ProfileWorldHistorySection history={worldHistory} isOwner={isOwner} />
</div>
)
}
}
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export default function useLazyProfileWorldHistory({ username, initialWorldHistory, enabled }) {
const hasInitialValue = initialWorldHistory !== null && initialWorldHistory !== undefined
const [data, setData] = useState(() => hasInitialValue ? initialWorldHistory : null)
const [loaded, setLoaded] = useState(hasInitialValue)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const requestKeyRef = useRef(username)
const requestedRef = useRef(hasInitialValue)
const abortRef = useRef(null)
useEffect(() => {
if (requestKeyRef.current === username) return undefined
abortRef.current?.abort()
abortRef.current = null
requestKeyRef.current = username
requestedRef.current = false
setData(null)
setLoaded(false)
setLoading(false)
setError(false)
return undefined
}, [username])
useEffect(() => {
if (!hasInitialValue || requestKeyRef.current !== username) return
abortRef.current?.abort()
abortRef.current = null
requestedRef.current = true
setData(initialWorldHistory)
setLoaded(true)
setLoading(false)
setError(false)
}, [hasInitialValue, initialWorldHistory, username])
const load = useCallback(async () => {
if (!enabled || hasInitialValue || requestKeyRef.current !== username || requestedRef.current || !username) return
requestedRef.current = true
const controller = new AbortController()
const requestKey = requestKeyRef.current
abortRef.current = controller
setLoading(true)
setError(false)
try {
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/world-history`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
})
if (!response.ok) throw new Error('World history could not be loaded.')
const payload = await response.json()
if (requestKey !== requestKeyRef.current || controller.signal.aborted) return
setData(payload)
setLoaded(true)
} catch (caught) {
if (caught?.name === 'AbortError' || requestKey !== requestKeyRef.current) return
requestedRef.current = false
setError(true)
} finally {
if (requestKey === requestKeyRef.current) {
abortRef.current = null
setLoading(false)
}
}
}, [enabled, hasInitialValue, username])
useEffect(() => {
if (enabled) load()
}, [enabled, load])
useEffect(() => () => abortRef.current?.abort(), [])
return { data, loaded, loading, error, retry: load }
}
@@ -0,0 +1,81 @@
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import useLazyProfileWorldHistory from './useLazyProfileWorldHistory'
function Harness({ username = 'alice', initial = null, enabled = false }) {
const state = useLazyProfileWorldHistory({
username,
initialWorldHistory: initial,
enabled,
})
return <output data-testid="state">{JSON.stringify(state)}</output>
}
describe('useLazyProfileWorldHistory', () => {
beforeEach(() => vi.restoreAllMocks())
it('does not request on Posts until a consuming tab is active', () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
render(<Harness />)
expect(fetchMock).not.toHaveBeenCalled()
})
it('requests once on first consuming-tab activation and reuses the result', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ summary: { available: true }, entries: [{ id: 'world-1' }] }),
})
const view = render(<Harness />)
view.rerender(<Harness enabled />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
view.rerender(<Harness enabled={false} />)
view.rerender(<Harness enabled />)
expect(fetchMock).toHaveBeenCalledTimes(1)
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true))
})
it('uses direct Worlds/Achievements SSR data without a lazy request', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
const initial = { summary: { available: true }, entries: [{ id: 'world-1' }] }
const view = render(<Harness initial={initial} enabled />)
view.rerender(<Harness initial={initial} enabled={false} />)
view.rerender(<Harness initial={initial} enabled />)
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).data).toEqual(initial))
expect(fetchMock).not.toHaveBeenCalled()
})
it('treats an empty response as loaded', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ summary: { available: false }, entries: [] }) })
const view = render(<Harness enabled />)
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true))
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('resets on profile navigation and ignores the stale response', async () => {
let resolveA
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { resolveA = resolve }))
const view = render(<Harness username="alice" enabled />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
view.rerender(<Harness username="bob" enabled />)
resolveA({ ok: true, json: async () => ({ summary: { available: true }, entries: [{ id: 'alice' }] }) })
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).data).toBe(null))
})
it('does not loop after an error and permits an explicit retry', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce({ ok: true, json: async () => ({ summary: { available: false }, entries: [] }) })
const view = render(<Harness enabled />)
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).error).toBe(true))
expect(fetchMock).toHaveBeenCalledTimes(1)
// The hook's returned retry is intentionally exercised through a small rerender activation.
view.rerender(<Harness enabled={false} />)
view.rerender(<Harness enabled />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
})
})
+1
View File
@@ -911,6 +911,7 @@ Route::middleware(['web', 'throttle:60,1'])
Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites');
Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats');
Route::get('featured-artworks', [\App\Http\Controllers\User\ProfileController::class, 'featuredArtworks'])->name('featured-artworks');
Route::get('world-history', [\App\Http\Controllers\User\ProfileController::class, 'worldHistory'])->name('world-history');
});
// ── Link Preview (auth, throttled) ─────────────────────────────────────────────
@@ -0,0 +1,73 @@
<?php
use App\Models\User;
use App\Services\Profile\WorldProfileHistoryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
uses(RefreshDatabase::class);
it('omits the world history producer from the default Posts SSR payload', function (): void {
$user = User::factory()->create(['username' => 'lazyworlds']);
DB::flushQueryLog();
DB::enableQueryLog();
$this->get('/@' . $user->username)
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('initialTab', 'posts')
->where('worldHistory', null));
$historyQueries = collect(DB::getQueryLog())->pluck('query')->filter(function (string $query): bool {
$query = strtolower($query);
return str_contains($query, 'world_submissions')
|| str_contains($query, 'group_challenge_outcomes')
|| str_contains($query, 'world_relations');
});
expect($historyQueries)->toBeEmpty();
});
it('keeps world history in direct Worlds and Achievements SSR payloads', function (string $tab): void {
$user = User::factory()->create(['username' => 'ssr' . $tab]);
$this->get('/@' . $user->username . '/' . $tab)
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('initialTab', $tab)
->has('worldHistory.summary')
->has('worldHistory.entries'));
})->with(['worlds', 'achievements']);
it('returns the exact public service payload without tracking a profile view', function (): void {
$user = User::factory()->create(['username' => 'lazyworldapi']);
$expected = app(WorldProfileHistoryService::class)->publicPayloadForUser($user);
$before = (int) ($user->profile_views_count ?? 0);
$this->getJson('/api/profile/' . $user->username . '/world-history')
->assertOk()
->assertJson($expected);
expect((int) $user->fresh()->profile_views_count)->toBe($before);
});
it('returns owner context only to the profile owner', function (): void {
$user = User::factory()->create(['username' => 'ownerworldapi']);
$other = User::factory()->create(['username' => 'otherworldapi']);
$this->actingAs($user)
->getJson('/api/profile/' . $user->username . '/world-history')
->assertOk()
->assertJsonPath('owner_context.pending_submissions', 0);
$this->actingAs($other)
->getJson('/api/profile/' . $user->username . '/world-history')
->assertOk()
->assertJsonPath('owner_context', null);
});
it('returns not found for an unknown world history profile', function (): void {
$this->getJson('/api/profile/missing-world-history/world-history')->assertNotFound();
});
@@ -95,7 +95,7 @@ it('exposes normalized world history on public profile pages', function (): void
'awarded_at' => now()->subHour(),
]);
$this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
$this->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
@@ -161,7 +161,7 @@ it('filters stale public world rewards while preserving owner-only context count
'status' => WorldSubmission::STATUS_PENDING,
]);
$this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
$this->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
@@ -170,7 +170,7 @@ it('filters stale public world rewards while preserving owner-only context count
->where('worldHistory.owner_context', null));
$this->actingAs($creator)
->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
@@ -223,4 +223,4 @@ it('supports the canonical worlds profile tab route', function (): void {
->where('profileTabUrls.worlds', url('/@' . strtolower((string) $creator->username) . '/worlds'))
->where('worldHistory.summary.available', true)
->where('worldHistory.entries.0.primary_recognition.key', 'featured'));
});
});